TurboLock:从 v1 Redis 分布式锁到 v2 Go 并发控制中间件

TurboLock v2 的重点不是重新实现一遍 SETNX,而是在 v1 锁内核之上,让业务只需要提供 Redis Client、锁 key 和回调函数,就能完成一次完整、可观测、可插拔的并发控制。

bash 复制代码
go get github.com/ThanksGiveMeCourage/turbolock@v1.1.0

版本说明: 文中"v2"指 TurboLock 的产品迭代版本(Try/Do 函数级 API),Go Module tag 为 v1.1.0------二者指代同一份代码。模块路径不带 /v2 后缀,因此遵循 Go 规范使用 v1.x 版本号。

一、为什么要从 v1 升级到 v2

在 v1 中,TurboLock 已经具备一套可用的 Redis 锁内核:

  • 使用 SETNX 抢锁;
  • 使用随机 value 标识锁的持有者;
  • 使用 Lua 脚本校验 value 后释放锁;
  • 使用 Leader-Follower 模型合流同进程内的热点请求;
  • 使用层级时间轮统一调度自动续期任务;
  • 使用 sync.Pool 复用随机数缓冲区和时间轮任务。

这些能力解决了"锁怎么实现"的问题,但没有完全解决"业务怎么使用"的问题。

业务仍然需要重复编写下面这段代码:

go 复制代码
unlock, err := locker.Lock(ctx, key)
if err != nil {
    return err
}
defer unlock(ctx)

return business(ctx)

这段代码看起来不长,却把几个容易出错的责任交给了调用者:

  1. 是否会忘记释放锁;
  2. 业务返回错误时是否仍会释放锁;
  3. 业务发生 panic 时是否仍会释放锁;
  4. 解锁错误和业务错误谁优先返回;
  5. 当前场景应该重试,还是应该快速失败。

因此,v2 的目标逐渐变得明确:

锁仍然是底层实现,但对外提供的是函数级并发控制能力。

二、一次重要的设计收敛

开发过程中,我们曾经把"中间件"直接理解成 HTTP Middleware,并尝试让使用者提供 http.Handler、路由参数提取器和锁 key 模板。

调用方式一度接近这样:

go 复制代码
handler := turbolock.HTTPMiddleware(
    controller,
    payHandler,
    turbolock.KeyFromPathValue("orderID", "order:pay:"),
)

这套设计可以工作,但它解决的是 HTTP 框架集成问题,不是 TurboLock 最核心的使用成本问题。调用者不仅要理解锁,还要理解 Handler、key extractor 和路由参数之间的关系。

最终我们重新定义了这里的"中间件":

  • 它不必接管 HTTP 请求;
  • 它不必理解路由和参数;
  • 它只需要包裹一段需要互斥执行的业务逻辑;
  • HTTP、RPC、定时任务只是调用它的不同上层场景。

因此 HTTP 能力被移出主体,以可选适配层的形式留在设计规划中(当前仓库未包含具体实现)。核心 API 收敛为三个输入:

text 复制代码
Redis Client + Lock Key + Business Callback

这个收敛决定了 v2 最终的样子。

三、最终对外 API

1. 初始化

go 复制代码
client := redis.NewClient(&redis.Options{
    Addr: "localhost:6379",
})

locker := turbolock.New(client)
defer locker.Close()

New 是 v2 推荐入口,内部仍然复用 v1 的 NewTurboLocker 构造逻辑和默认配置。

2. Try:快速失败

go 复制代码
err := locker.Try(ctx, "order:pay:123", func(ctx context.Context) error {
    return pay(ctx)
})

if errors.Is(err, turbolock.ErrLockFailed) {
    // 当前 key 正在被处理,可以跳过、重试或返回业务冲突。
    return nil
}
return err

Try 只进行一次 Redis 抢锁尝试。

如果同进程内已经有相同 key 的 leader,或者 Redis 中的锁已经存在,它会立即返回 ErrLockFailed,不会进入退避重试。

它适合:

  • 请求防重复提交;
  • 订单防重处理;
  • 秒杀热点保护;
  • 定时任务实例抢占;
  • 缓存重建保护。

3. Do:按配置重试

go 复制代码
err := locker.Do(ctx, "inventory:sku:8888", func(ctx context.Context) error {
    return rebuildInventory(ctx)
})

Do 复用底层 Lock,根据 WithTriesWithRetryDelay 执行指数退避重试,单次退避最大为 2 秒。

需要注意:Do 表示"按配置尝试获取锁",并不等于无限等待。重试耗尽、上下文取消或本地已有成功持锁者时,它仍然会返回错误。实际使用中应当为 ctx 设置合理的超时时间。

