真实事故复盘:Redis分布式锁居然失效了?公司十年老程序员踩的坑

引言

最近项目中有个订单相关的业务,并发量比较高,需要加分布式锁来保证数据一致性。按理说,这种场景我们一般都直接用 Redisson,成熟稳定,也没什么坑。

但是老丁坚持不用现成的轮子,说自己用 Lua 脚本封装一个更轻量的锁,简单又可靠。

作为一个有着十年经验的高级Java开发,大家也就没说什么,让他去发挥了。

结果差点出了大事,往下看。

正文

老丁的杰作

老丁很快就写出了他的分布式锁实现:

  • Lua 脚本
lua 复制代码
-- 加锁脚本
if (redis.call('setnx', KEYS[1], ARGV[1]) == 1) then 
    return redis.call('expire', KEYS[1], ARGV[2]); 
else 
    return 0; 
end

-- 解锁脚本  
if (redis.call('get', KEYS[1]) == ARGV[1]) then 
    return redis.call('del', KEYS[1]); 
else 
    return 0; 
end
  • Java 封装

这里仅提供关键代码,多余的都做删减处理。

java 复制代码
public class LockUtil {
    
    private static final String REDIS_FLAG_VALUE = "1";  // 注意这个1
    
    private static final String LOCK_LUA_SCRIPT = 
        "if (redis.call('setnx', KEYS[1], ARGV[1]) == 1) then " +
        "return redis.call('expire', KEYS[1], ARGV[2]); " +
        "else return 0; end";
    
    private static final String UNLOCK_LUA_SCRIPT = 
        "if (redis.call('get', KEYS[1]) == ARGV[1]) then " +
        "return redis.call('del', KEYS[1]); " +
        "else return 0; end";

    public boolean lock(String lockKey, long expireTime, TimeUnit unit) {
        DefaultRedisScript<Boolean> redisScript = new DefaultRedisScript<>(LOCK_LUA_SCRIPT, Boolean.class);
        long expireSeconds = unit.toSeconds(expireTime);
        return Boolean.TRUE.equals(redisTemplate.execute(redisScript, 
            Collections.singletonList(lockKey), REDIS_FLAG_VALUE, expireSeconds));
    }

    public void unLock(String lockKey) {
        DefaultRedisScript<Boolean> redisScript = new DefaultRedisScript<>(UNLOCK_LUA_SCRIPT, Boolean.class);
        redisTemplate.execute(redisScript, Collections.singletonList(lockKey), REDIS_FLAG_VALUE);
    }
}

然后他把锁用到了业务代码里,大概长这样:

java 复制代码
public void processOrder(Long orderId) {
    String lockKey = "order_lock_" + orderId;

    // 上锁
    boolean locked = lockUtil.lock(lockKey, 30, TimeUnit.SECONDS);
    if (!locked) {
        throw new BusinessException("系统繁忙,请稍后重试");
    }

    try {
        // 再次检查锁状态
        if (!isLocked(lockKey)) {
            throw new BusinessException("锁已失效");
        }

        // 业务逻辑
        doBusinessLogic(orderId);

    } catch (Exception e) {
        log.error("处理订单异常", e);
        throw e;
    } finally {
        // 解锁
        lockUtil.unLock(lockKey);
    }
}

看起来似乎一切正常,代码 review 的时候大家也没太细看。

毕竟是老丁写的,十年老司机,大家都放心的很。

但是此刻看文章的你,是不是发现了不对劲的地方?

没错。

上线提测后不久,压测一跑,出问题了。

问题暴露

代码提测后,压测很快就出了问题。我们来看看日志:

