Redis 系列 14. 手写 Redis 分布式锁


1. 分布式锁要解决什么

单机应用里可以用 synchronized、ReentrantLock。但服务部署成多个实例后,本地锁只能管住当前 JVM,管不住其他机器。

典型场景:

  1. 防止重复提交。
  2. 控制定时任务只有一个实例执行。
  3. 秒杀库存扣减前做互斥。
  4. 避免多个实例同时重建同一个热点缓存。

Redis 分布式锁的核心命令是 SET 的 NX 和过期时间组合:

set key value [EX seconds] [PX milliseconds] [NX|XX]
# EX:key 在多少秒之后过期
# PX:key 在多少毫秒之后过期
# NX:当 key 不存在的时候,才创建 key,效果等同于 setnx
# XX:当 key 存在的时候,覆盖 key

2. 一个合格的分布式锁

一个较完整的分布式锁实现,需要关注这些条件:

条件 说明
互斥性 同一时刻只能一个客户端持有锁
防死锁 持锁客户端宕机后锁能自动释放
防误删 只能释放自己持有的锁
原子性 加锁、解锁的关键动作不能拆成多步竞态
可重入 同一线程重复进入时可以正确计数,属于按需增强能力

Redis 分布式锁并不是万能解决方案。它适合低延迟、短执行时间的临界区,不适合金融账务这类强一致核心事务。

3. 错误版本:SETNX 后 EXPIRE

很多人第一版会这样写:

Boolean ok = redisTemplate.opsForValue().setIfAbsent("lock:order:1", "1");
if (Boolean.TRUE.equals(ok)) {
    redisTemplate.expire("lock:order:1", 30, TimeUnit.SECONDS);
}

问题是 SETNX 和 EXPIRE 不是一个原子操作。如果 SETNX 成功后应用宕机或进程 crash,EXPIRE 没来得及执行,锁就会永久存在。

所以加锁必须用一条 Redis 命令完成。

4. 正确基础版:SET NX EX

Redis 可以用 SET key value NX EX seconds 原子加锁:

set lock:order:1 7f3d2b9e-8b7e-4f0c-96c6-1c3d0a nx ex 30

Java 写法:

String lockKey = "lock:order:" + orderId;
String token = UUID.randomUUID().toString();

Boolean locked = redisTemplate.opsForValue()
    .setIfAbsent(lockKey, token, 30, TimeUnit.SECONDS);

if (!Boolean.TRUE.equals(locked)) {
    throw new IllegalStateException("request is running");
}

value 不能写死成 1。必须写唯一 token,用来判断释放锁时是不是当前持有者。生产里可以用 clientId + threadId + randomId 组合,既便于排查,也能避免同一客户端内不同线程互相误删。

5. 为什么需要 token

假设线程 A 拿到锁,执行业务超过 30 秒,锁自动过期。线程 B 这时拿到了同一个锁。线程 A 执行完后如果直接 DEL lock,就会把线程 B 的锁删掉。

所以释放锁前必须检查 value:

如果 get(lockKey) == token
  删除 lockKey
否则
  不删除

但 GET + DEL 也不是原子操作,这是典型 check-then-act race condition。检查完成后、删除之前,锁可能已经过期并被其他线程重新获取,所以需要 Lua。

6. Lua 原子释放锁

Lua 脚本:

if redis.call('get', KEYS[1]) == ARGV[1] then
    return redis.call('del', KEYS[1])
else
    return 0
end

Java 执行:

private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;

static {
    UNLOCK_SCRIPT = new DefaultRedisScript<>();
    UNLOCK_SCRIPT.setResultType(Long.class);
    UNLOCK_SCRIPT.setScriptText("""
        if redis.call('get', KEYS[1]) == ARGV[1] then
            return redis.call('del', KEYS[1])
        else
            return 0
        end
        """);
}

public void unlock(String lockKey, String token) {
    redisTemplate.execute(UNLOCK_SCRIPT, Collections.singletonList(lockKey), token);
}

Redis 执行 Lua 脚本时会保证脚本原子执行,中间不会穿插其他命令。EVAL 的基本格式如下:

