目录
近期项目在使用分布式锁实现业务并发控制方面频繁踩坑,事后对redis与zookeeper实现分布式锁的细节及选型的一些思考;
1. 分布式锁应用场景
在了解分布式锁之前先回顾一下JDK的synchronized和Lock,它们是作用在同一个JVM进程中的一种锁,但当你系统采用集群式部署之后,就无法使用JDK中的锁来保证全局互斥性了,这种情况下,就可以选用分布式锁来解决;
常用的分布式锁方案主要有:Redis、Zookeeper、Etcd来实现,但Etcd本次不做解释;
在项目中,我们该如何去做分布式锁的选型,在文章最后会给出个人所理解;
2. Redis分布式锁
2.1 原生redis实现
原生redis通常是使用:jedis.set(String key, String value, String nxxx, String expx, int time);
key:就是我们需要加锁的key;
value:这个一般是传入一个当前请求的唯一标识(比如uuid);
nxxx:它有两个取值 NX和XX,NX即当key不存在的问题进行set操作,若已存在,则不做任何操作;XX:当key存在的时候,才对键进行设置操作;
expx:指定key过期的时间单位,EX=秒;PX=毫秒;
time:表示key的过期时间;
2.1.1 加锁
jedis.set(lockKey, UUID.randomUUID().toString(), NX, PX, 3000);
2.1.2 解锁
解锁是不是认为直接jedis.del(lockKey)就可以了呢?当然不行,为什么呢?
正确的释放锁方式,应该先判断删除key的线程是否是加锁的线程,如果是同一个线程,则可以删除key,否则无法删除,但这块的判断逻辑需要我们借助lua脚本来实现;
// 获取lockKey的值是否== lockValue
if redis.call('get', KEYS[1]) == ARGV[1] then
// 删除lockKey
return redis.call('del', KEYS[1])
else
return 0
end
jedis.eval(lua,1,lockKey, lockValue);
里面的1、KEYS[1]和ARGV[1]代表什么意思呢?
1:表示键名参数的数量,类似KEYS[]数组长度;
KEYS[1]:则是取lockKey的值;
ARGV[1]:则表示lockValue的值;
2.1.3 续期问题
上面的问题逻辑看似已经接近完美,但在项目中总会出现一些意想不到的问题,就是我们接下来要说的锁过期问题;
正常情况下,业务在锁过期之前执行完毕不会有任何问题,但总会有一些异常情况(比如网络)会导致在你设置的过期时间内没有执行完业务逻辑,但锁过期会自动释放,此时线程B就会竞争到此锁,同样会产生并发问题;
解决这个问题的思路:
- 将锁的过期时间设置过大
- 为锁的线程添加守护线程,定期为key续期;
- 通过redis中的发布订阅模式实现通知;
这里以定期为Key续期为例:
private class RenewalThread extends Thread{
public RenewalThread(){
this.setDaemon(true);
}
@Override
public void run() {
String renewalScript = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
"return redis.call('expire',KEYS[1],ARGV[2]) " +
"else " +
"return 0 end";
jedis.eval(renewalScript, 1, "NX", "PX", "3000");
}
}
将该线程交由 ScheduledExecutorService定时去执行;
当然除了这个问题之外,还有锁可重入问题、集群环境还有锁的使用上可能需要while(true)去获取锁,获取不到的时候就休眠一会,然后再去获取,这种方式也增加了代码的危险因素;
2.2 redisson实现
综合以上因素,可以考虑使用redisson来解决以上的麻烦,redisson提供了锁重入问题、读写锁、信号量、红锁等功能非常的强大;详细介绍可参考官网
2.2.1 加锁
public boolean tryLock(String lockKey, long waitTime, long leaseTime) {
boolean suc;
try {
RLock lock = redissonClient.getLock(lockKey);
//第一个参数是等待时间,比如5秒内获取不到锁,则直接返回。 第二个参数 比如60是60秒后强制释放
suc = lock.tryLock(waitTime, leaseTime, TimeUnit.MILLISECONDS);
} catch (Throwable e) {
String msg = String.format("LOCK FAILED: key=%s||tryLockTime=%s||lockExpiredTime=%s", lockKey, waitTime, leaseTime);
throw new IllegalStateException(msg, e);
}
return suc;
}
2.2.2 释放锁
public void unlock(String lockKey) {
try {
RLock lock = redissonClient.getLock(lockKey);
if (lock != null && lock.isHeldByCurrentThread()) {
lock.unlock();
}
} catch (Throwable e) {
String msg = String.format("UNLOCK FAILED: key=%s", lockKey);
throw new IllegalStateException(msg, e);
}
}
2.2.3 加锁源码分析
public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException {
// 传入的锁等待时间
long time = unit.toMillis(waitTime);
// 当前时间
long current = System.currentTimeMillis();
// 当前线程id
long threadId = Thread.currentThread().getId();
// 尝试加锁,并返回锁的剩余时间
// tryAcquire -> tryAcquireAsync -> tryLockInnerAsync
// 此方法如果返回的null,说明加锁成功,如果返回的是key的过期时间,则表示没有加锁成功;
Long ttl = tryAcquire(leaseTime, unit, threadId);
// lock acquired
if (ttl == null) {
return true;
}
// 用当前时间-刚进入方法的时候,这个主要是判断上面的一系列操作时间是否>waitTime
// 如果大于,则说明在等待的时间内没有获取到锁;
time -= System.currentTimeMillis() - current;
if (time <= 0) {
acquireFailed(threadId);
return false;
}
current = System.currentTimeMillis();
// 订阅key的释放事件
RFuture<RedissonLockEntry> subscribeFuture = subscribe(threadId);
// 阻塞等待锁释放
// await返回false,说明等待时间已经超出获取锁最大等待时间,取消订阅并返回获取锁失败
// await返回true,进入循环尝试获取锁
if (!subscribeFuture.await(time, TimeUnit.MILLISECONDS)) {
if (!subscribeFuture.cancel(false)) {
subscribeFuture.onComplete((res, e) -> {
if (e == null) {
unsubscribe(subscribeFuture, threadId);
}
});
}
acquireFailed(threadId);
return false;
}
try {
// 如果已经超时,则直接返回,获取锁失败
time -= System.currentTimeMillis() - current;
if (time <= 0) {
acquireFailed(threadId);
return false;
}
while (true) {
long currentTime = System.currentTimeMillis();
// 再次尝试申请锁,如果已经释放,则直接加锁成功返回;
ttl = tryAcquire(leaseTime, unit, threadId);
// lock acquired
if (ttl == null) {
return true;
}
// 再次判断是否超时
time -= System.currentTimeMillis() - currentTime;
if (time <= 0) {
acquireFailed(threadId);
return false;
}
// waiting for message
currentTime = System.currentTimeMillis();
if (ttl >= 0 && ttl < time) {
// 如果锁剩余时间 < 当前线程剩余的等待时间
// 利用共享锁来阻塞等待判断是否允许等待共享锁,允许则加入共享锁等待释放信号
// 1、latch其实是个信号量Semaphore,调用其tryAcquire方法会让当前线程阻塞一段时间,避免了在while循环中频繁请求获取锁;
2、该Semaphore的release方法,会在订阅解锁消息的监听器消息处理方法org.redisson.pubsub.LockPubSub#onMessage调用;当其他线程释放了占用的锁,会广播解锁消息,监听器接收解锁消息,并释放信号量,最终会唤醒阻塞在这里的线程。
subscribeFuture.getNow().getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
} else {
subscribeFuture.getNow().getLatch().tryAcquire(time, TimeUnit.MILLISECONDS);
}
// 更新剩余的等待时间,如果超时则返回;
time -= System.currentTimeMillis() - currentTime;
if (time <= 0) {
acquireFailed(threadId);
return false;
}
}
} finally {
// 取消订阅
unsubscribe(subscribeFuture, threadId);
}
}
private <T> RFuture<Long> tryAcquireAsync(long leaseTime, TimeUnit unit, long threadId) {
// 当直接调用tryLock()时,leaseTime的值为-1,设置了超时时间
if (leaseTime != -1) {
return tryLockInnerAsync(leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
}
// 未设置超时时间,就使用redisson的看门狗监视,默认30s,会自动续期
RFuture<Long> ttlRemainingFuture = tryLockInnerAsync(commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout(), TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);
ttlRemainingFuture.onComplete((ttlRemaining, e) -> {
if (e != null) {
return;
}
// lock acquired
if (ttlRemaining == null) {
scheduleExpirationRenewal(threadId);
}
});
return ttlRemainingFuture;
}
<T> RFuture<T> tryLockInnerAsync(long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
internalLockLeaseTime = unit.toMillis(leaseTime);
return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, command,
// 首先判断key是否存在,如果不存在则创建key并设置过期时间,nil ,表示获取锁成功
"if (redis.call('exists', KEYS[1]) == 0) then " +
"redis.call('hincrby', 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]);",
Collections.<Object>singletonList(getName()), internalLockLeaseTime, getLockName(threadId));
}
2.2.4 解锁源码分析
public void unlock() {
try {
// 这里传入当前线程的id,为了避免删错
get(unlockAsync(Thread.currentThread().getId()));
} catch (RedisException e) {
if (e.getCause() instanceof IllegalMonitorStateException) {
throw (IllegalMonitorStateException) e.getCause();
} else {
throw e;
}
}
}
public RFuture<Void> unlockAsync(long threadId) {
RPromise<Void> result = new RedissonPromise<Void>();
RFuture<Boolean> future = unlockInnerAsync(threadId);
future.onComplete((opStatus, e) -> {
cancelExpirationRenewal(threadId);
if (e != null) {
result.tryFailure(e);
return;
}
if (opStatus == null) {
IllegalMonitorStateException cause = new IllegalMonitorStateException("attempt to unlock lock, not locked by current thread by node id: "
+ id + " thread-id: " + threadId);
result.tryFailure(cause);
return;
}
result.trySuccess(null);
});
return result;
}
protected RFuture<Boolean> unlockInnerAsync(long threadId) {
return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
// 若锁存在,但唯一标识不匹配:则表明锁被其他线程占用,当前线程不允许解锁其他线程持有的锁
"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 " +
// 锁重入计数已为0:间接表明锁已释放了。直接删除掉锁,并广播解锁消息,去唤醒那些争抢过锁但还处于阻塞中的线程
"redis.call('del', KEYS[1]); " +
"redis.call('publish', KEYS[2], ARGV[1]); " +
"return 1; "+
"end; " +
"return nil;",
Arrays.<Object>asList(getName(), getChannelName()), LockPubSub.UNLOCK_MESSAGE, internalLockLeaseTime, getLockName(threadId));
}
//在这里删除key之后,会发送publish消息;此消息会被监控到;
public class LockPubSub extends PublishSubscribe<RedissonLockEntry> {
public static final Long UNLOCK_MESSAGE = 0L;
public static final Long READ_UNLOCK_MESSAGE = 1L;
public LockPubSub(PublishSubscribeService service) {
super(service);
}
@Override
protected RedissonLockEntry createEntry(RPromise<RedissonLockEntry> newPromise) {
return new RedissonLockEntry(newPromise);
}
@Override
protected void onMessage(RedissonLockEntry value, Long message) {
// 判断是否是释放锁的消息
if (message.equals(UNLOCK_MESSAGE)) {
Runnable runnableToExecute = value.getListeners().poll();
if (runnableToExecute != null) {
runnableToExecute.run();
}
// 释放一个信号量,唤醒等待的【entry.getLatch().tryAcquire()】去再次尝试申请锁(加锁源码中会再次竞争)
value.getLatch().release();
} else if (message.equals(READ_UNLOCK_MESSAGE)) {
while (true) {
Runnable runnableToExecute = value.getListeners().poll();
if (runnableToExecute == null) {
break;
}
runnableToExecute.run();
}
value.getLatch().release(value.getLatch().getQueueLength());
}
}
}
2.2.5 红锁
Redlock算法的介绍看这里有超详细的说明,Redisson也对Redlock算法提供了实现;
RLock lock1 = redissonInstance1.getLock("lock1");
RLock lock2 = redissonInstance2.getLock("lock2");
RLock lock3 = redissonInstance3.getLock("lock3");
RedissonRedLock lock = new RedissonRedLock(lock1, lock2, lock3);
// 同时加锁:lock1 lock2 lock3
// 红锁在大部分节点上加锁成功就算成功。
lock.lock();
...
lock.unlock();
2.2.6 不同redis集群对redisson的影响
选用redisson没有问题,但是需要前期做好redis部署方式的调研,比如:我们的redis集群是基于codis实现的,而codis是不支持发布订阅模式,那这就不能很多的支持redisson了。顺便调研了一下 redis-cluster 对sub/pub的支持也不是太好。所以选择redisson是需要考虑你的redis集群是否支持sub/pub;
3. Zookeeper分布式锁
3.1 zk实现分布式锁原理
zk实现分布式锁的几个要求:
1.创建的节点是临时顺序节点,临时:断开连接自动删除节点,天然处理了锁无法释放问题,顺序:zookeeper会为我们创建000000001,0000000002类似这种节点;
2.创建一个临时顺序节点之后,获取当前key下的所有的节点,判断自己是否是最小的那一个,如果是则获取成功,否则,监听zookeeper的节点删除事件,监听比自己小1的节点的删除事件;
这样的话,整个并发加锁过程相当于形成一个链表串起来;
3.2 原生zookeeper
public interface Lock {
/**
* 获取锁
*/
void getLock() throws Exception;
/**
* 释放锁
*/
void unlock() throws Exception;
}
@Slf4j
public abstract class AbstractTemplateLock implements Lock {
@Override
public void getLock() {
if (tryLock()) {
log.info("获取锁成功");
} else {
//等待
waitLock();//事件监听 如果节点被删除则可以重新获取
// 重新获取
getLock();
}
}
protected abstract void waitLock();
protected abstract boolean tryLock();
protected abstract void releaseLock();
@Override
public void unlock() {
releaseLock();
}
}
@Slf4j
public class ZkSequenTemplateLock extends AbstractTemplateLock {
private static final String zkServers = "zk集群地址";
private static final int sessionTimeout = 16000;
private static final int connectionTimeout = 8000;
private static final String parentLockPath = "/lock_path";
private String beforePath;
private String currentPath;
private ZkClient client;
/**
* 初始化父节点
*/
public ZkSequenTemplateLock() {
client = new ZkClient(zkServers, sessionTimeout, connectionTimeout);
// 如果不存在,则说明还没有加过锁,则创建父节点;
if (!client.exists(parentLockPath)) {
client.createPersistent(parentLockPath);
}
log.info("zk client 连接成功:{}", zkServers);
}
@Override
protected void waitLock() {
// 如果没有获取到锁,则使用countdownlatch阻塞当前线程,当上个节点删除的时候,再继续获取;
CountDownLatch latch = new CountDownLatch(1);
IZkDataListener listener = new IZkDataListener() {
@Override
public void handleDataDeleted(String dataPath) throws Exception {
log.info("监听到节点被删除");
latch.countDown();
}
@Override
public void handleDataChange(String dataPath, Object data) throws Exception {
}
};
// 给排在前面的节点增加数据删除的watcher,本质是启动另一个线程去监听上一个节点
client.subscribeDataChanges(beforePath, listener);
// 阻塞自己
if (client.exists(beforePath)) {
try {
latch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
// 取消watcher注册
client.unsubscribeDataChanges(beforePath, listener);
}
@Override
protected boolean tryLock() {
if (currentPath == null) {
// 创建一个临时顺序节点
currentPath = client.createEphemeralSequential(parentLockPath + "/", "lock-data");
}
// 获得所有的子节点并排序。临时节点名称为自增长的字符串
List<String> childrens = client.getChildren(parentLockPath);
// 排序list,按自然顺序排序
Collections.sort(childrens);
if (currentPath.equals(parentLockPath + "/" + childrens.get(0))) {
return true;
} else {
// 如果当前节点不是排第一,则获取前面一个节点信息,赋值给beforePath
int curIndex = childrens.indexOf(currentPath.substring(parentLockPath.length() + 1));
beforePath = parentLockPath + "/" + childrens.get(curIndex - 1);
}
return false;
}
@Override
public void releaseLock() {
client.delete(currentPath);
}
}
这上面实现的是一种公平锁,zk也可以实现非公平锁;
3.2.1 原生锁的缺点
- 没有解决锁重入问题;
- 没有网络连接失败重连处理;
- 实现读写锁、信号量等比较麻烦;
3.3 Curator
zookeeper作者的一句话形容Curator:Guava is to Java that Curator to Zookeeper
实现代码也比较简单:
@Configuration
public class ZkProperties {
@Value("${zklock.config.retryCount:3}")
private int retryCount;
@Value("${zklock.config.retryMs:1000}")
private int retryMs;
@Value("${zklock.config.connect}")
private String connect;
@Value("${zklock.config.sessionTimeout}")
private int sessionTimeout;
@Value("${zklock.config.connectionTimeout}")
private int connectionTimeout;
@Value("${zklock.config.rootPath}")
private String rootPath;
// ...省略get/set
}
@Configuration
public class CuratorFrameworkConfig {
@Autowired
private ZkProperties zkProperties;
@Bean
public CuratorFramework curatorFramework() {
RetryPolicy retryPolicy = new RetryNTimes(zkProperties.getRetryCount(), zkProperties.getRetryMs());
CuratorFramework client = CuratorFrameworkFactory.builder()
.connectString(zkProperties.getConnect())
.sessionTimeoutMs(zkProperties.getSessionTimeout())
.connectionTimeoutMs(zkProperties.getConnectionTimeout())
.retryPolicy(retryPolicy)
.build();
// 启动
client.start();
initRootPath(client);
return client;
}
private void initRootPath(CuratorFramework client) {
try {
Stat stat = client.checkExists().forPath(zkProperties.getRootPath());
if (null == stat) {
client.create().withMode(CreateMode.PERSISTENT)
.forPath(zkProperties.getRootPath());
}
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
public interface Lock {
/**
* 获取锁
*/
InterProcessLock getLock(String key) throws Exception;
InterProcessLock getLock(String key, Long time, TimeUnit timeUnit) throws Exception;
/**
* 释放锁
*/
void unlock(InterProcessLock lock) throws Exception;
}
@Component
public class ZkLockService implements Lock {
@Autowired
private CuratorFramework curatorFramework;
@Autowired
private ZkProperties zkProperties;
/**
* 公平可重入排它锁
*
* @param key
* @return
* @throws Exception
*/
@Override
public InterProcessLock getLock(String key) throws Exception {
String keyPath = zkProperties.getRootPath() + "/" + key;
// 实例化 zk分布式锁
InterProcessMutex mutex = new InterProcessMutex(curatorFramework, keyPath);
mutex.acquire();
return mutex;
}
/**
* 公平可重入排它锁
*
* @param key
* @return
* @throws Exception
*/
@Override
public InterProcessLock getLock(String key, Long time, TimeUnit timeUnit) throws Exception {
String keyPath = zkProperties.getRootPath() + "/" + key;
// 实例化 zk分布式锁
InterProcessMutex mutex = new InterProcessMutex(curatorFramework, keyPath);
mutex.acquire(time, timeUnit);
return mutex;
}
@Override
public void unlock(InterProcessLock lock) throws Exception {
lock.release();
}
}
1.zookeeper的实现方案中也存在一些其它的问题,只是目前我还没遇到,比如:假死、脑裂问题;这个后面单独讨论;
2.上面的CuratorFramework是单例的,那如何保证它的连接健康呢?其它zookeeper客户端与服务器连接成功之后,会一直发送心跳以保证当前连接可用;
4. redis与zookeeper实现分布式锁选型
在选型之前首先搞明白两点:1.并发量;2.CP还是AP;
4.1.1 并发量
使用上redis的性能要比zookeeper分布式锁的性能高很多,具体的对比指标后面有时间补充;
在测试环境:
zookeeper加锁需要200ms左右
redis需要50ms左右
此值不太准,只供参考,以免误导;
4.1.2 一致性问题
redis的架构设计是基于AP模型,保证可用性,牺牲一致性(最大保证一致性),而zookeeper的架构设计是基于CP模型,保证数据的一致性,尽最大努力保证可用性。那么这它和我们分布式锁的选型有什么关系呢?
以redis为例:
假如发生线程A创建锁成功,但是还没等到master->slave的时候,这时候master挂了,但redis集群重新选出新的master,此是线程B请求新的master加锁成功,产生了并发问题;
如果你的服务即要求并发又要求一致性,那只能在业务层面再做一层一致性的处理了;
注:以上说的redis是redis集群,如果是单机,当然不存在数据一致性的问题;
4.1.3 比较完美的解决方案
我调研了一下我们公司,有些项目已经开始尝试对etcd的探索,但目前个人对etcd的了解还不太熟悉;
站在巨人的肩膀上成长;
参考文献:
1.redisson使用手册
2.Redis分布式锁-官网
3.慢谈 Redis 实现分布式锁 以及 Redisson 源码解析
4.七张图彻底讲清楚ZooKeeper分布式锁的实现原理【石杉的架构笔记】