服务里的 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 小时前
第 1 章:别把“需求文档”当成真正的需求
前端·后端·程序员
IT_陈寒6 小时前
闭包陷阱让我加了两天班,JavaScript你真行
前端·人工智能·后端
易协同低代码7 小时前
通达OA核心类库TD类深度解析
后端
Gopher_HBo7 小时前
Go语言学习笔记(十八)Gin处理Session
后端
谭光志8 小时前
工具塞满上下文窗口怎么办?深度拆解 AI Agent Tool Search 按需加载实现原理
前端·后端·ai编程
她说..8 小时前
Java 默认值设置方式
java·开发语言·后端·springboot
foggyprojects8 小时前
从0开始,一句话启动AI DataAgent
后端·数据分析·ai编程
郡杰8 小时前
一些基础和问题解决
后端
陈随易8 小时前
前端项目部署只要30秒
前端·后端·程序员
YIAN9 小时前
从零手写文件读取 MCP 服务:一文吃透 Model Context Protocol 全链路通信原理
前端·后端·mcp