惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

SecWiki News
SecWiki News
罗磊的独立博客
U
Unit 42
I
InfoQ
B
Blog RSS Feed
Google DeepMind News
Google DeepMind News
J
Java Code Geeks
Blog — PlanetScale
Blog — PlanetScale
The GitHub Blog
The GitHub Blog
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
B
Blog
S
SegmentFault 最新的问题
V
Visual Studio Blog
Engineering at Meta
Engineering at Meta
Microsoft Security Blog
Microsoft Security Blog
月光博客
月光博客
Vercel News
Vercel News
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
A
About on SuperTechFans
博客园 - 三生石上(FineUI控件)
博客园_首页
腾讯CDC
F
Fortinet All Blogs
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
Hugging Face - Blog
Hugging Face - Blog
MongoDB | Blog
MongoDB | Blog
阮一峰的网络日志
阮一峰的网络日志
D
Docker
N
Netflix TechBlog - Medium
云风的 BLOG
云风的 BLOG
Apple Machine Learning Research
Apple Machine Learning Research
Microsoft Azure Blog
Microsoft Azure Blog
Martin Fowler
Martin Fowler
人人都是产品经理
人人都是产品经理
酷 壳 – CoolShell
酷 壳 – CoolShell
爱范儿
爱范儿
大猫的无限游戏
大猫的无限游戏
V
V2EX
Last Week in AI
Last Week in AI
博客园 - 司徒正美
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
IT之家
IT之家
L
LangChain Blog
WordPress大学
WordPress大学
Y
Y Combinator Blog
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
M
MIT News - Artificial intelligence
The Cloudflare Blog
T
The Blog of Author Tim Ferriss
宝玉的分享
宝玉的分享

CodeSky 代码之空

如何让 AI 成为 24 小时牛马:我的个人项目实践 - CodeSky 代码之空 AI 浪潮下真实诉求的想象空间—同志仍需努力 - CodeSky 代码之空 AI 浪潮下的新时代开发模式—AI Coding 的快速成长 - CodeSky 代码之空 体验豆包手机的 24 小时 - CodeSky 代码之空 Agent Platform Self-Host 开源项目对比 - Coze Studio - CodeSky 代码之空 Chrome 端到端网络排查战记 - CodeSky 代码之空 瞎逼逼:2025 年还没有升级研发工具的你落伍了吗 - CodeSky 代码之空 Project. 博客重建计划 - CodeSky 代码之空 群晖 Gitea + Gitea Actions 安装与部署服务教程 - CodeSky 代码之空 Python 音频去广告+字幕提取 - CodeSky 代码之空 群晖通过 Active Backup for Business 实现服务器快照 - CodeSky 代码之空 Windows WSL 启用 NVIDIA CUDA 配置教程 - CodeSky 代码之空 HTTP Auth From BasicAuth to WebAuthn - CodeSky 代码之空 限流与常见实现 - CodeSky 代码之空 Redis 大 key、热 key 判别和解决方案 - CodeSky 代码之空 缓存:高并发读的救世主 - CodeSky 代码之空 如何解决服务中的事务问题 - CodeSky 代码之空 聊聊 MySQL 索引与索引设计 - CodeSky 代码之空 Redis 中使用键空间监听 key 过期消息 - CodeSky 代码之空 AI:Make 死宅 Great Again - CodeSky 代码之空
Redis 分布式锁的实现 - CodeSky 代码之空
敖天羽 · 2024-09-30 · via CodeSky 代码之空

在 Redis 的常见应用中,分布式锁是一个老生常谈的问题,本文主要讲讲怎么去实现一个分布式锁(最近真·写了不少 Lua 脚本)。

加锁

对于加锁操作,理论上应该是:

  1. 尝试加锁,如果成功,则记录锁,并且返回 true
  2. 如果失败,则不更新锁,返回 false

另外,1 或者 2 应该都是原子的。而 Redis 中针对这个操作只要一个 Set 就能搞定。

为此,我们先复习一下 SET

SET key value [NX | XX] [GET] [EX seconds | PX milliseconds |
  EXAT unix-time-seconds | PXAT unix-time-milliseconds | KEEPTTL]

