资讯动态

分布式锁为何会失效?与乐观锁组合兜底高并发数据一致性

发布时间:2026/9/30 4:24:40 来源:尧图企业网站定制
上个月线上一个库存扣减接口压测到 300 并发的时候监控里出现了莫名其妙的超卖数据。查日志的时候发现一个很反直觉的现象每个线程拿分布式锁都成功了Redis 里的锁也确实是互斥的但库存就是扣错了。后来定位到根因才发现问题不在于分布式锁有没有生效而在于分布式锁生效了也不能保证数据层不被并发写穿。这个案例特别适合回答一个高频面试题也是很多团队引入分布式锁之后最容易忽视的一环既然代码里已经加了分布式锁为什么还要在数据更新那一步再叠一个乐观锁先说结论后面细讲分布式锁负责的是“并发调度”乐观锁负责的是“数据正确性”。这两者负责的层级完全不同不能用其中任何一个替代另一个。分布式锁本身存在多级失效场景——单机宕机、主从切换、集群故障转移、锁过期、删除锁时误删别人的锁……任何一种情况发生都意味着锁的互斥语义被打破多个线程会同时进入临界区。这时候如果没有乐观锁在数据层兜底轻则多扣库存、重则订单金额串号。这篇文章我就从三种 Redis 部署形态逐一拆把分布式锁“为什么靠不住”这件事讲透再把和乐观锁组合使用的完整设计与代码写出来。1. 分布式锁在业务代码中的真实定位1.1 分布式锁到底解决什么问题分布式锁的核心语义是“互斥”也就是同一时间只允许一个线程进入临界区。单机 JVM 里我们用synchronized或者ReentrantLock加锁的对象是同一进程内的线程但在微服务架构下服务部署了多个实例同一份库存数据同时被不同机器上的线程处理JVM 锁就彻底失效了。这时候需要一个所有服务实例都能访问的公共组件来做协调Redis 因为高性能、低延迟成了最常用的选择。我见过不少团队把分布式锁当成了“万能药”只要接口有并发问题就上一个锁觉得锁一加万事大吉。但理解要再深一层分布式锁本质上是在分布式环境下模拟临界区它要求 Redis 在任何时候都是可靠的、一致的。而 Redis 是一个以高性能为核心的组件它设计上的默认选择是“最终一致”而非“强一致”这个根本矛盾决定了分布式锁存在先天性的脆弱面。分布式锁不是银弹它是一种“高概率有效”的互斥手段而不是“百分之百保证”的数据防线。1.2 先想清楚业务场景锁保护的是什么在决定用分布式锁之前先问自己我锁的这个东西是什么是数据库的一行记录还是外部接口的调用权还是本地缓存的一个 key这个答案决定了后续要考虑的一致性模型。以最典型的库存扣减举例业务逻辑是查库存 → 判断是否充足 → 扣减库存 → 返回结果。整个过程是典型的“读-判断-写”三段式如果不做任何控制并发请求可能全部读到同一个库存余额然后同时扣减最终数据库里的值就错了。加了分布式锁之后同一时刻只有一个线程能执行这三步互斥性在“正常情况下”是有保障的。但“正常情况”是有条件的。Redis 如果发生故障转移锁可能凭空消失锁的过期时间如果设置得太短业务还没执行完锁就自动释放了如果在释放锁的时候没有校验是不是自己加的锁还可能把别人刚拿到的锁给释放掉。这些问题的共同点是并发调度失效了多个线程开始并发执行同一段逻辑。这时候如果没有底层数据的校验机制错误就会真正落到数据上。1.3 为什么说分布式锁是“尽力而为”的“尽力而为”这个词可能不好听但在分布式环境下它非常准确。Redis 官方对分布式锁的定位从单机 SETNX 到 Redlock 方案都强调“在大多数情况下能够保证互斥”从来没有承诺过绝对。为什么不能绝对因为 CAP 理论在背后起作用——分区发生时Redis 选择的是可用性和性能而不是一致性和可靠性。所以我认为正确的工程姿态是这样的分布式锁用来降低冲突概率、提升系统吞吐而不是用来作为数据正确性的最后防线。数据正确性的最后防线应当落在数据库层面也就是乐观锁、唯一索引、数据库行锁等这类“一旦生效就从语义上保证正确”的机制。这也是我推荐“分布式锁 乐观锁”组合的根本原因。2. Redis 单机模式下的锁持久化就是最大的隐患2.1 单机锁看起来最安全其实并不很多人觉得单机 Redis 没有主从、没有集群网络层面不会出现脑裂分布式锁肯定万无一失。这个想法只对了一半。单机模式下最大的变量不再是网络分区而是 Redis 进程本身的生存周期——崩溃、重启、数据丢失。分布式锁在 Redis 里的本质就是一条带过期时间的 key。拿 Redis 自带的锁命令来说最常用的是SET key value NX PX 30000意思是只有这个 key 不存在时才设置成功并且 30 秒后自动过期。这段命令是原子的单机 Redis 下互斥性确实没有问题——只要这个 key 还在。问题恰恰出在“key 还在”这个前提上。如果 Redis 进程突然崩溃恢复之后内存里还有没有这条锁记录答案取决于持久化配置。2.2 没开持久化锁随着进程一起归零生产环境里确实还有不少公司图省事Redis 只当作纯缓存用save和appendonly全部关闭。这种情况下Redis 宕机后重启内存里的锁 key 就是零条任何客户端过来都能立刻拿到锁。表面上业务恢复得很快实际上所有并发请求已经同时冲进了临界区。有人会说Redis 宕机期间服务调用肯定已经大量报错了业务早就停了等恢复之后重新拿锁不就好了。这个观点忽略了时序问题Redis 宕机的那一瞬间可能已经有线程 A 持锁进入了临界区正在执行库存扣减的“读-判断-写”流程。Redis 重启后线程 B 发现锁没了立刻拿锁进入同一段代码。于是线程 A 和线程 B 在同一份数据上并发操作库存数据就错乱了。2.3 开了 AOF丢失一秒钟的锁如果不关闭持久化最常见的配置是 AOF 的appendfsync everysec。这个策略的含义是每秒钟将缓冲区中的写命令刷到磁盘最多可能丢失最近 1 秒的写入数据。在正常业务下丢失 1 秒的缓存数据无关紧要但在分布式锁场景下这 1 秒的窗口期足以让锁 key 一并丢失。我们来推演一下这个窗口线程 A 执行SET lock_sku_001 value NX PX 30000成功Redis 把这个写命令缓冲起来准备下一秒 fsync。就在这个瞬间Redis 进程崩溃。重启后缓冲区的命令丢失锁 key 不存在。线程 B 在 Redis 恢复后立刻尝试获取锁成功。而线程 A 可能还在处理业务甚至它自己都不知道锁已经没了。由此产生的并发问题不太容易通过日志去排查因为看起来每一步操作都是“正常”的。具体来看Redis 的 AOF 有三种刷盘策略对分布式锁的影响差异很大AOF 策略fsync 时机最多可能丢失的写命令对分布式锁的影响always每条写命令都刷盘0锁记录基本不丢但性能下降严重everysec每秒刷一次最近 1 秒的写命令锁 key 可能丢失窗口期约 1 秒no交给操作系统调度不确定可能较多锁丢失风险最高不推荐很多人觉得把 AOF 调成always就能彻底解决但实际生产里很少有人这么干因为每条命令都 fsyncRedis 的写吞吐会大幅下降可能连每秒几万次的写入都扛不住有些场景甚至会掉到几千。高性能和强一致在 Redis 单机上是不可兼得的这也是后面要引入乐观锁来兜底的核心原因之一。2.4 锁过期导致的提前失效单机 Redis 正常运行时锁还会遇到另一个问题过期时间到了但业务还没做完。假设一个复杂的订单处理流程需要 5 秒你给锁设置的过期时间是 3 秒那么第 3 秒锁自动消失另一个线程立刻拿到锁进来了。两个线程同时操作同一订单数据结果自然不可预期。我们通常会把过期时间设置得宽松一些比如业务平均耗时 50ms就设置 3 秒过期。但高并发峰值下GC 停顿、网络抖动、数据库慢查询都可能让业务耗时飙升到超过过期时间。这个概率不是零只要发生了分布式锁就失效了。后面要讲的“锁续期”也叫看门狗机制可以缓解一部分问题但它也只能降低概率不能根除。3. 主从复制架构下的锁异步复制是致命伤3.1 主从复制为什么会让锁失效单机 Redis 能扛的写入量有上限而且单点故障会导致整个服务不可用。于是就有了主从复制架构一个主节点负责处理写请求多个从节点同步数据并可以承担读请求。如果主节点挂了哨兵Sentinel会自动把某个从节点提升为新的主节点这个过程被称为故障转移。这套设计对缓存来说问题不大但对分布式锁来说存在一个天然的窗口。Redis 默认的主从复制是异步的主节点收到写命令后先返回给客户端“写入成功”再异步把命令同步到从节点。异步带来的问题在分布式锁场景下被放大了线程 A 向主节点写入锁 key主节点返回成功。这条锁 key 的写命令还没来得及同步到从节点。主节点突然宕机Sentinel 从从节点中选举出新的主节点。新的主节点内存里没有锁 key。线程 B 向新主节点请求同一把锁成功获得锁。线程 A 和线程 B 同时在临界区执行互斥被彻底打破。从第 3 步到第 5 步整个窗口期可能只需要几百毫秒。而对一个高并发的库存系统来说几百毫秒足够成千上万个请求穿过这道“破锁”了。3.2 哨兵故障转移的几个阶段Sentinel 的故障转移流程可以拆成几个阶段每个阶段都会耗费时间但在锁的场景里只要锁记录已经丢了阶段长短并不重要。重要的是意识到“主从切换”不是瞬间完成的期间客户端会经历一段不可用状态。这个时间窗口里如果业务代码没有做好重试和补偿就会出现锁丢失和请求失败同时发生的情况。这里有个很重要的点Sentinel 提升新主它只会保证数据不丢得太多不会保证数据不丢。Sentinel 的min-replicas-to-write参数可以要求主节点至少同步到 N 个从节点才允许写这可以在一定程度上降低锁丢失的概率但它只是降低了概率并不能彻底消除。只要复制是异步的从节点在同步完成前被提升为主节点理论上就肯定会丢写命令。这是原理层面的限制不是调参能解决的。3.3 Redlock 方案为什么不是银弹谈到分布式锁和主从复制绕不开 Redis 官方提出的 Redlock 算法。Redlock 的思路是不要只在一个 Redis 节点上加锁而是同时在多个独立的 Redis 节点上加锁只有超过半数节点加锁成功才算真正获取到锁。它的假设是即使某个节点挂了其他节点还在正常服务锁信息不会全部丢失。Redlock 比单节点锁要可靠得多业界也确实有公司在用。但它有两个绕不开的争议点。第一它要求部署多个完全独立的 Redis 节点成本和管理复杂度都不低第二它建立在“各节点时钟基本一致”的假设上如果某个节点的时钟出现跳跃锁的过期时间计算就可能出问题。著名分布式系统专家 Martin Kleppmann 写过一篇非常出名的文章专门怼 Redlock核心观点就是 Redlock 依然不能保证绝对的互斥尤其是在 GC 暂停和时钟跳跃的情况下。Redis 作者 antirez 也写了长文回应承认了部分限制但依然认为 Redlock 在工程上是可用的。我的看法是Redlock 可以做但它属于“提高概率”而不是“保证正确”。对大部分业务来说更务实的做法是分布式锁用单节点或哨兵模式即可保持简单接受它低概率失效的现实然后靠数据库乐观锁来做数据层面的兜底。这个组合性价比远高于直接上 Redlock。3.4 主从复制兼谈哨兵与集群模式的本质区别顺便澄清一个高频概念混淆Redis 哨兵模式和集群模式是两套不同的架构。哨兵模式架构是“一主多从 哨兵进程”它解决的是高可用和读扩展问题数据仍然都存储在各节点上同一份数据在主从之间全量复制。集群模式则是数据分片整个 Redis 数据被按 slot 分散在多个主节点上每个主节点又可以有从节点。这两种架构下分布式锁的失效逻辑有相通之处后文我会继续分析集群模式对应的场景。搞不清这两个架构的区别面试的时候一深入就容易露怯更重要的是在排查线上问题时容易定位错方向。4. 集群模式下的锁分片带来的新问题4.1 数据分片让锁的“主节点”也可能变Redis Cluster 集群采用了虚拟槽分区每个 key 通过 CRC16 算法计算出一个哈希值然后对 16384 个槽取模最终落到某个槽对应的主节点上。如果业务里锁的 key 是lock:sku:001它会固定落在某个槽、某个主节点上。这个主节点自身又带着若干从节点当主节点故障时从节点会晋升为新的主节点。集群模式的锁失效逻辑和主从复制本质上是一样的都是因为复制是异步的主节点宕机前没来得及同步的锁记录在从节点晋升后就是缺失的。但集群模式有一个额外的复杂度你要拿锁的 key 分布在不同槽上跨槽操作会带来额外的限制和性能损耗。4.2 集群模式下锁的常见陷阱第一个陷阱是锁 key 的选择。如果你用多个 key 组合作为锁的条件比如同时锁sku:001和sku:002这两个 key 大概率落在不同的 slot 上Redis Cluster 对多 key 操作有严格的限制需要强制将它们关联到同一个槽否则命令会报错。这给分布式锁设计带来额外的复杂度也更难保证互斥。第二个陷阱是集群的在线扩缩容。当集群进行 slot 迁移或者某个主节点因为负载过高触发了故障转移锁记录同样可能跟着丢。这些事件在运行多年的集群里并不罕见每一次故障转移都是分布式锁的一次“失守机会”。第三个陷阱是集群中如果某个主节点的从节点长时间没有完成数据同步那么在主节点故障后晋升的新主节点会丢失更多的写命令。健康检查只会告诉你“节点已恢复”不会告诉你“锁已丢失”。4.3 锁的秒级失效高并发下的放大效应无论是主从复制还是集群模式锁丢失造成的影响和高并发是相乘的关系。单次锁丢失的概率如果是千分之一平时流量小可能完全感受不到问题但到了秒杀、大促、活动高峰所有流量集中在同一个热点 key 上千分之一的概率会被放大到几乎每天都会触发。我见过一个真实的案例一个电商平台的大促入口页所有用户点击“抢购”按钮后后端都去 Redis 抢同一把锁流量峰值能到每秒几万。某个晚上 Redis 主节点因为内存碎片过多被系统 OOM killer 杀掉Sentinel 在几秒内完成了切换。切换本身很顺利但主节点上的锁 key 全部丢了切换完成后所有请求都成功拿到了锁接下来的几十秒里库存表被并发写了个一塌糊涂。事后复盘的时候大家发现其实数据库层面的一条UPDATE ... WHERE version?就能挡住绝大多数错误但因为当时只上了分布式锁数据库层面完全没有防护。这个案例让我至今坚持一个原则所有重要的写操作数据库条件更新 / 乐观锁是标配分布式锁只是可选的加速器。5. 为什么乐观锁是数据层最可靠的兜底5.1 乐观锁的底层逻辑乐观锁本质上是利用数据库的版本控制机制在数据更新时校验数据在读取之后有没有被其他人改过。最常用的实现方式是在表中增加一个版本号字段version每次更新数据时版本号加一并在 UPDATE 语句的 WHERE 条件中带上原版本号。如果更新影响的行数为 0说明读取的版本号与数据库当前版本不一致意味着数据已经被其他事务修改本次更新应当失败或重试。这条 SQL 的逻辑可以理解为UPDATE t_inventory SET inventory inventory - 1, version version 1 WHERE sku_id #{skuId} AND version #{oldVersion} AND inventory 1;为什么不直接UPDATE t_inventory SET inventory inventory - 1 WHERE sku_id ? AND inventory 1其实这种写法在很多场景下就够了因为它利用的是“条件更新 原子操作”数据库层面的 UPDATE 是原子的并且 WHERE 条件能保证库存不小于零并发执行时只有一个请求能成功。但这只适用于库存这类可以“直接在 SQL 里描述约束”的场景。更通用的业务逻辑往往是先查数据、在代码里做复杂判断比如检查用户等级、校验优惠券状态、计算分摊金额然后再写回数据库。这种情况下从读到写之间隔了多层逻辑必须把“读那一刻的数据版本”记下来写的时候校验。这就是 version 字段存在的意义。5.2 乐观锁和分布式锁的分工理解了各自的作用边界就能明白为什么说它们是“组合拳”而不是“二选一”。分布式锁在入口处做并发调度把绝大多数并发请求挡在临界区之外让系统在高并发下不会瞬间打满数据库而乐观锁在数据写入时做最终校验万一分布式锁因为各种原因失效了乐观锁也能阻断错误的并发写。我用一个表格把两者做一个直观对比对比项分布式锁乐观锁作用层面应用层代码执行过程数据库数据更新过程核心能力互斥同一时间只允许一个线程执行校验版本不一致时拒绝更新失效场景锁丢失、过期、误删、主从切换版本冲突时宁可失败也不写入错误数据保护对象整段业务逻辑单条或多条数据记录对数据库的压力无直接压力但会提升代码复杂度每次更新多一条 WHERE 条件开销极小从这张表可以看出分布式锁保护的是“过程”乐观锁保护的是“结果”。过程可以被打断结果必须正确。所以方案设计时的优先级非常明确先确保结果正确再考虑过程是否高效。5.3 乐观锁的两种使用姿势乐观锁在工程落地时有多种姿势最简单的是版本号字段稍微复杂一点的可以基于数据库的UPDATE行锁来实现类似效果但那更接近悲观锁的思路也有的场景直接使用 Redis 自身的WATCH命令配合MULTI/EXEC做事务但那要求业务逻辑与 Redis 事务的语义完全匹配局限性比较大。我最推荐的是数据库版本号方案。它有两个优点第一数据库本身是最终的数据归属地在数据写入时做校验语义最贴合第二实现成本极低加一个字段、写一句 WHERE 条件就行不需要引入额外的组件。另一个常见方案是使用数据库的乐观锁函数比如 PostgreSQL 的xmin系统字段但这对团队的技术栈有要求遇到 MySQL 为主的团队还是老老实实加version字段更通用。5.4 版本冲突时怎么办乐观锁在冲突发生时不会默默通过而是告诉你“写不进去”。这时候业务需要决定是重试、报错、还是做补偿。重试策略的设计要小心如果冲突率很高重试反而会加剧数据库压力。通常情况下我推荐在业务层面判断冲突次数超过阈值就返回用户“操作太频繁请稍后再试”。需要注意的是如果冲突频繁发生说明分布式锁大概率失效了这时候要去排查锁的问题而不是盲目调大重试次数。锁正常工作时乐观锁冲突率应当极低可能不足 0.1%。如果线上监控发现乐观锁冲突率持续偏高那这是一个重要的预警信号提示你分布式锁的可用性出了问题。6. 一份可以直接抄的“分布式锁 乐观锁”完整代码6.1 锁服务封装与使用要点下面的示例使用 Java 语言Redis 客户端用 Jedis数据库访问用 MyBatis-Plus。核心逻辑包括加锁时用SET key value NX PX原子命令锁的 value 用 UUID 唯一标识释放锁时用 Lua 脚本先比对 value 再删除防止误删别人的锁。Component public class RedisLockUtil { Autowired private StringRedisTemplate redisTemplate; private static final String LOCK_PREFIX lock:; private static final String LUA_SCRIPT if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end; public String tryLock(String key, long expireMillis) { String lockKey LOCK_PREFIX key; String requestId UUID.randomUUID().toString(); Boolean success redisTemplate.opsForValue() .setIfAbsent(lockKey, requestId, Duration.ofMillis(expireMillis)); if (Boolean.TRUE.equals(success)) { return requestId; } return null; } public boolean unLock(String key, String requestId) { String lockKey LOCK_PREFIX key; Long result redisTemplate.execute( new DefaultRedisScript(LUA_SCRIPT, Long.class), Collections.singletonList(lockKey), requestId); return result ! null result 0; } }这里有两个非常容易踩的坑值得展开说说。第一个坑锁的 value 必须唯一。如果不设置唯一标识就可能在以下场景出错线程 A 获取锁因为 GC 停顿导致锁过期自动释放线程 B 获取同一把锁此时线程 A 从 GC 中恢复执行完业务代码直接删锁结果把线程 B 的锁删掉了。加了唯一标识后A 删锁时因为 value 对不上Lua 脚本会返回 0删不掉 B 的锁。第二个坑释放锁必须用 Lua 脚本保证“比对 删除”两步的原子性。如果先 GET 对比再 DEL 删除这两步之间可能被其他线程介入又会出现误删问题。Lua 脚本是在 Redis 服务端原子执行的这是标准做法。6.2 业务层如何把锁和乐观锁串起来接下来看业务层的实现。这个例子模拟库存扣减核心流程是加锁 → 查库存和版本号 → 业务校验 → 带版本号更新 → 判断影响行数 → 释放锁。注意释放锁要放在 finally 中保证锁一定会被释放。Service public class InventoryService { Autowired private RedisLockUtil redisLockUtil; Autowired private InventoryMapper inventoryMapper; public boolean deductStock(Long skuId, Integer count) { String lockKey stock: skuId; String requestId redisLockUtil.tryLock(lockKey, 5000); if (requestId null) { throw new BizException(系统繁忙请稍后再试); } try { // 1. 查询当前库存和版本号 Inventory inventory inventoryMapper.selectBySkuId(skuId); if (inventory null || inventory.getInventory() count) { return false; } // 2. 模拟耗时业务处理 checkUserLimit(skuId); calculatePrice(skuId, count); // 3. 乐观锁更新版本号必须匹配 int rows inventoryMapper.deductByVersion( skuId, count, inventory.getVersion()); if (rows 0) { // 版本冲突说明并发更新了数据需要重试或者直接失败 throw new ConcurrentModifyException(库存数据已变化请重试); } return true; } finally { redisLockUtil.unLock(lockKey, requestId); } } private void checkUserLimit(Long skuId) { // 模拟复杂校验逻辑 } private void calculatePrice(Long skuId, Integer count) { // 模拟复杂计算逻辑 } }对应的 Mapper XML 语句update iddeductByVersion UPDATE t_inventory SET inventory inventory - #{count}, version version 1 WHERE sku_id #{skuId} AND version #{version} AND inventory gt; #{count} /update注意 SQL 里加上了inventory #{count}这个条件。即使版本号匹配如果库存不够也不能扣减这是一个防御性的边界条件必须保留。6.3 重试与降级策略设计上面的示例在版本冲突时直接抛异常这是最严格的做法。实际生产中可以根据业务容忍度做调整。比如阿里云订单类的场景冲突率极低直接让用户重试一次就可以而电商秒杀场景用户重复提交本身就是常态可以在异常时尝试重新获取锁、重新读版本号、再次更新最多重试 3 次。重试里的关键细节是每次重试都必须重新读取 version不能沿用旧版本号否则必然失败。而且重试之间建议加入随机退避避免多个线程同时重试造成新的冲突比如Thread.sleep(50 new Random().nextInt(50))。有一点容易忽略的是如果更新库存时事务还没提交锁就释放了下一个线程可能读到旧版本号。推荐的做法是让“更新数据 释放锁”处在一个事务控制范围内确保数据提交后再释放锁。具体的做法可以是把数据库更新放在一个事务方法中事务提交后再在调用方释放锁。6.4 锁的续期机制前面提到的“锁过期但业务没结束”的问题可以在客户端做续期。Redisson 的看门狗机制是最成熟的实现它默认会为锁自动续期每 10 秒检查一次如果业务还在执行就自动把过期时间重置为 30 秒。自己实现续期也不难可以起一个定时任务在过期时间过半时执行续期业务结束后取消续期。不过续期机制也有自己的问题如果 Redis 故障转移续期也会失败该丢的锁照样丢。所以续期只是降低了“锁提前过期”的触发概率不能根本解决“锁消失”的问题。真正能兜住最后一道防线的始终是乐观锁。6.5 监控与告警建议分布式锁的可用性和乐观锁的冲突率都应该纳入监控体系。我建议至少关注三个指标锁获取成功率正常情况下应该接近 100%如果出现明显下降说明 Redis 可能有问题或者 key 设置得太分散。锁释放失败率释放失败通常意味着 value 不匹配或 key 已过期连续多次失败说明业务耗时超过了过期时间需要调整过期时间或者开启续期。乐观锁冲突率这个指标很有价值。正常情况应该低于 1%。如果冲突率突然飙升不用想大概率是分布式锁失守了要去排查 Redis 节点的稳定性、主从切换记录、锁过期时间等。我们线上就把这三个指标做到了 Grafana 监控面板里配合告警规则每次锁相关的问题都能在几分钟内定位而不是等用户投诉之后才去翻日志。7. 常见问题排查与实战避坑速查表7.1 分布式锁 乐观锁组合常见问题分布式锁和乐观锁的组合本身并不复杂但工程落地时坑非常多。我把这些年见过的高频问题整理出来供参考问题现象可能原因排查思路加了锁之后仍出现数据错乱主从切换导致锁丢失锁过期提前释放查看 Redis 节点故障切换记录检查锁过期时间设置与业务耗时锁释放失败后续请求全部拿不到锁value 不匹配或释放锁时用了非原子操作检查是否使用 Lua 脚本释放锁确认锁 value 是否为唯一 ID乐观锁冲突率突然飙升分布式锁失效重试逻辑设计有问题检查锁获取成功率、Redis 节点状态定位失守窗口扣减成功后库存无变化UPDATE 影响行数为 0检查 version 字段是否匹配检查 SQL 的 WHERE 条件是否完整高并发下数据库压力巨大锁过期导致大量请求同时进入临界区调大过期时间、开启续期、优化业务耗时、增加乐观锁兜底7.2 关于锁过期时间的设置锁过期时间的设置没有万能公式但有一个基本原则在满足最差业务耗时的情况下加上 3 到 5 倍的冗余。比如业务平均耗时 50ms最差耗时 300ms那么 3 秒是一个比较合理的初始值。设置成 30 秒甚至 5 分钟没有必要因为一旦持有锁的节点宕机锁要等那么久才会被释放期间的请求全部被阻塞。如果业务耗时波动大最好的方案是开启自动续期比如 Redisson 的看门狗。但注意看门狗不是万能的它只延长锁的存活时间不会解决 Redis 节点故障带来的锁丢失问题。7.3 不要滥用分布式锁最后提醒一个最容易被忽视的坑不是所有接口都需要分布式锁。如果一份数据的并发写概率本身就极低或者并发写的量级很小数据库自身的行锁就能搞定没必要引入 Redis 分布式锁增加复杂度和运维负担。分布式锁适合的是“高并发 热点数据 多实例部署”这三个条件同时满足的场景。我见过有人一看到接口有并发问题就加锁结果锁加了一堆Redis 压力大了性能反而更差。正确的思路是先评估并发量和数据不一致的严重程度优先用数据库条件更新、唯一索引、乐观锁这类简单的机制去兜底再决定是否需要分布式锁来降低并发冲突频率。分布式锁是锦上添花不是雪中送炭。8. 写在最后分布式锁和乐观锁的分工用一个比方来收尾分布式锁相当于大门的门卫负责控制人流正常情况下一次只放一个人进仓库乐观锁相当于仓库里每件货物上的标签和计数就算门卫失误放进来了两个人货架上的标签会立刻指出“这单数据已经被改过了”第二个人只能空手而归。门卫负责效率标签负责正确。我在实际项目里的惯例是所有写操作先无条件加乐观锁加 version 字段只有在压测验证并发冲突确实频繁并且对性能有明显影响时才引入分布式锁做前置拦截。上线之后观察冲突率和锁获取成功率这两个指标如果冲突率长期在 1% 以下说明分布式锁工作得很健康如果冲突率飙高不要犹豫立刻查 Redis 的故障转移记录和锁过期时间。这套组合拳帮我在多个高并发项目里稳住了数据正确性希望对你也有参考价值。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