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 我用的是主从加哨兵单实例内存给到 8Gmaxmemory-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 重写导致了一次明显卡顿。这类环境差异必须在活动前一项项核对我后来做了一张上线检查清单逐条打勾才敢开活动。我个人在实际操作中的体会是秒杀系统最难的不是写出那几段扣减代码而是把所有边界情况想全、把每一层的失败路径都设计好兜底。代码量其实不大真正的工作量在预案和验证上。每次活动前我都会把上面那张速查表拿出来过一遍问自己如果这条发生了我知道去哪儿查吗答不上来的就补监控。这套习惯帮我躲过了好几次本可能上新闻的事故。