分布式锁演进:从一个用户下两单,到 Redisson,我改了四版
这篇不是"分布式锁是什么"的科普。是我在点评项目里把一把锁改了四遍的完整记录:
每一版都是因为上一版真的出了问题 才改的。
所有代码都是我自己仓库里的实现,不是课件。
一、现象:单机能锁住,集群锁不住
秒杀下单要求"一人一单"。最直觉的写法,就是在方法上加 synchronized:
java
@Transactional
public synchronized Result createVoucherOrder(Long voucherId) {
Long userId = UserHolder.getUser().getId();
// 查是否已下单 → 扣库存 → 创建订单
}
单机跑,一切正常。100 个并发压上来,一个用户只出一单,看着很完美。
然后我把服务起了两个实例(8081 / 8082),用同一个用户连点两次。
两笔订单都进库了。
原因不复杂:synchronized 的锁是对象头里的 monitor ,它只存在于自己这个 JVM 的堆里 。两个实例是两个 JVM、两块内存,各自锁各自的,谁也看不见谁。对它们来说,"另一个实例正在处理同一个用户"这件事根本不存在。
这一条想清楚,后面所有方案都是它的推论:锁必须放在一个所有实例都能看见的地方。
二、第一反应:把锁放进 Redis
既然要"所有实例都能看见",那就找一个公共存储。Redis 最顺手,因为它有两个特性正好用得上:
SET key value NX:key 不存在才设置成功 → 天然就是"抢锁"语义EX seconds:设置过期时间 → 持有者崩了也不会永久死锁
合起来就是 SET lock:order:1 <谁> NX EX 1200。
我按这个思路抽了个接口,因为后面肯定要换实现,先定契约:
java
public interface ILock {
/** 尝试获取锁(非阻塞) */
boolean tryLock(long timeoutSec);
/** 释放锁 */
void unlock();
}
三、版本一:看起来能用了
java
public boolean tryLock(long timeoutSec) {
Boolean success = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + name, "1", timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}
public void unlock() {
stringRedisTemplate.delete(KEY_PREFIX + name);
}
跑通了。一个用户只出一单,两个实例也互斥了。当时我以为完事了。
四、它把别人的锁删了
这个 bug 的触发条件很具体,但只要线上有 GC 停顿或慢查询,就一定会遇到:
- 线程 A 拿到锁,但业务卡住了(慢 SQL、Full GC、网络抖动)
- 锁到了 1200 秒自动过期,Redis 把 key 删了
- 线程 B 此时来抢,成功拿到锁
- 线程 A 终于跑完,走到
unlock()------ 它删的是 B 的锁
于是 B 的锁凭空消失,线程 C 又能进来,互斥彻底失效。
根因一句话:释放锁的时候,我没检查这把锁是不是我的。
五、版本二:加个身份标识
修法很直接:加锁时写一个能证明"我是谁"的值,解锁前先比对,不一致就不删。
java
/** 线程标识前缀:每个 JVM 启动时生成一次,避免跨实例线程 id 冲突 */
private static final String ID_PREFIX = UUID.randomUUID().toString() + "-";
public boolean tryLock(long timeoutSec) {
// "我是谁" = JVM 唯一前缀 + 线程 id
String threadId = ID_PREFIX + Thread.currentThread().getId();
Boolean success = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}
这里有个我踩过才想明白 的点:为什么不能只用 Thread.currentThread().getId()?
因为线程 id 是每个 JVM 各自从 1 开始编号 的。实例 A 的 1 号线程和实例 B 的 1 号线程,id 都是 1------两个不同的人拿着同一个身份证。加一个 JVM 启动时生成的 UUID 前缀,才真正唯一。
顺带一个 Java 细节:
setIfAbsent失败时返回的是null而不是false。直接if (success)会拆箱抛 NPE,所以我写成Boolean.TRUE.equals(success)。
六、还有原子性:判断和删除之间也能出事
版本二解锁长这样:
java
// ❌ 有问题的写法
String threadId = ID_PREFIX + Thread.currentThread().getId();
String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
if (threadId.equals(id)) {
stringRedisTemplate.delete(KEY_PREFIX + name);
}
看着对,但它不是原子的。"判断是我的"和"删除"这两步之间,线程可以被切走:
- 线程 A 判断
id是自己的 ✔ - A 被调度暂停(就在这一瞬间)
- 锁在这期间过期,B 抢到了锁
- A 恢复,执行
delete------ 删的是 B 的锁
版本二只是把"概率"降低了,没有把"窗口"关掉。
七、版本三:Lua 把两步并成一步
Redis 执行 Lua 脚本时是单线程原子执行的,中间不会插入别的命令。所以把"比对 + 删除"写进脚本:
lua
if (redis.call('GET', KEYS[1]) == ARGV[1]) then
return redis.call('DEL', KEYS[1])
end
return 0
Java 侧这样调用:
java
/** 释放锁的 Lua 脚本载体(只负责读文件,执行在 unlock 里) */
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
static {
UNLOCK_SCRIPT = new DefaultRedisScript<>();
UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
UNLOCK_SCRIPT.setResultType(Long.class);
}
@Override
public void unlock() {
// 参数 2 是 KEYS(脚本里 KEYS[1]),参数 3 起是 ARGV(脚本里 ARGV[1])
stringRedisTemplate.execute(
UNLOCK_SCRIPT,
Collections.singletonList(KEY_PREFIX + name),
ID_PREFIX + Thread.currentThread().getId());
}
到这一版,误删和原子性两个问题都堵住了。 这套 ILock + SimpleRedisLock + unlock.lua 就是我现在仓库里的版本,git log 里是那次提交:
4562403 feat(分布式锁): 第4章 Redis 分布式锁 ILock + SimpleRedisLock + Lua
八、那为什么最后还是要换 Redisson
因为手写锁能解决"互斥",但解决不了"好用"。真上生产还有四个坑,每一个都得自己补一遍:
8.1 不可重入
同一个线程如果调了两次加锁方法(比如 A 方法调用 B 方法,两个都加锁),第二次会把自己锁死 。因为 Redis 里那个 key 已经存在,SETNX 必然失败。
要支持重入,得在 value 里记"持有线程 + 重入次数",加锁时 +1、解锁时 -1,减到 0 才真删。光这一项,手写锁的代码量就翻倍了。
8.2 不可重试
我的 tryLock 是非阻塞的:抢不到立刻返回 false,业务直接失败。
但很多场景其实应该"等一会儿再试"。要支持,就得自己写自旋 + 等待 + 超时退出。
8.3 超时释放:给短了误删,给长了死锁
过期时间设多少都不对:
- 设短了:业务还没跑完锁就没了 → 又回到版本一的误删问题
- 设长了:持有者崩了,别人要干等很久 → 可用性差
Redisson 的解法是看门狗(WatchDog) :加锁成功后起一个后台定时任务,每 10 秒检查一次,如果业务还在跑就把过期时间重置回 30 秒。业务不停,锁就一直续;JVM 挂了,续期线程也跟着停,锁 30 秒后自动释放。
触发条件很容易记错:
java
// ✅ 看门狗生效:没有指定 leaseTime
lock.tryLock();
lock.tryLock(10, TimeUnit.SECONDS); // 只等 10 秒,锁不设过期 → 看门狗管
// ❌ 看门狗失效:一旦自己指定了 leaseTime
lock.tryLock(1, 10, TimeUnit.SECONDS); // 锁固定 10 秒后释放,看门狗不再续期
只要显式传了 leaseTime,看门狗就不工作了。 这个点面试官特别爱问。
8.4 主从一致性
如果 Redis 是主从架构:主节点写入锁成功,还没同步到从节点就宕机了 ,哨兵把从节点提成新主------新主上没有这把锁,别的线程立刻就能加锁成功。互斥失效。
这个问题要到 Redisson 的 RedLock / MultiLock(向多个独立节点分别加锁,多数成功才算拿到)才能缓解,而且它自己也有一致性争议。
九、最终代码:换 Redisson
配置很简单:
java
@Configuration
public class RedissonConfig {
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
config.useSingleServer().setAddress("redis://127.0.0.1:6379");
return Redisson.create(config);
}
}
业务里用起来就三行:
java
private void handleVoucherOrder(VoucherOrder order) {
Long userId = order.getUserId();
RLock lock = redissonClient.getLock("lock:order:" + userId);
// 1. 抢锁,失败说明该用户已有订单在处理
boolean isLock = lock.tryLock();
if (!isLock) {
log.error("不允许重复下单");
return;
}
try {
// 2. 用代理调用,事务才生效
proxy.createVoucherOrder(order);
} finally {
// 3. 保证释放锁
lock.unlock();
}
}
两处值得单独说:
第一,lock.tryLock() 不传参数,所以走的是看门狗续期(30 秒,每 10 秒续一次),不用担心业务跑久了锁过期。
第二,proxy.createVoucherOrder(order) 里的 proxy 不是 this。
这是个很隐蔽的坑:@Transactional 是靠 AOP 代理生效的,同一个类内部用 this 调用自己的方法,走不到代理,事务直接失效。所以要从容器里把代理对象取出来:
java
@Resource
private ApplicationContext applicationContext;
private IVoucherOrderService proxy;
@PostConstruct
public void init() {
proxy = applicationContext.getBean(IVoucherOrderService.class);
}
我先用
AopContext.currentProxy(),后来改成了从容器取 bean------因为AopContext依赖exposeProxy=true的配置,忘了配就是一个空指针;直接取 bean 更稳。
十、这四版到底差在哪(一张表)
| 版本 | 实现 | 解决了什么 | 还剩下什么 |
|---|---|---|---|
| 0 | synchronized |
单 JVM 内互斥 | 集群完全失效 |
| 1 | SETNX + EX |
跨实例互斥、防死锁 | 会误删别人的锁 |
| 2 | + UUID 身份校验 | 不误删 | 判断与删除不原子 |
| 3 | + Lua 脚本 | 原子释放 | 不可重入 / 不可重试 / 无续期 / 主从不一致 |
| 4 | Redisson | 上述全部 | 主从一致性仍需 RedLock 兜底 |
十一、面试怎么答(这段可以直接背)
问:分布式锁怎么实现的?
"我们项目里是先用 Redis 的
SETNX + EX手写过一版,过程中踩了两个坑:一是误删 ------业务超时锁过期后,别人拿到了锁,我 Release 时把别人的删了,所以加了 UUID 做持有者标识;
二是原子性 ------判断和删除之间仍可能被插队,所以用 Lua 脚本把两步合成一个原子操作。
但手写版还解决不了可重入、可重试、锁续期和主从一致性 ,最后换成 Redisson 。
Redisson 的看门狗 会在不指定
leaseTime时每 10 秒把锁续到 30 秒;一旦显式传了leaseTime,看门狗就不生效了。"
如果被追问"为什么不用 Zookeeper":
"ZK 是 CP,靠临时顺序节点和会话保活,一致性更强但性能低于 Redis;Redis 是 AP,靠过期时间兜底,性能和吞吐更好。秒杀这种高并发场景我更倾向 Redis,接受'极端情况下可能短暂不一致',用业务层的唯一索引兜底。"
如果被追问"RedLock 靠谱吗":
"RedLock 要在多数独立节点上加锁成功才算成功,能缓解主从切换丢锁。但 Martin Kleppmann 质疑过它在时钟漂移和 GC 停顿下的正确性,分布式系统里没有免费的午餐。我们项目实际用的是单机 Redisson + 数据库唯一索引双保险------锁是性能优化,唯一索引才是正确性保证。"
十二、小结
四版改下来,我最大的收获不是"会用 Redisson",而是三件事:
synchronized的边界是 JVM,不是"程序"。想清楚锁存在哪里,比记住 API 重要。- 每个坑都有明确的触发条件。误删要"业务超时 + 另一个线程抢到锁";原子性问题要"判断后线程被切走"。能说出条件,才说明真想过。
- 锁只是性能优化,不是正确性保证。最后那道防线永远该是数据库的唯一索引------锁失效时,它还能兜住。
系列说明 :本篇是《黑马点评项目复盘》第 1 篇,共 8 篇。下一篇写缓存三件套:穿透 / 击穿 / 雪崩------也是我踩得最惨的一章。
我是正在准备 Java 后端秋招的应届生,把项目里踩过的坑按"改坏---定位---修好"的过程记下来。如果这篇对你有用,欢迎关注,后面会持续更。