Redis 分布式锁的进阶之旅

在分布式系统中,针对共享资源的互斥访问 (mutually exclusive access) 一直是很多业务系统需要解决的问题,而分布式锁常常作为一种通用的解决方案被提出来。互斥能力一般是由第三方中间件来提供,比如:Redis 、ZooKeeper 和 Etcd 等;当然 MySQL 也是可以的,我们可以新建一个专门的锁表 (tbl_lock),数据插入成功意味着抢占到了锁,而数据删除成功则意味着释放了锁,在数据没有删除的情况下,另一客户端试图抢占该锁 (即插入一条记录) 的话则会报主键重复的错误。

1 Redis 分布式锁的进阶之旅

1.1 key 的设计

key 要能够全局地标识共享资源的唯一性,一般多选择 bizKey 来充当。

1.2 锁的粒度

锁的粒度尽量精细一些。比如在一个长流程的业务逻辑中,只有扣减库存才涉及对共享资源的互斥访问,那就应该只针对扣减库存逻辑进行锁的抢占与释放。

1.3 锁的释放

锁一旦抢占成功,待业务逻辑执行完毕,必须要显式地释放掉,建议将释放锁的逻辑放在finally代码块中;此外,锁只能由持有该锁的对象来释放,绝不允许出现“张三释放了李四持有的锁”这一现象。

大家觉得下面这段伪代码在锁的释放上有啥问题?

String rst = jedis.set(bizKey, randomIntValue, SetParams.setParams().nx().px(30));
if (!OK.equals(rst)) {
    return;
}
try {
    doBizAction();
} finally {
    jedis.del(bizKey);
}

想象一下,如果 Client 1 抢占到了锁之后发生了 Full GC,整个 JVM 进程卡在那里不动了,业务逻辑当然是不会执行的,当 Full GC 执行完毕后锁已经过期而被 Redis 自动释放掉了;然后 Client 2 将会抢占到该锁,此时 Client 1 执行完业务逻辑之后会把 Client 2 持有的锁给释放掉。如下图所示。

针对这一问题,我们可以在抢占锁的时候将 requestId 作为 value,改进版伪代码如下所示。

String rst = jedis.set(bizKey, requestId, SetParams.setParams().nx().px(30));
if (!OK.equals(rst)) {
    return;
}
try {
    doBizAction();
} finally {
    if(requestId.equals(jedis.get(bizKey))) {
        jedis.del(bizKey);
    }
}

很遗憾,这么改还是有问题,主要体现在 get()、equals() 和 del() 这三个操作不满足原子性。如果在执行 get() 操作之后发生了 Full GC,当 JVM 进程恢复后 Client 1 所持有的锁已经过期而被释放,此时 Client 2 成功抢占到了该锁,可此时 Client 1 所在线程会继续执行 equals() 和 del() 操作,也就是说 Client 1 还是释放掉了 Client 2 持有的锁。如下图所示。

显然,此时我们需要使用 lua 脚本来确保 get()、equals() 和 del() 这三个操作满足原子性即可。lua 脚本如下所示。

if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end

1.4 死锁

锁一定要有过期时间,万一持有锁的对象无法释放掉锁,那么该锁后续也就无法再次被持有了。

1.5 锁过期时间的设定

如果锁过期时间设定过短,那么在业务还未执行完毕的情况下,锁可能被别人抢占了;而过期时间设定过长,又会严重影响业务的吞吐量。比较好的方案是开启一个线程来不断续期,Redisson 就是这么干的,下一章节会详细介绍。

1.6 抢占锁失败之后的处理方式

抢占锁失败之后,一般有两种处理方式。1) 直接抛出异常从而让用户重试,存在用户体验不佳的问题;2) 通过自旋机制不断地重新抢占锁,该方案在高并发场景下并不可取,因为会导致 CPU 资源的浪费。笔者这里蹭个热点,来看看 ChatGPT 是如何回答该问题的。

1.7 异步复制

