利用redis设计分布式锁

首先说下在多台服务器运行的情况下,我们通常会遇到哪些问题.
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

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 3
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值