news 2026/6/16 14:08:50

服务里的 Redis 锁惊群问题:一次本地合流优化实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
服务里的 Redis 锁惊群问题:一次本地合流优化实践
本文不是要证明“Redis 的 SETNX 可以被优化 900 倍”,而是复盘一个更具体的工程问题:
当大量 goroutine 同时争抢同一个 Redis lock key 时,如何减少那些注定失败的无效请求?
在一个热点 key 正常竞争的 benchmark 中,本地合流把无合流基线的平均尝试成本从约12.8ms/op降到了约0.014ms/op
但这个数字有明确前提:它统计的是所有抢锁尝试的平均成本,其中包含大量被本地快速拦截的失败请求,不能简单理解成“一次成功持锁 + 执行业务 + Unlock 的完整耗时”。

一、问题不是 SETNX,而是太多请求在做无用功

Redis 分布式锁的基础写法大家都很熟:

SET key value NX PX ttl

释放时再用 Lua 比对 value,确保只删除自己的锁。

这个逻辑本身没有问题。

但在真实业务里,Redis 锁还有一个容易被忽略的工程问题:

如果同一个进程内,有大量 goroutine 同时争抢同一个 lock key,那么最后即使只有一个 goroutine 能成功,其他 goroutine 也可能已经向 Redis 发起了大量注定失败的请求。

这类场景在服务端并不少见:

  • 同一个订单被重复提交
  • 同一个用户任务被重复触发
  • 同一个库存 key 被短时间集中访问
  • 定时任务、活动任务、补偿任务出现并发触发
  • 游戏服务器里同一份玩家、房间、战斗资源被多路逻辑同时抢占

如果每个 goroutine 都独立执行:

SETNX 失败 → sleep → 再 SETNX → 再失败 → 再 sleep

那么 Redis 承受的不是“一个锁请求”,而是一波抢锁洪峰。

这次 TurboLock 的优化目标很明确:

不改变 Redis 锁的基本语义,而是在 Go 进程内部尽量拦截那些注定失败的抢锁请求。

二、先构造一个极端场景:锁一直不可用时会发生什么?

我最开始做 benchmark 时,故意构造了一个极端场景:

client.Set(ctx, lockKey, "occupied_by_main", 60*time.Second)

也就是:主线程先把这个 key 占住 60 秒,其他 goroutine 在这期间怎么抢都不可能成功。

这个场景并不是为了模拟正常业务,而是为了暴露两个问题:

  1. 热点锁下的无效 Redis 请求会迅速放大。
  2. 循环里的time.After会带来大量堆分配。

当时的原始 benchmark 数据类似这样:

BenchmarkTurboLock_HighConcurrency-8 100 143597280 ns/op 58648 B/op 1019 allocs/op

143ms/op1000+ allocs/op

这个结果看起来很夸张,但它背后的原因并不神秘。

在无本地拦截的情况下,每个 goroutine 都会自己去 Redis 抢锁:

func (t *defaultTurboLocker) Lock(ctx context.Context, key string) (UnlockFunc, error) { value, err := t.genValue() if err != nil { return nil, err } for i := 0; i < t.opts.Tries; i++ { ok, err := t.client.SetNX(ctx, key, value, t.opts.Expiry).Result() if err == nil && ok { return func(unCtx context.Context) error { return t.client.Eval(unCtx, delLuaScript, []string{key}, value).Err() }, nil } select { case <-ctx.Done(): return nil, ctx.Err() case <-time.After(t.opts.RetryDelay): } } return nil, ErrLockFailed }

这里有两个性能漏斗:

第一,所有 goroutine 都会直接访问 Redis。
第二,每次重试都会创建一个新的time.Aftertimer。

如果重试次数设置得比较高,热点 key 又一直不可用,那么请求数和分配数都会被快速放大。


三、第一层优化:同一个 key,只让一个 goroutine 去 Redis

我的第一层优化是本地 Leader-Follower 合流。

思路很简单:

同一个进程内,同一个 key 的锁请求: 1. 第一个 goroutine 成为 Leader,负责去 Redis 抢锁。 2. 其他 goroutine 成为 Follower,在本地等待 Leader 的结果。 3. Leader 成功后,Follower 直接快速失败,不再继续冲向 Redis。 4. Leader 失败后,再允许下一个 goroutine 成为新的 Leader。