127.0.0.1:6379> eval script numkeys [key [key ...]] [arg [arg ...]]

其中 numkeys 表示后面有多少个 key 参数;这些 key 会进入 Lua 脚本的 KEYS 数组,剩余参数会进入 ARGV 数组。KEYS / ARGV 是 Redis script sandbox 的核心参数传递机制。释放锁脚本里,KEYS[1] 就是锁 key,ARGV[1] 就是加锁时写入的 token。

生产环境可以把脚本预加载后使用 EVALSHA 执行,减少每次传输脚本文本的成本。需要注意脚本缓存可能因 Redis 重启丢失,客户端要能在 NOSCRIPT 时回退到 EVAL。

7. 封装一个基础工具类

public class RedisLock {

    private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;

    static {
        UNLOCK_SCRIPT = new DefaultRedisScript<>();
        UNLOCK_SCRIPT.setResultType(Long.class);
        UNLOCK_SCRIPT.setScriptText("""
            if redis.call('get', KEYS[1]) == ARGV[1] then
                return redis.call('del', KEYS[1])
            else
                return 0
            end
            """);
    }

    private final StringRedisTemplate redisTemplate;
    private final String lockKey;
    private String token;

    public RedisLock(StringRedisTemplate redisTemplate, String lockKey) {
        this.redisTemplate = redisTemplate;
        this.lockKey = lockKey;
    }

    public boolean tryLock(long timeoutSeconds) {
        this.token = UUID.randomUUID().toString();
        Boolean locked = redisTemplate.opsForValue()
            .setIfAbsent(lockKey, token, timeoutSeconds, TimeUnit.SECONDS);
        return Boolean.TRUE.equals(locked);
    }

    public void unlock() {
        if (token == null) {
            return;
        }
        try {
            redisTemplate.execute(UNLOCK_SCRIPT, Collections.singletonList(lockKey), token);
        } finally {
            token = null;
        }
    }
}

这个类按“一次加锁创建一个对象”的方式使用。不要把带 token 成员变量的锁对象做成 Spring 单例后多线程复用;生产封装可以用局部变量或 ThreadLocal 保存持锁标识。

这里的 token 是实例字段,如果同一个 RedisLock 对象被多个线程复用,就会有线程安全问题。更稳妥的做法是每次调用返回一个 lock handle,里面保存本次加锁 token,避免共享 lock instance。

使用:

RedisLock lock = new RedisLock(redisTemplate, "lock:stock:" + skuId);
if (!lock.tryLock(10)) {
    throw new IllegalStateException("too many requests");
}
try {
    deductStock(skuId);
} finally {
    lock.unlock();
}

临界区要尽量短,不要在持锁期间做慢查询、远程调用、长事务。

还要接受一个事实:锁过期后业务仍在执行,就会出现并发执行窗口。比如线程 A 因为慢 SQL、远程调用或 GC pause 超过 TTL,锁过期后线程 B 获得锁,A 和 B 就可能同时执行临界区。这是 Redis 锁最大的风险点之一。

8. 可重入锁怎么做

可重入锁的含义是:同一个线程已经拿到锁后,再次进入同一把锁,不应该被自己挡住。

Redis 里可以用 Hash 表示:

lock:order:1
  field = clientId:threadId
  value = 重入次数

加锁 Lua:

if redis.call('exists', KEYS[1]) == 0 then
    redis.call('hset', KEYS[1], ARGV[1], 1)
    redis.call('expire', KEYS[1], ARGV[2])
    return 1
end

if redis.call('hexists', KEYS[1], ARGV[1]) == 1 then
    redis.call('hincrby', KEYS[1], ARGV[1], 1)
    redis.call('expire', KEYS[1], ARGV[2])
    return 1
end

return 0

解锁 Lua:

if redis.call('hexists', KEYS[1], ARGV[1]) == 0 then
    return nil
end

local counter = redis.call('hincrby', KEYS[1], ARGV[1], -1)
if counter > 0 then
    redis.call('expire', KEYS[1], ARGV[2])
    return counter
