微服务架构或分布式场景下,以往基于单机 jvm 的锁机制失效了,为此我们需要实现分布式锁。分布式锁的思路就是,将锁资源统一存放在一个外部共享系统,各进程实例在这个外部共享系统实现锁的互斥申请。
这个外部系统可以是 Mysql,redis,zookeeper。
因为 redis 天然的原子操作和支持多客户端访问的特性,这里我们讲 redsi 实现分布式锁的解决方案。
我们采用一个 redis 实例进行锁资源的存储。获取锁,则锁状态为 1,释放锁,则锁状态为 0。
首先,对于锁的获取,我们使用 setnx 命令 setnx lock 1;del lock 释放锁。但是,在获取锁和释放锁期间,有异常发生时无法释放锁,会造成死锁问题;
为此,我们可以设置过期时间,不推荐 expire tiemout;因为加锁和设置过期时间无法保证原子性,如果中间有异常设置过期时间可能无法成功(当然我们可以使用setnx+expire+lua保证原子性);但是版本升级后redis提供了打包命令,可以使用 set lock 1 px 10000 nx 命令保证原子性。然后对于锁的释放,可能由于共享资源操作时间无法准确评估或者是网络异常原因,存在锁过期和释放别人的锁等问题。
对于释放别人的锁:引入唯一值,能够防止错误释放了别人的锁。
对于锁过期,这种方式不能很好解决。提供一个思路:通过一个守护线程定时获取锁状态,实现锁续期。(比如 redisson 框架使用 watchdog 机制实现锁续期)
对于锁的释放:涉及到锁变量的读、检查、设置,需要保证原子性执行,我们可以使用 lua 脚本解决:
(k==v保证释放的是自己的锁)
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
至此,分布式锁基本够用了。
一些思考的问题:加锁失败怎么办?自旋获取锁是一个思路。另外,基于发布订阅机制,如果锁获取失败,则进入阻塞状态,订阅锁锁释放消息,当有客户端释放锁时,收到释放信息,尝试加锁。
单实例同通常不够可靠,我们 redis 通常采用主从模式。在主从切换过程种上述单实例锁机制就存在可靠性问题。为此,我们可以构建基于多个 redis 实例的锁。也就是 redlock 锁。基本思路:
锁申请成功的标志:客户端拿到半数以上实例的锁,且申请总时间不超过锁的有效期。
那么锁的释放也是类似的,全部节点都释放锁,采用 lua 脚本原子性释放锁。
redis 实现分布式锁的正确打开方式:利用 set px timeout nx 命令加锁,利用 lua 脚本判断并释放锁。但是你要知道,这里锁过期时间不好评估,也就意味着会有提前过期的风险。比冗余过期时间更好的方案是,需要额外增守护线程。有一个更好的机制是采用 redisson 框架,可以去了解一下。
redlock 可能实际使用中少,性能差是主要原因。
框架实现了类似的功能。它还提供了可重入、乐观锁、公平锁、读写锁等能力,像操作本地锁一样操作分布式锁。实现思路也是单机锁类似的思路,比如:
可重入锁:获取锁的客户端将锁与唯一 id 绑定,并使重入次数自增;释放时自减;当下次获取锁,识别 ID 和判断重入次数是否大于 0.
mysql 可以使用乐观锁:版本号机制;悲观锁:for update 形式实现分布式锁;
zookerper,在某个持久节点添加临时有序节点,判断当前节点是否是序列中最小的节点,如果不是则监听比当前节点还要小的节点。如果是,获取锁成功。当被监听的节点释放了锁 (也就是被删除),会通知当前节点。然后当前节点再尝试获取锁,如此反复。
基于 CAP 理论,一致性(Consistency) 可用性(Availability) 分区容错性(Partition tolerance) 只能满足其二。
如果业务场景,更需要的是保证数据一致性。那么使用 CP 类型的分布式锁,比如:zookeeper,它是基于磁盘的,性能可能没那么好,但数据一般不会丢。
如果你的实际业务场景,更需要的是保证数据高可用性。那么请使用 AP 类型的分布式锁,比如:redis,它是基于内存的,性能比较好,但有丢失数据的风险。
其实,在我们绝大多数分布式业务场景中,使用 redis 分布式锁就够了,因为数据不一致问题,可以通过最终一致性方案解决。我们更应该关注可用性。
参考链接:https://www.zhihu.com/question/300767410