本文不是要证明“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 在这期间怎么抢都不可能成功。
这个场景并不是为了模拟正常业务,而是为了暴露两个问题:
- 热点锁下的无效 Redis 请求会迅速放大。
- 循环里的
time.After会带来大量堆分配。
当时的原始 benchmark 数据类似这样:
BenchmarkTurboLock_HighConcurrency-8 100 143597280 ns/op 58648 B/op 1019 allocs/op约143ms/op,1000+ 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同时准备两个实现:
- NoMergeLocker
有 Lock/Unlock、timer 复用、指数退避,但没有本地合流。 - 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| 指标 | NoMergeLocker | TurboLock | 变化 |
|---|---|---|---|
| ns/op | ~12.8ms | ~0.014ms | ~912× |
| B/op | 3297 | 426 | ~7.7× less |
| allocs/op | 31 | 1 | 31× 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: } }这个改造的收益主要有两个:
- Timer 对象从“每轮重试创建一个”变成“单次 Lock 复用一个”。
- 指数退避降低了锁不可用期间单位时间内的 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/op | 10206 | 9653 | 噪声范围内 |
| B/op | 25 | 25 | 无明显变化 |
| allocs/op | 0 | 0 | 无明显变化 |
这组数据只能说明:
在这个 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。
一个用于时间轮续期任务。
这类对象有两个特点:
- 体积不大。
- 高频创建。
- 生命周期短。
- 可复用。
因此可以用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。
| 指标 | NoMergeLocker | TurboLock | 说明 |
|---|---|---|---|
| ns/op | ~12.8ms | ~0.014ms | 平均尝试成本下降 |
| B/op | 3297 | 426 | 分配字节下降 |
| allocs/op | 31 | 1 | 分配次数下降 |
这个场景说明:
在同一进程内大量 goroutine 争抢同一热点 key 时,本地合流可以显著降低平均抢锁尝试成本。
3. AutoRenew 同步路径
| 指标 | 关闭 AutoRenew | 开启 AutoRenew | 说明 |
|---|---|---|---|
| ns/op | 10206 | 9653 | 噪声范围内 |
| B/op | 25 | 25 | 无明显变化 |
| allocs/op | 0 | 0 | 无明显变化 |
这个场景说明:
在短持锁正常竞争 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 锁语义通常可以接受 |
| 业务执行时间可能略超过 TTL | AutoRenew 可以在 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/turbolockGitHub:
https://github.com/ThanksGiveMeCourage/turbolock文中涉及到的测试代码案例:
https://gist.github.com/ThanksGiveMeCourage/16990c4c842dc4995c9fd5ec43ff5807项目里包含:
- Redis 锁基础实现
- Leader-Follower 本地合流
- Lua 原子释放与续期
- 层级时间轮自动续期
- MaxHoldDuration 最大持锁时间
- sync.Pool 对热点对象的复用
- benchmark 与相关文档
如果你也遇到过 Go 服务里热点 Redis 锁打爆 Redis、重试风暴、自动续期 goroutine 膨胀这类问题,欢迎一起交流。