一般的锁只能针对单机下同一进程的多个线程,或单机的多个进程。多机情况下,对同一个资源访问,需要对每台机器的访问进程或线程加锁,这便是分布式锁。分布式锁可以利用多机的共享缓存(例如redis)实现。redis的命令文档[1],实现及分析参考文档[2]。
利用redis的get、setnx、getset、del四个命令可以实现基于redis的分布式锁:
- get key:表示从redis中读取一个key的value,如果key没有对应的value,返回nil,如果存储的值不是字符串类型,返回错误
- setnx key value:表示往redis中存储一个键值对,但只有当key不存在时才成功,返回1;否则失败,返回0,不改变key的value
- getset key:将给定 key 的值设为 value ,并返回 key 的旧值(old value)。当旧值不存在时返回nil,当旧值不为字符串类型,返回错误
- del key:表示删除key,当key不存在时不做操作,返回删除的key数量
关于加锁思考,循环中:
0、setnx的value是当前机器时间+预估运算时间作为锁的失效时间。这是为了防止获得锁的线程挂掉而无法释放锁而导致死锁。
0.1、返回1,证明已经获得锁,返回啦
0.2、返回0,获得锁失败,需要检查锁超时时间
1、get 获取到锁,利用失效时间判断锁是否失效。
1.1、取锁超时时间的时刻可能锁被删除释放,此时并没有拿到锁,应该重新循环加锁逻辑。
2、取锁超时时间成功
2.1、锁没有超时,休眠一下,重新循环加锁
2.2、锁超时,但此时不能直接释放锁删除。因为此时可能多个线程都读到该锁超时,如果直接删除锁,所有线程都可能删除上一个删除锁又新上的锁,会有多个线程进入临界区,产生竞争状态。
3、此时采用乐观锁的思想,用getset再次获取锁的旧超时时间。
3.1、如果此时获得锁旧超时时间成功
3.1.1、等于上一次获得的锁超时时间,证明两次操作过程中没有别人动过这个锁,此时已经获得锁
3.1.2、不等于上一次获得的锁超时时间,说明有人先动过锁,获取锁失败。虽然修改了别人的过期时间,但因为冲突的线程相差时间极短,所以修改后的过期时间并无大碍。此处依赖所有机器的时间一致。
3.2、如果此时获得锁旧超时时间失败,证明当前线程是第一个在锁失效后又加上锁的线程,所以也获得锁
4、其他情况都没有获得锁,循环setnx吧
关于解锁的思考:
在锁的时候,如果锁住了,回传超时时间,作为解锁时候的凭证,解锁时传入锁的键值和凭证。我思考的解锁时候有两种写法:
1、解锁前get一下键值的value,判断是不是和自己的凭证一样。但这样存在一些问题:
- get时返回nil的可能,此时表示有别的线程拿到锁并用完释放
- get返回非nil,但是不等于自身凭证。由于有getset那一步,当两个竞争线程都在这个过程中时,存在持有锁的线程凭证不等于value,而是value是稍慢那一步线程设置的value。
2、解锁前用凭证判断锁是否已经超时,如果没有超时,直接删除;如果超时,等着锁自动过期就好,免得误删别人的锁。但这种写法同样存在问题,由于线程调度的不确定性,判断到删除之间可能过去很久,并不是绝对意义上的正确解锁。
一个样例代码
public class RedisLock {
private static final Logger logger = LoggerFactory.getLogger(RedisLock.class);
//显然jedis还需要自己配置来初始化
private Jedis jedis = new Jedis();
//默认锁住15秒,尽力规避锁时间太短导致的错误释放
private static final long DEFAULT_LOCK_TIME = 15 * 1000;
//尝试锁住一个lock,设置尝试锁住的次数和超时时间(毫秒),默认最短15秒
//成功时返回这把锁的key,解锁时需要凭借锁的lock和key
//失败时返回空字符串
public String lock(String lock, int retryCount, long timeout) {
Preconditions.checkArgument(retryCount > 0 && timeout > 0, "retry count <= 0 or timeout <= 0 !");
Preconditions.checkArgument(retryCount < Integer.MAX_VALUE && timeout < Long.MAX_VALUE - DEFAULT_LOCK_TIME,
"retry count is too big or timeout is too big!");
String $lock = Preconditions.checkNotNull(lock) + "_redis_lock";
long $timeout = timeout + DEFAULT_LOCK_TIME;
String ret = null;
//重试一定次数,还是拿不到,就放弃
try {
long i, status;
for (i = 0, status = 0; status == 0 && i < retryCount; ++i) {
//尝试加锁,并设置超时时间为当前机器时间+超时时间
if ((status = jedis.setnx($lock, ret = Long.toString(System.currentTimeMillis() + $timeout))) == 0) {
//获取锁失败,查看锁是否超时
String time = jedis.get($lock);
//在加锁和检查之间,锁被删除了,尝试重新加锁
if (time == null) {
continue;
}
//锁的超时时间戳小于当前时间,证明锁已经超时
if (Long.parseLong(time) < System.currentTimeMillis()) {
String oldTime = jedis.getSet($lock, Long.toString(System.currentTimeMillis() + $timeout));
if (oldTime == null || oldTime.equals(time)) {
//拿到锁了,跳出循环
break;
}
}
try {
TimeUnit.MILLISECONDS.sleep(1L);
} catch (InterruptedException e) {
logger.error("lock key:{} sleep failed!", lock);
}
}
}
if (i == retryCount && status == 0) {
logger.info("lock key:{} failed!", lock);
return "";
}
//给锁加上过期时间
jedis.pexpire($lock, $timeout);
logger.info("lock key:{} succsee!", lock);
return ret;
} catch (Exception e) {
logger.error("redis lock key:{} failed! cached exception: ", lock, e);
return "";
}
}
//释放lock的锁,需要传入lock和key
//尽力确保删除属于自己的锁,但是不保证做得到
public void releaseLock(String lock, String key) {
String $lock = Preconditions.checkNotNull(lock) + "_redis_lock";
Preconditions.checkNotNull(key);
try {
long timeout = Long.parseLong(key);
//锁还没有超时,锁还属于自己可以直接删除
//但由于线程运行的不确定性,其实不能完全保证删除时锁还属于自己
//真正执行删除操作时,距离上语句判断可能过了很久
if (timeout <= System.currentTimeMillis()) {
jedis.del($lock);
logger.info("release lock:{} with key:{} success!", lock, key);
} else {
logger.info("lock:{} with key:{} timeout! wait to expire", lock, key);
}
} catch (Exception e) {
logger.error("redis release {} with key:{} failed! cached exception: ", lock, key, e);
}
}
}
2、解锁前用凭证判断锁是否已经超时,如果没有超时,直接删除;如果超时,等着锁自动过期就好,免得误删别人的锁。但这种写法同样存在问题,由于线程调度的不确定性,判断到删除之间可能过去很久,并不是绝对意义上的正确解锁。对于新版的redis,在set方法中通过两个参数达到一条命令执行。在旧版的redis中使用pipeline的方式也能达到这个效果。
关于解锁我只想到这么多,希望有帮助,欢迎拍砖多交流。
参考链接:
[1].http://doc.redisfans.com/
[2].http://blog.csdn.net/ugg/article/details/41894947