
刚经历了一次颇为难忘的秒杀大促凌晨两点盯着监控面板上那根扶摇直上的红线差点以为系统要交代在当晚。回想两年前第一次面对百万级峰值流量时库存扣减接口被瞬间打崩Redis集群宿主机CPU飙升到百分百数据库连接池被行锁拖到耗尽用户端疯狂重试后台一堆超卖告警——那个场面堪称灾难。后来把这套高并发优化链路一点一点啃下来从热点Key分散、缓存击穿防护到Redis原子扣减、MQ削峰填谷再到对账补偿、熔断降级系统才算真正从雪崩边缘拉回来变成能平稳扛住秒杀的磐石。这篇文章就把整套实战思路拆开揉碎讲清楚适合正在做电商中台、会员交易、营销活动的后端同学尤其是那些被秒杀流量弄得睡不好觉的架构师们。1. 先看清痛点在哪儿秒杀库存更新的本质矛盾1.1 秒杀系统到底难在哪里秒杀场景和普通商品下单有个本质区别普通场景请求量是均匀的数据库在单位时间内处理的事务数是有边界的。但秒杀不一样一个单品一万件库存开售瞬间可能涌进几十万个请求所有流量在同一时刻砸向同一个商品ID、同一行库存记录、同一个Redis Key这就是典型的热点数据高并发写问题。更麻烦的是库存这个数据极其敏感扣多了会超卖造成资损扣少了用户骂娘。这就要求库存扣减必须做到原子性和准确性。数据库的原子性靠事务和行锁Redis的原子性靠单线程模型和Lua脚本两者性能差了好几个数量级但可靠性又各有短板。秒杀优化的本质就是在准和快之间找一个平衡点把压力层层削平。另一个容易踩坑的点是很多团队把秒杀单纯理解成加缓存、上Redis结果把库存扣减请求直接怼到Redis上以为万事大吉。实际上Redis虽然单线程处理快但热点Key存在单一分片上所有请求打到一个分片CPU先被打满集群其他分片却闲着这就是热Key倾斜问题后面会细讲怎么拆。1.2 从一次事故说起雪崩是怎么被引爆的先描述一下我曾经经历过的典型雪崩链路方便后面讲方案时对照。某个大促活动运营给一批商品设置了相同的缓存过期时间比如统一凌晨零点失效。零点一过所有商品的缓存同时失效大量请求穿透到数据库商品表和库存表因为行锁竞争导致查询阻塞数据库连接池被打满。数据库撑不住后应用层还在持续重试和新建请求线程池很快也耗尽新的请求全部排队。这时候部分请求开始超时用户端重试网关层被刷爆最终服务对任何请求都返回失败整个秒杀入口处于不可用状态。这就是典型的缓存雪崩。那次事故让我彻底明白一件事高并发优化不是单点优化是一条链路优化。从客户端到网关、从Redis到数据库、从同步逻辑到异步任务任何一环掉链子整个系统都会被拖下水。所以下面的方案也都是按链路来拆解的。2. 热点Key库存这个单点为什么这么娇气2.1 热点Key与普通Key的本质区别在Redis集群模式下Key会根据哈希槽分布到不同节点理论上请求分散到各个节点整体吞吐量是叠加的。但秒杀商品只有一个库存Key比如stock:item_10001它的哈希槽是固定的所有请求都落到同一个Redis节点上其他节点再空闲也帮不上忙这就是热点Key倾斜。打个比方一个高速公路收费站的入口有十个闸口平时车流分散走十个口。秒杀开始后所有车都挤往一个闸口其他九个闸口空着。你说总通行能力够吗理论上是够的但实际只有一个闸口在工作当场堵死。热点Key带来的直接问题就是单点瓶颈不管集群扩容多少节点热点分片的CPU都最先被打满。如果热点分片同时承担着其他写操作或持久化任务Redis主线程阻塞整个集群的响应都会被拖慢。所以热点Key的治理优先级甚至比缓存命中率的优化更高。2.2 热点Key识别别等打挂了才后悔很多团队是等故障发生了才去看哪些Key流量异常这种事后补救成本很高。比较靠谱的做法是提前做热点预测结合业务特征和上报数据来判断。业务特征层面秒杀活动的商品清单在活动开始前就确定了运营配置活动时系统自动把这些商品的ID纳入热点名单提前给对应Key做分散处理。这个成本最低准确度也最高。数据上报层面在Redis客户端SDK里加上Key维度的访问统计比如用拦截器记录每个命令的Key访问次数超过阈值的上报到监控平台。这里有个小技巧上报采样不用全量按千分之一或万分之一采样就够了否则监控本身就成了新的性能压力。我在项目里用的是异步批量上报每5秒聚合一次把热点Key发现延迟控制在分钟级。识别到热点之后还要考虑热点迁移问题因为秒杀流量是波峰波谷形态开售前10分钟是热点之后逐渐消退。所以热点治理方案必须支持动态启停活动结束自动回收不然非热点数据也被加上冗余逻辑白白增加维护成本。2.3 热点Key的优化三板斧第一板斧是Key拆分。把原Key拆成N个带后缀的子Key比如stock:item_10001:0到:9一共10个子Key每个分片存储一部分库存。请求进来时先用商品ID取模比如userId % 10映射到对应子Key去扣减。这样流量就从1个Redis分片均匀分散到10个分片瓶颈瞬间缓解。代价是需要额外维护一个映射逻辑以及扣减前的库存合并读取。第二板斧是本地缓存兜底。在应用进程内加一层Caffeine缓存热点Key的库存数据在本地存一份查询库存时优先走本地。秒杀的核心其实不是所有人都在同一时刻查库存而是扣库存所以本地缓存主要用来拦截读请求。扣减请求还是要落到Redis但读请求不下传Redis压力就减掉一大半。第三板斧是提前预热与过期抖动。参与秒杀的商品在活动开始前就把库存写入Redis设置比活动开始时间略早的过期时间。过期时间不能是固定值必须加上随机偏移量比如基础过期时间600秒每个Key再随机加0到300秒。这样即使多个热点商品同时开始也不会在同一个时间点集体失效直接切断雪崩的触发源。3. 库存扣减的原子性之争从数据库到Redis3.1 数据库扣库存行锁与超卖的战斗早期项目里直接让数据库扛秒杀扣减SQL大概是这样的UPDATE stock SET available available - 1 WHERE sku_id #{skuId} AND available 0;这行SQL的好处是自带原子性available 0条件保证了不会扣成负数也就不会超卖。但问题在于秒杀场景下同一行数据会被大量并发更新数据库对这行记录加了行锁其他更新请求全部阻塞等待。连接池里几百个连接全卡在这行锁上后续查询也拿不到连接整个库的表现就是有货但查不了。有人提议用悲观锁先SELECT ... FOR UPDATE锁住行再判断库存再更新这种做法在秒杀场景更不合适锁等待时间被无限拉长吞吐量直接掉到每秒几百。还有乐观锁的变种用版本号控制更新但秒杀场景下更新冲突率接近百分之百重试次数会非常夸张性能反而更差。所以结论要明确一点数据库适合做最终一致性的校验和对账不适合做秒杀的实时扣减。后面讲的方案都是围绕实时扣减在Redis完成异步对账再回数据库这个思路展开的。3.2 Redis原子扣减Lua脚本才是正解Redis为什么适合做扣减因为它是单线程执行命令一个命令从开始到结束不会被其他命令打断天然保证原子性。但要扣库存这样多步操作就需要借助Lua脚本把多个命令打包一次发送一次执行期间不会插入其他命令。我常用的脚本是这种local stock tonumber(redis.call(get, KEYS[1])) if not stock then return -2 -- 库存不存在 end if stock tonumber(ARGV[1]) then return -1 -- 库存不足 end redis.call(decrby, KEYS[1], ARGV[1]) return 0 -- 扣减成功这段脚本的逻辑是先读取键对应的库存值如果不存在直接返回-2表示未初始化如果库存小于请求扣减数量返回-1表示库存不足否则执行decrby扣减并返回0表示成功。调用方的Java代码大致是这样DefaultRedisScriptLong script new DefaultRedisScript(SCRIPT, Long.class); Long result redisTemplate.execute(script, Collections.singletonList(stock:item_ skuId), buyCount); if (result ! null result 0L) { // 扣减成功生成订单 } else if (result ! null result -1L) { // 库存不足直接提示 } else { // 库存未初始化触发初始化流程 }这里有几个细节值得注意。第一扣减数量要作为参数传入不能写死在脚本里保证一个用户购买多件的场景也能正确处理。第二脚本执行结果要区分业务异常和技术异常比如Redis连接超时就不能当成库存不足返回给用户而是要触发重试或降级。第三所有后续依赖库存扣减结果的操作比如生成订单、发送消息、更新统计都要放到RabbitMQ消息里异步处理而不是在扣减成功后同步去调一堆下游接口否则又把同步链路的耗时拉上去了。3.3 扣减后的一致性问题怎么兜底Redis扣库存快是快但你不能把宝全押在Redis上原因很简单Redis是易失性存储如果持久化没做好宕机可能丢数据。买一台服务器做Redis持久化默认RDB快照可能隔很久才刷一次盘AOF如果设置成everysec最坏也丢一秒的写操作那一秒可能正好是高并发的扣减窗口库存数据一旦丢失后果不堪设想。所以我的做法是先加Redis再异步落库最终以数据库为准。具体流程是用户扣减Redis库存成功后发送一条消息到MQ消息体里带上订单号、商品ID、用户ID和购买数量消费者拿到消息后执行数据库层面的库存扣减和订单记录落库。数据库层的SQL还是用上面那句带条件的更新保证不会出现超卖。如果数据库扣减失败比如库存已经为0那就触发补偿机制把Redis里扣掉的库存加回来同时发送告警通知人工介入。这里有个核心经验是Redis和数据库之间必然存在一个短暂的不一致窗口这是最终一致性设计可接受的但不能无限期不一致。所以要有一个对账任务定期扫描订单表与库存流水表发现订单存在但库存扣减缺失或者流水与库存账不平的自动修复并告警。我实际用的对账周期是秒级延迟用MQ保证分钟级延迟用对账任务保底半小时做一次全量核对这样即使MQ出现积压或重复消费系统也能自愈。4. 防雪崩缓存层的三层防线4.1 缓存过期时间的抖动设计上一节提到的过期时间抖动再展开细讲一下。很多团队知道要给缓存加随机过期时间但具体怎么加还是存在误区。比如所有Key的过期时间都设成600秒固定随机数有的Key可能加100秒有的加500秒看上去随机了但并发高峰一过这些Key在同一波时间段内又集中失效。我的做法是在基础时间上叠加两级随机第一级随机是基础时长的百分之二十到百分之五十第二级随机是固定的0到300秒偏移。这样处理之后Redis中同一批商品的Key过期点会大幅度分散不会出现整点集体失效的现象。同时针对秒杀商品把缓存过期时间单独配置由活动运营在后台设置默认活动结束时间再往后延长一小时避免活动中库存正常扣减到0后缓存被误删。还有一点容易被忽略Redis的内存淘汰策略也会造成非主动雪崩。如果内存设置了allkeys-lru业务高峰期Redis内存压力大时某些热点Key可能被LRU算法淘汰掉对应的缓存被移除后下一波请求就会穿透到数据库。所以秒杀专用的Redis实例建议设置noeviction宁可拒绝新写入也不能把已存在的热点Key给挤掉。4.2 互斥锁重建与多级缓存即使做了过期抖动单Key的缓存依然可能在某个瞬间失效比如秒杀活动结束后缓存删除或者首次发布商品缓存未命中。这时候如果恰好有大量请求同时发现缓存缺失会一起冲向数据库这就是缓存击穿。对付单Key击穿业界最经典的手段是互斥锁重建缓存。进程内用ReentrantLock分布式环境用Redis分布式锁具体逻辑是缓存未命中时先尝试加锁拿到锁的线程去数据库查询并重建缓存其他线程在锁等待后重新查缓存此时缓存已被重建就直接返回。这个方案的关键是锁必须设置合理的超时时间我一般设3秒防止重建线程发生异常导致其他线程无限等待。多级缓存的思路还要再往本地缓存延伸。我在应用层引入了Caffeine本地缓存热点Key的库存值以极短的过期时间缓存在本地例如200毫秒。查询请求先打本地缓存本地没有再去RedisRedis也没有才走互斥锁查库。这层本地缓存对读多写少的场景特别有效能挡掉大量Redis网络IO。秒杀场景要注意的是扣库存成功之后必须主动把本地缓存和Redis中的对应Key删除或更新不然用户看到库存还有下单却提示库存不足体验会很差。4.3 熔断降级与流量控制即便做好上面的防护Redis实例本身也可能因为机器故障或网络分区而不可用。这时候如果应用层还坚持把请求打到Redis那结果就是Redis重试超时应用线程池被拖垮数据库也遭殃。所以必须设计熔断降级开关。我采用的策略是分三级降级。第一级Redis库存扣减接口耗时超过阈值比如50毫秒且错误率超过百分之二十时熔断器打开直接走数据库扣减虽然慢但保证业务正确。第二级数据库也出现明显的锁等待或慢查询时开启初级限流模式把超出系统容量的请求快速返回活动太火爆提示不进入扣减流程。第三级核心链路完全异常时直接人工关闭秒杀入口保留浏览商品和查看订单能力保证大面的用户体验不受影响。限流手段我用的是网关层的令牌桶加应用层的信号量配合。网关令牌桶控制整体入口流量比如每秒放行1000个请求超出的直接拒绝。应用层信号量控制并发执行的线程数比如最多同时处理500个扣减请求超出就排队等待或快速失败。这两个参数必须根据压测结果来定不能拍脑袋。5. 完整方案落地从单点秒杀到可扩展架构5.1 分层架构与职责划分现在把前面所有优化串联成一个完整方案。我最终落地的架构分五层每层职责单一互不越权。客户端和网关层负责静态化渲染和流量控制秒杀页面提前静态化到CDN动态请求只保留库存查询和下单一小部分接口。网关做全局限流和黑白名单。应用层承载业务逻辑包括Redis扣减、消息发送、状态缓存。缓存层由本地Caffeine和Redis组成分别承担读热点和写热点。数据层包括MySQL和MQMySQL是最终数据归属MQ负责削峰和异步解耦。每一层之间的交互都用明确的接口协议约束比如应用层只能通过缓存中间件访问Redis不能直接引入Jedis到处散用否则连接数会失控。代码层面我用了一个统一的库存服务类所有扣减入口都走这个类的方法方便埋点和后续治理。5.2 削峰填谷消息队列和异步对账秒杀的瞬时流量峰值很大但很多操作并不需要实时完成。比如订单创建后的库存验扣、用户维度的购买次数统计、活动商品的销量汇总都可以放到MQ里异步消费。核心链路只做Redis扣减成功和发送MQ消息两个动作耗时可控制在5毫秒内。MQ的选型RocketMQ和RabbitMQ都可以关键在于消息可靠性。发送消息要确认成功消息消费要手动ack消费者要保证幂等。我遇到过最常见的坑是消费者重复消费造成的库存重复扣减解决方法是消息体里带唯一业务ID比如订单号)数据库表加唯一索引重复消息插入时直接冲突报错捕获后跳过。异步对账任务用的是XXL-Job调度每5分钟扫描一次最近一小时的订单和库存操作流水发现不一致就自动生成补偿任务。对账SQL类似这样SELECT o.order_id, o.sku_id, o.buy_count, s.op_count FROM t_order o LEFT JOIN t_stock_flow s ON o.order_id s.biz_id WHERE o.create_time DATE_SUB(NOW(), INTERVAL 1 HOUR) AND (s.id IS NULL OR o.buy_count ! s.op_count);这条SQL找出来的是订单已落库但库存流水缺失或数量不符的记录补偿逻辑就是补扣库存或回补库存。别小看这个兜底线上跑下来绝大多数对不一致问题都是靠这个任务抓住并自动修复的。5.3 压测验证与容量评估系统上线前不做全链路压测等于让团队裸奔。我用的是JMeter加自研压测脚本的混合方案压测时特别关注几个指标接口P99延迟、Redis节点CPU使用率、数据库连接池活跃连接数、MQ积压数量。压测中比吞吐量更重要的是观察性能拐点。比如每秒请求从1000涨到2000时接口延迟还是10毫秒但从2000涨到3000时延迟突然变成200毫秒连接池开始出现等待说明2000就是这个通道的容量上限。容量规划就按预估峰值乘以1.5的安全系数来配资源不能正好卡着峰值上线。压测还要注意数据构造的合理性。库存量不能太少不然压测刚开始库存就扣完了后面全是库存不足的响应压的根本不是真实场景。我通常把测试库存设置成预估并发量的10倍以上确保整个压测周期内都有可扣减的库存量。6. 常见问题排查与避坑实录6.1 超卖、少卖、库存不一致的排查路径超卖问题排查第一件事不是看代码而是看数据库当前库存和订单数是否对得上。如果差值为负数说明超卖这时候看库存扣减的操作是否走了带条件的WHERE available 0同时看MQ消费者里是否把扣减库存的SQL写成了先查询后更新。少卖问题正好相反表现是数据库有库存但Redis显示已售罄。排查路径是先看缓存中的库存Key是否被外部任务修改过比如运营后台直接改库存但没有同步刷新缓存。再看MQ消息是否有丢弃消息积压超时导致扣减请求没有落地。还有一个很隐蔽的原因是本地缓存未及时失效用户请求打到了本地缓存读取了旧库存值但Redis中库存早已清零。库存不一致的问题还有一个高发场景就是秒杀结束后回补库存。比如用户下单后未支付订单超时关闭系统要把库存加回来。如果这个加库存操作走的是Redis的incr而数据库扣减的流水没有同步回滚就会出现Redis库存和数据库库存双份不一致。我的解决办法是把回补操作也做成一条MQ消息消费者里同时回补Redis和数据库并且对账任务做交叉验证。6.2 缓存三大问题症状对照表很多同学分不清缓存穿透、击穿和雪崩的区别一度在排查时走了弯路。这里整理一个对照表方便快速定位。问题类型触发原因典型症状有效防护缓存穿透请求查询不存在的KeyRedis命中率为0数据库查询量暴增布隆过滤器拦截无效Key空值短暂缓存缓存击穿单个热点Key过期失效大量并发同时查询同一个Key数据库单点压力大互斥锁重建缓存本地缓存兜底缓存雪崩大量Key同时过期或Redis故障数据库连接池耗尽接口大面积超时过期时间加随机偏移多级缓存集群高可用顺便说一句面试和实际排查中经常把击穿和雪崩搞混。击穿是一个热点Key挂了雪崩是一波Key集体挂掉两者的防护思路完全不同对照表里已经写清了。6.3 实操心得的几条忠告先讲一个我踩过的坑。最早做秒杀时给Redis加了一个大Key做库存扣减以为10万QPS没问题结果开售后Redis CPU直接打满分片卡死连带其他业务的缓存也全部超时。后来拆了Key配合本地缓存又把限流挡在前面才彻底解决。所以说高并发优化必须分层治理任何一层都不能成为单点。第二个忠告是Redis的性能强但不是数据库。不要在Redis里存复杂状态或长事务逻辑它是用来抗瞬时性能压力的最终数据一致性还得交给专业的数据存储。持久化方案也不能省AOF设置为everysec是底线重要活动建议开启混合持久化。第三个忠告是监控和告警比优化代码更重要。秒杀类系统优化做得再漂亮如果没有提前把Redis CPU、内存、连接数、数据库连接池、MQ积压量这些指标配好告警真出问题的时候反应速度会慢很多。我现在的告警规则是Redis CPU超过70%持续30秒就触发P1告警数据库连接池使用率超过80%触发P2告警MQ积压上万条时立刻推送宁可告警多也不能漏。最后一个个人体会是秒杀优化这件事永远没有终点。每一次大促复盘都能发现新的瓶颈和新的隐患比如上次发现网关的限流参数需要根据Redis分片数动态调整这次发现热点Key拆分的粒度还可以更细。如果这篇文章能帮你避开我之前踩过的几个大坑让你在下一次大促前多一点底气那就值得了。