4. TryDo:兼容旧名称

go 复制代码
err := locker.TryDo(ctx, key, fn)

TryDo 在 v2 中保留用于兼容已有代码,内部直接调用 Try。注意 TryDo 仅存在于 NewTurboLocker(v1 兼容接口)上;使用 New 创建的 v2 实例请直接用 TryDo

四、函数级 API 帮业务处理了什么

DoTry 都遵循相同的执行结构:

text 复制代码
校验 key 和 fn
    -> 获取锁
    -> defer 解锁
    -> 执行业务回调
    -> 返回业务错误或解锁错误

对应的核心逻辑可以简化为:

go 复制代码
func (t *defaultTurboLocker) Try(
    ctx context.Context,
    key string,
    fn Func,
) (err error) {
    if err := validateDoArgs(key, fn); err != nil {
        return err
    }

    unlock, err := t.tryLock(ctx, key) // 简化示意:内部根据 Try/Do 调用栈选择一次尝试或带重试的 Lock(非真实方法名)
    if err != nil {
        return err
    }

    defer func() {
        unlockErr := unlock(ctx)
        if err == nil {
            err = unlockErr
        }
    }()

    return fn(ctx)
}

这里有三个明确的错误约定:

  1. 业务回调成功、解锁失败:返回解锁错误;
  2. 业务回调失败、解锁也失败:优先返回业务错误;
  3. 业务回调 panic:defer 仍会尝试解锁,然后 panic 继续向上传播。

TurboLock 负责锁的生命周期,但不会吞掉或恢复业务 panic。

五、本地合流:热点 key 为什么不会打满 Redis

如果 1000 个 goroutine 同时竞争同一个 key,直接使用 Redis 锁意味着 1000 个请求都会到达 Redis。

TurboLock 在 Redis 前增加了一层本地 slot:

text 复制代码
goroutine A ----\
goroutine B -----+--> localSlot(key) --> leader --> Redis SETNX
goroutine C ----/          |
                           +--> followers 本地等待或快速失败

localSlot 维护三个关键状态:

  • active:是否已有 leader 正在访问 Redis;
  • isSuccess:当前进程是否已经成功持有这个 key;
  • cond:让阻塞式 follower 等待 leader 的抢锁结果。

对于 Try

  • 发现 activeisSuccess 后立即失败;
  • 不进入 cond.Wait()
  • 不产生无意义的 Redis 请求。

对于 Lock/Do

  • leader 尚在抢锁时,follower 可以在本地等待抢锁结果;
  • leader 成功后,follower 返回 ErrLockFailed
  • leader 失败后,后续调用者可以重新成为 leader。

v2 还明确了 OnCoalesced 的语义:一次调用最多记录一次合流,并且用户 Hook 不在 slot.mu 内执行,避免观测代码阻塞内部并发控制。

六、锁所有权不是一个 Redis 命令错误

每次成功抢锁时,TurboLock 都会生成一个随机 value:

text 复制代码
key   = order:pay:123
value = 当前持有者的随机身份标识

释放锁时不能直接执行 DEL key,否则旧持有者可能误删新持有者的锁。因此必须通过 Lua 原子地比较 value:

lua 复制代码
if redis.call("GET", KEYS[1]) == ARGV[1] then
    return redis.call("DEL", KEYS[1])
else
    return 0
end

一个容易忽略的问题是:Lua 返回 0 时,Redis 命令本身并没有报错。

如果代码只检查:

go 复制代码
client.Eval(...).Err()

那么"锁已过期""value 不匹配"和"重复解锁"都会被误判为成功。

v2 改为同时检查 Lua 返回值,并引入:

go 复制代码
var ErrLockNotOwned = errors.New(
    "turbolock: lock is no longer owned",
)

现在,以下情况会明确返回 ErrLockNotOwned

  • 锁已经自然过期;
  • 当前 value 不再是锁的持有者;
  • 锁已经被其他实例重新获取;
  • 同一个 unlock 被重复执行。

续期 Lua 脚本也遵循相同约定,不能把返回 0 记录为续期成功。

七、自动续期为什么默认关闭

短业务通常不需要续期。只要锁 TTL 大于业务执行时间,让锁在结束时正常释放即可。

如果业务可能运行超过 TTL,可以显式开启:

go 复制代码
locker := turbolock.New(client,
    turbolock.WithExpiry(2*time.Second),
    turbolock.WithAutoRenew(),
    turbolock.WithMaxHoldDuration(10*time.Second),
)
defer locker.Close()

TurboLock 按 Expiry / 3 调度续期:

