资讯详情

资讯详情

订单幂等三层防线:数据库锁、Redis分布式锁与流式计算去重实战

上周五晚上23:07促销活动刚开始三分钟监控群里同时跳出三条告警订单库写入延迟飙升、支付回调重复入账、Redis 锁超时释放。我一边翻日志一边看见同一笔订单在半小时内被处理了五次——用户手滑连点、网关超时自动重试、MQ 消息重放。账面上多扣了三笔钱库存多减了两件。这就是订单幂等没做好的典型现场。今天不聊概念只讲我实际搭过、也踩过坑的一套“订单幂等三层防线”第一层是数据库行锁和唯一约束第二层是 Redis 分布式锁和红锁RedLock第三层是分布式流式计算里的去重与状态机。这套组合打完之后重复下单、重复扣款、重复发券这类问题基本都能被按死在各自层。下面把方案、代码、参数和踩过的坑一次性说清楚。1. 为什么说订单幂等是一场“生死局”1.1 一次线上事故回放那次事故让我印象特别深。活动页只开放了一个商品库存五百件前十分钟涌入的流量就把订单服务打到了临界值。用户端提交订单时前端按钮没有做防抖下单接口超时后网关层又自动重试了一次后端发现库存不足就直接返回失败但异步消息队列里那条“支付成功”的消息因为消费者处理太慢被重复拉取最终同一个order_no被写进了支付流水表三次。事后复盘时发现问题并不在某个单一环节而是每一层都默认“下游会帮我做幂等”。前端以为后端会拦截重复提交后端以为消息队列不会重复投递消息队列以为消费端会做去重。结果所有环节都漏了同一笔订单就像滚雪球一样被反复处理。这种事故在电商、支付、营销行业里一旦发生轻则资损重则要面临客诉和监管所以我才把它叫“生死局”。1.2 幂等的数学底色与工程含义“幂等”这个词本身就来自数学里的“幂”。一个操作如果执行多次的结果和执行一次完全一致就称它是幂等的。用函数表达就是f(f(x)) f(x)工程上的接口幂等也是一回事同一个请求用同一个业务标识重复调用 N 次系统的状态只会被推进一次。比如创建订单传同一个requestId不管调一次还是调五次数据库里最终只有一笔订单。这里顺带说一个有意思的对照。很多人搜过“快速幂算法C”、“矩阵快速幂”、“2的幂数组”这些是在解决“如何快速计算 a 的 n 次方”的问题。经典写法长这样long long quickPow(long long a, long long n, long long mod) { long long res 1 % mod; while (n 0) { if (n 1) res res * a % mod; a a * a % mod; n 1; } return res; }快速幂通过二进制拆解把 n 次乘法压缩成 O(log n) 次本质上是“把重复计算压掉”而工程里的幂等设计本质上是“把重复请求压成一次有效执行”。一个在算法世界做加速一个在分布式系统里做收敛思想同源。理解了这一点后面所有方案就都好懂了。1.3 哪些系统必须做幂等不是所有接口都需要幂等。只读查询不做也问题不大真正必须做的是所有会产生状态变更的写操作订单创建防止重复下单避免库存超卖支付回调防止同一笔支付通知被多次入账库存扣减防止同一个扣减请求重复生效优惠券/积分发放防止同一事件发放两次消息队列消费防止重放导致下游重复处理对账和批处理任务防止任务重复执行产生脏数据只要你发现某个操作前面可能挂着重试、回调、消息队列、异步任务那它就必须考虑幂等。2. 第一道防线数据库行锁与幂等约束2.1 幂等键一切防御的地基做数据库层的幂等第一步不是加锁而是先给业务定义一个全局唯一的幂等键。这个键必须能唯一标识“同一次业务请求”我通常使用req_id biz_type也可以直接用调用方生成的requestId。有了幂等键之后直接在业务表上建唯一索引让数据库去裁决重复。例如订单表CREATE TABLE order_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, req_id VARCHAR(64) NOT NULL COMMENT 幂等请求号, order_no VARCHAR(32) NOT NULL COMMENT 业务订单号, user_id BIGINT NOT NULL, status TINYINT NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_req_id (req_id), UNIQUE KEY uk_order_no (order_no) ) ENGINEInnoDB;插入的时候直接用insert让唯一索引去拦截重复INSERT INTO order_record (req_id, order_no, user_id, status) VALUES (#{reqId}, #{orderNo}, #{userId}, 0);如果是 MySQL可以用ON DUPLICATE KEY UPDATE做合并如果用的是 PostgreSQL可以用ON CONFLICT DO NOTHING。关键点在于捕获到唯一键冲突后不要直接抛出异常应该回查已有数据并返回给上游。这条回查路径就是把“重复请求”变成“返回首次结果”的关键。2.2 行锁 FOR UPDATE 的使用边界唯一索引能挡住“重复插入”但挡不住“并发更新同一行数据时的互相覆盖”。比如扣库存两个请求同时读到库存剩余 1各自判断库存充足然后都执行减 1库存就变成了 -1。这时候要用行锁把临界区串行化。典型操作BEGIN; SELECT stock FROM inventory WHERE sku_id 100 FOR UPDATE; -- 业务层校验 stock 0 UPDATE inventory SET stock stock - 1 WHERE sku_id 100; COMMIT;FOR UPDATE会给命中行加上排他锁第二个事务只能等第一个事务提交或回滚后才能继续。这个方案有几个使用前提必须在事务内执行锁的生命周期和事务生命周期一致WHERE条件必须走主键或唯一索引否则可能锁住全表事务里不要做远程调用、不要做耗时操作否则会放大锁等待时间行锁解决的是“并发互斥”不等于幂等最终防重仍然要靠唯一索引2.3 乐观锁版本号如果读多写少或者不想让事务长期持锁可以换乐观锁。给表加一个version字段更新时校验版本号UPDATE order_record SET status 200, version version 1 WHERE order_no xxx AND version 5;影响行数为 0说明版本已变更请求需要重试或者直接返回“处理中”。这个方案的好处是不加锁、无阻塞坏处是并发冲突高时大量请求会直接失败。订单场景里我一般把它用在“状态流转”这种低频更新上比如待支付到已支付。2.4 数据库层防御的常见坑数据库幂等看起来简单实际落地坑不少幂等键选得太宽。有人直接用整个 JSON 请求体做 hash 作为唯一键索引体积大、碰撞概率反而上升。正确做法是用业务维度的requestId或sourceId sourceNo。唯一索引冲突后直接报错。应该做一次降级查询把首次结果返回给调用方。长事务持锁太久。库存扣减这类高频操作事务里只该放 SQL不放外部调用。主从延迟下走从库查询。重复请求一旦被路由到了从库可能查不到刚写入的数据被误判成非重复。所以幂等回查必须走主库或强制读主。数据库这一层的核心结论是唯一约束是“最终裁决”行锁和乐观锁是“并发控制”。它们解决的是“同一时刻谁来写”的问题还不足以扛住流量入口的重复风暴。3. 第二道防线Redis 分布式锁与红锁的边界3.1 为什么数据库之上还要加一层锁如果所有请求都打到数据库行锁上数据库的锁等待、死锁检测、连接数压力都会成为瓶颈。尤其秒杀这种场景几千个重复请求同时抢同一笔订单在数据库行锁上排队既不经济也不安全。所以在数据库之上我通常会加一层 Redis 分布式锁。它的作用不是代替数据库唯一索引而是负责在入口处做“快速互斥”同一个订单同一时刻只能有一个线程进入业务处理其余请求直接返回“正在处理”。3.2 用 SET NX Lua 搭一个正确的分布式锁很多同学写分布式锁还停留在setnx加del两步这是最容易出问题的写法。标准的做法是用原子命令设置带过期时间的锁释放时用 Lua 脚本校验持有者身份。获取锁String lockKey lock:order: orderNo; String requestId UUID.randomUUID().toString(); boolean locked redis.opsForValue() .setIfAbsent(lockKey, requestId, Duration.ofSeconds(30)); if (!locked) { return 订单处理中请勿重复提交; }释放锁String lua if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end; redis.execute( new DefaultRedisScript(lua, Long.class), Arrays.asList(lockKey), requestId );这里的requestId就是“锁持有者标识”。释放时先比对值再删除能避免一个线程把另一个线程刚获得的锁误删。过期时间也很关键不能设太短否则业务还没跑完锁就自动释放了也不能设太长否则 Redis 宕机后锁恢复时间太久。具体值要根据业务接口的 P99 耗时来定常见做法是接口最大耗时的 2 到 3 倍。3.3 RedLock 红锁到底解决什么问题标题里提到的“Redis 红锁”就是 RedLock 算法。它的目标是解决单实例 Redis 锁在主从切换时可能丢失的问题。简单说就是不再依赖一个 Redis 节点而是向 N 个独立的 Redis 节点同时申请锁只有拿到超过半数节点的锁才算真正持有锁。RedLock 的完整流程通常是这样客户端记录当前时间准备一个随机 value 和一个统一的锁过期时间 TTL依次向 5 个独立的 Redis master 节点发送SET key value NX PX ttl统计成功获取锁的节点数超过 N/2 1 并且总耗时小于 TTL才认为获取锁成功释放锁时无论成功失败都向所有节点发送 Lua 删除脚本这套算法在理论上很严密但实践中的争议也很大。它依赖一个关键假设所有节点的时钟是一致的。现实中节点的时钟漂移、Java 的 GC 停顿、网络分区都可能导致两个客户端同时认为自己持有了锁。所以如果业务真的强一致我更倾向使用 ZooKeeper 或 etcd 这类带一致性协议的协调服务而不是自己实现 RedLock。对于订单幂等这种场景我的看法是红锁属于“重型武器”只有在支付、清算这类资金级操作且你对 Redis 运维有充分把握时才值得用。普通订单防重用单实例 Redis 分布式锁 数据库唯一索引兜底已经能解决 99% 的问题。3.4 锁粒度和看门狗才是日常重点比红锁更重要的是锁粒度和锁续期。锁粒度一定要细。不要用lock:order这种全局锁它会让所有订单互相阻塞。正确做法是锁在业务对象上在订单维度lock:order:{orderNo}在用户维度lock:user:{userId}:{action}在请求维度lock:req:{requestId}锁的 key 越细并发度越高。比如同一个用户同时提交两笔不同订单锁在orderNo上就能并行处理只有同一笔订单的重复请求才会互相等待。锁续期是另一个高频坑。如果 TTL 设置 30 秒业务却跑了 50 秒锁就会提前释放其他线程就能趁虚而入。生产上我用 Redisson 的RLock它默认带看门狗机制只要业务没结束看门狗会每隔一段时间自动续期。如果自己手写锁就需要在业务循环里做续期或者干脆把 TTL 调大到业务最大耗时的安全区间。这里还有一条经验分布式锁的职责是“尽量不让并发请求同时进入”它不是数学上的绝对保证。最终能不能防住重复仍然要看数据库唯一索引。4. 第三道防线分布式流式计算中的幂等防御4.1 MQ 重复消费是常态不是异常订单链路一旦引入消息队列问题就从“接口幂等”扩展到了“消费幂等”。Kafka 消费者在以下几种情况下一定会遇到重复消息消费者处理完消息但还没提交 offset 就宕机分区 rebalance 导致消息被重新分配生产者发送后网络超时重试导致 broker 收到两条相同消息记住一句话在分布式消息系统中重复投递是常态去重只能由消费端自己做。4.2 三种投递语义先把概念对齐分布式流式计算里有三个基础语义语义含义典型表现at-most-once至多一次消息可能丢失但不会重复at-least-once至少一次消息不丢失但可能重复exactly-once恰好一次不丢不重工程上很难绝对做到Kafka 生产端的enable.idempotencetrue能做到 producer 到 broker 的幂等但它只能保证“同一个生产者对同一个分区的消息不重复”解决不了消费端重复消费的问题。Flink 这类流式计算引擎通过 checkpoint 和两阶段提交能做到端到端的 exactly-once但前提是下游的 sink 也要配合幂等。所以对我们做订单系统的人来说最稳的路线不是追求绝对 exactly-once而是把消费逻辑做成幂等用“at-least-once 幂等处理”去逼近“恰好一次”的效果。4.3 流式计算引擎里怎么做去重在 Flink 中去重通常围绕业务键展开。比如支付结果流每条消息里带一个eventId处理时按订单维度去重DataStreamOrderEvent events ...; events .keyBy(OrderEvent::getOrderId) .process(new KeyedProcessFunctionString, OrderEvent, OrderEvent() { private ValueStateString lastEventId; private ValueStateLong lastTimestamp; Override public void open(Configuration parameters) { lastEventId getRuntimeContext() .getState(new ValueStateDescriptor(lastEventId, String.class)); lastTimestamp getRuntimeContext() .getState(new ValueStateDescriptor(lastTimestamp, Long.class)); } Override public void processElement(OrderEvent value, Context ctx, CollectorOrderEvent out) throws Exception { String prevId lastEventId.value(); if (value.getEventId().equals(prevId)) { return; // 重复消息直接丢弃 } lastEventId.update(value.getEventId()); lastTimestamp.update(value.getTimestamp()); out.collect(value); } });这段代码的关键点是状态里保存的是上次处理过的eventId重复事件在内存中被直接过滤。但要注意 state 是无限增长的必须配置 State TTL比如只保留最近 7 天的去重记录StateTtlConfig ttlConfig StateTtlConfig .newBuilder(Time.days(7)) .setUpdateType(StateTtlConfig.UpdateType.OnCreateAndWrite) .setStateVisibility(StateTtlConfig.StateVisibility.NeverReturnExpired) .build();不过要注意状态去重只是第一层防护。如果任务重启并从 checkpoint 恢复或者同一个事件被发送到了另一个作业里状态里的记录可能不起作用。所以真正落库时仍然要在目标表上建立唯一索引兜底。4.4 订单异步链路的流式幂等实战我给你看一个我实际搭过的链路。支付服务会把“支付成功事件”写入 KafkaFlink 作业消费这个事件后做三件事更新订单状态发积分发送站内信这三件事的下游分别是订单库、积分库、消息服务。我在三张表里都加了幂等字段。订单表用payment_event_id做唯一索引积分流水表用source_event_no做唯一索引站内信表用event_id做唯一索引。Flink 作业处理时先查本地状态去重再写三张表如果某张表已经存在相同event_id写入被数据库拒绝作业记录一条 warn 日志不报错、不阻塞。这样即使 Kafka 重放了同一个支付事件积分不会重复发放站内信不会重复发送订单状态也不会从“已支付”被打回去重新流转。流式计算的幂等最后还是要靠“去重状态 唯一约束 状态机”三个机制同时生效。5. 全面防御体系的架构编排与接口幂等设计5.1 一条订单请求到底要过几道关先看完整的一条订单提交路径从用户点击到异步处理结束客户端生成requestId前端按钮置灰防止连续点击网关或应用层做幂等表检查同一个requestId是否已经被处理进入订单服务先获取lock:order:{orderNo}或lock:req:{requestId}分布式锁数据库插入订单记录靠req_id唯一索引做最终裁决订单状态更新时用乐观锁版本号防止并发覆盖支付结果通过 MQ 异步回流Flink 作业做事件去重后更新状态下游积分、库存、通知都靠各自表里的业务键唯一索引兜底每一层都不是多余的。入口幂等表负责拦掉 90% 的重复请求分布式锁负责拦掉并发突变数据库唯一索引负责在极端情况下兜底流式计算去重负责异步链路的重复投递。5.2 幂等 ID 的生成规范幂等 ID 是整个方案的地基。我在多份对外接口文档里定过规范核心就几条优先使用 UUID 或雪花算法保证全局唯一一个请求只生成一个幂等 ID重试时必须复用同一个 ID不能只用时间戳毫秒级时间戳在高并发下很容易碰撞如果客户端没有能力生成服务端需要提供一个生成接口并在网关层记录请求与 ID 的映射幂等 ID 的组成建议包含来源渠道、业务类型、唯一序列。比如ACTIVITY:ORDER:20250601:8fa6e...这样在排查问题时只看 key 就能知道是哪个渠道、哪种业务、哪一天的请求。5.3 API 幂等设计的具体落地对外接口的幂等性设计我推荐在 HTTP 层用Idempotency-Key头。调用方每次请求设置一个唯一键服务端按这个键做去重和响应缓存。实现逻辑一般是这张幂等表CREATE TABLE idempotency_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, request_id VARCHAR(64) NOT NULL, request_hash VARCHAR(64) NOT NULL, status TINYINT NOT NULL DEFAULT 0, response_code INT NULL, response_body TEXT NULL, created_at DATETIME NOT NULL, expire_at DATETIME NOT NULL, UNIQUE KEY uk_request_id (request_id) );流程是这样收到请求后先查idempotency_record如果status PROCESSING说明首次请求还没处理完直接返回“订单处理中”如果status SUCCESS把首次请求的response_body原样返回如果没查到先插入一条PROCESSING状态的记录再进入业务逻辑业务处理完毕更新这条记录为SUCCESS并写入响应体这里有个并发细节两个相同requestId的请求同时到达如果都先查后插就可能都查不到然后都进入业务。所以“查询 占位”必须是原子操作要么用数据库唯一索引去拦截要么用一段 Lua 脚本local v redis.call(GET, KEYS[1]) if v then return v end redis.call(SET, KEYS[1], ARGV[1], EX, ARGV[2]) return 应用层拿到空字符串就说明自己抢到了“首次处理权”拿到非空字符串就说明已经有人在处理直接返回对方的状态。5.4 各层防御不是替代关系而是兜底链条很多团队的问题在于只做了一层防御然后希望这层防御永远不出错。我见过只加 Redis 锁的锁一过期就出事故也见过只靠唯一索引的高并发下数据库被打到连接池耗尽。正确的认知是每一层服务不同的目标防御层主要解决的问题失败时的兜底API 幂等表调用方面重复提交数据库唯一索引兜底Redis 分布式锁并发线程同时进入临界区数据库行锁和乐观锁兜底数据库唯一约束最终一致性裁决无法兜底所以必须建好流式计算去重消息重复投递目标表唯一索引和状态机兜底有了这张表你设计时就会很清楚每一层都可能失效但失效之后要有下一层接住。6. 常见的坑和排查技巧实录6.1 并发请求全部触发 DuplicateKey有一段时间我们发现线上偶尔会出现“请求已处理但日志里全是唯一键冲突”的告警。排查后发现第一个请求的事务还没提交第二个请求的 insert 已经触发唯一索引冲突。由于代码里 catch 到冲突后直接抛了异常没有做回查导致上游收到失败结果后进行了一次无谓的重试。解决办法是在捕获DuplicateKeyException后要求事务先提交然后再走一次主库查询把已存在的数据返回给调用方。对 MySQL还可以考虑用INSERT ... ON DUPLICATE KEY UPDATE让数据库自行处理冲突并通过影响行数判断是新增还是重复。6.2 Redis 锁莫名其妙失效最常见的锁失效原因有三个锁的 TTL 设置太短业务还没跑完锁就自己释放了释放锁时直接del没有校验持有者标识把别人的锁删了主从切换期间旧 master 上的锁没有同步到新 master导致锁丢失对应解法分别是用看门狗续期、用 Lua 脚本比对 value 再删除、资金敏感场景用 RedLock 或 etcd 替代单点 Redis。需要注意的是这三类问题不一定同时出现但只要你压测时把接口耗时调大基本都能复现出来。6.3 MQ 重放导致重复发券消息重放是流式计算里最容易踩的坑。Kafka 消费者在处理完业务逻辑之后、提交 offset 之前宕机重启后就会把刚才那条消息再拉一次。如果发券逻辑没有幂等用户就会收到两张券。我们的解法是在发券流水表上建立source_event_no唯一索引。每次处理消息前先尝试插入一条发券流水如果插入成功就说明这是第一次处理继续发券如果插入失败就说明已经处理过直接跳过。这里要注意事务边界插入流水的操作必须和发券操作在同一个本地事务里否则还是可能不一致。6.4 重复请求返回的响应不一致有一个隐蔽的问题A 请求第一次进来时处理成功返回“已支付”但幂等表 TTL 过期后被删了。第二天同样的requestId再次进来系统把它当成新请求返回的却是“订单不存在”。用户看到同一个操作出现两种结果体验极差。解决思路是让幂等表的保留时间长于整个业务生命周期的重试窗口。如果是低频对账类接口幂等记录建议永久保留如果是高频接口至少保留到业务进入终态之后。更稳妥的做法是幂等键即使过期业务处理前也要先查订单状态如果订单已经是终态就直接返回终态信息。6.5 排查问题的一条实用建议真出问题的时候先看一眼你能不能在日志里串起整条链路。我要求团队所有订单相关日志必须带上三个字段orderNo、requestId、eventId。这样不管是数据库层面、Redis 层面还是流计算层面都可以用同一个 ID 把整个处理过程拉出来。否则你对着一堆漂亮的架构图根本不知道是哪个环节把重复请求放过去的。还有一个压测技巧不要只压并发要压“完全相同请求体的并发重复提交”。模拟 500 个线程同时发送同一个requestId观察最终是不是只有一条生效记录。这个测试能一次性暴露入口幂等、分布式锁和数据库唯一索引的所有问题。这套架构做下来我最大的感受是不要迷信某一个组件。数据库行锁解决了并发却防不住重复Redis 红锁解决了互斥却建立在时间假设上流式计算解决了重复投递却依赖业务键设计。真正扛住线上冲击的永远是“入口幂等 临界区互斥 最终唯一约束”这套组合拳。最后再分享一个小技巧幂等键千万不要裸用一个自增 ID最好设计成“业务类型 来源标识 唯一序列”的组合。平时看不出来差别等你要跨系统排查重复订单时一个自解释的幂等键能帮你省下至少半天时间。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →