首先你必须弄清楚,用分布式锁来做什么?是解决效率问题,还是一致性问题;不能混为一谈,跟个追星族一样,哪个工具火就用哪个,比如Redis很火,什么都用它,锁就想当然的用Redlock;如果是效率问题,那可能还好一些;但是如果是一致性问题,那你得认真对待,你目前的实现方案可能会某一天让你痛苦不堪!

你为什么要用分布式锁

  • 效率
    比如分布式多节点,虽然业务有幂等性保护,但是不想让一次任务被调度多次(打印太多错误日志,数据库主键约束,耗费资源)

  • 保证准确完整
    为保证数据状态的准确一致,如果多个节点同时被调度,会造成数据损坏或丢失,或者系统运行状态不可控


这两种情况,一定要区别对待!

如果是后者,你用的方案不是强一致,那不是自欺欺人吗?比如很流行的Redlock本身并非强一致性,却要解决一致性问题,参见Jepsen test Redis, How to do distributed locking
如果是前者,出于效率优化,却大费周折搞了个大规模集群,增加问题的复杂度,或多或少有点大炮打蚊子

问题分析

一定要谨慎谨慎,再谨慎!不能github上找一个就用!比如下面这个用Redis实现的,他可没告诉你不适于强一致性要求;你发现问题了吗?

public synchronized void lock() {
long timeout = timeoutMsecs;
JedisCommands jedis = jedisPool.getResource();
try {
while (timeout >= 0) {
long expires = System.currentTimeMillis() + expireMsecs + 1;
String expiresStr = String.valueOf(expires);
if (jedis.setnx(lockKey, expiresStr) == 1) {
// lock acquired
jedis.expire(lockKey, (int) TimeUnit.MILLISECONDS.toSeconds(expireMsecs) + 30);
locked = true;
return;
}
String currentValueStr = jedis.get(lockKey);
if (currentValueStr != null
&& Long.parseLong(currentValueStr) < System.currentTimeMillis()) {
// lock is expired
String oldValueStr = jedis.getSet(lockKey, expiresStr);
jedis.expire(lockKey, (int) TimeUnit.MILLISECONDS.toSeconds(expireMsecs) + 30);
if (oldValueStr != null && oldValueStr.equals(currentValueStr)) {
// lock acquired
locked = true;
return;
}
}
timeout -= 100;
try {
Thread.sleep(100);
} catch (InterruptedException e) {
throw new LockException(-1, e);
}
}
} finally {
jedisPool.returnResource(jedis);
}
}

我敢说90%多都是这么实现的,setnx => get => getSet, 官方文档也是这么给的http://redis.io/commands/setnx

  • System.currentTimeMillis比较,通过和系统本地时间比较判定超时时间,有待商榷;尤其是大规模集群,时间准同步基本不可能,或多或少都有偏差,网络问题造成delay很难避免

  • synchronized多个jvm还有用吗?

考虑这个场景:

  1. A,B节点同时判定锁超时
  2. A节点getSet成功,成功获取到锁返回
  3. B节点getSet, 比较oldValueStr.equals(currentValueStr),获取锁失败,进入下一轮回
  4. B节点时钟跳前,执行getSet,比较oldValueStr.equals(currentValueStr)成功,成功获取到锁返回

这样是不是A,B节点就同时获取到锁了?虽说概率极低,但是还是有这种可能

继续看unlock方法

public synchronized void unlock() {
JedisCommands jedis = jedisPool.getResource();
try {
if (locked) {
jedis.del(lockKey);
locked = false;
}
} finally {
jedisPool.returnResource(jedis);
}
}

我们知道为了避免死锁,都会设置锁超时时间;这是对的,但是这个unlock实现是有问题的

  1. A节点成功获取锁
  2. A节点full gc造成锁超时
  3. B节点成功获取到锁
  4. A节点full gc后,任务执行完,调用unlock
  5. C节点成功获取到锁,此时B,C节点同时占有锁

虽说超时问题确实很难解决,因为很难断定A节点是真的宕机还是处理很慢,但是问题在于A节点删除了别人的锁

解决方法

该解决方法仅限于解决效率问题,不适用于强一致性要求,同时超时情况下有可能多节点同时持有锁;但是会很好的避免上述状况

不要用System.currentTimeMillis

http://redis.io/commands/set, 把【超时时间,key, token】原子塞进Redis,把超时判断逻辑交给Redis(其实Redis也是用系统时间判断的https://github.com/antirez/redis/blob/edd4d555df57dc84265fdfb4ef59a4678832f6da/src/server.c#L390-L404)

public synchronized boolean tryLock() {
Jedis jedis = jedis();
try {
int timeout = getTimeoutMsecs();
while (timeout >= 0) {
if ("OK".equals(jedis().set(getLockKey(), getToken(), "NX", "PX", getExpireMsecs()))) {
setLocked(true);
return true;
}
timeout -= 100;
try {
TimeUnit.MILLISECONDS.sleep(100);
} catch (InterruptedException e) {
throw new LockException(-1, e);
}
}
} finally {
jedis.close();
}
return false;
}

unlock加入token校验

利用lua脚本,保证原子性

private static final String DELETE_IF_OWNED =
"if redis.call('get', KEYS[1]) == ARGV[1] then "
+ "return redis.call('del', KEYS[1]) "
+ "else "
+ "return 0 "
+ "end";

public synchronized void unlock() {
Jedis jedis = jedis();
try {
if (isLocked()) {
jedis.eval(DELETE_IF_OWNED, Arrays.asList(getLockKey()), Arrays.asList(getToken()));
setLocked(false);
}
} finally {
jedis.close();
}
}

最后

分布式锁服务实现确实很难,但是至少要先明确用来做什么,效率 or 准确性;这样才能选型,若准确性需求,那选择的工具最起码要天生支持一致性(Paxos,Raft,数据库事务机制), 然后再考虑超时等其他问题;

超时问题是个通病,前面也说到了,目前还没发现很好的解决方法,很多异常检测算法也是概率性的;所以如果需求必须保证准确性,基于数据库事务机制设计会稳妥一些,不可避免侵入业务逻辑,合理权衡