Administrator
发布于 2020-04-02 / 11672 阅读
152

Redis 分布式锁从 setnx 到 Redisson 的演进

一次库存超发,我们把手写的 setnx 锁全换成了 Redisson

3 月底的一次促销活动,某款商品库存 200 件,实际卖出 213 件。超发 13 件。客服赔了 13 张 50 元券,加上运营的抱怨,够我记很久。

查下来是分布式锁失效。我们当时用的是自己封装的 setnx 锁,问题出在一个很隐蔽的地方:锁过期了,但业务还没执行完。

第一版:setnx 加 expire,两条命令

2018 年刚开始用 Redis 锁的时候,代码是这样的:

public boolean tryLock(String key, String value, int expireSec) {
    Long ok = jedis.setnx(key, value);
    if (ok == 1) {
        jedis.expire(key, expireSec);   // 这两步之间如果进程挂了……
        return true;
    }
    return false;
}

这不是分布式锁的问题,这是原子性的问题。setnx 成功之后、expire 之前进程崩溃,这把锁就永远不过期了,其他节点全部阻塞。

线上出过一次:2019 年 6 月,Redis 里有个 lock:stock:10037 存活了 11 天,直到 Redis 重启才释放。期间这个商品的库存调整全部超时。

第二版:一条命令搞定

Redis 2.6.12 之后 SET 支持 NXPX,可以一条命令完成加锁加过期:

public boolean tryLock(String key, String requestId, long expireMs) {
    String ret = jedis.set(key, requestId, "NX", "PX", expireMs);
    return "OK".equals(ret);
}

requestId 不能是固定值,必须是每个线程唯一的(我们用 UUID + threadId),用来在解锁时确认"这把锁是不是我加的"。

第三版:解锁要判断 owner,而且得用 Lua

解锁的时候不能直接 del。想一下这个时序:

线程 A 加锁,过期时间 10 秒
线程 A 执行了 12 秒(GC 停顿、数据库慢查询),锁已经自动过期了
线程 B 加锁成功
线程 A 终于执行完了,del(key) —— 把 B 的锁删了
线程 C 加锁成功,B 和 C 同时在临界区

正确姿势是"取值判断 + 删除"两步,而这两步又必须是原子的,所以要 Lua:

private static final String UNLOCK_LUA =
    "if redis.call('get', KEYS[1]) == ARGV[1] then " +
    "    return redis.call('del', KEYS[1]) " +
    "else " +
    "    return 0 " +
    "end";

public void unlock(String key, String requestId) {
    jedis.eval(UNLOCK_LUA, Collections.singletonList(key),
               Collections.singletonList(requestId));
}

到这里,单实例的 Redis 锁基本是完备的。但我们还是超发了 13 件。为什么?

真正的坑:锁过期时间不好定

出事那天的代码:

String lockKey = "lock:stock:" + skuId;
try {
    if (redisLock.tryLock(lockKey, requestId, 5000)) {   // 锁 5 秒
        Stock stock = stockMapper.selectForUpdate(skuId);
        if (stock.getAvailable() > 0) {
            stockMapper.deduct(skuId, 1);
            createOrder(...);        // 这里调了风控,偶尔要 3 到 8 秒
        }
    }
} finally {
    redisLock.unlock(lockKey, requestId);
}

超时设了 5 秒,是因为当时大部分订单处理只要 800 毫秒。但那天风控服务抖动,13 个请求的处理时间超过了 5 秒。锁自动释放,下一个请求进来,看到的是还没扣减的旧库存。

从监控上看得很清楚:

20:31:07  接口 P99 从 780ms 涨到 6.2s(风控 RT 飙升)
20:31:12  第一个超发订单产生
20:31:19  第 13 个超发订单
20:32:40  风控恢复,超发停止

把过期时间调大到 30 秒?那如果进程真的挂了,要等 30 秒锁才释放,可用性又变差。这是个两难。

第四版:Redisson 的看门狗

Redisson 的答案是自动续期。加锁成功之后,后台起一个定时任务,每隔 lockWatchdogTimeout / 3 的时间检查一次,如果锁还在持有,就把过期时间重置。业务跑多久,锁就续多久。

<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson-spring-boot-starter</artifactId>
    <version>3.11.5</version>
</dependency>
@Service
public class StockService {

