跳过正文
  1. 文章/

从一个秒杀选课项目,理解高并发系统为什么需要 Redis 和 MQ

高华阳·1KURA
作者
高华阳·1KURA
记录我的 Golang源码理解、工程实践、项目复盘和长期学习笔记。
目录

开课的瞬间,1 秒内几千个请求同时打进来,数据库连接池直接打满,服务宕机了。这是我在做高并发秒杀选课项目时真实遇到的问题。

最近在看后端面经时,发现面试官有时候会问到:

  • 有没有考虑过高并发情况?
  • 接口压力突然增加 10 倍,你的系统还能撑住吗?

我自己对高并发这方面比较感兴趣,于是从零开始实现了一个高并发秒杀选课项目。

最初的同步 MySQL 方案
#

我最初的代码逻辑和普通管理系统其实没有太大区别:

用户请求 → Gin 路由 → MySQL 扣库存 → 写入选课记录

看起来没什么问题。

但考虑到高并发场景,比如 1 秒内同时发来几千个请求,这套逻辑就完全扛不住了。

MySQL 在落库时需要对库存加行锁,大量请求同时竞争锁资源,并且 MySQL 是基于磁盘 IO 的,最终大量请求会变成串行落库。与此同时,MySQL 的连接池通常只有几十到一百多个连接,瞬时大量请求很容易直接耗尽连接池,压力瞬间打满,严重情况下甚至可能导致数据库宕机。

后来我开始意识到:

这个业务本身并不复杂,真正的问题在于,不能让瞬时的大量请求直接打到数据库。

同步 MySQL 扣库存与 Redis MQ 异步削峰链路对比

为什么要引入 Redis
#

于是我在 MySQL 前面加了一层 Redis,由 Redis 缓存课程库存数据,并负责处理库存扣减逻辑。

Redis 天然适合高并发场景。

首先 Redis 是基于内存的,IO 速度远高于 MySQL。其次 Redis 单线程执行命令,虽然是单线程,但并不意味着性能低,反而避免了线程切换和锁竞争的问题。

但新的问题很快又出现了。

我一开始以为,只要把库存放到 Redis 中,就不会再出现超卖问题。所以我最开始的逻辑是:

先 GET 库存
判断库存是否大于 0
再进行扣减

后来压测时发现,系统还是出现了超卖。

问题的本质在于:

查询库存和扣减库存之间,不具备原子性。

例如当前库存只剩 1:

请求 A 查询库存 = 1
请求 B 查询库存 = 1

请求 B 扣减库存成功
请求 A 再次扣减库存

最终库存变成 -1

Redis 单线程,并不意味着多个操作组合在一起后天然原子。

于是我引入了 Lua 脚本,将请求去重、查询库存、判断库存、扣减库存等操作封装成一个原子操作,从而解决超卖问题。

Redis Lua 原子预扣库存与防超卖流程

Redis 扣库存后,为什么还需要 Redis Stream
#

到这里,Redis 已经成功帮 MySQL 挡住了大量无效请求。只有真正抢到库存的请求,才有资格继续进入数据库。

但新的问题仍然存在:

Redis 已经成功扣减库存
但服务端在发送 RabbitMQ 消息前宕机

此时就会出现:

Redis 库存已经减少
但数据库里永远不会出现这条选课记录

问题的本质在于:

Redis 扣库存成功和 MQ 消息成功发送,并不是一个原子操作。

于是我进一步优化链路,将:

请求去重
库存扣减
写入消息

全部封装进 Lua 脚本中一次完成。

但这里的消息写入并不是直接发送 RabbitMQ,而是先写入 Redis Stream。Redis 所有命令单线程执行,Lua 脚本在 Redis 内部是原子的,写入 Stream 也是 Redis 命令,所以整个 Lua 脚本执行期间不会有其他请求插入。

整个流程变成:

请求校验
Lua 原子执行:
    - 判断库存
    - 请求去重
    - 扣减库存
    - 写入 Redis Stream
