03-应用 1:千帆竞发 —— 分布式锁
课程
1
开篇:授人以鱼不若授人以渔 —— Redis 可以用来做什么?
学习时长: 5分21秒
2
基础:万丈高楼平地起 —— Redis 基础数据结构
上次学到
学习时长: 16分14秒
3
应用 1:千帆竞发 —— 分布式锁
学习时长: 7分47秒
4
应用 2:缓兵之计 —— 延时队列
学习时长: 8分9秒
5
应用 3:节衣缩食 —— 位图
学习时长: 8分52秒
6
应用 4:四两拨千斤 —— HyperLogLog
学习时长: 14分17秒
7
应用 5:层峦叠嶂 —— 布隆过滤器
学习时长: 17分54秒
8
应用 6:断尾求生 —— 简单限流
学习时长: 4分37秒
9
应用 7:一毛不拔 —— 漏斗限流
学习时长: 7分22秒
10
应用 8:近水楼台 —— GeoHash
学习时长: 7分52秒
11
应用 9:大海捞针 —— Scan
学习时长: 8分42秒
12
原理 1:鞭辟入里 —— 线程 IO 模型
学习时长: 4分1秒
13
原理 2:交头接耳 —— 通信协议
学习时长: 3分34秒
14
原理 3:未雨绸缪 —— 持久化
学习时长: 5分27秒
15
原理 4:雷厉风行 —— 管道
学习时长: 3分51秒
16
原理 5:同舟共济 —— 事务
学习时长: 6分36秒
17
原理 6:小道消息 —— PubSub
学习时长: 7分7秒
18
原理 7:开源节流 —— 小对象压缩
学习时长: 7分14秒
19
原理 8:有备无患 —— 主从同步
学习时长: 4分9秒
20
集群 1:李代桃僵 —— Sentinel
学习时长: 3分52秒
21
集群 2:分而治之 —— Codis
学习时长: 7分28秒
22
集群 3:众志成城 —— Cluster
学习时长: 8分38秒
23
拓展 1:耳听八方 —— Stream
学习时长: 13分40秒
24
拓展 2:无所不知 —— Info 指令
学习时长: 4分4秒
25
拓展 3:拾遗补漏 —— 再谈分布式锁
学习时长: 2分18秒
26
拓展 4:朝生暮死 —— 过期策略
学习时长: 2分21秒
27
拓展 5:优胜劣汰 —— LRU
学习时长: 4分34秒
28
拓展 6:平波缓进 —— 懒惰删除
学习时长: 2分13秒
29
拓展 7:妙手仁心 —— 优雅地使用 Jedis
学习时长: 6分35秒
30
拓展 8:居安思危 —— 保护 Redis
学习时长: 2分19秒
31
拓展 9:隔墙有耳 —— Redis 安全通信
学习时长: 6分34秒
32
拓展 10:法力无边 —— Redis Lua 脚本执行原理
学习时长: 9分24秒
33
拓展 11:短小精悍 —— 命令行工具的妙用
学习时长: 9分21秒
34
源码 1:丝分缕析 —— 探索「字符串」内部
学习时长: 5分20秒
35
源码 2:循序渐进 —— 探索「字典」内部
学习时长: 7分24秒
36
源码 3:挨肩迭背 —— 探索「压缩列表」内部
学习时长: 10分42秒
37
源码 4:风驰电掣 —— 探索「快速列表」内部
学习时长: 3分49秒
38
源码 5:凌波微步 —— 探索「跳跃列表」内部
学习时长: 9分57秒
39
源码 6:破旧立新 —— 探索「紧凑列表」内部
学习时长: 2分42秒
40
源码 7:金枝玉叶 —— 探索「基数树」内部
学习时长: 5分36秒
41
源码 8:精益求精 —— LFU vs LRU
学习时长: 8分4秒
42
源码 9:如履薄冰 —— 懒惰删除的巨大牺牲
学习时长: 9分53秒
43
源码 10:跋山涉水 —— 深入字典遍历
学习时长: 9分24秒
44
源码 11:见缝插针 —— 探索 HyperLogLog 内部
学习时长: 13分3秒
45
尾声:百尺竿头 —— 继续深造指南
学习时长: 2分32秒
juejin_logo copyCreated with Sketch.

应用 1:千帆竞发 —— 分布式锁

分布式应用进行逻辑处理时经常会遇到并发问题。