less 复制代码
2025-06-19 10:30:01.123 [thread-1] INFO  - 分布式锁 上锁order_lock_1001
2025-06-19 10:30:01.124 [thread-1] INFO  - 订单1001开始处理,获取锁成功
2025-06-19 10:30:01.125 [thread-2] INFO  - 分布式锁 上锁order_lock_1001
2025-06-19 10:30:01.126 [thread-2] ERROR - 处理订单异常: BusinessException: 系统繁忙,请稍后重试
2025-06-19 10:30:01.127 [thread-2] INFO  - 分布式锁 解锁order_lock_1001
2025-06-19 10:30:01.128 [thread-3] INFO  - 分布式锁 上锁order_lock_1001
2025-06-19 10:30:01.129 [thread-3] INFO  - 订单1001开始处理,获取锁成功  // 问题出现!
2025-06-19 10:30:01.130 [thread-1] INFO  - 订单1001业务逻辑执行完成
2025-06-19 10:30:01.131 [thread-1] INFO  - 分布式锁 解锁order_lock_1001

看到了吗?时间线是这样的:

  1. 10:30:01.123 - 线程1获取锁成功,开始处理业务
  2. 10:30:01.125 - 线程2尝试获取锁失败,抛出异常
  3. 10:30:01.127 - 线程2在 finally 中释放了锁!
  4. 10:30:01.128 - 线程3获取锁成功,开始处理业务
  5. 10:30:01.130 - 线程1业务逻辑执行完成
  6. 10:30:01.131 - 线程1也释放锁

问题很明显:线程2根本没有获得锁,却在 finally 中释放了线程1的锁,导致线程3可以获取锁。

于是线程3和线程1同时处理了同一订单。

问题很明显了。

老丁犯了两个非常低级的错误:

两个低级问题

  1. 所有线程使用相同的锁值
java 复制代码
private static final String REDIS_FLAG_VALUE = "1";  // 所有线程都用这个值!

再来回顾一下线程日志:

  1. 线程1获取锁SETNX order_lock_1001 "1" → 成功,Redis中存储 order_lock_1001: "1"
  2. 线程2尝试获取锁SETNX order_lock_1001 "1" → 失败,key已存在
  3. 线程2抛异常进入finally:调用解锁
  4. 线程2执行解锁脚本
lua 复制代码
if (redis.call('get', 'order_lock_1001') == '1') then  -- 返回true,因为值确实是"1"
    return redis.call('del', 'order_lock_1001');      -- 删除了线程1的锁!
end
  1. 线程3获取锁SETNX order_lock_1001 "1" → 成功,因为key已被删除

问题核心 :所有线程使用相同的锁值 "1" ,导致任何线程都能删除其他线程的锁。

  1. 异常处理逻辑错误
java 复制代码
try {
    if (!isLocked(lockKey)) {
        throw new BusinessException("锁已失效");  // 抛异常
    }
    // 业务逻辑
} finally {
    lockUtil.unLock(lockKey);  // 即使try中抛异常,finally依然执行
}

即使没有获取到锁,在抛异常后finally块依然会执行,导致错误的解锁操作。

修复问题

现在我们沿用老丁的代码,做一个最简单的修复:

  1. 使用唯一的锁值
java 复制代码
public class LockUtil {
    
    public boolean lock(String lockKey, long expireTime, TimeUnit unit) {
        String uniqueValue = Thread.currentThread().getName() + "_" + System.currentTimeMillis();
        // 或者使用: String uniqueValue = UUID.randomUUID().toString();
        
        DefaultRedisScript<Boolean> redisScript = new DefaultRedisScript<>(LOCK_LUA_SCRIPT, Boolean.class);
        long expireSeconds = unit.toSeconds(expireTime);
        
        boolean locked = Boolean.TRUE.equals(redisTemplate.execute(redisScript, 
            Collections.singletonList(lockKey), uniqueValue, expireSeconds));
        
        if (locked) {
            // 将锁值存储到ThreadLocal中,供解锁时使用
            lockValueHolder.set(uniqueValue);
        }
        return locked;
    }

    public void unLock(String lockKey) {
        String lockValue = lockValueHolder.get();
        if (lockValue != null) {
            DefaultRedisScript<Boolean> redisScript = new DefaultRedisScript<>(UNLOCK_LUA_SCRIPT, Boolean.class);
            redisTemplate.execute(redisScript, Collections.singletonList(lockKey), lockValue);
            lockValueHolder.remove();
        }
    }
    
    private static final ThreadLocal<String> lockValueHolder = new ThreadLocal<>();
}
  1. 修正业务逻辑
