1. 秒杀场景到底难在哪:先把问题看清楚
做过秒杀的人都清楚,这活儿真正的难点从来不在"把按钮点下去"这一步。页面上的倒计时、进度条、动效,随便找个前端都能给你做得漂漂亮亮,真正让人睡不着觉的是:活动开始那一瞬间涌进来的那波流量,以及后台那几百件库存被几千人同时惦记时的账目问题。
我把秒杀系统的核心矛盾概括成三件事。第一件是瞬时高并发——平时一天几千访问量的系统,活动开始那几秒可能要扛住几十万次请求,流量曲线不是缓坡,是垂直的悬崖。第二件是库存不能超卖,一件商品卖出去两件,用户投诉、客服赔钱、活动信誉全崩。第三件是数据一致性——扣了库存但订单没生成,或者订单生成了但库存没扣,都会造成账实不符,事后排查比登天还难。
这篇内容面向的是已经有一定后端基础、准备自己动手做一套秒杀能力的开发者,也适合正在被"活动一上就挂"折磨的技术负责人。我会从整体架构思路讲起,把库存扣减、流量削峰、防超卖的具体实现掰开揉碎,再把我踩过的几个坑原样摊给你看。所有参数和方案都基于我反复压测后的经验值,可以直接拿去改改用。
先说一个必须提前建立的认知:秒杀系统不是靠一台更强的服务器解决的,是靠一整套分层拦流的设计解决的。任何一个环节想"硬扛",最后都会在某个点上崩掉。下面我们一层层往里拆。
2. 整体架构设计与拦流思路拆解
2.1 为什么要把请求分成四层来拦
我最开始做第一版秒杀的时候,思路特别朴素:加机器、加数据库连接池、加缓存。结果压测到八万并发的时候,数据库直接被打到 CPU 100%,慢查询堆了一屏,接口平均响应时间从 80 毫秒飙到 6 秒,前端全在转圈。那次之后我彻底想通了——入口处的流量必须被逐层削减,不能指望后端全接住。
我的设计是把一次秒杀请求拆成四层漏斗:
| 层级 | 拦截手段 | 目标留存比例 | 主要作用 |
|---|---|---|---|
| 第一层:前端 | 按钮置灰、倒计时校准、本地限频 | 100% → 70% | 挡住重复点击和脚本无脑刷 |
| 第二层:网关 | 限流、黑名单、签名校验 | 70% → 20% | 挡住恶意流量和超频请求 |
| 第三层:应用 | 库存预判、Redis 原子扣减 | 20% → 2% | 只有真正有货的请求才往下走 |
| 第四层:数据层 | 异步下单、最终扣减 | 2% → 0.5% | 把写压力从数据库挪走 |
这个漏斗的意义在于:越靠前的拦截成本越低。前端挡掉一次点击几乎不花钱,数据库挡掉一次要花几十毫秒的 IO。所以设计的核心原则是——能往前挪的拦截,绝不留到后面。
这里有个细节值得说:第一层的"本地限频"很多人不做,觉得前端不可信。我的看法是前端确实不可信,但它能挡掉大量"手抖连点"和低级脚本,这对降低后面几层的压力是实打实的。当然你不能只靠它,它只是第一道筛子,不是保险柜。
2.2 库存放 Redis 还是放数据库:一个必须想明白的取舍
这是所有秒杀方案里第一个要拍板的问题。数据库扣减的好处是强一致、简单直接、事务兜底;坏处是扛不住并发,一行库存记录在几千个事务里排队等锁,性能会塌方式下降。
我最终的方案是Redis 做实时扣减、数据库做最终落地。理由很实在:Redis 的单线程模型保证了命令的原子性,DECR这类操作天然不会有并发问题,单实例轻松跑到十万级 QPS;而数据库的事务能力用来保证"订单和库存最终对得上",两者各干各擅长的事。
但这里要提前埋一个伏笔,也是很多人翻车的地方:Redis 和数据库这两份库存,怎么保证不打架。这个问题我在第 4 节会专门展开,先把结论放这儿——靠"预扣 + 异步确认 + 定时对账"三件套,不能只靠一个 Redis 扣减就完事。
2.3 为什么下单必须异步化
假设你的秒杀商品有 500 件,按 20% 通过率算,也就 100 个请求能走到下单。听起来不多对吧?但如果这 100 个请求同步走"创建订单 → 写订单表 → 扣数据库库存 → 发消息通知",每个请求平均占用数据库连接 200 毫秒,那一瞬间就是 20 秒的连接占用堆积。数据库连接池一般也就配 50 到 100 个,直接打满。
异步化的做法是:应用层拿到 Redis 扣减成功的信号后,立刻给用户返回"排队中",真正的订单创建丢进消息队列,由后台消费者慢慢消化。用户的体感是"抢到了,等结果",实际系统压力被摊平到一个时间窗口里。
提示:异步化之后一定要给用户一个明确的"排队中"状态页,并且支持结果推送。我见过太多系统抢到之后页面一直转圈,用户以为没抢到又点了一次,结果产生重复下单投诉。
2.4 技术选型的整体拼图
把上面的思路串起来,我的技术组合是这样的:网关层做限流和鉴权,应用层用 Redis 做库存预扣,消息队列做下单削峰,数据库做最终一致性落地,再加一个定时任务做对账兜底。这套组合没有用什么特别罕见的组件,全是成熟方案,好处是出了问题好排查、好招人维护。
我个人不太建议一上来就上特别复杂的分布式事务框架。秒杀场景下的库存扣减,本质上可以用"允许极少量误差 + 事后对账补偿"来解决,比引入两阶段提交那套东西要轻得多,也更稳。
3. 防超卖的核心机制与关键细节
3.1 超卖到底是怎么发生的
先把事故现场还原一遍,你才知道要防什么。超卖的典型路径是这样的:库存剩 1 件,两个用户的请求同时到达,两个线程同时读到"库存 = 1",都判断"大于 0",于是都执行扣减,最终库存变成 -1,两个人都下单成功。
这个问题的根源是**"读"和"写"之间有时间窗口**。只要你的代码是"先查询、再判断、再更新"这种三步走,就有这个窗口。哪怕你把这三步包在一个数据库事务里,如果没有加锁或者锁的粒度不对,一样会出问题。
3.2 三种防超卖方案的对比与选择
我把常见的三种做法列出来,附上我的实测结论:
| 方案 | 实现方式 | 并发能力 | 一致性 | 适用场景 |
|---|---|---|---|---|
| 数据库悲观锁 | SELECT ... FOR UPDATE | 低(千级) | 强 | 库存少、并发低的普通活动 |
| 数据库乐观锁 | UPDATE ... WHERE stock = 原值 | 中(万级) | 较强 | 中等并发、冲突不激烈 |
| Redis 原子扣减 | DECR/ Lua 脚本 | 高(十万级) | 最终一致 | 秒杀主战场 |
悲观锁的问题在于它会阻塞其他请求,秒杀场景下几千个请求排队等一把锁,响应时间会难看到没法看。乐观锁好一些,但冲突激烈时会有大量更新失败需要重试,重试本身又加重了数据库负担。
我选的是 Redis 原子扣减,但有个关键点必须强调:光用DECR是不够的,必须配合"先判断余量"的逻辑。因为DECR在库存为 0 的时候会继续减成负数,等于放了一批人进来。正确做法是用 Lua 脚本把"判断 + 扣减"打包成一个原子操作。
3.3 Lua 脚本实现原子预扣的正确写法
下面这段是我压测后一直在用的脚本,逻辑很简单但每个细节都有讲究:
-- KEYS[1]: 库存 key -- ARGV[1]: 本次扣减数量 -- 返回: 剩余库存(>=0 表示成功),-1 表示库存不足 local stock = tonumber(redis.call('GET', KEYS[1])) if stock == nil then return -2 -- key 不存在,说明活动未初始化 end if stock < tonumber(ARGV[1]) then return -1 -- 库存不足 end return redis.call('DECRBY', KEYS[1], ARGV[1])几个必须注意的点:
第一,GET和DECRBY必须在同一个脚本里,Redis 执行 Lua 是单线程原子的,这样才真正没有并发窗口。如果你在应用层先GET再DECRBY,那和不用 Redis 没区别。
第二,我特意区分了返回 -1 和 -2 两种情况。库存不足是正常业务结果,key 不存在是系统异常(活动没初始化或者 key 被误删),两者的处理逻辑完全不同,日志里也必须分开记录。这个细节看着小,线上排查的时候能救命。
第三,脚本里别做复杂运算,别循环,别调用多个 key。Lua 脚本执行期间会阻塞 Redis 的其他命令,脚本越重,阻塞时间越长。我曾经在一个脚本里加了个遍历检查,结果高峰期 Redis 出现明显卡顿,后来老老实实拆成两个简单脚本。
3.4 一人一单的幂等控制
防超卖只解决了"总量不超",还有个问题:同一个用户用脚本疯狂刷,一个人抢走几十件。这在真实活动里是必须堵死的。
我的做法是在 Redis 里再加一个"用户-商品"维度的占位标记,和库存扣减放在同一个 Lua 脚本里完成:
-- KEYS[1]: 库存 key KEYS[2]: 用户占位 key local stock = tonumber(redis.call('GET', KEYS[1])) if stock == nil or stock < 1 then return -1 end if redis.call('EXISTS', KEYS[2]) == 1 then return -3 -- 该用户已经抢过 end redis.call('DECR', KEYS[1]) redis.call('SET', KEYS[2], '1', 'EX', 86400) return stock - 1这里把占位 key 设了 24 小时过期,是因为活动通常持续几小时,过了这个窗口标记就没必要留着了,否则 Redis 里会堆积大量无用 key。过期时间要给得比活动时长大一些,留出缓冲。
注意:这个占位 key 是"抢购资格"标记,不是"订单成功"标记。也就是说用户抢到了但没付款,这个标记也不能提前删,否则他能再抢一次。释放逻辑要跟订单状态挂钩,这个后面在幂等那节会细说。
4. 数据一致性:Redis 与数据库怎么不打架
4.1 扣减、下单、落库的完整时序
这一块是我认为整个秒杀系统里最容易出隐蔽 bug 的地方。先把正常时序捋清楚:用户请求进来 → Lua 脚本原子扣 Redis 库存并打用户占位 → 返回"排队中" → 发一条消息到队列 → 消费者创建订单、写订单表 → 数据库库存做最终扣减 → 回写订单状态为成功。
看起来挺顺,但中间每一步都可能断。消息发失败了呢?消费者处理到一半挂了呢?数据库扣减成功但回写状态失败了呢?这些断点每一个都会造成数据不一致。
4.2 消息可靠投递的三个关键动作
第一个动作:扣减成功后,消息必须能可靠发出去。我用的做法是本地消息表——在同一个数据库事务里,既扣了 Redis 之前记录的预扣流水,又把待发消息写入一张消息表,然后由独立线程扫描消息表推送。这样即使推送时进程挂了,重启后扫描还能补发。
第二个动作:消费者必须做幂等。消息队列在极端情况下会重复投递,消费者收到同一条消息两次是正常现象。我的做法是订单表上给"用户 ID + 商品 ID + 活动 ID"建唯一索引,重复插入直接报错,捕获后当作已处理返回成功。
第三个动作:消费失败要能重试且不能无限重试。我配的是最多重试 3 次,间隔按 1 秒、5 秒、30 秒递增,超过 3 次进死信队列,由人工或者定时任务介入。无限重试会把一条坏消息变成雪崩。
4.3 数据库库存的最终扣减写法
数据库这边的扣减一定要带条件,不能无脑减:
UPDATE seckill_stock SET stock = stock - 1, version = version + 1 WHERE goods_id = ? AND stock > 0;执行完检查影响行数,等于 0 就说明扣减失败,这时候要把 Redis 的预扣回滚掉,并把订单标记为失败。这里为什么要用stock > 0作为条件而不是先查再判断?因为单条 UPDATE 语句自带行锁,条件判断和更新在一条语句里完成,天然没有并发窗口,比乐观锁的version对比还简单可靠。
4.4 定时对账:最后一道保险
前面所有机制都做对了,还是会有极端情况漏网,比如 Redis 主从切换时丢了少量写、机器突然断电。所以必须有一个定时对账任务,我一般是五分钟跑一次,逻辑是:把这段时间内的订单成功记录汇总,和数据库的实际库存变化做比对,同时对 Redis 剩余库存做一次校准。
对账发现差异时的处理原则是:以数据库为准,修正 Redis。因为数据库是最终账本,Redis 只是加速层。如果发现数据库库存已经扣成负数(理论上不该发生,但要兜底),立刻停止活动并告警,这属于严重事故,必须人工介入。
提示:对账任务本身必须有幂等性和可重入性,跑一半挂了下次能接着跑。我最初写对账没注意这点,结果任务超时被重复触发,把库存又修了一次,反而制造了新的不一致。
5. 完整实操流程与核心参数
5.1 环境准备与基础配置
先说基础设施。Redis 我用的是主从加哨兵,单实例内存给到 8G,maxmemory-policy设成noeviction——这点很重要,秒杀库存 key 绝对不能因为内存满被淘汰,宁可写入报错也不能让库存 key 消失。消息队列用任意成熟的中间件都行,关键是支持手动 ACK 和死信队列。
数据库连接池大小我算过:假设下单消费者并发消费线程是 20 个,每个线程同时占用 1 个连接,再留 30 个给其他业务,配 60 到 80 就够了。连接池不是越大越好,我见过配 500 的,结果数据库端上下文切换开销把 CPU 吃满,反而更慢。
5.2 活动初始化的正确姿势
活动开始前必须把库存预热进 Redis,这一步经常被草率处理。我的流程是:运营在后台配置好商品和库存 → 系统写数据库 → 触发预热任务把库存写入 Redis → 校验 Redis 值和数据库值一致 → 活动开始。
预热脚本里我会额外写一个"活动元信息"的 key,存活动开始时间、结束时间、商品 ID。Lua 脚本执行前可以先读这个元信息判断活动是否在有效期内,避免活动没开始就被脚本刷。
# 预热示例(伪命令) SET seckill:stock:1001 500 SET seckill:meta:1001 '{"start":"...","end":"...","goodsId":1001}'初始化完成后一定要跑一次校验,把 Redis 剩余库存和数据库库存做对比,不一致就报警。这一步花不了两分钟,但能避免一场灾难。
5.3 压测怎么做才有意义
很多人压测就是拿工具对接口猛打,看到 QPS 数字漂亮就收工。这种压测没有意义,因为你没验证业务正确性。
我的压测分两轮。第一轮打纯接口,验证系统吞吐和响应时间,关注 P99 延迟和错误率。第二轮是正确性压测:用 1000 个不同用户 ID,对 100 件库存发起 5000 次请求,跑完之后检查三件事——成功订单数是否恰好 100、Redis 剩余库存是否为 0、数据库库存是否为 0、有没有同一用户出现两条订单。这四项全对,才算真通过。
参数上我会逐级加码:1 千、5 千、2 万、5 万、10 万并发,每一级观察 Redis 的 CPU、数据库连接池占用、消息队列堆积量。找到拐点在哪一级,那个数字才是你系统的真实上限。
5.4 活动进行中的监控指标
线上跑的时候我盯这几个数:Redis 库存 key 的剩余值(和预期销量对比)、消息队列的堆积深度、下单消费者的处理成功率、数据库慢查询数量。其中队列堆积深度是最灵敏的预警指标,一旦持续上涨就说明消费能力跟不上,得赶紧扩容消费者。
还有个小技巧:给库存 key 设一个"熔断阈值",比如剩余库存低于总量的 5% 时触发告警。这时候往往是最疯狂的时候,提前有人盯着,出事能第一时间响应。
6. 常见问题与排查实录
6.1 库存扣成负数,到底该查哪里
这是被问得最多的问题。我的排查顺序是:先看 Lua 脚本是不是真的原子执行了,有没有人在应用层做了"先查后扣";再看有没有绕过脚本直接操作库存 key 的代码路径,比如后台管理手动改库存;最后看 Redis 有没有发生过主从切换导致的数据丢失。
有一次线上真出现负库存,查了半天发现是运营在后台手动补库存时用了SET而不是INCRBY,把正在扣减的 key 直接覆盖了。这类"人为绕过"的路径一定要在代码评审时重点检查。
6.2 用户反馈抢到了却没有订单
这种问题的根源通常在异步链路。按这个顺序查:消息有没有发出去(查本地消息表状态)→ 消费者有没有收到(查队列消费记录)→ 消费有没有报错(查死信队列和错误日志)→ 订单表有没有唯一索引冲突(查重复插入记录)。
我遇到过一次是消费者在处理时抛了空指针,消息进了死信队列但没人监控,结果一批用户干等。后来我在死信队列上加了告警,有消息进去立刻通知。
6.3 高频问题速查表
| 现象 | 可能原因 | 处理方向 |
|---|---|---|
| 库存出现负数 | 非原子扣减、人为覆盖 key | 检查所有写库存的代码路径 |
| 请求超时但实际抢到了 | 同步链路太长、返回慢 | 缩短同步路径,尽早返回排队态 |
| 队列持续堆积 | 消费者能力不足 | 扩容消费者、优化下单 SQL |
| 同一用户多笔订单 | 幂等缺失或占位 key 提前释放 | 补唯一索引,检查释放逻辑 |
| Redis CPU 飙高 | Lua 脚本过重、大 key | 拆脚本、检查 key 结构 |
| 活动开始瞬间大量 5xx | 网关限流配置过严或过松 | 按压测结果重配阈值 |
6.4 几个我踩过的坑,直接抄结论
第一个坑:占位 key 的过期时间设太短。我一开始设了 10 分钟,结果用户抢到后犹豫了 15 分钟才付款,占位失效了他又能再抢一次。后来改成和订单支付超时时间对齐,并且在订单关闭时才主动删除占位。
第二个坑:对账任务和活动高峰期撞车。对账要扫全量数据,很吃数据库资源,我最早配的是整点跑,正好撞上活动开始。后来改成活动期间降低频率,并且限制对账任务的并发度。
第三个坑:日志打太多把磁盘写满。秒杀期间每个请求都打详细日志,一天几百 G,磁盘告警。后来改成只记录状态变更和异常,正常请求只采样记录。
第四个坑,也是最疼的一个:测试环境和生产环境的 Redis 配置不一致。测试环境没开持久化,生产开了 AOF,结果生产上因为 AOF 重写导致了一次明显卡顿。这类环境差异必须在活动前一项项核对,我后来做了一张上线检查清单,逐条打勾才敢开活动。
我个人在实际操作中的体会是,秒杀系统最难的不是写出那几段扣减代码,而是把所有边界情况想全、把每一层的失败路径都设计好兜底。代码量其实不大,真正的工作量在预案和验证上。每次活动前我都会把上面那张速查表拿出来过一遍,问自己"如果这条发生了,我知道去哪儿查吗",答不上来的就补监控。这套习惯帮我躲过了好几次本可能上新闻的事故。