text 复制代码
T=0       获取锁,TTL=2s
T~0.66s   校验 value,重置 TTL
T~1.33s   校验 value,重置 TTL
T~2.00s   继续续期,或业务完成后取消任务

续期任务不是"一把锁启动一个 goroutine 和 ticker",而是统一挂载到三层时间轮上,由一个调度 goroutine 推进。

这样做的价值不只是减少 goroutine,更重要的是让大量锁的定时任务拥有统一生命周期:

  • 获取锁后注册续期任务;
  • 解锁前取消续期任务;
  • 达到 MaxHoldDuration 后停止续期;
  • 锁所有权丢失时触发 OnRenewFailed

自动续期默认关闭,是为了让普通锁的行为保持简单和显式。只有业务确实可能超过 TTL 时才应该开启。

八、NoopLocker:同一套业务代码的配置开关

Noop 模式的目的不是让业务维护两套逻辑,而是让业务依赖统一接口:

go 复制代码
var concurrency turbolock.Controller

if config.EnableTurboLock {
    concurrency = turbolock.New(redisClient)
} else {
    concurrency = turbolock.NewNoopLocker()
}

业务始终只调用:

go 复制代码
err := concurrency.Try(ctx, key, func(ctx context.Context) error {
    return business(ctx)
})

启用时,调用经过 Redis 锁和本地合流;关闭时,NoopLocker 直接执行 fn(ctx)

这里的"zero-cost"更准确地说是"零锁资源成本":

  • 不访问 Redis;
  • 不创建本地 slot;
  • 不启动时间轮;
  • 不维护续期任务。

它仍然会进行一次接口调用和 nil 回调校验,因此不是字面意义上的零条指令。

九、Hooks:保持核心库轻量的可观测入口

TurboLock 没有直接依赖 Prometheus 或 OpenTelemetry,而是提供一组最小 Hook:

go 复制代码
type Hooks struct {
    OnLockSuccess  func(key string)
    OnLockFailed   func(key string, err error)
    OnCoalesced    func(key string)
    OnRenewSuccess func(key string)
    OnRenewFailed  func(key string, err error)
    OnUnlockFailed func(key string, err error)
}

接入方式:

go 复制代码
locker := turbolock.New(client,
    turbolock.WithHooks(turbolock.Hooks{
        OnLockSuccess: func(key string) {
            metrics.LockSuccess.Inc()
        },
        OnLockFailed: func(key string, err error) {
            metrics.LockFailed.Inc()
        },
        OnCoalesced: func(key string) {
            metrics.Coalesced.Inc()
        },
    }),
)

Hook 的语义如下:

Hook 触发条件
OnLockSuccess 当前调用成功取得锁
OnLockFailed 当前调用最终未取得锁,参数保留实际错误
OnCoalesced 当前调用被同进程相同 key 的 leader 合流
OnRenewSuccess Lua 确认所有权并完成续期
OnRenewFailed Redis 错误或锁所有权已经丢失
OnUnlockFailed Redis 错误或当前调用不再拥有该锁

Hook 会参与实际调用路径,因此应该保持轻量、非阻塞。复杂日志、网络上报或高延迟统计最好投递到业务自己的异步队列中。

十、错误语义

v2 对外提供四个主要错误:

错误 含义
ErrLockFailed 锁冲突,当前调用没有取得锁
ErrLockNotOwned 续期或解锁时,当前调用已经不再拥有锁
ErrNilFunc 业务回调为 nil
ErrEmptyKey 正常锁模式下 key 为空

Redis 连接失败、超时等基础设施错误会保留原始错误,不再统一覆盖成 ErrLockFailed。这样调用者和监控系统才能区分"正常锁竞争"和"Redis 故障"。

十一、完整配置项

Option 默认值 作用
WithExpiry(d) 8s Redis 锁 TTL
WithTries(n) 32 Lock/Do 最大尝试次数
WithRetryDelay(d) 50ms 初始退避时间
WithAutoRenew() 关闭 开启时间轮自动续期
WithMaxHoldDuration(d) 30s 自动续期模式下的最长持锁时间
WithHooks(h) 空 Hook 注入观测回调

常用配置示例:

go 复制代码
locker := turbolock.New(client,
    turbolock.WithExpiry(5*time.Second),
    turbolock.WithTries(8),
    turbolock.WithRetryDelay(20*time.Millisecond),
    turbolock.WithHooks(hooks),
)

十二、四个可运行示例

v2 最终保留了四个核心业务示例:

1. 秒杀防超卖

example/seckill 使用商品 ID 作为锁粒度,100 个 goroutine 竞争一份库存,展示 Try 的快速失败语义。

2. 订单支付防重复