其中 Options:

  • EX:多少秒后过期
  • PX:多少毫秒后过期
  • EXAT:什么时候过期(时间戳-秒)
  • PXAT:什么时候过期(时间戳-毫秒)
  • NX:只有当 key 不存在时才 SET
  • XX:只有 key 存在时才能 SET
  • KEEPTTL:意味着更新时过期时间保持不变
  • GET:返回更新前的旧字符串

换言之,假设我们把锁作为一个 redis key,那么加锁只要使用 SET key value NX 就行了。

另外,为了避免程序错误导致锁没释放,还需要加入一个超时时间,比如我们预估一个请求 timeout = 800ms,那么锁的时间可以设计为 1s,进行一些容错 SET key value NX EX 1

释放锁

因为加锁 = SET key,那么解锁自然是 DEL。但是问题是,不是谁都能释放锁的,只有那个拥有锁的对象可以释放锁。

因此对象匹配和释放锁需要是原子的:

if redis.call("GET", KEYS[1]) == ARGV[1] then  
    return redis.call("DEL", KEYS[1])
else   
    return 0  
end

结合 Golang 的实现

1  
2type Lock struct {  
3    redis   *redis.Client  
4    name    string  
5    timeout time.Duration  
6    uuid    string  
7}  
8  
9type Options struct {  
10    Uuid string  
11}  
12  
13func (o *Options) GetUuid() string {  
14    if o.Uuid == "" {  
15       return ""  
16    } else {  
17       return o.Uuid  
18    }  
19}  
20  
21// KEYS[1] = lockName  
22// ARGV[1] = uuid  
23const lockLua = `  
24if redis.call("GET", KEYS[1]) == ARGV[1] then  
25    return redis.call("DEL", KEYS[1])else   
26    return 0  
27end  
28`  
29  
30func NewLock(name string, redis *redis.Client, timeout time.Duration, options *Options) *Lock {  
31    if name == "" {  
32       panic("lock name is empty")  
33    }    return &Lock{  
34       redis:   redis,  
35       name:    name,  
36       timeout: timeout,  
37       uuid:    options.GetUuid(),  
38    }}  
39  
40func (l *Lock) Uuid() string {  
41    return l.uuid  
42}  
43  
44func (l *Lock) Lock(ctx context.Context, options *Options) (bool, error) {  
45    uuid := l.uuid  
46    if options.GetUuid() != "" {  
47       uuid = options.GetUuid()  
48    }    res, err := l.redis.SetNX(ctx, l.name, uuid, l.timeout).Result()  
49    if err != nil {  
50       fmt.Printf("SetNX failed: %v\n", err)  
51       return false, err  
52    }  
53    return res, nil  
54}  
55  
56func (l *Lock) Unlock(ctx context.Context, options *Options) (bool, error) {  
57    uuid := l.uuid  
58    if options.GetUuid() != "" {  
59       uuid = options.GetUuid()  
60    }    result, err := l.redis.Eval(ctx, lockLua, []string{l.name}, uuid).Result()  
61    if err != nil {  
62       return false, err  
63    }  
64    return result.(int64) == 1, nil  
65}
66

基于 Redis 的分布式锁的优缺点

优点很明显:

  1. 相比 DB 来当锁,拥有更好的性能
  2. 实现简单,因为实现原子操作的成本更低
  3. 避免了单点故障,因为 Redis 本身是分布式的

当然,也不全是优点,比如:

  1. 超时时间的设置:这个问题也不能说是 Redis 的问题,但是需要避免程序还在运行但锁超时了的情况发生
  2. 主从并非强一致,可能会导致其实上锁时主节点宕机了,但是还没来得及同步到其他节点,因此数据不一致:
    1. 客户端 A 从 master 中获取到锁
    2. 同步期间 master crash
    3. 从节点被提升为 master,但缺乏相应数据
    4. 客户端 B 从新 master 获取到锁,就产生了两条不同的记录

要解决这个问题,Redis 提供了一个 Redlock 算法来实现分布式锁。

Redlock

核心逻辑

Redlock 的核心理念和投票类似,也就是,既然一个 master 可能会存在问题,那我多加几个 master,不就不会出现问题了吗?

