Administrator
发布于 2018-10-24 / 3824 阅读
96

Redis 分布式锁的正确实现:setnx 的那些坑

同一笔订单被扣了两次款:我写的分布式锁形同虚设

十月下旬,财务对账发现 7 笔订单重复扣款。查下来是用户点了两次支付按钮,两个请求几乎同时打到两台不同的应用Ubuntu 服务器上,都通过了库存和状态校验,各扣了一次。

我当时的第一反应是"加个锁不就行了",然后写出了这一版:

public boolean pay(Long orderId) {
    String lockKey = "pay:lock:" + orderId;
    Boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1");
    if (locked != null && locked) {
        redisTemplate.expire(lockKey, 30, TimeUnit.SECONDS);      // 单独设过期
        try {
            return doPay(orderId);
        } finally {
            redisTemplate.delete(lockKey);
        }
    }
    return false;
}

看着挺像回事。上线之后重复扣款确实没了,但出现了新问题,而且更严重。

问题一:setnx 和 expire 是两条命令

Redis 单条命令是原子的,但两条命令组合起来不是。下面这种情况会发生:

T1: 线程 A 执行 setnx 成功
T2: 应用进程被 kill -9(或 Redis 连接断开、机器宕机)
T3: expire 永远没执行 → 这把锁永不过期

后果是 pay:lock:12345 这个 key 永久存在,这笔订单再也付不了款。我们那次是发布应用时 kill 掉进程触发的,第二天客服接到投诉才发现。

正确做法是用一条命令搞定。Redis 从 2.6.12 开始扩展了 SET 的参数:

SET lock_key request_id NX PX 30000

参数含义:

  • NX:只有 key 不存在时才设置(等价于 setnx);
  • PX 30000:过期时间 30000 毫秒;
  • 整条命令原子执行,不存在中间态。

Jedis 和 Spring Data Redis 2.x 都支持:

// Jedis
String result = jedis.set(lockKey, requestId, "NX", "PX", 30000);
if ("OK".equals(result)) {
    // 拿到锁
}

// Spring Data Redis 2.0(StringRedisTemplate)
Boolean ok = stringRedisTemplate.opsForValue()
        .setIfAbsent(lockKey, requestId, 30, TimeUnit.SECONDS);

注意 Spring Data Redis 1.xsetIfAbsent(K, V) 没有带超时参数的重载,必须分开调 expire。我们项目一开始用的是 Boot 1.5 带的老版本,这也是我踩这个坑的直接原因。后来升到 Boot 2.0 + Spring Data Redis 2.0.8 才有这个重载。

问题二:误删别人的锁

第二个坑更隐蔽。看这个时序:

T1: 线程 A 拿到锁,过期时间 30 秒
T2: 线程 A 业务逻辑跑了 35 秒(比如调用第三方支付接口超时)
T3: 第 30 秒时锁自动过期,线程 B 拿到锁
T4: 第 35 秒线程 A 执行完,finally 里 delete(lockKey)
T5: A 把 B 的锁删了!线程 C 立刻又能拿到锁

这就是误删他人锁。锁失效之后,同时有两个线程在临界区里,重复扣款又回来了。

解决思路是:删锁之前先确认这把锁是不是自己的。所以 value 不能存 "1",要存一个全局唯一标识

String requestId = UUID.randomUUID().toString();
// 或者更实用的:线程 ID + 进程标识
String requestId = ManagementFactory.getRuntimeMXBean().getName() + ":" + Thread.currentThread().getId();

然后删除时先 get 比对再 del。但这两步又不是原子的

// 错误写法:get 和 del 之间有窗口
if (requestId.equals(redisTemplate.opsForValue().get(lockKey))) {
    redisTemplate.delete(lockKey);     // 这里锁可能已经过期并被别人持有
}

必须用 Lua 脚本把两步合成一个原子操作:

private static final String UNLOCK_SCRIPT =
    "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 requestId) {
    redisTemplate.execute(new DefaultRedisScript<Long>(UNLOCK_SCRIPT, Long.class),
            Collections.singletonList(lockKey), requestId);
}

Redis 执行 Lua 脚本是单线程的,整个脚本要么全执行要么不执行,中间不会被其他命令插入。这是做分布式锁的标准姿势。

问题三:业务没跑完,锁就过期了

上面那个 35 秒的场景还有个更麻烦的地方:就算加了唯一标识不会误删,A 的锁在第 30 秒失效了,B 照样能进来。锁的超时时间到底该设多少?

设短了业务跑不完,设长了宕机后要等很久才能恢复。我们的做法是加一个续期线程(watchdog):拿到锁之后起一个后台线程,每隔 过期时间 / 3 去检查锁是否还持有,是的话就延长过期时间。

private final ScheduledExecutorService renewPool = Executors.newScheduledThreadPool(1);

public boolean tryLockWithRenew(String key, String requestId, long expireMs) {
    Boolean ok = stringRedisTemplate.opsForValue()
            .setIfAbsent(key, requestId, expireMs, TimeUnit.MILLISECONDS);
    if (!Boolean.TRUE.equals(ok)) {
        return false;
    }
    ScheduledFuture<?> future = renewPool.scheduleAtFixedRate(() -> {
        String script = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
                        "    return redis.call('pexpire', KEYS[1], ARGV[2]) " +
                        "else return 0 end";
        redisTemplate.execute(new DefaultRedisScript<Long>(script, Long.class),
                Collections.singletonList(key), requestId, String.valueOf(expireMs));
    }, expireMs / 3, expireMs / 3, TimeUnit.MILLISECONDS);

    renewTasks.put(requestId, future);
    return true;
}

