【Redis】分布式锁完整演进

【Redis】系列第3期:VibeLoop 的数据盾牌 --- 分布式锁完整演进

贯穿案例「VibeLoop」为虚拟的轻量级内容互动平台,仅用于技术演示,并非真实存在的产品。

上期速递:【Redis】缓存策略与三大经典问题【Redis】Redis 数据结构与 Spring Boot 集成

本文覆盖 setnx→Lua→WatchDog→Redlock 六个版本完整演进、Redisson 四件套实战、源码走读、3个 VibeLoop 实战场景、8道面试题、必背速查表。

目录

  • 开篇场景:热门帖子的编辑灾难
  • 理论速览:分布式锁的三要素
  • [版本一:setnx 基础锁与三个致命漏洞](#版本一:setnx 基础锁与三个致命漏洞)
  • [版本二:set EX NX 原子加锁](#版本二:set EX NX 原子加锁)
  • [版本三:校验 Value 防误删](#版本三:校验 Value 防误删)
  • [版本四:Lua 脚本原子释放](#版本四:Lua 脚本原子释放)
  • [版本五:WatchDog 自动续期](#版本五:WatchDog 自动续期)
  • [版本六:Redlock 多节点加锁](#版本六:Redlock 多节点加锁)
  • [Redisson 四件套实战](#Redisson 四件套实战)
  • [VibeLoop 三大实战场景](#VibeLoop 三大实战场景)
  • 源码走读:面试关键路径
  • [面试八连问 + 详解](#面试八连问 + 详解)
  • 必背速查表

开篇场景:热门帖子的编辑灾难

VibeLoop 运营团队刚推出一篇"双十一战报",半小时浏览量破 50 万。

Alice 正在改数据错误,Bob 同时在调排版,Charlie 接到运营指令要把标题换成"V2.1 版本"。三个人几乎同时点了保存。

结果:DB 里只剩 Charlie 的版本。Alice 的数据修正没了,Bob 的排版也丢了。翻日志一看,三个 UPDATE 语句几乎同时打进去,后到的直接覆盖前一个。

这就是分布式锁要解决的事:互斥。同一时刻只能有一个客户端操作共享资源。

但"加个锁"三个字,落地到分布式环境里会踩一串坑。本文沿着 setnx → set EX NX → 校验 value → Lua 原子释放 → WatchDog 自动续期 → Redlock 多节点加锁的演进线,一个个拆。

面试官不会问"会不会用 Redis 加锁"------他会问"你的锁在什么场景下会失效"。


理论速览:分布式锁的三要素

一把能用的分布式锁必须同时满足三条:

要素 含义 面试关键词
互斥性 同一时刻只有一个客户端持有锁 SET NX
防死锁 持有者崩溃后锁自动释放 EXPIRE / WatchDog
解铃还须系铃人 只有加锁的客户端能释放 UUID 校验

少一条,你的锁压力一上来就会露馅。


版本一:setnx 基础锁与三个致命漏洞

最原始的写法,不到 10 行:

java 复制代码
// 版本一:setnx 基础锁
public void editPost(Long postId, String content) {
    String lockKey = "lock:post:" + postId;
    
    // 1. 尝试加锁
    Boolean locked = jedis.setnx(lockKey, "1");
    if (!locked) throw new RuntimeException("获取锁失败");
    
    try {
        // 2. 执行业务
        postMapper.updateContent(postId, content);
    } finally {
        // 3. 释放锁
        jedis.del(lockKey);
    }
}

并发场景推演:三个致命漏洞

致命漏洞1:无过期时间 → 死锁

复制代码
T1: Client A 执行 setnx → 成功,获取锁
T2: Client A 执行业务逻辑(还没执行到 finally)
T3: Client A 所在服务器突然宕机
结果: lock:post:10001 永不过期 → 所有后续请求全部失败

致命漏洞2:释放锁无归属校验

复制代码
T1: Client A setnx 成功,加锁
T2: Client A 业务执行超时
T3: Client B 尝试加锁,因为锁还在,失败(此时业务正常)
T4: Client A 终于执行完,执行 del → 正常释放

这个版本还埋着一个"隐身"漏洞3:jedis.del(lockKey) 根本不管这锁是不是自己加的------后面会炸。

面试追问预答

问:setnx 做分布式锁有什么问题?

答:三个致命问题。一是没有过期时间,客户端崩溃导致死锁;二是锁没有持有者标识,A 的锁可能被 B 误删;三是加锁和设过期不是原子操作。后续版本逐个修。


版本二:set EX NX 原子加锁

Redis 2.6.12 开始支持 SET key value NX EX seconds,一条命令搞定加锁加过期:

java 复制代码
// 版本二:set EX NX 原子加锁
String lockKey = "lock:post:" + postId;
String lockValue = UUID.randomUUID().toString(); // 持有者标识
Boolean locked = jedis.set(lockKey, lockValue, 
    SetParams.setParams().nx().ex(30)); // NX + 30s 过期

if (!locked) throw new RuntimeException("获取锁失败");
try {
    postMapper.updateContent(postId, content);
} finally {
    jedis.del(lockKey);
}

并发场景推演:锁被误删

复制代码
T0: Client A 使用 "uuid-A" 加锁,TTL=30s
T1: Client A 执行业务,耗时 35s(超过 30s TTL)
T2: Redis 自动删除锁(锁已过期)
T3: Client B 使用 "uuid-B" 加锁成功,开始执行业务
T4: Client A 执行完业务,执行 jedis.del(lockKey)
结果: Client B 的锁被 Client A 删除了!Client C 此时可以加锁
       → 同一时刻 Client B 和 Client C 同时持有锁 → 互斥被打破

已修复 :死锁问题(有 TTL 了)、setnx+expire 两步非原子的问题。

仍存在:del 之前没判断持有者,锁仍然能被误删。

面试追问预答

问:为什么 SET NX 和 EXPIRE 要放在一条命令里?

答:分两步的话,setnx 成功后、expire 执行前客户端崩了,锁永不过期。SET key value NX EX seconds 一条命令完成,Redis 单线程保证原子性。


版本三:校验 Value 防误删

del 之前先比较 value,不是自己的锁不动它:

java 复制代码
// 版本三:校验 value 再删除
String lockValue = UUID.randomUUID().toString();
Boolean locked = jedis.set(lockKey, lockValue, 
    SetParams.setParams().nx().ex(30));
try {
    postMapper.updateContent(postId, content);
} finally {
    // 先查后删:判断锁的持有者
    if (lockValue.equals(jedis.get(lockKey))) {
        jedis.del(lockKey);
    }
}

并发场景推演:非原子操作的竞态窗口

复制代码
T1: Client A 执行 jedis.get(lockKey) → 返回 "uuid-A",判断通过
T2: 就在此时,锁刚好过期,Redis 自动删除
T3: Client B 加锁成功,value="uuid-B"
T4: Client A 执行 jedis.del(lockKey) → 删除了 Client B 的锁!

问题本质get + del 之间存在竞态窗口。这个窗口极短但确实存在,高并发下概率触发。

面试追问预答

问:get 比较后再 del,不是已经解决问题了吗?

答:没解决。get 和 del 之间有一个极短的竞态窗口。锁在 get 之后、del 之前过期的话,Client A 照样删 Client B 的锁。这就是为什么需要 Lua 把判断和删除捏成一个原子操作。


版本四:Lua 脚本原子释放

Redis 单线程执行 Lua 脚本,执行期间其他命令插不进来:

java 复制代码
// 版本四:Lua 脚本原子释放
String lockScript = 
    "if redis.call('get', KEYS[1]) == ARGV[1] then " +
    "    return redis.call('del', KEYS[1]) " +
    "else " +
    "    return 0 " +
    "end";

String lockValue = UUID.randomUUID().toString();
Boolean locked = jedis.set(lockKey, lockValue, 
    SetParams.setParams().nx().ex(30));
try {
    postMapper.updateContent(postId, content);
} finally {
    // 原子判断 + 删除
    jedis.eval(lockScript, Collections.singletonList(lockKey),
        Collections.singletonList(lockValue));
}

为什么 Lua 是原子的

整个 Lua 脚本在 Redis 中被加载、编译、执行,中间不会有其他命令穿插进来。脚本内部的多个 Redis 命令是串行连续的。

面试追问预答

问:Lua 脚本为什么能保证原子性?Redis 6.0 引入 IO 多线程后还原子吗?

答:Redis 6.0 的 IO 多线程只处理网络读写,命令执行仍然是主线程单线程。Lua 脚本执行时不会被其他命令打断,原子性不受影响。IO 多线程不等于命令并行执行。


版本五:WatchDog 自动续期

版本四解决了误删问题,但新问题来了:TTL 设多少?

  • 太短:业务没跑完锁就过期了,互斥失效
  • 太长:客户端崩溃后,其他人得干等好久才能抢到锁

Redisson 的 WatchDog 就是解决这个的。默认 internalLockLeaseTime=30000ms,续期间隔 = 30000/3 = 10000ms。调用 lock()lock(leaseTime, unit) 时,当且仅当 leaseTime=-1 才会触发看门狗自动续期。如果你显式传了 leaseTime=30000(看起来和默认值一样),看门狗不会启动------锁会在 30s 后静默过期,业务还没执行完的话直接并发裸奔。这是个非常容易踩的坑。

java 复制代码
// 版本五:Redisson RLock + WatchDog
RLock lock = redissonClient.getLock("lock:post:" + postId);
try {
    // leaseTime=-1 触发看门狗机制,默认 30s 自动续期
    lock.lock();
    postMapper.updateContent(postId, content);
} finally {
    lock.unlock();
}

WatchDog 核心机制

复制代码
加锁成功 → 启动定时任务(10s 后执行)
       → 10s 后 Lua 脚本 pexpire key 30000(重置 TTL 为 30s)
       → 再次注册 10s 后的定时任务
       → 循环,直到 unlock() 被调用
unlock() → 取消定时任务 → Lua 脚本校验并删除 key

底层基于 Netty 的 HashedWheelTimer 时间轮:128 个槽位,每 100ms 前进一格,每圈 12.8 秒。续期任务被分配到对应的槽位,到时自动触发。

面试追问预答

问:WatchDog 的原理是什么?续期间隔为什么是 10s?

答:基于 Netty HashedWheelTimer 时间轮实现。默认 internalLockLeaseTime=30s,续期间隔 = 30s/3 = 10s。这样即使一次续期网络超时,还有 20s 余量够重试。客户端崩了,30s 后锁自动释放,不会死锁。


版本六:Redlock 多节点加锁

版本五在单节点 Redis 上够用了。但 Redis 是单点的话,宕机了怎么办?

Redlock 由 Redis 作者 antirez 提出:在 N 个独立 Redis 节点上分别加锁,超过半数(N/2+1)成功才算获取锁。

java 复制代码
// 版本六:RedissonRedLock 多节点加锁
RLock lock1 = redissonClient1.getLock("lock:post:" + postId);
RLock lock2 = redissonClient2.getLock("lock:post:" + postId);
RLock lock3 = redissonClient3.getLock("lock:post:" + postId);
RLock lock4 = redissonClient4.getLock("lock:post:" + postId);
RLock lock5 = redissonClient5.getLock("lock:post:" + postId);

RedissonRedLock redLock = new RedissonRedLock(lock1, lock2, lock3, lock4, lock5);

try {
    // 尝试在 5 个节点上加锁,超时时间 100ms
    boolean locked = redLock.tryLock(100, 10000, TimeUnit.MILLISECONDS);
    if (locked) {
        postMapper.updateContent(postId, content);
    }
} finally {
    redLock.unlock();
}

Redlock 算法步骤

  1. 获取当前时间戳(毫秒)
  2. 依次向 N 个节点发起 SET key value NX PX ttl,设置短的超时时间(远小于锁 TTL)
  3. 统计成功节点数。如果 >= N/2+1 且总耗时 < 锁有效期 → 获取锁成功
  4. 锁实际有效时间 = 锁 TTL - 总耗时
  5. 失败则向所有节点发送释放请求

NTP 时钟偏移风险

Redlock 最大的争议在这里。Martin Kleppmann 在 《How to do distributed locking》 中指出:如果某个 Redis 节点的 NTP 时钟突然向前跳了 30s,该节点上的锁提前过期,另一个客户端就能在这个节点上加锁------互斥被打破。

antirez 在 《Is Redlock safe?》 中回应:可以用 fencing token(单调递增令牌)在业务层防御。即使两个客户端同时拿到锁,资源层(如 DB)可以拒绝较小 token 的写入。

面试追问预答

问:Redlock 安全吗?有什么争议?

答:大多数场景下安全,但正确性要求极高的系统(金融交易级别)存在 NTP 时钟偏移的理论风险。可以配合 fencing token 在资源层兜底。VibeLoop 内容编辑场景用单节点 WatchDog 就够了,没必要上 Redlock 的复杂度。


Redisson 四件套实战

除了基础 RLock,Redisson 还提供三种高级锁。

1. 可重入锁(ReentrantLock)

同一个线程可以多次获取同一把锁,内部用 hset 维护重入计数:

java 复制代码
RLock lock = redissonClient.getLock("lock:post:10001");

public void outerMethod() {
    lock.lock();
    try {
        innerMethod(); // 同一线程再次获取同一把锁,计数器 +1
    } finally {
        lock.unlock(); // 计数器 -1
    }
}

public void innerMethod() {
    lock.lock();     // 重入,不阻塞
    try {
        // 业务
    } finally {
        lock.unlock();
    }
}

底层用 Hash 结构:hset lock:post:10001 uuid:threadId 1,每次重入 hincrby +1,释放时 -1,计数归零后才真正 del

2. 读写锁(RReadWriteLock)

读锁共享,写锁互斥:

java 复制代码
RReadWriteLock rwLock = redissonClient.getReadWriteLock("lock:post:10001");

// 读操作:多个线程可以同时持有读锁
rwLock.readLock().lock();
Post post = postMapper.selectById(postId);
rwLock.readLock().unlock();

// 写操作:独占,等待所有读锁释放
rwLock.writeLock().lock();
postMapper.updateContent(postId, content);
rwLock.writeLock().unlock();

VibeLoop 场景:帖子浏览(读多)用读锁,帖子编辑(写少)用写锁。读读不互斥。

3. 联锁(MultiLock)

多把锁必须全部获取成功才算成功。和 Redlock 的多数派不同,联锁要求 100%:

java 复制代码
RLock lock1 = redissonClient1.getLock("lock:resource-a");
RLock lock2 = redissonClient2.getLock("lock:resource-b");
RedissonMultiLock multiLock = new RedissonMultiLock(lock1, lock2);
multiLock.lock(); // 两把锁都成功才算获取

适用场景:转账时需要同时锁住 A 账户和 B 账户。

4. 红锁(RedLock)

版本六已详解。用于对 Redis 单点故障零容忍的场景。


VibeLoop 三大实战场景

场景一:帖子编辑锁

java 复制代码
@Service
public class PostEditService {
    @Autowired
    private RedissonClient redissonClient;
    
    private static final long LEASE_TIME = 30000; // 30s
    
    public boolean editPost(Long postId, Long userId, PostDTO dto) {
        String lockKey = "lock:post:edit:" + postId;
        RLock lock = redissonClient.getLock(lockKey);
        
        try {
            if (lock.tryLock(3, LEASE_TIME, TimeUnit.MILLISECONDS)) {
                postMapper.updateById(dto);
                return true;
            }
            return false;
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return false;
        } finally {
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }
}

场景二:定时任务 Leader 选举

VibeLoop 有 3 个节点,每天凌晨生成热门帖子榜单。用分布式锁保证只有一个节点执行:

java 复制代码
@Component
public class HotPostTask {
    @Autowired
    private RedissonClient redissonClient;
    
    @Scheduled(cron = "0 0 2 * * ?") // 每天凌晨 2:00
    public void generateHotPosts() {
        RLock lock = redissonClient.getLock("lock:task:hot-posts");
        if (!lock.tryLock()) {
            return; // 已有节点在执行,直接返回
        }
        try {
            hotPostService.generate();
        } finally {
            lock.unlock();
        }
    }
}

场景三:用户注册唯一性校验

用户注册时,用分布式锁防止同一用户名被并发注册:

java 复制代码
public boolean register(String username, RegisterDTO dto) {
    String lockKey = "lock:register:" + username;
    RLock lock = redissonClient.getLock(lockKey);
    
    try {
        if (lock.tryLock(5, TimeUnit.SECONDS)) {
            if (userMapper.countByUsername(username) > 0) {
                return false; // 双重检查
            }
            userMapper.insert(dto.toUser());
            return true;
        }
        return false;
    } finally {
        lock.unlock();
    }
}

源码走读:面试关键路径

关键路径1:Redisson tryLock 加锁全流程

java 复制代码
// RedissonBaseLock.tryLock()
public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) {
    long threadId = Thread.currentThread().getId();
    Long ttl = tryAcquire(waitTime, leaseTime, unit, threadId);
    if (ttl == null) {
        return true; // 加锁成功
    }
    // 加锁失败 → 订阅释放事件 → 信号量阻塞等待 → 唤醒后重试
    // ...
}

核心 Lua 脚本(加锁):

lua 复制代码
-- KEYS[1] = lockKey, ARGV[1] = leaseTime, ARGV[2] = uuid:threadId
if (redis.call('exists', KEYS[1]) == 0) then
    redis.call('hset', KEYS[1], ARGV[2], 1);
    redis.call('pexpire', KEYS[1], ARGV[1]);
    return nil;
end;
-- 锁已存在,判断是否是同一线程(可重入)
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
    redis.call('hincrby', KEYS[1], ARGV[2], 1);
    redis.call('pexpire', KEYS[1], ARGV[1]);
    return nil;
end;
return redis.call('pttl', KEYS[1]); -- 返回剩余 TTL

关键路径2:WatchDog 续期触发

java 复制代码
// RedissonBaseLock.scheduleExpirationRenewal()
private void scheduleExpirationRenewal(long threadId) {
    ExpirationEntry entry = new ExpirationEntry();
    // 每 internalLockLeaseTime/3 执行一次续期
    entry.getTimeout().set(internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);
    // 注册到 Netty 时间轮
    renewExpiration(threadId, entry);
}

续期 Lua:

lua 复制代码
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
    redis.call('pexpire', KEYS[1], ARGV[1]); -- 续期 30s
    return 1;
end;
return 0;

关键路径3:Redlock 多数派判断

java 复制代码
// RedissonRedLock.tryLock()
int successCount = 0;
long startTime = System.currentTimeMillis();
for (RLock lock : locks) {
    if (lock.tryLock(remainTime, leaseTime, unit)) {
        successCount++;
    }
    long elapsed = System.currentTimeMillis() - startTime;
    if (elapsed > waitTime) break; // 总超时
}
if (successCount >= locks.size() / 2 + 1) {
    return true;
} else {
    unlockInner(locks); // 失败 → 释放所有已获得的锁
    return false;
}

面试八连问 + 详解

Q1:Redis 分布式锁用在什么场景?和数据库锁有什么区别?

Redis 分布式锁适合跨 JVM、跨服务的互斥场景,比如防止定时任务重复执行、保证幂等。和 SELECT FOR UPDATE 的区别:DB 锁依赖事务和连接,粒度粗、性能差;Redis 锁是内存操作,微秒级响应,但一致性弱于 DB 锁。防并发用 Redis,防事务用 DB。

Q2:setnx 的三个致命漏洞是什么?

  1. 无过期时间,客户端崩溃导致死锁;2) 释放锁时没校验持有者,A 的锁被 B 误删;3) 加锁和设过期不是原子操作,两步之间崩溃仍会死锁。

Q3:为什么用 Lua 而不是 Java 判断后删除?

Java 判断和删除是两个 Redis 命令,中间有网络往返的竞态窗口。Lua 脚本在 Redis 单线程中执行,判断和删除是原子的。Redis 6.0 的 IO 多线程只处理网络读写,命令执行仍是单线程。

Q4:Redisson 的 WatchDog 怎么防止死锁?

WatchDog 每 10s 把 TTL 重置为 30s,直到客户端主动 unlock。客户端崩了,续期停止,30s 后锁自动过期。底层基于 Netty HashedWheelTimer 时间轮。

Q5:Redlock 和普通锁的核心区别?

普通锁依赖单个 Redis 节点,节点宕机锁就没了。Redlock 在 N 个独立节点上分别加锁,超过半数成功才算获取锁。即使少数节点宕机,剩余节点仍能提供服务。代价是网络开销和复杂度增加。

Q6:Redlock 安全吗?争议在哪里?

基本安全,但正确性要求极高(如金融)的场景存在 NTP 时钟偏移风险。某个节点时钟突然跳变,锁可能提前过期。antirez 建议配合 fencing token 在资源层兜底。

Q7:可重入锁怎么实现的?

底层用 hset lockKey uuid:threadId 1,field 存线程标识,value 存重入计数。每次加锁 hincrby +1,释放时 -1,归零后才 del

Q8:分布式锁的过期时间设多长合适?

不用 WatchDog 的话,设为业务平均时间的 3~5 倍,加 10%~30% 随机抖动。用 WatchDog 的话,设 leaseTime=-1 交给它自动续期。


必背速查表

六版本演进速查

版本 核心命令 解决的问题 遗留问题 互斥 防死锁 归属 面试关键词
v1 SETNX 基础互斥 无过期→死锁、无归属 基础用法
v2 SET NX EX 死锁、原子性 锁被误删 原子命令
v3 GET + DEL 误删(校验) 非原子操作 ⚠️ UUID 标识
v4 EVAL Lua 原子性 过期时间难评估 Lua 脚本
v5 WatchDog 自动续期 单点故障 Netty 时间轮
v6 Redlock 单点故障 NTP 时钟偏移 多数派

常用命令速查

命令 用途 时间复杂度
SET key val NX EX ttl 原子加锁 O(1)
EVAL script numkeys key... arg... Lua 脚本原子操作 取决于脚本
PEXPIRE key ttl 设置/更新过期时间 O(1)
DEL key 删除锁 O(1)
HEXISTS key field 检查可重入锁持有者 O(1)

Redisson 常用 API

java 复制代码
RLock lock = redissonClient.getLock("myLock");
lock.lock();                        // 阻塞直到获取锁(带 WatchDog)
lock.tryLock(3, 30, TimeUnit.SECONDS); // 等待3s,持锁30s
lock.tryLock();                     // 尝试一次,失败立即返回
lock.unlock();                      // 释放
lock.isLocked();                    // 是否被锁定
lock.isHeldByCurrentThread();       // 当前线程是否持有

选型决策树

复制代码
单节点 Redis → RLock + WatchDog(leaseTime=-1)  ← VibeLoop 内容编辑
多节点可用 → Redlock(N≥5,容忍少数节点宕机)
需要读写分离 → RReadWriteLock
需要锁定多个资源 → RedissonMultiLock

下期预告 --- 第4期「VibeLoop 的生存手册 --- 持久化与高可用架构」:RDB 快照原理、AOF 三种同步策略、主从复制 PSYNC、哨兵集群故障转移、Cluster 哈希槽分片。VibeLoop 从 3 主 3 从 + 3 哨兵到 6 节点 Cluster 的演进路线。


相关推荐
mN9B2uk171 小时前
数据库的约束简介
java·数据库·sql
计算机安禾1 小时前
【数据库系统原理】第4篇:关系数据结构的形式化定义:域、笛卡尔积与关系模式
数据结构·数据库·算法
Henry-SAP1 小时前
SAP(ERP) BOM变更实时同步MRP方案
数据库·云原生
AI人工智能+电脑小能手1 小时前
【大白话说Java面试题 第99题】【Mysql篇】第29题:如何选择合适的分布式主键方案?
java·数据库·分布式·mysql·面试
happyprince1 小时前
11-Hugging Face Transformers 分布式与并行系统深度分析
分布式·c#·wpf
不知名的老吴2 小时前
在Spinklock中分布式锁的概念
分布式
zhangfeng11332 小时前
天数智芯天垓 100 加密大模型分布式部署安全方案
人工智能·分布式·安全·transformer·gpu算力·芯片
倔强的石头_2 小时前
kingbase备份与恢复实战(七)—— 恢复演练与验收:从“能恢复”到“可交付预案”
数据库
满昕欢喜2 小时前
第2章 SQL Server 2019服务器管理
数据库·sqlserver