1、什么是分布式锁?
就是在分布式环境下,用来解决多实例对数据访问一致性的一种技术方案。
2、使用场景
只要涉及到多个实例进程对同一份数据进行修改等操作都会需要用到分布式锁。
目前几乎很多大型网站及应用都是分布式部署的,分布式场景中的数据一致性问题一直是一个比较重要的话题。
分布式的CAP理论告诉我们“任何一个分布式系统都无法同时满足一致性(Consistency)、可用性(Availability)和分区容错性(Partition tolerance),最多只能同时满足两项。”所以,很多系统在设计之初就要对这三者做出取舍。
在互联网领域的绝大多数的场景中,都需要牺牲强一致性来换取系统的高可用性,系统往往只需要保证“最终一致性”即可,只要这个最终时间是在用户可以接受的范围内即可。
在很多场景中,我们为了保证数据的最终一致性,需要很多的技术方案来支持,比如分布式事务、分布式锁等。有的时候,我们需要保证一个方法在同一时间内只能被同一个线程执行。
基于文件、DB的分布式锁会遇到各式各样的问题,性能也经常是瓶颈。
1、基于Redis(Tair)
选用Redis实现分布式锁原因
set命令
SET resource_name my_random_value NX PX max-lock-time
:当且仅当key不存在时,set一个key为val的字符串,返回1;若key存在,则什么都不做,返回0。
delete:delete key
:删除key
在使用Redis实现分布式锁的时候,主要就会使用到这两个命令。
具体的Redis分布式锁实现有:
原理
优点
缺点
存在的问题
setnx命令设置完key-value后,还没来得及使用expire命令设置过期时间,当前线程挂掉了,会导致当前线程设置的key一直有效,后续线程无法正常通过setnx获取锁,造成死锁
解决:该问题是因为两个命令是分开执行并且不具备原子特性,如果能将这两个命令合二为一就可以解决问题了。在Redis2.6.12版本中实现了这个功能,Redis为set命令增加了一系列选项,可以通过SET resource_name my_random_value NX PX max-lock-time来获取分布式锁,这个命令仅在不存在key(resource_name)的时候才能被执行成功(NX选项),并且这个key有一个max-lock-time秒的自动失效时间(PX属性)。这个key的值是“my_random_value”,它是一个随机值,这个值在所有的机器中必须是唯一的,用于安全释放锁。
在分布式环境下,线程A通过这种实现方式获取到了锁,但是在获取到锁之后,执行被阻塞了,导致该锁失效,此时线程B获取到该锁,之后线程A恢复执行,执行完成后释放该锁,直接使用del命令,将会把线程B的锁也释放掉,而此时线程B还没执行完,将会导致不可预知的问题
解决:释放锁的时候,只有key存在并且存储的“my_random_value”值和指定的值一样才执行del命令
为了实现高可用,将会选择主从复制机制,但是主从复制机制是异步的,会出现数据不同步的问题,可能导致多个机器的多个线程获取到同一个锁。
解决:不采用主从复制,使用RedLock算法,这里引用网上一段关于RedLock算法的描述。
在Redis的分布式环境中,假设有5个Redis master,这些节点完全互相独立,不存在主从复制或者其他集群协调机制。为了取到锁,客户端应该执行以下操作:
获取当前Unix时间,以毫秒为单位
依次尝试从N个实例,使用相同的key和随机值获取锁。在步骤2,当向Redis设置锁时,客户端应该设置一个网络连接和响应超时时间,这个超时时间应该小于锁的失效时间。例如你的锁自动失效时间为10秒,则超时时间应该在5-50毫秒之间。这样可以避免服务器端Redis已经挂掉的情况下,客户端还在死死地等待响应结果。如果服务器端没有在规定时间内响应,客户端应该尽快尝试另外一个Redis实例
客户端使用当前时间减去开始获取锁时间(步骤1记录的时间)就得到获取锁使用的时间。当且仅当从大多数(这里是3个节点)的Redis节点都取到锁,并且使用的时间小于锁失效时间时,锁才算获取成功
如果取到了锁,key的真正有效时间等于有效时间减去获取锁所使用的时间(步骤3计算的结果)
如果因为某些原因,获取锁失败(没有在至少N/2+1个Redis实例取到锁或者取锁时间已经超过了有效时间),客户端应该在所有的Redis实例上进行解锁(即便某些Redis实例根本就没有加锁成功)
使用一(dlock)
Lock lock = dLockGenerator.gen("stat_dlock_lockType_" + entry.getKey(), "stat_dlock_target" + entry.getKey(), 3, TimeUnit.SECONDS);
try {
lock.lock();
logger.info("redis锁获取成功,准备处理数据...");
// do();
} catch(Exception e) {
e.printStackTrace();
logger.error("redis锁操作失败, {}", e.getMessage());
} finally {
lock.unlock();
logger.info("redis锁已释放...");
}
使用二(Tair)
使用com.taobao.tair.TairManager接口的put()、invalid()方法。
mdb有可能丢、且invalid时不保证跨机房一致性,所以这个锁肯定需要用ldb来实现的。
public class CommonLocker {
private static final Logger logger = LoggerFactory.getLogger(CommonLocker.class);
@Resource
private TairManager ldbTairManager;
private static final short NAMESPACE = 1310;
private static CommonLocker locker;
public void init() {
if (locker != null) return;
synchronized (CommonLocker.class) {
if (locker == null)
locker = this;
}
}
public static Lock newLock(String format, Object... argArray) {
FormattingTuple ft = MessageFormatter.arrayFormat(format, argArray);
return newLock(ft.getMessage());
}
public static Lock newLock(String strKey) {
String key = "_tl_" + strKey;
return new TairLock(key, CommonConfig.lock_default_timeout);
}
public static Lock newLock(String strKey, int timeout) {
String key = "_tl_" + strKey;
return new TairLock(key, timeout);
}
private static class TairLock implements Lock {
private String lockKey;
private boolean gotLock = false;
private int retryGet = 0;
private int retryPut = 0;
private int timeout;
public TairLock(String key, int timeout) {
this.lockKey = tokey(key);
this.timeout = timeout;
}
public boolean tryLock() {
return tryLock(timeout);
}
/**
* need finally do unlock
*
* @return
*/
public boolean tryLock(int timeout) {
Result<DataEntry> result = locker.ldbTairManager.get(NAMESPACE, lockKey);
while (retryGet++ < CommonConfig.lock_get_max_retry &&
(result == null || ResultCode.CONNERROR.equals(result.getRc()) || ResultCode.TIMEOUT.equals(result.getRc()) || ResultCode.UNKNOW.equals(result.getRc()))) {
// 重试一次
result = locker.ldbTairManager.get(NAMESPACE, lockKey);
}
if (ResultCode.DATANOTEXSITS.equals(result.getRc())) { // lock is free
// 已验证version 2表示为空,若不是为空,则返回version error
ResultCode code = locker.ldbTairManager.put(NAMESPACE, lockKey, locker.getValue(), 2, timeout);
if (ResultCode.SUCCESS.equals(code)) {
gotLock = true;
return true;
} else if (retryPut++ < CommonConfig.lock_put_max_retry &&
(code == null || ResultCode.CONNERROR.equals(result.getRc()) || ResultCode.TIMEOUT.equals(result.getRc()) || ResultCode.UNKNOW.equals(result.getRc()))) {
return tryLock(timeout);
}
} else if (result.getValue() != null && locker.getValue().equals(result.getValue().getValue())) {
// 【注意】其实这里线程复用时,ThreadName有相同风险,可以改为uuid逻辑,复用锁传入uuid。
// 若是自己的锁,自己继续用
gotLock = true;
return true;
}
// 到这里表示没有拿到锁
return false;
}
public void unlock() {
if (gotLock) {
ResultCode invalidCode = locker.ldbTairManager.invalid(NAMESPACE, lockKey);
gotLock = false;
}
}
public void lock() {
throw new NotImplementedException();
}
public void lockInterruptibly() throws InterruptedException {
throw new NotImplementedException();
}
public boolean tryLock(long l, TimeUnit timeUnit) throws InterruptedException {
throw new NotImplementedException();
}
public Condition newCondition() {
throw new NotImplementedException();
}
}
// 【注意】其实这里线程复用时,ThreadName有相同风险,可以改为uuid逻辑,复用锁传入uuid。
private String getValue() {
return getHostname() + ":" + Thread.currentThread().getName();
}
/**
* 获得机器名
*
* @return
*/
public static String getHostname() {
try {
return InetAddress.getLocalHost().getHostName();
} catch (UnknownHostException e) {
return "[unknown]";
}
}
public void setLdbTairManager(TairManager ldbTairManager) {
this.ldbTairManager = ldbTairManager;
}
}
注: 像程序发布、进程crash这种情况,是无可避免的让锁没机会释放。还有其他可能性,大多是bug了。。
tair存在让人苦恼的超时问题,即使千分之1,本业务有时也不能容忍 超时请重试;
锁应该是能够经得起复查的(类似偏向锁):A拿到的锁,没有unlock之前,无论A重试检查多少次,都是A的!
既然用的是ldb缓存,它是key-value结构的,前面version控制等,都只用到了key。 这里,我们可以从tair
value里做文章:让value包含机器ip+线程name,trylock内先get value做检查
若是get超时怎么办? 超时无法避免,还是要靠重试!(前提是逻辑可以重试)
if (result == null || ResultCode.CONNERROR.equals(result.getRc()) || ResultCode.TIMEOUT.equals(result.getRc()) || ResultCode.UNKNOW.equals(result.getRc())) // get timeout retry case
result = locker.ldbTairManager.get(NAMESPACE, key);
if (result == null || ResultCode.CONNERROR.equals(result.getRc()) || ResultCode.TIMEOUT.equals(result.getRc()) || ResultCode.UNKNOW.equals(result.getRc())){ // 还是超时,则留下日志痕迹
logger.error("ldb tair get timeout. key:{}.",key);
return false;
}
进一步的,我们还可以对get/put的retry做次数控制;
真实线上的情况,一般一次retry就能解决问题,次数多了,反而可能导致雪崩,需要慎重;
批量锁
批量锁,主要注意拿锁的顺序和释放锁相反。
if(trylock("A") && trylock("B") && trylock("C")){
try{
// do something
}finally{ // 注意这里的顺序要反过来
unlock("C");
unlock("B");
unlock("A");
}
}
2、基于ZooKeeper临时顺序节点
ZooKeeper是一个为分布式应用提供一致性服务的开源组件,它内部是一个分层的文件系统目录树结构,规定同一个目录下只能有一个唯一文件名。
核心
原理
实现
Curator
它是Apache开源的一个ZooKeeper客户端,Curator提供的InterProcessMutex是分布式锁的实现,acquire方法用于获取锁,release方法用于释放锁。
优点
缺点
3、数据库锁
基于数据库的分布式锁个人觉得性能不是很好,在高并发的情况下对数据库服务器的压力过大,会影响业务,不建议使用,不过从学习的角度,还是有必要了解一下具体的实现方式。
基于数据库的分布式锁的实现大致有两种方式,这里的数据库我们以 MySQL 为例。两种方案的实现都需要一个额外的表,并且要有一个唯一索引字段。
a、阻塞式语句(排他锁/悲观锁)
for update
public boolean lock(){
connection.setAutoCommit(false);
while(true){
try {
result = select * from methodLock where method_name = xxx for update;
if(result == null) {
return true;
}
} catch (Exception e) {
}
sleep(1000);
}
return false;
}
我们可以认为获得排它锁的线程即可获得分布式锁,当获取到锁之后,可以执行方法的业务逻辑,执行完方法之后,再通过以下方法解锁:
public void unlock(){
connection.commit(); // 手动提交事务
}
b、非阻塞式语句(基于表记录的增删)
表method_lock
字段:id、method_name、desc、update_time、expire_time、ip
insert into method_lock
delete from method_lock where method_name = "methodName"
c、乐观锁
CAS原理:Compare And Swap
ABA问题
乐观锁假设认为数据一般情况下不会造成冲突,只有在进行数据的提交更新时,才会检测数据的冲突情况,如果发现冲突了,则返回错误信息
在表中添加一个版本号控制字段version,默认为0,每次线程获取锁,执行相关操作,version增加1
-- 添加版本号控制字段
ALTER TABLE table ADD COLUMN version INT DEFAULT '0' NOT NULL AFTER t_bonus;
-- 线程1查询,当前left_count为1,则有记录,当前版本号为1234
select left_count, version from t_bonus where id = 10001 and left_count > 0
-- 线程2查询,当前left_count为1,有记录,当前版本号为1234
select left_count, version from t_bonus where id = 10001 and left_count > 0
-- 线程1,更新完成后当前的version为1235,update状态为1,更新成功
update t_bonus set version = 1235, left_count = left_count-1 where id = 10001 and version = 1234
-- 线程2,更新由于当前的version为1235,udpate状态为0,更新失败,再针对相关业务做异常处理
update t_bonus set version = 1235, left_count = left_count-1 where id = 10001 and version = 1234
d、性能对比
悲观锁实现方式是独占数据,其它线程需要等待,不会出现修改的冲突,能够保证数据的一致性,但是依赖数据库的实现,且在线程较多时出现等待造成效率降低的问题。一般情况下,对于数据很敏感且读取频率较低的场景,可以采用悲观锁的方式
乐观锁可以多线程同时读取数据,若出现冲突,也可以依赖上层逻辑修改,能够保证高并发下的读取,适用于读取频率很高而修改频率较少的场景
e、优点
容易理解和实现,但是细节要注意
f、缺点及优化点
高并发的情况下性能不好,阻塞式的情况下很多链接不释放会拖垮数据库服务
数据库的行锁会因为 MySQL 的查询优化而失效
因为是基于数据库实现的,数据库的可用性和性能将直接影响分布式锁的可用性及性能,所以,数据库需要双机部署、数据同步、主备切换
不具备可重入的特性,因为同一个线程在释放锁之前,行数据一直存在,无法再次成功插入数据,所以,需要在表中新增一列,用于记录当前获取到锁的机器和线程信息,在再次获取锁的时候,先查询表中机器和线程信息是否和当前机器和线程相同,若相同则直接获取锁
没有锁失效机制,因为有可能出现成功插入数据后,服务器宕机了,对应的数据没有被删除,当服务恢复后一直获取不到锁,所以,需要在表中新增一列,用于记录失效时间,并且需要有定时任务清除这些失效的数据
不具备阻塞锁特性,获取不到锁直接返回失败,所以需要优化获取逻辑,循环多次去获取
在实施的过程中会遇到各种不同的问题,为了解决这些问题,实现方式将会越来越复杂;依赖数据库需要一定的资源开销,性能问题需要考虑。
4、总结
上面的三种实现方式,没有在所有场合都是完美的,所以,应根据不同的应用场景选择最适合的实现方式。
在分布式环境中,对资源进行上锁有时候是很重要的,比如抢购某一资源,这时候使用分布式锁就可以很好地控制资源。
当然,在具体使用中,还需要考虑很多因素,比如超时时间的选取,获取锁时间的选取对并发量都有很大的影响,上述实现的分布式锁也只是一种简单的实现,主要是一种思想。