1. 分布式锁要解决什么
单机应用里可以用 synchronized、ReentrantLock。但服务部署成多个实例后,本地锁只能管住当前 JVM,管不住其他机器。
典型场景:
- 防止重复提交。
- 控制定时任务只有一个实例执行。
- 秒杀库存扣减前做互斥。
- 避免多个实例同时重建同一个热点缓存。
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 的锁竞争唤醒等能力。手写锁要达到同等可靠性,需要补齐大量异常路径。
手写前至少确认:
- 是否需要可重入。
- 是否需要自动续期。
- 是否允许锁过期后并发执行。
- Redis 主从切换时是否能接受短暂互斥失效。
- 业务正确性是否真的能放在 Redis 锁上。
还要确认 unlock 是否幂等。重复 unlock、锁已过期、锁已被别人持有时,都不应该误删别人的锁。Lua 执行失败时可以做有限重试,但不能无限重试,也不能在不确认持有者的情况下直接 DEL。
高并发竞争同一把锁时,不建议所有线程固定间隔自旋。应该加入 backoff 和 jitter,或者使用消息通知机制降低 Redis 压力。
分布式锁的关键不是“能加锁”,而是在异常路径下不会误删、不会死锁、不会把偶发问题放大成数据错误。