ARTICLE DETAIL

资讯详情

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

高并发接口幂等性设计:六种主流方案与实战避坑指南

高并发接口幂等性设计:六种主流方案与实战避坑指南 1. 高并发下的重复请求问题远比你想的严重我记得刚接手公司支付系统那会儿线上出过一次事故。凌晨促销活动瞬时流量是平时的几十倍结果案发后一查一批用户被重复扣款了两次。原因特别简单——用户在网络抖动时疯狂点了好几次支付按钮网关层又做了超时重试结果同一笔订单的请求打到了后端两次而我们的下单接口不是幂等的就这么把钱扣了两遍。从那以后我彻底明白了一件事在高并发场景下接口幂等性不是一个加分项而是生存底线。先给还不熟悉这个概念的朋友说清楚。幂等Idempotency这个词来自数学f(f(x)) f(x)就是幂等。放到接口上就是同一个请求执行一次和执行N次最终结果完全一致而且不会产生副作用。一个查询接口天然幂等但一个扣款、下单、发券接口就不一定了。问题的核心在于高并发环境下产生重复请求的路径太多了。前端的重复点击、用户手动刷新、网关的超时重试机制、消息队列的重新投递、RPC框架的retry策略这些在平时看起来无害的行为一旦流量放大就会制造出海量的重复请求。如果接口没有幂等保护这些重复请求带来的损失是实打实的——重复扣款、重复下单、重复发放优惠券每一笔都是真金白银。这篇内容里我会把我在多个高并发项目里实际用过的幂等方案完整梳理一遍包括每个方案的适用场景、实现细节、核心代码以及那些只有踩过坑才会理解的教训。不管你是刚接触后端的新人还是正在处理线上幂等问题的开发者应该都能从中找到参考。2. 先分清哪些接口需要幂等哪些天然就是幂等很多人一上来就想着给所有接口都加幂等逻辑这是一个典型误区。加幂等是有代价的——每次请求都要查一次状态、判断一次来源这会引入额外的存储访问和逻辑判断在高频接口上会影响吞吐。所以第一步不是设计方案而是做好接口分类搞清楚哪些必须做哪些不需要做。2.1 天然幂等的操作查询与纯删除对数据库的SELECT操作查一百次也是一样的结果天然幂等。固定条件的DELETE操作比如DELETE FROM orders WHERE id 123删一次和删十次最终结果都是这条记录没了也天然幂等。这类接口不需要做任何额外处理。2.2 需要重点防护的写操作真正需要幂等设计的是那些会产生增量影响的操作我按业务类型给你分一下支付扣款类同一笔订单扣两次钱这是最严重的事故订单创建类用户下单一笔重复点击导致创建多个订单优惠券/积分发放类一次活动只能领一张券重复请求导致多发状态流转类比如订单状态从待支付流转到已支付重复通知可能导致状态错乱文件上传/数据提交类重复提交产生两份相同数据2.3 从业务角度判断而不是从技术角度判断一个接口要不要做幂等问自己一个问题如果这个请求因为超时被客户端重试了一次业务结果会不会变如果会变就需要幂等设计。比如查询用户余额这个接口重试一百次余额也不会变不需要做。用户用100积分兑换一张券这个接口重试一次就可能多兑换一张必须做防护。这里有一个容易被忽略的场景需要特别提一下读后写的操作尤其危险。比如用户先查询了当前可用库存再提交一个直接下单的请求这种操作在密集并发下极易产生超卖或重复。因为每次请求拿到的库存状态都是基于上一次请求改变后的结果重试时状态可能已经被污染了。这类接口的幂等设计要格外仔细后面我会专门讲状态判断类的处理。3. 六种主流幂等方案全景对比做幂等设计的本质说白了就是回答一个问题怎么识别出这是同一个请求的重复执行。围绕这个核心业内沉淀出了多种方案我从适用场景、优缺点、实施成本三个维度给你做个全景梳理。方案核心原理适用场景优点缺点实施成本唯一索引数据库约束利用数据库唯一键约束防止重复插入订单、流水、日志等插入类操作实现简单、数据强一致不适合业务状态会变动的场景低Token预生成令牌请求前先获取令牌提交时校验并删除创建类接口如表单提交、下单能防前端重复点击业务侵入低需要额外的一次请求获取token若删除token失败会影响重试中状态机乐观锁通过版本号或状态字段控制更新条件订单状态流转、任务进度更新逻辑清晰、天然防并发覆盖要求业务有明确的状态变化流程中分布式锁Redis用锁防护对同一资源的并发操作多实例部署时的资源竞争类操作性能高、适用范围广需要考虑锁过期、锁误删问题中高去重表消息幂等通过唯一业务ID先查重再处理MQ消费、回调通知、文件处理实现直观、不依赖业务改造多一次查询开销需要保证查询与插入的原子性低请求唯一ID全局追踪调用方生成全局唯一ID服务端记录通用场景适合对外API适用面最广需要额外传递ID服务端仍需配合去重存储高表格里每个方案都各有侧重但实际项目中通常不会只依赖一种方案而是组合使用。拿我们系统的经验来说前端秒级防重用Token方案服务端最核心的下单操作用数据库唯一索引兜底MQ消费端用去重表订单状态流转用状态机锁。每一层防的是不同路径产生的重复请求。下面我挑四个最常用也最容易写错的方案给你完整展开包括实现代码和那些文档里不会写的坑。4. 实战方案一唯一索引——最简单也最可靠的兜底手段数据库唯一约束是我个人最喜欢的一种幂等方案因为它的可靠性来自数据库本身的ACID保证不会出现代码觉得幂等了、但数据库其实没挡住的情况。4.1 设计思路与建表实战假设你有一个下单接口为了防止同一用户对同一商品重复下单最直观的做法是给订单表加一个业务唯一键。这个唯一键不能是订单ID本身——因为每次插入系统都会生成新的ID得用一个能代表同一业务动作的字段组合。比如一个典型的订单表CREATE TABLE t_order ( id bigint(20) NOT NULL AUTO_INCREMENT, order_no varchar(32) NOT NULL COMMENT 订单号, user_id bigint(20) NOT NULL COMMENT 用户ID, sku_id bigint(20) NOT NULL COMMENT 商品ID, biz_token varchar(64) NOT NULL COMMENT 业务幂等键, status tinyint(4) NOT NULL DEFAULT 0 COMMENT 订单状态, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_biz_token (biz_token) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单表;关键在于biz_token这个字段。它由业务方根据请求内容生成规则必须是同一个业务动作生成的biz_token一定相同不同动作一定不同。下单场景里可以用user_id _ sku_id _ 活动ID来拼支付场景里可以直接用支付流水号作为唯一键。插入时不再是无脑insert而是捕获重复异常Transactional(rollbackFor Exception.class) public Order createOrder(CreateOrderRequest request) { String bizToken buildBizToken(request); try { Order order new Order(); order.setBizToken(bizToken); order.setUserId(request.getUserId()); order.setSkuId(request.getSkuId()); // 其他字段... orderMapper.insert(order); return order; } catch (DuplicateKeyException e) { // 唯一键冲突说明请求已经处理过了直接查询已存在的订单返回 Order existOrder orderMapper.selectByBizToken(bizToken); log.warn(duplicate order request, bizToken{}, bizToken); return existOrder; } }这段代码有个细节值得注意insert和catch暴露的查询必须放在同一个事务里后面讲坑的时候会专门展开。这里还有个容易被忽略的点DuplicateKeyException的捕获范围可以更精确一些。实际生产里我习惯在Mapper层把可能抛重复异常的具体插入方法单独隔离而不是在Service层catch整个大try块否则后面加数据操作时容易误吞别的异常。4.2 这个方案的致命弱点session丢失和业务重放唯一索引方案最怕两种场景。第一种是事务回滚导致唯一键没插入——如果订单表的事务因为其他错误回滚了但前端因为超时又发起重试此时唯一键并不存在插入会成功这是合理的。但如果你把biz_token只放在一个先插入再更新业务数据的组合里一旦中间某个环节异常回滚下次请求还是能插入成功幂等就被绕过了。第二种是重复请求走了一条绕过唯一索引的路径。比如状态更新类接口你不可能给同一个订单的状态字段加唯一索引——同一个订单的状态会从0变成1再变成2不可能要求只允许变一次。所以状态更新类业务唯一索引只适合做插入兜底不能解决状态流转问题。提示唯一索引方案是当前所有方案里数据一致性级别最高的一种因为它最终依赖的是数据库本身的约束而不是依赖程序逻辑判断。凡是一个业务动作只产生一条数据记录的场景优先考虑用它兜底。5. 实战方案二Token机制——拦截前端重复提交的第一道防线Token方案可能是业界最普及的防重方案几乎没有哪个Web项目没用过。它的实现逻辑分两阶段调用接口前先向服务端申请一个唯一令牌真正提交接口时必须带上令牌服务端校验令牌存在且未被使用时才放行同时消费掉这个令牌。5.1 完整实现链路Token方案的代码有三个核心点生成、校验、消费。生成与存储可以用Redis因为天然支持超时过期避免死token堆积。第一步提供获取Token的接口RestController public class TokenController { Autowired private StringRedisTemplate redisTemplate; GetMapping(/token) public ResultString getToken(RequestParam String bizType) { String token UUID.randomUUID().toString().replace(-, ); // key设计带上业务类型和用户维度方便排查问题 String key idem:token: bizType : token; // 设置5分钟有效期防止token堆积占用内存 redisTemplate.opsForValue().set(key, 1, 5, TimeUnit.MINUTES); return Result.success(token); } }第二步在需要防重的接口里校验并消费Token。这里要特别注意校验和删除必须是原子操作。如果分两步做先查出token存在再删除这中间如果有两个并发请求都查到存在那就都通过了。Redis的Lua脚本正好可以原子完成查询删除if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 endSpring这边这样调用public boolean checkAndConsumeToken(String tokenKey, String tokenValue) { String luaScript if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end; DefaultRedisScriptLong redisScript new DefaultRedisScript(luaScript, Long.class); Long result redisTemplate.execute(redisScript, Arrays.asList(tokenKey), tokenValue); return Long.valueOf(1L).equals(result); }第三步业务流程前置调用这个校验方法public ResultOrder submitOrder(SubmitOrderRequest request) { // 从请求头或参数里获取token String token request.getToken(); String userId request.getUserId(); String tokenKey idem:token:order: userId : token; if (!checkAndConsumeToken(tokenKey, 1)) { return Result.error(请勿重复提交); } // 校验通过继续业务操作 Order order createOrder(request); return Result.success(order); }5.2 Token方案的三个教训这个方案看着简单但我见过三个高频问题。第一个是校验消费Token和业务操作不在同一个事务/流程里。上面代码中token消费成功后如果业务操作失败比如库存不足导致下单失败token已经被删了。用户重新提交时拿不到旧token会被“请勿重复提交”挡住。这时候用户体验就很差。解决思路有两种一种是把Token消费放在业务成功之后先处理业务再删token但这样并发控制会弱另一种是给业务失败提供token退还机制把token重新放回Redis。我们最后选的是第一种的变种——先查token存在性但不删除业务成功后异步删除。这样并发窗口期的重复提交风险会变大一点但通过下游的唯一索引做了兜底整体是安全的。第二个教训是token的有效期设置太短或没有重置策略。用户打开下单页申请token中间填写地址、选优惠券磨蹭了几分钟提交时token过期了。解决方式是把有效期适当拉长并且配合在提交时延长token有效期的续期策略。我一般设15分钟足够覆盖绝大多数用户操作时长。第三个教训是请求重试的token不一致。有些API网关会对同一个请求做重试但重试时会重新生成请求头导致token不一致这样服务端会把它当成两个不同请求处理防重就失效了。所以如果你用了Token方案必须确保网关层的retry策略是透传原始请求头而不是重建新的。6. 实战方案三状态机与乐观锁——处理状态流转的正确姿势关于状态更新类接口比如订单状态、任务状态、审批流状态很多人在初期会纠结我要把状态从A改成B但并发时两个相同请求都来改怎么办。这是状态机的核心场景。我用一个最常见也最经典的例子带你走一遍订单支付回调。6.1 状态机模型怎么设计假设订单状态有六个待支付INIT、已支付PAID、已发货SHIPPED、已完成FINISHED、已取消CANCELLED、退款中REFUNDING。一个订单从PAID变成FINISHED是合法的但从CANCELLED直接变成PAID就不合法。如果状态机设计得好那么非法状态转换本身就是最好的幂等屏障——因为第二次请求来的时候状态已经不是它期待的初始状态了直接被拒绝。实际代码里用乐观锁做版本控制比单纯判断status字段更严谨Update(UPDATE t_order SET status #{newStatus}, version version 1 WHERE order_no #{orderNo} AND version #{oldVersion} AND status #{expectStatus}) int updateOrderStatus(OrderStatusUpdate update);调用方public boolean updateStatus(String orderNo, OrderStatus fromStatus, OrderStatus toStatus) { int rows orderMapper.updateOrderStatus( new OrderStatusUpdate(orderNo, fromStatus.getCode(), toStatus.getCode())); return rows 0; } Transactional(rollbackFor Exception.class) public void handlePaymentCallback(PaymentCallback callback) { // 先查当前订单状态 Order order orderMapper.selectByOrderNo(callback.getOrderNo()); // 判断当前状态是否可以流转到目标状态 if (!orderStateMachine.canTransit(order.getStatus(), callback.getTargetStatus())) { log.info(订单状态不允许流转, orderNo{}, current{}, target{}, callback.getOrderNo(), order.getStatus(), callback.getTargetStatus()); return; } // 乐观锁更新update影响行数为0说明并发时已被其他请求更新过 boolean ok updateStatus(order.getOrderNo(), order.getStatus(), callback.getTargetStatus()); if (!ok) { // 这里说明有并发请求先修改了状态本次请求丢弃或标记待处理 handleConcurrentUpdate(callback); return; } // 继续后续事务操作如账户入账、通知用户等 doSubsequentBusiness(order, callback); }6.2 状态机方案容易被忽略的细节用状态机做幂等最大的坑是业务过程并不像状态图那样规则。支付回调可能会重复投递多次第一次回调把订单从INIT变成了PAID第二次回调来时订单已经PAID这时候canTransit判断PAID到PAID是否允许必须允许。幂等系统里重复请求到达时状态已到达目标态不能视为非法流转而应该视为该请求已经生效直接返回成功。所以状态机设计的时候要显式定义同状态重复是合法的。我在代码里一般会加一行-- 允许状态相同的情况直接返回成功避免重复回调导致业务中断 SELECT 1 FROM t_order WHERE order_no #{orderNo} AND status #{targetStatus}查到就说明目标状态已经达成后续业务流程跳过直接返回成功。另一个容易踩的坑是事务边界和乐观锁更新的顺序。updateStatus这条SQL必须在事务内尽早执行不要先做一堆耗时逻辑再更新状态。因为先查后改如果步骤中耗时太长锁持有时间会拉长并发能力急剧下降。我一般是第一步就做状态机校验固定状态条件更新如果更新成功再接着做后续业务。这样既保证了幂等又最大化了并发能力。7. 实战方案四分布式锁与去重表——应对多实例与消息乱序上面三个方案在高并发单机场景下已经能处理很多问题但一旦系统部署了多个实例幂等问题的复杂度就会直线上升。你以为同一个请求只会打到同一台机器不负载均衡可能把它轮询到任何一台机器上。分布式锁和去重表是应对这一个级别问题的关键手段。7.1 Redis分布式锁的正确使用姿势分布式锁配合业务操作的思路是同一个业务唯一键同一时间只允许一个线程进入处理流程其他线程等待或直接返回。Redis的SET NX EX命令是实现分布式锁的基础public boolean tryLock(String lockKey, String requestId, long expireSeconds) { String result redisTemplate.opsForValue() .setIfAbsent(lockKey, requestId, Duration.ofSeconds(expireSeconds)); return Boolean.TRUE.equals(result); } public void unlock(String lockKey, String requestId) { // 用Lua脚本保证判断是自己持有锁和删除锁这两个操作原子执行 String luaScript if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end; DefaultRedisScriptLong redisScript new DefaultRedisScript(luaScript, Long.class); redisTemplate.execute(redisScript, Arrays.asList(lockKey), requestId); }这里有两个在生产环境里会直接引发故障的细节。第一个是锁过期时间同业务执行时间的关系。如果业务操作需要执行10秒但你设置的锁过期时间是3秒那锁在业务执行过程中就自动释放了。第二个请求就能成功获取锁从而绕过幂等。但这个锁的过期时间也不能设得太长——一旦持有锁的线程崩溃其他线程会被阻塞很久。比较稳妥的做法是给锁设置一个合理的过期时间比如10秒同时在持有锁期间启动一个守护线程自动续期每隔三分之一过期时间就延长一次。如果线程崩溃守护线程也停止续期锁自然过期。第二个是锁的粒度设计。不要把锁粒度设在整个接口上。比如用户提交订单接口如果锁粒度是整个用户ID维度那么这个用户同时下两个不同商品的订单也会被锁排队体验会明显劣化。更合理的设计是锁在用户ID商品ID活动场次这个业务维度上。锁粒度越小系统并发度越高这是分布式锁设计里最重要的一条原则。实际经验中我甚至遇到过把锁粒度设成class级别的惨痛教训——整个下单接口全部串行化压测时TPS从几千直接掉到几十。7.2 去重表MQ消费幂等的标配消息队列的消费是另一个高发重复场景。Kafka、RocketMQ的at-least-once投递语义下消费者可能收到同一条消息多次。处理方式是在消费端维护一张去重表核心是消息ID或业务唯一ID。CREATE TABLE t_message_dedup ( id bigint(20) NOT NULL AUTO_INCREMENT, msg_id varchar(64) NOT NULL COMMENT 消息唯一ID, biz_type varchar(32) NOT NULL COMMENT 业务类型, consume_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_msg_id (msg_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT消息消费去重表;消费逻辑Transactional(rollbackFor Exception.class) public void handleMessage(Message message) { try { dedupMapper.insert(message.getMsgId(), message.getBizType()); } catch (DuplicateKeyException e) { log.info(duplicate message, skip. msgId{}, message.getMsgId()); return; } // 继续实际的业务处理 processBusinessData(message); }这里怎样保证插入去重记录和业务处理的原子性是核心问题。如果是单库事务两者放在同一个事务里靠数据库的ACID保证原子性——但一旦业务处理失败回滚去重记录也会回滚消息重投时还能再次成功处理反而符合业务最终成功的期望。如果是跨库或微服务场景就需要使用分布式事务来做补偿这往往会把复杂度推高一个量级。一个折中方案是先记录处理中状态完成业务后再标记已完成恢复线程扫描超时的处理中记录做补偿。这样去重状态就多了一维需要额外确保状态流转准确性。去重表的查询插入必须是原子的——同一个消息两个线程同时插入只有一条能成功另一条触发DuplicateKeyException这正是我们要的效果。这也是去重表方案相比先查再插的天然优势——不需要担心并发窗口期。7.3 消息乱序场景下的幂等策略再扩展一下。消息重复还好办更头疼的是消息乱序。比如支付回调先到订单已经变成PAID随后支付超时取消的消息才到状态机这时候拒绝把PAID流转回INIT这是对的。但有些业务消息到达顺序不同会导致处理结果不一致。一种解决思路是消息体里携带时间戳或版本号服务端比较时间戳只接受较新的操作。比如库存扣减消息带了流水时间如果当前处理的消息时间戳早于订单上已记录的流水时间直接丢弃。另一种是把业务状态流转设计成可重复执行的幂等操作。即加库存操作不是把库存设置成某个值而是在现有库存基础上增加N件。这种操作天然幂等因为无论这个加库存消息被消费多少次最终库存增量只计算一次的前提是数据库里唯一记录了这个增量动作。8. 高并发下幂等方案的选型与组合策略前面分别讲了几个主流方案但实话实说真正在生产环境里运行良好的系统从来不是依赖某一个方案打天下。幂等设计本质上是一套分层防御体系每一层解决不同来源的重复请求。我拿我们支付系统最终落地的一套组合给你做个参考防御层级应对的重复来源使用的方案关键实现客户端层用户重复点击、双击提交前端置灰Token方案后端生成token前端提交时携带接入层网络抖动导致的HTTP重试网关保留原始请求头重试透传网关识别业务唯一ID服务层下游重复回调、多实例并发分布式锁状态机业务维度加锁状态机校验流转数据层兜底所有遗漏路径唯一索引去重表事务内插入唯一约束捕获重复这套组合有几个核心设计原则我总结给你。原则一每一层独立工作不依赖上一层是否成功。比如Token层万一token校验逻辑有Bug放行了重复请求数据层的唯一索引必须要能接住这个漏网之鱼。所以幂等设计时永远要问自己一个问题如果上面那层失效了我下面这层能兜住吗如果答案是否定的就说明这套体系还不完整。原则二幂等键的生成规则是整个体系的命门。所有方案都建立在同一个业务动作生成同一个幂等键这个基础上。幂等键必须满足稳定同一个动作永远相同、唯一不同动作不冲突、可解析能从键上看出业务维度。业务唯一键常见的生成规则包括UUID随机性高、时间业务ID序号可读性好、Snowflake ID分布式唯一、hash(关键业务参数)一致性最好。最终选择哪种取决于你的业务场景但固定规则后不要轻易更改——一旦幂等键规则变化去重记录和唯一索引就会失效。原则三幂等不应该无限重试。有些团队会把幂等做成请求永远能重试直到成功这其实很危险。幂等的目的是重复请求不产生错误影响不是无论怎样都一直执行。合理的做法是第一次请求失败时业务数据没产生变化下一个重复请求仍然可以正常处理如果连续多次失败比如依赖的下游系统持续异常更合适的策略是快速失败并告警把问题交给人工排查而不是让自动化重试把系统资源耗尽。原则四观察与监控必须配套。幂等判断被触发的次数、被拦截的重复请求量这些指标要暴露到监控系统里。我们当时就给每个幂等拦截点加了Metrics埋点有一天监控告警显示下单接口token拦截率突然飙升到30%排查发现是前端某个版本Bug导致请求重复发送。没有监控的话这种问题可能要等用户投诉才会暴露。9. 那些年我踩过的幂等坑——排查方法与经验教训最后一个部分我把实践中的高频故障场景和排查链路分享出来。每一个都是我或者团队在线上真实踩过的希望能帮你少走弯路。9.1 第一个坑锁过期导致幂等失效现象压测时偶尔出现重复扣款频率不高但很随机。排查链路如下先看告警确认重复扣款的比例约为万分之几集中在某个特定商品上查看该商品的订单表发现同一个bizToken存在两条不同订单号的记录回溯代码发现加的是Redis分布式锁但锁过期时间设的是2秒继续深挖发现这个商品下单逻辑里有一段第三方风控调用正常5秒慢的时候8秒多结论加锁后业务执行时间超过了锁过期时间锁自动释放第二请求趁虚而入修复方案参考前面提到的锁续期机制把定长过期时间改为动态续期同时用Redisson的watchDog替代手写的续期逻辑。这里多说一句如果你的项目已经引入了Redisson它的分布式锁自带看门狗续期这比自己手写Lua脚本更可靠。9.2 第二个坑事务内先查后插的并发窗口现象用了先查询是否存在不存在则插入的逻辑结果依然产生了重复数据。原因两个请求并发执行请求A先执行select发现记录不存在请求B也执行select同样发现不存在然后A执行insert成功B继续执行insert也成功——因为B的select结果已经过时。解法这是最坑的模式之一处理方式有两种。一种是把查询插入换成直接insert捕获DuplicateKeyException就是我前面讲唯一索引方案时的做法另一种是必须保留先查后插的写法时给查询路径也加上分布式锁或数据库锁把整个窗口期串行化。顺带提一句MySQL默认隔离级别是RR但先查后插在并发下照样有窗口别以为用了事务就安全了事务隔离保护的是一致性不是幂等。9.3 第三个坑主从延迟导致的唯一键误判现象插入订单后如果订单插入操作走主库后续的查询走从库那么在一个事务刚提交完从库同步还没完成时去查这条记录会查不到。场景在前面的下单接口中我们用的是插入完成后立刻在另一个事务查询返回订单。因为主从延迟查询在从库上找不到刚插入的记录导致重复请求被当作新请求处理。解法对于写完立即读的场景要么强制走主库用hint或者把这个查询路由到主库要么把查询也放到与插入相同的主库事务里。更通用的思路是我们当时最终定的幂等校验和业务插入必须连贯在主库完成不要把写和幂等校验拆到两个数据源上。9.4 第四个坑接口设计层面天生就无法幂等最后说一种最棘手的场景——接口本身设计得无法做幂等。比如一个生成新的优惠券码接口每次调用都应该产生一个不一样的结果。这个接口要真正幂等只能靠入参的业务唯一ID去重——但业务方调用时如果每次传了一个不一样的ID服务端根本无从判断这两个请求其实是一次操作。所以这里给一个非常重要的经验在设计对外接口时API文档里必须明确要求调用方传入请求唯一ID或幂等键字段。这不是可选项是必须项。对于无法修改上游的存量接口只能在网关层做规则基于请求方法URL请求体内容hash生成幂等键在网关内存缓存一段时间。但这种方式对请求体顺序敏感hash不稳定效果有限只适合做兜底。9.5 排查幂等问题时的高效检查清单最后给你一套排障清单按优先级排列[ ] 幂等键的生成是否稳定且具备业务语义同一个操作用不同的键是整个体系失效的根源[ ] 校验和消费幂等键的步骤是否原子查删、插入查重都不要跨多个非原子操作[ ] 锁的过期时间是否可能小于业务执行时间[ ] 业务操作和幂等记录是否在同一个事务里数据库主从是否影响读写一致性[ ] 分布式环境下负载均衡会不会把同一请求分到不同实例实例本地缓存做不了全局判断[ ] 有无监控指标告诉你幂等拦截发生了几次、集中在哪个接口按照这份清单过一遍大部分幂等相关的线上问题都能快速定位到根因。我做高并发接口这几年最深的一个体会是幂等不是一个功能而是一种系统属性得从接口设计、存储选型、并发控制、异常处理多个层面共同保证。别指望一个分布式锁或者一个唯一索引就能把所有问题接住它需要你在每一层都留一道防线然后在最关键的路径上——通常是数据库——再加最后一道物理屏障。这套思路落实到位高并发下的重复请求才真正不足为惧。
返回列表