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

本文不是要证明"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 在这期间怎么抢都不可能成功。

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

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

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

bash 复制代码
BenchmarkTurboLock_HighConcurrency-8    100    143597280 ns/op    58648 B/op    1019 allocs/op

143ms/op1000+ 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

同时准备两个实现:

  1. NoMergeLocker
    有 Lock/Unlock、timer 复用、指数退避,但没有本地合流。
  2. 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:
    }
}

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

  1. Timer 对象从"每轮重试创建一个"变成"单次 Lock 复用一个"。
  2. 指数退避降低了锁不可用期间单位时间内的 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。

一个用于时间轮续期任务。

这类对象有两个特点:

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

因此可以用 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 膨胀这类问题,欢迎一起交流。

相关推荐
万岳科技1 小时前
教育培训系统开发流程详解:平台建设关键环节解析
数据库·后端·学习
Nturmoils1 小时前
线上修一批脏数据,先别急着全量重来
数据库·后端
飞天狗1111 小时前
零基础JavaWeb入门——第五课第一小节:九大内置对象 · 第1个:request(请求对象)
java·开发语言·前端·后端·servlet
码云骑士2 小时前
23-Django-ORM的N+1问题-select_related与prefetch_related详解
后端·python·django
掘金者阿豪3 小时前
当内容平台越来越多后,我决定把文章放回自己的地盘
后端
llz_1123 小时前
web-第六次课后作业
前端·spring boot·后端
何以解忧,唯有..3 小时前
Go语言类型转换详解:从基础到进阶实践
开发语言·后端·golang
爱勇宝3 小时前
CEO通知5100名员工:今年不涨薪了,钱要投给AI!
前端·后端·程序员
何以解忧,唯有..3 小时前
Go 语言指针类型详解:从基础到实战
开发语言·后端·golang