今天我想和你聊聊分布式锁的使用场景和常见实现。熟悉多线程编程的同学对锁的概念一定不会陌生。计算机操作系统中,为了解决多线程并发场景下的资源占用问题,引入了锁的概念。通过锁,我们可以保证一个资源在同一时刻只能被一个线程访问。主流的编程语言都会在自己的标准库中提供完备的锁实现,这些实现已经能够很好地解决我们在单进程应用中遇到的并发问题。
但是,随着业务高速发展,业务系统会快速迭代拆分成多个子服务,同时,为了应对逐渐增加的流量,同一个子服务也会包含多个分立的实例,部署在不同的服务器上。整个系统逐步向微服务演进,此时,在单进程中已经被解决的并发问题又重新浮现出来,而分布式锁就是解决这些问题的有效方案。
分布式锁
分布式锁可以协同那些部署在不同服务器上的进程对同一个资源的使用,实现同一个时刻只有一个线程可以访问该资源。常见的分布式锁的实现通常会选择一个存储系统作为全局状态存储,依赖这个存储系统提供的对存储对象原子化的排他性操作,来实现分布式锁的全局排他性。
同时,通常我们也会将锁的状态、过期时间、持有者等信息保存在这个全局状态存储中,以实现更为丰富的锁特性。常见的可以用来作为分布式锁的全局状态存储的系统包括:
-
数据库
-
Redis
-
ZooKeeper
关于 ZooKeeper 和 Reids 的分布锁实现,我们将会放到下次讲解。今天这节课,我们先来学习一下基于数据库的分布锁实现。
基于数据库的分布式锁实现
数据库本身是一个强一致性的系统,有很多特性可以用来实现分布式锁,如唯一索引约束、for update 子句等。
基于 for update 子句的悲观锁
这种锁是for update
子句可以利用 MySQL InnoDB 提供的排他锁。在执行事务操作时,对于包含 for update 子句的 SQL 语句,MySQL 会对查询结果集中的每一行记录都设置一个排他锁,其他线程在更新或删除这些记录时都会被阻塞。
基于这个机制,我们也可以很容易地实现分布式锁的需求,在获取锁的时候开启事务,成功获取到锁即可以执行业务逻辑,在业务逻辑结束后,完成事务即可以释放锁。
本实现方式简单易用,但同样不支持可重入,同时,本实现是阻塞的,锁占用期间会一直占用数据库连接,在高并发下容易出现耗尽连接池的情况,影响系统的稳定性。因此,在实际场景中很少使用这个方案。
基于唯一索引约束的分布式锁实现
一个表如果存在唯一键索引,只有第一次插入操作会成功,其他插入操作都会报错。
首先创建如下_lock
表,其中resource_key
即为唯一键,表示一个需要抢占的资源。应用在获取锁的时候,实际会往这张表里插入一条 resource_key 为该资源 key 的记录,插入成功即为获取了锁,删除这条记录即为解锁。如果插入失败,则需要重复执行插入操作,直到插入成功或者超过指定的超时时间,抛出异常。
同时,为了避免操作失败等原因导致锁记录没有正确被删除,通常需要额外增加一个定时清理任务来清理过期的锁记录,以避免出现死锁,这一过程的实现代码如下:
go
CREATE TABLE`_lock` (
`id`BIGINTNOTNULL AUTO_INCREMENT,
`resource_key`varchar(64) NOTNULLCOMMENT'锁定的资源 Key,表示一个需要占用的资源'
PRIMARY KEY (`id`),
UNIQUEKEY`uk_resource_key` (`resource_key`) USING BTREE
) ENGINE=InnoDBDEFAULTCHARSET=utf8mb4 COMMENT='基于唯一索引约束的锁';
基于唯一索引约束的分布式锁实现原理非常简单,使用起来也十分简便,但是它的缺点也十分明显。首先,这个锁实现不支持可重入,为了实现可重入操作,还需要进一步扩展上述表的字段,将锁持有者的主机、线程等信息记录到里面,在获取锁的时候,先判断锁记录中的相关信息是否和当前主机、线程信息一致,如果一致就直接认为已经获取了锁。其次,在并发下,这种插入操作会造成大量死锁,影响数据库的稳定。
基于 CAS 的乐观锁
CAS(Compare And Swap) 是现代 CPU 支持的一个指令级的操作,即在更新数据前先比较该数据当前值是否等于期望值,如果相等,则将其设置为更新的值,否则就不设置。该指令通常用来实现乐观锁,Java 的 Java.util.concurrent.atomic 包提供了大量支持 CAS 操作的原子变量。
在数据库中,我们同样也可以借用这种思想实现 CAS 操作实现分布式乐观锁。同样地,首先创建如下_lock
表,这个表相比上一节多了一个 version 字段,这个字段就是在 CAS 操作中用于比较交换的字段。
go
CREATE TABLE`_lock` (
`id`int(4) NOTNULL AUTO_INCREMENT COMMENT'主键',
`resource_key`varchar(64) NOTNULLDEFAULT''COMMENT'锁定的资源 Key,表示一个需要占用的资源',
`version`int(4) NOTNULLDEFAULT''COMMENT'版本号'
PRIMARY KEY (`id`),
UNIQUEKEY`uk_resource_key` (`resource_key `) USING BTREE
) ENGINE=InnoDBDEFAULTCHARSET=utf8 COMMENT='乐观锁实现';
乐观锁的执行逻辑如下所示:
csharp
do {
val old_version = (select version from _lock where resource_key = '{resource_key_1}');
// 通过 CAS 更新 version, 一次事务仅又一个进程可以成功
bool success = (update _lock set version = '{new_version}' where resource_key = '{resource_key_1}' and version = '{old_version}'")
if (success) {
// 获取锁成功,直接返回
return;
}
// 获取失败,重试
} while(true);
乐观锁认为数据的更新在大多数情况下是不会产生冲突的,所以只在更新操作提交时才进行冲突检测。乐观锁通常比较适合多读的场景,可以增加系统的吞吐量。
总结
今天我们主要介绍了基于数据库三种特性的分布式锁实现,分别是基于 for update 子句的悲观锁、基于唯一索引约束的分布式锁和基于 CAS 的乐观锁。基于数据库的分布式锁方案的主要优点是简单可靠,不需要引入额外的依赖(大部分业务系统通常都会使用数据库),同时,缺点也比较明显,就是并发性能较差。因此,基于数据库的分布式锁方案比较适合并发较小的业务场景。