
1. 秒杀系统的核心矛盾与架构思路1.1 为什么秒杀系统这么容易出问题只要在电商行业做过几年几乎每个人都能说出几个秒杀事故的版本库存显示负数、页面打不开、用户付了款订单没了、老板盯着大屏数字骂了半天。秒杀系统真正难办的不是“流量大”这个表象而是“瞬时流量远大于系统处理能力”这个本质矛盾。平时日均几万的系统到了秒杀那一秒可能涌进来几十万甚至上百万请求这中间差着两三个数量级常规架构在那儿根本撑不住。秒杀系统的第一个特点是高并发。10点开抢前10秒内QPS可能冲到日常峰值的几十倍而且这些请求高度集中在同一个商品、同一个SKU、同一个Redis key上。第二个特点是写多读少。普通商品详情页主要是读加缓存就能扛但秒杀有大量真实的下单写操作还要扣库存、查订单、锁用户这些动作没法简单靠缓存解决。第三个特点是业务链路长。从用户点击到最终支付成功要经过网关、风控、限流、商品服务、库存服务、订单服务、MQ、支付回调任何一个环节慢一点整个链路就雪崩。很多人第一次做秒杀的时候会把精力放在“怎么让系统扛住更多流量”上结果压测时发现系统能扛活动一上线反而崩了。问题往往不在TPS上限而在细节数据库连接池被打满、Redis热key单点瓶颈、MQ消费不过来导致库存不准、前端轮询接口把带宽吃光。秒杀系统的难点从来不是“性能能有多高”而是“在极端流量下还能保持数据正确、服务稳定、体验可接受”。1.2 请求模型拆解读、写、扣减三类请求要分开对待处理秒杀第一步是拆解请求类型。用户一秒之内的操作看上去是“点击按钮”实际上产生了一串请求大致可以分成三类。第一类是“读请求”包括打开活动页、查看商品详情、看还剩多少库存。这类请求占比最大往往占到总流量的80%以上特点是只读不写、可以缓存、可以降级。很多第一次做秒杀的人在抢购瞬间看到页面卡死问题就出在详情页请求打到了数据库哪怕只是天然带有唯一索引的商品查询在高并发下也会把数据库连接池拖死。处理读请求的正确姿势是把商品静态信息预渲染成HTML存到CDN或Nginx本地缓存活动开始后页面资源基本不需要回源。库存数字属于动态数据不能缓存太久但可以做成短TTL缓存比如每2秒刷新一次以“还有货/已抢光”的粗粒度展示替代精确数字数据一致性要求真正精确的时间点非常短。第二类是“写请求”就是用户提交订单包含用户ID、商品ID、数量、收货地址等。这类请求才是真正需要进核心链路处理的要经过风控校验、资格校验、限流、库存扣减、订单生成、推送MQ通知支付每一个环节都要能承受压力且尽量简洁。第三类是“扣减请求”特指对库存的增减操作。这是秒杀系统里最敏感的一类请求数据一致性要求最高性能压力也最大。库存不能超卖不能误扣不能出现扣了库存但没生成订单更不能出现订单生成了但库存没扣。这三类请求的处理策略完全不能混在一起。有人把用户点击按钮后的所有操作都串成一个同步链路结果抢购瞬间数据库直接被打挂。正确的做法是把读和写分开写链路里把高成本的操作放在后面异步化比如支付结果查询、订单状态同步都和瞬时抢购动作剥离。1.3 秒杀容量规划的基本估算方法每次做秒杀活动前都需要正儿八经地估一次容量不估就上线的基本都是在赌。估算方法并不复杂但很多人没认真算过。假设一个秒杀商品库存是1万件活动时长10秒理论上最大抢购人数可能是库存量的几百倍甚至几千倍。按照常见的经验值点击率按1%到5%估算抢购前十分钟预约人数可能是库存量的200到500倍。也就是1万库存预约人数可能到300万瞬间并发点击可能到3万到15万QPS其中写请求大约占5%到10%。写请求量级在1500到15000 QPS之间扣减请求还要加上“部分用户重复点击”的放大实际可能到2万到3万。这个量级如果直接打到MySQL单库单表上哪怕只是执行一条带索引的UPDATE连接数也会立刻爆掉数据库很容易成为瓶颈。容量规划有两种思路一种是把系统能力堆到能扛住峰值适合库存量小、单价高、品牌要求高的活动另一种是“削峰填谷”通过预扣库存、排队、限流等方式把瞬时高峰削平成几十秒甚至几分钟的均匀流量适合库存量大、用户参与度高、对排队体验容忍度高的活动。大多数实际案例都是两种思路混合使用前端限流挡住一部分超卖风险后端削峰把流量摊开。2. 超卖问题秒杀系统最怕的事故之一2.1 超卖是怎么发生的库存为什么变成负数秒杀系统出现超卖事故最常见的原因不是某一条SQL写错了而是并发环境下多条请求同时通过了“查库存-判断充足-扣减”这个没有加锁的流程。假设库存只剩1件两个用户的请求同时读到“当前库存为1”都判断为充足于是先后执行了扣减库存就变成了-1这就是一次超卖。早期电商系统用数据库直接扣库存很多人会写这样的逻辑SELECT stock FROM product WHERE sku_id 10001; -- 业务代码判断 stock 0 UPDATE product SET stock stock - 1 WHERE sku_id 10001;这条逻辑看起来没毛病但两个事务并发执行时第一步的SELECT会读到相同的stock值两个都判定可以通过然后两个UPDATE依序执行库存被扣成负数。这种情况在低并发下偶尔出现在高并发下是必然出现只是时间早晚的问题。有人会改成“带上库存条件再更新”以为这样就能避免UPDATE product SET stock stock - 1 WHERE sku_id 10001 AND stock 0;如果受影响行数大于0才算扣减成功那么并发场景下只有一条语句能执行成功另一条因为stock已经变成0而无法匹配从数据正确性上确实不会超卖。但问题在于这条SQL在高并发下虽然不超卖却会对同一条记录加行锁大量请求排队等待锁释放数据库行锁竞争激烈时每秒能处理的扣减次数要按行锁排队时间倍增计算性能远远不够。这就引出了秒杀系统的核心话题数据库扣减不超卖但扛不住并发缓存扣减性能高但一致性和持久化风险需要额外处理。实际项目中强烈建议把“库存扣减”这个环节从数据库主链路里拆出去用专门的库存服务处理。2.2 基于Redis原子扣减的正确姿势Redis做库存扣减核心原因是它的单线程模型保证了一条命令从执行到结束是原子的天然没有并发竞争问题。扣减库存最常用的方案是用Lua脚本把检查库存、扣减库存、返回结果放在一个原子操作里完成。-- 输入KEYS[1] 为库存key -- 输入ARGV[1] 为扣减数量 if tonumber(redis.call(get, KEYS[1])) tonumber(ARGV[1]) then return redis.call(decrby, KEYS[1], ARGV[1]) else return -1 end这个脚本的意思是先读取当前库存如果大于等于扣减数量就执行减操作并返回减后的库存如果库存不足直接返回-1表示失败。单个Redis实例执行这段脚本是原子性的不存在两个请求同时读到同一个库存值的问题所以不会超卖。但我得提一个容易忽略的细节Redis自身是单线程执行命令Redis集群模式下key会根据哈希槽分散到不同节点同一个商品库存的key只会存在同一个节点上。也就是说热key并发请求会集中打到同一个Redis分片这个分片可能成为新的瓶颈。实际遇到的情况是Redis集群总QPS压测没问题但某一个商品key所在的分片CPU跑满导致整个分片上的其它key全部受影响。应对思路有两个一个是给热key做拆分比如把一个商品的库存拆成多个片每个片一个key请求散列到不同key上扣减成功后再汇总判断总量另一个是直接在秒杀商品信息里区分冷热只把真正会被抢爆的商品做这种特殊处理其它普通商品不拆避免所有库存都走复杂逻辑。Redis扣减成功之后还需要把数据同步到MySQL做最终持久化。这个“同步”环节要特别设计不能每秒同步一次全量库存否则MySQL会被频繁写爆。常见的做法是Redis扣减后用MQ发送一条“库存变更消息”消费者收到后更新MySQL库存并在同一事务里生成订单重试和幂等逻辑也围绕这条消息来设计。2.3 异步对账兜底数据正确性的最后一道防线即使有了Redis原子扣减也不能保证100%不出问题。Redis可能宕机MQ可能丢消息消费者可能重复消费数据库连接可能超时回滚。所以成熟系统都会加一道对账机制定期把“用户成功抢到的订单量”和“真实库存变化量”做比对。对账的核心设计是所有扣减动作都生成一条明细流水流水记录包含用户ID、商品ID、数量、扣减时间、库存快照订单系统也记录成功和失败的订单明细。对账程序定时扫描发现“有扣减流水但无订单”的记录就触发补偿发现“有订单但无扣减流水”的记录就要回滚或补扣发现“扣减量超过初始库存总量”那情况更严重要立即告警。这个环节是要写进开发排期的不是上线后想起来再补的。我见过有些项目上线后出现了库存不一致运维靠人工核对Excel两个星期没核对明白最后只能推倒重来。做对账不用多复杂一个定时任务、一张流水表、一个比对脚本就算初期写得粗糙一点也比完全没有强得多。2.4 超卖排查的常见SQL和工具技巧排查超卖问题时常用的SQL很简单但很有价值-- 查看当前库存和初始库存的差异 SELECT sku_id, initial_stock, current_stock, initial_stock - current_stock AS diff FROM product_stock WHERE diff 0 OR diff 总卖出数; -- 查看扣减流水是否存在异常重复 SELECT user_id, sku_id, COUNT(*) AS cnt FROM stock_flow GROUP BY user_id, sku_id HAVING cnt 1;第一组SQL可以快速定位哪些商品的当前库存和初始库存对不上第二组SQL能够找出同一用户重复扣减的场景。实际排查时还要关注MySQL binlog、Redis的keyspace通知或MQ消费日志判断是哪个环节丢了数据或重复处理。对一个秒杀系统来说“能快速定位数据异常”这个能力和“尽量别出数据异常”同等重要。3. 缓存与数据库的一致性秒杀系统每天都得处理的问题3.1 缓存穿透、缓存击穿以及秒杀场景的特殊性缓存穿透指查询一个根本不存在的数据缓存和数据库都没有请求每次都穿透到数据库。秒杀场景里如果商品被下架或id不存在大量请求会不断打到后端对数据库压力非常大。缓存击穿则指某个热点key过期瞬间大量请求同时回源到数据库。这些名词在教科书里讲得很清楚但到了秒杀场景里真正的难点是热点key只有那么几个所有流量都会打向它们而它们还经常恰好是秒杀开始和结束那个时间点附近。要么是预热好但过期了要么是库存扣光后大量查询还在路往上打。应对穿透的做法是布隆过滤器加缓存空值应对击穿的做法是互斥锁重建缓存或使用逻辑过期兜底。这些方案网上都有但我想提醒的是秒杀场景不要让“缓存重建”发生在业务高峰期。更好的做法是提前预热活动开始前就把热点商品、活动页、库存信息全部加载到缓存里活动期间尽量不让缓存失效即使失效也要用逻辑过期加异步重建避免用户请求阻塞在缓存回源上。3.2 缓存更新的时机和顺序缓存更新最常见的错误是“先更新数据库再删除缓存”或者反过来“先删缓存再更新数据库”这两种顺序在极端情况下都会造成数据不一致。以秒杀为例库存扣减成功后会写MySQL同时要更新Redis里的库存。如果先更新数据库再更新Redis中间有一个时间窗口Redis里还是旧数据读请求会拿到不准确的库存如果先更新Redis再更新数据库数据库写入失败时Redis的库存已经是新值后续对账和补偿会很麻烦。比较稳妥的做法是把“Redis扣减”作为主链路数据库库存作为最终结果异步更新。Redis扣减成功后用MQ发送“扣减成功”事件消费者在事务里更新MySQL并生成订单如果MQ消费失败定时对账任务会发现库存流水和数据库库存不一致用补偿任务修复。同时读路径的展示库存和真实扣减库存可以是两个不同的key。展示库存可以缓慢刷新比如2秒一次允许用户看到略微滞后的库存数字这是产品上可以接受的扣减库存必须实时准确一次性判断能不能下单。3.3 热key问题Redis集群单节点崩溃的前兆秒杀场景里最典型的一个坑就是热key。Redis集群把key分散到多个节点理论上每个节点压力平均但秒杀商品就那一个key所有扣减和查询都打到同一个分片上这个分片的CPU会急剧升高而其它分片还很空闲。实际遇到的情况是Redis集群的整体QPS只有20万看起来没什么压力但某一个分片的CPU已经到了90%以上延迟从1毫秒涨到50毫秒进而把依赖这个分片的全部操作都拖慢了。这个坏味道在监控上特别隐蔽很多团队的Redis监控只看整体指标不按节点拆分等发现的时候往往已经出现了超时。解决热key的方案有不少本地缓存JVM/Caffeine扛读流量但要注意缓存一致性展示库存可以用扣减库存不能用本地缓存key散列拆分把一个商品的库存拆成多个key比如stock:10001:0到stock:10001:9用户请求按用户ID或者按简单轮询散列到不同key先扣减分片库存再聚合判断总库存如果确实无法拆分那就在Redis节点层面做特殊处理保证热点key所在分片有足够的CPU和带宽余量。秒杀系统的热key处理和常规热点处理有一个显著区别普通热点往往是突发的热度持续几分钟到几小时秒杀热点是预定好的活动开始时间清清楚楚。所以完全可以在活动前把所有热点key分散好把可能的瓶颈前置解决而不是等到线上出问题再扩容。这是秒杀和普通高并发系统处理逻辑上一个很大的不同点。4. 削峰填谷与消息队列别让MQ成为新的隐患4.1 为什么要削峰削峰之后系统的真实流量长什么样秒杀的核心矛盾是瞬时流量远大于处理能力。如果系统能力是5000 TPS而秒杀瞬间涌入5万TPS硬扛是扛不住的。削峰的目的是把5万TPS削成5000 TPS让系统在能力范围内平滑处理。削峰最常见的做法是把“拦截”放在入口让真正进入核心处理链路的请求数量可控。通过限流组件如Sentinel在网关层做QPS限流超出的请求直接返回“排队中”或“已抢光”。同时为了不让限流误杀真实用户需要区分新用户和活跃用户、正常用户和疑似机器行为这些判断都在网关层完成。削峰还可以结合队列实现。用户点击抢购后第一步先调用“预扣接口”接口把用户请求写入MQ立刻返回“已提交”队列消费者按照每秒一定速率去真正处理扣减处理完成后再通过异步通知或用户主动轮询告诉用户结果。这种方式对用户的体感影响是几秒钟延迟但对系统来说压力直接降了一个数量级。有个项目经验是前端抢购结果不要用“成功/失败”二元状态来做而是用“排队中/成功/失败”三态UI上加一个“查看结果”的按钮让用户主动查询而不是服务器推送。这样既把查询压力摊薄又能兼容异步处理的延迟。4.2 MQ选型与消息不丢的细节MQ选型在秒杀场景里很关键需要重点关注三件事吞吐量、消费积压能力、消息持久化能力。RocketMQ和Kafka是一个级别RabbitMQ在吞吐量上会稍微吃亏但如果峰值QPS在几千级别RabbitMQ也能胜任。选型的核心是看团队熟悉哪套以及监控运维体系是否成熟而不是看谁在技术栈里最流行。消息不丢是秒杀系统里最容易产生误解的地方。很多人测试时发一条消息、消费一条消息觉得没问题但线上环境跟测试完全不一样消息可能出现各种状况消息发送方在发送时抛了异常消息到了Broker但消费方还没收到时Broker宕机消费者成功消费但还没提交offset时宕机重启重试。要确保不丢需要三个环节同时加保障发送端同步确认接收端成功消费后再提交offset消费逻辑具备幂等性。这里我想特别强调幂等性这是秒杀项目里经常被低估的环节。消费者从MQ里取到“扣减成功”消息去更新MySQL库存并生成订单如果网络超时导致消费方没提交offsetMQ会重新投递这条消息消费者又执行了一次更新如果是简单累加或生成订单的逻辑重复执行就会造成重复扣减、重复订单。所以要求消费者的业务逻辑天然带上“按业务ID去重”的能力比如订单表加唯一订单号重复消息执行时会因为唯一键冲突而跳过。4.3 消费积压时先保住核心链路再处理边缘数据MQ消费积压是秒杀系统上了MQ之后最常见的故障。库存扣减消息一秒产生2万条消费者一组5个、每个处理300 TPS处理能力只有1500 TPS消息越积越多。这种情况下如果数据处理的顺序还是“按用户请求原始顺序”可能会造成部分用户等待时间过长支付超时甚至订单状态一直处于中间态。实际运维中遇到积压第一步不是盲目扩容消费者实例而是要先判断积压消息的类型。扣减库存消息是核心中的核心必须优先处理否则用户能不能下单订单创建、状态同步、营销通知这些可以往后放。在消费者设计阶段就要支持“多topic隔离、按优先级消费”核心topic单独一组消费者边缘topic另一组避免边缘数据拖垮核心链路。另外消费积压还有一个隐藏问题消息的时间价值。如果一条扣减库存消息积压了30分钟才被消费等到消费时用户已经流失了对业务来说这条消息等于没有意义。所以秒杀场景的MQ需要预设“消息有效期”有效期过了直接丢弃或转入补偿处理绝对不能让过期消息继续占用消费资源。4.4 消息重复消费的排查思路排查消息重复消费最直接的路径是看消费日志里同一业务ID出现了几次。RocketMQ的Message ID每次发送都会生成同一消息重试时会使用同一个Message ID但如果是业务重新发送比如超时后重发Message ID会变化。所以排重不能只依赖Message ID要在业务表里设计业务唯一键。以秒杀订单为例订单号由用户ID组合生成例如order_no user_id 时间戳 随机数或者直接用雪花算法。订单表以order_no为唯一索引消费消息时先尝试插入订单如果唯一键冲突说明订单已存在直接跳过不执行库存更新。这套逻辑简单可靠比用分布式锁去重简单得多代码量少排查也容易。5. 压测、监控与容量评估上线前的最后一道保障5.1 为什么接口压测不够必须做全链路压测很多团队做秒杀上线前的压测把单个接口单独拎出来压一压得到几个漂亮的TPS数字就宣布“系统没问题”结果正式活动一上线就出各种状况原因就是接口压测和真实链路差异太大。真实秒杀请求会经过网关、鉴权、限流、风控、商品查询、库存扣减、订单创建、MQ、数据库等多个环节任何一个环节被压爆整体体验就会崩而这些瓶颈在单接口压测里完全看不出来。全链路压测要模拟真实流量来源和走向规则如下压测流量从网关层灌入完整走一遍真实请求链路压测数据要与线上隔离不能把压测的库存扣减、订单生成混进生产数据常见做法是压测用户打指定标签压测请求走独立的压测环境或压测标记位压测场景要覆盖正常流量、异常流量、热点商品流量三种不只测“大家都正常抢”的场景还要测“商品ID不存在”“库存已空还继续抢”“限流后用户重试”等情况。全链路压测最常暴露的问题集中在几类数据库连接池打满、Redis连接池排队超时、MQ消费能力跟不上、网关层线程池耗尽。这些问题在单接口压测中是看不到的因为单接口压测不涉及跨环节依赖但实际场景里只要一个环节堵住整条链路都会被拖死。5.2 Jmeter压测场景的设计与参数计算用Jmeter做秒杀压测场景设计比工具本身重要得多。单纯把所有线程同时压同一个接口得到的结果没有实际参考意义因为真实用户不会所有人同时点击且只点一次。推荐的做法是把压测请求分成三类第一类是静态页面加载走CDN和Nginx占比最高比如70%的请求第二类是动态库存查询走网关到缓存占比25%第三类是真正的提交订单/扣减库存请求占比5%这5%的请求权重最高因为成本最高。压测参数怎么定目标QPS可以按预估峰值的1.5倍来配置留出余量。假设预估峰值写请求5000 QPS那么压测时写请求至少压到7500 QPS。线程数和循环次数的关系不要想成线性要结合每个线程处理一个请求的耗时来算。如果单次请求耗时200毫秒单线程每秒只能处理5个请求要达到7500 QPS需要1500个并发线程。Jmeter机器本身也是瓶颈一个Jmeter进程最多支持几千个线程压测时可能需要多台施压机做分布式压测。压测结束后要分析三个关键指标TPS是否达到目标、平均响应时间是否在可接受范围、错误率是否为零。这里的错误率要特别留意有些压测工具的响应超时被计算为错误但真实用户侧可能表现为超时重试一样会导致体验崩坏。错误率为零是底线只要出现非预期错误码就说明系统还有隐患。5.3 监控指标不只是看CPU和内存秒杀系统的监控CPU和内存要看但绝不能只看这些。真正决定系统稳定性的指标包括网关入口QPS和拒绝率Redis按节点拆分的CPU、内存、命中率、命令耗时热点key的访问量数据库的连接数、活跃连接、慢查询数、锁等待时间MQ的积压数量和消费滞后时间按topic拆开看核心接口的TP99和错误码分布。有一个特别值得盯的指标是“线程池队列深度”。秒杀场景下即使每个接口都做了限流如果某个下游依赖慢了线程池里等待的请求就会排队队列深度持续上涨最终触发拒绝策略或OOM。通过监控线程池活跃线程数和队列深度可以提前发现下游依赖劣化处理手段是快速失败、熔断降级而不是让请求无限排队。监控数据的价值不在“出了一个告警”这个动作本身而在能回答三个问题系统现在什么状态会不会出事如果出事了从哪个环节开始所以告警设置要按级别区分P0级是库存扣减失败率超过阈值、数据库死锁出现P1级是TP99超过基线、Redis热key节点CPU超过80%P2级是MQ积压超过预警线、网关拒绝率升高。不同级别对应不同响应速度和处理通道避免所有问题都变成紧急抢修。5.4 兜底降级方案秒杀系统必须有Plan B秒杀系统必须有降级预案而且要提前演练。降级不只是“关掉某个功能”是要明确降级动作的触发条件、执行手段、恢复流程并且知道降级后对用户和数据的影响边界。常见的降级方案包括非核心接口直接熔断比如推荐位、营销弹窗、优惠券领取这些不影响秒杀主链路的功能可以直接降级库存查询降级为只读本地缓存用户看到的是粗略库存状态但扣减请求不受影响数据库写操作限流当数据库连接池达到阈值时新进入的写请求直接返回“稍后再试”保护数据库不被打瘫支付结果通知降级为延迟查询用户支付后如果回调没及时收到由用户端主动查询订单状态秒杀场景里用户大概率会有等待过程这个降级是业务可接受的。降级演练最好是全链路做一次“沙盘推演”从入口流量一直打到数据库和MQ中途人为制造故障停掉一个Redis分片、停掉一个MQ topic、把数据库连接池调小看系统会不会自动降级、会不会自愈、会不会出现数据不一致。这个过程比任何代码 review 都更能发现问题。6. 秒杀系统的独特挑战与个人实践体会真正把秒杀系统做线上之后最大的体会是这个系统85%的功夫都在“设计预期”上。到底什么数据可以接受短暂不一致用户等待多长时间是可容忍的系统最优解不一定是最快扣减而是“该挡的挡掉、该等的等住、该保的保住”。限流阈值、热点key拆分数、MQ消费速率这些参数在设计阶段就要确定并接受压测校验、沙盘推演、上线后灰度观察不能等活动当天边调参边祈祷。另一个深刻体会是秒杀系统在数据一致性处理上的“分而治之”。抢购瞬间精确性是第一优先级所以用Redis原子扣减保证库存准确用户等待期体验是第一优先级所以用展示库存的延迟换取系统稳定性支付后结算期终态是第二优先级所以用异步对账抹平各种中间态。把不同阶段的优先级想清楚系统设计会减少大量不必要的纠结。如果现在让我从头做一个秒杀系统我会按这样的顺序分配时间需求梳理和容量估算占20%核心链路库存扣减、MQ、订单生成的开发占30%全链路压测和调优占20%监控、告警、降级预案和对账机制的建立占30%。大多数团队把第三块和第四块严重压缩这是线上事故频发的根本原因。最后分享一个小技巧秒杀系统上线前把线上全链路的日志链路打通。从网关入口、限流判断、库存扣减、MQ投递、订单创建每个环节都带上同一条traceId和业务orderNo出了问题能够在一分钟内通过日志检索定位到具体环节。这个投入不大但能让你在事故处理时比其他团队快一个数量级而这往往是最有价值的时间差。