
去年下半年我接手一个商品抢购类的性能改造项目。活动一开场几万用户同时点“立即购买”第一版实现很朴素库存扣减直接打 MySQL一条UPDATE 库存表 SET stock stock - 1 WHERE sku_id ? AND stock 0走到底。结果实战压测非常难看数据库连接池被打满行锁等待堆积订单超时一片。也就是从那次开始我把库存扣减的链路从上到下重新审视了一遍最后用 Redis 分片缓存把前置扣减扛了下来。这篇文章会把完整思路、设计和实操中的坑一起整理出来适合正在准备秒杀、抢购、限量库存系统的后端同学参考。1. 库存扣减的数据库瓶颈到底卡在哪里1.1 一条 UPDATE 语句引发的连锁反应很多人以为数据库瓶颈是“查询太慢”但在库存扣减这个场景里真正的杀手是行锁。InnoDB 引擎下UPDATE stock SET stock stock - 1 WHERE sku_id ?会对命中行加排他锁直到事务提交或回滚才释放。秒杀开始的那一秒几百上千个请求同时更新同一行实际执行顺序是被强制排队成一条线。前面的事务跑不完后面的事务只能干等lock wait timeout默认 50 秒超时你根本等不起。我当时的日志里出现最多的异常就是Deadlock found when trying to get lock; try restarting transaction和Lock wait timeout exceeded。锁等待不只是让单条 SQL 变慢它还会吃掉数据库连接一个事务占据一个连接不提交连接池里的连接就被这些排队中的事务占满。应用层的 Tomcat 线程池继续创建新请求全部阻塞在获取数据库连接的环节服务线程耗尽整条链路开始雪崩。1.2 单行热点是数据库难以水平扩展的根源库存表和其他表的本质区别在于它的热点高度集中。用户关注的不是一整张表而是那一个sku_id对应的行。用户量再大最终流量都汇聚到这一行上。MySQL 集群可以做读写分离、分库分表但这些手段面对单行热点基本失效分库分表按照sku_id路由后同一个 SKU 的所有写请求仍然落在同一个库同一张表同一行上跨库事务还更麻烦。主从复制也解决不了写瓶颈因为写流量全部打在主库。我把这个现象理解为“独木桥效应”数据库的行锁就是一座独木桥桥的宽度是固定的你在桥头加再多排队区过桥的速度也不会变快只会让队伍越来越长。库存扣减需要的不是让更多请求同时过桥而是让请求不要都挤在同一座桥上。1.3 直扣数据库方案的两难处境直接改库还有个经典问题如何防超卖。第一版里面我写的是WHERE stock 0这条 SQL 表面上能避免库存减成负数但它本身就要加行锁性能已经输了。如果用UPDATE stock stock - 1 WHERE stock 0再检查影响行数效果也一样。另一种做法是加版本号 CASUPDATE stock SET stock stock - 1, version version 1 WHERE sku_id ? AND version ?。但这样做在并发高的时候大量请求会更新失败客户端只能重试重试又带来一波请求风暴数据库压力成倍上涨。也就是说传统的数据库直扣在“防超卖”和“高性能”之间几乎是不可调和的。你要保证不超卖就要把并发请求串行化要高性能就要放弃串行化约束。Redis 分片缓存方案能起作用核心就是把这个矛盾从数据库层挪到了内存层用原子操作和一个更灵活的分片模型来化解。2. 把库存放进 Redis 前置扣减选型理由与原子性设计2.1 为什么 Redis 适合做库存计数器Redis 解决库存扣减的第一个优势是纯粹的快。所有数据都在内存里单命令执行是微秒级单实例轻松支撑每秒几万次甚至十万次以上的简单操作。第二个优势是原生支持计数命令INCRBY、DECRBY都是原子操作不需要应用层加锁。但新手很容易踩一个坑直接用DECRBY扣减没有判断结果是否为负数。比如库存只剩 1 件两个请求同时DECRBY 1Redis 执行后返回 0 和 -1两边都以为扣成功了订单照样建超卖就发生了。原子命令只能保证“扣减”这个动作不被打断不能保证“库存是否够扣”这个业务规则。所以库存扣减的原子性必须把“检查库存”和“扣减”放在同一个脚本里完成。2.2 用 Lua 脚本封住超卖的口子Redis 的 Lua 脚本可以保证一段逻辑的原子执行这是库存扣减最核心的依赖。我的做法是把校验和扣减写进同一个脚本扣减失败直接返回错误码应用层不再做任何补救判断。-- KEYS[1]库存 key -- ARGV[1]期望扣减的数量 local stock redis.call(GET, KEYS[1]) if not stock then return -2 -- 表示 key 不存在可能是尚未预热 end if tonumber(stock) tonumber(ARGV[1]) then return -1 -- 库存不足 end redis.call(DECRBY, KEYS[1], ARGV[1]) return 0 -- 扣减成功脚本很短但有几个细节需要说明。返回值我没有用剩余库存量而是用状态码因为在高并发下应用层依赖返回值做判断容易产生误解。另外 Lua 脚本里一定要处理 key 不存在的情况否则会走到nil比较的坑里去。预热不到位时返回 -2 比返回“库存不足”更利于排查问题。2.3 从数据库直扣到 Redis 优先的完整流程库存扣减的前置化不是简单地换成 Redis而是要重新设计业务链路。我最后落地的流程是请求进入订单服务先调 Redis 执行 Lua 脚本扣减库存。扣减成功才允许创建订单、生成订单号、锁定商品信息。订单创建成功后异步把扣减明细写入 MySQL 或者投递到消息队列。如果订单创建失败要立即对 Redis 做回补把刚扣掉的库存加回去。为什么扣减成功之后还要回写数据库因为 Redis 本质上是易失性存储不能当唯一账本。数据库的作用是保存最终扣减流水、支撑后续对账和财务核算。订单服务挂了Redis 里库存已经减了数据库库存还没变如果没有对账机制两边一比对就是一笔坏账。2.4 最终一致性怎么补异步回写与失败回补异步回写最常见的方式是“扣减即发消息”每次扣减成功后往 MQ 里投递一条消息内容包含skuId、扣减数量、业务流水号。消费者拿着消息去更新 MySQL通过唯一流水号做幂等避免重复回写。这套机制正常情况下是最终一致的短时间可能 Redis 和 MySQL 有差值但对账线程会定期校准。比较麻烦的是失败回补。订单创建失败、用户取消支付、订单超时未支付这些场景都要把库存加回去。回补动作也必须走 Lua 脚本而且要明确一个原则回补的库存回到哪个分片尽量回到原来扣出的那个分片。如果库存已经分散到多个分片回补到错误的节点会造成某个分片有余量、另一个分片早早耗尽整体看起来库存还有但下单却一直失败。3. Redis 分片缓存从单点热点到可水平扩展3.1 单实例 Redis 为什么也会成为瓶颈用 Redis 单 key 扣库存性能比数据库直扣好很多但离“高可用”还差得远。Redis 虽然快命令执行仍然是单线程模型一个实例的 CPU 是瓶颈。秒杀场景下每秒几万甚至几十万的扣减请求会把单个 Redis 节点的 CPU 打到接近 100%命令延迟开始飙升。更关键的是如果把整个 SKU 的库存放在一个 key 上那么这个 key 就是新的热点。Redis 集群可以水平扩展但 Redis Cluster 的 key 分片是基于哈希槽的同一个 key 只会落在某一个节点上。也就是说不管你集群有多少个节点这个热点 key 对应的请求压力全部压在一个节点上集群扩容对它没有任何帮助。3.2 逻辑分片加物理集群两个层面缺一不可要打破单点限制必须把“一个 SKU 一把锁”改成“一个 SKU 很多把锁”这就是库存分片的基本思路。我把单个 SKU 的总库存拆成 N 份分别存储在 N 个不同的 key 上比如sku:1001:stock:0 sku:1001:stock:1 sku:1001:stock:2 ... sku:1001:stock:31这些 key 经过 Redis Cluster 的哈希分布大概率会落在不同的物理节点上。请求被路由到不同的分片 key物理节点的负载就摊开了。这里要特别强调物理分片和逻辑分片要配合使用。只做 Redis Cluster 而不拆库存 key热点依然存在只做逻辑分片但 Redis 是单实例CPU 也迟早被打满。3.3 分片数量与路由策略的取舍分片数量 N 怎么定我用得比较多的是 16 到 64 之间。太小了起不到分散热点的作用太大了又会带来额外复杂度比如库存汇总要扫太多 key分片间余量碎片化也更严重。实务中我会结合单次请求量和预估峰值来算假如单个分片能扛每秒 5000 次扣减预估峰值每秒 10 万那至少拆 20 个分片留出一定余量取 32 比较稳妥。路由策略常见两种取模路由根据用户 ID 或请求批次号对 N 取模确定分片号。优点是实现简单、固定缺点是如果某些用户 ID 的取模结果集中分片负载会不均衡。随机路由每次请求随机选一个分片扣减减失败就换一个分片重试。优点是可以让流量在整个分片集合上均匀分布缺点是极端情况下同一请求可能要重试多次。我后来采用的是“随机路由 失败重试”重试次数限制在 3 次以内。如果 3 次都提示库存不足再认定这个 SKU 已经卖完。这样能让每个分片的消耗速度大致接近避免某几个分片提前见底。3.4 分片扣减的汇总查询与余量展示库存分散之后“还剩多少件”变成了一件麻烦事。你不能每次用户刷新页面都把 32 个 key 全部GET一遍然后求和这样每次查询都会变成一次多个 Redis 请求的放大流量一大反而把 Redis 打垮。我的做法是单独维护一个“展示用总量 key”由后台的一个定时任务每隔几秒扫描全部分片并汇总写入页面查询只读这个总量 key。注意这个总量 key 不参与扣减路径只用于展示。扣减路径始终走分片 key展示 key 允许短时间误差秒杀页面上显示“还剩 N 件”本来就是一个近似值用户不会关心这几秒的误差。4. 落地部署中绕不开的细节预热、同步、故障与监控4.1 预热与过期策略避免缓存击穿库存 key 必须在活动开始前预热完毕不能等流量来了再加载。我这边会有一个独立的预热脚本活动前扫描数据库库存表把每个 SKU 的总库存除以分片数写入对应的分片 key。同时脚本会做一次全量校验确认每个分片之和等于数据库可用库存。Redis key 的过期时间绝对不能随便设置更不能设成“和活动结束时间一致”。万一 Redis 中的 key 提前到了 TTL 自动删除后续请求全部查到 key 不存在业务会被直接击穿。我建议库存 key 设置为永不过期由运维在活动结束后统一清理这样最稳妥。4.2 数据库回写的节奏与对账异步回写数据库时如果每一个扣减请求都立刻写 MySQL那 Redis 的缓存价值就打了折扣数据库压力又回来了。所以要控制回写节奏常见做法是攒批消费者从 MQ 里拉取一批扣减消息批量执行UPDATE stock stock - ? WHERE sku_id ?再配合乐观锁条件防止覆盖。批量大小我一般配置成 100 条一批每批一个事务数据库压力可控。对账是这套方案的最后一道保险。我会写一个定时任务每 5 分钟运行一次读取数据库当前库存和 Redis 初始库存减去总扣减量做差值比对超过阈值就告警。实际运行中绝大多数不一致都是由消息重复、漏发、订单回补不及时引起的对账脚本能把这些数据捞出来人工处理。4.3 Redis 故障降级与数据补偿Redis 集群虽然做了主从和哨兵仍然要面对抖动、切换、脑裂等场景。库存扣减的降级策略必须提前想好不能等故障发生再讨论。我当时的方案是加一层开关控制正常情况下走 Redis 扣减一旦监测到 Redis 集群整体不可用立刻切换到数据库直扣模式同时通过限流把流量压到数据库能承受的范围。这个降级方案不完美数据库直扣会重新暴露性能问题但它的意义是保证核心业务流程不中断哪怕牺牲一部分吞吐也比直接 502 强。等到 Redis 恢复再通过对账脚本把差值补回来。4.4 监控指标与告警阈值库存扣减链路需要监控的指标比普通接口多很多我重点关注这几项Redis 分片 key 的扣减成功率低于 95% 就要报警。Redis 各节点的 CPU、内存、网络流量出现单节点 CPU 过高说明分片不均。MQ 积压数量回写数据库的消费线程如果跟不上积压会越来越大。对账差值只要 Redis 和 MySQL 库存差值不等于 0就发邮件通知。监控数据我建议走 Prometheus GrafanaRedis 的INFO指标和业务自定义指标都通过 exporter 暴露出来。没有监控就敢上分片缓存的系统出事时基本只能靠猜。5. 压测结果与对比一次真实的性能验证5.1 压测场景配置为了直观比较三种方案我在测试环境做了压测。机器配置是 2 台 8C16G 应用服务器、1 台 MySQL 8C32G、3 台 Redis 集群节点模拟 2000 个并发用户同时抢购同一个 SKU库存总量 10000 件持续压测 3 分钟。压测数据需要根据各自环境调整但数量级有参考意义。压测覆盖了三种方案方案 A直接 MySQL 扣减UPDATE stock stock - 1 WHERE stock 0方案 BRedis 单 key 扣减Lua 脚本检查库存不拆分方案 CRedis 32 分片扣减随机路由 失败重试5.2 三种方案的指标对比指标方案 AMySQL 直扣方案 BRedis 单 key方案 CRedis 分片峰值吞吐 TPS约 1,500约 23,000约 43,000P99 延迟860 ms42 ms18 ms失败率14.7%0%0%超卖数量000数据库连接池占用100%约 30%约 30%Redis 单节点 CPU无87%最高 58%方案 B 的问题在压测里已经显现Redis 单节点 CPU 接近 90%如果并发再往上加延迟会肉眼可见地上升。方案 C 把流量摊到多个分片后各节点 CPU 保持在一个健康水位P99 延迟也降下来了。数据库连接池的压力主要来自异步回写表现稳定。5.3 从数据中看到的几个现象第一方案 A 的失败率 14.7% 看起来不高但实际上大量请求是超时返回错误用户点击“立即购买”会看到失败提示体验很差。第二方案 B 在压测后段 Redis 节点 CPU 升高时P99 延迟并不是线性增长而是突然劣化这就是单线程模型遇到瓶颈的典型表现。第三方案 C 的库存销售速度非常均匀到活动结束剩余库存量接近零基本没有出现“某些分片有库存但客户买不到”的碎片化情况。这套压测数据也验证了一个结论瓶颈始终在热点集中度而不在 Redis 本身。把热点打散之后同样的节点规模可以支撑更高的流量。6. 实战踩坑与个人建议6.1 订单超时未支付库存回补是最容易翻车的环节上线后第一个遇到的大坑不是扣减而是回补。秒杀场景里大量用户抢到了订单但未支付系统设置了 15 分钟超时自动取消。取消之后要回补库存刚开始我把回补逻辑写成了往任意一个分片加 1。结果就出现一个奇葩现象某个分片已经卖到 0另外几个分片还剩不少库存可用户刷出来的却是“已售罄”。后来我把回补逻辑改成了“原分片回补”扣减成功的响应里带上分片号订单取消时带着同一个分片号去做回补。如果分片号丢失了再退回全局随机回补。这个改动上线后碎片化问题基本消失。6.2 Key 的容量管理要提前规划分片之后 key 数量变多一个大促可能有几千个 SKU 乘以 32 个分片就是几十万个 key。虽然量不大但如果每个 key 都带很大的业务字段内存也会被白白吃掉。我建议库存 key 只存储数值不存储其他业务属性避免内存浪费。另外要做定期的过期清理。活动结束后如果不清理下个活动前再写入时会覆盖旧值逻辑上没问题但内存里躺着大量冷数据会抬高运维成本。清理动作也要做好灰度不能一次性把所有 key 删完万一下一个活动提前启动库存预热又会出错。6.3 “绝对不超卖”是一个有代价的目标Redis 分片方案并不能保证 99.9999% 不超卖。Redis 主从切换时异步复制会丢少量写命令极端情况下已经扣减的库存可能在故障切换后恢复原值造成超卖。只要走分布式链路绝对一致就是有成本上限的。我的处理方式是保留一个“安全库存”兜底。比如可售卖库存是 10000 件我实际放到 Redis 的分片总量只放 9800 件剩下 200 件作为数据库侧的缓冲。一旦对账发现差值异常或者 Redis 故障恢复后库存数据漂移就把这部分兜底库存拿出来人工校准。用少量可售卖空间换一致性冲击在大促场景里是划算的。6.4 什么时候该用分片缓存什么时候不该用最后给一点个人建议不是所有库存扣减都要上 Redis 分片。普通电商的日常售卖流量数据库直扣配合合理索引完全能撑住没必要引入一套缓存一致性机制。真正需要分片方案的是那种瞬间峰值非常高、热点极度集中的场景比如秒杀、限量发售、演唱会门票抢购。如果你正在评估要不要上这套方案先做两件事第一压测你的数据库直扣方案搞清楚它到底能扛多少 TPS第二评估 Redis 单 key 扣减是否已经把瓶颈转移到了 CPU 上。如果这两步都证实了瓶颈存在再考虑分片缓存否则就是过度设计。架构的复杂度要和流量一起走不要为了技术而技术。我在实际运维中踩过不少坑最深的体会是库存扣减从来不是一个“把库存放到 Redis 就行”的简单问题而是一场关于原子性、热点分散和最终一致的持续性平衡。每次大促过后复盘都能看到新问题这套方案也是在一次次的故障和调优里慢慢变得可靠的。