分布式锁演进:从一个用户下两单,到 Redisson,我改了四版

分布式锁演进:从一个用户下两单,到 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 停顿或慢查询,就一定会遇到:

  1. 线程 A 拿到锁,但业务卡住了(慢 SQL、Full GC、网络抖动)
  2. 锁到了 1200 秒自动过期,Redis 把 key 删了
  3. 线程 B 此时来抢,成功拿到锁
  4. 线程 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",而是三件事:

  1. synchronized 的边界是 JVM,不是"程序"。想清楚锁存在哪里,比记住 API 重要。
  2. 每个坑都有明确的触发条件。误删要"业务超时 + 另一个线程抢到锁";原子性问题要"判断后线程被切走"。能说出条件,才说明真想过。
  3. 锁只是性能优化,不是正确性保证。最后那道防线永远该是数据库的唯一索引------锁失效时,它还能兜住。

系列说明 :本篇是《黑马点评项目复盘》第 1 篇,共 8 篇。下一篇写缓存三件套:穿透 / 击穿 / 雪崩------也是我踩得最惨的一章。

我是正在准备 Java 后端秋招的应届生,把项目里踩过的坑按"改坏---定位---修好"的过程记下来。如果这篇对你有用,欢迎关注,后面会持续更。

相关推荐
He BianGu2 小时前
【WPF-Control教程】MVVM 二次开发文档
c#·wpf·wpf-control
He BianGu2 小时前
【WPF-Control教程】Theme 主题系统二次开发文档
c#·wpf·wpf-control
skywalk81632 小时前
hive —— 多机 AI Agent 协同的任务总线:分布式,多节点同时运行,保障不会宕机
人工智能·hive·分布式
一条泥憨鱼2 小时前
【RabbitMQ】高级篇
java·分布式·rabbitmq·php·ruby
棣廷4 小时前
初识Spark Core
大数据·分布式·spark
凯凯姜12 小时前
分布式锁场景问题
分布式
每天一道题15 小时前
CAP、Raft 与多数派:分布式系统如何达成一致?
分布式·后端·微服务
方方洛18 小时前
ray教程-18-性能调优与反模式
人工智能·分布式·机器学习
每天一道题1 天前
分布式锁真的安全吗?从 Redis 锁、Lease 到 Fencing Token
redis·分布式·安全