Watch不是锁而是乐观并发控制机制,需配合手动重试才能实现锁语义;直接调用会因未处理redis.TxFailedErr及未重试导致事务失败或静默错误。

Watch 不是锁,它只是 Redis 提供的乐观并发控制机制。go-redis 的 Watch 方法封装了这一能力,但必须配合手动重试才能真正落地为“锁”语义——不重试就等于没用。
为什么直接调用 Watch 会失败?
Redis 的 WATCH 命令本身不阻塞、不加锁,只在 EXEC 时检查被监控 key 是否被其他客户端修改过。一旦有变更,整个事务返回 (nil)(v8/v9 中表现为 redis.TxFailedErr),而 go-redis 的 Watch 方法默认不重试,直接把错误抛给上层。
- 常见错误现象:
Transaction failed: redis: tx failed或静默失败(没处理 error 导致业务逻辑继续执行旧值) - 根本原因:没捕获
redis.TxFailedErr,也没做重试循环 - 注意:
Watch监控后若 key 被修改,后续所有EXEC都会失败,直到重新WATCH
如何写一个带重试的 Watch 事务函数?
必须显式包裹重试逻辑,且重试次数要有限制,避免无限循环。典型结构是 for i := 0; i + <code>break on success。
初始化并管理阿里云SDK Go 客户端,涵盖 sync.Once 单例、goroutine 安全、endpoint 与 region 配置、VPC 端点及调试。
- 每次循环都要重新调用
rdb.Watch(ctx, fn, keys...),不能复用上一轮的tx - 回调函数
fn内部不能依赖外部变量状态(比如缓存的旧值),每次都要从tx.Get重新读取 - 示例关键片段:
const maxRetries = 5 for i := 0; i < maxRetries; i++ { err := rdb.Watch(ctx, func(tx *redis.Tx) error { val, err := tx.Get(ctx, "balance").Int() if err != nil && err != redis.Nil { return err } if val < amount { return errors.New("insufficient balance") } _, err = tx.Pipelined(ctx, func(pipe redis.Pipeliner) error { pipe.Set(ctx, "balance", val-amount, 0) return nil }) return err }, "balance") if err == nil { return nil // 成功退出 } if !errors.Is(err, redis.TxFailedErr) { return err // 其他错误直接上报 } // TxFailedErr:主动 sleep 后重试,避免 CPU 空转 time.Sleep(time.Millisecond * 10) } return fmt.Errorf("watch retry exhausted")
Watch 和 SETNX / Lua 锁的本质区别
Watch 是事务级校验,适合「读-改-写」类操作;SETNX 或 Lua 脚本是资源抢占式加锁,适合需要排他执行长流程的场景。
立即学习“go语言免费学习笔记(深入)”;
-
Watch不阻塞其他客户端读写,冲突时靠重试解决;SETNX成功即获得独占权,失败需主动等待或放弃 -
Watch无法防止 ABA 问题(key 被改两次又改回原值),Redis 不记录版本号,只看最终值是否变化 - 高并发下,
Watch重试开销可能比 Lua 锁更大,尤其当冲突频繁时;但实现更轻量,无租约管理负担 - 不要混用:
Watch监控的 key 如果同时被SETNX锁住并修改,依然会触发事务失败
真正容易被忽略的是:每次 Watch 失败后,你读到的 key 值已经过期,必须在下一轮重试中重新 GET,而不是沿用上一次缓存的值——这是多数人写出竞态 bug 的根源。

















