ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

分布式锁与幂等设计实战:从 Redis 锁、ZooKeeper 到防重与去重方案

分布式锁与幂等设计实战:从 Redis 锁、ZooKeeper 到防重与去重方案 摘要高并发下重复下单、重复扣款、消息重复消费是分布式系统的资损重灾区。本文把「分布式锁防并发」与「幂等防重复」的职责边界讲透从 Redis SET NX PX / Redlock、ZooKeeper 临时顺序节点到 fencing token再给出唯一索引、状态机、Token 五大幂等落地方案与 MQ 消费去重附可运行代码与生产避坑清单。导语做过交易、支付、库存系统的同学大概率都踩过这样的坑用户手抖连点两下两张订单生成了支付回调因为网络重试发了三次钱扣了三遍消息队列消费失败被重投同一笔业务被处理了多回。这些问题表面看都像「请求多了」但根因和处理手段其实分两类一类是同一时刻真的有多个执行在抢同一份资源需要互斥即分布式锁另一类是同一个请求被重复投递了多次需要去重即接口幂等。很多人一上来就「加个锁」或者「做下幂等」却没分清该用哪个、怎么组合结果锁没防住重复、幂等也没拦住并发。本文先把这两件事的职责边界理清再逐个拆解 Redis 锁、Redlock、ZooKeeper 锁、fencing token最后落到五大幂等方案与 MQ 去重的工程落地。一、为什么分布式系统既要锁又要幂等先说清楚两个概念各自解决什么问题这是后面所有选型的前提。分布式锁解决的是「同一时刻只允许一个执行」——它保证互斥让并发的多个请求串行进入临界区。它关心的是时间点上的「同时」。幂等idempotency中文常译作幂等性解决的是「同一个请求执行一次和执行多次业务结果保持一致」——它关心的是结果上的「重复」。一个接口做到幂等意味着客户端重试、网关重投、MQ 重发都不会让副作用累积。二者是互补关系不是替代关系。举个库存扣减的例子用锁保证「查库存 扣库存」这段逻辑同一时刻只跑一个防止超卖用幂等保证同一个扣减请求因为网络重试来了三遍库存只真正扣一次而不是扣三次。只加锁不幂等重试照样重复扣只幂等不锁高并发下并发写仍可能击穿校验。所以生产正解通常是「锁做并发串行化 幂等做最终兜底」。典型的三类触发源客户端重复点击、网关/HTTP 超时重试、MQ 的 at-least-once至少一次投递。后两者你拦不住只能让自己「重复来也不怕」。图1锁防并发、幂等防重复二者互补不替代二、Redis 分布式锁SET NX PX 的正确姿势单实例 Redis 实现分布式锁业界公认的正确加锁命令是SET key value NX PX# 加锁key 不存在才设置NX并设置过期时间PX单位毫秒 # my_random_value 必须是全局唯一的随机值用于安全释放 SET lock:order:1001 my_random_value NX PX 30000这里两个参数缺一不可NXNot eXists只有 key 不存在时才写入保证互斥谁先抢到谁持锁PX 30000给锁一个 30 秒自动过期防止持锁客户端崩溃后锁永不释放导致死锁。为什么value要填随机值而不是固定字符串因为释放锁时必须能证明「这把锁是我的」。如果大家都用同一个 valueA 的锁过期了B 拿到了锁A 执行完去释放就会把 B 的锁给删了。释放锁严禁直接DEL必须用 Lua 脚本先比较 value 再删除保证「判断归属」和「删除」是原子操作-- 安全释放锁只有当 value 相等时才删除避免误删他人锁 -- KEYS[1] 锁的 keyARGV[1] 加锁时的随机值 if redis.call(GET, KEYS[1]) ARGV[1] then return redis.call(DEL, KEYS[1]) else return 0 end在 Java 里用EVAL调用这段脚本即可。较新版本的 Redis8.4也提供了DELEX命令可以在单条命令内完成「比较值 删除」语义等价但更省一次往返不过 Lua 脚本的兼容面更广是当前最稳妥的写法。这里有个绕不开的工程坑锁过期时间PX小于业务执行时间。业务还没跑完锁先过期了别的客户端又能拿到锁两个客户端同时进了临界区——这就是「锁失效窗口」。解决办法是引入看门狗watchdog自动续期或在存储层引入 fencing token 兜底见第五节。图2NX 互斥、PX 自动过期防死锁随机 value Lua 才安全释放三、Redlock 算法与它的争议边界单实例 Redis 一旦挂掉锁服务就不可用为了高可用Redis 官方提出了Redlock 算法——在 N 个互相独立的 Redis master 上并行加锁N 通常取 5只有当客户端在多数派≥ N/2 1即 5 个里至少 3 个上都成功拿到锁才算整体加锁成功。加锁流程并行 → 多数派可以概括为Client ├─ SET NX PX ──► Redis-1 ├─ SET NX PX ──► Redis-2 ├─ SET NX PX ──► Redis-3 ├─ SET NX PX ──► Redis-4 └─ SET NX PX ──► Redis-5 │ 获得 ≥ 3 个成功 且 总耗时 锁 TTL → 加锁成功 否则 逐一对已加锁节点释放加锁失败Redlock 成立有两个关键假设系统时钟大致正确因为锁有效性依赖 TTL 减去获取耗时以及加锁网络耗时远小于锁 TTL。这里要提一个著名的争议分布式系统专家 Martin Kleppmann 曾指出在出现 GC 长时间停顿、或者系统时钟发生跳跃的场景下Redlock 并不能严格保证互斥安全他主张用带fencing token的方案替代。社区达成的相对共识是Redlock 适合对一致性要求不极端的场景如防重复任务调度但在强一致、强互斥的金融核心场景应当配合 fencing token 或直接选用 ZooKeeper 这类基于一致性协议的协调服务。理解它的边界比盲目套用重要得多。图3N5 独立 Redis 多数派加锁强一致场景应配 fencing 或换 ZK四、ZooKeeper 分布式锁临时顺序节点方案ZooKeeper 实现分布式锁走的是官方 recipe临时顺序节点ephemeral sequential。思路是这样的所有想抢锁的客户端都在同一个父节点/lock下创建一个「临时 顺序」子节点比如/lock/guid-00000001、/lock/guid-00000002。因为序号是全局递增的序号最小的那一个获得锁。没抢到锁的客户端不去轮询而是对自己序号的前一个节点注册一个 watch监听它是否存在。当前一个节点被删除持锁者释放或崩溃自己被通知再判断是不是轮到自己最小——这样避免了惊群效应herd effect也无需超时和轮询。// 基于 Curator 的分布式锁本质是上述临时顺序节点 recipe 的封装 import org.apache.curator.framework.CuratorFramework; import org.apache.curator.framework.recipes.locks.InterProcessMutex; CuratorFramework client CuratorFrameworkFactory.newClient( 127.0.0.1:2181, new RetryNTimes(3, 1000)); client.start(); InterProcessMutex lock new InterProcessMutex(client, /lock/order-1001); try { // 阻塞获取锁拿到才继续 lock.acquire(); try { // 临界区同一时刻只有一个客户端能进入 doBusiness(); } finally { lock.release(); // 释放删除自己的临时节点 } } catch (Exception e) { // 处理中断 / 异常 }ZooKeeper 锁最大的优点是客户端会话失效时临时节点会被 ZooKeeper 自动删除天然防死锁不需要像 Redis 那样担心 TTL 续期。代价是它依赖 ZK 集群的一致性协调性能和运维复杂度高于 Redis。关于 Redis 内部数据结构如何支撑这类高频读写场景可参考这篇Redis 数据结构底层实现原理而分布式系统中另一块常被一起讨论的协调能力——一致性哈希见这篇一致性哈希原理与实战。图4/lock 下顺序子节点最小号获锁崩溃自动删防死锁五、锁的可靠性陷阱与 fencing token 补强无论 Redis 锁还是 Redlock都绕不开一个根本难题锁的持有者可能因为 GC 停顿、网络延迟、或者锁 TTL 提前过期导致它「以为自己还持锁、但其实锁已经没了」而另一个客户端已经拿到锁。这个重叠窗口叫做「锁失效窗口」。更极端的场景是脑裂split-brain在分布式集群发生网络分区时原本的主节点和从节点互相失联双方都可能认为自己是「主」从而同时对外提供服务、同时持锁造成写冲突。这也是为什么单纯靠「谁持有锁」做互斥在强一致场景并不足够。解决思路之一是fencing token栅栏令牌一个单调递增的数字每次加锁成功协调服务发一个比之前都大的 token下游的存储层数据库/文件在执行写操作前先记录「已见过的最大 token」只接受 token 更大的写遇到更小的旧锁持有者的迟到写直接拒绝。Client-A 获锁拿到 token5 → 写存储「允许记录 seen5」 A 发生 GC 停顿锁过期 Client-B 获锁拿到 token6 → 写存储「允许记录 seen6」 A 恢复拿 token5 再来写 → 存储发现 5 seen(6) → 拒绝防脏写fencing token 从存储层根除了「旧锁迟到写」的冲突是比单纯续期更本质的兜底。工程上常见组合是Redis 锁 看门狗自动续期缩小失效窗口再对关键写加上 fencing 或版本约束单点 Redis 锁不要单独作为强一致锁使用。图5旧锁迟到写小 token被栅栏门拒绝根绝经锁失效窗口六、接口幂等本质多次执行与一次一致讲完锁来到另一半——幂等。它的核心是业务的「结果」与「执行次数」无关。用户点了两下、网关重投了三回最终效果应当和只来一次完全一样。落地幂等有三要素业务唯一键能标识「这一次请求是哪一笔」的字段比如订单号、支付流水号、请求指纹去重规则拿唯一键去查「是不是已经处理过」状态流转约束处理过的状态不允许被重复改写。最常见的更新类幂等写法是乐观锁用「旧状态」做条件受影响行数为 0 即视为已处理/重复-- 乐观锁幂等只有当前状态是 待支付 时才改成 已支付 -- 重复请求进来时状态早已不是 待支付影响行数 0天然幂等 UPDATE orders SET status PAID, paid_at NOW() WHERE order_no 202601010001 AND status UNPAID;要注意前端按钮置灰、防抖只是体验层优化拦不住服务端的超时重试和第三方回调绝不能作为安全方案。真正可靠的是服务端基于唯一键的去重与状态约束。这里还有一个细节值得提ABA 问题——在 CAS / 乐观锁场景里某个字段从 A 变成 B 又变回 A校验者以为「没变化」而放行其实中间已经发生过业务。用单调递增版本号每改一次 1而不是用业务值本身比对就能规避 ABA。七、五大幂等落地方案选型把主流的幂等方案摆在一起对比才能知道什么时候该用哪个。方案适用场景优点缺点 / 边界① 数据库唯一索引新增类下单、注册兜底零侵入、绝对可靠只拦重复插入拦不住更新② 业务流水号 Redis SET NX EX高并发前置挡板性能好、原子预占需唯一索引二次兜底③ 状态机幂等订单 / 支付核心状态单向流转语义清晰需设计好状态图④ Token / 幂等号防重复提交一次性令牌专治重复点击需前端配合申请⑤ 乐观锁 / 版本号更新类并发实现简单、无锁高冲突下有重试开销先看最可靠的兜底——给业务唯一键建唯一索引-- 幂等兜底对订单号建唯一约束重复插入直接报唯一键冲突 CREATE TABLE order_idempotent ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(64) NOT NULL, biz_type VARCHAR(32) NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_order_no (order_no, biz_type) );Token 机制则专门对付「重复提交」客户端先向服务端申请一个一次性令牌真正的写请求必须携带这个令牌服务端校验通过后立即作废重复携带同一个令牌的请求会被直接拒绝。// 幂等号校验用 Redis SET NX EX 原子抢占抢占成功首次失败重复 public boolean tryAcquireToken(String token, long expireSeconds) { // SET token value NX EX不存在才写入自带过期原子操作 String result jedis.set(idemp: token, 1, NX, EX, expireSeconds); return OK.equals(result); // 返回 OK 表示首次null 表示已存在重复 }组合建议是Redis 挡并发前置挡板 状态机限流转核心业务 唯一索引兜底最后防线三层叠加才能在生产稳得住。图6三层防线叠加高并发挡并发、核心限流转、唯一索引兜底八、MQ 消息去重与消费端幂等消息队列几乎都采用at-least-once至少一次投递语义消费者处理失败、ACK 超时、网络抖动 broker 都会重新投递所以「同一条消息来多次」是常态而非异常。因此消费端必须自己保证幂等不能把希望寄托在「消息只来一次」。常用手段是基于业务唯一 ID订单号 / 流水号做去重。最直观的是 Redis 原子抢占# 消费前先用业务唯一键抢占抢占成功才处理失败则跳过已消费过 # SET 不存在才写入NX并设置覆盖重试窗口的过期时间EX SET consumed:order:202601010001 1 NX EX 86400 # 返回 OK → 首次消费继续处理 # 返回 nil → 已消费过直接 ACK 丢弃保证幂等去重还可以按精度分两档精确去重用SET NX或SADDSISMEMBER精确记录每一个已处理 ID零误判但占用内存随量增长近似去重用SETBIT/ Bloom Filter内存极小但存在「误判已存在」的可能不会漏判存在可能误判适合容忍偶发重复的场景。值得一提的是Redis 的 Stream 结构在XADD时支持去重参数同一 producer-name 相同内容可天然去重在基于 Redis Stream 做消息收口的场景里可以省掉一层手写去重。选型时按业务对「误判」的容忍度决定金融核心走精确去重日志类旁路走近似去重。图7消费前用订单号唯一键 SET NX 抢占重复消息直接 ACK 丢弃九、生产落地规范与避坑清单把前面所有点串成一个可落地的标准执行顺序比单点会用某个 API 重要得多。推荐的处理骨架是请求进入 → 1. 参数校验 → 2. 缓存预占Redis SET NX 挡并发 / 防重复提交 → 3. 状态校验是否已处理 / 状态机是否允许 → 4. 业务事务数据库事务内完成核心写 → 5. 落库兜底唯一索引拦截最后的重复插入 → 6. 写成功标记Redis 记录已处理供 MQ 去重避坑清单请逐条对照不要只靠前端防抖服务端重试和回调它拦不住Redis 锁必须配续期 / fencing裸SET NX PX不续期业务跑长一点就失效释放锁必须用 Lua / DELEX直接DEL会误删他人锁唯一索引只拦新增更新类幂等要靠状态机 / 乐观锁Redlock 别当强一致锁用强一致场景加 fencing 或上 ZK可观测要跟上监控锁等待时长、幂等命中率、去重集合增长否则出了问题无从排查。最后再强调一次核心认知分布式锁防的是「并发冲突」幂等防的是「重复副作用」两者常组合而非互斥——锁做并发串行化幂等做最终兜底这才是生产环境里经得起资损考验的做法。参考资料Redis 官方文档Distributed Locks with RedisSET NX PX / 安全释放 / Redlock地址https://redis.io/docs/latest/develop/clients/patterns/distributed-locks/ 要点单实例加锁用 SET key random_value NX PX ttl随机值 Lua/DELEX 安全释放Redlock 在 N 个独立 master 上多数派加锁。Redis 官方文档SET 命令NX/EX/PX 选项与释放模式地址https://redis.io/docs/latest/commands/set 要点SET resource-name token NX EX max-lock-time 实现简易锁释放用比较值后删除的脚本避免误删他人锁。ZooKeeper 官方 RecipesLocks临时顺序节点锁 recipe地址https://zookeeper.apache.org/doc/r3.4.14/recipes.html 要点create ephemeral sequential 子节点序号最小者获锁对前序节点 watch 避免惊群崩溃自动释放。ZooKeeper 官方 Recipes PDF含 Shared/Revocable Lock 细节地址https://zookeeper.apache.org/doc/r3.5.2-alpha/recipes.pdf 要点每个节点只被一个客户端 watch 避免 herd effectGUID 处理 create 成功但响应丢失的可恢复错误。Redis 官方博客Idempotency patterns with Redis幂等键 / Lua / 缓存地址https://redis.io/blog/what-is-idempotency-in-redis/ 要点SET idempotency:req NX EX ttl 原子 check-and-setLua 处理多状态请求级幂等键防重复调用。Redis 官方教程Data deduplication with RedisSET NX/SADD/Bitset地址https://redis.io/tutorials/data-deduplication-with-redis/ 要点精确去重用 SET NX SADD SISMEMBER近似去重用 SETBIT/BITCOUNT可误判TTL 覆盖重试窗口。CSDN接口幂等怎么设计一次讲清重复提交、支付回调、幂等键与防重落地地址https://blog.csdn.net/weixin_46026202/article/details/160198709 要点推荐「业务唯一键 状态机 数据库唯一约束 必要时 Redis 前置防重」组合唯一索引做最终底线。CSDN防重放与接口安全设计四大幂等落地方案优先级地址https://blog.csdn.net/wbkang/article/details/162557302 要点唯一索引兜底首选/ Redis SET NX EX高并发/ 状态机核心业务/ 分布式锁复杂事务的优先级与标准执行顺序。© 2026 | 转载请注明出处结论PASS
返回列表