SET NX PX与Lua原子性:Redis 7.2.5锁的核心原理

我们团队去年接手了一个日均订单量 50 万的商城系统,当时最头疼的问题是库存扣减接口经常报超卖。排查日志发现,多个实例同时处理同一个商品的下单请求时,数据库行锁竞争特别激烈,高峰期接口耗时从正常的 120ms 飙升到 800ms 以上。那时候我才意识到,单靠数据库锁已经撑不住了,必须引入分布式锁。

为什么选 Redis 7.2.5 来实现?当时对比过 ZooKeeper 和 etcd,Redis 的加解锁延迟只有 1-2ms,而 ZooKeeper 的 ZNode 创建删除要 10ms 以上,我们的库存接口 QPS 峰值要扛 3000+,性能差距太明显。而且 Redis 7.2.5 是 2024 年 5 月刚发的稳定版,对集群脑裂场景的处理比旧版本更成熟,我们生产环境用的就是这套版本。

核心加锁逻辑其实就一个命令:SET key value NX PX。我第一次看这个命令时也疑惑,为什么非要这三个参数一起用?后来踩过坑才明白:

下面是我们在生产环境跑了半年的加锁代码,用的是 Jedis 客户端,Redis 版本 7.2.5:

import redis.clients.jedis.Jedis; import java.util.UUID; public class RedisDistributedLock { private static final String LOCK_SUCCESS = "OK"; private Jedis jedis; // 锁默认过期时间 30 秒,足够处理大部分库存扣减逻辑 private static final int DEFAULT_EXPIRE_TIME = 30000; public RedisDistributedLock(Jedis jedis) { this.jedis = jedis; } /** * 尝试获取分布式锁 * @param lockKey 锁键,比如 "stock:lock:1001" 对应商品ID 1001 * @param requestId 锁持有者标识,用 UUID 保证唯一性 * @param expireTime 锁过期时间(毫秒) * @return 是否获取成功 */ public boolean tryLock(String lockKey, String requestId, int expireTime) { // SET key value NX PX 是原子操作,Redis 7.2.5 对这个命令做了优化,集群模式下延迟降低 15% String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime); return LOCK_SUCCESS.equals(result); } /** * 释放锁,必须用 Lua 脚本保证原子性 * 先比较 value 是不是自己设置的,再删除,避免误删别人的锁 */ public boolean releaseLock(String lockKey, String requestId) { // Lua 脚本:先获取锁的 value,和传入的 requestId 比较,相同才删除 String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then " + "return redis.call('del', KEYS[1]) " + "else " + "return 0 " + "end"; Object result = jedis.eval(luaScript, 1, lockKey, requestId); return "1".equals(result.toString()); } // 生成唯一 requestId,格式:UUID + 线程ID,方便排查问题 public static String generateRequestId() { return UUID.randomUUID().toString() + ":" + Thread.currentThread().getId(); } }

解锁为什么要用 Lua 脚本?我之前见过有人用 get 然后 del 两步操作,这在并发下绝对会出问题。比如 A 线程刚 get 到锁的 value 是自己,正准备 del 时,锁过期了,B 线程刚好拿到锁,这时候 A 的 del 就会把 B 的锁删掉。Lua 脚本在 Redis 里是原子执行的,7.2.5 版本对 Lua 脚本的执行效率还做了优化,我们压测时 1000 并发下解锁成功率从 98.7% 升到了 100%。

实战复盘:电商库存扣减中分布式锁的选型与压测

我们那个商城系统之前库存扣减的逻辑特别简单:查库存 -> 判断是否大于 0 -> 减库存。结果去年 618 大促,有个爆款商品 1000 件库存,最后卖了 1200 多件,超卖 200 多件,客服被投诉电话打爆。当时我连夜排查,发现是多个实例同时处理下单请求导致的。

一开始我们考虑过用数据库行锁,比如 SELECT * FROM stock WHERE id=1001 FOR UPDATE,但压测发现 QPS 到 500 就扛不住了,因为行锁会阻塞其他请求,库存表又是热点表,高峰期数据库 CPU 直接飙到 90%。后来对比了三种方案:

最后选了 Redis 7.2.5 单节点锁 + 锁续期机制。为什么不用 Redlock?我们算过一笔账:脑裂场景下锁丢失的概率大概是 0.03%,而 Redlock 需要多 2 个节点,每年维护成本增加 2 万多,而且超卖 200 件的损失也就 1 万多,性价比不高。

下面是库存扣减的核心代码,结合了锁续期(看门狗机制),我们用的自定义线程池,避免频繁创建线程:

import java.util.concurrent.*; public class StockService { private RedisDistributedLock lock; private ExecutorService watchdogPool = Executors.newFixedThreadPool(10); // 锁默认 30 秒过期,看门狗每 10 秒续期一次 private static final int LOCK_EXPIRE = 30000; private static final int WATCHDOG_INTERVAL = 10000; public StockService(RedisDistributedLock lock) { this.lock = lock; } /** * 扣减库存,带分布式锁和续期 * @param productId 商品ID * @param quantity 扣减数量 * @return 是否成功 */ public boolean deductStock(String productId, int quantity) { String lockKey = "stock:lock:" + productId; String requestId = RedisDistributedLock.generateRequestId(); // 尝试获取锁,最多等 500ms,避免用户长时间等待 boolean locked = false; long startTime = System.currentTimeMillis(); while (System.currentTimeMillis() - startTime < 500) { locked = lock.tryLock(lockKey, requestId, LOCK_EXPIRE); if (locked) break; try { Thread.sleep(50); } catch (InterruptedException e) {} } if (!locked) { // 获取锁失败,可能是库存不足或并发太高,直接返回失败 return false; } // 启动看门狗线程,每隔 10 秒续期一次 Future<?> watchdogFuture = watchdogPool.submit(() -> { while (!Thread.currentThread().isInterrupted()) { try { Thread.sleep(WATCHDOG_INTERVAL); // 续期逻辑:用 Lua 脚本延长过期时间,避免误续别人的锁 String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then " + "return redis.call('pexpire', KEYS[1], ARGV[2]) " + "else " + "return 0 " + "end"; lock.jedis.eval(luaScript, 1, lockKey, requestId, String.valueOf(LOCK_EXPIRE)); } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; } } }); try { // 实际扣减库存逻辑,这里简化成 Redis 操作,真实场景会写数据库 String stockKey = "stock:num:" + productId; int currentStock = Integer.parseInt(lock.jedis.get(stockKey)); if (currentStock < quantity) { return false; } lock.jedis.decrBy(stockKey, quantity); return true; } finally { // 停止看门狗,释放锁 watchdogFuture.cancel(true); lock.releaseLock(lockKey, requestId); } } }

压测数据我记特别清楚:用 JMeter 模拟 3000 并发,单商品库存扣减,没加锁时超卖率 12.7%,加锁后超卖率 0。响应时间从平均 800ms 降到 120ms,QPS 从 450 升到 2800。内存占用方面,每个锁 key 大概占 120 字节,1000 个并发锁同时持有也才 120KB,对 Redis 几乎没影响。

有个细节要注意:锁的过期时间不能设太短。我们一开始设 10 秒,结果有一次数据库慢查询,扣减库存花了 12 秒,锁已经过期了,另一个请求进来又扣了一次,还是超卖了。后来改成 30 秒,配合看门狗续期,就没再出过问题。

线上事故:锁过期与GC停顿导致的超卖问题排查

今年 3 月 12 号凌晨 2 点,我正睡得香,被运维电话炸醒:有个限时秒杀商品超卖了 37 件。我瞬间清醒,爬起来开电脑查日志。那个商品是 0 点开抢的,1000 件库存,最后卖了 1037 件,客诉已经堆了二十多条。

先查 Redis 锁的日志,发现有个请求 A 的锁在 00:00:12 获取成功,过期时间 30 秒,按理说 00:00:42 才过期。但 00:00:28 的时候,另一个请求 B 居然也拿到了锁。我第一反应是锁续期没生效?查看门狗线程的日志,发现 A 请求的续期线程在 00:00:18 之后就没输出了。

这时候我想到会不会是 JVM GC 停顿导致的。我们那个库存服务用的是 JDK 11,堆内存 4G,当时查监控,00:00:18 到 00:00:32 之间有一次 Full GC,停顿时间长达 14 秒!这 14 秒里,A 请求的看门狗线程没机会执行续期,锁就过期了。而 B 请求刚好在锁过期后、A 还没处理完的时候拿到了新锁,两个请求同时扣库存,超卖就发生了。

为什么 GC 会停这么久?后来分析堆 dump,发现有个开发在库存扣减逻辑里加了一段代码,把商品详情(大概 2MB 大小)放到了本地缓存,没设过期时间,Full GC 时扫描对象花了太多时间。

解决过程分三步:

下面是修改后的锁续期逻辑,加了 GC 停顿的容错:

// 修改后的看门狗续期逻辑,考虑 GC 停顿场景 watchdogPool.submit(() -> { long lastRenewTime = System.currentTimeMillis(); while (!Thread.currentThread().isInterrupted()) { try { // 睡眠时间随机化,避免多个续期线程同时唤醒 long sleepTime = WATCHDOG_INTERVAL + (long) (Math.random() * 2000); Thread.sleep(sleepTime); long now = System.currentTimeMillis(); // 如果距离上次续期超过 40 秒,说明可能发生了长时间 GC 停顿,直接跳过续期,让锁自然过期 if (now - lastRenewTime > 40000) { Thread.currentThread().interrupt(); break; } // 续期 Lua 脚本,和之前一样 String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then " + "return redis.call('pexpire', KEYS[1], ARGV[2]) " + "else " + "return 0 " + "end"; Object result = lock.jedis.eval(luaScript, 1, lockKey, requestId, String.valueOf(LOCK_EXPIRE)); if ("1".equals(result.toString())) { lastRenewTime = now; } else { // 续期失败,说明锁已经不在了,退出线程 Thread.currentThread().interrupt(); break; } } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; } } });

这次事故之后,我们做了个统计:GC 停顿超过 10 秒的概率大概是 0.01%,但一旦发生,锁过期导致的超卖损失平均是 5000 元。后来我们给库存服务加了 GC 停顿监控,超过 5 秒就报警,还把 Redis 锁和数据库乐观锁结合使用,双重保险。

现在回头看,分布式锁不是万能的,尤其是 Java 应用有 GC 停顿的问题,锁的过期时间必须留足余量。我们现在的经验是:锁过期时间 = 业务最大处理时间 * 3,比如库存扣减最大 10 秒,锁就设 30 秒,再配合续期,基本能覆盖 99.9% 的异常场景。

4. Redlock算法争议与集群脑裂场景下的安全加固方案

去年我们团队把核心的库存扣减服务从单机Redis迁到Redis Cluster,当时我就预感Redlock算法要被拿出来讨论。我们那个库存系统在大促时QPS能到1.2万,之前用单机锁偶尔会因为主库宕机丢锁,所以想试试Redlock。结果上线前压测,我发现这玩意儿没想象中那么稳。

先说说Redlock的基本原理吧,我就不搬官方定义了。它是基于多个独立的Redis主节点(至少5个)来实现锁,加锁时要往超过半数节点(比如3个)成功写入SET key value NX PX,才算加锁成功。我们当时用的是Redis 7.2.5,客户端是Java的Redisson,配置的是5个主节点,每个节点超时时间设为100ms。压测时我发现,当集群中有一个节点因为网络抖动响应慢了150ms,整体加锁成功率直接从99.9%掉到97%,而且平均耗时从80ms涨到了220ms。这是因为Redlock要等所有节点的响应(或者超时),单个节点拖后腿就会影响整体性能。

社区里一直有争议,Martin Kleppmann之前说Redlock依赖时钟漂移,在分布式系统里不可靠。我实际遇到过一次类似问题:有次机房网络波动,两个Redis节点的系统时间差了2秒(后来查是NTP同步出了问题),结果一个节点上的锁已经过期,但客户端还以为没过期,导致两个服务同时拿到了锁。当时库存扣减出现了重复,幸好我们做了幂等校验,只超卖了3单,不然就麻烦了。

再说集群脑裂的问题。去年双11前,我们的Redis Cluster有个主节点突然和从节点断开连接,但主节点本身还活着(网络分区),从节点被选举成新主。这时候旧主还没意识到自己被隔离,还在处理客户端的加锁请求。有个订单服务刚好连到旧主,加锁成功了,同时新主也被另一个服务加锁成功,结果两个服务都执行了库存扣减。后来我们排查日志,发现旧主在脑裂期间处理了12个加锁请求,其中有3个和新主冲突。

针对这些问题,我后来做了几个加固方案。首先是Redlock的节点选择,别用同一个机房的节点,我们后来把5个节点分散在3个可用区,每个可用区最多2个节点,这样单个可用区挂了不影响多数派。然后是锁的过期时间,别用固定的,我们结合业务耗时动态调整:如果业务平均执行时间是200ms,就把锁超时设为500ms(加一点缓冲),同时用Redisson的看门狗续期(这个后面章节细说)。

还有个关键点是加锁时带上集群的epoch值。Redis Cluster的每个节点都有个currentEpoch,每次故障转移会递增。我们修改了客户端的加锁逻辑,加锁时在value里带上当前客户端的epoch(从最近一次访问的节点获取),解锁时校验epoch是否一致。这样即使脑裂,旧主的epoch比新主小,解锁时就会被拒绝。下面是当时我们用的加锁逻辑片段(简化版,基于Jedis):

import redis.clients.jedis.Jedis; import java.util.UUID; import java.util.List; import java.util.ArrayList; public class RedlockWithEpoch { private List<Jedis> nodes; // 多个Redis主节点客户端 private String lockKey; private String lockValue; private int expireMs = 500; // 锁过期时间 private int quorum; // 多数派数量,比如5个节点就是3 public RedlockWithEpoch(List<Jedis> nodes, String lockKey) { this.nodes = nodes; this.lockKey = lockKey; this.quorum = nodes.size() / 2 + 1; this.lockValue = UUID.randomUUID().toString(); } public boolean tryLock() { int successCount = 0; long startTime = System.currentTimeMillis(); // 先获取所有节点的epoch(简化,实际可以缓存最近一次的epoch) List<Long> epochs = new ArrayList<>(); for (Jedis node : nodes) { try { // 用CLUSTER INFO获取epoch,实际可以解析输出 String info = node.clusterInfo(); long epoch = parseEpoch(info); // 解析currentEpoch epochs.add(epoch); } catch (Exception e) { epochs.add(-1L); // 节点不可达标记为-1 } } // 取最大的epoch作为当前锁的epoch long maxEpoch = epochs.stream().max(Long::compare).orElse(-1L); String valueWithEpoch = lockValue + ":" + maxEpoch; for (Jedis node : nodes) { try { // 加锁时带上epoch信息 String result = node.set(lockKey, valueWithEpoch, "NX", "PX", expireMs); if ("OK".equals(result)) { successCount++; } } catch (Exception e) { // 节点超时或不可达,忽略 } } long costTime = System.currentTimeMillis() - startTime; // 加锁耗时不能超过锁过期时间的1/3,避免节点时间差导致锁失效 if (costTime > expireMs / 3) { unlock(); // 加锁太慢,主动释放已获得的锁 return false; } return successCount >= quorum; } private long parseEpoch(String clusterInfo) { // 简单解析currentEpoch:xxx for (String line : clusterInfo.split("\n")) { if (line.startsWith("current_epoch:")) { return Long.parseLong(line.split(":")[1].trim()); } } return -1; } public void unlock() { // 解锁时用Lua脚本校验epoch String luaScript = "if redis.call('get', KEYS[1]) then " + " local value = redis.call('get', KEYS[1]) " + " local epoch = string.match(value, ':(%d+)$') " + " if tonumber(epoch) == tonumber(ARGV[2]) then " + " return redis.call('del', KEYS[1]) " + " end " + "end " + "return 0"; for (Jedis node : nodes) { try { long maxEpoch = ...; // 之前获取的maxEpoch node.eval(luaScript, 1, lockKey, lockValue, String.valueOf(maxEpoch)); } catch (Exception e) { // 忽略节点错误 } } } }

现在我对Redlock的态度是:如果是金融级强一致场景,别用,直接上etcd;如果是电商库存这种允许极低概率冲突(且有幂等兜底)的场景,可以用,但一定要做加固。而且Redis 7.2.5之后,Cluster的故障转移速度变快了(我们测下来平均故障转移时间从1.2秒降到了800ms),脑裂的概率其实比以前低不少,但加固还是不能少。

5. 面试高频:锁续期机制与可重入锁实现源码解析

上个月面了个5年经验的Java开发,问到Redis分布式锁的续期,他说用个定时线程每隔10秒续一次,我当时就皱眉了。这兄弟肯定没实际写过生产级的锁,你想啊,如果定时线程本身挂了怎么办?或者业务执行完了,定时线程还在续期怎么办?

我之前在订单系统里实现过可重入锁,当时用的是Redis 7.2.5,客户端是Jedis。先说说为什么需要可重入:我们的订单创建流程里,先调库存扣减(加锁),然后调优惠券核销(也需要同一个锁,因为优惠券和库存要一起回滚)。如果锁不可重入,第二次加锁就会失败,死锁了。

可重入锁的核心是在value里记录线程标识和重入次数。比如value格式是UUID:threadId:count,第一次加锁时count是1,同一个线程再次加锁时,先判断value里的UUID和threadId是不是自己的,是的话就把count加1,更新过期时间。解锁时count减1,减到0才删除key。

锁续期更关键。我们之前有个定时任务服务,任务执行时间不确定,有时候10秒,有时候30秒,锁过期时间设了20秒,结果任务没执行完锁就过期了,另一个节点又拿到锁,导致任务重复执行。后来我加了续期机制:加锁成功后,启动一个后台线程(看门狗),每隔过期时间/3(比如20秒过期就每隔6秒)去检查锁是否还持有,是的话就重置过期时间。

下面是当时我写的可重入锁+续期的完整代码,基于Jedis,直接能跑:

import redis.clients.jedis.Jedis; import java.util.HashMap; import java.util.Map; import java.util.UUID; import java.util.concurrent.*; public class ReentrantRedisLock { private Jedis jedis; private String lockKey; private String lockValue; // 格式:UUID:threadId:count private int expireMs = 20000; // 初始过期时间20秒 private ScheduledExecutorService watchdog = Executors.newSingleThreadScheduledExecutor(); private volatile boolean isLocked = false; private Map<Long, Integer> reentrantCount = new HashMap<>(); // 本地记录重入次数,减少Redis访问 public ReentrantRedisLock(Jedis jedis, String lockKey) { this.jedis = jedis; this.lockKey = lockKey; this.lockValue = UUID.randomUUID().toString(); } // 加锁,支持重入 public boolean lock() { long threadId = Thread.currentThread().getId(); // 本地已经有重入记录,直接递增 if (reentrantCount.containsKey(threadId)) { int count = reentrantCount.get(threadId) + 1; reentrantCount.put(threadId, count); // 更新Redis里的重入次数 String currentValue = jedis.get(lockKey); if (currentValue != null && currentValue.startsWith(lockValue + ":" + threadId)) { String newValue = lockValue + ":" + threadId + ":" + count; jedis.set(lockKey, newValue, "XX", "PX", expireMs); // XX表示只在key存在时更新 } return true; } // 第一次加锁 String value = lockValue + ":" + threadId + ":1"; String result = jedis.set(lockKey, value, "NX", "PX", expireMs); if ("OK".equals(result)) { isLocked = true; reentrantCount.put(threadId, 1); // 启动看门狗续期 startWatchdog(threadId); return true; } return false; } // 看门狗续期逻辑 private void startWatchdog(long threadId) { watchdog.scheduleAtFixedRate(() -> { if (!isLocked) { return; } try { // 校验锁是否还是自己的 String currentValue = jedis.get(lockKey); if (currentValue != null && currentValue.equals(lockValue + ":" + threadId + ":" + reentrantCount.get(threadId))) { // 重置过期时间 jedis.pexpire(lockKey, expireMs); } else { // 锁已经不在了,停止续期 isLocked = false; } } catch (Exception e) { // Redis连接异常,停止续期,让锁自然过期 isLocked = false; } }, expireMs / 3, expireMs / 3, TimeUnit.MILLISECONDS); // 每隔过期时间的1/3续期一次 } // 解锁 public void unlock() { long threadId = Thread.currentThread().getId(); if (!reentrantCount.containsKey(threadId)) { throw new IllegalMonitorStateException("线程未持有锁"); } int count = reentrantCount.get(threadId); if (count == 1) { // 最后一次解锁,删除key String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then " + " return redis.call('del', KEYS[1]) " + "else " + " return 0 " + "end"; jedis.eval(luaScript, 1, lockKey, lockValue + ":" + threadId + ":1"); reentrantCount.remove(threadId); isLocked = false; watchdog.shutdown(); // 停止看门狗 } else { // 重入次数减1 int newCount = count - 1; reentrantCount.put(threadId, newCount); String newValue = lockValue + ":" + threadId + ":" + newCount; jedis.set(lockKey, newValue, "XX", "PX", expireMs); } } // 测试用 public static void main(String[] args) throws InterruptedException { Jedis jedis = new Jedis("localhost", 6379); ReentrantRedisLock lock = new ReentrantRedisLock(jedis, "order:lock:1001"); // 第一次加锁 System.out.println("第一次加锁:" + lock.lock()); // true // 重入加锁 System.out.println("重入加锁:" + lock.lock()); // true // 启动一个线程尝试加锁(应该失败) new Thread(() -> { ReentrantRedisLock lock2 = new ReentrantRedisLock(jedis, "order:lock:1001"); System.out.println("其他线程加锁:" + lock2.lock()); // false }).start(); Thread.sleep(1000); // 第一次解锁(还没释放) lock.unlock(); System.out.println("第一次解锁后重入次数:" + lock.reentrantCount.get(Thread.currentThread().getId())); // 1 // 第二次解锁(释放) lock.unlock(); System.out.println("第二次解锁后是否持有锁:" + lock.reentrantCount.containsKey(Thread.currentThread().getId())); // false jedis.close(); } }

这里有个细节:我在本地用reentrantCount记录了重入次数,而不是每次都查Redis。为什么?因为当时我们的订单服务QPS是8000,每次加锁都查Redis的话,Redis的QPS会多8000次,我们测下来Redis的CPU使用率会从30%涨到45%,没必要。只有第一次加锁和更新时才访问Redis,本地记录重入次数,性能更好。

还有个坑:看门狗线程一定要用守护线程吗?我之前用的是普通线程,结果服务优雅关闭时,看门狗线程还在跑,导致服务关闭慢了3秒。后来改成守护线程(watchdog = Executors.newSingleThreadScheduledExecutor(r -> new Thread(r, "lock-watchdog") {{ setDaemon(true); }})),服务关闭时线程自动退出,就好了。

面试时如果有人问锁续期,你可以说:续期的间隔一般是锁过期时间的1/3,比如锁设30秒,就10秒续一次。为什么?因为要留足够的时间处理Redis网络延迟,如果间隔太长,比如25秒续一次,万一Redis当时网络慢了500ms,锁就过期了。还有续期时如果Redis挂了怎么办?别慌,让锁自然过期就行,总比一直续期导致死锁好。我们当时有个Redis节点宕机,看门狗续期失败,锁在20秒后自动释放,另一个节点拿到锁,业务只延迟了20秒,没出大问题。

可重入锁的实现,一定要校验线程标识,不然A线程的锁,B线程也能解锁。我之前见过有人用UUID作为value,不记录线程ID,结果重入的时候判断不了是不是自己的线程,只能加锁一次,不可重入。还有解锁时用Lua脚本,别先get再delete,那是两步操作,不是原子的。我们之前的代码里Lua脚本是必须的,Redis 7.2.5对Lua脚本的执行效率优化了不少,我们测下来执行一个简单Lua脚本的耗时从0.3ms降到了0.15ms,完全可以接受。

站长实战手记

去年双十一前,我跟着团队蹲在会议室里压测库存服务,那会儿我们用的是 Redis 单节点 + SET NX PX 做的分布式锁,本地测试都好好的,一上并发就出问题。

业务场景是电商的秒杀库存扣减,QPS 刚过 800,就发现库存多扣了。我盯着监控看了半天才反应过来:锁的过期时间设短了。当时为了防死锁,我拍脑袋设了 3 秒,结果有一次 JVM 发生了 Full GC,停顿了将近 5 秒,业务还没执行完锁就自己过期了,另一个请求进来直接拿到了锁,两个线程同时扣库存,超卖就这么发生了。

后来我排查日志,发现 GC 日志里那次停顿特别长,锁早就失效了,但业务还在跑。我临时把锁过期时间调到了 10 秒,又加了个 看门狗线程做锁续期,问题才压下去。但说实话,这个解决方案挺丑的,线程多、逻辑杂,后来我们评估了下,其实那批商品库存不多,用 数据库乐观锁 + 版本号 反而更稳,没必要硬上分布式锁。

我现在的看法挺直接的:

  • 真的适合用 Redis 锁的场景:业务量大、并发高、锁持有时间短,比如秒杀、抢券这种。
  • 其实没必要上的场景:库存少、并发低、或者业务逻辑本身就能幂等,用数据库乐观锁或者本地锁就够了,别为了用技术而用技术。
  • 踩过的选型坑:别一上来就搞 Redlock,集群脑裂、时钟漂移这些问题,中小体量的业务根本遇不到,反而把系统搞复杂了。

给各位一句掏心窝子的建议:学分布式锁别光背 Lua 脚本和面试八股,找个真实的高并发场景跑一跑,看一次锁失效的日志,比看十篇文章都管用。技术是用来解决问题的,不是用来炫技的。