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

推荐订阅源

Security Latest
Security Latest
量子位
博客园 - 三生石上(FineUI控件)
小众软件
小众软件
S
SegmentFault 最新的问题
The GitHub Blog
The GitHub Blog
AWS News Blog
AWS News Blog
T
Threat Research - Cisco Blogs
博客园 - Franky
Vercel News
Vercel News
H
Help Net Security
Martin Fowler
Martin Fowler
Security Archives - TechRepublic
Security Archives - TechRepublic
L
LINUX DO - 热门话题
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
L
Lohrmann on Cybersecurity
Cyberwarzone
Cyberwarzone
W
WeLiveSecurity
V2EX - 技术
V2EX - 技术
C
CERT Recently Published Vulnerability Notes
S
Secure Thoughts
C
Cyber Attacks, Cyber Crime and Cyber Security
B
Blog RSS Feed
H
Hacker News: Front Page
P
Proofpoint News Feed
博客园 - 聂微东
N
News and Events Feed by Topic
C
Cybersecurity and Infrastructure Security Agency CISA
D
Docker
博客园_首页
Exploit-DB.com RSS Feed
Exploit-DB.com RSS Feed
CTFtime.org: upcoming CTF events
CTFtime.org: upcoming CTF events
人人都是产品经理
人人都是产品经理
The Hacker News
The Hacker News
S
Security @ Cisco Blogs
博客园 - 【当耐特】
F
Fortinet All Blogs
The Register - Security
The Register - Security
A
About on SuperTechFans
D
Darknet – Hacking Tools, Hacker News & Cyber Security
S
Schneier on Security
NISL@THU
NISL@THU
Attack and Defense Labs
Attack and Defense Labs
Help Net Security
Help Net Security
Cisco Talos Blog
Cisco Talos Blog
月光博客
月光博客
IT之家
IT之家
有赞技术团队
有赞技术团队
Know Your Adversary
Know Your Adversary
Hugging Face - Blog
Hugging Face - Blog

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 来实现分布式锁,这个我们下次再研究。

参考资料