最近在工作中碰到一个分布式锁问题,这个问题之前做项目的过程也搞过,不过没有深入整理,这个周末有时间刚好整理一把。
在分布式系统中,常常需要协调他们的动作。如果不同的系统或是同一个系统的不同主机之间共享了一个或一组资源,那么访问这些资源的时候,往往需要互斥来防止彼此干扰进而保证一致性,这个时候,便需要使用到分布式锁。
最容易想到的一种实现方案就是基于数据库的。可以在数据库中创建一张锁表,然后通过操作该表中的数据来实现。
可以这样创建一张表:
create table TDistributedLock ( `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键', `lock_key` varchar(64) NOT NULL DEFAULT '' COMMENT '锁的键值', `lock_timeout` datetime NOT NULL DEFAULT NOW() COMMENT '锁的超时时间', `create_time` datetime NOT NULL DEFAULT NOW() COMMENT '记录创建时间', `modify_time` datetime NOT NULL DEFAULT NOW() COMMENT '记录修改时间', PRIMARY KEY(`id`), UNIQUE KEY `uidx_lock_key` (`lock_key`) USING BTREE ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='分布式锁表';
这样在需要加锁时,只需要往这张表里插入一条数据就可以了:
INSERT INTO TDistributedLock(lock_key, lock_timeout) values('lock_xxxx', '2020-07-19 18:20:00');
当对共享资源的操作完毕后,可以释放锁:
DELETE FROM TDistributedLock where lock_key='lock_xxxx';
该方案简单方便,主要利用数据库表的唯一索引约束特性,保证多个微服务模块同时申请分布式锁时,只有一个能够获得锁。
下面讨论下一些相关问题:
lock_timeout
属性,可以另外跑一个 lock_cleaner
,将超时的锁记录删除。当然为了安全, lock_timeout
最好设置一个合理的值,以确保在这之后正常的共享资源操作一定是完成了的。 该方案的缺陷:
除了动态地往数据库表里插入数据外,还可以预先将锁信息写入数据库表,然后利用数据库的行排它锁来进行加锁与释放锁操作。
例如加锁时可以执行以后命令:
SET autocommit = 0; START TRANSACTION; SELECT * FROM TDistributedLock WHERE lock_key='lock_xxxx' FOR UPDATE;
当对共享资源的操作完毕后,可以释放锁:
COMMIT;
该方案也很简单,利用数据库表的行排它锁特性,保证多个微服务模块同时申请分布式锁时,只有一个能够获得锁。
下面讨论下一些相关问题:
COMMIT for update
该方案的缺陷:
使用redis做分布式锁的思路大概是这样的:在redis中设置一个值表示加了锁,然后释放锁的时候就把这个key删除。
具体代码如下:
获取锁
-- NX是指如果key不存在就成功,key存在返回false,PX可以指定过期时间 redis.call("set", key, ARGV[1], "NX", "PX", ARGV[2])
释放锁
-- 释放锁涉及到两条指令,这两条指令不是原子性的 -- 需要用到redis的lua脚本支持特性,redis执行lua脚本是原子性的 if redis.call("get", key) == ARGV[1] then return redis.call("del", key) else return 0 end
上述的lua代码比较简单,不具体解释了。这里要注意给锁键的value值要保证唯一,这个是为了避免释放错了锁。场景如下:假设A获取了锁,过期时间30s,此时35s之后,锁已经自动释放了,A去释放锁,但是此时可能B获取了锁。加上释放锁前的value值判断,A客户端就不能删除B的锁了。
如果是javascript的项目,可以使用 redislock 库,它封装了上述加锁、释放锁等操作逻辑,用起来很方便。
才区区几行代码就优雅地搞定了分布式锁,而且redis的写入、删除键值的性能超高,看样子很完美,但事实上并非如此。
在redis中设置键时可以通过 PX
指定过期时间,这个时间不宜设置得太大,否则万一获得锁的进程crash了,要等很久此键才能过期自动删除。这个时间也不宜设得过小,否则对共享资源的操作还没完成,锁就释放了,其它服务就又能获得锁了。这里可以采用watchdog之类的方案,获得锁时设置一个较小的超时时间,然后在持有锁的过程中定期对锁的租期进行延长。
为了解决redis的单点问题,一般在生产环境会为redis实施高可用架构方案。可问题是redis主从复制方案均是异步的,在主从切换过程中有可能造成锁丢失。Redis的作者 Antirez
为此提出了一个 RedLock的算法方案
,这个算法的大概逻辑如下:
假设存在多个Redis实例,这些节点是完全独立的,不需要使用复制或者任何协调数据的系统,多个redis实例中获取锁的过程就变成了如下步骤:
这个方案看似很好,但仔细审视后还是发现一些问题的,分布式架构师 Martin
就提出了自己的 意见
。这些意见总结下来如下:
分布式锁的用途无非两种:
RedLock
方案从理论上说并不能保证锁的安全性,主要有以下几点原因:
RedLock
方案对于系统时钟有强依赖。假设有A、B、C、D、E 5个redis节点: RedLock
方案同样无法避免redis实例意外重启导致的问题。假设有A、B、C、D、E 5个redis节点:
虽说 RedLock
从理论上说确实无法100%保证锁的安全性,但以上列举的场景极为严苛,事实上在现实中很难碰到。而由于该方案获取锁的效率确实很高,事实上还是有不少业务场景就是使用的该方案。
如果是javascript的项目,配合着使用 timers
库,即可实施watchdog方案,在持有锁的过程中定期对锁的租期进行延长。如果要使用 RedLock
方案,可以使用 node-redlock
库,它封装了上述 RedLock
方案的复杂逻辑,用起来也很方便。
如果对锁的安全性要求极高,真的不允许任何锁安全性问题,还可以试试下面的zookeeper方案。
zookeeper
是一种提供配置管理、分布式协同以及命名的中心化服务。很明显 zookeeper
本身就是为了分布式协同这个目标而生的,它采用一种被称为ZAB(Zookeeper Atomic Broadcast)的一致性协议来保证数据的一致性。基于zk的一些特性,我们很容易得出使用zk实现分布式锁的落地方案:
/lock/003
,然后所有的节点列表为 [/lock/001,/lock/002,/lock/003]
,则对 /lock/002
这个节点添加一个事件监听器。 如果锁释放了,会唤醒下一个序号的节点,然后重新执行第3步,判断是否自己的节点序号是最小。
比如 /lock/001
释放了, /lock/002
监听到时间,此时节点集合为 [/lock/002,/lock/003]
,则 /lock/002
为最小序号节点,获取到锁。
从数据一致性的角度来说,zk的方案无疑是最可靠的,而且等候锁的客户端也不用不停地轮循锁是否可用,当锁的状态发生变化时可以自动得到通知。
但zookeeper实现也存在较大的缺陷:
zookeeper作为分布式协调系统,不适合作为频繁的读写存储系统。而且通过增加zookeeper服务器来提高集群的读写能力也是有上限的,因为zookeeper集群规模越大,zookeeper数据需要同步到更多的服务器。同时zookeeper分布式锁每一次都要申请锁和释放锁,都要动态创建删除临时节点,所以zookeeper不能维护大量的分布式锁,也不能维护大量的客户端心跳长连接。在分布式定理中,zookeeper追求的是CP,也就是zookeeper保证集群向外提供统一的视图,但是zookeeper牺牲了可用性,在极端情况下,zookeeper可能会丢弃一些请求。并且zookeeper集群在进行leader选举的时候整个集群也是不可用的,集群选举时间长达30 ~ 120s。
在获取锁的时候有一个细节,客户端在获取锁失败的情况下,会监听 /lock
节点。这会存在性能问题,如果在 /lock
节点下排队等待的有1000个进程,那么锁持有者释放锁(删除 /lock
节点下的临时节点)时,zookeeper会通知监听 /lock
的1000个进程。然后这1000个进程会读取zookeeper下 /lock
节点的全部临时节点,然后判断自己是否为最小的临时节点。但是这1000个进程中只有一个最小序号的进程会持有分布式锁,也就是说999个进程做的都是无用功。这些无用功会对zookeeper造成较大压力的读负载。为了解决惊群效应,需要对zookeeper分布式锁监听逻辑进行优化,实际上,排队进程真正感兴趣的是比自己临时节点序号小的节点,我们只需要监听序号比自己小的节点。
另外还需要注意的是,使用zookeeper方案也不是说就高枕无忧了。假设某一个客户端通过上述方案获得了锁,但由于网络问题,该客户端与zookeeper集群间失去了联系,zookeeper的心跳无效后,该客户端会收到了一个zookeeper的SessionTimeout的事件。为了保证分布式锁的有效性,这个时候客户端就需要在下一个等侯者获得锁之前,中断对共享资源的访问,然后继续尝试获取锁。
如果是javascript的项目,可以使用 zk-lock 库,它封装了上述方案的复杂逻辑,用起来也很方便。
总的来说,目前分布式锁领域暂时没有出现十分完美、无懈可击的方案。个人觉得,综合对比下,还是推荐采用缓存redis方案。如果项目较小,影响面不大,采用单实例redis就差不多了。如果对redis的高可用有要求,可以采用 RedLock
方案,配合 watchdog
机制定期延长key的续约租期,不过要通过合理的部署、运维等方式规避系统时钟、网络分区问题。如果真的对分布锁有着极高的一致性要求,同时对锁的性能不太在意的话,也可以采用zookeeper方案。