后台协程监听 Redis Stream
异步转发 RabbitMQ

这样做的好处是:

Redis 内部可以保证“扣库存”和“写入 Stream”是同一个原子操作。

即使服务端突然宕机,只要消息已经进入 Stream,后台协程恢复后仍然可以继续转发 RabbitMQ,不会出现库存已经扣减但消息彻底丢失的问题。

Redis Stream 在这里更像一个 Redis 内部的可靠消息缓冲层。

它负责衔接:

Redis 原子操作
RabbitMQ 异步削峰

此时系统链路变成:

请求校验 → Redis Lua 原子预扣减 → 写入 Redis Stream → 后台协程转发 RabbitMQ → MySQL 异步落库

为什么还需要 RabbitMQ
#

虽然 Redis 已经过滤掉了大量请求,但最后仍然需要将选课记录写入 MySQL。

问题在于:

即使 Redis 已经挡住了大部分流量,但在高库存场景下,仍然会有上千条成功请求需要同时落库,数据库依然需要承受瞬时写压力。

到这里我开始意识到:

Redis 解决的是“哪些请求有资格进入数据库”,但 MySQL 仍然需要解决“如何平稳处理大量写请求”。

于是我引入了 RabbitMQ。

整个链路变成:

Redis 预扣减库存
写入 Redis Stream
后台协程转发 RabbitMQ
返回前端“排队中”
消费者异步消费消息
MySQL 落库

这条链路最大的变化在于:

原本几千个请求会瞬间打到 MySQL,现在则变成了:

请求先进入消息队列
消费者按照 MySQL 的承载能力平缓消费

也就是说,MQ 并不是简单异步化,它真正的价值在于削峰填谷。

消费者平缓消费消息,能够有效保护 MySQL。

与此同时,链路也从:

Redis 扣减库存后同步落库

变成了:

Redis 扣减库存后异步落库

原本用户请求需要等待一次磁盘 IO,现在只需要在内存中完成 Redis 预扣减和消息写入,就可以快速响应用户。

引入 RabbitMQ 后的新问题
#

引入 RabbitMQ 后,新的问题又出现了。

例如:

  • Redis Stream 中的消息转发到 RabbitMQ 失败怎么办?
  • 生产者消息有没有真正到达 MQ?
  • 消费者处理消息时宕机怎么办?
  • 消息重复消费怎么办?
  • 消费者一直处理失败怎么办?

RabbitMQ 的整体模型大概是:

生产者 → Exchange → Queue → Consumer

首先需要保证生产者发送的消息真正成功到达 MQ。

这里我使用了 RabbitMQ 的 Confirm 机制。

生产者发送消息后,需要等待 MQ 返回 ACK。如果长时间未收到 ACK,或者收到了 NACK,就需要重新投递消息。

与此同时,MQ 本身也需要开启消息持久化。否则如果 RabbitMQ 宕机,消息只存在内存中,就会直接丢失。

RabbitMQ 生产确认、持久化、手动 ACK 和幂等组成的可靠性链路

消费端为什么要手动 ACK
#

消费者端开启手动 ACK。

只有真正符合业务逻辑时,才 ACK 消息。

例如:

  • 库存不足
  • 选课成功
  • 重复选课请求

这些都可以正常 ACK。

但如果是系统异常,例如:

  • MySQL 锁竞争严重
  • 消费超时
  • 服务宕机

则不能直接 ACK。

否则消息已经被确认,但业务实际上并未成功执行,最终会导致数据丢失。

为什么还要保证幂等
#

新的问题依然存在。

例如:

消息已经成功写入 MySQL
但系统在 ACK 前宕机

RabbitMQ 会认为这条消息没有消费成功,于是重新投递消息。

这样同一条消息就会被重复消费。

因此消费者端还必须保证幂等性。

我这里使用的是联合唯一索引:

student_id + course_id

它们共同唯一确定一条选课记录。

即使消息被重复消费,也只能成功插入一次,从而保证数据一致性。

为什么需要重试队列和死信队列
#