example/order_dedup 使用订单 ID 构造锁 key,同一订单只允许一个请求进入支付临界区。

3. 定时任务单实例执行

example/cron_singleton 模拟任务执行时间超过锁 TTL 的场景,展示 AutoRenewMaxHoldDuration

4. 热点 key 压力验证

example/hot_key_stress 使用 1000 个 goroutine 竞争同一个 key,并通过 Hooks 统计:

  • 成功获取锁的数量;
  • 抢锁失败的数量;
  • 在本地被合流的数量;
  • 实际进入临界区的数量。

运行示例:

bash 复制代码
go run ./example/seckill
go run ./example/order_dedup
go run ./example/cron_singleton
go run ./example/hot_key_stress

十三、测试覆盖

v2 的测试不只验证成功路径,还覆盖了并发控制中容易遗漏的边界:

  • Do 正常返回、业务错误和 panic 后解锁;
  • Try 快速失败、不等待重试间隔和不同 key 并发;
  • TryDo 向后兼容;
  • Noop 模式不依赖 Redis;
  • 自动续期、关闭续期和 goroutine 数量;
  • 时间轮添加、取消、级联、并发删除和 panic 恢复;
  • Hook 成功、失败、合流、续期和解锁事件;
  • Hook 不在 slot 内部锁中执行;
  • Lua 返回 0 时识别锁所有权丢失;
  • Redis 原始错误不会被 ErrLockFailed 覆盖。

需要本地 Redis 的测试会在 Redis 不可用时主动跳过。完整验证命令:

bash 复制代码
go test ./...
go test -race ./...
go test -bench=. -benchmem ./...

十四、TurboLock 的边界

TurboLock 解决的是单 Redis 节点语义下的工程并发控制,不是共识系统。

适合:

  • Go 服务中的热点 key 竞争;
  • 可接受 Redis 锁语义的短时间互斥;
  • 希望减少同进程重复 Redis 请求的场景;
  • 需要轻量自动续期和观测能力的任务。

不适合:

  • 金融级强一致事务;
  • 需要 fencing token 阻止旧持有者写入的资源;
  • Redis 故障切换期间也不能接受锁语义变化的系统;
  • 需要 etcd、ZooKeeper 这类 CP 协调语义的场景。

另外,本地合流只发生在当前服务进程内。不同实例之间的互斥仍然由 Redis 完成。

十五、从 v1 到 v2 真正完成了什么

回头看,从 v1 到 v2 最重要的变化并不是增加了多少文件,而是完成了三次抽象上的收敛:

第一,从"让用户管理一把锁"收敛为"让用户提交一段需要并发控制的函数"。

第二,从"中间件等于 HTTP Middleware"收敛为"中间件是一种可插拔能力,HTTP 只是可选适配层"。

第三,从"所有失败都叫抢锁失败"收敛为可区分的锁冲突、Redis 故障和所有权丢失。

最终,业务侧只保留最必要的表达:

go 复制代码
locker := turbolock.New(client)
defer locker.Close()

err := locker.Try(ctx, key, func(ctx context.Context) error {
    return business(ctx)
})

这就是 TurboLock v2 的定位:

以 Redis 锁为执行内核,以本地合流降低热点竞争,以时间轮管理续期,并通过函数级 API、Noop 和 Hooks 向 Go 业务提供可插拔的并发控制能力。

安装并使用 v2:

bash 复制代码
go get github.com/ThanksGiveMeCourage/turbolock@v1.1.0
go 复制代码
locker := turbolock.New(client)
defer locker.Close()

err := locker.Try(ctx, key, func(ctx context.Context) error {
    return business(ctx)
})
相关推荐
贺国亚1 小时前
特征平台-Feature-Store设计
后端
杨运交1 小时前
[049][Crypto模块]前后端混合加密API实战:基于Spring Boot的AES+RSA安全传输方案
spring boot·后端·安全
geovindu1 小时前
CSharp: Recursion Algorithm
开发语言·后端·算法·c#·递归算法
Eofer1 小时前
零成本内网自定义域名 + Nginx 建站完整教程|家用路由 + Debian 本地搭建
后端
自进化Agent智能体1 小时前
Hermes Agent 消息网关——连接 20+ 聊天平台
后端
你为她披上外套时我正站在窗外1 小时前
subtitler v2.0:从稳健到极致
后端
吃饱了得干活2 小时前
实时弹幕应该怎么搞?一篇带你从入门到生产级实战
java·后端
用户0207199207722 小时前
WebSocket 从建立连接到稳定运行
后端
你为她披上外套时我正站在窗外2 小时前
subtitler v2.0 的零拷贝设计:从 2000 次分配到 0 次
后端