库存被打成 -37是我在一次大促前的压测里亲眼看到的结果。压测脚本一停运营同学把监控截图甩过来某个爆款 SKU 的可售数量显示为负数而且负得很有规律——几乎是并发线程数的量级。同一批数据里还夹着另一个更尴尬的现象同一个账号在 300 毫秒内下出了 11 笔订单。这就是库存超卖、一人一单和Redis 分布式锁这三件事为什么总被放在一起讲的原因它们不是三个独立的知识点而是同一条下单链路上三个不同位置的防线任何一道失守最后的账单都得由业务来买单。这篇内容面向的是已经能写增删改查、但一遇到并发就心里发虚的后端同学也适合那些知道要加锁、但说不清为什么这么加的开发者。我会从超卖的真实成因讲起把数据库条件和 Redis 预扣减这两条路线的取舍讲透再把 Lua 脚本、唯一索引、幂等 token、SET NX PX、锁续期这些零件一个个拆开最后拼成一条能跑的下单链路。全程按为什么这么设计来讲而不是甩一段代码让你抄。1. 那次压测把库存打成了负数三个问题的共同根因1.1 超卖的现场还原读到的是旧值写回的是新值先把超卖的机制说清楚。库存扣减在代码里最朴素的写法是三段式先SELECT stock FROM item WHERE id ?查到库存 10在内存里判断stock 0然后UPDATE item SET stock 10 - 1 WHERE id ?。单线程跑一万遍都不会出问题但只要两个线程同时走到第一步它们读到的都是 10都判断通过都写回 9。两次扣减库存只减了 1。这就是经典的丢失更新跟 Redis 一点关系都没有它发生在任何读—判断—写被拆成多条语句的地方。那为什么标题里要扯上 Redis因为绝大多数团队发现超卖之后的第一反应是数据库太慢我上 Redis。于是把库存搬到 Redis 里用DECR扣减看起来原子了但业务流程变成了Redis 扣减成功 → 创建订单 → 数据库扣减。这条链路上任何一步失败或者超时Redis 的库存和数据库的库存就对不上了。更麻烦的是很多人只把 Redis 当成缓存扣减完之后异步刷库刷库失败没有补偿对账的时候才发现账实不符。所以超卖的本质是并发控制缺失Redis 只是提供了更细粒度的原子操作工具。你得先想清楚谁是库存的权威数据源Redis 扣减成功但订单创建失败时怎么回滚回滚会不会重复执行这三个问题答不上来加再多锁也只是把问题往后推。我见过一个比较典型的翻车现场某本地生活平台的秒杀活动Redis 里预扣减写得很规范但回滚逻辑放在catch块里而那次故障是下游服务超时触发的异常被框架吞掉了一层catch根本没进。结果 Redis 库存少了一百多份数据库库存一份没动活动结束后运营手动补了三天数据。这件事给我的教训是回滚不能只依赖异常捕获必须有定时对账兜底。1.2 一人一单和分布式锁经常被混成同一件事第二个误区是把一人一单直接等同于加分布式锁。逻辑听起来很顺用户下单时用userId加锁锁住之后查一下有没有订单没有就创建完事释放锁。单机时代这么写没问题但在集群环境下这套逻辑有两个隐藏成本。第一是锁的粒度问题。用userId做锁键同一个用户在同一时刻只能有一个下单请求在跑这确实能防住重复提交。但一个大促期间热门商品的并发压力集中在少数几个 SKU 上你用用户维度加锁锁竞争其实很分散看起来没毛病。真正的问题在于锁只能保证同一时刻只有一个请求在临界区它不保证历史请求留下的数据不存在。用户昨天已经买过一件了今天再来买锁是全新的照样能过。所以一人一单的判定必须落到持久化的唯一约束上锁只是减少无效的数据库写入。第二是锁的可靠性问题。Redis 分布式锁在极端情况下会失效比如主从切换丢锁、GC 长停顿导致锁过期。如果你的业务正确性完全押在锁上那这些极端情况就会变成超卖的入口。业界的共识是Redis 锁用来削峰和防重数据库唯一索引用来保证最终正确。两层配合才能既扛住流量又不丢数据。把这两点想明白之后三个问题的层次就清晰了超卖是库存数量的正确性一人一单是用户维度的唯一性分布式锁是临界区的互斥性。前者靠原子扣减加补偿中者靠唯一索引加缓存拦截后者靠正确的加解锁协议。下面逐个拆。2. 库存扣减的三条路线从数据库行锁到 Redis 预扣减2.1 悲观锁扣减业务最省心吞吐最难看最直接的方案是在事务里SELECT ... FOR UPDATE锁住这一行再判断再更新。它的好处是逻辑极其直观所有并发请求在数据库层面排队业务代码不用关心任何并发细节。坏处也很明显行锁的持有时间等于整个事务的执行时间如果事务里还调了远程接口、发了消息锁的持有时间会被拉长到几十甚至几百毫秒同一商品的请求全部堵在这里。我做过一次简单的压测对比同一个商品、同样的机器配置FOR UPDATE方案在并发 50 的时候就开始出现明显的等待TPS 卡在 200 上下而且响应时间的 P99 会突然翘起来。对于日订单量几千的系统这完全够用甚至在很多内部管理系统里这是最合适的选择——毕竟代码简单出了问题好排查。但如果你的场景是秒杀或者整点抢购这条路走不通。还有一个容易忽略的点FOR UPDATE在没有命中索引的时候会退化成表锁。我见过有人用WHERE sku_code ?加行锁而sku_code上没有索引结果整张商品表被锁住所有商品的加购全部卡死。这个坑排查起来很费劲因为业务日志看起来一切正常只是慢。2.2 CAS 条件更新一条 SQL 挡住八成并发比悲观锁轻量得多的是条件更新也就是把判断塞进WHERE里UPDATE item_stock SET available available - 1, updated_at NOW() WHERE sku_id #{skuId} AND available 1;然后看affectedRows。返回 1 说明扣减成功返回 0 说明库存不足或者 SKU 不存在。这条语句的原子性由数据库保证不需要显式加锁InnoDB 会在执行时对命中的行加排他锁锁的范围和持有时间都远小于FOR UPDATE。这个方案的性价比很高我在好几个中等规模的项目里都用它作为主方案。它的极限在于所有请求最终还是要打到数据库热点行上的行锁竞争依然存在。当同一 SKU 的 QPS 上千时InnoDB 的行锁等待会变成瓶颈你会在监控里看到大量lock wait和连接池耗尽。有个细节值得说affectedRows在 MySQL 默认配置下返回的是实际变更的行数如果你把available available - 1写成available available - 0即使条件命中返回的也是 0。所以这个方案的可靠性完全依赖 SQL 写法不要在SET里加任何花活。另外用available而不是stock命名是为了区分可售库存和物理库存预占和实际出库分开记对账的时候会方便很多。2.3 Redis 预扣减把热点流量挡在数据库之外真正的高并发场景思路是先扣 Redis扣成功了再去落库。Redis 单线程执行命令DECR和DECRBY本身就是原子的天然不存在丢失更新。这样一来几千 QPS 的扣减压力全部由 Redis 承担数据库只处理成功的那部分请求压力被削掉一个数量级。但这套方案引入了新的复杂度必须提前想清楚四件事库存预热活动开始前把库存写入 Redis键的 TTL 要覆盖整个活动周期不能中途过期。扣减失败的语义Redis 返回负数说明库存不足要立刻返回前端已售罄不能继续往下走。落库失败的补偿订单创建失败、数据库扣减失败都要把 Redis 的库存加回去。对账机制定时比对 Redis 余量和数据库余量差异超过阈值要告警。第三点是最容易被轻视的。很多实现只在业务代码的异常分支里做回滚但真实故障里异常可能被全局异常处理器吞掉、可能发生在异步线程里、也可能是进程直接被 kill。所以回滚逻辑要设计成可重入、可重放的配合一张回滚记录表或者一个回滚队列由后台任务兜底重试。2.4 三种方案的取舍对照把三种方案放在一起看选择其实取决于你的并发量级和团队运维能力方案适用并发数据库压力实现复杂度主要风险点悲观锁FOR UPDATE低百级以下高锁等待明显低锁范围失控、事务过长CAS 条件更新中千级以内中热点行竞争低连接池耗尽、行锁等待Redis 预扣减高万级低高数据不一致、回滚遗漏我的实际经验是不要一上来就上 Redis 预扣减。日订单量在十万以内的系统CAS 条件更新配合合理的限流就够了多加一层 Redis 只会多一个故障点。只有当压测明确显示数据库行锁成为瓶颈或者业务上确实存在整点抢购这种瞬时洪峰才值得引入预扣减和全套补偿机制。3. Lua 脚本落地预扣减键设计、类型陷阱与回滚补偿3.1 为什么单条 DECR 不够必须上 Lua有人会问DECR已经是原子的了为什么还要写 Lua因为真实的扣减逻辑不止一步。你需要先判断库存够不够够了才扣不够要返回明确的失败标识很多时候还要顺便记录扣减数量、更新版本号、写一条流水。这些操作如果拆成多条 Redis 命令中间就可能被其他请求插入。举个具体的例子库存只剩 1两个请求同时进来。如果先GET判断再DECR两个请求都可能读到 1都判断通过然后都执行DECR库存变成 -1。这就是 Redis 层面的丢失更新。而把判断和扣减写进一个 Lua 脚本Redis 保证脚本执行期间不会插入其他命令整个脚本就是一个原子操作单元。注意Lua 脚本的原子性指的是不被其他命令打断不是执行失败会回滚。脚本中途报错前面已经执行的写命令不会撤销。所以脚本里要避免可能出现运行时错误的操作比如对字符串执行DECR。3.2 库存键怎么设计才不会在扩容时翻车键的设计看起来是小事但在集群环境下会直接影响可用性。常见错误是只用stock:{skuId}这样的单键然后指望 Redis Cluster 帮你分片。单键在集群里只会落在一个节点上热点 SKU 的所有请求都会打到同一个分片这个分片就是新的瓶颈。更合理的做法是按业务维度拆键比如把总库存拆成若干份分别放在stock:{skuId}:0到stock:{skuId}:N上请求按用户 ID 哈希取模选择分片。这样热点被打散到多个节点单节点的压力下降。代价是库存不足的判断变复杂了——某个分片扣完了不代表全局没货需要有一定的容错策略比如允许一定比例的分片不均损耗。如果不想做分片至少要把键的命名规范统一起来用冒号分隔层级方便用SCAN做批量操作。另外要区分预扣减键和已售键我用的是seckill:stock:{activityId}:{skuId}和seckill:sold:{activityId}:{skuId}前者存余量后者存已售对账的时候两个值加起来应该等于总库存。3.3 扣减脚本与回滚脚本扣减脚本要处理三种情况库存充足扣减成功、库存不足、键不存在活动未开始或已过期。三种情况要返回不同的标识让上层能做区分处理。-- KEYS[1]: 库存键 -- ARGV[1]: 扣减数量 -- 返回: 剩余库存(0) 表示成功; -1 表示库存不足; -2 表示键不存在 local stock redis.call(GET, KEYS[1]) if not stock then return -2 end local current tonumber(stock) local need tonumber(ARGV[1]) if current need then return -1 end return redis.call(DECRBY, KEYS[1], need)回滚脚本相对简单但必须幂等。我的做法是引入一个回滚标记键用订单号做后缀SET NX成功才执行加法-- KEYS[1]: 库存键 -- KEYS[2]: 回滚标记键(rollback:{orderNo}) -- ARGV[1]: 回滚数量 -- ARGV[2]: 标记过期秒数 -- 返回: 1 表示回滚成功; 0 表示已回滚过, 忽略 local marked redis.call(SET, KEYS[2], 1, NX, EX, tonumber(ARGV[2])) if not marked then return 0 end redis.call(INCRBY, KEYS[1], tonumber(ARGV[1])) return 1这个标记键是整个回滚逻辑的核心。没有它一次消息重投或者定时任务重跑就会把库存加两次直接导致超卖的反面——少卖。3.4 回滚的幂等与对账兜底幂等标记解决了重复回滚但还有两个场景需要额外处理。第一种是扣减成功但脚本没返回比如网络超时客户端不知道到底扣没扣。这时候不能简单重试因为可能已经扣过了。解决方案是给每次扣减生成一个唯一的请求号扣减脚本里额外写一个deduct:{requestId}的标记重试时先查标记存在就当作成功处理。第二种是回滚标记写入成功但加库存失败虽然 Lua 保证了原子性理论上不会出现但如果加了过期时间标记键可能在库存键之前过期导致重复回滚。所以标记键的 TTL 一定要远大于库存键的 TTL我一般设成库存键 TTL 的两倍以上。对账是最后一道防线。我会跑一个每五分钟执行一次的定时任务扫描所有进行中的活动比对 Redis 余量和数据库余量差异超过千分之一就告警。这个任务的成本不高但救过我三次。有一次是下游服务升级导致回调丢失靠对账发现并修复了二十多笔差异。4. 一人一单唯一索引兜底Redis 做第一道闸门4.1 先想清楚一人一单的判定口径写代码之前必须和产品对齐三件事一人一单是按什么维度算活动期间算还是自然日算取消订单之后名额还不还第一件事影响唯一索引的字段组合。如果是每个用户每个活动每个 SKU 限购一件索引就是(activity_id, user_id, sku_id)如果是每个用户每个活动限购一件就去掉sku_id。我曾经接过一个需求产品说的是每人每天限购三件实现的时候按活动维度建了唯一索引上线后用户投诉说第二天买不了才发现口径没对齐。第二件事和索引的唯一性设计直接冲突。每天限购意味着同一用户在不同日期可以重复购买唯一索引里就必须带上日期字段比如biz_date。这种索引的写入量会随时间增长需要配合归档策略。第三件事最容易被忽略。用户下单后取消订单名额要不要释放如果索引是唯一的取消后不删记录用户就永远买不了了。常见的处理是逻辑删除加部分索引或者把已取消的订单迁到另一张表。前者在 MySQL 里可以用deleted_at IS NULL配合唯一索引实现但要注意 NULL 值的语义。4.2 Set、Bitmap、Hash 三种去重结构的选择Redis 层面做去重最常用的是 SetSADD order:users:{activityId} {userId}返回 1 表示首次返回 0 表示重复。这个结构简单直接成员数量等于参与人数活动规模在百万级以内都可以接受。Bitmap 更省内存用SETBIT order:bitmap:{activityId} {userId} 1GETBIT判断。一个用户占一位一千万用户也只占 1.2MB。但它要求用户 ID 是连续的整数如果用户 ID 是雪花算法生成的 64 位长整型位图的偏移量会大到无法使用。所以 Bitmap 只适合自增 ID 的场景用之前先确认 ID 生成策略。Hash 适合需要记录更多信息的场景比如HSET order:records:{activityId} {userId} {orderNo}既能判断是否存在又能拿到订单号做后续处理。代价是内存占用比 Set 高因为每个字段都要存键值对。我的默认选择是 Set配合一个独立的过期时间。活动结束后让键自然过期不需要主动清理。如果活动规模特别大可以做分片用userId % 16选择 16 个 Set 中的一个分散单键的写入压力。4.3 幂等 token防止重复提交的那道前置关卡前端防重只能防住手抖防不住脚本。真正要挡重复提交需要在进入下单流程之前发一个令牌。用户打开商品详情页时服务端生成一个 UUID 写入 RedisSET order:token:{userId}:{skuId} {uuid} EX 300同时返回给前端。提交订单时带上这个 token服务端用GETDEL或者 Lua 脚本原子地取出并删除取不到就拒绝。这个方案的好处是把重复请求挡在了最外层不消耗任何库存和数据库资源。坏处是用户体验上需要多一次交互而且如果用户开了多个页面后开的页面会覆盖前一个的 token导致前一个页面提交失败。所以实际落地时token 的键要带上页面标识或者随机串允许多个有效 token 并存。注意token 的删除必须是原子的。如果先GET判断再DEL两个并发请求可能都判断成功都会继续往下走。用GETDELRedis 6.2或者一段 Lua 脚本才能保证只有一个请求能拿到令牌。4.4 取消订单之后这个名额要不要还这个问题没有标准答案取决于业务。秒杀活动的常见做法是不还——用户主动取消意味着放弃资格名额回收会造成二次抢购的公平性问题。普通商品的限购则通常要还不然用户体验太差。如果决定要还Redis 层用SREM删掉成员数据库层把订单记录标记为已取消唯一索引配合逻辑删除。这里有个时序问题如果先删 Redis 再改数据库中间挂掉会导致 Redis 里没记录但数据库里订单还在用户能再买一次最终被唯一索引挡住体验是下单失败。反过来先改数据库再删 Redis中间挂掉会导致用户一段时间内无法再次购买但数据是安全的。两害相权我选后者。下单失败有明确提示用户能理解数据错乱则要人工排查好几天。这种宁可在缓存层多留一会儿脏数据也要保证数据库先正确的思路在分布式场景里基本是通用原则。5. 分布式锁SET NX PX 只是及格线5.1 加锁必须是 SET key value NX PX 一条命令老代码里常见的写法是先SETNX再EXPIRE这两条命令之间存在一个时间窗口如果进程在这中间挂掉锁就永远不会过期变成死锁。Redis 2.6.12 之后SET支持了NX和PX参数应该用一条命令完成SET lock:order:{orderNo} {uuid} NX PX 30000返回 OK 表示加锁成功返回 nil 表示锁被占用。uuid这个值不是随便填的它是锁的持有者凭证解锁的时候要校验。很多人图省事写个固定字符串结果 A 线程超时释放了锁B 线程拿到锁A 线程醒来后又把 B 的锁删了整个互斥就崩了。这里还有一个细节PX和EX不要混用PX是毫秒EX是秒。我见过把PX 30000写成EX 30000的锁的持有时间是 8 个多小时出问题时排查了半天才发现是单位写错。5.2 解锁不校验持有者等于给别人开门解锁必须用 Lua 脚本先比对值再删除两步原子执行-- KEYS[1]: 锁键 -- ARGV[1]: 持有者标识 if redis.call(GET, KEYS[1]) ARGV[1] then return redis.call(DEL, KEYS[1]) else return 0 end返回 0 说明锁已经不属于自己了这时候不要重试删除也不要报错静默处理即可。因为这意味着自己的业务执行时间超过了锁的 TTL锁已经被别人拿走此时最该做的事情是记录一条告警日志回头看看为什么业务跑了这么久。用 Redisson 这类客户端的话这部分已经封装好了它的unlock内部就是这段逻辑。但用之前最好还是搞清楚原理不然出了锁相关问题会很被动。我遇到过有人用 Redisson 的tryLock参数传了(0, -1, TimeUnit.SECONDS)本意是锁永不过期实际上触发了看门狗机制锁被无限续期最终把整个商品的下单链路锁死了。参数含义搞不清比不用框架更危险。5.3 锁续期看门狗解决不了所有问题业务执行时间超过锁 TTL 是常态尤其是那些要调好几个下游接口的流程。Redisson 的看门狗机制会在锁快要过期时自动续期默认每 10 秒续一次把 TTL 重置回 30 秒。这解决了一部分问题但它有三个前提锁是通过 Redisson 加的、加锁时不指定leaseTime、应用进程还活着。如果进程被 kill看门狗线程跟着死掉锁就在 30 秒后自然过期这是符合预期的。但如果发生了长时间的 Full GC看门狗线程也被暂停锁可能在 GC 期间就过期了另一个线程拿到锁开始执行等 GC 结束后原来的线程继续跑两个线程就同时在临界区里。这类问题极其难排查因为它在开发环境几乎不会出现只在高负载的生产环境偶发。所以我的建议是锁的 TTL 要按业务 P99 耗时的 3 到 5 倍来设而不是拍脑袋填个 30 秒。同时业务代码里要加临界区幂等即使两个线程同时进来也要保证最终结果正确。锁是性能优化不是正确性保障。5.4 主从切换与长停顿锁失效的两类真实场景Redis 主从是异步复制的。客户端在主节点上加锁成功主节点还没把这条命令同步给从节点就宕机了哨兵把从节点提升为主节点新主节点上没有这把锁另一个客户端就能加锁成功。同一时刻两个客户端都认为自己持有锁。这是异步复制的固有缺陷不是某个客户端的 bug。业界围绕这个问题有过一轮很激烈的争论一方主张用多节点投票的方式也就是常说的红锁提高可靠性另一方指出红锁在极端时序下依然不安全而且会带来严重的性能损耗。争论没有赢家最终的实践共识是不要指望分布式锁提供绝对的正确性保证把关键约束落到数据库的唯一索引和条件更新上。长停顿的场景前面提过了。除了 GC容器环境下还有 CPU 限流、页面置换、网络抖动都可能导致进程停住。这些都不是代码能控制的。所以凡是用了锁的地方都要问自己一句假设锁失效了我的数据会不会坏如果答案是会那就得补一个数据库层的兜底约束。5.5 锁粒度锁商品、锁用户还是锁订单号锁的粒度直接决定了系统的并发能力。锁商品lock:sku:{skuId}意味着同一商品的所有下单请求串行这对库存扣减是安全的但吞吐量被限制在单件商品的级别热门 SKU 会被彻底卡死。锁用户lock:user:{userId}竞争分散了但挡不住同一商品的并发超卖。我的实践是分场景库存扣减用原子操作Lua 或条件更新不加锁用户维度的防重去重用 Set 或者唯一索引不加锁真正需要锁的是那些跨多个资源、有严格顺序要求的复合操作比如扣库存 扣优惠券 建订单这三步要保证要么全做要么全不做。这时候锁的键可以用订单号或者请求号粒度最细互不影响。用请求号做锁键还有一个好处天然幂等。同一个请求重试时锁键相同如果前一次还没执行完这次会等待如果前一次已经完成并释放这次会重新执行但因为后续操作有幂等标记不会产生重复数据。6. 把三层防线串成一条下单链路6.1 一次下单的完整执行顺序把前面的零件拼起来我现在的下单链路是这样的前端带幂等 token 提交服务端用 Lua 脚本原子取出并删除 token取不到直接返回请勿重复提交。校验活动状态和商品状态这一步走本地缓存不打 Redis。SADD用户去重集合返回 0 则返回您已参与过。执行库存扣减 Lua 脚本返回 -1 返回已售罄返回 -2 返回活动未开始同时用SREM把第 3 步加进去的用户移除。在本地事务里创建订单、写订单明细、执行数据库条件扣减。事务失败则投递回滚消息由消费者执行幂等回滚脚本。事务成功后发消息通知下游做履约。这个顺序有几个刻意的设计。第一步放 token 校验是为了让重复请求在消耗任何资源之前就被挡掉。第三步的去重放在扣库存之前是为了避免库存被扣了但用户是重复的这种情况。第五步的数据库条件扣减是兜底即使 Redis 那层出了问题数据库也不会扣成负数。6.2 每层拦截的职责边界防线拦截的问题失效后的后果兜底手段幂等 token重复提交、网络重试多笔无效请求进入流程用户去重集合用户去重集合同一用户并发多单Redis 层放过多余请求数据库唯一索引库存 Lua 脚本并发扣减导致的丢失更新Redis 余量可能为负脚本内判断不会为负数据库条件更新Redis 与数据库不一致数据库扣成负数唯一约束、对账任务分布式锁复合操作的中间态部分资源被占用未释放回滚消息、定时补偿这张表的用法是上线前逐行问自己如果这一层挂了下一层能不能接住。接不住的就得补。比如用户去重集合如果因为 Redis 故障整体不可用数据库唯一索引必须能挡住重复订单所以那个索引一定不能省。6.3 压测怎么打看哪些指标压测不是把并发数调高跑一遍就完事。我一般分三轮打。第一轮单接口压测固定 100 并发观察接口的 TPS 和 P99确认基线。第二轮混合场景把下单、查询、取消按真实比例混合看资源竞争情况。第三轮故障注入人为制造 Redis 超时、数据库慢查询验证降级和补偿逻辑。指标方面除了常规的 QPS、RT、错误率我会重点盯这几个Redis 库存余量和数据库余量的差值、Lua 脚本的平均执行耗时、锁等待的 P99、回滚消息的积压量、唯一索引冲突的次数。最后这个指标特别有用它直接反映了一人一单那层防线的工作情况——如果冲突次数很高说明前面的 Redis 去重没有起到作用可能是键设计错了也可能是过期时间设短了。有一次压测发现唯一索引冲突次数异常高排查下来是去重集合的键用了活动 ID 加日期而压测跨了零点键切换导致去重失效。这种问题在功能测试里根本发现不了只有压测跨时间边界才会暴露。7. 几个真踩过的坑7.1 回滚脚本把正常订单的库存也加回去了这是我最惨痛的一次。当时的回滚逻辑是监听订单取消事件收到就加库存。但那个事件同时也会在订单创建超时自动关闭时发出而超时关闭的订单库存其实在创建时就已经回滚过了。结果就是同一笔订单被回滚两次库存凭空多出来。根因是回滚的触发条件没有区分是否已经回滚过。修复方案就是前面提到的幂等标记键用订单号做后缀SET NX成功才执行加法。这个改动只有三行代码但避免了一次严重的资损。由此还引申出一条经验任何涉及资源增减的操作都要先问这个操作如果执行两次会怎样。如果答案是会出错那就必须加幂等控制而不是指望上游只发一次消息。7.2 Lua 里的字符串和数字比较Redis 的GET返回的是字符串Lua 里10 5这种比较会直接报错因为类型不匹配。必须用tonumber转换。但tonumber也有坑如果键里存的不是合法数字它会返回nil后续比较会报attempt to compare nil with number。更隐蔽的是浮点数。Lua 5.1 里所有数字都是双精度浮点超过 2^53 的整数会丢精度。库存一般不会这么大但如果用时间戳做键值就会出现精度问题。所以脚本里凡是涉及大整数的地方都用字符串处理不要转成数字。还有一点脚本里如果出现运行时错误前面已经执行的写命令不会回滚。所以脚本要做防御性判断GET之后先判断nil再判断类型宁可返回一个错误码让上层处理也不要让脚本抛异常。7.3 锁超时时间拍脑袋设定早期我设锁的 TTL 全靠感觉觉得 30 秒应该够了。有一次一个批量下单的接口单次要处理 50 个商品每个商品都要走一次远程校验总耗时超过了 30 秒锁提前释放另一个请求进来两个请求同时操作同一批库存出现了重复扣减。后来改成按实际耗时来定先在日志里统计这个接口的 P99 耗时然后 TTL 设成 P99 的 3 倍同时加上看门狗续期。这样即使偶发慢请求也不会因为锁过期出问题。另外对于那些明显可能超时的长任务我会把大事务拆成小批次每批独立加锁释放避免单次持锁时间过长。最后分享一个小的检查习惯每次写完加锁的代码我都会在纸上画一条时间轴标出加锁时刻、业务开始、业务结束、解锁时刻、锁过期时刻然后问自己如果业务在哪个点卡住会发生什么。这个动作花不了两分钟但能提前发现大部分锁的问题。