
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 也行最少也要四次 RTTRound-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 次操作之间发生时间重叠的概率是多少简单估算每个操作占用 5ms10 个操作总共占用的时间比例是 50ms / 1000ms 5%。也就是说两个操作恰好在这个 5ms 窗口内交叠的概率大约在 5% 左右。这个估算很粗糙因为实际请求有高峰有低谷往往不是均匀分布但足以说明问题低并发下绝大多数事务都能一次成功重试很少发生。我在项目里做过一个统计口径记录乐观锁事务的 EXEC_calls 和 EXEC_abortsEXEC 返回 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.xSpring Data Redis 2.7.xRedis 6.x。RedisTemplate 配置用 StringRedisSerializerRedisTemplate 的 key 和 value 都序列化为字符串这个在工程里最常见不建议在这个场景用 JDK 序列化因为 WATCH 的东西是 keykey 序列化不一致会导致监视失效。核心代码长这样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. 执行事务 ListObject 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 在事务被放弃时返回 nullSpring Data Redis 的 exec() 在某些版本也可能返回空列表。所以判断条件不要只写results null要同时判断 size。我上面的代码写的是results ! null results.size() 2这个写法相对稳妥。如果你发现返回的 size 不是事务命令数量检查一下是否有命令执行错误Redis 事务在执行过程中如果某条命令有语法错误整个事务会放弃如果是运行时错误比如对 string 类型执行 list 操作其余命令会继续执行但结果里会有一个 Error 对象需要单独判断。再讲一个容易忽视的细节重试循环里第二次循环开始时要重新 GET、重新 WATCH。还有一点要注意RedisTemplate 的 watch 和 multi 是在同一个连接上操作的。Spring Data Redis 默认每次操作从连接池里拿连接用完归还。如果你在同一个事务里多次调用 redisTemplateRedisTemplate 内部是通过线程绑定连接的方式保证同一个连接这个设计是好的但要小心如果你的业务代码里调用了SessionCallback同时又混用了独立的RedisTemplate方法可能会出现连接不一致的情况。另外重试次数设多少我建议 3 次以下。WATCH 乐观锁重试的上限本质上是一个业务容忍度问题。如果重试 3 次都失败说明冲突已经很激烈了再重试只会给 Redis 增加无谓的负载。你可以换一种策略把重试失败后的操作转成异步消息队列让后台慢慢重试避免把压力堆积在用户请求链路上。我在积分商城遭遇过一次瞬时峰值重试 3 次全部失败的概率大概 0.1%这个量级可以接受无需做异步降级。4. WATCH、SETNX 分布式锁、Lua 脚本到底怎么选很多人看到 WATCH 性能低第一反应是“那我换成分布式锁SETNX行不行”我的回答是先别急分布式锁不是万能药而且它的坑比 WATCH 更多。这里做一张对比表很直观方案原子性保证网络往返冲突处理典型场景最大风险WATCH 乐观锁事务级依赖冲突检测4N 次返回 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 不在同一个 slotRedis 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 不是不能用而是要知道它什么时候好用、什么时候该换。希望这篇分享能帮你少走弯路。