消费者处理失败也不能无限重试。

如果一条消息因为数据库短暂锁竞争失败,重试是有意义的。但如果一条消息本身数据异常,或者业务逻辑一直无法处理,反复重试只会不断占用消费者资源,甚至进一步打爆数据库。

所以我又引入了分级重试和死信队列。

消费者处理失败时,并不一定代表消息永远无法处理。

例如:

  • MySQL 短暂锁竞争
  • 数据库连接抖动
  • 网络瞬时异常

这些问题往往只需要等待几秒就能恢复。

如果失败后立刻无限重试,反而可能不断放大系统压力,形成恶性循环。

于是我设计了三级重试队列:

正常消费队列
    ↓ 消费失败
1s 重试队列
    ↓ 再次失败
5s 重试队列
    ↓ 再次失败
10s 重试队列
    ↓ 仍然失败
死信队列
RabbitMQ 分级重试队列和死信队列流转

第一次消费失败后,消息不会立刻再次消费,而是等待 1 秒后重新投递。

如果仍然失败,则进入 5 秒重试队列。

再失败,则进入 10 秒重试队列。

这样做的目的其实是给下游系统恢复时间,避免数据库在异常期间被大量失败请求不断重试,进一步放大压力。

如果经过多次延迟重试后,消息仍然无法正常消费,就说明问题可能已经不是短暂异常,而是业务数据异常或系统逻辑问题。

此时消息会进入死信队列。

死信队列并不负责正常业务消费,而是专门保存这些系统暂时无法处理的异常消息。后续可以通过后台任务、管理接口或人工排查的方式,对这些消息进行补偿处理。

我对 MQ 的理解变化
#

到这里,我对 MQ 的理解也发生了变化。

一开始我只是觉得:

MQ 可以异步处理请求。

后来才意识到,在真实系统里,MQ 还需要考虑:

  • 消息是否成功发送
  • 消息是否持久化
  • 消费者是否成功处理
  • 消息是否会重复消费
  • 消费失败如何重试
  • 重试多次失败后如何兜底

所以 MQ 并不是简单地把请求丢进去就结束了。

它真正解决的是:

如何把瞬时流量变成可控的、可恢复的、可补偿的异步处理流程。

最终链路
#

最终,这个项目的核心链路大概变成了:

用户请求
Gin 接口
JWT / 参数校验
Redis Lua 原子执行:
    - 请求去重
    - 判断库存
    - 扣减库存
    - 写入 Redis Stream
后台协程监听 Stream
转发 RabbitMQ
消费者异步消费
MySQL 落库
手动 ACK
高并发选课系统最终链路总览

同时通过:

  • Redis Lua 保证库存扣减原子性
  • Redis Stream 衔接 Redis 和 RabbitMQ
  • RabbitMQ 实现异步削峰
  • Confirm 机制保证消息到达 MQ
  • 消息持久化减少 MQ 宕机丢失风险
  • 手动 ACK 保证消费结果可控
  • 联合唯一索引保证消费幂等
  • 分级重试处理临时异常
  • 死信队列兜底异常消息

来保证整个选课链路在高并发下尽可能稳定。

总结
#

写完这个项目后,我最大的感受是:

高并发系统并不是简单地加 Redis 或者加 MQ。

真正重要的是理解:

  • 压力在哪里?
  • 哪些请求应该被提前过滤?
  • 哪些操作必须原子?
  • 哪些流程可以异步?
  • 异步之后如何保证消息不丢?
  • 重复消费如何保证幂等?
  • 失败之后如何重试和兜底?

这些问题一步步推着系统从最初的:

Gin → MySQL

演进成:

Redis Lua → Redis Stream → RabbitMQ → MySQL

也让我更清楚地理解了:

高并发系统设计的核心,不是让数据库硬扛所有流量,而是通过缓存、原子操作、消息队列和异步消费,把瞬时压力拆开、削平,并保证最终数据一致性。

这些问题没有一个是看文档就能想到的,都是压测出来、踩坑踩出来的。