开课的瞬间,1 秒内几千个请求同时打进来,数据库连接池直接打满,服务宕机了。这是我在做高并发秒杀选课项目时真实遇到的问题。
最近在看后端面经时,发现面试官有时候会问到:
- 有没有考虑过高并发情况?
- 接口压力突然增加 10 倍,你的系统还能撑住吗?
我自己对高并发这方面比较感兴趣,于是从零开始实现了一个高并发秒杀选课项目。
最初的同步 MySQL 方案#
我最初的代码逻辑和普通管理系统其实没有太大区别:
用户请求 → Gin 路由 → MySQL 扣库存 → 写入选课记录看起来没什么问题。
但考虑到高并发场景,比如 1 秒内同时发来几千个请求,这套逻辑就完全扛不住了。
MySQL 在落库时需要对库存加行锁,大量请求同时竞争锁资源,并且 MySQL 是基于磁盘 IO 的,最终大量请求会变成串行落库。与此同时,MySQL 的连接池通常只有几十到一百多个连接,瞬时大量请求很容易直接耗尽连接池,压力瞬间打满,严重情况下甚至可能导致数据库宕机。
后来我开始意识到:
这个业务本身并不复杂,真正的问题在于,不能让瞬时的大量请求直接打到数据库。
为什么要引入 Redis#
于是我在 MySQL 前面加了一层 Redis,由 Redis 缓存课程库存数据,并负责处理库存扣减逻辑。
Redis 天然适合高并发场景。
首先 Redis 是基于内存的,IO 速度远高于 MySQL。其次 Redis 单线程执行命令,虽然是单线程,但并不意味着性能低,反而避免了线程切换和锁竞争的问题。
但新的问题很快又出现了。
我一开始以为,只要把库存放到 Redis 中,就不会再出现超卖问题。所以我最开始的逻辑是:
先 GET 库存
判断库存是否大于 0
再进行扣减后来压测时发现,系统还是出现了超卖。
问题的本质在于:
查询库存和扣减库存之间,不具备原子性。
例如当前库存只剩 1:
请求 A 查询库存 = 1
请求 B 查询库存 = 1
请求 B 扣减库存成功
请求 A 再次扣减库存
最终库存变成 -1Redis 单线程,并不意味着多个操作组合在一起后天然原子。
于是我引入了 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 宕机,消息只存在内存中,就会直接丢失。
消费端为什么要手动 ACK#
消费者端开启手动 ACK。
只有真正符合业务逻辑时,才 ACK 消息。
例如:
- 库存不足
- 选课成功
- 重复选课请求
这些都可以正常 ACK。
但如果是系统异常,例如:
- MySQL 锁竞争严重
- 消费超时
- 服务宕机
则不能直接 ACK。
否则消息已经被确认,但业务实际上并未成功执行,最终会导致数据丢失。
为什么还要保证幂等#
新的问题依然存在。
例如:
消息已经成功写入 MySQL
但系统在 ACK 前宕机RabbitMQ 会认为这条消息没有消费成功,于是重新投递消息。
这样同一条消息就会被重复消费。
因此消费者端还必须保证幂等性。
我这里使用的是联合唯一索引:
student_id + course_id它们共同唯一确定一条选课记录。
即使消息被重复消费,也只能成功插入一次,从而保证数据一致性。
为什么需要重试队列和死信队列#
消费者处理失败也不能无限重试。
如果一条消息因为数据库短暂锁竞争失败,重试是有意义的。但如果一条消息本身数据异常,或者业务逻辑一直无法处理,反复重试只会不断占用消费者资源,甚至进一步打爆数据库。
所以我又引入了分级重试和死信队列。
消费者处理失败时,并不一定代表消息永远无法处理。
例如:
- MySQL 短暂锁竞争
- 数据库连接抖动
- 网络瞬时异常
这些问题往往只需要等待几秒就能恢复。
如果失败后立刻无限重试,反而可能不断放大系统压力,形成恶性循环。
于是我设计了三级重试队列:
正常消费队列
↓ 消费失败
1s 重试队列
↓ 再次失败
5s 重试队列
↓ 再次失败
10s 重试队列
↓ 仍然失败
死信队列第一次消费失败后,消息不会立刻再次消费,而是等待 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也让我更清楚地理解了:
高并发系统设计的核心,不是让数据库硬扛所有流量,而是通过缓存、原子操作、消息队列和异步消费,把瞬时压力拆开、削平,并保证最终数据一致性。
这些问题没有一个是看文档就能想到的,都是压测出来、踩坑踩出来的。