比如一个操作要修改用户的状态,修改状态需要先读出用户的状态,在内存里进行修改,改完了再存回去。如果这样的操作同时进行了,就会出现并发问题,因为读取和保存状态这两个操作不是原子的。(Wiki 解释:所谓原子操作是指不会被线程调度机制打断的操作;这种操作一旦开始,就一直运行到结束,中间不会有任何 context switch 线程切换。)

这个时候就要使用到分布式锁来限制程序的并发执行。Redis 分布式锁使用非常广泛,它是面试的重要考点之一,很多同学都知道这个知识,也大致知道分布式锁的原理,但是具体到细节的使用上往往并不完全正确。

分布式锁

分布式锁本质上要实现的目标就是在 Redis 里面占一个“茅坑”,当别的进程也要来占时,发现已经有人蹲在那里了,就只好放弃或者稍后再试。

占坑一般是使用 setnx(set if not exists) 指令,只允许被一个客户端占坑。先来先占, 用完了,再调用 del 指令释放茅坑。

// 这里的冒号:就是一个普通的字符,没特别含义,它可以是任意其它字符,不要误解
> setnx lock:codehole true
OK
... do something critical ...
> del lock:codehole
(integer) 1

但是有个问题,如果逻辑执行到中间出现异常了,可能会导致 del 指令没有被调用,这样就会陷入死锁,锁永远得不到释放。

于是我们在拿到锁之后,再给锁加上一个过期时间,比如 5s,这样即使中间出现异常也可以保证 5 秒之后锁会自动释放。

> setnx lock:codehole true
OK
> expire lock:codehole 5
... do something critical ...
> del lock:codehole
(integer) 1

但是以上逻辑还有问题。如果在 setnx 和 expire 之间服务器进程突然挂掉了,可能是因为机器掉电或者是被人为杀掉的,就会导致 expire 得不到执行,也会造成死锁。

这种问题的根源就在于 setnx 和 expire 是两条指令而不是原子指令。如果这两条指令可以一起执行就不会出现问题。也许你会想到用 Redis 事务来解决。但是这里不行,因为 expire 是依赖于 setnx 的执行结果的,如果 setnx 没抢到锁,expire 是不应该执行的。事务里没有 if-else 分支逻辑,事务的特点是一口气执行,要么全部执行要么一个都不执行。

为了解决这个疑难,Redis 开源社区涌现了一堆分布式锁的 library,专门用来解决这个问题。实现方法极为复杂,小白用户一般要费很大的精力才可以搞懂。如果你需要使用分布式锁,意味着你不能仅仅使用 Jedis 或者 redis-py 就行了,还得引入分布式锁的 library。

为了治理这个乱象,Redis 2.8 版本中作者加入了 set 指令的扩展参数,使得 setnx 和 expire 指令可以一起执行,彻底解决了分布式锁的乱象。从此以后所有的第三方分布式锁 library 可以休息了。

> set lock:codehole true ex 5 nx
OK
... do something critical ...
> del lock:codehole

上面这个指令就是 setnx 和 expire 组合在一起的原子指令,它就是分布式锁的奥义所在。

超时问题

Redis 的分布式锁不能解决超时问题,如果在加锁和释放锁之间的逻辑执行的太长,以至于超出了锁的超时限制,就会出现问题。因为这时候第一个线程持有的锁过期了,临界区的逻辑还没有执行完,这个时候第二个线程就提前重新持有了这把锁,导致临界区代码不能得到严格的串行执行。

为了避免这个问题,Redis 分布式锁不要用于较长时间的任务。如果真的偶尔出现了,数据出现的小波错乱可能需要人工介入解决。

tag = random.nextint()  # 随机数
if redis.set(key, tag, nx=True, ex=5):
    do_something()
    redis.delifequals(key, tag)  # 假想的 delifequals 指令

有一个稍微安全一点的方案是为 set 指令的 value 参数设置为一个随机数,释放锁时先匹配随机数是否一致,然后再删除 key,这是为了确保当前线程占有的锁不会被其它线程释放,除非这个锁是过期了被服务器自动释放的。 但是匹配 value 和删除 key 不是一个原子操作,Redis 也没有提供类似于delifequals这样的指令,这就需要使用 Lua 脚本来处理了,因为 Lua 脚本可以保证连续多个指令的原子性执行。

# delifequals
if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end

但是这也不是一个完美的方案,它只是相对安全一点,因为如果真的超时了,当前线程的逻辑没有执行完,其它线程也会乘虚而入。