也就是说:

同一时刻、同一个 key,进程内只放行一个 goroutine 去 Redis。

核心结构类似这样:

type localSlot struct { mu sync.Mutex cond *sync.Cond active bool isSuccess bool }

active表示当前是否已经有 Leader 出发。
isSuccess表示上一轮 Leader 是否已经抢锁成功。

简化后的逻辑如下:

slot := t.getSlot(key) slot.mu.Lock() for slot.active { slot.cond.Wait() } if slot.isSuccess { slot.mu.Unlock() return nil, ErrLockFailed } slot.active = true slot.isSuccess = false slot.mu.Unlock() var success bool defer func() { slot.mu.Lock() slot.active = false slot.isSuccess = success slot.cond.Broadcast() slot.mu.Unlock() }() // 当前 goroutine 作为 Leader 访问 Redis unlock, err := t.lockRedis(ctx, key) if err == nil { success = true } return unlock, err

这里有几个关键点:

  • sync.Cond负责挂起和唤醒同 key 下的 follower。
  • for slot.active而不是if slot.active,是为了应对虚假唤醒。
  • isSuccess用来让 follower 在本地快速失败,不再继续访问 Redis。
  • 合流只发生在单进程内,不改变跨进程 Redis 锁的基本语义。

需要注意的是,合流不是万能加速器。
它本质上是在做一件事:

把并发冲向 Redis 的无效请求,收敛成本地等待和快速失败。

四、一个容易误读的 benchmark:为什么病理场景下 ns/op 反而变高?

在“主线程占锁 60 秒”的病理场景里,引入本地合流后,我得到过类似这样的数据:

BenchmarkTurboLock_HighConcurrency-8 100 600076505 ns/op 29486 B/op 557 allocs/op

对比原始版本:

指标无合流版本合流版本变化
ns/op~143ms~600ms变慢
B/op~58648~29486下降
allocs/op~1019~557下降

第一眼看,合流好像把锁变慢了。

但这个场景的前提是:锁被人为占住 60 秒,所有请求都注定失败。

无合流版本是:

多个 goroutine 并发撞 Redis,并发失败。

合流版本是:

一个 Leader 撞 Redis,失败后下一个 Leader 再撞。

也就是说,合流层把“并发失败”变成了“有序失败”。
在这个极端病理场景里,它确实会牺牲平均耗时,换来更少的 Redis 请求和更低的分配。

所以这组数据不能用来证明“合流一定更快”。
它只能说明:

当锁长期不可用时,合流会削减无效请求和内存分配,但也可能因为串行化导致平均耗时变高。

这也是我后来重新设计 benchmark 的原因:必须区分“病理占锁场景”和“正常竞争场景”。


五、正常竞争场景:合流层真正优化的是什么?

为了更公平地衡量合流层的收益,我做了一个正常竞争 benchmark:

Lock → 持锁 1ms → Unlock

同时准备两个实现:

  1. NoMergeLocker
    有 Lock/Unlock、timer 复用、指数退避,但没有本地合流。
  2. TurboLock
    与 NoMergeLocker 的基础能力一致,但多了 Leader-Follower 合流层。

也就是说,这个对比尽量保证单一变量:

唯一区别是有没有本地合流。

benchmark 结果如下:

BenchmarkNoMergeLocker_NormalContention-8 200 12804344 ns/op 3297 B/op 31 allocs/op BenchmarkTurboLock_Fair-8 200 14033 ns/op 426 B/op 1 allocs/op
指标NoMergeLockerTurboLock变化
ns/op~12.8ms~0.014ms~912×
B/op3297426~7.7× less
allocs/op31131× fewer

这里必须强调一个细节:

0.014ms/op是该 benchmark 下所有抢锁尝试的平均成本,其中包含大量被本地合流快速拦截的失败请求。
它不能被理解为“一次成功 Lock + 持锁 1ms + Unlock 的完整耗时”。

它真正说明的是:

在热点 key 正常竞争下,大量 follower 被本地快速拦截,不再反复访问 Redis,因此平均尝试成本显著下降。

这也是本地合流最核心的收益:

减少无用功,而不是让单次 Redis SETNX 变快。

六、第二层优化:循环里的 time.After 为什么会放大分配?

