分布式锁学习与总结

分布式锁学习与总结

背景

在系统中进行数据修改时,一般都需要先读取数据,然后进行修改保存,在并发场景下,如果对数据的准确性要求比较严格,对数据的修改和保存要进行原子性操作,避免出现数据丢失和失效的情况。传统的单服务系统我们常用本地锁来避免并发带来的问题,然而,当服务采用微服务集群方式部署时,本地锁无法在多个服务器之间生效,这时候保证数据的一致性就需要分布式锁来实现。

例如,现在的业务应用通常都是微服务架构,已采购库下单扣减库存为例,当部署的多个订单服务需要同时扣减数据库中的同一行库存时,如果还是使用本地服务本地锁来避免并发问题,多个订单单体服务中的锁不知道其他订单服务本地锁的存在,不具备互斥性,为了避免操作乱序导致数据错误,此时,我们就需要引入这个外部系统,必须要实现「互斥」的能力,来解决这个问题了,这个外部系统提供的能力我们可以称之为分布式锁。

通常为了确保锁服务安全可用,需同时满足以下几个特性:

  1. 互斥性:在相同时刻,只有一个客户端能持有相同资源的锁
  2. 安全性:对一把锁,只能由同一个客户端进行加锁和解锁
  3. 可用性:避免死锁,相同资源的锁不能无限期有某个客户端持有,导致后续其他客户端不能加锁

方案

分布式锁的实现,目前常用的方案有以下三类:

  1. 基于数据库事务实现的锁服务;
  2. 基于分布式缓存实现的锁服务,典型代表有 Redis等;
  3. 基于分布式一致性算法实现的锁服务,典型代表有 ZooKeeper等。

下面分别介绍一下这些典型的方案的实现方式和大概逻辑

基于数据库

最简单的方法就是创建一张锁表,获取锁的时候在表中增加一条记录,释放锁的时候删除这条记录,利用数据库的唯一索引来保障互斥性。

例如参考的表结构如下:

