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)
这段代码看起来不长,却把几个容易出错的责任交给了调用者:
- 是否会忘记释放锁;
- 业务返回错误时是否仍会释放锁;
- 业务发生 panic 时是否仍会释放锁;
- 解锁错误和业务错误谁优先返回;
- 当前场景应该重试,还是应该快速失败。
因此,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,根据 WithTries 和 WithRetryDelay 执行指数退避重试,单次退避最大为 2 秒。
需要注意:Do 表示"按配置尝试获取锁",并不等于无限等待。重试耗尽、上下文取消或本地已有成功持锁者时,它仍然会返回错误。实际使用中应当为 ctx 设置合理的超时时间。
4. TryDo:兼容旧名称
go
err := locker.TryDo(ctx, key, fn)
TryDo 在 v2 中保留用于兼容已有代码,内部直接调用 Try。注意 TryDo 仅存在于 NewTurboLocker(v1 兼容接口)上;使用 New 创建的 v2 实例请直接用 Try 或 Do。
四、函数级 API 帮业务处理了什么
Do 和 Try 都遵循相同的执行结构:
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)
}
这里有三个明确的错误约定:
- 业务回调成功、解锁失败:返回解锁错误;
- 业务回调失败、解锁也失败:优先返回业务错误;
- 业务回调 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:
- 发现
active或isSuccess后立即失败; - 不进入
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 的场景,展示 AutoRenew 和 MaxHoldDuration。
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)
})