RedisTemplate在分布式锁场景中,如何解决锁续期和可重入的问题?

219 阅读9分钟

使用 RedisTemplate 实现分布式锁时,​锁续期可重入是两个需要重点解决的问题。下面我将为你解释如何应对这两个问题,并提供相应的解决方案。

🧠 核心解决思路

在分布式环境中,一个可靠的锁必须具备以下特性:

特性描述常见风险
互斥性任何时刻只有一个客户端能持有锁。
防死锁即使客户端崩溃,锁也能在过期后自动释放,避免资源被永久锁定。业务未执行完,锁过期释放,导致数据不一致或其他客户端抢锁。
防误删只能由锁的持有者释放锁,不能删除其他客户端的锁。A 客户端操作超时,锁过期后 B 客户端获锁,A 完成操作后可能误删 B 的锁。
可重入性同一个线程在已持有锁的情况下,可以再次获取该锁。非重入锁会导致同一线程再次获取锁时被阻塞,形成死锁。
续期能力当业务执行时间超过锁的初始过期时间时,能自动延长锁的持有时间,防止锁提前释放。

⏳ 一、锁续期(Watchdog Mechanism)

锁续期(看门狗机制)解决的是业务逻辑执行时间可能超过锁的初始过期时间的问题。如果锁过期而业务还在执行,其他客户端就可能获取到锁,导致数据不一致。

解决方案的核心是:在业务执行期间,启动一个后台任务,定期(例如,每隔过期时间的 1/3)重置锁的过期时间。​

🔧 实现步骤:

  1. 获取锁成功时启动续期任务​:在设置锁时(使用 setIfAbsent并设置过期时间),如果成功,则启动一个定时任务。
  2. 定时续期​:该定时任务定期检查锁是否还存在且是否仍由当前客户端持有(通过比较 value 值)。如果是,则执行 EXPIRE命令重置过期时间。
  3. 停止续期​:当业务执行完毕,手动释放锁时,或客户端宕机时,续期任务应被正确停止。

📝 代码示例 (基于 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 结构)。​

🔧 实现步骤:

  1. 本地计数器​:使用 ThreadLocal存储一个 Map,记录当前线程持有的每个锁(通过 lockKey 识别)的重入次数。

  2. Redis 存储结构​:使用 Redis 的 ​Hash​ 结构存储锁。Hash 的 key 是锁的名称,field 是客户端的唯一标识(requestId),value 是该客户端重入的次数。

  3. 获取锁​:

    • 首先检查本地计数器,如果已经大于 0,则直接增加本地计数并返回成功。
    • 如果不是重入,则通过 Lua 脚本向 Redis 发起请求:如果锁不存在或锁是自己的(field 存在且 requestId 匹配),则增加重入次数并设置过期时间;否则获取失败。
  4. 释放锁​:

    • 减少本地计数器。
    • 如果本地计数器归零,则通过 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 脚本原子完成。

💡 三、实践建议与注意事项

  1. 唯一请求标识 (requestId)​​:务必使用全局唯一的标识(如 UUID.randomUUID().toString()结合线程ID)作为锁的 value。这是防止误删和实现可重入的基础。
  2. 锁的过期时间​:设置一个合理的过期时间,不宜过短(易导致业务未完成锁失效)或过长(宕机后恢复时间慢)。可根据业务平均耗时调整。
  3. 网络与重试​:网络波动可能导致 Redis 操作失败。对于获取锁的操作,可以添加有限次数的重试机制,但需避免活锁。
  4. Redis 集群模式​:在 Redis 集群环境下,分布式锁的实现会变得更加复杂,需要考虑跨节点的数据一致性问题。对于要求极高一致性的场景,可以考虑使用 Redlock 算法,但它也有其争议和复杂性。通常,如果业务可以容忍极低概率的锁失效,主从架构+上述实现已能满足大多需求。
  5. 考虑使用成熟框架​:如果你发现需要实现非常复杂的分布式逻辑,或者对稳定性要求极高,那么直接使用 ​Redisson​ 这样的专业库可能是更明智的选择。它内置了看门狗、可重入锁等多种高级特性,经过充分测试。

💎 总结

通过结合 ​Lua 脚本保证原子性、后台定时任务实现锁续期、以及 ​ThreadLocal 结合 Redis Hash​ 结构管理重入计数,可以用 RedisTemplate 构建一个功能相对完善的分布式锁。

问题核心解决方案关键技术点
锁续期看门狗机制Lua脚本、定时调度线程池 (ScheduledExecutorService)
可重入本地计数器 + Redis Hash 存储重入次数ThreadLocal、Redis Hash 结构、Lua脚本

这些方案能有效应对大多数分布式场景下的并发控制需求。但请注意,分布式系统本身就很复杂,你需要根据自身的业务特点和容错需求进行调整和测试。如果你的应用对分布式锁的要求非常苛刻,那么评估并使用像 Redisson 这样的专业客户端可能会更省心省力。