public void unlock(String key, String requestId) {
    ScheduledFuture<?> f = renewTasks.remove(requestId);
    if (f != null) {
        f.cancel(true);          // 先停掉续期线程
    }
    // 再执行解锁 Lua
}

自己实现这套东西有几个坑我都踩过:续期线程抛异常后 scheduleAtFixedRate静默停止后续调度,必须在任务体里 try-catch;续期任务没 cancel 会导致线程池里堆满僵尸任务;应用停机时要确保所有续期任务都停掉。

最终方案:直接用 Redisson

师傅看完我这套代码说了句:"Redisson 都帮你做了,别自己造。"

Redisson 2.x 的用法非常简单:

<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
    <version>3.6.5</version>
</dependency>
Config config = new Config();
config.useSingleServer()
      .setAddress("redis://10.0.1.20:6379")
      .setDatabase(0);
RedissonClient redisson = Redisson.create(config);

// 使用
RLock lock = redisson.getLock("pay:lock:" + orderId);
try {
    if (lock.tryLock(5, 30, TimeUnit.SECONDS)) {
        return doPay(orderId);
    }
    return false;
} finally {
    lock.unlock();
}

它的看门狗(watchdog)机制就是我上面手写的那个续期逻辑,而且做得更完善:

  • 不传 leaseTime 调用 lock() 时,默认锁超时 30 秒(lockWatchdogTimeout,可配置);
  • 后台线程每 lockWatchdogTimeout / 3 = 10 秒检查一次,还持有就重置为 30 秒;
  • 锁的实现是 hash 结构而不是 string,field 是线程标识,value 是重入次数,所以天然支持可重入
  • 解锁时如果重入次数大于 1 就只减 1,等于 0 时才真正删除。

我抓了下 Redisson 加锁的实际命令:

$ redis-cli monitor
1539823471.128940 [0 10.0.1.31:51234] "EVAL" "if (redis.call('exists', KEYS[1]) == 0) then
  redis.call('hset', KEYS[1], ARGV[2], 1);
  redis.call('pexpire', KEYS[1], ARGV[1]);
  return nil; end; ..." "1" "pay:lock:10086" "30000" "b983f7c1-...:thread-12"

确认是 hash + Lua,重入计数存在 field 的 value 里。

还有一个绕不过去的问题:主从切换

这个坑是我在做容灾演练时发现的。我们的 Redis 是一主一从 + 哨兵。场景:

T1: 客户端 A 在 master 上拿到锁
T2: master 还没把这条 SET 同步给 slave,master 挂了
T3: 哨兵把 slave 提升为新 master
T4: 客户端 B 在新 master 上拿到了同一把锁 → 两把锁同时存在

Redis 的主从复制是异步的,这个窗口客观存在。官方的解法是 Redlock 算法:部署 5 个独立的 Redis 节点(不是主从,是 5 个 master),客户端依次向 5 个节点申请锁,拿到超过半数(≥3 个)且总耗时小于锁有效时间才算成功。

RedissonRedLock redLock = new RedissonRedLock(lock1, lock2, lock3, lock4, lock5);
try {
    redLock.tryLock(5, 30, TimeUnit.SECONDS);
    // ...
} finally {
    redLock.unlock();
}

但 Redlock 有争议(Martin Kleppmann 那篇著名的文章就是反对它的,主要质疑点在于 GC 停顿和时钟漂移会导致锁提前失效)。我们最后没有上 Redlock,理由是:支付这种强一致场景改用数据库唯一索引 + 状态机来做幂等,锁只是辅助;而库存扣减用 Redis 的 decrby 原子操作配合数据库乐观锁,不依赖互斥锁。

具体做法是加一张去重表:

CREATE TABLE pay_record (
  id bigint NOT NULL AUTO_INCREMENT,
  order_id bigint NOT NULL,
  request_id varchar(64) NOT NULL,
  status tinyint NOT NULL,
  PRIMARY KEY (id),
  UNIQUE KEY uk_order (order_id)      -- 关键
) ENGINE=InnoDB;

重复请求插入时会撞唯一索引,捕获 DuplicateKeyException 直接返回"处理中"。数据库层面的约束比 Redis 锁可靠得多。

最终效果和踩坑清单

改成 Redisson + 去重表之后,跑了三周,重复扣款 0 笔。压测数据:单机 500 并发下,同一订单的重复请求全部被挡掉,加锁平均耗时 1.8ms(Redis 内网 RTT 约 0.6ms)。

清单记一下:

  • 加锁必须用 SET key value NX PX timeout 一条命令,不能拆成 setnx + expire;
  • value 存唯一标识,解锁用 Lua 脚本先比对再删;
  • 超时时间要覆盖业务最大耗时,或者用 Redisson 的看门狗自动续期;
  • 锁的粒度要细,pay:lock:{orderId} 而不是 pay:lock,否则所有订单串行;
  • 不要指望 Redis 锁解决一致性问题,兜底一定放在数据库约束上;
  • finally 里解锁前要判断当前线程是否真的持有锁,否则会抛 IllegalMonitorStateException

最后一条我也踩过:业务抛异常时 Redisson 的 unlock() 会因为锁已经过期而报错,反而把真正的业务异常覆盖了。

参考