1. 乐观锁 WATCH 的机制与“性能低”到底低在哪
先聊一个实际背景。我去年维护过一个积分商城项目,里面有个核心操作:用户用积分兑换商品时,要扣减账户余额,同时扣减商品库存。最初用的是 MySQL 行锁加事务,后来为了扛一波活动流量,把库存和余额搬到了 Redis。团队里两个老哥为了“到底用不用 WATCH”吵了一下午,一个说 WATCH 是官方乐观锁,业务简单;另一个说网上都说 WATCH 性能拉胯,高并发肯定撑不住。最后我们做了一个判断:先上 WATCH,压测看重试率,不行再换。这个决策过程让我把 WATCH 的底裤扒了一遍。
redis乐观锁的核心机制很简单,就是 WATCH + MULTI + EXEC 三个命令配合:
- WATCH key:告诉 Redis,你帮我盯着这个 key,等会儿我要做一件事,期间如果这个 key 被别的事务改了,你就取消我的执行资格。
- MULTI:开启事务队列,后续命令先排队不执行。
- EXEC:把队列里的命令一次性执行,如果前面 WATCH 的 key 没有被修改,返回执行结果;如果被修改了,返回 nil。
看起来逻辑很顺:先查看“当前值”,然后基于这个值做计算,最后提交时让 Redis 判断“期间有没有人动过”。这种机制有两个关键点很多人第一次接触时会忽略。
第一,WATCH 必须在 MULTI 之前调用。有些新手会把顺序写成交替执行,结果 WATCH 没有起到任何监视作用。第二,EXEC 返回 nil 不代表事务失败,而是代表“监视的 key 被动了,所以整个事务被放弃”。如果返回 nil,客户端需要重试整个流程,重新 GET、重新计算、重新 WATCH。
那“性能低”到底低在哪?我总结为四个层面。
第一个层面是网络往返次数。一个典型的乐观锁操作流程是:GET 读取当前值,WATCH 开始监视,MULTI 开启事务,写入命令入队,EXEC 提交执行。即使把 GET 和 WATCH 顺序优化一下(先 WATCH 再 GET 也行),最少也要四次 RTT(Round-Trip Time)。如果是在内网环境,每次 RTT 大概是 0.1ms 到 0.5ms,四次也就是 0.4ms 到 2ms 的纯网络开销。对比单个 INCR 命令一次 RTT 就能完成,成本明显翻倍。如果业务跨机房调用 Redis,网络延迟按 5ms 到 10ms 算,四次往返就是 20ms 到 40ms,这个开销就很可观了。
第二个层面是冲突验证成本。WATCH 的乐观思想是“假设没人抢,提交时验证”。Redis 在 EXEC 时发现 WATCH 的 key 被修改,直接返回 nil,这个过程本身很快。但客户端拿到 nil 以后要重试,重试意味着重新执行整个业务流程,包括重新计算业务逻辑、重新组织事务里的命令。一旦冲突概率高,净耗时会被放大。比如一次成功成本是 1ms,冲突率 30%,那平均耗时大约是 1 / (1 - 0.3) ≈ 1.43ms,还在可接受范围内。但如果冲突率达到 70%,平均耗时接近 3.3ms,这就很肉疼了。
第三个层面是事务队列里的命令数量。WATCH 监视的往往不止一个 key,事务里也不止一两条命令。像扣积分加扣库存这种操作,事务里可能有四五条命令。虽然 Redis 事务里的命令是按顺序串行执行的,但是每条命令都有解析执行的开销,事务里塞的东西越多,单次 EXEC 的耗时就越长,WATCH 的“并发窗口”也就越大,冲突概率跟着提升。这里有个不太直观的结论:事务越复杂,乐观锁越容易失败。
第四个层面是连接与线程模型。WATCH 是基于连接维度的状态,也就是说客户端进程内多个线程共用同一个连接时会互相干扰。比如线程 A 调用了 WATCH username,还没 EXEC,线程 B 在同一个连接上发了一个别的命令,Redis 会直接丢弃这个 WATCH 状态。所以使用 WATCH 时,要么连接池每个连接同一时刻只跑一个事务,要么自己保证线程内独占连接。这本身不算性能开销,但实现不好会出现诡异的问题:明明没冲突,EXEC 也返回 nil,排查的时候特别容易怀疑人生。
聊完这四个层面,你应该理解为什么说 WATCH 在极致并发场景下性能低。它不是一个 O(1) 的单命令,而是一个多步骤状态机,任何一环被破坏都要从头再来。
2. 访问量不多时,WATCH 为什么“性能也不错”
既然 WATCH 的网络往返和重试机制决定了它在高并发下会放大成本,那“访问量不多的时候性能也不错”这句话怎么理解?核心原因在于,WATCH 的性能开销不是固定的,它高度依赖冲突概率。而冲突概率在低并发下天然就低。
我尝试给一个直观的数学直觉。假设一个业务 key 每秒被操作 10 次,每次 WATCH 事务从开始到 EXEC 的窗口时间大约 5ms。那么在任意一个 1 秒的时间窗口内,10 次操作之间发生时间重叠的概率是多少?简单估算:每个操作占用 5ms,10 个操作总共占用的时间比例是 50ms / 1000ms = 5%。也就是说,两个操作恰好在这个 5ms 窗口内交叠的概率大约在 5% 左右。这个估算很粗糙,因为实际请求有高峰有低谷,往往不是均匀分布,但足以说明问题:低并发下,绝大多数事务都能一次成功,重试很少发生。
我在项目里做过一个统计口径:记录乐观锁事务的 EXEC_calls 和 EXEC_aborts(EXEC 返回 nil 的次数),用后者除以前者得到重试率。在我那个积分商城活动期间,QPS 峰值大概在 80 到 120 之间,分散在 8 个 key(每个商品一个库存 key)上,实际重试率大概在 2% 到 4%。这个数字对一个“用户在页面上点一下兑换”的业务来说,几乎无感。用户即使遇到一次重试,客户端自动重试一下就成功了,感知不到。
WATCH 在低并发下“仿佛不存在”还有一个结构性原因:它没有锁的阻塞成本。MySQL 行锁在高并发写同一行时会排队,等待锁的线程越多,等待时间越长,这是实打实的延迟。而 WATCH 是真正的无锁竞争,大家各干各的,Redis 是单线程串行处理命令,冲突时直接返回失败,不存在“等锁”这个过程。所以在访问量不高时,平均延迟几乎等于“一次正常读 + 一次正常写”的耗时,不会像悲观锁那样随着并发上升而无限增加排队时间。
再补一个实际感受。我有一个小工具站点,每天只有几百个访问量,其中有一个功能是“用户自助领取新人优惠券”,同一个优惠券 key 会被并发领取。这个场景如果用 MySQL 悲观锁,要开事务、加锁、提交、释放,步骤繁琐;如果用 SETNX 分布式锁,虽然能保证互斥,但是要多维护一个锁的 key 和过期时间,而且高并发下可能出现加锁成功但反射到库存扣减又失败的尴尬问题。我用 WATCH 实现,代码量很少,逻辑一眼能看懂,线上跑了大半年,重试率从来没超过 1%。
所以结论就藏在标题里:WATCH 的性能是“看场景的”。它的劣势是机制带来的,优势也是机制带来的。没有锁超时、没有死锁、没有等待,这在低并发场景下比锁方案更稳。你要做的是先承认它不适合高并发强冲突,再用数据判断自己的业务到底算不算高并发。
这里我说一个判断的“经验阈值”:如果 WATCH 事务的冲突重试率超过 10%,就不要硬撑了,赶紧换方案。10% 意味着每 10 次业务请求里至少有 1 次要重试,加到用户体验上就是整体延迟明显上升,而且重试请求会进一步增加冲突概率,形成恶性循环。我自己的经验是,重试率控制在 5% 以内都算健康,超过之后我会直接考虑 Lua 脚本或分布式锁。
3. RedisTemplate 实现 WATCH 乐观锁:完整代码与三个大坑
这一节给一份能直接跑的代码。以 SpringBoot + RedisTemplate 为例,场景是“用户使用积分兑换商品,扣减积分余额”。
先说明环境:Spring Boot 2.7.x,Spring Data Redis 2.7.x,Redis 6.x。RedisTemplate 配置用 StringRedisSerializer,RedisTemplate 的 key 和 value 都序列化为字符串,这个在工程里最常见,不建议在这个场景用 JDK 序列化,因为 WATCH 的东西是 key,key 序列化不一致会导致监视失效。
核心代码长这样:
@Service public class StockService { @Autowired private StringRedisTemplate redisTemplate; private static final int MAX_RETRY = 3; public boolean exchangeWithWatch(String userKey, String stockKey, int costAmount) { for (int retry = 0; retry < MAX_RETRY; retry++) { // 1. 读取当前余额和库存 String balanceStr = redisTemplate.opsForValue().get(userKey); String stockStr = redisTemplate.opsForValue().get(stockKey); int balance = balanceStr == null ? 0 : Integer.parseInt(balanceStr); int stock = stockStr == null ? 0 : Integer.parseInt(stockStr); // 2. 业务校验:余额不足或库存不足,直接失败 if (balance < costAmount || stock <= 0) { return false; } // 3. 开启监视 redisTemplate.watch(userKey); redisTemplate.watch(stockKey); // 4. 开启事务 redisTemplate.multi(); redisTemplate.opsForValue().decrement(userKey, costAmount); redisTemplate.opsForValue().decrement(stockKey, 1); // 5. 执行事务 List<Object> results = redisTemplate.exec(); // 6. 判断结果 if (results != null && results.size() == 2) { return true; } // 结果为 null,说明 WATCH 的 key 被修改,需要重试 } return false; } }这段代码我第一次写完,自测两个案例都过了:正常兑换一次成功,两个客户端同时兑换时只有一个成功、另一个重试后成功。但放到生产环境没多久就遇到几个问题,逐个说。
第一个坑:事务里的命令是入队执行,不是立即执行。也就是说redisTemplate.opsForValue().decrement(userKey, costAmount)这句代码执行后,Redis 并没有真正 decrement,只是把这个操作放进事务队列。要等redisTemplate.exec()才会真正执行。所以你在 multi 里调用的命令,返回值都是 null,不能依赖中间结果做业务判断。我见过有同事在事务里调用 increment 后想读取返回值,拿到 null 就以为失败了,其实是设计问题。
第二个坑:WATCH 和 MULTI 之间不能有别的命令。WATCH 之后的命令如果在 EXEC 之前执行,会取消 WATCH 监视。很多人会在 WATCH 和 MULTI 之间加一个日志打印或者做一次远程调用,比如在 Java 里打日志、调外部接口校验,这不会影响 Redis 连接,但如果这些操作触发了 RedisTemplate 的其他 Redis 命令,比如查询一个配置项,就会导致 WATCH 被取消,整个事务变成一根“失去防御的矛”,冲突时也不会返回 null。排查这个问题要花不少时间,因为重现条件并不稳定。
第三个坑:EXEC 返回的 List 可能为 null,也可能为空列表。这里要看具体客户端实现。Jedis 在事务被放弃时返回 null,Spring Data Redis 的 exec() 在某些版本也可能返回空列表。所以判断条件不要只写results == null,要同时判断 size。我上面的代码写的是results != null && results.size() == 2,这个写法相对稳妥。如果你发现返回的 size 不是事务命令数量,检查一下是否有命令执行错误,Redis 事务在执行过程中如果某条命令有语法错误,整个事务会放弃;如果是运行时错误(比如对 string 类型执行 list 操作),其余命令会继续执行,但结果里会有一个 Error 对象,需要单独判断。
再讲一个容易忽视的细节:重试循环里,第二次循环开始时要重新 GET、重新 WATCH。还有一点要注意,RedisTemplate 的 watch 和 multi 是在同一个连接上操作的。Spring Data Redis 默认每次操作从连接池里拿连接,用完归还。如果你在同一个事务里多次调用 redisTemplate,RedisTemplate 内部是通过线程绑定连接的方式保证同一个连接,这个设计是好的,但要小心:如果你的业务代码里调用了SessionCallback同时又混用了独立的RedisTemplate方法,可能会出现连接不一致的情况。
另外,重试次数设多少?我建议 3 次以下。WATCH 乐观锁重试的上限本质上是一个业务容忍度问题。如果重试 3 次都失败,说明冲突已经很激烈了,再重试只会给 Redis 增加无谓的负载。你可以换一种策略:把重试失败后的操作转成异步消息队列,让后台慢慢重试,避免把压力堆积在用户请求链路上。我在积分商城遭遇过一次瞬时峰值,重试 3 次全部失败的概率大概 0.1%,这个量级可以接受,无需做异步降级。
4. WATCH、SETNX 分布式锁、Lua 脚本到底怎么选
很多人看到 WATCH 性能低,第一反应是“那我换成分布式锁(SETNX)行不行?”我的回答是:先别急,分布式锁不是万能药,而且它的坑比 WATCH 更多。
这里做一张对比表,很直观:
| 方案 | 原子性保证 | 网络往返 | 冲突处理 | 典型场景 | 最大风险 |
|---|---|---|---|---|---|
| WATCH 乐观锁 | 事务级,依赖冲突检测 | 4+N 次 | 返回 nil,客户端重试 | 低并发、多 key 联动 | 高冲突率下重试风暴 |
| SETNX 分布式锁 | 加锁+业务+释放锁,三者非原子 | 3+ 次再加业务调用 | 获取锁失败则等待或返回 | 多服务互斥操作、定时任务 | 锁过期导致并发穿透,误删锁 |
| Lua 脚本 | 服务端原子执行,无需担心并发 | 1 次 | 不冲突就写成功,返回结果 | 高并发、读写组合固定 | 脚本复杂度上升,调试成本 |
关键区别在“原子性”的粒度。WATCH 是事务级的原子性,它保证的是“从 Watch 开始到 Exec 之间没有被别人动过”,但代价是如果被动了,整个事务作废,要重来。SETNX 分布式锁的原子性依赖锁本身,但“加锁、执行业务、释放锁”这三个步骤之间如果有网络问题或超时,锁就过期了,另一个线程可能拿不到锁而进入临界区。这个问题比 WATCH 的重试更隐蔽,更难排查。
举个例子说明。你有一个库存扣减操作,用 SETNX 加锁:
Boolean locked = redisTemplate.opsForValue().setIfAbsent("lock:stock", "1", Duration.ofSeconds(10)); if (locked) { try { // 扣减库存 } finally { redisTemplate.delete("lock:stock"); } }这段代码有三个问题:第一,如果扣减库存耗时超过 10 秒,锁自动过期,另一个线程进来,并发就发生了;第二,如果 finally 中删锁时锁已经过期,会把别人新建的锁误删掉(解决方式是给锁 value 设置唯一标识,删除前先比对);第三,如果持有锁的线程在加锁后、扣减前发生 JVM 卡顿或 Full GC,锁过期,后续线程全部涌入。这些问题在低并发下几乎不可能暴露,高并发下一旦出现就是事故级问题。
反观 WATCH,它没有锁超时概念,不存在“锁过期”这种状态,冲突了就告诉你 nil,语义简单明了。
那既然 Lua 一次网络往返就能搞定,为什么不全用 Lua?因为 Lua 脚本适合“读写逻辑固定、不依赖外部业务参数”的场景。比如“库存减一且不能小于零”这种经典操作,用 Lua 写是十几行的事。但如果你的业务里需要调用外部接口、查询复杂对象、做多维计算,把这些逻辑搬进 Lua 会非常痛苦,而且 Redis Cluster 模式下 Lua 脚本访问的 key 必须在同一个 slot,限制了脚本的适用范围。所以 Lua 不是 replacement,而是一种“当你发现 WATCH 重试率太高,且业务逻辑能收敛为一个简单脚本”时的优选方案。
我自己的选型思路很简单,按顺序问自己三个问题:
- 业务核心是不是一个“读-判断-写”组合?如果是,优先考虑 Lua 脚本,因为最省事性能也最好。
- 是否需要多个 key 联动、业务逻辑复杂、无法用脚本表达?那就考虑 WATCH。
- 是否涉及多服务之间互斥执行(比如只有一个服务可以跑定时任务)?这时候 WATCH 就没有意义了,因为冲突不是同一份数据造成的,而是“互斥资源”造成的,需要分布式锁。
不要一上来就 Redisson、Redlock 那一套。分布式锁是最后手段,因为它维护成本最高,而且一旦配置不对,带来的问题比解决的问题多。
5. 常见问题排查与避坑记录
这一部分直接上速查表,都是我实际踩过坑或者排查过的案例。
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| EXEC 一直返回 null,代码看不出问题 | WATCH 和 MULTI 之间插入了其他 Redis 命令,导致 WATCH 被清除 | 严格检查 WATCH 到 MULTI 之间有无线程池内共用连接、有无调用 RedisTemplate 其他方法 |
| 事务结果里某个命令的值是 null,走了失败分支 | Redis 事务中命令是延迟执行的,中间命令返回值不可用 | 不要在 multi 里依赖中间结果做业务判断 |
| 两个客户端同时扣减库存,库存居然变负数了 | 没有正确使用 WATCH,或者 key 序列化不一致导致监视对象不同 | 统一使用 StringRedisSerializer,确认 WATCH 的 key 与事务操作的 key 完全一致 |
| 使用 Redis Cluster 时报 CROSSSLOT 错误 | WATCH 多个 key 不在同一个 slot,Redis Cluster 不支持跨 slot 事务 | 使用 hash tag:{stock}:001等,让多个 key 落在同一 slot |
| 连接池下事务结果不稳定,有时成功有时失败 | 连接被复用,导致上一个线程的 WATCH 状态残留 | 确认 Spring RedisTemplate 同一线程绑定连接;若手动管理连接,务必在 finally 中 reset 或关闭 |
| 重试率莫名升高,但业务无变化 | 可能存在某段代码频繁调用 Redis 导致 key 频繁变动;或者连接池压力大导致事务窗口变长 | 排查除业务逻辑外是否有日志、监控、统计逻辑也在写同一个 key |
补充三个我踩过的最深的坑。
第一个坑是“WATCH 被同连接的其他命令清掉”。当时写了个批量导入工具,用线程池并发处理,每个线程自己 new 了一个 Jedis 连接,但线程池复用连接。第一次跑没问题,第二次跑就发现 EXEC 疯狂返回 null。排查后定位到原因是:一个线程执行完一次 WATCH 事务后,没有调用jedis.watch(...)清空监视,下一个线程复用这个连接后,旧 key 的 WATCH 状态还在,再加上新 WATCH,导致事务冲突判断异常。解决办法是在事务结束后调用jedis.unwatch()或者直接关闭连接。
第二个坑是“事务里某个命令报错,EXEC 不返回 null,而是返回部分结果 + Error”。Redis 事务在遇到运行时错误时不会回滚,其他命令照常执行。比如你在事务里对同一个 key 先 decrement 再 incr,中间写错类型,那 decrement 成功、incr 失败,EXEC 返回的 List 里会有一个 Error 对象。如果代码只判断results == null,就会误判为事务成功。正确做法是对结果逐条检查是否包含异常信息。
第三个坑和“连接与线程模型”有关。Spring Data Redis 的multi()和exec()必须发生在同一个线程中。如果你在 multi 之后调用了一个异步方法,或者把 RedisTemplate 传给了子线程执行,连接状态就会错乱。这个问题在代码 review 阶段很难发现,只有在特定时机才会触发。建议项目中指定 RedisTemplate 只用于同步调用,禁止跨线程使用。
再补充一个判断小技巧:如果生产环境 WATCH 重试率突然飙升,先别急着改代码,用redis-cli monitor观察一段时间,看是哪个命令在频繁触发 key 变更。我有一次排查发现是监控 Agent 每秒钟执行了一遍GET和SET到同一个 key,每分钟更新一次业务配置,把 WATCH 的冲突率拉高了十几倍,业务本身根本没变。
最后分享一个我个人的判断标准,也是我开头说的那次项目争吵的结论:如果你的业务 QPS 在几十到几百这个量级,且操作同一个 key 的并发窗口很短(事务内命令数少、耗时低),那就放心用 WATCH,它带来的代码简洁性和无锁语义,远大于那点性能损耗。如果 QPS 上千,且重试率超过 10%,直接考虑用 Lua 脚本原子化“读-判断-写”的逻辑。分布式锁只有在需要跨服务互斥时才引入,并且一定要做好锁超时、唯一标识、自动续期这三件事。
我后来把积分兑换的 WATCH 方案保留了下来,因为它的重试率长期在 3% 左右,完全没必要上更重的锁。而库存扣减这种高频场景,我换成了 Lua 脚本,单个库存 key 的 QPS 从 200 提到了 2000,还稳得很。这就是我的真实结论:WATCH 不是不能用,而是要知道它什么时候好用、什么时候该换。希望这篇分享能帮你少走弯路。