java 复制代码
public void processOrder(Long orderId) {
    String lockKey = "order_lock_" + orderId;
    boolean lockAcquired = false;
    
    try {
        // 上锁
        lockAcquired = lockUtil.lock(lockKey, 30, TimeUnit.SECONDS);
        if (!lockAcquired) {
            throw new BusinessException("系统繁忙,请稍后重试");
        }
        
        // 业务逻辑
        doBusinessLogic(orderId);
        
    } catch (Exception e) {
        log.error("处理订单异常", e);
        throw e;
    } finally {
        // 只有成功获取锁的线程才能释放锁
        if (lockAcquired) {
            lockUtil.unLock(lockKey);
        }
    }
}

当然还有另一种修复方案,直接使用redisson

java 复制代码
RLock lock = redissonClient.getLock("order:" + orderId);
boolean locked = lock.tryLock(5, 30, TimeUnit.SECONDS);
try {
    if (!locked) {
        throw new RuntimeException("获取锁失败");
    }
    // 业务逻辑
} finally {
    if (lock.isHeldByCurrentThread()) {
        lock.unlock();
    }
}

解锁前再多加一层判断:isHeldByCurrentThread(),防止误删别人的锁。

问题复盘

这些问题,其实 Redisson 早就帮我们解决了:

  • 内置 UUID 隔离
  • 自动续期机制
  • 可重入锁、看门狗机制
  • 非阻塞获取锁方法 + 公平锁策略

写再多 Lua,也很难考虑周全这些场景。

尤其在多线程高并发、自动续约、锁抢占等复杂情况下,自己封装的玩具锁真扛不住压测。

很多时候,造轮子 并不是问题,问题是你有没有那个能力和时间去造好一个轮子,尤其还是在一家业务很忙的中型互联网公司,其实是没有那么多时间来验证一个轮子的可靠性。

虽然我们不排斥造轮子,也欢迎每位开发深耕技术,但前提还是要优先服务好业务。

换个角度,这次事件又不只是技术问题,也是一次团队管理的教训:

  • Code Review 流程过于依赖资深程序员的信誉,缺乏真正有效的审查;
  • 没有对关键链路提前做并发压测;
  • 容易被经验主义带偏,以为经验丰富了就不会错。

但现实是:谁都会犯错。

真正稳定的技术方案,靠的是足够多的验证和失败教训积累出来的,而不是某一个人或某几个人的多少年的经验。

虽然世界就是个巨大的草台班子,但我们还是要尽量把这个班子夯实。

最后

还好提测阶段就发现了问题,没有影响生产。

如果到了生产环境,后果不堪设想。

吸取教训,继续前行。

毕竟,从失败中学习,才是程序员成长的最佳方式。

技术没有完人,流程不能靠人。

就像某位大师说的: "别造一个车轮,结果发现它是方的。"

如果你也遇到类似的问题,别忘了问自己一句:

你写的锁,是能抗住并发的吗?

相关推荐
超级小忍28 分钟前
JVM 中的垃圾回收算法及垃圾回收器详解
java·jvm
weixin_4461224631 分钟前
JAVA内存区域划分
java·开发语言·redis
Piper蛋窝1 小时前
深入 Go 语言垃圾回收:从原理到内建类型 Slice、Map 的陷阱以及为何需要 strings.Builder
后端·go
勤奋的小王同学~1 小时前
(javaEE初阶)计算机是如何组成的:CPU基本工作流程 CPU介绍 CPU执行指令的流程 寄存器 程序 进程 进程控制块 线程 线程的执行
java·java-ee
TT哇1 小时前
JavaEE==网站开发
java·redis·java-ee
2401_826097621 小时前
JavaEE-Linux环境部署
java·linux·java-ee
缘来是庄2 小时前
设计模式之访问者模式
java·设计模式·访问者模式
Bug退退退1232 小时前
RabbitMQ 高级特性之死信队列
java·分布式·spring·rabbitmq
梵高的代码色盘2 小时前
后端树形结构
java
代码的奴隶(艾伦·耶格尔)2 小时前
后端快捷代码
java·开发语言