可重入性

可重入性是指线程在持有锁的情况下再次请求加锁,如果一个锁支持同一个线程的多次加锁,那么这个锁就是可重入的。比如 Java 语言里有个 ReentrantLock 就是可重入锁。Redis 分布式锁如果要支持可重入,需要对客户端的 set 方法进行包装,使用线程的 Threadlocal 变量存储当前持有锁的计数。

# -*- coding: utf-8
import redis
import threading


locks = threading.local()
locks.redis = {}

def key_for(user_id):
    return "account_{}".format(user_id)

def _lock(client, key):
    return bool(client.set(key, True, nx=True, ex=5))

def _unlock(client, key):
    client.delete(key)

def lock(client, user_id):
    key = key_for(user_id)
    if key in locks.redis:
        locks.redis[key] += 1
        return True
    ok = _lock(client, key)
    if not ok:
        return False
    locks.redis[key] = 1
    return True

def unlock(client, user_id):
    key = key_for(user_id)
    if key in locks.redis:
        locks.redis[key] -= 1
        if locks.redis[key] <= 0:
            del locks.redis[key]
            self._unlock(key)
        return True
    return False

client = redis.StrictRedis()
print "lock", lock(client, "codehole")
print "lock", lock(client, "codehole")
print "unlock", unlock(client, "codehole")
print "unlock", unlock(client, "codehole")

以上还不是可重入锁的全部,精确一点还需要考虑内存锁计数的过期时间,代码复杂度将会继续升高。老钱不推荐使用可重入锁,它加重了客户端的复杂性,在编写业务方法时注意在逻辑结构上进行调整完全可以不使用可重入锁。下面是 Java 版本的可重入锁。

public class RedisWithReentrantLock {

  private ThreadLocal<Map<String, Integer>> lockers = new ThreadLocal<>();

  private Jedis jedis;

  public RedisWithReentrantLock(Jedis jedis) {
    this.jedis = jedis;
  }

  private boolean _lock(String key) {
    return jedis.set(key, "", "nx", "ex", 5L) != null;
  }

  private void _unlock(String key) {
    jedis.del(key);
  }

  private Map<String, Integer> currentLockers() {
    Map<String, Integer> refs = lockers.get();
    if (refs != null) {
      return refs;
    }
    lockers.set(new HashMap<>());
    return lockers.get();
  }

  public boolean lock(String key) {
    Map<String, Integer> refs = currentLockers();
    Integer refCnt = refs.get(key);
    if (refCnt != null) {
      refs.put(key, refCnt + 1);
      return true;
    }
    boolean ok = this._lock(key);
    if (!ok) {
      return false;
    }
    refs.put(key, 1);
    return true;
  }

  public boolean unlock(String key) {
    Map<String, Integer> refs = currentLockers();
    Integer refCnt = refs.get(key);
    if (refCnt == null) {
      return false;
    }
    refCnt -= 1;
    if (refCnt > 0) {
      refs.put(key, refCnt);
    } else {
      refs.remove(key);
      this._unlock(key);
    }
    return true;
  }

  public static void main(String[] args) {
    Jedis jedis = new Jedis();
    RedisWithReentrantLock redis = new RedisWithReentrantLock(jedis);
    System.out.println(redis.lock("codehole"));
    System.out.println(redis.lock("codehole"));
    System.out.println(redis.unlock("codehole"));
    System.out.println(redis.unlock("codehole"));
  }

}

跟 Python 版本区别不大,也是基于 ThreadLocal 和引用计数。

以上还不是分布式锁的全部,在小册的拓展篇《拾遗漏补 —— 再谈分布式锁》,我们还会继续对分布式锁做进一步的深入理解。

思考题

  1. Review 下你自己的项目代码中的分布式锁,它的使用方式是否标准正确?
  2. 如果你还没用过分布式锁,想想自己的项目中是否可以用上?
