生产环境挂机2小时:我对分布式锁的3次“痛彻领悟”

曾几何时,我认为分布式锁不过是 Redis 的一行命令。直到2021年的那个双十一预热活动,我们的库存中心在流量高峰期突然“假死”,整个系统像被施了定身术,重启服务都无济于事。

那次事故导致核心业务停摆近2小时,老板在钉钉群里的每一个问号,都像敲在我头上的重锤。

复盘当晚,我盯着屏幕上看似完美的 SETNX 代码发呆。作为负责中小团队技术选型的架构师,我甚至一度陷入自我怀疑:为什么教科书式的写法,到了真实的高并发战场上却不堪一击?

配图

这也是很多资深开发容易陷入的误区:把“实现了功能”等同于“生产可用”。

在这篇文章里,我不讲枯燥的 CAP 理论,只想和你分享我在实战中踩过的三个深坑,以及我和团队是如何填坑的。

配图

坑一:原子性的“薛定谔”陷阱

那是项目早期的代码。为了防止商品超卖,我们在下单接口加了锁。当时的逻辑简单粗暴:

// 伪代码:当时的“教科书”式写法
if (redis.setnx(lockKey, value) == 1) {
    redis.expire(lockKey, 30); // 设置30秒过期,防止死锁
    try {
        // 执行业务逻辑
    } finally {
        redis.del(lockKey);
    }
}

这段代码跑了半年没出事,直到那次服务器因负载过高突然宕机。

事故现场: 宕机发生的时间点,偏偏卡在 setnx 执行成功之后,expire 执行之前。这意味着,这个锁没有过期时间!当服务重启后,新请求进来发现锁一直存在(死锁),所有相关业务全部卡死。我们需要手动去 Redis 里一个个删除 Key 才恢复了业务。

深度反思: 在分布式环境下,“加锁”和“设置超时”必须是原子的。只要它们是两条指令,就存在中间状态失效的风险。

落地方法: 对于中小团队,不要自己造轮子去拼接命令。现在的我,会在代码 Review 规范中强制要求:必须使用 Lua 脚本或成熟的客户端(如 Redisson)来保证原子性。

如果你还在裸写 Redis 命令,请立即替换为如下逻辑(Lua 脚本示例):

if redis.call('setnx', KEYS[1], ARGV[1]) == 1 then
    return redis.call('expire', KEYS[1], ARGV[2])
else
    return 0
end

坑二:锁不住的“幽灵”与误删

原子性问题解决后,我们以为万事大吉。但在2022年的一次月度财务报表生成任务中,数据又出错了。

事故现场: 财务同事拿着两份报表找我:“为什么同一笔订单,在两个时间段都被统计了?”

排查日志发现,这是一个长耗时任务。

  1. 线程 A 获取锁,设置过期时间 30秒。
  2. 因为数据库抖动,线程 A 的业务执行了 45秒。
  3. 在第 30秒 时,锁自动过期。
  4. 线程 B 趁机获取了新锁,开始执行业务。
  5. 第 45秒,线程 A 执行完毕,执行 DEL 操作。重点来了:它删除的不是自己的锁,而是线程 B 刚刚加上去的锁!
  6. 线程 C 随即进入…

这就造成了连锁反应,锁形同虚设,多个线程在裸奔,我们称之为“幽灵写入”。

深度反思: 这个坑告诉我们两点:

  1. 锁的过期时间很难评估准确(网络波动、GC 都会影响耗时)。
  2. 解铃还须系铃人,线程绝对不能删除别人的锁。

落地方法: 这是我看重 Redisson 的核心原因。它引入了一个“看门狗(Watchdog)”机制。

我现在的架构设计原则是:凡是耗时超过 500ms 的业务,禁止手动设置固定过期时间。

使用 Redisson 的默认配置,只要线程 A 的业务还没结束,Watchdog 后台线程就会每隔 10秒 自动把锁的超时时间“续命”到 30秒。直到业务结束,线程 A 显式释放锁。

// 使用 Redisson 避免手动维护过期时间
RLock lock = redisson.getLock("finance_report_lock");
lock.lock(); // 默认开启 Watchdog,自动续期
try {
   // 执行超长业务逻辑
} finally {
   lock.unlock(); // 只有持有锁的线程才能解锁
}

坑三:数据库事务与锁的“死亡拥抱”

这是最隐蔽、也是最容易被资深开发忽视的坑。

那是2023年初,我们在重构支付模块。代码逻辑看起来无懈可击:

@Transactional
public void updateOrder() {
    RLock lock = redisson.getLock("order_123");
    lock.lock();
    try {
        // 查询订单状态,防止并发修改
        Order order = orderMapper.selectById(123);
        if (order.getStatus() == PAID) return;
        
        order.setStatus(PAID);
        orderMapper.updateById(order);
    } finally {
        lock.unlock();
    }
}

即便加了锁,依然出现了少量的“订单状态覆盖”问题。为什么?

事故现场: 问题出在 @Transactionallock 的嵌套顺序上。

  1. 线程 A 获得锁,执行更新,释放锁。
  2. 注意: 此时 Spring 的事务还没有提交(事务提交是在方法结束后)。
  3. 线程 B 获得锁,读取数据库。
  4. 由于数据库的隔离级别(通常是 Read Committed 或 Repeated Read),线程 B 读到的依然是旧数据(因为线程 A 的事务还没提交)。
  5. 线程 B 再次执行更新逻辑。

深度反思: 分布式锁必须包裹在数据库事务的外层,而不是里层。锁的生命周期必须大于事务的生命周期,才能保证看到的数据是最终一致的。

落地方法: 我给团队定下的规矩是:Service 层严禁在事务方法内部加锁。

我们通常采用“门面模式”或手动控制事务粒度来解决:

// 正确做法:锁在事务之外
public void payOrderFacade(Long orderId) {
    RLock lock = redisson.getLock("order_" + orderId);
    lock.lock();
    try {
        // 调用事务方法
        orderService.updateOrderStatusInTransaction(orderId); 
    } finally {
        lock.unlock();
    }
}

这种改动虽然繁琐,需要拆分方法,但它从根源上杜绝了并发脏读的可能性。

结语

setnx 的盲目自信,到 Redisson 的拿来主义,再到对事务边界的精细控制,这也是我作为一个架构师的成长路径。

分布式锁从来不是一个简单的“工具使用”问题,而是对原子性、一致性、隔离性的综合考量。在中小团队,我们不需要 Google 级别的基础设施,但我们需要对每一行代码背后的代价心知肚明。

我现在的办公桌上贴着一张便利贴,每次评审代码时我都会问三个问题:

  1. 这把锁会死锁吗?(原子性)
  2. 业务跑久了锁会失效吗?(自动续期)
  3. 锁释放了,数据真的落库了吗?(事务边界)

最后,想听听你的看法:

在你的项目中,为了解决并发一致性,你更倾向于使用 A. 悲观锁(如 Redis 分布式锁) 还是 B. 乐观锁(如数据库版本号 Version)

欢迎在评论区留下你的选择(A 或 B)及理由。

给读者的3个行动建议:

  1. 全量扫描: 搜索代码中的 setnx 关键字,检查是否存在“加锁”与“设置过期”非原子操作的风险。
  2. 引入看门狗: 对于执行时间波动较大的定时任务,尝试引入 Redisson 或自行实现续期机制,替换掉硬编码的过期时间。
  3. 检查边界: 重点 Review 带有 @Transactional 注解的方法,确认分布式锁是否被错误地包裹在事务内部。