背景
SOA或微服务架构体系下必不可少的一个分布式组件,常用于解决分布式场景下数据一致性的问题。
应用场景:
- 资源竞争控制:在分布式系统中,多个节点可能同时访问共享资源,如数据库、文件系统、缓存等。分布式锁可以用来控制对这些共享资源的访问,确保在任何时候只有一个节点能够对资源进行修改,避免数据的并发更新导致的问题。
- 避免重复执行:分布式任务调度中,多个节点可能同时执行同一个任务,为了避免重复执行,可以使用分布式锁来确保只有一个节点能够执行任务。
- 防止消息重复消费:可使用分布式锁来确保消息只被消费一次,从而保证消息消费的幂等性。
- 分布式事务控制:在分布式事务处理中,需要对多个节点的操作进行协调和同步,分布式锁可以用来确保事务的一致性和原子性,防止数据不一致的情况发生。
- 限流和流量控制:在高并发场景下,为了保护系统不被过载,可以使用分布式锁来实现限流和流量控制,限制同时访问系统的请求数量,保护系统的稳定性和可用性。
实现方案有4类:
- 数据库
- 缓存
- Consul
- ZooKeeper
数据库
有不同的方案:
- 基于数据库表
- 基于数据库排他锁
数据库表锁
数据库表锁的问题:
- 锁强依赖数据库的可用性,单实例部署,则存在单点故障。
- 设置表锁时不支持设置过期时间,一旦解锁操作失败,会导致锁记录一直在数据库中,其他线程无法再获得到锁。
- 表锁只能是非阻塞的,数据的insert操作,一旦插入失败就会直接报错。没有获得锁的线程并不会进入排队队列,要想再次获得锁就要再次触发获得锁操作。
- 表锁是非重入的,同一个线程在没有释放锁之前无法再次获得该锁,因为此条数据已经存在。
解决方法:
- 数据库集群化。不过会增加系统复杂度。
- 引入定时任务,定期把数据库中的超时数据清理掉。但定时任务的执行频率又是一个需要权衡的问题。执行频率过高则存在资源浪费问题,频率过低则其他线程需要等待获取锁的间隔时间较长。
- 引入while循环重试逻辑,直到insert成功才退出while循环。
- 可考虑在表里加字段,记录当前获取到锁的机器主机和线程等唯一性信息。下次再次尝试获取锁时,先查询数据库信息,查询成功,则可以获取到锁。
数据库排他锁
利用DB的select for update
排他锁,当多个线程并发同时操作时,只有一个线程能成功,成功获取到锁后,可以执行方法的业务逻辑,其他线程都block住,执行完业务方法之后,此线程使用connection.commit()
操作释放锁,block住的其他所有线程的其中一个线程才能开始干活。
依然存在单点故障和不可重入问题。
是阻塞锁,执行select for update
语句的若干个线程,若执行成功后立即返回,若执行失败时一直处于阻塞状态,直到成功。
锁肯定会被释放,获取成功的线程可以主动释放锁。如果万一发生数据库(服务节点或实例)宕机,则会自动把排他锁释放掉。具体原理请看文末问答章节。
总结
使用数据库实现分布式锁,容易理解实现简单。但会有各种各样的问题,在解决问题的过程中会使整个方案变得复杂。此外还有性能开销问题。
Zookeeper
ZK节点ZNode是一个和文件系统类似的小型的树状的目录结构,ZK规定:同一个目录下只能有一个唯一的文件名。ZK的临时节点,临时节点由某个客户端创建,当客户端因为宕机,与ZK集群断开连接时,则该临时节点会被自动删除,避免死锁问题。
原理
- 在ZK指定节点下创建临时顺序节点
node_n
- 获取locks下所有子节点children
- 对子节点按节点自增序号从小到大排序
- 判断本节点是不是第一个子节点,若是,则获取锁;若不是,则监听比该节点小的那个节点的删除事件
- 若监听事件生效,则回到第二步重新进行判断,直到获取到锁
ZK分布式锁可解决单点、不可重入、非阻塞及锁无法释放等问题,实现简单。但有一个缺点,性能不够高。每次在创建锁和释放锁的过程中,都要动态创建、销毁瞬时节点来实现锁功能。ZK中创建和删除节点只能通过Leader服务器来执行,然后将数据同步到所有的Follower机器上。
Curator
Curator提供封装好的API简化分布式锁组件的使用:
java
String path = "ORDER_LOCK";
InterProcessMutex lock = InterProcessMutex(client, path);
try {
lock.acquire();
lock.release();
} catch (Throwable e) {
// just log
}
finally {
executorService.schedule(new Cleaner(client, path), 120, TimeUnit.SECONDS);
}
Consul
Consul提供一种称为Session的机制,可用于实现分布式锁。Session是一种临时对象,与TTL相关联,可绑定到一个或多个Key。
原理
- Session创建:客户端首先创建一个Consul Session。
- Key绑定:客户端尝试将一个特定的Key与前面创建的Session绑定,即获取锁。Consul使用一个原子操作来确保只有一个客户端能够成功地将该键绑定到它的Session上
- 锁续租:获得锁的客户端需要定期续租(renew)Session,以表明它仍然持有锁并且是活跃的。续租是由客户端定期发送请求来完成的,如果客户端未能续租,Session过期,锁将被释放
- 释放锁:客户端可显式地释放锁,方法是删除与Session绑定的Key或销毁Session。一旦Session被销毁,Consul将自动解除该Session绑定的所有Key,从而释放锁
创建Session:
bash
curl -X PUT -d '{
"Name": "my-lock",
"TTL": "10s",
"Behavior": "release"
}' http://localhost:8500/v1/session/create
这个请求会创建一个名为my-lock
的Session,TTL为10秒,行为模式为release
,表示Session到期时自动释放锁。
尝试获取锁:
通过使用acquire参数将一个Key与Session绑定:
bash
curl -X PUT -d 'my-value' http://localhost:8500/v1/kv/my/key?acquire=<session-id>
成功则返回true,表示锁已被成功获取。失败则返回false。
续租Session:
bash
curl -X PUT http://localhost:8500/v1/session/renew/<session-id>
请求需要在TTL过期前发送,以确保Session和锁继续有效。
释放锁的两种方式:
删除Key:
bash
curl -X DELETE http://localhost:8500/v1/kv/my/key
销毁Session:
bash
curl -X PUT http://localhost:8500/v1/session/destroy/<session-id>
锁的独占性:Consul的原子操作保证锁的独占性,即在任何时间点,只有一个客户端能够持有锁。
Redis
常规锁:
java
- 获取锁(unique_value可以是UUID等)
SET resource_name unique_value NX PX 30000
- 释放锁(lua脚本中,一定要比较value,防止误解锁)
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
这种实现方式有3大要点:
- set命令要用
set key value px milliseconds nx
; - value要具有唯一性;
- 释放锁时要验证value值,不能误解锁;
常规的单节点锁有缺点:加锁只作用在一个Redis节点上,即使Redis通过sentinel保证高可用,如果这个master节点由于某些原因发生主从切换,就会出现锁丢失的情况:
在Redis的master节点上拿到锁,但是这个加锁的key还没有同步到slave节点,master故障,发生故障转移,slave节点升级为master节点,导致锁丢失。
Redlock原理
antirez提出的Redlock算法。假设有N个Redis Master节点,这些节点完全互相独立,不存在主从复制或其他集群协调机制。确保将在N个实例上使用与在Redis单实例下相同方法获取和释放锁。
假设N=5,为了取到锁,客户端应该执行以下操作:
- 获取当前Unix时间,以毫秒为单位。
- 依次尝试从5个实例,使用相同的key和具有唯一性的value获取锁。当向Redis请求获取锁时,客户端应该设置一个网络连接和响应超时时间,这个超时时间应该小于锁的失效时间。例如你的锁自动失效时间为10秒,则超时时间应该在5-50毫秒之间。这样可以避免服务器端Redis已经挂掉的情况下,客户端还在死死地等待响应结果。如果服务器端没有在规定时间内响应,客户端应该尽快尝试去另外一个Redis实例请求获取锁。
- 客户端使用当前时间减去开始获取锁时间(步骤1记录的时间)就得到获取锁使用的时间。当且仅当从大多数的Redis节点都取到锁,并且使用的时间小于锁失效时间时,锁才算获取成功。
- 如果获取到锁,key的真正有效时间等于有效时间减去获取锁所使用的时间(步骤3计算的结果)。
- 若获取锁失败(没有在至少N/2+1个Redis实例取到锁或者取锁时间已经超过有效时间),客户端应该在所有的Redis实例上进行解锁(即便某些Redis实例根本就没有加锁成功,防止某些节点获取到锁但是客户端没有得到响应而导致接下来的一段时间不能被重新获取锁)。
开源实现
一般情况下,不会自己使用Redis来实现一个分布式锁,而是使用三方开源组件,如Redisson,引入:
xml
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
</dependency>
使用
java
Config config = new Config();
config.useSentinelServers().addSentinelAddress("127.0.0.1:6369","127.0.0.1:6379", "127.0.0.1:6389")
.setMasterName("masterName")
.setPassword("password").setDatabase(0);
RedissonClient redissonClient = Redisson.create(config);
// getFairLock(), getReadWriteLock()
RLock redLock = redissonClient.getLock("REDLOCK_KEY");
boolean isLock;
try {
isLock = redLock.tryLock();
// 500ms拿不到锁,就认为获取锁失败,10000ms,锁失效时间。
isLock = redLock.tryLock(500, 10000, TimeUnit.MILLISECONDS);
if (isLock) {
// get lock success, do your business
}
} catch (Exception e) {
} finally {
redLock.unlock();
}
set的value要具有唯一性,Redisson的方式:UUID:threadId
,源码:
java
protected final UUID id = UUID.randomUUID();
String getLockName(long threadId) {
return id + ":" + threadId;
}
源码
获取锁
调用redLock.tryLock()
或redLock.tryLock(500, 10000, TimeUnit.MILLISECONDS)
,前者获取锁的默认租约时间(leaseTime)是-1,即一直占用锁,除非手动释放,核心源码:
java
<T> RFuture<T> tryLockInnerAsync(long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
internalLockLeaseTime = unit.toMillis(leaseTime);
// 获取锁时向5个redis实例发送的命令
return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, command,
// 首先分布式锁的KEY不能存在,如果确实不存在,那么执行hset命令(hset REDLOCK_KEY UUID + threadId 1),并通过pexpire设置失效时间(也是锁的租约时间)
"if (redis.call('exists', KEYS[1]) == 0) then " +
"redis.call('hset', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
// 如果分布式锁的KEY已经存在,并且value也匹配,表示是当前线程持有的锁,那么重入次数加1,并且设置失效时间
"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
"redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
// 获取分布式锁的KEY的失效时间毫秒数
"return redis.call('pttl', KEYS[1]);",
// 这三个参数分别对应KEYS[1],ARGV[1]和ARGV[2]
Collections.<Object>singletonList(getName()), internalLockLeaseTime, getLockName(threadId));
}
KEYS[1]
就是Collections.singletonList(getName())
,表示分布式锁的key,即REDLOCK_KEY;
ARGV[1]
就是internalLockLeaseTime
,即锁的租约时间,默认30s;
ARGV[2]
就是getLockName(threadId)
,是获取锁时set的唯一值,即UUID+threadId
:
释放锁
redLock.unlock()
的核心源码:
java
protected RFuture<Boolean> unlockInnerAsync(long threadId) {
// 向5个redis实例都执行如下命令
return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
// 如果分布式锁KEY不存在,那么向channel发布一条消息
"if (redis.call('exists', KEYS[1]) == 0) then " +
"redis.call('publish', KEYS[2], ARGV[1]); " +
"return 1; " +
"end;" +
// 如果分布式锁存在,但是value不匹配,表示锁已经被占用,那么直接返回
"if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +
"return nil;" +
"end; " +
// 如果就是当前线程占有分布式锁,那么将重入次数减1
"local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +
// 重入次数减1后的值如果大于0,表示分布式锁有重入过,那么只设置失效时间,还不能删除
"if (counter > 0) then " +
"redis.call('pexpire', KEYS[1], ARGV[2]); " +
"return 0; " +
"else " +
// 重入次数减1后的值如果为0,表示分布式锁只获取过1次,那么删除这个KEY,并发布解锁消息
"redis.call('del', KEYS[1]); " +
"redis.call('publish', KEYS[2], ARGV[1]); " +
"return 1; "+
"end; " +
"return nil;",
// 这5个参数分别对应KEYS[1],KEYS[2],ARGV[1],ARGV[2]和ARGV[3]
Arrays.<Object>asList(getName(), getChannelName()), LockPubSub.unlockMessage, internalLockLeaseTime, getLockName(threadId));
}
可见不管是加锁还是解锁,都是调用commandExecutor.evalWriteAsync()
方法,至少有6个参数:
java
<T, R> RFuture<R> evalWriteAsync(String key, Codec codec, RedisCommand<T> evalCommandType, String script, List<Object> keys, Object ... params);
不同的主要是传入的Lua脚本。另外,renewExpirationAsync()
和forceUnlockAsync()
方法也是调用evalWriteAsync()
方法,前者用于给锁加时间,后者用于异常情况下强制释放锁。
问题
分布式锁分类?
在分布式系统中,一般不将分布式锁再分为排他锁和共享锁这两种,因为排他锁和共享锁的概念通常用于描述传统的数据库锁。
在一些特定的分布式锁实现,分布式锁又分为排他锁和共享锁两种。
排他锁
Exclusive Locks,X锁,写锁或独占锁。
共享锁
Shared Locks,S锁,读锁。如果事务T1对数据对象O1加上共享锁,那么T1只能对O1进行读操作,其他事务也能同时对O1加共享锁(不能是排他锁),直到O1上的所有共享锁都释放后O1才能被加排他锁。
总结:可以多个事务同时获得一个对象的共享锁(同时读),有共享锁就不能再加排他锁(因为排他锁是写锁)
ReentrantLock能不能用于实现分布式锁
ReentrantLock,可重入锁,是基于线程的锁,其lock和unlock操作必须由同一个线程来执行,只能用于单JVM实例中,无法直接用于分布式系统中。
select for update
排他锁,遇到数据库宕机是否会释放
在 MySQL 中使用 SELECT ... FOR UPDATE
语句可以对选定的行加上排他锁(exclusive lock),以确保其他事务无法修改这些行,直到当前事务结束。关于数据库宕机时锁的释放问题,以下是详细的说明:
SELECT ... FOR UPDATE
加上的排他锁是事务级别的锁。锁的生命周期与事务相关联。当事务提交(COMMIT)或回滚(ROLLBACK)时,锁会被自动释放。这些锁存在于会话的生命周期内。如果会话由于某种原因断开,比如客户端失去连接或数据库发生宕机,事务会被自动回滚,锁也会被释放。
具体情况
- 服务器:
- MySQL服务器宕机,所有未完成的事务都会在服务器重启时自动回滚。这意味着所有与这些事务相关的锁会被释放
- MySQL InnoDB存储引擎具有崩溃恢复机制,在服务器重启时会扫描重做日志(redo log)并回滚未完成的事务,确保数据一致性和锁的释放
- 客户端:如果客户端崩溃或网络连接断开,服务器会检测到会话断开,并自动回滚该会话中未完成的事务,并释放所有相关的锁
具体锁释放行为
- 事务回滚:当事务显式或隐式回滚时,锁会被释放
- 事务提交:当事务提交时,锁会被释放
- 会话终止:如果会话由于客户端崩溃或网络问题而终止,未提交的事务会被回滚,锁会被释放
- 服务器重启:MySQL服务器宕机并重新启动,崩溃恢复机制会回滚未完成的事务并释放锁
总结
MySQL的崩溃恢复机制会自动回滚未完成的事务,从而释放所有与这些事务相关的锁。这种机制确保即使在意外宕机的情况下,数据库也能保持一致性和正确性。
对比
基于场景/业务需求,稳定性和性能等诸多考虑,选择开源产品或者自研。
特性 | 可重入锁 | 持锁断开连接后释放锁 | 锁持久化 | 优缺点 |
---|---|---|---|---|
数据库 | 支持 | 安全性高,性能低 | ||
ZK | 支持 | 支持,临时节点当连接中断会删除锁 | 支持 | 安全性较高,效率稍低 |
Redis | 支持 | 支持,过期时间 | 不支持 | 安全性较低,效率高 |