在讨论分布式锁原理的时候,我们带着如下思考进入今天的主题:
锁的本质就是对共享资源的串行化处理。在单进程环境中,Java JDK提供了两种互斥锁实现:Lock和Synchronized。这两种锁对共享资源的操作前后加解锁,保证不同线程可以互斥有序的操作共享资源。
在分布式环境下,由于不同主机之间无法直接访问共享资源,所以就需要我们自己来实现分布式锁,保证不同JVM、不同主机之间不会出现资源抢占。
实现分布式锁有以下基本条件:
最简单的实现可以用数据库实现一个简单的分布式锁:
建表:
CREATE TABLE `tb_lock` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`resource` bigint(64) NOT NULL COMMENT '锁定的资源',
`status` tinyint(2) NOT NULL DEFAULT '0' COMMENT '锁资源状态:(0:解锁,1:加锁)',
`desc` varchar(120) NOT NULL DEFAULT "" COMMENT '描述',
`create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_idx_resource` (`resource`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='数据库分布式锁表';
伪代码:
// 1、读锁 (resourceId对应数据库resource字段,可以为用户编号、订单编号等其他场景)
lock = mysql.getByResourceId(resourceId);
// 2、判断锁状态(lock.status = 1是否为加锁状态 ,是表示锁被占用, sleep后再尝试)
while(lock.status == 1) {
sleep(100);
}
// 3、加锁(更新lock.status = 1,表示占有锁)
mysql.update(lock.status = 1);
// 4、执行业务逻辑
doBizSomething();
// 5、解锁(更新lock.status = 1,表示解锁)
mysql.update(lock.status = 0);
以上是基于数据库简单实现的分布式锁。这样的分布式锁可能会有什么问题呢?
加锁非原子操作:假设请求1 执行第1、2步读锁且发现 lock.status != 1表示锁未被占用,接着准备执行第3步准备加锁(还未执行),与此同时假设请求2也执行完第1、2步读锁、发现锁未被占用,此时请求2也准备执行第3步加锁逻辑;接着请求1执行第3步操作将lock.status = 1表示加锁成功,此时请求2也执行第3步加锁成功,这样就同时有两个请求获得锁,违背分布式锁的规则。根本原因就是读锁和加锁这两步操作不是原子操作,所以存在同一把锁会被多个请求占用的情况。
加锁能否及时释放:假设请求1执行完第3步成功获取分布式锁之后,执行完第4步处理业务逻辑时服务宕机,未能执行第5步将锁释放。这样这个分布式锁status就一直是被占用的状态。所以我们需要考虑持有锁的主机或服务在发生宕机或者异常时能够及时释放锁,保证后续请求能够正常获取锁,确保锁的公平性。
解锁的正确性:
如何保证存储的锁资源不丢失:假设请求1成功获取分布式锁的时候,数据库宕机之后恢复后数据丢失,请求2又能成功获取到同样的分布式锁;
解决这种情况我们就要考**虑使用集群来存储分布式锁资源,同步锁资源数据防止数据丢失。**但是这样又可能存在数据不一致的场景,这就涉及到使用AP还是CP的分布式锁。
1、MySQL
2、ZK
3、Redis
4、etcd
在讨论分布式锁的实现方案时,一般不会考虑到基于MySQL数据库来实现分布式锁,原因是依赖数据库,数据需要落到硬盘上,频繁读取数据会导致IO开销大,性能不高,适用于并发量低、性能要求低的业务场景。优点是不需要引入ZK、Redis等第三方组件。
选用何种分布式锁的实现都不能脱离业务场景的前提,所以基于MySQL数据库实现的分布式锁可以应用在一些对数据一致性要求不是很高、请求量不大、对性能要求不高的场景。举个例子,之前做过一个给运营的后台系统,其中一个功能要求当有一个运营编辑某个页面时,别的运营就不可以进行编辑。此种场景下并发量不高,对数据一致性要求也不高,就可以基于MySQL数据库实现一个简单的分布式锁来实现,简单易懂。
基于MySQL的乐观锁:
乐观锁任务大部分情况下不会发生冲突,只有在更新数据的时候与预期数据进行比较,如果一致则更新数据,否则返回失败。
乐观锁为每个数据增加一个版本字段,读取的时候读取版本号(version 1),更新的时候先读出待更新数据的版本号(version 2),对两个版本号进行比较,没有发生变化则更新,否则更新失败。
我们看下乐观锁的简单实现,在业务表中加上version字段
CREATE TABLE `tb_lock` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`goodId` bigint(64) NOT NULL COMMENT '商品id',
`count` bigint(64) NOT NULL COMMENT '商品数量',
`version` bigint(20) NOT NULL DEFAULT '0' COMMENT '版本号',
`desc` varchar(120) NOT NULL DEFAULT "" COMMENT '描述',
`create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_idx_resource` (`resource`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='数据库分布式锁表';
引入了version字段后,具体操作如下:
update tb_lock set count= count+1 where goodId=2 and version = 0
,由于goodId=2这条记录的version被线程2更新过,所以线程1更新失败。以上就是乐观锁的简单实现思路,当然除了用version字段也可以使用时间戳:更新时判断获取的更新时间戳是否与待更新的数据更新时间戳一致。
乐观锁的优点是不需要依赖第三方组件,也不依赖数据库本身锁机制,对请求的性能影响较小,产生并发时直接失败即可。
缺点是:业务表需要添加额外字段,增加数据库冗余;并发量高的时候,大量请求可能会请求同一记录的行锁,岁数据库产生较大读写压力。
所以数据库适合并发量小、写操作不频繁的场景。
Zookeeper(简称ZK)是一个高性能、高可用的分布式协调服务,可以用来解决分布式数据一致性问题。基于ZK可以实现诸如数据发布/订阅、负载均衡、命名服务、集群管理、分布式锁等功能。
ZK集群是CP模型,具有原子性、可靠性、顺序一致性等特点。
基于ZK实现的分布式锁中每个数据节点就代表一个锁,客户端请求成功创建的临时顺序节点就代表成功获取到锁;当客户端与ZK集群断开链接时该节点会自动被删除,代表释放锁。以下是ZK分布式锁(共享锁)的实现逻辑:
锁的获取
/distributed-locks/lockname/
节点下尝试创建临时顺序子节点:如/distributed-locks/order/00000001
、/distributed-locks/order/00000002
;/distributed-locks/lockname/
节点下所有已经创建的子节点;锁的释放
上述客户端在获取锁时创建的节点(如:/distributed-locks/order/00000001
)是一个临时顺序节点,释放锁时删除该临时顺序节点并通知到/distributed-locks/
节点下所有注册的子节点。子节点收到变更通知后,会再次发起上述获取锁的流程。
锁的释放有以下两种情况:
总结
ZK的临时节点可以避免客户端与ZK集群因为网络中断或客户端主机宕机导致锁无法释放的问题;
ZK的顺序节点可以避免羊群效应(ZK服务器短时间向客户端发送大量事件通知),每个锁请求者只会watch序号比他小的最大节点,当锁释放时只会有一个锁请求者会被通知到。
Redis是一种基于内存的缓存,其分布式缓存特性使其成为分布式锁较为常见的实现之一。
Redis实现分布式锁的原理是:多个进程并发设置同一个key,只有一个进程能够设置成功代表该进程获取到锁,其余进程设置失败继续尝试。
(1) 如何保证读锁、加锁是原子操作?
Redis提供了SetNX命令,表示在指定的key不存在时为key设置指定值,设置成功返回1代表加锁成功;设置失败返回0表示加锁失败。
这个过程是原子操作的。
(2) 如何保证锁能够被正常释放,避免死锁?
当客户端出现网络异常或宕机时,Redis无法像ZK那样清除锁状态,而是提供了锁过期时间的方式来保证锁能够及时被释放,确保锁的公平性,这样其他客户端才能有机会申请到这把锁。
Redis 2.6.12及以上版本中提供了set key value NX PX milliseconds
的保证加锁和设置锁过期时间两步操作能够同时执行成功。
NX表示指定key不存在时设置才指定的值value;PX表示锁的过期时间,单位为ms。
(3) 如何保证容错性,即锁资源不丢失?
如果采用Redis单点模式,假设服务S1和服务S2同时申请锁lock1,Redis分布式锁会保证只有1个服务申请到锁,另外一个申请失败。
如果此时Redis宕机,内存中锁全部丢失,再次启动后服务S2重新申请锁成功,而业务上S1仍然持有锁。这样就出现同一把锁被多个客户端占有的局面。
解决Redis单机模式数据丢失的问题是通过Redis集群的主从模式。Redis主从集群会将主节点的数据异步同步给Redis从节点。当Redis主节点宕机后,Redis集群的Sentinel哨兵机制和主从切换机制能够保证从节点选举为Redis集群的主节点,继续对外提供锁服务。
但是Redis集群模式实现的分布式锁存在这样一个问题:服务S1在主节点上获取到锁,此时服务S2无法再获取到锁。极端情况下,主节点发生宕机,刚好此时主节点上的锁数据还未来得及同步到从节点上。接着Redis Sentinel哨兵机制选举从节点变为新的主节点,但是新的主节点上没有之前的锁数据,导致服务S2获取锁成功。这样就出现了同一时刻两个客户端拥有同一把锁。
从架构层面分享,分布式锁是CP模型,任何情况要保证所有节点上的数据一致。但是Redis集群的主从模式实现的分布式锁是AP模型,所以就会出现上述问题。
etcd是一个高可用的分布式KV系统,采用一致性算法raft协议,基于Go语言实现,可以用来实现各种分布式协同服务。
参考
https://mp.weixin.qq.com/s/dnNUmlHR7-sjzjMYXID_Cw
https://mp.weixin.qq.com/s/Uya33qfxO0Xy3B76GmAHZQ
https://tech.meituan.com/2016/09/29/distributed-system-mutually-exclusive-idempotence-cerberus-gtis.html
https://segmentfault.com/a/1190000018826044?utm_source=tag-newest