引言
最近项目中有个订单相关的业务,并发量比较高,需要加分布式锁来保证数据一致性。按理说,这种场景我们一般都直接用 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
看到了吗?时间线是这样的:
- 10:30:01.123 - 线程1获取锁成功,开始处理业务
- 10:30:01.125 - 线程2尝试获取锁失败,抛出异常
- 10:30:01.127 - 线程2在 finally 中释放了锁!
- 10:30:01.128 - 线程3获取锁成功,开始处理业务
- 10:30:01.130 - 线程1业务逻辑执行完成
- 10:30:01.131 - 线程1也释放锁
问题很明显:线程2根本没有获得锁,却在 finally 中释放了线程1的锁,导致线程3可以获取锁。
于是线程3和线程1同时处理了同一订单。
问题很明显了。
老丁犯了两个非常低级的错误:
两个低级问题
- 所有线程使用相同的锁值
java
private static final String REDIS_FLAG_VALUE = "1"; // 所有线程都用这个值!
再来回顾一下线程日志:
- 线程1获取锁 :
SETNX order_lock_1001 "1"
→ 成功,Redis中存储order_lock_1001: "1"
- 线程2尝试获取锁 :
SETNX order_lock_1001 "1"
→ 失败,key已存在 - 线程2抛异常进入finally:调用解锁
- 线程2执行解锁脚本:
lua
if (redis.call('get', 'order_lock_1001') == '1') then -- 返回true,因为值确实是"1"
return redis.call('del', 'order_lock_1001'); -- 删除了线程1的锁!
end
- 线程3获取锁 :
SETNX order_lock_1001 "1"
→ 成功,因为key已被删除
问题核心 :所有线程使用相同的锁值 "1" ,导致任何线程都能删除其他线程的锁。
- 异常处理逻辑错误
java
try {
if (!isLocked(lockKey)) {
throw new BusinessException("锁已失效"); // 抛异常
}
// 业务逻辑
} finally {
lockUtil.unLock(lockKey); // 即使try中抛异常,finally依然执行
}
即使没有获取到锁,在抛异常后finally块依然会执行,导致错误的解锁操作。
修复问题
现在我们沿用老丁的代码,做一个最简单的修复:
- 使用唯一的锁值
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<>();
}
- 修正业务逻辑
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 流程过于依赖资深程序员的信誉,缺乏真正有效的审查;
- 没有对关键链路提前做并发压测;
- 容易被经验主义带偏,以为经验丰富了就不会错。
但现实是:谁都会犯错。
真正稳定的技术方案,靠的是足够多的验证和失败教训积累出来的,而不是某一个人或某几个人的多少年的经验。
虽然世界就是个巨大的草台班子,但我们还是要尽量把这个班子夯实。
最后
还好提测阶段就发现了问题,没有影响生产。
如果到了生产环境,后果不堪设想。
吸取教训,继续前行。
毕竟,从失败中学习,才是程序员成长的最佳方式。
技术没有完人,流程不能靠人。
就像某位大师说的: "别造一个车轮,结果发现它是方的。"
如果你也遇到类似的问题,别忘了问自己一句:
你写的锁,是能抗住并发的吗?