CREATE TABLE `db_lock` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`resource` int NOT NULL COMMENT '锁定的资源',
PRIMARY KEY (`id`),
UNIQUE KEY `uiq_idx_resource` (`resource`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='数据库分布式锁表';
// 获取锁时,可以插入一条数据:
INSERT INTO db_lock(resource) VALUES (1);
// 释放锁时,可以删除这条数据:
DELETE FROM db_lock WHERE resource=1;

在表 db_lock 中,resource 字段做了唯一性约束,这样如果有多个请求同时提交到数据库的话,数据库可以保证只有一个操作可以成功。

数据库表实现方式非常简单,但是需要注意以下几点:

  1. 如果锁释放失败了,可能导致后续超过出现问题,需要定时进行清理或者进行监控运营
  2. 锁是非阻塞的,如果需要阻塞的,可以使用循环语句直至INSERT成功
  3. 大规模并发的情况下,数据库并发可能会成为瓶颈,适合于并发不是特别高的场景

基于Redis

常见的基于Redis实现分布式锁主要有两大类,一类是基于Redis主从模式的单机实现,另一类是基于Redis集群的多机实现。

基于Redis单机实现的分布式锁

基本步骤如下:

  1. 使用原子性命令进行加锁,加锁示例代码如下:

// 加锁
String uuid = UUID.randomUUID().toString().replaceAll("-","");
SET key uuid NX EX 30

  1. 为了避免执行业务逻辑时间过长,超过了redis的失效时间,启动守护线程,对加锁的key进行续期
  2. 执行业务逻辑,操作共享资源
  3. 为了避免非原子性操作,使用lua脚本来对锁进行删除释放,删除时需对加锁的来源进行判断,避免删除其他客户端持有的锁,示例代码如下

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

上面的代码作为参考,实际在生产环境中,可以使用开源框架Redisson来实现,Redisson不仅简便易用,且支持Redis单实例、Redis主从、Redis Sentinel、Redis Cluster等多种部署架构。

总体而言,主从单机模式性能比较好,也有比较成熟的业界开源方案,也可能出现以下的一些问题

  1. 锁是非阻塞的,无论成功还是失败都直接返回,可以循环语句重复执行并设置重试次数,直到获取对应的锁为止
  2. 如果删除锁失败,守护线程一直执行,可能导致后续处理逻辑获取不到锁,这里可以自行设定下续期逻辑,例如设置多长续期时间
  3. 主从模式在切换的时候存在一定的时间差,这个时候可能出现同时获取到锁的情况,一般来说需要对这样的场景进行监控及运营处理

基于Redis多机实现的分布式锁Redlock

上面讨论到基于Redis单机实现的分布式锁的一个问题,主从切换的时候存在时间差,在未完成数据同步的情况,其他客户端上的线程依然可以获取到锁,因此会丧失锁的安全性。正因为如此,Redis的作者Antirez提供了RedLock的算法来实现一个分布式锁。下面简单介绍一下该算法的流程:

假设有 N(N>=5)个Redis节点,这些节点完全互相独立。(不存在主从复制或者其他集群协调机制,确保这N个节点使用与在Redis单实例下相同的方法获取和释放锁)

获取锁的过程,客户端应执行如下操作:

  1. 获取当前Unix时间,以毫秒为单位。
  2. 依次尝试从N个实例,使用相同的key和具有唯一性的value(例如UUID)获取锁。当向Redis请求获取锁时,客户端设置一个创建锁的超时时间,这个超时时间应该远小于锁的失效时间。这样可以避免服务器端Redis已经挂掉的情况下,客户端还在一直等待响应结果。如果服务器端没有在规定时间内响应,客户端应该尽快尝试去另外一个Redis实例请求获取锁。
  3. 客户端使用当前时间减去开始获取锁时间(步骤1记录的时间)就得到获取锁消耗的时间。当且仅当从大多数(大于N/2+1,这里是3个节点)的Redis节点都取到锁,并且获取锁消耗的时间小于锁失效时间时,锁才算获取成功。
  4. 如果取到了锁,key的真正有效时间等于有效时间减去获取锁消耗的时间(步骤3计算的结果)。
  5. 如果因为某些原因,获取锁失败(没有在至少N/2+1个Redis实例取到锁或者取锁时间已经超过了有效时间),客户端应该在所有的Redis实例上进行解锁。(虽然某些Redis实例根本就没有加锁成功,防止某些节点获取到锁但是客户端没有得到响应而导致接下来的一段时间不能被重新获取锁)

对于Redlock算法,分布式专家 Martin对提出了质疑以及Redis的作者Antirez的回应,感兴趣可以取网上搜索一下

Redlock通过多节点确实可以提供更高的可用性,但也可能存在一些问题

  1. 需要多台redis服务器,运营维护更加困难,部署更加复杂,加锁,解锁,续期等都需要更多的协调机制
  2. 如果出现服务器挂掉的情况,可能出现脑裂等情况

基于ZooKeeper

ZooKeeper是一个分布式的,开放源码的分布式应用程序协调服务,是Google的Chubby一个开源的实现,是Hadoop和Hbase的重要组件。可以类比为一个分布式的文件存储系统,提供类似于文件系统的目录树的管理方式,并对树的节点进行有效管理,用来为分布式应用提供一致性服务。一般来说,利用ZooKeeper临时顺序节点的特性实现一个分布式锁。

基本步骤如下:

  1. 客户端连接ZooKeeper,并在 /locks 下相应资源 /key 下创建临时有序的子节点,假设第一个客户端对应的子节点为001,第二个为002,以此类推。
  2. 客户端获取子节点列表,判断创建的节点是否为当前列表中序号最小的节点,如果是则认为获得锁,否则监听前一个子节点的删除消息。
  3. 获取锁的客户端,执行业务代码流程后,删除当前客户端对应的子节点释放锁,
  4. 监听当前子节点的删除消息的后一个客户端接收到删除消息,则获取到锁,进行业务逻辑处理及删除锁等流程,以此类推。

该方案中,ZooKeeper具备很好的故障恢复能力和数据一致性保障,能够在Leader节点宕机后, 集群会根据一致性算法选出新的Leader处理请求,同时ZooKeeper有序临时节点在在客户端宕机或者断开后能够自动删除,保障了不会因为环境问题来导致节点不会删除,引发后续流程阻塞的问题,但是因为采用强一致性协议,高并发场景,性能上不如使用Redis实现分布式锁,同时对比主从redis的分布式式锁,ZooKeeper的运营成本可能会更高一些。

经验

在小型系统中,可以使用mysql等来实现数据库的分布式锁,在注重CP一致性的场景下,可以使用基于分布式一致性算法实现的锁服务,如ZooKeeper等,而在注重AP的场景下,使用Redis等基于分布式缓存实现的锁服务则是更好的选择。

posted @ 2023-01-23 23:16  S&L·chuck  阅读(46)  评论(0编辑  收藏  举报