合流层解决的是“谁去 Redis”的问题。
但 Leader 自己的重试循环里,还有一个常见问题:

case <-time.After(t.opts.RetryDelay):

在循环中频繁调用time.After,会不断创建 timer 对象。
在高并发、长重试链路下,这会放大堆分配和 GC 压力。

改造前:

for i := 0; i < t.opts.Tries; i++ { ok, err := t.client.SetNX(ctx, key, value, t.opts.Expiry).Result() if err == nil && ok { return unlock, nil } select { case <-ctx.Done(): return nil, ctx.Err() case <-time.After(t.opts.RetryDelay): } }

改造后:

timer := time.NewTimer(t.opts.RetryDelay) defer timer.Stop() for i := 0; i < t.opts.Tries; i++ { ok, err := t.client.SetNX(ctx, key, value, t.opts.Expiry).Result() if err == nil && ok { return unlock, nil } delay := t.opts.RetryDelay * time.Duration(1<<i) if delay > 2*time.Second { delay = 2 * time.Second } timer.Reset(delay) select { case <-ctx.Done(): return nil, ctx.Err() case <-timer.C: } }

这个改造的收益主要有两个:

  1. Timer 对象从“每轮重试创建一个”变成“单次 Lock 复用一个”。
  2. 指数退避降低了锁不可用期间单位时间内的 Redis 请求频率。

这里也要说清楚一个边界:

指数退避不会凭空减少Tries上限。
如果没有 context timeout 或提前返回,理论上仍然可能跑满Tries
它降低的是锁不可用期间的请求频率;如果配合 context deadline 或最大等待时间,才会进一步减少一次失败抢锁过程中的实际 Redis 调用次数。

七、第三层优化:自动续期不要一锁一 goroutine

Redis 锁还有一个常见问题:业务执行时间可能超过锁 TTL。

例如:

锁 TTL = 8s 业务执行 = 10s 第 8 秒锁过期 第 9 秒另一个 goroutine 拿到锁 第 10 秒原 goroutine 还在执行

这会导致两个执行流同时进入临界区。

常见解决方案是自动续期。
但如果实现成:

一把锁 = 一个 goroutine + 一个 ticker

那么锁数量一多,goroutine 和 ticker 成本就会线性增长。

TurboLock 使用的是层级时间轮:

Level 0: 256 slots Level 1: 64 slots Level 2: 64 slots 全局 1 个 goroutine + 1 个 ticker 推进时间轮 N 把锁的续期任务统一挂到时间轮槽位中

简化理解就是:

lock acquired ↓ schedule renewal at TTL/3 ↓ Lua compare-and-renew ↓ reschedule next renewal ↓ stop when unlocked or MaxHoldDuration reached

自动续期时仍然使用 Lua 比对 value:

if redis.get(key) == value then redis.expire(key, ttl) else return 0 end

这样可以避免误续其他持有者的锁。

另外,TurboLock 提供MaxHoldDuration作为兜底:

MaxHoldDuration允许范围内,时间轮会自动续期,避免业务执行时间略长于 TTL 时锁提前过期。
超过最大持锁时间后,TurboLock 会停止续期,让 Redis TTL 自然释放锁。

这比“业务执行多久就续多久”更安全。
自动续期不是为了让锁无限存在,而是为了覆盖合理范围内的业务抖动。


八、AutoRenew 的 benchmark 应该怎么看?

我做过一组正常竞争 benchmark,对比开启和关闭 AutoRenew 的同步 Lock/Unlock 路径:

BenchmarkTurboLock_NormalContention_NoRenew-8 200 10206 ns/op 25 B/op 0 allocs/op BenchmarkTurboLock_NormalContention_WithRenew-8 200 9653 ns/op 25 B/op 0 allocs/op
指标关闭 AutoRenew开启 AutoRenew变化
ns/op102069653噪声范围内
B/op2525无明显变化
allocs/op00无明显变化

这组数据只能说明:

在这个 benchmark 的同步 Lock/Unlock 路径上,开启 AutoRenew 没有观察到明显额外分配和延迟。

但要注意:

真正的续期 Redis I/O 发生在后台 goroutine 中,因此它的成本应该通过单独的续期压力测试评估。

所以我不会说“AutoRenew 没有成本”。
更准确的说法是:

