[WenJi项目实战]拒绝死锁与误删:从手写 Redis 锁到 Redisson 看门狗的演进之路

WenJi项目实战拒绝死锁与误删:从手写 Redis 锁到 Redisson 看门狗的演进之路

前言

文迹项目的用户发布博客的场景中,为了防止同一用户频繁刷接口赚取积分,我们通常会使用 Redis 分布式锁进行限流。本文将结合实际业务,拆解手写 Redis 锁的核心原理,分析其在极端场景下的隐患,并探讨如何通过 Lua 脚本和 Redisson 进行架构升级。

🔍 当前分布式锁的实现原理

核心代码

java 复制代码
// ① 加锁:SETNX + TTL 原子操作
String lockKey = "lock:blog:" + userId;          // 按用户粒度加锁
String lockValue = UUID.randomUUID().toString();   // 唯一标识
Boolean acquired = stringRedisTemplate.opsForValue()
    .setIfAbsent(lockKey, lockValue, 10, TimeUnit.SECONDS);

if (Boolean.FALSE.equals(acquired)) {
    throw new BusinessException(429, "操作太频繁");
}

// ② 释放锁:GET 比较 + DELETE(防误删)
String currentValue = stringRedisTemplate.opsForValue().get(lockKey);
if (lockValue.equals(currentValue)) {
    stringRedisTemplate.delete(lockKey);
}

原理拆解(三个关键设计点)

设计点 实现方式 解决什么问题
原子加锁 setIfAbsent(key, value, ttl, unit) 对应 Redis SET key value NX EX 10,NX 保证互斥,EX 防止死锁
唯一标识 UUID 作为 value 防止误删其他线程持有的锁
安全解锁 GET → 比较 UUID → DELETE 解决"锁过期后误删新锁"的经典问题

💡 为什么需要 UUID?

假设线程 A 获取锁后,业务执行超时导致锁自动过期。此时线程 B 获取了锁,如果线程 A 随后直接执行 delete,就会把线程 B 的锁删掉。通过 UUID 校验,可以确保线程 A 只能删除属于自己的锁。

锁粒度设计

目前按用户粒度 加锁(lock:blog:{userId}):

  • 用户 A(id=1): lock:blog:1 ← 只锁用户 A 自己
  • 用户 B(id=2): lock:blog:2 ← 不影响用户 B
    同一用户并发 100 次请求,仅 1 次成功,其余返回 429;不同用户之间完全隔离。

现存隐患分析

虽然手写锁能解决大部分并发问题,但在架构层面仍存在以下缺陷:

  1. 锁过期时间写死:业务执行时间如果超过 10s,锁会自动释放,后续请求进入,导致并发安全问题。
  2. 缺乏续期机制:没有类似 Redisson 的"看门狗"机制,锁一旦过期只能自动释放。
  3. 解锁非原子性 :释放锁分为 GETDELETE 两步。在极端时序下(GET 之后、DELETE 之前锁恰好过期),依然有极小概率发生误删。

🚀 架构升级方案

方案 1:引入 Lua 脚本,保证解锁绝对原子性(当前最快捷的修改方式,项目也修改成这个方法)

GETDELETE 合并为 Redis 端的原子操作,彻底消除误删风险。

1. 编写 lua/unlock.lua

lua 复制代码
if redis.call("GET", KEYS[1]) == ARGV[1] then
    return redis.call("DEL", KEYS[1])
else
    return 0
end

2. 配置 Spring Bean(启动时加载一次)

java 复制代码
@Configuration
public class RedisLuaConfig {
    @Bean
    public DefaultRedisScript<Long> unlockScript() {
        DefaultRedisScript<Long> script = new DefaultRedisScript<>();
        script.setLocation(new ClassPathResource("lua/unlock.lua"));
        script.setResultType(Long.class); 
        return script;
    }
}

3. 业务层调用

java 复制代码
Long result = stringRedisTemplate.execute(
    unlockScript, 
    Collections.singletonList(lockKey), 
    lockValue 
);

方案 2:引入 Redisson + 看门狗(终极方案)

如果希望彻底解决锁过期和续期问题,引入 Redisson 是最佳选择。

1. 添加依赖

xml 复制代码
<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson-spring-boot-starter</artifactId>
    <version>3.27.0</version>
</dependency>

2. 业务代码重构

java 复制代码
@Autowired
private RedissonClient redissonClient;

public void publishBlog(TravelBlog blog) {
    RLock lock = redissonClient.getLock("lock:blog:" + blog.getUserId());
    
    // tryLock(等待时间, 锁持有时间, 时间单位)
    // 锁持有时间传 -1 则启用看门狗(自动续期)
    if (!lock.tryLock(0, -1, TimeUnit.SECONDS)) {
        throw new BusinessException(429, "操作太频繁");
    }
    
    try {
        // 业务逻辑... 锁会自动续期(默认每 10 秒续一次)
    } finally {
        // 必须在 finally 中释放,且 Redisson 内部已实现原子化释放
        lock.unlock();  
    }
}

📝 总结

  • 轻量级场景 :如果业务执行极快(毫秒级),手写 SETNX + Lua 脚本解锁 已经足够,无需引入额外依赖。
  • 复杂/耗时场景 :如果业务逻辑复杂、执行时间不可控,强烈建议直接上 Redisson,利用看门狗机制保障分布式锁的绝对安全。