分布式锁的一种实现思路

怎样才是一个好的分布式锁

实现分布式锁一般有三种方式:

  1. 数据库乐观锁;
  2. 基于Redis实现;
  3. 基于Zookeeper实现(其他类ZK的组件,如Etcd等);

考虑架构来说,一般从性能和可靠性考虑,综合而言,一般选用Redis来实现分布式锁。
那如何从可靠性来评估一个好的分布式锁呢?
互斥性 : 互斥性指在同一时刻,有且仅有一个客户端能拿到这把锁;
防止死锁 : 需要保证,任何情况下拿到锁的客户端崩溃,没有释放锁,这把锁也能主动释放,给别的客户端使用;
加锁解锁必须是一个人 : 任何情况下,主动解锁的必须是加锁的客户端,一个客户端不能释放另外客户端持有的锁(业务处理情况只有自己客户端清楚)
可重入 可重入指的是同一个客户端可以在持有锁的时候重复调用锁而不发生死锁的特性,不是必须特性,在一些场景下必要;

代码实现

基于上面的要求,我们来实现一把分布式锁:
先引入redis客户端;

加锁代码

先看代码:

/**
     * 尝试获取分布式锁
     * @param jedis Redis客户端
     * @param lockKey 锁标识
     * @param requestId 请求标识
     * @param expireTime 超期时间
     * @return 是否获取成功
     */
    public static boolean tryLock(Jedis jedis, String lockKey, String requestId, int expireTime) {

        String result = jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);

        if (LOCK_SUCCESS.equals(result)) {
            return true;
        }
        return false;

    }

这里用的jedis客户端来实现,spring的redis客户端同理:
首先,set的时候需要指定key,通常在我们的业务场景下,key可能是订单号,资金流水单号,数据库唯一键这类的业务参数,来满足互斥性;
其次我们需要指定value,可能有人会说一般的实现指定了key就是唯一的,确实是这样的;但是我们需要满足“加锁解锁都是同一人”的目的,光指定key就不够了,这里我们还指定了requestId,当然也可以是我们请求的traceId,能唯一标识一个请求的都可以作为value;这样在加锁解锁时就能判断客户端的身份了。同时,因为我们指定了requestId,那么在使用锁的时候可以很方便的改造成可重入锁。
然后我们需要设置SET_IF_NOT_EXIST,顾名思义,在这个key不存在时设置key的值,这里保证了只有一个客户端可以拿到锁;
最后我们需要同时指定过期时间,来保证客户端在一定时间内没有释放锁的时候,redis可以主动释放锁资源,防止死锁;这里的过期时间需要考量业务处理流程的rt来设置,通常10s就可以。
这段代码只会有两个结果;该key不存在,则setkey成功,同时设置了过期时间;该key存在,则获取锁失败,则业务重试即可;
这里的代码思想主要是一点:需要保证判断锁存在,加锁,设置过期这三个操作的原子性,所以在同一个语句中同时执行了这三个操作,尽可能的保证了操作的原子性。
错误示例:

public static void wrongTryLock(Jedis jedis, String lockKey, String requestId, int expireTime) {

    Long result = jedis.setnx(lockKey, requestId);
    if (result == 1) {
        // 若在这里程序突然崩溃,则无法设置过期时间,将发生死锁
        jedis.expire(lockKey, expireTime);
    }

}

这是一种比较常见的加锁方式,设置redis的key,设置成功了我们就设置缓存时间;这里的问题在于如果setnx()执行完毕后系统崩溃了,那这个key就没有过期时间,这样就产生了死锁。

解锁代码

/**
     * 释放分布式锁
     * @param jedis Redis客户端
     * @param lockKey 锁
     * @param requestId 请求标识
     * @return 是否释放成功
     */
    public static boolean unLock(Jedis jedis, String lockKey, String requestId) {

        String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
        Object result = jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId));

        if (1L.equals(result)) {
            return true;
        }
        return false;

    }

同加锁的思想一致,我们也需要保证解锁过程中,判断锁存在,判断key的值是否正确,释放锁的操作的原子性;
故而我们使用了Lua脚本来实现;我们先定义了一行脚本:获取这个key的value,如果value和传入ARGV参数相等,则删除这个key;
然后我们将这个脚本提交给jedis客户端,jedis.eval()执行这个脚本,同时完成判断和删除操作来保证操作的原子性;
这里利用了eval的特性,整个脚本会被当成一个命令来执行,直到该命令执行完成,redis才会执行下一个命令。
错误示例:

public static void wrongUnLock(Jedis jedis, String lockKey, String requestId) {

    // 判断加锁与解锁是不是同一个客户端
    if (requestId.equals(jedis.get(lockKey))) {
        // 若在此时,这把锁突然不是这个客户端的,则会误解锁
        jedis.del(lockKey);
    }

}

通常我们释放锁的方式是这样的,但是这里有个问题,操作不能原子,在并发的情况下,有可能if条件刚执行完成,锁突然过期了,这个时候正好另外一个客户端正好获取了锁,那么del语句释放的就可能是别人的锁,造成紊乱。

组合使用

获取锁失败就结束的场景:

final String key = UUID.randomUUID().toString(); // 业务逻辑key

// 注意:先成功获取锁,再尝试释放锁。
if (this.tryLock(key)) {
    try {
        // do something...
    } finally {
        this.unLock(key); // 执行完业务逻辑后,显示释放锁。注意:整个过程须同步操作!
    }
}

这里需要注意的点就是释放锁的操作一定要在finally里面来做,且必须是同步来释放,否则会有死锁问题。
需要自旋获取锁的场景:

final String key = UUID.randomUUID().toString(); // 业务逻辑key
boolean lock = false;

//自旋加锁,可以添加次数限制来break循环
do{
    Thread.sleep(100);
}while (tryLock(key));

if (lock) {
    try {
        // do business...
    } finally {
        this.unLock(""); // 执行完业务逻辑后,显示释放锁。注意:整个过程须同步操作!
    }
}

自旋一般不建议一直执行下去,可以通过次数限制来中断掉,抛出异常供业务处理。

总结

这里的核心思想是在尽可能保证性能的同时,让加锁和解锁的操作成为一个原子操作,来保证上面所说的分布式锁的可靠性条件;当然也可以给加解锁的代码加锁来实现,但是会有性能的牺牲。
理解了这个思想,我们去理解一些常用的分布式锁中间件也会更容易,例如Redis官方推荐的RedisSon、Apache的Shared Lock等,避免重复造轮子。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,456评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,370评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,337评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,583评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,596评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,572评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,936评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,595评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,850评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,601评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,685评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,371评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,951评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,934评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,167评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,636评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,411评论 2 342