本文不是要证明"Redis 的 SETNX 可以被优化 900 倍",而是复盘一个更具体的工程问题:
当大量 goroutine 同时争抢同一个 Redis lock key 时,如何减少那些注定失败的无效请求?
在一个热点 key 正常竞争的 benchmark 中,本地合流把无合流基线的平均尝试成本从约
12.8ms/op降到了约0.014ms/op。但这个数字有明确前提:它统计的是所有抢锁尝试的平均成本,其中包含大量被本地快速拦截的失败请求,不能简单理解成"一次成功持锁 + 执行业务 + Unlock 的完整耗时"。
一、问题不是 SETNX,而是太多请求在做无用功
Redis 分布式锁的基础写法大家都很熟:
vbnet
SET key value NX PX ttl
释放时再用 Lua 比对 value,确保只删除自己的锁。
这个逻辑本身没有问题。
但在真实业务里,Redis 锁还有一个容易被忽略的工程问题:
如果同一个进程内,有大量 goroutine 同时争抢同一个 lock key,那么最后即使只有一个 goroutine 能成功,其他 goroutine 也可能已经向 Redis 发起了大量注定失败的请求。
这类场景在服务端并不少见:
- 同一个订单被重复提交
- 同一个用户任务被重复触发
- 同一个库存 key 被短时间集中访问
- 定时任务、活动任务、补偿任务出现并发触发
- 游戏服务器里同一份玩家、房间、战斗资源被多路逻辑同时抢占
如果每个 goroutine 都独立执行:
bash
SETNX 失败 → sleep → 再 SETNX → 再失败 → 再 sleep
那么 Redis 承受的不是"一个锁请求",而是一波抢锁洪峰。
这次 TurboLock 的优化目标很明确:
不改变 Redis 锁的基本语义,而是在 Go 进程内部尽量拦截那些注定失败的抢锁请求。
二、先构造一个极端场景:锁一直不可用时会发生什么?
我最开始做 benchmark 时,故意构造了一个极端场景:
javascript
client.Set(ctx, lockKey, "occupied_by_main", 60*time.Second)
也就是:主线程先把这个 key 占住 60 秒,其他 goroutine 在这期间怎么抢都不可能成功。
这个场景并不是为了模拟正常业务,而是为了暴露两个问题:
- 热点锁下的无效 Redis 请求会迅速放大。
- 循环里的
time.After会带来大量堆分配。
当时的原始 benchmark 数据类似这样:
bash
BenchmarkTurboLock_HighConcurrency-8 100 143597280 ns/op 58648 B/op 1019 allocs/op
约 143ms/op,1000+ allocs/op。
这个结果看起来很夸张,但它背后的原因并不神秘。
在无本地拦截的情况下,每个 goroutine 都会自己去 Redis 抢锁:
go
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.After timer。
如果重试次数设置得比较高,热点 key 又一直不可用,那么请求数和分配数都会被快速放大。
三、第一层优化:同一个 key,只让一个 goroutine 去 Redis
我的第一层优化是本地 Leader-Follower 合流。
思路很简单:
markdown
同一个进程内,同一个 key 的锁请求:
1. 第一个 goroutine 成为 Leader,负责去 Redis 抢锁。
2. 其他 goroutine 成为 Follower,在本地等待 Leader 的结果。
3. Leader 成功后,Follower 直接快速失败,不再继续冲向 Redis。
4. Leader 失败后,再允许下一个 goroutine 成为新的 Leader。
也就是说:
同一时刻、同一个 key,进程内只放行一个 goroutine 去 Redis。
核心结构类似这样:
go
type localSlot struct {
mu sync.Mutex
cond *sync.Cond
active bool
isSuccess bool
}
active 表示当前是否已经有 Leader 出发。
isSuccess 表示上一轮 Leader 是否已经抢锁成功。
简化后的逻辑如下:
go
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 秒"的病理场景里,引入本地合流后,我得到过类似这样的数据:
bash
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 结果如下:
bash
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 自己的重试循环里,还有一个常见问题:
arduino
case <-time.After(t.opts.RetryDelay):
在循环中频繁调用 time.After,会不断创建 timer 对象。
在高并发、长重试链路下,这会放大堆分配和 GC 压力。
改造前:
css
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):
}
}
改造后:
css
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。
例如:
ini
锁 TTL = 8s
业务执行 = 10s
第 8 秒锁过期
第 9 秒另一个 goroutine 拿到锁
第 10 秒原 goroutine 还在执行
这会导致两个执行流同时进入临界区。
常见解决方案是自动续期。
但如果实现成:
一把锁 = 一个 goroutine + 一个 ticker
那么锁数量一多,goroutine 和 ticker 成本就会线性增长。
TurboLock 使用的是层级时间轮:
yaml
Level 0: 256 slots
Level 1: 64 slots
Level 2: 64 slots
全局 1 个 goroutine + 1 个 ticker 推进时间轮
N 把锁的续期任务统一挂到时间轮槽位中
简化理解就是:
vbnet
lock acquired
↓
schedule renewal at TTL/3
↓
Lua compare-and-renew
↓
reschedule next renewal
↓
stop when unlocked or MaxHoldDuration reached
自动续期时仍然使用 Lua 比对 value:
vbnet
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 路径:
bash
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 降低热点对象分配
最后一层优化是处理热点对象分配。
逃逸分析可以帮我们找到堆分配位置:
ini
go build -gcflags="-m" ./... 2>&1 | grep "escapes"
当时主要关注两个对象:
go
make([]byte, 32)
&timerTask{}
一个用于生成锁 value。
一个用于时间轮续期任务。
这类对象有两个特点:
- 体积不大。
- 高频创建。
- 生命周期短。
- 可复用。
因此可以用 sync.Pool 做对象复用:
go
var randPool = sync.Pool{
New: func() any {
return make([]byte, 32)
},
}
var taskPool = sync.Pool{
New: func() any {
return &timerTask{}
},
}
生成随机 value 时:
go
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
}
时间轮任务执行完毕或取消后:
scss
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 分布式协调系统。
它解决的是:
csharp
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 方案。
十三、开源地址
这个项目已经开源:
arduino
go get github.com/ThanksGiveMeCourage/turbolock
GitHub:
bash
https://github.com/ThanksGiveMeCourage/turbolock
文中涉及到的测试代码案例:
arduino
https://gist.github.com/ThanksGiveMeCourage/16990c4c842dc4995c9fd5ec43ff5807
项目里包含:
- Redis 锁基础实现
- Leader-Follower 本地合流
- Lua 原子释放与续期
- 层级时间轮自动续期
- MaxHoldDuration 最大持锁时间
- sync.Pool 对热点对象的复用
- benchmark 与相关文档
如果你也遇到过 Go 服务里热点 Redis 锁打爆 Redis、重试风暴、自动续期 goroutine 膨胀这类问题,欢迎一起交流。