    @Autowired
    private RedissonClient redisson;

    public boolean deduct(Long skuId, int qty) {
        RLock lock = redisson.getLock("lock:stock:" + skuId);
        boolean locked = false;
        try {
            // 最多等 3 秒拿锁,拿到之后由看门狗自动续期
            locked = lock.tryLock(3, TimeUnit.SECONDS);
            if (!locked) {
                return false;
            }
            Stock stock = stockMapper.selectForUpdate(skuId);
            if (stock.getAvailable() < qty) {
                return false;
            }
            stockMapper.deduct(skuId, qty);
            return true;
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return false;
        } finally {
            if (locked) {
                lock.unlock();
            }
        }
    }
}

关键细节:

  • tryLock(waitTime, unit) 只传等待时间,不传 leaseTime。这才是看门狗模式。lockWatchdogTimeout 默认 30 秒,续期间隔 10 秒。
  • 如果写成 tryLock(3, 30, TimeUnit.SECONDS) 传了 leaseTime,看门狗不会启动,锁 30 秒后无条件释放。这个坑我见过好几次,源码里 tryLockInnerAsync 只在 leaseTime == -1 时才注册续期任务。
  • 看门狗是 Redisson 实例级别的后台线程。进程被 kill -9 之后续期停止,锁最多再活 30 秒自动过期,不会永久死锁。

关于 Redlock 的争议

单 Redis 实例有个绕不开的问题:主从切换会丢锁。

客户端 A 在 master 上加锁成功
master 还没把这条 set 同步给 slave,master 挂了
slave 被提升为新 master
客户端 B 在新 master 上加锁,也成功了
A 和 B 同时持有锁

Redis 的复制是异步的,这个窗口客观存在。Redlock 就是 antirez 给出的解法:部署 5 个互相独立的 Redis master(不是主从,是没有关系的 5 个实例),客户端要拿到多数(3 个)才算加锁成功。

Martin Kleppmann 在 2016 年写过一篇很出名的反驳,核心论点是:

  • Redlock 依赖"本地时钟是可靠的"这个假设。如果客户端发生长时间 GC 停顿,锁过期了它还以为自己持有,Redlock 无能为力。
  • 异步模型下,没有 fencing token(单调递增的令牌)就无法保证互斥。

antirez 的回应是"时钟跳跃可以通过运维避免,GC 停顿可以通过合理设置超时缓解"。

我们的结论是不用 Redlock。理由很实在:

考虑我们的判断
成本5 个独立 Redis 实例,运维复杂度和机器成本翻倍,我们 Redis 只有 3 个节点
收益它解决的是极端情况下的锁失效,而我们有数据库层的兜底
争议连作者和分布式系统专家都没达成共识,工程上不该押注

真正的兜底在数据库

这是我最想强调的一点:Redis 锁是性能优化手段,不是数据一致性手段

我们改完之后,减库存的 SQL 长这样:

UPDATE t_stock
   SET available = available - #{qty},
       version = version + 1
 WHERE sku_id = #{skuId}
   AND available >= #{qty}

available >= #{qty} 这个条件让减库存变成了原子操作,返回的影响行数是 0 就说明库存不足。即使 Redis 锁完全失效,也不会超卖。

压测验证过:把 Redis 锁整个停掉,只靠这条 SQL,200 件库存并发卖出,实际卖出 200 件,0 超发。代价是所有请求都打到数据库,QPS 从 3400 掉到 890。

所以我们的分层是:

  • Redis 锁:挡住 99% 的并发,让数据库少干活。这是性能层。
  • 数据库条件更新:保证数据正确性。这是安全层。

选型对比

方案正确性复杂度我们的场景
手写 setnx + Lua单实例够用,主从切换丢锁中,续期要自己做已废弃
Redisson RLock同上,但有可重入和续期当前主力
Redlock理论上更强,但有争议高,要 5 个独立实例不用
数据库排他锁 select for update最强低,但性能差对账、定时补偿这类低频任务

Redisson 还有个我们常用的:RReadWriteLock。商品信息缓存更新用它,读锁共享、写锁互斥,比普通锁吞吐高不少。

就写到这。如果哪天你也被《Redis 分布式锁从 setnx 到 Redisson 的演进》里同一个坑绊住,回来翻这篇,能省半小时。

参考