为了构建高可用的 Redis 服务,往往很少选择单节点或者单纯的 master-slave 部署架构,一般会选择哨兵 (Sentinel) 或 集群 (Cluster) 部署架构。在哨兵和集群架构中,一个 master 节点总会有一个或多个 slave 节点,毕竟为了高可用,数据冗余是必须的,而在 master 节点通过异步复制 (asynchronous replication) 机制将数据传递到若干 slave 节点过程中,由于没有 ZooKeeper 那种强一致性共识协议,这可能造成数据不一致的现象,也就是说分布式锁的互斥性在 Redis 中是无法做到百分之百可靠的!

如上图所示。Client 1 成功抢占到了锁;紧接着 master 节点挂点了,从而导致数据无法传递到 slave 节点;然后 salve 节点晋升为新的 master 节点;最终,Client 2 将会抢占到该锁。

为了解决这一问题,Redis 的设计者 antirez 提出了大名鼎鼎的 RedLock 方案。RedLock 方案的前提是需要 N 个 Redis master 节点,这些 master 节点之间是完全相互独立的,不存在任何异步复制操作!RedLock 的核心思想:依次向 N 个 master 节点发起抢占锁的请求,如果在至少N/2+1个 master 节点中成功地抢占了锁,那么就认为最终锁抢占成功。

Martin Kleppmann 大神在其 How to do distributed locking 一文中,评价 RedLock 是一种不伦不类、完全建立在三种假设基础上的分布式锁方案。这三种假设如下。

  • 进程暂停 (processe pausing) ,假设进程暂停时间远远小于锁的过期时间。
  • 网络时延 (network delaying) ,假设网络时延远远小于锁的过期时间。
  • 时钟漂移 (clock drift) ,假设锁在所有 Redis 节点中的存活时间与其过期时间是相匹配的。

Redis 是由 C 语言开发而来的,自然不存在进程暂停之说,其实进程暂停的对象指的是 Redis 客户端,比如一个 Spring Boot 应用。Martin Kleppmann 给出了一个由 Redis 客户端进程暂停造成数据不一致的典型场景,如下图所示。

针对上述问题,Martin Kleppmann 给出了一个名为 fencing token 的解决方案,如下图所示。

2 Redisson 部分源码解读

Redisson 应该是当前市面上最强大的一款 Redis 分布式锁解决方案了,使用起来比较省心,很多问题都替你考虑到了。

Config config = new Config();
config.setLockWatchdogTimeout(30000)
        .useSingleServer()
        .setAddress("redis://127.0.0.1:6379")
        .setDatabase(0);
RedissonClient redissonClient = Redisson.create(config);

RLock lock = redissonClient.getLock("my-lock");
boolean rst = lock.tryLock();
try {
    if (rst) {
        doBizAction();
    }
} finally {
    if (lock.isLocked() && lock.isHeldByCurrentThread()) {
        lock.unlock();
    }
}

redissonClient.shutdown();

RedissonSpinLockRedissonLock是 Redisson 中两个极为常用的非公平、可重入锁。RedissonSpinLock 与 RedissonLock 最大的区别在于它没有将“发布-订阅”机制整合到锁的抢占与释放流程中,这应该是有意为之。因为在大规模 Redis Cluster 中,“发布-订阅”机制会产生广播风暴。具体地,Redis 的“发布-订阅”机制是按照 channel (通道) 来进行发布与订阅的,然后在 Redis Cluster 模式下,channel 不会参与基于 hash 值的 slot 分发,也就是说发布的消息将以广播的形式在集群中传播开来。那么问题是显而易见的,假设一个 Redis Cluster 中有 100 个分片主节点;用户在节点 1 发布消息,该节点就会把消息广播给其他 99 个节点;若在这 99 个节点中,只有零星几个节点订阅了该 channel,这势必会造成网络、CPU 等资源的浪费。幸运的是,Redis 7.0 终于支持Sharded Pub/Sub特性了。

RedissonSpinLock 与 RedissonLock 均使用 Redis Hash 数据结构来承载锁的抢占与释放动作,lockName 用于标识一个 Hash 实例࿰

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值