Redis中唯一能实现条件性原子提交的事务路径是WATCH+MULTI+EXEC组合,需在回调函数内通过tx对象操作所有命令,且WATCH必须覆盖所有读写key并前置调用。

Redis 的 WATCH + MULTI 是唯一可行的“事务”路径
Go 里没有真正意义上的 Redis 事务(ACID),redis.TxPipeline 或 redis.Pipeline 都不等价于 MULTI/EXEC。真正能实现“条件性原子提交”的,只有客户端显式调用 WATCH、MULTI、EXEC 这一组合。官方 github.com/go-redis/redis/v9 库中,client.Watch 方法就是为此设计的封装——它自动处理了乐观锁失败重试逻辑,但前提是你要把所有读写逻辑写进回调函数里。
常见错误是试图在 Watch 回调外读取 key 再决定是否写入,这会破坏一致性:中间值可能已被其他客户端改写,而你没 WATCH 它。
-
Watch必须在读操作之前调用,且要覆盖所有后续可能影响判断的 key - 回调函数里不能有阻塞或耗时操作(如 HTTP 请求、数据库查询),否则 WATCH 锁等待时间拉长,冲突概率飙升
- 如果回调返回非
nilerror,Watch会自动重试(默认 10 次),但重试不是无限的;超时或达到最大重试次数后抛出redis.TxFailedErr
client.Watch 回调里只能用 tx 对象操作 Redis
传给 client.Watch 的函数签名是 func(*redis.Tx) error,里面拿到的 tx 是一个临时事务上下文,所有命令都必须通过它发出,例如 tx.Get(ctx, "balance")、tx.Set(ctx, "balance", newBal, 0)。直接用原始 client 发命令会绕过事务,导致部分操作落库、部分被丢弃,且无法回滚。
典型误用:val, _ := client.Get(ctx, "balance").Result() 在回调外读,再用 tx.Set 写——这完全失去 WATCH 的意义,因为读到的值可能已过期。
立即学习“go语言免费学习笔记(深入)”;
在 Golang 中使用 samber/hot 进行内存缓存,支持 LRU、LFU、TinyLFU、W‑TinyLFU、S3FIFO、ARC、TwoQueue、SIEVE、FIFO 等淘汰算法,提供 TTL、缓存加载器及分片功能。
-
tx.Get、tx.Incr、tx.HSet等方法返回的是待执行指令,实际在EXEC时才真正执行 - 所有 key 必须提前声明在
Watch调用的参数列表中,漏掉任何一个被读/写的 key,都会导致事务失效(比如只WATCH "a"却在回调里tx.Get("b"),这个GET不受保护) - 不支持跨 DB 的 WATCH(Redis 本身限制),
client初始化时指定的DB值决定了整个事务作用域
用 redis.TxFailedErr 判断是否因冲突失败,而非泛化 error
事务执行失败只有两类原因:业务逻辑报错(比如金额为负)、或乐观锁冲突(key 被其他客户端修改)。前者是你该处理的业务异常,后者是正常流程,应静默重试或降级。但很多人用 errors.Is(err, xxx) 匹配模糊字符串,或者直接 if err != nil 就 panic,结果把冲突当故障。
go-redis/v9 明确导出了 redis.TxFailedErr 类型,它是唯一表示 “WATCH 失败导致 EXEC 返回 nil reply” 的错误标识。其他错误(如网络超时、命令语法错)不会包装成它。
- 检查方式必须是
errors.Is(err, redis.TxFailedErr),不能用==或字符串匹配 - 如果你手动实现重试逻辑(不用
Watch),需捕获EXEC返回的[]interface{},当其为nil时表示事务被丢弃 - 注意:
TxFailedErr不包含重试次数信息,日志里建议加上当前尝试序号,方便排查高频冲突点
简单计数器场景下,优先用 INCR 而非 WATCH+GET+SET
不是所有“需要原子性”的地方都得上事务。比如实现点赞数自增,直接 client.Incr(ctx, "post:123:likes") 就是原子的,比 WATCH "post:123:likes" + 读值 + 加一 + 写回更轻量、更快、无冲突风险。Redis 原生命令只要单 key、单操作,基本都自带原子性。
事务只在涉及多个 key 读写、且存在依赖关系时才必要,例如:“用户 A 转账给 B,需同时扣 A 余额、加 B 余额、记录流水”,三个操作必须全成功或全失败。
-
INCR、DECR、HINCRBY、LPUSH、SADD等命令天然原子,无需事务包裹 - 多 key 场景下,若能用 Lua 脚本替代(如
EVAL "redis.call('decr',KEYS[1]);redis.call('incr',KEYS[2])" 2 a b),性能通常更好,且避免了客户端重试逻辑的复杂度 - Lua 脚本也有局限:不可用
redis.replicate_commands()以外的复制方式,且在集群模式下要求所有 key 在同一 slot(需用{}手动哈希对齐)
WATCH 反而引入不必要的重试开销和逻辑耦合。

















