ElIauk

Redis 分布式锁入门:SETNX 与过期时间

为什么 SETNX + EXPIRE 不够 用 Redis 做分布式锁,最直观的写法是: Boolean ok = redisTemplate.opsForValue().setIfAbsent(key, "1"); if (Boolean.TRUE.equals(ok)) { redisT

评论
Redis 分布式锁入门:SETNX 与过期时间

为什么 SETNX + EXPIRE 不够

用 Redis 做分布式锁,最直观的写法是:

Boolean ok = redisTemplate.opsForValue().setIfAbsent(key, "1");
if (Boolean.TRUE.equals(ok)) {
    redisTemplate.expire(key, 30, TimeUnit.SECONDS);
    // 执行业务
}

这段代码有两个隐患。第一,SETNX 和 EXPIRE 是两条命令,如果服务在两条命令之间宕机,锁就永远不会过期,其他节点全部被卡死。第二,释放锁时如果直接 DEL,可能删掉别人的锁——自己的锁过期了,别人重新加锁成功,自己执行完再把别人的锁删了。

Redis 2.6.12 之后,SET 命令支持 NX 和 PX 参数,可以一条命令完成"不存在才设置 + 设置过期时间"。释放锁则需要 Lua 脚本保证"比较值 + 删除"的原子性。

加锁:一条命令

public String tryLock(String lockKey, Duration ttl) {
    String token = UUID.randomUUID().toString();
    Boolean ok = stringRedisTemplate.opsForValue()
            .setIfAbsent(lockKey, token, ttl);
    return Boolean.TRUE.equals(ok) ? token : null;
}

对应的 Redis 命令是:

SET lock:order:1001 <uuid> NX PX 30000

几个要点:

  • token 必须是每次加锁唯一的值,不能写死成 1,否则没法区分是谁的锁;
  • NX 保证只有 key 不存在时才设置成功;
  • PX 30000 表示 30 秒后自动过期,避免死锁;
  • 加锁和设置过期在一条命令里完成,不存在中间状态。

解锁:必须原子

先 GET 判断值,再 DEL,这两步之间锁可能已经过期并被别人持有,判断就失效了。正确做法是用 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 =
        new DefaultRedisScript<>(
                "if redis.call('get', KEYS[1]) == ARGV[1] then " +
                "return redis.call('del', KEYS[1]) else return 0 end",
                Long.class);

public boolean unlock(String lockKey, String token) {
    Long result = stringRedisTemplate.execute(
            UNLOCK_SCRIPT, Collections.singletonList(lockKey), token);
    return result != null && result > 0;
}

释放时放在 finally 里,并且只在确实拿到锁的情况下调用:

String token = tryLock("lock:order:" + orderId, Duration.ofSeconds(30));
if (token == null) {
    throw new BusinessException(40900, "操作太频繁,请稍后再试");
}
try {
    doBusiness(orderId);
} finally {
    unlock("lock:order:" + orderId, token);
}

锁过期了业务还在跑

即使有了过期时间,还有一个绕不开的问题:业务执行时间超过 TTL,锁自动释放,另一个线程拿到锁,两个线程同时操作同一份数据。

常见应对方式有三种:

  1. 评估耗时,把 TTL 设得足够长。简单,但业务变慢时仍然会失效;
  2. 看门狗续期。起一个后台任务,在锁过期前定期把 TTL 重置。Redisson 的 tryLock 默认就带看门狗,不指定 leaseTime 时会每 10 秒续一次;
  3. fencing token。每次加锁生成一个递增的版本号,写数据库时带上版本号做条件更新,旧版本号的写入会被拒绝。这是更彻底的方案,但改造成本高。

大多数业务用前两种就够了。如果一致性要求极高,比如扣款、库存,最终还是要靠数据库的唯一约束或乐观锁兜底,不能把正确性完全押在 Redis 锁上。

还有一个容易被忽略的问题:主从切换。Redis 主节点写入锁之后还没同步到从节点就挂了,从节点升主,锁就丢了。RedLock 算法试图解决这个问题,但业界对其安全性一直有争议。对强一致场景,用数据库唯一索引或者 fencing token 更可靠。

常见坑

1. 忘记设置过期时间

只要有一条加锁路径没设 TTL,进程崩溃后这个 key 就永久存在,对应的业务再也拿不到锁。把加锁封装成一个方法,强制传 TTL。

2. TTL 拍脑袋

30 秒对简单查询够用,对批量导入就太短。按 P99 耗时乘以一个安全系数来估,同时给业务加超时控制。

3. 解锁不校验 token

直接 DEL 是分布式锁最经典的事故来源。哪怕业务逻辑再简单,也要用 Lua 比较 token。

4. 锁粒度过粗

用 lock:order 锁住所有订单,并发度直接降到 1。锁的粒度应该精确到资源,比如 lock:order:1001。

5. 锁内做长耗时操作

在锁里调远程接口、等用户输入、跑大批量计算,锁持有时间被无限拉长。锁内只做必要的临界区操作。

6. 先释放锁再提交事务

@Transactional
public void update() {
    // ... 更新数据库
    unlock(); // 事务还没提交就释放,其他线程读到旧数据
}

锁应该在事务提交之后释放。可以把加锁放在事务外层,或者用 afterCommit 回调释放。

7. 不可重入导致自己锁自己

同一个线程嵌套调用加锁方法,第二次拿不到锁,直接死锁。要么避免嵌套,要么用带重入计数的实现,比如 Redisson。

8. 获取失败没有退避

拿不到锁就立刻重试,Redis 和数据库都会被打出额外压力。用带随机抖动的退避重试,或者直接快速失败。

排查步骤

锁相关问题通常表现为"接口偶发超时""同一条数据被处理两次"。排查时先看日志:

  1. 记录加锁的 key、token、耗时、是否成功;
  2. 用 TTL lock:order:1001 确认锁的剩余时间;
  3. 用 GET 看 value 是不是当前线程的 token;
  4. 统计加锁失败率和平均等待时间,判断是竞争激烈还是锁泄漏;
  5. 检查业务代码有没有在锁内做远程调用、有没有漏掉 finally。

MONITOR 命令可以看实时命令流,但会对性能有影响,只在测试环境短时间使用。生产环境优先看业务日志和 SLOWLOG。

参考

评论

推荐阅读

订阅邮件

订阅更新,及时收到最新文章。

作者