前言
多线程情况下对共享资源的操作需要加锁,避免数据被写乱,在分布式系统中,这个问题也是存在的,此时就需要一个分布式锁服务。
分布式锁的特点
分布式锁方案
基于数据库的分布式锁
在数据库新建一张表用于控制并发控制,表结构可以如下所示:
1 | CREATE TABLE `lock_table` ( |
key_id
作为分布式key用来并发控制,memo
可用来记录一些操作内容(比如memo可用来支持重入特性,标记下当前加锁的client和加锁次数)。
将key_id
设置为唯一索引,保证了针对同一个key_id
只有一个加锁(数据插入)能成功。此时lock和unlock伪代码如下:
1 | def lock : |
注意,伪代码中的lock操作是非阻塞锁,也就是tryLock,如果想实现阻塞(或者阻塞超时)加锁,只需要反复执行lock伪代码直到加锁成功为止即可。
基于DB的分布式锁其实有一个问题,那就是如果加锁成功后,client端宕机或者由于网络原因导致没有解锁,那么其他client就无法对该key_id进行加锁并且无法释放了。为了能够让锁失效,需要在应用层加上定时任务,去删除过期还未解锁的记录,比如删除2分钟前未解锁的伪代码如下:
1 | def clear_timeout_lock : |
因为单实例DB的TPS一般为几百,所以基于DB的分布式性能上限一般也是1k以下,一般在并发量不大的场景下该分布式锁是满足需求的,不会出现性能问题。
不过DB作为分布式锁服务需要考虑单点问题,对于分布式系统来说是不允许出现单点的,一般通过数据库的同步复制,以及使用vip切换Master就能解决这个问题。
以上DB分布式锁是通过insert
来实现的,如果加锁的数据已经在数据库中存在,那么用select xxx where key_id = xxx for udpate
方式来做也是可以的。
缺点
缺陷解决方案
基于Redis的分布式锁
原理
基于redis的锁是通过以下命令对资源进行加锁:
1 | set key_id key_value NX PX expireTime |
其中,set nx
命令只会在key
不存在时给key进行赋值,px
用来设置key过期时间,key_value
一般是随机值,用来保证释放锁的安全性(释放时会判断是否是之前设置过的随机值,只有是才释放锁)。由于资源设置了过期时间,一定时间后锁会自动释放。
set nx
保证并发加锁时只有一个client能设置成功(redis内部是单线程,并且数据存在内存中,也就是说redis内部执行命令是不会有多线程同步问题的)。
Java实现
maven依赖
首先通过Maven引入Jedis开源组件,在pom.xml文件加入下面的代码:
1 | <dependency> |
加锁代码
1 | public class RedisLock { |
从上面的代码可以看到,加锁的核心代码只有一行:
1 | jedis.set(String key, String value, String nxxx, String expx, int time) |
这个set()方法一共有五个形参:
总的来说,执行上面的set()方法就只会导致两种结果:
- 当前没有锁(key不存在),那么就进行加锁操作,并对锁设置个有效期,同时value表示加锁的客户端。
- 已有锁存在,不做任何操作。
上面的加锁代码已经满足了分布式锁的排他性和避免死锁的特点。下面列几个错误示例:
错误示例1
比较常见的错误示例就是使用jedis.setnx()
和jedis.expire()
组合实现加锁,代码如下:
1 | public static void wrongGetLock1(Jedis jedis, String lockKey, String requestId, int expireTime) { |
乍一看好像和前面的set()
方法结果一样,然而由于这是两条Redis命令,不具有原子性,如果程序在执行完setnx()
之后突然崩溃,导致锁没有设置过期时间。那么将会发生死锁。
网上之所以有人这样实现,是因为低版本的jedis并不支持多参数的set()方法。
错误示例2
1 | public static boolean wrongGetLock2(Jedis jedis, String lockKey, int expireTime) { |
这一种错误示例就比较难以发现问题,执行过程如下:
通过setnx()方法尝试加锁,如果当前锁不存在,返回加锁成功。
如果锁已经存在则获取锁的过期时间,和当前时间比较,如果锁已经过期,则设置新的过期时间,返回加锁成功。
那么这段代码问题在哪里?
- 由于是客户端自己生成过期时间,所以需要强制要求分布式下每个客户端的时间必须同步。
- 当锁过期的时候,如果多个客户端同时执行jedis.getSet()方法,那么虽然最终只有一个客户端可以加锁,但这个客户端的锁过期时间可能被其他客户端覆盖。
- 锁不具备拥有者标识,即任何客户端都可以解锁。
解锁代码
1 | public class RedisLock { |
可以看到,解锁只需要两行代码就搞定了!
💛 那为什么要用Lua脚本呢?
另附加锁的Lua脚本
1 | String script = "if redis.call('setNx',KEYS[1],ARGV[1]) then if redis.call('get',KEYS[1])==ARGV[1] then return redis.call('expire',KEYS[1],ARGV[2]) else return 0 end end"; |
错误示例1
1 | public static void wrongReleaseLock1(Jedis jedis, String lockKey) { |
这种不先判断锁的拥有者而直接解锁的方式,会导致任何客户端都可以随时进行解锁,即使这把锁不是它的。
错误示例2
1 | public static void wrongReleaseLock2(Jedis jedis, String lockKey, String requestId) { |
如代码注释,问题在于如果调用jedis.del()
方法的时候,这把锁已经不属于当前客户端的时候会解除他人加的锁。
那么是否真的有这种场景?
答案是肯定的,比如客户端A加锁,一段时间之后客户端A解锁,在执行jedis.del()之前,锁突然过期了,此时客户端B尝试加锁成功,然后客户端A再执行del()方法,则将客户端B的锁给解除了。
基于ZooKeeper的分布式锁
Zookeeper重要的3个特征是:zab协议、node存储模型和watcher机制。
通过zab协议保证数据一致性,Zookeeper集群部署保证可用性,node存储在内存中,提高了数据操作性能,使用watcher机制,实现了通知机制(比如加锁成功的client释放锁时可以通知到其他client)。
Zookeeper node模型支持临时节点特性,即client写入的数据是临时数据,当客户端宕机时临时数据会被删除,这样就不需要给锁增加超时释放机制了。
当针对同一个path并发多个创建请求时,只有一个client能创建成功,这个特性用来实现分布式锁。注意:如果client端没有宕机,由于网络原因导致Zookeeper服务与client心跳失败,那么Zookeeper也会把临时数据给删除掉的,这时如果client还在操作共享数据,是有一定风险的。
基于Zookeeper实现分布式锁,相对于基于Redis和DB的实现来说,使用上更容易,效率与稳定性较好。curator封装了对Zookeeper的API操作,同时也封装了一些高级特性,如:Cache事件监听、选举、分布式锁、分布式计数器、分布式Barrier等,使用curator进行分布式加锁示例如下:
1 | <dependency> |
1 | package com.itstyle.seckill.distributedlock.zookeeper; |
下面再贴一下使用原生Zookeeper实现的分布式锁:
1 | package com.springboot.whb.study.distributedlock.zookeeper; |
总结
从上面介绍的3种分布式锁的设计与实现中,可以看出每种实现都有各自的特点,针对潜在的问题有不同的解决方案,归纳如下:
性能:Redis > Zookeeper > DB
。
避免死锁:DB通过应用层设置定时任务来删除过期还未释放的锁,Redis通过设置超时时间来解决,而Zookeeper是通过临时节点来解决。
可用性:DB可通过数据库同步复制,vip切换master来解决;Redis可通过集群或者master-slave方式来解决;Zookeeper本身自己是通过zab协议集群部署来解决的。注意,DB和Redis的复制一般都是异步的,也就是说某些时刻分布式锁发生故障可能存在数据不一致问题,而Zookeeper本身通过zab协议保证集群内(至少n/2+1个)节点数据一致性。
锁唤醒:DB和Redis分布式锁一般不支持唤醒机制(也可以通过应用层自己做轮询检测锁是否空闲,空闲就唤醒内部加锁线程),Zookeeper可通过本身的watcher/notify机制来做。
使用分布式锁,安全性上和多线程(同一个进程内)加锁是没法比的,可能由于网络原因,分布式锁服务(因为超时或者认为client挂了)将加锁资源给删除了,如果client端继续操作共享资源,此时是有隐患的。
因此,对于分布式锁,一个是要尽量提高分布式锁服务的可用性,另一个就是要部署同一内网,尽量降低网络问题发生几率。
这样来看,貌似分布式锁服务不是“完美”的,那么该如何选择分布式锁呢?最好是结合自己的业务实际场景,来选择不同的分布式锁实现,一般来说,基于Redis的分布式锁服务应用较多。