上周三凌晨,我盯着监控面板上的订单数据心跳加速——10分钟内的库存扣减记录里,同一个SKU居然出现了6次重复扣减。事后排查发现,正是团队里那个"看似靠谱"的Redis分布式锁实现,在流量突增时放行了并发请求。今天我们就来解剖这个差点酿成大祸的"伪锁"。
一、现象:锁了,但没完全锁
当时我们的秒杀系统正在做全链路压测,QPS刚突破3k时,监控突然报警库存异常。查看Redis锁日志时发现了诡异现象:
[16:23:45] 客户端A 获取锁成功: order_1234 (TTL=30s) [16:23:46] 客户端B 获取锁成功: order_1234 (TTL=30s) <-- WTF?两个不同的客户端,在1秒内先后拿到了同一个订单ID的锁。最终导致库存服务处理了完全相同的6个请求,商品超卖不说,财务对账时发现账户余额也出现了负数。
二、根因:自以为是的原子性
翻出当时"祖传"的锁实现代码,问题一目了然:
// 错误示范:三宗罪齐备的锁实现 public boolean tryLock(String key) { Long expireTime = System.currentTimeMillis() + 30000; if (redisTemplate.opsForValue().setIfAbsent(key, expireTime)) { // 罪1:非原子操作 redisTemplate.expire(key, 30, TimeUnit.SECONDS); // 罪2:非原子续期 return true; } Long oldExpireTime = (Long) redisTemplate.opsForValue().get(key); if (oldExpireTime != null && oldExpireTime < System.currentTimeMillis()) { redisTemplate.opsForValue().set(key, expireTime); // 罪3:竞态条件 return true; } return false; }这段代码犯了三个致命错误:
- SETNX+EXPIRE非原子:两个命令之间如果进程崩溃,会导致死锁
- 时间校验不可靠:客户端时钟不同步时可能误判锁过期
- 删除校验缺失:解锁时不做value比对,可能误删其他客户端持有的锁
三、救火:从Redlock到红皮书方案
当时紧急回滚后,我们测试了三种解决方案:
| 方案 | 吞吐量(QPS) | 强一致性 | 复杂度 |
|---|---|---|---|
| 原生SET NX PX | 12k | ❌ | ⭐ |
| Redlock | 5k | ✅ | ⭐⭐⭐ |
| 红皮书+续期线程 | 8k | ✅ | ⭐⭐ |
最终选择了《Redis设计与实现》推荐的改进方案:
// 正确姿势:Lua脚本保证原子性 String script = "if redis.call('setnx', KEYS[1], ARGV[1]) == 1 then " + " redis.call('pexpire', KEYS[1], ARGV[2]) " + " return 1 " + "else " + " return 0 " + "end"; Boolean success = redisTemplate.execute( new DefaultRedisScript<>(script, Boolean.class), Collections.singletonList(lockKey), clientId, // 必须用唯一值 30000 );关键改进点:
- 原子操作:用Lua脚本打包SETNX和EXPIRE
- 唯一标识:value使用clientId+线程ID,避免误删
- 续期机制:另起线程对未完成的锁定期续期
四、避坑指南:血泪换来的经验
- 时钟跳跃是大敌:NTP同步时可能导致锁提前过期,优先使用TTL而非时间戳
- 网络延迟会骗人:业务处理时间超过锁有效期时,CAP理论会教你做人
- 单点故障无解:Redis主从切换时的锁丢失问题,Redlock也救不了
- 锁重入要谨慎:相同线程重复获取锁时,Java的ReentrantLock思维会害了你
五、什么时候该用分布式锁?
经过这次事故,我们定下新规范:
- 读多写少场景 → 直接用CAS
- 低频写操作 → 数据库乐观锁
- 高频写且允许少量偏差 → 本地锁+延时队列
- 必须强一致时→ Redis锁+Zookeeper备份
现在看监控面板上的锁争用指标时总会多瞄两眼——你在项目里是怎么处理分布式锁的?有没有遇到过更诡异的锁失效案例?评论区聊聊你的实战经历。