在正常短持锁的同步路径里,时间轮注册续期任务的成本很低;后台续期本身仍然是 Redis I/O,需要结合锁数量和续期间隔单独评估。

九、第四层优化:用 sync.Pool 降低热点对象分配

最后一层优化是处理热点对象分配。

逃逸分析可以帮我们找到堆分配位置:

go build -gcflags="-m" ./... 2>&1 | grep "escapes"

当时主要关注两个对象:

make([]byte, 32) &timerTask{}

一个用于生成锁 value。
一个用于时间轮续期任务。

这类对象有两个特点:

  1. 体积不大。
  2. 高频创建。
  3. 生命周期短。
  4. 可复用。

因此可以用sync.Pool做对象复用:

var randPool = sync.Pool{ New: func() any { return make([]byte, 32) }, } var taskPool = sync.Pool{ New: func() any { return &timerTask{} }, }

生成随机 value 时:

func getValue() (string, error) { b := randPool.Get().([]byte) defer randPool.Put(b) if _, err := rand.Read(b); err != nil { return "", err } return base64.StdEncoding.EncodeToString(b), nil }

时间轮任务执行完毕或取消后:

task.reset() taskPool.Put(task)

sync.Pool不是银弹。
它适合的是这种高频、短生命周期、可复用的临时对象。
如果对象生命周期很长,或者复用后状态清理不彻底,反而会引入问题。


十、最终效果怎么总结才不容易被误读?

我现在会把结果分成三类说。

1. 病理占锁场景

锁被主线程人为占住 60 秒,所有请求都注定失败。

指标无合流版本合流版本说明
ns/op~143ms~600ms合流串行化导致变慢
allocs/op~1019~557分配下降
B/op~58648~29486内存下降

这个场景说明:

合流在锁长期不可用时会削减无效请求和分配,但可能牺牲平均耗时。

2. 正常热点竞争场景

Lock → 持锁 1ms → Unlock,对比无合流基线和 TurboLock。

指标NoMergeLockerTurboLock说明
ns/op~12.8ms~0.014ms平均尝试成本下降
B/op3297426分配字节下降
allocs/op311分配次数下降

这个场景说明:

在同一进程内大量 goroutine 争抢同一热点 key 时,本地合流可以显著降低平均抢锁尝试成本。

3. AutoRenew 同步路径

指标关闭 AutoRenew开启 AutoRenew说明
ns/op102069653噪声范围内
B/op2525无明显变化
allocs/op00无明显变化

这个场景说明:

在短持锁正常竞争 benchmark 中,注册时间轮续期任务没有观察到明显额外分配和延迟。
后台续期 Redis I/O 仍需单独评估。

这样表达,比单纯说“提升 900+ 倍”更稳。


十一、这个优化给我的几个启示

1. 减少无用功,比优化有用功更重要

这次收益最大的地方,不是把 Redis 命令本身变快了,而是让大量注定失败的请求不再出门。

这类优化的核心不是:

让每一次请求更快

而是:

减少根本不该发生的请求

2. 并发问题不一定要靠更多 goroutine 解决

自动续期如果用“一锁一 goroutine”,实现简单,但规模上来后成本会线性增长。

时间轮的价值在于:

用一个调度结构管理 N 个定时任务

这本质上是用数据结构替代 goroutine 数量。

3. benchmark 要先讲清楚前提

这次我最大的教训是:
性能数据如果不讲清楚场景,很容易被误读。

病理占锁、正常竞争、同步路径、后台续期,是完全不同的测试目标。
如果把它们混在一起讲,就会变成看起来很猛、实际很容易被质疑的数据叙事。

4. Redis 锁要主动讲边界

TurboLock 是单 Redis 节点工程锁库,不是 CP 分布式协调系统。

它解决的是:

Go 服务里热点 Redis lock key 的抢锁请求削峰问题

它不解决:

Redis failover 下的强一致问题 网络分区下的共识问题 旧持有者恢复后的 fencing 问题 跨机房强一致协调问题

这些边界越早说清楚,项目反而越可信。


十二、适用场景与边界

TurboLock 适合:

场景为什么适合
高并发抢同一个 Redis key本地合流可以减少无效 Redis 请求
订单、用户、任务维度的短时间互斥锁粒度清晰,业务临界区较短
定时任务单实例执行Redis 锁语义通常可以接受
业务执行时间可能略超过 TTLAutoRenew 可以在 MaxHoldDuration 内续期
Go 服务内大量 goroutine 争抢同一资源合流层只在进程内生效,正好匹配这种场景