else
    redis.call('del', KEYS[1])
    return 0
end

这已经接近 Redisson 的思路。自己实现时要考虑线程标识、续期、异常退出、锁超时和脚本加载失败等细节。

可重入锁必须绑定持有者,通常 field 使用 clientId:threadId。解锁时计数器递减,只有 counter 归零才真正删除锁。

每次重入都刷新 TTL 可以避免业务嵌套调用时锁提前过期,但也要防止异常循环重入导致 TTL 被持续拉长。业务层仍然需要超时保护。

9. 自动续期

如果业务执行时间可能超过锁 TTL,就需要自动续期。续期也必须校验持有者。基础 String 锁可以用这个脚本:

if redis.call('get', KEYS[1]) == ARGV[1] then
    return redis.call('expire', KEYS[1], ARGV[2])
else
    return 0
end

如果使用前面的可重入 Hash 锁,续期脚本要按 Hash 的持有者字段判断,而不是再用 GET:

if redis.call('hexists', KEYS[1], ARGV[1]) == 1 then
    return redis.call('expire', KEYS[1], ARGV[2])
else
    return 0
end

续期线程要在释放锁后停止,否则可能出现无意义续期或资源泄露。

自动续期的本质是 watchdog 模型,类似 Redisson 的看门狗机制:持锁期间后台定时检查持有者,如果锁仍属于当前线程,就延长 TTL。

续期必须绑定持有者校验,不能只看到 key 存在就续期。续期失败时要有重试策略,并加入少量 jitter,避免大量锁在同一时间集中续期。

自动续期不能掩盖慢业务。锁时间过长会降低吞吐,也会扩大故障影响面。如果进程长时间 STW、网络抖动或 Redis 短暂不可用,续期仍然可能失败。

10. 主从切换和 fencing token

Redis 主从复制默认是异步的。线程 A 在 master 上拿到锁后,如果锁还没复制到 replica,master 就发生故障切换,新 master 上可能没有这把锁。此时线程 B 可能在新 master 上再次拿到锁,形成双持锁。

因此,Redis 单实例锁或普通主从锁不适合承载强一致互斥。Cluster 环境也要保证同一把锁始终落在同一个 slot 上,不能把一个逻辑锁拆到多个节点。

对于“旧锁持有者超时后继续写下游资源”的问题,可以引入 fencing token。每次成功加锁后生成一个单调递增的 token,下游资源只接受更大的 token,拒绝旧 token 的写入。

A 获得锁,fencing token = 10
A GC pause,锁过期
B 获得锁,fencing token = 11
B 写资源成功
A 恢复后再写资源,资源侧发现 token=10 小于 11,拒绝写入

fencing token 需要下游存储或业务资源配合校验。Redis 锁本身不能单独解决这个问题。

11. 什么时候不要手写

手写锁适合学习和简单场景。生产 Java 项目里,如果已经引入 Redisson,优先使用 Redisson 的 RLock。

Redisson 已经提供 reentrant lock、watchdog 自动续期、Lua 原子释放、等待/通知以及基于 pub/sub 的锁竞争唤醒等能力。手写锁要达到同等可靠性,需要补齐大量异常路径。

手写前至少确认:

  1. 是否需要可重入。
  2. 是否需要自动续期。
  3. 是否允许锁过期后并发执行。
  4. Redis 主从切换时是否能接受短暂互斥失效。
  5. 业务正确性是否真的能放在 Redis 锁上。

还要确认 unlock 是否幂等。重复 unlock、锁已过期、锁已被别人持有时,都不应该误删别人的锁。Lua 执行失败时可以做有限重试,但不能无限重试,也不能在不确认持有者的情况下直接 DEL。

高并发竞争同一把锁时,不建议所有线程固定间隔自旋。应该加入 backoff 和 jitter,或者使用消息通知机制降低 Redis 压力。

分布式锁的关键不是“能加锁”,而是在异常路径下不会误删、不会死锁、不会把偶发问题放大成数据错误。


文章作者: hnbian
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 hnbian !
评论
  目录