留言
Ctrl + Enter
全部评论(427)
柏油的头像
删除
JAVA后端
设计分布式锁有个很难抉择的问题是过期时间的选择,业内常用 watch dog(看门狗)的方案解决。juejin.cn 分析了 redisson 看门狗解决方案,有兴趣可以看看
点赞
回复
晓峰同志的头像
删除
可重入锁为什么额不用lua脚本实现呢
点赞
回复
BinK_1783的头像
删除
怪风太温柔,像老朋友,像旧时候
点赞
回复
刘炜煌的头像
删除
watch dog呢 通过心跳守护线程客户端的分布式锁续期
点赞
回复
light0x00的头像
删除
看第一章的时候,下面评论水我还以为是他们自己菜. 看到这章,可以确定是水逼无疑.
3
回复
luoluocaihong的头像
删除
开发经理
多年之后的今天,用的Redission实现的分布式锁。
使用hash结构,客户端使用LUA脚本来编写命令集。
简单描述如下:
比如首次成功加锁:hset key threadId1 1;expirekey ttl;
可重入锁,当前线程再次加锁:hincrbykey threadId1 1 ;expire key ttl;
其他线程过来的时候尝试加锁:先判断key是否存在,existskey; 存在的话表示已经有线程持有锁,判断是否当前线程持有的锁:hexists key threadId2 ;不存在,表示其他线程持有锁;那就可以获取下锁的有效期ttl key; 可以过 ttl的时间之后再来尝试获取锁。
详细可以看RedissonLock这个类
对了,业务侧最后释放锁的时候,要先判断下当前线程是否持有锁。
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
否则可能会报错attempt to unlock lock, not locked by current thread by node id x x x x x
展开
7
回复
小诺同学的头像
删除
后端程序员
锁超时那边的介绍,感觉不够详细。可以多讲点吗?最后有代码实例。
点赞
回复
optional的头像
删除
开发狗 @ haha
总结的很好:
锁超时:
expire 解决永久死锁
del-if-equal: 通过乐观锁解决删除问题
锁重入:
TheadLocal: 记录当前线程
引用计数:当前线程所有锁的计数
锁排队:
使用各自JVM内的机制排队(redission)
展开
点赞
回复
玉兔捣药的头像
删除
说唱
真的坑啊。拿这个去回答分布式锁,可能只螚得50分。
点赞
回复
Lakers32的头像
删除
Java高级工程师
本来是来看看是哪五个参数的,又如何续命,结果发现自己错了。
点赞
回复
decodes的头像
删除
Java 码农
写的真水
8
回复
xiangpica12138的头像
删除
写的好水,不值得
2
回复
Jemini的头像
删除
开发 @ 未知科技
这。。就是在外面蹭了蹭没进去
8
回复
用户9796376885153的头像
删除
写的太tm 水了 浪费 20块
11
回复
Impassive的头像
删除
Java工程师 @ 积极学习中
要是能横向比较就好了。分布式锁还有什么解决方案,redis的优劣势
1
回复
雪粮的头像
删除
Java工程师 @ ABC
通常情况下,可重入性只是对于当前用有锁的线程的可重入,是不会跨线程或者跨进程可重入的。因此,可以在获取到分布式锁之后,通过为该分布式锁绑定一个本地的可重入锁,例如Java的ReentrantLock,来解决分布式锁的可重入问题。
这样是否可行?
点赞
回复
上校的小金鱼的头像
删除
百年Java攻城狮(还差97年)
“使用线程的 Threadlocal 变量存储当前持有锁的计数”
文章这句不成立啊,分布式锁本来就是要解决多实例问题,你用Threadlocal计数,别的示例怎么知道现在有几个线程拿到锁了?
2
1
删除
Threadlocal 是解决已经获取了锁的实例,重入锁的问题。别的实例都没获取到redis锁,为什么要知道是谁拿了锁呢?
点赞
回复
杜阿的头像
删除
java程序媛
可重入锁线程不安全
点赞
回复
_ClassNotFound的头像
删除
redis 在 2.6.12版本增加了EX,PX,NX和XX选项。
点赞
回复
大表哥酱的头像
删除
我理解这个分布式跟应用是否多台和是否单个redis服务都没关系,针对的是并发的业务场景,单台服务器高并发的时候也有可能出现多个线程同时执行某个任务的情况,这时候就需要redis提供的这个锁来保证业务正确性,此处的分布也可理解为分享,大家都可以拿,只不过同一时刻只能被某某拿到,即为被某某锁定,综合起来所以叫做分布式锁
点赞
2
删除
如果在单台服务器上就没必要用分布式锁了,比如Java直接用Java提供的锁API就可以了
点赞
回复
删除
不是的,分布式锁和分布式事务的根本原因都是在不同的机器上执行,所以靠普通的LOCK锁缓存锁总线行不通,所以在别的地方加了一个中间层
点赞
回复

查看全部 427 条回复