TurboLock 不适合:

场景原因
金融级强一致事务锁Redis 单节点锁不是 CP 协调系统
跨机房强一致协调网络分区和时钟/故障模型更复杂
Redis failover 期间不能容忍任何锁语义异常单节点 SETNX 无法覆盖这类语义
需要 fencing token 的资源写入TurboLock 当前不提供 fencing token
长时间无限持锁任务MaxHoldDuration 会限制最大续期时间
需要公平锁或可重入锁TurboLock 不保证 FIFO,也不支持重入

一句话总结:

TurboLock 优化的是“同一 Go 进程内大量 goroutine 争抢同一个 Redis lock key”时的无效请求问题。
如果你的瓶颈不在这里,它会退化为普通 Redis 锁;如果你需要强一致协调,应优先考虑 etcd、 ZooKeeper、Consul 或 fencing token 方案。

十三、开源地址

这个项目已经开源:

go get github.com/ThanksGiveMeCourage/turbolock

GitHub:

https://github.com/ThanksGiveMeCourage/turbolock

文中涉及到的测试代码案例:

https://gist.github.com/ThanksGiveMeCourage/16990c4c842dc4995c9fd5ec43ff5807

项目里包含:

  • Redis 锁基础实现
  • Leader-Follower 本地合流
  • Lua 原子释放与续期
  • 层级时间轮自动续期
  • MaxHoldDuration 最大持锁时间
  • sync.Pool 对热点对象的复用
  • benchmark 与相关文档

如果你也遇到过 Go 服务里热点 Redis 锁打爆 Redis、重试风暴、自动续期 goroutine 膨胀这类问题,欢迎一起交流。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/6/16 14:08:50

百度网盘高速下载终极教程:免费获取直连地址的完整指南

百度网盘高速下载终极教程&#xff1a;免费获取直连地址的完整指南 【免费下载链接】baidu-wangpan-parse 获取百度网盘分享文件的下载地址 项目地址: https://gitcode.com/gh_mirrors/ba/baidu-wangpan-parse 还在为百度网盘蜗牛般的下载速度而烦恼吗&#xff1f;今天我…

作者头像 李华
网站建设 2026/6/16 14:06:16

79万真实医患对话:中文医疗对话数据集的完整指南

79万真实医患对话&#xff1a;中文医疗对话数据集的完整指南 【免费下载链接】Chinese-medical-dialogue-data Chinese medical dialogue data 中文医疗对话数据集 项目地址: https://gitcode.com/gh_mirrors/ch/Chinese-medical-dialogue-data 当深夜的急诊室灯火通明&…

作者头像 李华
网站建设 2026/6/16 14:06:13

Memos 私人碎片笔记怎么搭?Docker 加 Caddy 一小时跑起来

Memos 私人碎片笔记怎么搭&#xff1f;Docker 加 Caddy 一小时跑起来 想要一个轻量的私人碎片笔记&#xff0c;不一定非要上完整知识库。Memos 更适合随手记录想法、链接、待办和短内容&#xff0c;部署成本低&#xff0c;手机浏览器也能用。本文给一套 Docker Compose Caddy …

作者头像 李华
网站建设 2026/6/16 14:06:12

FOCAS2开发实战:打通FANUC数控系统数据采集与设备联网

1. 项目概述&#xff1a;FOCAS2到底是什么&#xff1f; 如果你在制造业&#xff0c;特别是数控机床&#xff08;CNC&#xff09;领域摸爬滚打过&#xff0c;那你一定对“数据孤岛”这个词深有体会。机床就在那里日夜不停地运转&#xff0c;生产数据、状态信息、报警记录都锁在控…

作者头像 李华
网站建设 2026/6/16 14:01:54

空间数据科学:让模型真正理解‘位置’的底层能力

1. 什么是空间数据科学&#xff1f;它真不是“加个经纬度就完事”的花架子你有没有遇到过这种情况&#xff1a;团队里跑出一个预测模型&#xff0c;准确率高达92%&#xff0c;结果一上线&#xff0c;业务方盯着地图看了三分钟&#xff0c;突然说&#xff1a;“等等&#xff0c;…

作者头像 李华