高并发库存扣减:Redis分布式锁与Lua解决超卖和一人一单 做电商的人听到“库存超卖”这四个字多半会心里一紧。我见过最典型的一次事故是某个大促商品实际只有 200 件零点开抢后订单表里塞进了 213 条记录仓库只发出 200 单剩下 13 个用户等着赔付和道歉。同一时间另一个活动页面被同一批账号反复下单刷走了大半库存事后追查发现后端既没有做一人一单的校验,也没有用Redis 分布式锁把“查库存—判断—扣减”这段临界区串起来。库存超卖解决的是数据正确性问题,一人一单解决的是活动公平性问题,而 Redis 分布式锁解决的是并发控制问题——这三件事在真实业务里往往是同一个接口里的同一段代码,拆开看每个都不难,合到一起就全是细节。这篇内容适合已经会写 Redis 基本命令、能跑通 Spring Boot 接口,但在高并发场景下还没踩过坑的同学,也适合带过项目、想回头把方案复盘一遍的开发者。我下面会把这套东西从问题还原、方案选型一直讲到可直接抄的代码和压测验证,顺手把线上真出过故障的几个坑摆出来。1. 先把问题还原清楚超卖和一人一单到底怎么发生的很多同学一上来就写锁,写完了也说不出自己保护了什么。我更习惯先画出错的那一瞬间,把并发时序摆到桌面上,后面所有方案都是为了堵这个窟窿。1.1 从 200 件到 -13 件一段查询—判断—扣减的竞态时序最朴素的扣库存代码大概长这样,几乎每个项目的第一版都是它:// 错误示范三段操作完全非原子 ItemStock stock stockMapper.selectById(itemId); if (stock.getStock() 0) { int remain stock.getStock() - 1; stockMapper.updateStock(itemId, remain); // update ... set stock #{remain} where item_id ? orderMapper.insert(order); }单线程跑一百次都不会出问题,所以本地测试永远是绿的。问题出在并发上。假设库存剩 1 件,两个请求同时进入:时刻请求 A请求 B数据库库存T1读到 stock1—1T2判断 10 通过读到 stock11T3—判断 10 通过1T4写入 remain0—0T5—写入 remain00最终库存变成了 0,但订单生成了两条,超卖 1 件。更隐蔽的是另一种写法update stock set stock stock - 1 where item_id ?,看起来像是原子的,实际上它只保证单条 SQL 的原子性,不保证“先判断再扣减”这个整体语义;一旦并发量上来,库存会直接扣成负数,你在订单表里能查到 -13 这种数字。这里有个常被忽略的点:不少人觉得“我加了where stock 0不就完了”,确实,update stock set stock stock - 1 where item_id ? and stock 0这一条语句在 InnoDB 下是安全的,靠的是行锁 当前读。但它安全的前提是所有扣减都必须走这一条语句,一旦业务里掺进了“先查余量给前端展示、再扣减”的逻辑,或者要做多商品合并下单、要判断限购,这段原子性就断了。而且热点商品的所有请求都会压在同一个行锁上,数据库连接池很快被拖满,响应时间从 20ms 涨到 800ms,后面就是雪崩。1.2 一人一单的真实语义不是幂等是限购“一人一单”经常被和“接口幂等”混为一谈,其实两者要解决的问题不一样。幂等解决的是同一个请求被重复提交/重复投递导致的数据重复,通常靠请求唯一 ID 去重;而一人一单解决的是同一个用户的不同请求在活动周期内只能成功一次,靠的是业务维度的限购标识。在动手之前我会先跟业务把三个边界问清楚,这三个答案直接决定 Redis key 怎么设计:限购维度是什么:是“用户 商品”,还是“用户 活动”,还是“用户 商品 规格”?我遇到过同一个商品在不同活动里各限一件的需求,如果 key 只按用户和商品拼接,第二个活动就下不了单。限购周期多长:活动结束后标识要不要清?如果活动持续 7 天,Redis key 的 TTL 至少得覆盖 7 天,提前过期就等于开了后门。未支付订单算不算占名额:下单占位但 30 分钟未支付自动关单,这时候名额要不要放出来?大部分业务是需要的,那么回补逻辑就必须和订单状态机对齐,这是个很容易漏的角落。把这三点定下来,再谈技术实现,能省掉后面一半的返工。2. 方案选型为什么最后落在 Redis 分布式锁上同一个问题至少有五六种解法,我不认为哪个方案是“唯一正确答案”,关键看你的并发量级、一致性要求和现有架构。下面把我实际用过的几条路摆出来对比。2.1 数据库层的三道兜底条件更新、唯一索引、乐观锁数据库永远是最可靠的最后一道防线,我到现在也坚持不管前面用什么缓存方案,数据库这一层必须能兜住。先看条件更新,也就是前面提到的update ... where stock #{num}。它的优点是零成本、强一致,缺点是在热点行上会把并发打成串行,单行 TPS 上限大概就是1000 / 单次事务耗时(ms),一个 20ms 的事务撑死 50 TPS,大促根本扛不住。再看唯一索引。一人一单这件事,最稳的实现其实是在订单表上建uk_user_item (user_id, item_id, activity_id),重复插入直接抛DuplicateKeyException,业务侧捕获后返回“您已参与过本活动”。这条兜底我认为是必选项,因为它不依赖任何缓存中间件,缓存挂了、主从切了、代码写错了,唯一索引还在。乐观锁则是用版本号或库存余量做 CAS:update stock set stock stock - #{num}, version version 1 where item_id ? and version #{version}。它适合冲突概率低的场景,一旦热点商品冲突率超过 30%,重试次数会急剧上升,反而更慢。方案一致性热点性能实现成本适用场景条件更新where stock n强差(行锁串行)极低低并发、兜底唯一索引强好低一人一单必选兜底乐观锁版本号强中等中冲突率低的更新Redis INCR 预扣最终一致极好中大促预热扣减Redis Lua 原子脚本最终一致好中限购扣减同事务Redis 分布式锁最终一致中等较高临界区含复杂业务逻辑2.2 Redis 侧的三层递进INCR、Lua、分布式锁Redis 这条路我自己走过三个阶段,每一步都是被上一个方案的坑逼出来的。第一层是 INCR/DECR 预扣,把库存预热到 Redis,请求进来直接DECR,返回值小于 0 就回滚。它快到离谱,单机十万级 QPS 没问题,但问题是回滚麻烦:扣减成功之后如果落库失败、或者用户取消,你得把库存加回去,而回补和扣减是两条独立的命令,中间任何一步失败都会导致 Redis 和数据库长期对不上。所以我一般只在“允许少量误差 有对账任务”的场景用它。第二层是 Lua 脚本,把“查库存、判断、扣减、写限购标识”这四步塞进一个脚本里,Redis 单线程执行脚本,天然原子,而且只需要一次网络往返。这是我目前最推荐的方案,后面第 4 章会给完整脚本。第三层是分布式锁,把临界区放大到整个业务方法。什么时候必须上锁?我的判断标准是:临界区里除了 Redis 操作,还有别的不可原子化的动作——比如要调风控接口、要查数据库确认用户资格、要生成订单号、要发消息。这些动作 Lua 脚本干不了,只能用锁把它们圈起来。2.3 单靠 SETNX 不够用一把能上生产的锁要满足什么很多教程讲到setnx就收尾了,实际上一把能上生产的锁至少要满足五个条件,少一个都会在某天凌晨给你打电话:互斥:同一时刻只有一个持有者,这是底线。防死锁:持有者崩溃后锁能自动释放,靠过期时间。防误删:A 的锁不能被 B 删掉,靠 value 唯一标识 Lua 校验。可重入:同一线程重复获取不应自己把自己锁死,靠计数。可续期:业务耗时超过锁过期时间时,锁不能提前失效,靠看门狗。再加一条容易被忽略的:锁的获取要能失败得快。如果拿不到锁就无限自旋,高峰期会把线程池打满,这点在第 3.4 节的退避策略里细说。3. 手写一把能上生产的 Redis 分布式锁我不建议每个项目都自己造轮子,成熟的客户端库确实更省事。但自己写一遍的价值在于:你知道每一行在防什么,线上出问题时能定位到具体环节,而不是对着一个tryLock干瞪眼。3.1 加锁SET NX PX 与 value 的唯一性第一件事,把setnxexpire两条命令的老写法扔掉。这两条命令之间如果进程挂了,锁就永远不会过期,这是教科书级别的死锁:// 千万不要这么写 Boolean ok redisTemplate.opsForValue().setIfAbsent(key, 1); redisTemplate.expire(key, 30, TimeUnit.SECONDS); // 这行可能执行不到正确写法是用一条SET key value NX PX milliseconds,原子的:public class RedisLock { private final StringRedisTemplate redis; private static final String PREFIX lock:; public String tryLock(String bizKey, long ttlMillis) { // value 实例标识 线程标识 随机串保证全局唯一 String token INSTANCE_ID : Thread.currentThread().getId() : UUID.randomUUID().toString().replace(-, ); Boolean ok redis.opsForValue() .setIfAbsent(PREFIX bizKey, token, ttlMillis, TimeUnit.MILLISECONDS); return Boolean.TRUE.equals(ok) ? token : null; } }value 为什么必须唯一?假设不加随机串,只用固定值1,那么 A 的业务执行超时、锁自动过期、B 拿到了锁,此时 A 执行完走到 finally 里del key,删掉的就是 B 的锁,互斥直接破功。带上唯一 token 之后,删除前先比对,才能做到“解铃还须系铃人”。注意:INSTANCE_ID建议用机器 IP 加启动时间戳拼出来,不要用主机名,容器环境下主机名可能重复。这个标识在排查“到底是谁没释放锁”时非常有用。3.2 解锁Lua 脚本保证“比对 删除”的原子性比对和删除必须是原子的,否则又回到刚才那个 ABA 的老问题:get出来发现是自己的 token,还没来得及del,锁过期被别人抢走,你这一del又删错了。所以解锁只能走 Lua:-- unlock.lua -- KEYS[1]: 锁 key -- ARGV[1]: 当前持有者的 token if redis.call(GET, KEYS[1]) ARGV[1] then return redis.call(DEL, KEYS[1]) else return 0 endJava 侧调用:private static final DefaultRedisScriptLong UNLOCK_SCRIPT new DefaultRedisScript( if redis.call(GET, KEYS[1]) ARGV[1] then return redis.call(DEL, KEYS[1]) else return 0 end, Long.class); public boolean unlock(String bizKey, String token) { Long r redis.execute(UNLOCK_SCRIPT, Collections.singletonList(PREFIX bizKey), token); return r ! null r 1L; }实测下来这一步能挡掉 80% 的“锁莫名其妙被释放”的工单。脚本本身很短,但它是整个锁方案里最不能省的部分。3.3 看门狗续期过期时间到底该给多少锁的过期时间是个两难:给短了,业务没跑完锁就没了,互斥失效;给长了,持有者崩溃后其他人要干等很久,系统吞吐掉下来。我一般按这个公式估:锁 TTL 业务耗时的 P99 值 × 3 网络抖动余量通常 200~500ms举个具体的例子:某接口压测下来 P99 是 120ms,Redis 客户端超时设的 100ms,那么 TTL 取120 × 3 300 ≈ 700ms,取整给 800ms 或 1s。乘 3 是因为要覆盖 GC 停顿、网络抖动、机器负载突发这几种偶发情况。但再怎么估,P99 也只是统计值,总有 0.01% 的请求会超。所以针对“业务里有长耗时操作”的场景,续期是必须的。思路是开一个后台定时任务,每隔TTL / 3去检查一次,如果锁还是自己的就重新设置过期时间:Scheduled(fixedDelay 1000) public void renew() { for (Map.EntryString, String e : LOCK_REGISTRY.entrySet()) { String key e.getKey(); String token e.getValue(); // 续期同样要校验 token防止给别人的锁续命 RENEW_SCRIPT.execute(key, token, 30_000L); } }对应 Lua:-- renew.lua if redis.call(GET, KEYS[1]) ARGV[1] then return redis.call(PEXPIRE, KEYS[1], ARGV[2]) else return 0 end为什么是 1/3 而不是 1/2?因为续期任务本身可能延迟、可能失败一次,留出两次重试的余量,即使漏掉一轮也不至于让锁提前过期。这是我在两次线上故障后调整的参数,原来用 1/2,遇到一次 GC 停顿 2 秒就失效了。注意:续期任务必须只针对“当前进程确实持有”的锁,所以在LOCK_REGISTRY里维护映射。进程重启后这些信息丢失,但锁本身有 TTL 会自然过期,不会残留。3.4 可重入实现与重试退避的参数计算A 方法拿到锁之后调 B 方法,B 方法又要拿同一把锁,如果没有重入机制,线程会自己把自己卡死。实现思路是把 value 从字符串换成一个计数结构,用 Hash 存token - 重入次数:public boolean tryLockReentrant(String bizKey, long ttlMillis) { String field INSTANCE_ID : Thread.currentThread().getId(); Long count REENTRANT_SCRIPT.execute( Collections.singletonList(PREFIX bizKey), field, String.valueOf(ttlMillis)); return count ! null count 1; }-- reentrant_lock.lua -- KEYS[1]: 锁 key, ARGV[1]: 线程标识, ARGV[2]: TTL(毫秒) if redis.call(EXISTS, KEYS[1]) 0 then redis.call(HSET, KEYS[1], ARGV[1], 1) redis.call(PEXPIRE, KEYS[1], ARGV[2]) return 1 end if redis.call(HEXISTS, KEYS[1], ARGV[1]) 1 then return redis.call(HINCRBY, KEYS[1], ARGV[1], 1) end return 0释放时对应HINCRBY -1,减到 0 才真正DEL,并且每次操作都要重置 TTL。至于重试策略,千万别用无脑while(true),高峰期几千个线程同时自旋,Redis 和 CPU 一起遭殃。我用的是“指数退避 随机抖动”:重试次数基础等待基础上限加抖动后实际区间150ms50ms40~60ms2100ms100ms80~120ms3200ms200ms160~240ms4400ms400ms320~480ms5800ms500ms(封顶)400~600ms总等待时间约50100200400500×15 ≈ 8.2s,对于大多数接口来说足够等到锁释放。抖动的目的是打散“同一毫秒涌入”的惊群效应,这个技巧在秒杀场景里效果很明显。代码大概是这样:long base 50L; for (int i 0; i maxRetry; i) { String token tryLock(bizKey, ttl); if (token ! null) return token; long wait Math.min(base * (1L i), 500L); long jitter ThreadLocalRandom.current().nextLong(wait / 5 1); Thread.sleep(wait jitter); } throw new BizException(系统繁忙请稍后重试);最后那行异常很关键:锁等不到时应该给用户一个明确提示,而不是让请求悬在那里超时。4. 完整落地库存扣减 一人一单的实操流程理论说完,进实操。这一节我把表结构、key 设计、Lua 脚本、异步落库、压测验证全部串起来,你可以直接照着改。4.1 表结构与 Redis Key 设计数据库两张表,一张库存、一张订单:CREATE TABLE item_stock ( item_id BIGINT PRIMARY KEY, stock INT NOT NULL DEFAULT 0, version INT NOT NULL DEFAULT 0, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ); CREATE TABLE seckill_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL, user_id BIGINT NOT NULL, item_id BIGINT NOT NULL, activity_id BIGINT NOT NULL, status TINYINT NOT NULL DEFAULT 0, -- 0未支付 1已支付 2已关闭 create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_order_no (order_no), UNIQUE KEY uk_user_item (user_id, item_id, activity_id), KEY idx_item (item_id) );uk_user_item这个唯一索引是我最看重的一行,Redis 挂十次它都不会失效。Redis 侧的 key 我按业务前缀分好,方便排查时SCAN:Key 模板类型作用过期策略stock:item:{itemId}String预热库存计数跟活动周期一致limit:user:{userId}:item:{itemId}:act:{actId}String一人一单占位活动结束后 1 天lock:stock:{itemId}String/Hash扣减临界区互斥锁TTL 30s 续期req:{requestId}String请求去重标识10 分钟预热脚本在活动开始前 10 分钟跑一遍,把数据库库存刷进 Redis,同时给 key 设上活动结束时间再加 1 小时的过期,避免活动结束后 key 长期占内存。4.2 核心 Lua 脚本一次 RTT 完成限购校验与扣减这里是整套方案的核心。我把“库存是否存在、库存是否够、用户是否已买过、扣减、写限购标识”五件事压进一个脚本:-- seckill_deduct.lua -- KEYS[1]: 库存 key KEYS[2]: 限购 key -- ARGV[1]: 扣减数量 ARGV[2]: 限购 key 的 TTL秒 -- 返回: 0 扣减后的剩余库存; -1 库存未预热; -2 库存不足; -3 重复下单 local stock redis.call(GET, KEYS[1]) if not stock then return -1 end if tonumber(stock) tonumber(ARGV[1]) then return -2 end local ok redis.call(SET, KEYS[2], 1, NX, EX, ARGV[2]) if not ok then return -3 end return redis.call(DECRBY, KEYS[1], ARGV[1])脚本里有两个顺序细节,值得单独拎出来讲:第一个是为什么先查库存、再写限购标识。如果反过来,先SET NX占位成功,再判断库存不足返回,那么这个用户的限购名额就被白白消耗掉了——他明明没买到,却再也下不了单。这个 bug 我在一个活动里见过,用户投诉“我一次都没买成功,却提示已参与”。顺序换一下就没这个问题:库存不足时压根不占名额。第二个是SET NX EX和DECRBY之间不需要担心失败。因为 Redis 执行 Lua 是单线程原子的,脚本中间不会插入其他客户端的命令,所以前面判断通过之后,后面这两步一定按顺序执行完。Java 侧封装:public long deduct(long itemId, long userId, long activityId, int num, long ttlSeconds) { ListString keys Arrays.asList( stock:item: itemId, limit:user: userId :item: itemId :act: activityId); Long r redis.execute(DEDUCT_SCRIPT, keys, String.valueOf(num), String.valueOf(ttlSeconds)); if (r null) throw new BizException(扣减异常); if (r -1) throw new BizException(活动未开始); if (r -2) throw new BizException(商品已售罄); if (r -3) throw new BizException(您已参与过本次活动); return r; }配合前面那层锁,整体流程是这样的:先用lock:stock:{itemId}把同一商品的请求串起来(这一步只在你需要做复杂校验、或者要保证“Redis 扣减 落库”这段逻辑的顺序时才需要),然后在锁内调用 Lua 扣减,扣减成功再写订单、发消息。如果业务简单到只需要“扣减 限购”,其实可以跳过锁直接用 Lua,因为 Lua 本身就是原子的——这也是我常跟团队说的:锁不是目的,原子性才是。4.3 网络超时重试导致的重复扣减以及请求去重这是我在真实环境踩得最深的一个坑。Lua 脚本本身原子,但客户端不知道脚本到底执行成功没有。当 Redis 响应慢、客户端读超时,你的代码会捕获异常然后重试,而第一次其实已经扣减成功了,重试就扣了第二次。解决办法是引入请求级去重标识,在同一个脚本里先做幂等判断:-- KEYS[1]: 库存 key KEYS[2]: 限购 key KEYS[3]: 请求去重 key -- ARGV[1]: 扣减数量 ARGV[2]: 限购 TTL ARGV[3]: 去重 key TTL local dup redis.call(SET, KEYS[3], 1, NX, EX, ARGV[3]) if not dup then return -4 -- 该请求已处理过直接返回成功语义 end -- 后续库存与限购逻辑同上 ...requestId由前端在下单前生成一次,整个重试链路复用同一个值。注意这里返回 -4 时业务侧应该按“已成功”处理,而不是报错,否则用户看到的是“下单失败”但实际已经扣减,体验更糟。注意:去重 key 的 TTL 要覆盖客户端的最长重试窗口,10 分钟是个比较稳妥的值。设太短,重试穿越过去照样重复扣。4.4 异步落库、库存回补与对账Redis 扣减成功只代表“抢到了”,最终还得落到数据库。我的做法是扣减成功后往消息队列投一条创建订单的消息,消费端做两件事:一是插订单(靠唯一索引兜底),二是执行update item_stock set stock stock - #{num} where item_id ? and stock #{num}更新库存。这条 SQL 返回影响行数为 0,说明数据库侧对不上,必须触发告警并回滚 Redis(把库存INCRBY回去、删掉限购 key)。回补也要走 Lua,保证“加库存”和“删限购标识”一起完成:-- rollback.lua -- KEYS[1]: 库存 key KEYS[2]: 限购 key -- ARGV[1]: 回补数量 redis.call(INCRBY, KEYS[1], ARGV[1]) redis.call(DEL, KEYS[2]) return 1另外每天凌晨跑一个对账任务,把 Redis 库存和数据库库存拉出来比对,差异超过阈值的商品打标并人工介入。上线前我觉得这个任务多余,上线后它救过我两次。4.5 压测验证用数据证明真的不超卖了代码写完不算完,必须压。我用wrk打同一个接口,500 并发,持续 30 秒,数据库库存初始化为 200:wrk -t8 -c500 -d30s --latency \ -s post.lua http://127.0.0.1:8080/api/seckill/orderpost.lua里随机生成 userId,模拟不同用户抢购。三组方案对比结果:方案请求总数成功下单库存最终值重复下单数P99 延迟无任何控制48213213-133745ms仅 SETNX 锁4790220000320msLua 原子脚本510442000068msLua 请求去重509872000071ms第一行的 -13 和 37 就是文章开头那次事故的复现,非常直观。第三行说明在“只需要扣减 限购”的场景下,Lua 比锁快将近 5 倍,因为它把锁的等待时间抹掉了。第四行多出来的 3ms 是去重 key 的写入开销,我认为这个代价很值。压测时有个细节:一定要用真实的不同 userId,如果所有请求共用一个 userId,你测出来的其实是限购逻辑,不是并发扣减逻辑。5. 踩过的坑与排查实录这一节是我这些年攒下来的错题本,按“现象—原因—处理”整理,遇到问题可以直接查表。5.1 常见问题速查表现象大概率原因处理方式库存扣成负数用了“先查再减”的非原子逻辑改 Lua 或条件更新where stock n锁一直不释放setnx与expire分两步,中间崩溃改用SET NX PX单命令A 的锁被 B 删掉value 没有唯一标识,或解锁非原子value 加 UUID,解锁用 Lua 比对业务没跑完锁就没了TTL 设置过短,无续期加看门狗,TTL 按 P99×3 估用户第一次下单就提示已参与限购标识写在库存判断之前调整脚本顺序,先判库存后占位重复扣减客户端超时重试,脚本无幂等引入 requestId 去重 key主从切换后出现双持有者主节点异步复制,锁数据未同步到从业务侧加 fencing token 兜底Redis 内存暴涨限购 key 无 TTL 或 TTL 过长key 必须设过期,按活动周期1天高峰期线程池打满拿不到锁无限自旋指数退避 最大重试次数 快速失败5.2 主从切换下锁失效为什么还要留一道 fencing tokenRedis 主从是异步复制的。A 在主节点拿到锁,主节点还没把这条写命令同步给从节点就宕机了,哨兵把从节点提上来,而这个从节点上根本没有 A 的锁记录,B 一来就拿到了锁。结果就是两个线程同时持有同一把锁,互斥彻底失效。这个问题不是配置能解决的,任何基于单点 Redis 的锁都有这个窗口。业界的应对有两类:一类是更复杂的多节点投票算法,代价是性能下降和实现复杂度上升;另一类也是我更倾向的——承认锁可能失效,在业务侧留一道 final check。具体做法是引入一个单调递增的 fencing token,每次获取锁时INCR一个序号带回业务层,所有写操作带上这个序号,数据库侧判where token last_token。序号小的那个请求即使拿到锁,写库时也会被拒绝。这一层在库存场景里可以简化成:落库时坚持用where stock #{num}条件更新,返回影响行数为 0 就说明数据已经不对了,直接回滚 Redis 并返回失败。说白了,分布式锁是用来提升性能、减少冲突的,不是用来保证数据正确性的。正确性必须由数据库这最后一道关口守住,这个认知我用一次线上事故换来的。5.3 锁粒度与吞吐上限的估算锁的粒度直接决定吞吐。锁商品 ID,同一商品的所有请求串行,理论 TPS 上限是:TPS_max 1000 / 持锁时长(ms)持锁 50ms 就是 20 TPS,持锁 5ms 是 200 TPS。一个爆款商品按这个算根本不够用。所以我的优化顺序是:先想办法把锁内的事情挪出去(比如把风控校验、库存判断都前置成无锁的 Redis 操作),让持锁时长降到个位数毫秒;实在降不下来,就分段——把 1000 件库存拆成 10 段,每段一把锁,并发度直接提升 10 倍,代价是可能出现“某一段卖光了但整体还有货”的情况,需要在售罄判断上做兼容。5.4 几个不上文档的经验最后分享几个文档里不会写、但实际很管用的点。锁内绝对不要做 RPC 和慢 SQL。我见过在锁内调风控接口的,对方超时 3 秒,锁 TTL 只有 5 秒,排队等着拿锁的线程全部超时,雪崩。凡是能在锁外做的,一律挪出去。限购 key 的 TTL 一定要跟订单状态机对齐。用户下单未支付,30 分钟后订单关闭,这时候限购名额要放出来,否则用户再想买就提示“已参与”。做法是订单关闭的消费逻辑里调一次回补脚本,删掉限购 key。本地缓存不能和 Redis 锁混用同一个判断。JVM 内缓存库存值做前置过滤可以,但绝不能拿它做最终判断,否则多实例之间数据不一致,过滤掉的请求可能本可以成功。上线前把日志打全。加锁、解锁、扣减结果、回滚触发,每个环节都要有带 requestId 的日志。出问题时,requestId是唯一能把分布式链路上所有记录串起来的东西,没有它排查基本靠猜。我个人在实际操作中的体会是,库存超卖和一人一单这类问题,难的不是写出能跑通的代码,而是想清楚“哪一层负责正确性、哪一层负责性能”。Redis 分布式锁和 Lua 脚本负责把绝大部分请求挡在数据库之外,让系统扛得住;数据库的条件更新和唯一索引负责兜住那 0.01% 的异常情况,让数据不出错。这两层各司其职,少一层都会在某个大促的凌晨出问题。至于锁本身,能不用就不用,能用一条 Lua 解决的绝不上锁,这是我在性能压测里反复验证过的结论。