首先说下在多台服务器运行的情况下,我们通常会遇到哪些问题.
1.前端重复点击的时候,比如发表文章吧.后端接收到多次请求,由于是多台服务器,请求可能被分发到了不同的服务器上执行.这时候,就可能产生文章同时插入数据库的情况.类似这种情形主要出现在更新和插入的时候.
2.定时任务的执行.多台服务器会同时执行相同的任务.这样就会导致重复的操作.
对于上面的情况,如果存在于单机上,即同一个jvm中,只要加上锁就可以轻松的解决.但是在多台机器时就是个无法避免的问题了.
也许有人会说,我们可以在数据库纬度进行加锁.嗯,或许可以吧.因为在mysql中行级加锁操作只适应于update操作.而insert操作就必须得加表锁,这就代价很大了,完全不适合实际场景.而不管是行级还是表级,我觉得在数据库加锁都不是最优先的选择,而是最后没办法的保全.
好了,我们明确了分布式锁的必要性后,来看看如何实现redis锁.
实现锁的关键,在于线程唯一拥有资源的使用权,也就是我们常说的保证唯一线程进入临界区.所以,我们需要redis提供的原子操作,让redis承担我们线程进入临界区的钥匙管理员.
如图,我们目的在于设置redis的key,我们可以使用setnx,这个命令的意思是如果不存在才设置,并且是个原子操作.这样一旦setnx返回成功,说明我们设置key成功(获取锁成功),然后我们设置锁失效时间expireTime(value是到期的时间点),以防解锁失败后,key无法删除.
private final boolean doLock() {
Jedis jedis = JedisUtils.getRedis();
try {
lockValue = System.currentTimeMillis() + Expire_Seconds * 1000;
// 竞争上锁成功
if (jedis.setnx(key, lockValue + "") == 1) {
// 这里可能失败
jedis.expire(key, Expire_Seconds);
accquires.set(n + 1);
return true;
}
} finally {
if (jedis != null) {
jedis.close();
}
}
return false;
}
private final void doRelease() {
Jedis jedis = JedisUtils.getRedis();
try {
String now = jedis.get(key);
// 这里可能失败
jedis.del(key);
} finally {
if (jedis != null) {
jedis.close();
}
}
}
如果一切正常的话,我们就搞定了.但是理论上所有操作都有可能出错的.我们看看上面俩处可能出错的地方.
1.setnx后设置过期时间可能失败,那么这时候key就永远不会消失了,后续就再也不能获取锁了.
2.删除key失败(释放锁),这时候我们就在锁还未失效之前都无法获取锁.
对于第2种失败,暂时没有很好的解决方法,因为我们无法判断是因为删除失败了还是业务代码还未执行完.但是这种只是暂时导致业务无法执行,而且这种几乎不可能发生,而且如果我们把业务分的足够明确,则影响的范围就更小.
对于第1种错误,就直接导致业务无法进行了.那我们来看看如何解决这种错误,不,应该是出现这种错误了,我们怎么继续下去.
当我们再次竞争锁的时候,发现key还未消失,那么我们得先判断下这个锁是否已经过期失效了.上文说过,我们设置key的value值是到期的时间点,好,我们取出当前的value和当前的时间比较下,就可以知道是否已经失效了.当然这里也存在出现第1种错误时,在ExireTime的时间段无法继续请求锁的情况.
如果发现失效了,我们就可以再次获取锁了.要注意的是这里只是告诉线程我们可以竞争锁了而已.这里的竞争锁就不能使用setnx了,因为key早已经存在了.所以我们选择另外一个原子操作getset,意思是设置新值并返回旧值.这里需要判断下返回的旧值是否和刚刚判断失效的那个值(因为这里可能很多个竞争,无法确定getSet是否其他线程优先成功),如果相同说明我们获取锁成功.
String current = jedis.get(key);
// 上次没有成功设置expire,现在其实已经过期了
if (Long.valueOf(current).longValue() < System.currentTimeMillis()) {
lockValue = System.currentTimeMillis() + Expire_Seconds * 1000;
// 都来竞争上锁
String old = jedis.getSet(key, lockValue + "");
// 竞争上锁成功
if (String.valueOf(current).equals(old)) {
jedis.expire(key, Expire_Seconds);
accquires.set(n + 1);
return true;
} else {
return false;
}
}
从获取锁失效的问题上,我们考虑下释放锁的问题(就是del key).释放锁的问题,如果我们只是单纯的删除key的话,我们是不知道是不是应该删除.为什么这么说了,我们获取锁成功了不应该在finally里面释放掉么.从我们上面锁失效的问题来看,锁是有个失效时间的,假如我们的业务处理时间特别的异常的长,那么其实在锁释放的时候,锁其实已经失效(key消失了),别的线程都成功获取锁了.那这时候,我们却把别人的锁给释放了,是吧.所以,我们在删除之前应该判断下是不是我们应该删除的值.
String now = jedis.get(key);
if (now != null && now.equals(lockValue + "")) {
jedis.del(key);
}
这样的删除就很保险了.
ok,整个锁就设计完了,她能做啥了.我所知道的就是:
1.可以用于单纯防重操作;
2.可用于分布式锁.
然后,我们梳理下整个流程
这就是整个锁的设计流程.基本满足日常开发的需求了.
不过并没有到此结束,接下来我想说下重入锁的问题.主要是一次我在我的service层加了锁,并调用了另外一个service的方法,最后运行的时候一直报获取锁失败,我觉得很奇怪,我只请求了一次,并没有任何竞争锁的其他线程.最后发现是因为另一个service也加了相同的锁,因此获取锁失败了.这种错误可以从代码结构设计上避免,不过既然出现了,就需要考虑如何解决.
首先重入锁的意思是,如果对同一个对象加多次锁,是不会阻塞的,比如常见的ReetrantLock,实现的思路是用一个state充当锁的数量,每次重复加锁即给state加1,释放的时候,只有state==0的时候才算真正释放.那么我们也借用这种思想设计一下.我们来看下重入锁出现的情况.
lock1(obj) {
lock2(obj) {
lock3(obj) {
...
}
}
}
也就是说lock2的时候我们认为是获取成功了,只需要给获取锁数量加1就行了.首先我们得认识,重入的条件是线程已经获取了锁,那么重入必然是在同一个线程里进行的.于是我们借助ThreadLocal就可以实现了.在每次获取锁的时候,我们判断下:
int n = accquires.get();
if (n > 1) {
accquires.set(n + 1);
return true;
}
一旦获取成功我们就
accquires.set(accquires.get() + 1);
同理释放的时候:
int n = accquires.get();
if (n > 1) {
accquires.set(n - 1);
return;
}
这样就可以重入了,不过要好好理解下同一个线程,跨服务调用可不是同一个线程,也是无法重入的.
最后贴下整体的代码:
整体代码详见github