使用 RedisTemplate 实现分布式锁时,锁续期和可重入是两个需要重点解决的问题。下面我将为你解释如何应对这两个问题,并提供相应的解决方案。
🧠 核心解决思路
在分布式环境中,一个可靠的锁必须具备以下特性:
| 特性 | 描述 | 常见风险 |
|---|---|---|
| 互斥性 | 任何时刻只有一个客户端能持有锁。 | |
| 防死锁 | 即使客户端崩溃,锁也能在过期后自动释放,避免资源被永久锁定。 | 业务未执行完,锁过期释放,导致数据不一致或其他客户端抢锁。 |
| 防误删 | 只能由锁的持有者释放锁,不能删除其他客户端的锁。 | A 客户端操作超时,锁过期后 B 客户端获锁,A 完成操作后可能误删 B 的锁。 |
| 可重入性 | 同一个线程在已持有锁的情况下,可以再次获取该锁。 | 非重入锁会导致同一线程再次获取锁时被阻塞,形成死锁。 |
| 续期能力 | 当业务执行时间超过锁的初始过期时间时,能自动延长锁的持有时间,防止锁提前释放。 |
⏳ 一、锁续期(Watchdog Mechanism)
锁续期(看门狗机制)解决的是业务逻辑执行时间可能超过锁的初始过期时间的问题。如果锁过期而业务还在执行,其他客户端就可能获取到锁,导致数据不一致。
解决方案的核心是:在业务执行期间,启动一个后台任务,定期(例如,每隔过期时间的 1/3)重置锁的过期时间。
🔧 实现步骤:
- 获取锁成功时启动续期任务:在设置锁时(使用
setIfAbsent并设置过期时间),如果成功,则启动一个定时任务。 - 定时续期:该定时任务定期检查锁是否还存在且是否仍由当前客户端持有(通过比较 value 值)。如果是,则执行
EXPIRE命令重置过期时间。 - 停止续期:当业务执行完毕,手动释放锁时,或客户端宕机时,续期任务应被正确停止。
📝 代码示例 (基于 RedisTemplate 和 Lua 脚本)
@Component
public class RedisLockWithRenewal {
@Autowired
private RedisTemplate<String, String> redisTemplate;
// 存储正在续期的锁的任务,key: lockKey, value: ScheduledFuture
private final ConcurrentHashMap<String, ScheduledFuture<?>> renewalTasks = new ConcurrentHashMap<>();
private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(5);
/**
* 获取锁并启动续期
* @param lockKey 锁的key
* @param requestId 请求标识(需唯一,如UUID+线程ID)
* @param expireTime 锁的过期时间
* @param unit 时间单位
* @return 是否获取成功
*/
public boolean tryLockWithRenewal(String lockKey, String requestId, long expireTime, TimeUnit unit) {
// 1. 原子性操作:设置锁(不存在才设置)并指定过期时间
Boolean success = redisTemplate.opsForValue().setIfAbsent(lockKey, requestId, expireTime, unit);
if (Boolean.TRUE.equals(success)) {
// 2. 获取锁成功,启动续期任务
scheduleRenewal(lockKey, requestId, expireTime, unit);
return true;
}
return false;
}
/**
* 调度续期任务
*/
private void scheduleRenewal(String lockKey, String requestId, long expireTime, TimeUnit unit) {
// 将过期时间转换为毫秒
long expireMs = unit.toMillis(expireTime);
// 计算续期间隔:建议为过期时间的1/3
long renewalInterval = expireMs / 3;
ScheduledFuture<?> future = scheduler.scheduleAtFixedRate(() -> {
// Lua脚本:检查锁是否存在且value匹配,如果匹配则续期
String luaScript =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('pexpire', KEYS[1], ARGV[2]) " +
"else " +
" return 0 " +
"end";
DefaultRedisScript<Long> script = new DefaultRedisScript<>(luaScript, Long.class);
Long result = redisTemplate.execute(script, Collections.singletonList(lockKey), requestId, String.valueOf(expireMs));
// 如果续期失败(锁不存在或value不匹配),取消续期任务
if (result == null || result == 0L) {
cancelRenewal(lockKey);
}
}, renewalInterval, renewalInterval, TimeUnit.MILLISECONDS); // 初始延迟后定期执行
renewalTasks.put(lockKey, future);
}
/**
* 释放锁并取消续期
*/
public boolean unlockWithRenewal(String lockKey, String requestId) {
// 1. 先取消续期任务
cancelRenewal(lockKey);
// 2. Lua脚本释放锁:保证判断requestId和删除锁的原子性
String luaScript =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('del', KEYS[1]) " +
"else " +
" return 0 " +
"end";
DefaultRedisScript<Long> script = new DefaultRedisScript<>(luaScript, Long.class);
Long result = redisTemplate.execute(script, Collections.singletonList(lockKey), requestId);
return result != null && result > 0L;
}
/**
* 取消续期任务
*/
private void cancelRenewal(String lockKey) {
ScheduledFuture<?> future = renewalTasks.remove(lockKey);
if (future != null) {
future.cancel(false); // 尝试取消任务
}
}
}
续期机制的关键点:
- 原子性续期:使用 Lua 脚本保证检查锁持有者和续期操作的原子性,防止在判断后、续期前锁过期或被释放。
- 合理的续期间隔:续期间隔通常设置为锁过期时间的 1/3(例如锁过期 30 秒,则每 10 秒续期一次)。这样即使一次续期失败,也有重试的机会。
- 及时清理:释放锁时或续期失败时,务必取消续期任务,避免资源泄露和无效操作。
🔁 二、可重入性(Reentrancy)
可重入性解决的是同一个线程在已经持有锁的情况下,再次请求该锁时应能成功,而不是被自己阻塞。这在递归调用或同一个线程内需要多次获取锁的场景中至关重要。
解决方案的核心是:在客户端内存中维护一个锁的计数器(ThreadLocal),并在 Redis 中存储该计数信息(通常使用 Hash 结构)。
🔧 实现步骤:
-
本地计数器:使用
ThreadLocal存储一个 Map,记录当前线程持有的每个锁(通过 lockKey 识别)的重入次数。 -
Redis 存储结构:使用 Redis 的 Hash 结构存储锁。Hash 的 key 是锁的名称,field 是客户端的唯一标识(requestId),value 是该客户端重入的次数。
-
获取锁:
- 首先检查本地计数器,如果已经大于 0,则直接增加本地计数并返回成功。
- 如果不是重入,则通过 Lua 脚本向 Redis 发起请求:如果锁不存在或锁是自己的(field 存在且 requestId 匹配),则增加重入次数并设置过期时间;否则获取失败。
-
释放锁:
- 减少本地计数器。
- 如果本地计数器归零,则通过 Lua 脚本告诉 Redis 减少重入次数;如果重入次数归零,则删除该锁的 key。
📝 代码示例 (简化版)
public class ReentrantRedisLock {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
// ThreadLocal保存当前线程的锁重入计数
private final ThreadLocal<Map<String, Integer>> lockCount = ThreadLocal.withInitial(() -> new HashMap<>());
/**
* 获取可重入锁
* @param lockKey 锁的key
* @param requestId 请求标识
* @param expireTime 过期时间
* @param unit 时间单位
* @return 是否获取成功
*/
public boolean tryReentrantLock(String lockKey, String requestId, long expireTime, TimeUnit unit) {
Map<String, Integer> counts = lockCount.get();
Integer count = counts.get(lockKey);
// 1. 检查本地是否已重入
if (count != null && count > 0) {
counts.put(lockKey, count + 1); // 重入次数+1
return true;
}
// 2. 不是重入,尝试在Redis中获取锁
// Lua脚本:检查锁是否可被当前客户端获取(不存在或是自己的锁)
String luaScript =
"if (redis.call('exists', KEYS[1]) == 0 or redis.call('hexists', KEYS[1], ARGV[1]) == 1) then " +
" redis.call('hincrby', KEYS[1], ARGV[1], 1) " + // 重入次数+1
" redis.call('pexpire', KEYS[1], ARGV[2]) " + // 设置或刷新过期时间
" return 1 " +
"else " +
" return 0 " +
"end";
DefaultRedisScript<Long> script = new DefaultRedisScript<>(luaScript, Long.class);
Long result = redisTemplate.execute(script, Collections.singletonList(lockKey), requestId, String.valueOf(unit.toMillis(expireTime)));
boolean acquired = (result != null && result == 1L);
if (acquired) {
counts.put(lockKey, 1); // 本地计数设为1
}
return acquired;
}
/**
* 释放可重入锁
* @param lockKey 锁的key
* @param requestId 请求标识
* @return 是否释放成功(当完全释放时)
*/
public boolean unReentrantLock(String lockKey, String requestId) {
Map<String, Integer> counts = lockCount.get();
Integer count = counts.get(lockKey);
if (count == null || count <= 0) {
throw new IllegalMonitorStateException("Attempt to unlock a lock that is not held by current thread");
}
count--; // 本地重入次数-1
if (count > 0) {
// 还有重入,只更新本地计数
counts.put(lockKey, count);
return false; // 并未完全释放Redis中的锁
} else {
// 本地计数归零,需要释放Redis中的锁
counts.remove(lockKey);
if (counts.isEmpty()) {
lockCount.remove(); // 清理ThreadLocal
}
// Lua脚本:减少重入次数,如果归零则删除锁
String luaScript =
"if (redis.call('hexists', KEYS[1], ARGV[1]) == 1) then " +
" local counter = redis.call('hincrby', KEYS[1], ARGV[1], -1) " +
" if (counter <= 0) then " +
" redis.call('del', KEYS[1]) " + // 计数器<=0,删除锁
" return 1 " +
" else " +
" return 2 " + // 计数器>0,无需删除,只需更新过期时间?
" end " +
"else " +
" return 0 " + // 锁不属于当前客户端
"end";
DefaultRedisScript<Long> script = new DefaultRedisScript<>(luaScript, Long.class);
Long result = redisTemplate.execute(script, Collections.singletonList(lockKey), requestId);
// 可根据返回值判断结果
return result != null && result == 1L;
}
}
}
可重入实现的关键点:
- 线程安全的数据结构:使用
ThreadLocal来维护每个线程的重入计数,避免并发问题。 - Redis Hash 结构:使用 Hash 的 field 来存储客户端标识,value 存储重入次数,完美建模。
- 原子性操作:所有检查重入次数、修改重入次数、设置过期时间的操作都必须通过 Lua 脚本原子完成。
💡 三、实践建议与注意事项
- 唯一请求标识 (requestId):务必使用全局唯一的标识(如
UUID.randomUUID().toString()结合线程ID)作为锁的 value。这是防止误删和实现可重入的基础。 - 锁的过期时间:设置一个合理的过期时间,不宜过短(易导致业务未完成锁失效)或过长(宕机后恢复时间慢)。可根据业务平均耗时调整。
- 网络与重试:网络波动可能导致 Redis 操作失败。对于获取锁的操作,可以添加有限次数的重试机制,但需避免活锁。
- Redis 集群模式:在 Redis 集群环境下,分布式锁的实现会变得更加复杂,需要考虑跨节点的数据一致性问题。对于要求极高一致性的场景,可以考虑使用 Redlock 算法,但它也有其争议和复杂性。通常,如果业务可以容忍极低概率的锁失效,主从架构+上述实现已能满足大多需求。
- 考虑使用成熟框架:如果你发现需要实现非常复杂的分布式逻辑,或者对稳定性要求极高,那么直接使用 Redisson 这样的专业库可能是更明智的选择。它内置了看门狗、可重入锁等多种高级特性,经过充分测试。
💎 总结
通过结合 Lua 脚本保证原子性、后台定时任务实现锁续期、以及 ThreadLocal 结合 Redis Hash 结构管理重入计数,可以用 RedisTemplate 构建一个功能相对完善的分布式锁。
| 问题 | 核心解决方案 | 关键技术点 |
|---|---|---|
| 锁续期 | 看门狗机制 | Lua脚本、定时调度线程池 (ScheduledExecutorService) |
| 可重入 | 本地计数器 + Redis Hash 存储重入次数 | ThreadLocal、Redis Hash 结构、Lua脚本 |
这些方案能有效应对大多数分布式场景下的并发控制需求。但请注意,分布式系统本身就很复杂,你需要根据自身的业务特点和容错需求进行调整和测试。如果你的应用对分布式锁的要求非常苛刻,那么评估并使用像 Redisson 这样的专业客户端可能会更省心省力。