为了解决多线程并发场景下的资源占用问题,引入了锁的概念,使用锁可以保证一个资源在同一时刻只能被一个线程访问。随着业务的高速发展,业务系统会快速迭代拆分成多个子服务,同时,为了应对大流量,同一个子服务又会部署多个实例,部署在不同的机器上,单进程中已经被解决的并发问题又会重新出现,而分布式锁就是解决这些问题的有效方案。
分布式锁的实现通常会选择一个存储系统作为全局状态存储,依赖这个系统提供的对象存储原子化的排他性操作,来实现分布式锁的全局排他性。实现分布式锁的方式有很多,根据不同的业务场景选择合适的分布式锁:
- 数据库
- Redis
- Zookeeper
这篇文章我们将详细介绍一个每一种分布式锁的实现方式。
数据库实现分布式锁
数据库本身的特性决定了它本身就是一个强一致性的系统,有很多特性可以用来实现分布式锁,如唯一索引约束、for update等。
- 基于for update的悲观锁
这种锁主要是利用InnoDB引擎提供的排他锁,在执行事务操作时,对于包含for update子句的SQL,MySQL会对查询结果集中的每一行都加一个排他锁,其他线程在更新或者删除这些结果的时候都会被阻塞。
利用这个机制,我们可以很容易就实现分布式锁,在获取锁的时候开启事务,成功获取到锁就可以执行业务逻辑,在执行完业务逻辑后,完成事务就可以释放锁。
这种方式实现虽然简单,但是不支持可重入,同时这种实现是阻塞的,锁占用期间会一直占用数据库连接,在高并发场景下很容易消耗完数据库连接池,影响其他业务,因此指定这种方式即可,实际的场景中不推荐这种方式。
- 基于唯一索引约束的实现
如果表设置了唯一索引,只有在第一次插入的时候会成功,后边的插入操作都会失败,我们创建一个order_lock
表,其中resource_key
为唯一键,表示一个需要抢占的资源,应用在获取锁的时候,会往这张表里插入一条resource_key
为该资源的key的记录,插入成功就认为获取到了锁,删除这条记录就是释放锁,如果插入失败,就不断重试,直到插入成功或超过指定的超时时间,抛出异常。
为了避免操作失败等原因导致锁记录没有正确被删除,还需要定时清理过期的锁记录,以避免出现死锁,具体实现:
代码语言:javascript复制CREATE TABLE `order_lock` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`resource_key` varchar(64) NOT NULL COMMENT '锁定的资源 Key, 表示一个需要占用的资源'
PRIMARY KEY (`id`),
UNIQUE KEY `uk_resource_key` (`resource_key`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='基于唯一索引约束的锁';
如果按照上边的实现实现起来还是不支持可重入,为了实现可重入要改造一下,将锁持有的主机,线程等信息记录在信息里,在获取锁的时候先判断锁记录的相关信息是否与当前主机、线程是否一致,如果一致就认为已经获取了锁。还有一个缺点就是高并发下,这种插入会造成大量死锁,影响数据库的稳定,进而拖垮其他业务的运行。
- 基于CAS的乐观锁
CAS是CPU支持的一个指令级的操作,即在更新数据前先比较该数据当前值是否等于期望值,如果相等,就将其设置为更新的值,否则就不设置,该指令通过用来是实现乐观锁.
代码语言:javascript复制CREATE TABLE `order_lock` (
`id` int(4) NOT NULL AUTO_INCREMENT COMMENT '主键',
`resource_key` varchar(64) NOT NULL DEFAULT '' COMMENT '锁定的资源 Key, 表示一个需要占用的资源',
`version` int(4) NOT NULL DEFAULT '' COMMENT '版本号'
PRIMARY KEY (`id`),
UNIQUE KEY `uk_resource_key` (`resource_key `) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='乐观锁实现';
代码实现:
代码语言:javascript复制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);
乐观锁认为数据的更新在大部分情况下都不会产生冲突,所以只在更新操作时进行冲突检测,适合多读的场景,可以增加系统的吞吐量。
基于Zookeeper的分布式锁
Apache ZooKeeper 致力于开发和维护一个开源服务器,该服务器支持高度可靠的分布式协调。其设计目标是通过简单易用的接口封装复杂且易出错的分布式一致性服务。Zookeeper有很多特性可以用来实现分布式锁,这里介绍的是基于临时有序ZNode的分布式锁实现方案。
Zookeeper使用层级目录的结构来组织存储节点,每个节点称为ZNode,默认情况下,每个Znode可以存储最多1MB的数据,同时每个ZNode下可以包含多个ZNode。看一下ZNode存储模型的代码实例:
代码语言:javascript复制
|- [/lock]
| |- [/lock/lock_001]
| └- [/lock/lock_002]
└- [/node1]
...
Zookeeper使用不同的参数可以创建不同类型的ZNode节点:
- 持久节点
- CreatMode为PERSISTENT时,创建普通持久节点,存储在该节点上的数据会永久存储在Zookeeper上。
- reatMode为PERSISTENT_SEQUENTIAL,创建有序持久节点,存储在该节点上的数据同样是持久化的,和普通持久节点相比,有序节点的节点名称会自动加一个全局单调递增的序号。
- 临时节点
- CreatMode为EPHEMERAL时,创建出来的节点为普通临时节点,临时节点在一个连接Session有效期内是活跃的,当链接的Session过期后,这个Session创建的临时有序节点就会被删除
- CreatMode为EPHEMERAL_SEQUENTIAL时,创建出来的节点为有序临时节点,和普通临时节点一样,节点及其存储的数据不是持久的,同时,每创建一个新的有序节点,该节点的名称会自动加一个全局单调递增的序号。
利用临时有序节点的全局单调递增,过期会自动删除的特性,我们就可以构建一个可靠的分布式锁,基本原理有以下几点:
- 创建一个持久化节点作为父节点,代表一把分布式锁实例。
- 一个线程要想持有这把锁时,在该节点下创建一个临时有序节点
- 检查新建的临时节点,如果该节点为父节点下所有子节点中序号最小的时,表示加锁成功
- 如果当前节点不是最小节点,则需要持续检查节点是否为最小,直到获取锁或者超时,可以通过Zookeeper的Watch机制,当前节点的上一个序号的节点设置一个监听,一直阻塞直到收到上一个节点的删除事件,再重新比较节点的序号,看是否可以获得锁。
- 在完成需同步协调的业务逻辑后,可以通过手动删除临时节点的方式释放锁
- 如果获得锁的进程因某些原因挂掉,这个临时节点会在Session超时后自动删除,也就自动释放锁了。
上边这种实现的分布式锁是阻塞公平锁的,对于实际使用还是不够的,这个方案是不支持可重入,最简单的实现可重入的方法是,再=在获取锁的线程中维护一个锁标记和计数器,每次加锁的时候判断当前线程是否已经获取了这把锁,如果获取了锁就只将计数器加一,释放锁的时候将计数器减一,如果计数器归零,就释放锁,调用Zookeeper的客户端删除对应的临时节点。
上边介绍的是原理,实际使用的过程中,还有很多边界条件需要考虑,一般复杂的问题都会有封装开箱即用的工具,Apache Curator就为我们提供了开箱即用、工作可靠、特性丰富的基于Zookeeper的分布式锁实现。
基于Redis的分布式锁
Redis在我们的业务系统中通常是作为缓存使用,其实Redis的原子操作、支持原子化执行Lua脚本这些特性也可以快速实现分布式锁。
基本思路:
- 基于
SET key value [expiration EX seconds | PX milliseconds] [NX|XX]
复合命令实现加锁操作,具体实现:
public static boolean tryLock(String lockKey, String uid, int expireTimeSec) {
String result = jedis.set(lockKey, uid, "NX", "EX", expireTimeSec);
return "OK".equals(result);
}
- 基于Lua脚本完成原子化的删除操作,实现可靠的解锁操作:
public static boolean unlock(String lockKey, String uid) {
String script =
"if redis.call('get', KEYS[1]) == ARGV[1] "
"then "
"return redis.call('del', KEYS[1]) "
"else "
"return 0 "
"end";
return jedis.eval(script, Lists.newArrayList(lockKey), Lists.newArrayList(uid)).equals(1L);
}
删除操作使用Lua脚本是因为Redis没有提供原子性的del操作,使用del实现释放锁的逻辑时,需要先判断是否持有锁,再进行删除,这个过程不是原子性的存在误删除的风险,而Lua脚本是整体来执行的,在执行的过程中不会插入其他命令,可以实现原子性的删除操作。
上边的Redis分布式锁的实现就是很常见的实现,这种方案也有很多的缺点,首先就是单点问题,其次是需要先预估超时时间,锁到期后不会自动续租,如果业务执行时间超过了设置的超时时间,或者出现网络阻塞等问题使业务逻辑长时间阻塞,都会导致锁机制失效。
为了解决单点问题,Redis的作者提出了一种解决方案:Redlock(红锁),Redlock依赖多个Master节点(官方推荐大于5个),Master之间都彼此独立。Redlock的实现原理:
- 加锁过程:
- 获取节点当前时间。
- 一次获取所有节点的锁,每个节点加锁的超时时间都依次减去前面节点加锁所耗的时间总和。
- 如果在超时时间内没有完成所有节点的加锁操作,就任务加锁失败。增加这个超时时间的约束主要是为了保证获取的锁始终是有效的。
- 判断是否加锁成功,如果成功获取了超过半数的节点的锁,则任务加锁成功,否则加锁失败,释放锁。只要获取到大多数节点的锁,就能保证锁的正常工作、
- 释放锁
- 需要释放所有节点上的锁,因为加锁过程中虽然只能成功获取了大多数节点的锁,并不能代表失败节点没有实际加锁。
基于Redis的分布式锁,也有开箱即用的开源实现,Redisson支持多种类型的锁和同步量,实际项目中使用较多。
总结
本文主要介绍了几种分布式锁的实现原理,我们平时项目中可能使用Apache Curator和Redisson比较多,但是各种分布式锁的实现方式也需要清除,根据实际情况选择合适的实现方式。