1:分布式锁产生的原因
Java本身提供了两种内置的锁的实现,一种是由JVM实现的synchronized 和 JDK 提供的 Lock,以及很多原子操作类都是线程安全的,当应用是单机或者说单进程应用时,可以使用这两种锁来实现锁。但是当下互联网公司的系统几乎都是分布式的,这个时候Java自带的 synchronized 或 Lock 已经无法满足分布式环境下锁的要求了,因为代码会部署在多台机器上,为了解决这个问题,分布式锁应运而生,分布式锁的特点是多进程,多个物理机器上无法共享内存。
但是有了分布式锁的情况下,单机锁还是有存在的必要。分布式锁必然是通过redis或者zookeeper等第三方实现,会有IO性能的限制。假设有10个线程并发访问,3台机器提供服务。当不使用单机锁时会有10个线程去竞争分布式锁,当使用单机锁的时候,只会有3个线程竞争分布式锁,因为单机锁能确保一台机器只有一个线程获得单机锁成功。
2:分布式锁的常见问题
2.1:常见问题
- 在高并发下的分布式锁实现中,key的过期肯定不能设置的太长,否则会影响后续线程持有该锁。
- 但是如果设置过期时间很短,持有该锁的线程A,直到key过期还未执行完任务;接着下一个线程B获取到该锁,这时候线程A执行完成,触发释放锁操作,但此时这把锁已经被线程B持有。即产生线程A释放线程B的锁。
- 获取锁是非阻塞的,无论成功还是失败就直接返回;
- 锁公平问题,所有等待线程同时发起获取锁命令操作。
2.2:解决方法
- 守护线程:如果某个线程在expire时间内,还未执行完成,守护线程自动续期一个新过期时间,直到该线程执行完成或释放;
- 释放验证:线程加锁前通过线程ID和Key Value匹配,释放前通过两者判断是否一致,一致再释放该锁,避免错误释放其他线程执行时持有相同的锁(原子性可以通过lua脚本来实现);
- 阻塞锁: 通过while true之类的机制去阻塞代码实现;
- 公平锁:可以通过将所有等待线程放入同一个队列来实现。
3:分布式锁的实现方式
分布式锁的实现方式大致有三种实现方式。
1. 基于数据库实现分布式锁;
2. 基于缓存(Redis等)实现分布式锁;
3. 基于Zookeeper实现分布式锁。
3.1:基于数据库
3.1.1: 悲观锁
利用select … where … for update 行级锁
注意: 其他附加功能与实现一基本一致,这里需要注意的是“where name=lock ”,name字段不是索引或者是索引但失效的情况下,会锁表。
3.1.2. 乐观锁
所谓乐观锁与前边最大区别在于基于CAS思想,是不具有互斥性,不会产生锁等待而消耗资源,操作过程中认为不存在并发冲突,只有update version失败后才能觉察到。我们的抢购、秒杀就是用了这种实现以防止超卖。
通过增加递增的版本号字段实现乐观锁。
3.1.3.基于表记录
3.2:基于Redis
3.2.1:代码中的具体使用
首先创建key键,其次通过tryLock尝试获取锁,最后unLock释放锁。注意在获取和释放的时候需要将redisStoreClien也传递进去。
StoreKey lockKey = new StoreKey(Constants.REVIEWREDISLOCK, "redis_distributed", request.getProjectID() + "_apply");
String applyID;
final String identifier = UUID.randomUUID().toString();
if (RedisLockUtils.tryLock(redisStoreClient, lockKey, identifier, 5, true)) {
try {
applyID = reviewBiz.applyHandle(request, reviewHandler);
return Responses.of(applyID);
} catch (ValidateException e) {
return Responses.fail(e.getCode(), e.getMessage());
} catch (Exception e1) {
log.error("apply project failed", e1);
} finally {
RedisLockUtils.unLock(redisStoreClient, lockKey, identifier);
}
}
3.2.2:new StoreKey,tryLock,tryLock的实现
new StoreKey:
正常的情况下直接指定某一个字符串作为key即可,但是这里却额外做了一番设计。
存储 key 的组成包括3部分:category、template、version,最终存储的 key 字符串是由 {template}_${version} 组合而成:
- category 是某一类存储的名称,它不是存储 key,它是存储 key 的前缀,可以理解它是数据库里的一张表。对应到代码里就是上面的Constants.REVIEWREDISLOCK对应的值redis_distributed_lock(不用魔数)。
- template 是指如何组装 key 的模板。比如u{0}c{1}这个templete,u{0}代表第1个参数,真实意义是把userId组装到key中去,其中u就是userId的缩写。c{1}代表第2个参数比如cityId,是将cityId组装到key中去,其中c是cityId的缩写。在申请category的时候,会被要求填写详细的参数是什么意思,方便查看。对应到代码里就是上面的"redis_distributed"和applyTicketRequest.getOrderID() + "_apply"两个参数。
- version 是 squirrel 内部管理的一个数字,如果这个 category 清一次缓存,那么 version 会加1,这样最后的key字符串会变成全新的字符串,应用使用新的key之后需要重新从数据源加载数据到缓存,一开始默认是0.
因此上面的key通过new StoreKey()组装之后最终存储的key为:redis_distributed_lock.redis_distributed41249902_apply-0
Squirrel中的Category 类似于 tair 中的area概念, 同一个集群中可以有多个Category ,可以保证不同应用的 key 不会冲突。如果把squirrel底层看做一个数据库,那么 category 就是这个数据库中表的概念。
抽象出 Category 这样一个中间层的概念,一是便于开发识别不同Category的业务含义,二是方便于对底层的管理,有如下几点
1,统一管理,很多权限的分配都是基于Category的。比如key/value查询。
2,缓存清理,通过category的版本更改可以一键清理category下所有数据。
3,影子key 的设计 ,数据流控等也都是在Category级别上。
tryLock:
1:获取锁的时候需要记录日志,所以无论获取成功还是重试的过程都需要进行日志的记录。
2:在acquireTimeout的时间内,通过do whie循环一直尝试获取锁。这可以解决基于redis实现分布式锁造成的问题3
3:isRenewal字段可以解决基于redis实现分布式锁造成的问题1和2
4:具体的实现是通过调用setnx实现。这个有别于redis原生自带。正常情况下当线程获取锁成功,执行完任务之后就需要将锁释放,以便其他线程可以获取到锁执行任务。但是由于加锁和解锁是分为两步实现,不是原子操作,所以可能会出现中间状态:即加锁完成后,没有解锁(或解锁失败);导致资源锁住。
基于此有这种方案:
-
通过制定key的过期时间,让锁到期后自动释放:
> expire key显而易见,这边也有一个上述的风险点:就是非原子操作,可能存在中间状态。所以我们引入第二种方案。
-
基于set 原子操作实现:
> set key value [EX seconds] [PX milliseconds] [NX|XX]- EX seconds:设置键key的过期时间,单位时秒;
- PX milliseconds:设置键key的过期时间,单位时毫秒;
- NX:只有键key不存在的时候才会设置key的值;
- XX:只有键key存在的时候才会设置key的值。
通过 set (key, value, EX a,NX) 取代setnx 来实现原子加锁(自动释放锁)操作。
RedisLockUtils.tryLock的实现
/**
* 获取锁
* @param redisStoreClient redisClient
* @param storeKey 要获取的锁
* @param identifier 使用者唯一标识
* @param lockTimeout 锁失效时间
* @param acquireTimeout 锁竞争超时时间
* @param retryDuration 尝试竞争锁的间隔时间(每次等待retryDuration毫秒后,再次竞争锁)
* @param isRenewal 锁续期是否开启
* @return {@code true} 获取锁成功, {@code false} 竞争锁失败
*/
public static Boolean tryLock(RedisStoreClient redisStoreClient, StoreKey storeKey, String identifier, int lockTimeout, int acquireTimeout, long retryDuration,boolean isRenewal) {
if (storeKey != null && lockTimeout > 0) {
Long endTime = System.currentTimeMillis() + acquireTimeout * 1000L;
log.info("Try to acquire the lock. lockKey={},acquireTimeout={}s,lockTimeout={}s",
storeKey, acquireTimeout, lockTimeout);
do {
boolean acquiredLock = redisStoreClient.setnx(storeKey,identifier,lockTimeout);
if (acquiredLock) {
if (isRenewal) {
//lock renewal
scheduleExpirationRenewal(redisStoreClient, storeKey, identifier, lockTimeout);
}
log.info("acquired lock. lockKey={}", storeKey);
return true;
}
log.info("Retry to acquire the lock. lockKey={},acquireTimeout={}s,lockTimeout={}s",
storeKey, acquireTimeout, lockTimeout);
try {
log.info("wait 1000 milliseconds before retry. lockKey={}", storeKey);
Thread.sleep(retryDuration);
} catch (InterruptedException ignored) {
;
}
} while (System.currentTimeMillis() < endTime);
}
return false;
}
redisStoreClient.setnx的实现
public Boolean setnx(StoreKey key, final Object value, final int expireInSeconds) {
final StoreCategoryConfig categoryConfig = categoryConfigManager.findCacheKeyType(key.getCategory());
final String finalKey = categoryConfig.getFinalKey(key);
return new MonitorCommand(new Method(Method.Command.WRITE, "setnx", finalKey).expire(expireInSeconds)
, storeType, categoryConfig) {
@Override
public Object excute() throws Exception {
byte[] str = transcoder.encodeToBytes(value);
if (expireInSeconds > 0) {
String result = clientManager.getClient().set(SafeEncoder.encode(finalKey), str, "NX", "EX", expireInSeconds);
return OK_STR.equals(result);
} else {
return 1 == clientManager.getClient().setnx(SafeEncoder.encode(finalKey), str);
}
}
}.run();
}
锁续期
/**
* 锁续期机制
* @param redisStoreClient redisClient
* @param storeKey 要获取的锁
* @param identifier 使用者唯一标识
* @param lockTimeout 锁失效时间
*/
private static void scheduleExpirationRenewal(RedisStoreClient redisStoreClient, StoreKey storeKey, String identifier, int lockTimeout){
Timeout task = timer.newTimeout(new TimerTask() {
@Override
public void run(Timeout timeout) throws Exception {
try {
if (redisStoreClient.compareAndSet(storeKey,identifier,identifier,lockTimeout)) {
log.info("lock time renewal success. lockKey={}", storeKey);
scheduleExpirationRenewal(redisStoreClient, storeKey, identifier, lockTimeout);
}
}catch (Exception e){
log.info("lock time renewal failed. lockKey={}", storeKey);
}
}
},lockTimeout*1000L*2 / 3,TimeUnit.MILLISECONDS);
}
unLock:
相比简单粗暴的直接释放,这里进行了释放验证。对应的就是基于redis实现分布式锁的解决办法2 RedisLockUtils.unLock的实现
/**
* 释放锁
* @param redisStoreClient redisClient
* @param storeKey 要获取的锁
* @param userTag 使用者唯一标识
*/
public static void unLock(RedisStoreClient redisStoreClient, final StoreKey storeKey, final String userTag) {
if (storeKey != null && userTag != null) {
//比较value并删除key采用原子操作,保证解除的是自己的锁
redisStoreClient.compareAndDelete(storeKey,userTag);
}
}
redisStoreClient.compareAndDelete
public Boolean compareAndDelete(StoreKey key, final Object expect) {
checkNotNull(key, STORE_KEY_IS_NULL);
checkNotNull(expect, STORE_VALUE_IS_NULL);
final StoreCategoryConfig categoryConfig = categoryConfigManager.findCacheKeyType(key.getCategory());
final String finalKey = categoryConfig.getFinalKey(key);
return new MonitorCommand(new Method(Method.Command.WRITE, "compareAndDelete", finalKey)
, storeType, categoryConfig) {
@Override
public Object excute() throws Exception {
return clientManager.getClient().compareAndDelete(SafeEncoder.encode(finalKey),
transcoder.encodeToBytes(expect));
}
}.run();
}
3.3:基于zookeeper
3.3.1:基本思想
ZooKeeper是一个为分布式应用提供一致性服务的开源组件,它内部是一个分层的文件系统目录树结构,规定同一个目录下只能有一个唯一文件名。大致思想即为:每个客户端对某个方法加锁时,在zookeeper上的与该方法对应的指定节点的目录下,生成一个唯一的临时有序节点。判断是否获取锁的方式很简单,只需要判断有序节点中序号最小的一个。当释放锁的时候,只需将这个瞬时节点删除即可。同时,其可以避免服务宕机导致的锁无法释放,而产生的死锁问题。
3.3.2:具体步骤
- 创建一个目录mylock;
- 线程A想获取锁就在mylock目录下创建临时顺序节点;
- 获取mylock目录下所有的子节点,然后获取比自己小的兄弟节点,如果不存在,则说明当前线程顺序号最小,获得锁;
- 线程B获取所有节点,判断自己不是最小节点,设置监听比自己次小的节点;
- 线程A处理完,删除自己的节点,线程B监听到变更事件,判断自己是不是最小的节点,如果是则获得锁。
3.3.3:优缺点
优点:具备高可用、可重入、阻塞锁特性,可解决失效死锁问题。具体说明如下:
锁无法释放,造成死锁!使用Zookeeper可以有效的解决锁无法释放的问题,因为在创建锁的时候,客户端会在ZK中创建一个临时节点,一旦客户端获取到锁之后突然挂掉(Session连接断开),那么这个临时节点就会自动删除掉。其他客户端就可以再次获得锁。
阻塞锁特性!使用Zookeeper可以实现阻塞的锁,客户端可以通过在ZK中创建顺序节点,并且在节点上绑定监听器,一旦节点有变化,Zookeeper会通知客户端,客户端可以检查自己创建的节点是不是当前所有节点中序号最小的,如果是,那么自己就获取到锁,便可以执行业务逻辑了。
可重入!使用Zookeeper也可以有效的解决不可重入的问题,客户端在创建节点的时候,把当前客户端的主机信息和线程信息直接写入到节点中,下次想要获取锁的时候和当前最小的节点中的数据比对一下就可以了。如果和自己的信息一样,那么自己直接获取到锁,如果不一样就再创建一个临时的顺序节点,参与排队。
单点问题?使用Zookeeper可以有效的解决单点问题,ZK是集群部署的,只要集群中有半数以上的机器存活,就可以对外提供服务。
缺点:因为需要频繁的创建和删除节点,性能上不如Redis方式。