假设我们准备了五个 Redis master 节点,那么客户端在获取锁时会往五个实例申请持有锁。这里需要注意的是:

  1. 超时处理:超时时间的配置应该要 < 自动过期时间,避免节点阻塞,也不能最后一个节点申请完第一个已经过期了
  2. 异常处理:如果节点出现异常,应该尽快下一个

因此我们记录拿第一个锁的开始时间,和最后一个锁的结束时间来判断锁的持有情况:

  1. 多数实例持有(>= 3 个)
  2. t(申请结束)-t(申请开始) > t(锁有效期)

如果最终只有少数持有了锁,我们还需要释放资源。

对于释放资源来说,可能存在「其实我成功了,但是网络失败」的情况,因此不应当只针对成功的节点发释放请求,而应该广播给每一个 master。

快速重试

如果失败时会先尝试重试,避免同时有多个客户端都在申请获取资源产生脑裂问题,最终没有人可以持有锁,也因此客户端的总体响应速度越快,出现这种情况的概率就越小。

延迟重启

要保证崩溃恢复,我们必然会考虑将数据持久化,如果不进行持久化,那么节点重启时就可能会遇到当时我们单 Redis 中遇到的问题。

如果持久化了,那么问题将会改善很多,但改善并不代表着解决,如果实例崩溃后一直不可用,那只是参与投票的人少了,似乎没什么问题。

但如果实例崩溃后快速的恢复了,而此时 AOF 的数据没有来得及刷到磁盘中,就仍然会遇到相同的问题。解决方案就是将恢复时间拉长,这个恢复重启时间需要大于锁的有效时间,这样重启时所有的锁都到期了,就不会存在问题了。

时钟同步

上一步我们提到要考虑过期时间,但即使时钟是近似同步的,可能每个 master 中的 time 也会存在一定误差,因此我们可以设置一个漂移量来修复这个问题。

扩展锁

如果锁本身有效期较短,且得到时已经快到期了,可以尝试发送一个指令来进行续期。在 go 的 redlock 包中已有实现:

1var touchWithSetNXScript = redis.NewScript(1, `
2	if redis.call("GET", KEYS[1]) == ARGV[1] then
3		return redis.call("PEXPIRE", KEYS[1], ARGV[2])
4	elseif redis.call("SET", KEYS[1], ARGV[1], "PX", ARGV[2], "NX") then
5		return 1
6	else
7		return 0
8	end
9`)
10
11var touchScript = redis.NewScript(1, `
12	if redis.call("GET", KEYS[1]) == ARGV[1] then
13		return redis.call("PEXPIRE", KEYS[1], ARGV[2])
14	else
15		return 0
16	end
17`)
18

可能存在的问题

RedLock 算法相比单机来说更加可靠,但是实际应用中仍然会受到一些挑战:

  1. 需要更多的资源,且引入了更多的网络 IO 和耗时
  2. 依赖时钟:如果机器中的时间被人工修改造成较大偏差,那可能会存在灾难性问题
  3. 延迟重启对系统的入侵:作为一个业务系统,往往不是独享 Redis 的,重启时间不一定可控

下图是其中一个问题出现的例子:

在例子中提到了 GC 导致的 pause,当然实际上,可能也会有其他原因导致类似的效果,比如 CPU 资源竞争,网络延迟。此时光看时间判断就没什么用了。

要解决这个问题,可以在存储侧引入版本号校对,有点类似于一些业务的更新策略,如果发现是老的版本,则不允许更新。

但问题是,Redlock 本身并没有这样的机制去保证这一设计,我们也很难保证计数器的一致性。而如果真的在业务侧计入了版本,那么相当于有序写入,似乎和「互斥锁」也没多大关系。

这也成为了一个 Redlock 高不成低不就的漏洞。因此也有文章抨击这一算法没什么卵用。

总结

综上来看,选择怎么样的锁也是一个问题,在现实程序中,我们经常看到单 master 的锁实现,因为他相比 RedLock 来说更加轻量,如果并不需要强一致性和可靠性,允许少量误差的前提下,用它可能更方便。

除了 Redis 以外,我们也可以用 etcd 或者 zookeeper 来实现分布式锁,这个我们下次再研究。

参考资料