做后端开发的朋友十有八九都被重复提交坑过。一个POST订单接口客户端超时重试库里瞬间多出两张一模一样但应该只有一张的订单一个支付回调因为网络抖动重发了两次用户结结实实被扣了两笔钱。这些事故的本质其实是同一个问题——REST API缺少了幂等性保护。在ASP.NET Core 8.0里解决这个问题不是装个中间件完事而是要从HTTP语义、存储选型、并发控制、重试策略几个维度同时下手才能做出一套真正经得起线上考验的方案。这篇文章不会只讲什么叫幂等这种教科书写法我准备把从方案选型到源码实现、再到生产环境坑点排查的完整路径都走一遍。包括最近讨论热度很高的幂等性检查用db实现好还是redis、请求指纹该用什么字段生成、响应缓存怎么处理才能安全重放、多副本部署下怎么避免本地状态导致的穿帮。无论你是刚开始接触这个概念的.NET新人还是被线上重复数据折磨过的老手应该都能从中找到能直接落到代码里的东西。1. 先看清问题REST API重复请求的根因和幂等性的真实含义1.1 HTTP语义和业务语义的错位很多人背过这样一条结论GET、PUT、DELETE天然幂等POST不幂等。这个结论在HTTP协议层面没错但放到真实业务系统里它很容易让人产生误判。一个PUT接口如果你实现时用的是先读出来改几个字段再整体覆盖写的流程并且没有做任何并发控制那么两个相同内容的PUT请求同时到达时最后的结果可能取决于处理顺序这在业务效果上就不幂等。反过来一个POST接口只要在服务端做了完善的幂等保护业务层面也可以做到不管执行多少次结果等同于执行一次。所以真正的问题不是HTTP方法哪个幂等而是我的操作在什么情况下会产生副作用重复执行会带来什么后果。用一个生活化的例子去ATM取款你输入取1000元机器吐了钱但凭条没打出来。你肯定会再操作一次吗不会。因为你会担心卡里被扣两次。可银行系统如果设计得好你第二次取款时它知道你刚才已经取过要么拒绝、要么提示你上次交易成功。这就是业务层面的幂等保护。1.2 重复请求的来源比你想象的多真实的分布式系统里重复请求的来源远不止用户手抖点了两下这么简单。我梳理下来至少有这么几类客户端框架自动重试比如HttpClient配置了Polly重试策略超时就重发消息队列的重复投递消费者处理完消息但还没来得及提交Offset就崩了重启后这条消息又被投递一次网关或负载均衡器在判定上游超时后发起重试前端按钮未做防抖用户连续点击提交定时任务在分布式调度框架里发生故障转移同一个Job被两个节点各自执行这些来源叠在一起意味着重复请求会从链路的不同位置冒出来。只在客户端做防抖根本防不住因为很多重试来自我们完全控制不到的基础设施层。所以服务端必须自己扛住而且每个可能有副作用的写操作都应该纳入幂等保护范围。1.3 幂等保护的两个核心目标第一个目标大家都懂重复请求不能产生新的副作用。不能重复建单、重复扣款、重复加库存。第二个目标却经常被忽略——同一个请求多次执行时返回给客户端的结果要一致。第一次请求成功了客户端没收到响应超时重试时服务端应该直接把第一次成功的结果返回给它而不是抛一个重复请求错误。如果只做到第一个目标接口确实是安全了但对客户端很不友好。客户端收到重复请求错误后根本分不清这次操作到底是成功还是失败只能去查订单、问客服或者再次重试碰运气。生产级设计应该是让重试请求拿到和第一次完全相同的HTTP状态码和响应体客户端看见200就知道业务已经完成不需要再做任何额外处理。这其实也是Stripe这类支付服务商的API设计原则。2. DB和Redis二选一幂等存储方案选型对比与组合实战2.1 数据库实现唯一索引加事务天然可靠但不算快幂等性检查用db实现好还是redis这个话题几乎每隔一段时间就会在技术社区里被翻出来聊。先看数据库实现。核心思路是给业务数据加上唯一键让数据库在约束层面把重复请求挡在门外。举个例子如果我做一个防重复扣款功能可以在扣款流水表上加一个唯一索引字段可以是用户ID加业务单据号。请求处理的第一步就是往流水表里插入一条记录。如果插入成功说明这个请求是第一次来继续走业务逻辑如果插入冲突抛出唯一键冲突异常说明请求重复了这时去查已存在的记录把之前的处理结果返回给客户端。数据库方案的好处非常明显和应用共用同一个数据库不需要额外引入Redis组件事务的ACID特性保证了强一致性。数据不会因为缓存服务重启而丢失。坏处是性能上限相对低尤其是在插入冲突发生的高并发场景下大量请求会竞争同一组索引页可能拖慢数据库响应。如果幂等记录表的数据量膨胀起来查询和清理也要花精力设计。2.2 Redis实现性能好但一致性复杂度上移Redis方案现在更流行一些因为它能支撑更高的QPS。原理也不复杂请求进来时用业务唯一键执行SET NX操作只有键不存在时才能设置成功。设置成功说明是第一个请求设置失败说明之前已经有过请求了。一个典型流程是这样的收到请求拼出业务键比如idempotency:{userId}:{bizNo}执行SET key requestStatus EX 30 NX。如果返回OK继续处理业务处理完成后把响应体写进同一个Key。如果返回null说明Key已经存在直接读取里面的响应体返回。这个方案性能高、代码简单而且Key自带过期时间不用写额外的清理任务。但Redis方案有一个绕不开的问题一致性边界需要自己小心处理。比如业务处理时间超过Key的过期时间记录在业务还没结束时就被Redis自动删了此时第二个请求进来会再次进入Pending状态重复执行业务逻辑。再比如Redis宕机Key全丢如果业务已经在数据库里写了一半怎么办这些问题不是说Redis方案不行而是说要有对应的兜底策略不能把宝全押在Redis上。2.3 我的组合拳建议Redis前置拦截加数据库兜底在真实生产项目里我通常不搞二选一而是让两者配合。Redis放在前面负责拦截绝大多数重复请求靠它的高性能把入口压力扛住。数据库在后面兜底用唯一索引保证极端情况下一旦Redis出现异常也不会产生重复数据。一个标准的请求流程是这样请求进入中间件先到Redis查幂等记录。没查到就通过SETNX抢占标记然后执行业务逻辑。执行业务前数据库层还会做一次唯一索引校验。如果Redis在这个过程中出现故障SETNX返回失败中间件会把请求直接放行到下一层让数据库唯一索引来兜底防重。这种设计牺牲了一点点性能但换来了非常高的容错性。下面是几个方案的快速对比方便不同场景的朋友参考维度数据库方案Redis方案组合方案一致性依赖事务强一致依赖Redis主从配置偏弱Redis异常时由DB兜底性能受限于数据库IOPS毫秒级吞吐高Redis扛大头性能高复杂度需要设计表和定时清理需要处理过期与宕机边界两套逻辑都要写稍复杂适用场景金融级强一致低频接口高并发大流量接口大多数生产级API推荐这个对比是通用参考你要结合团队现有的基础设施条件来选择。如果一个团队连Redis都没有运维经验硬上Redis方案反而会引入更多变量。3. ASP.NET Core 8.0中间件源码拆解从状态机到响应重放3.1 核心设计请求指纹、幂等键和状态机写一个可复用的幂等中间件本质上就是在管道里塞进一套请求处理状态机。一个请求从进来走到最后状态可以分成三档Pending处理中、Completed处理完成且响应已缓存、Failed处理失败。请求到了中间件后先用幂等键查状态。如果状态是Completed说明同样的请求已经成功执行过了直接重放第一次缓存下来的响应。如果状态是Pending说明同一个请求正在处理中返回一个特定状态码比如424。如果查不到就创建一个Pending记录继续走业务管道业务正常执行完再把状态更新为Completed。要把同一个请求这个语义落地需要两个字段配合小的是客户端每次发起业务操作时生成的幂等键Idempotency-Key一般放在HTTP Header里大的是请求指纹也就是HTTP方法、路径和请求体内容的摘要。幂等键用来区分不同业务请求请求指纹用来防止同一个Key被误用在不同的请求上。两者拼接才能得到一个全局唯一的存储键。3.2 一个可运行的中间件骨架代码先设计一个存储抽象这样后面切换Redis或数据库都方便public interface IIdempotencyStore { bool TryCreatePending(string key, IdempotencyEntry entry); IdempotencyEntry? GetEntry(string key); void MarkCompleted(string key, IdempotencyEntry entry); void TryRemove(string key); }开发环境可以先用ConcurrentDictionary做一个内存实现方便调试public class InMemoryIdempotencyStore : IIdempotencyStore { private readonly ConcurrentDictionarystring, IdempotencyEntry _dict new(); public bool TryCreatePending(string key, IdempotencyEntry entry) { return _dict.TryAdd(key, entry); } public IdempotencyEntry? GetEntry(string key) { return _dict.TryGetValue(key, out var entry) ? entry : null; } public void MarkCompleted(string key, IdempotencyEntry entry) { _dict[key] entry; } public void TryRemove(string key) { _dict.TryRemove(key, out _); } }中间件本体public class IdempotencyMiddleware { private readonly RequestDelegate _next; private readonly IIdempotencyStore _store; private readonly ILoggerIdempotencyMiddleware _logger; public IdempotencyMiddleware(RequestDelegate next, IIdempotencyStore store, ILoggerIdempotencyMiddleware logger) { _next next; _store store; _logger logger; } public async Task InvokeAsync(HttpContext context) { // 只拦截可能产生副作用的写操作 if (HttpMethods.IsGet(context.Request.Method) || HttpMethods.IsHead(context.Request.Method)) { await _next(context); return; } // 没有幂等键的请求直接放行保持兼容旧客户端 if (!context.Request.Headers.TryGetValue(Idempotency-Key, out var keyValues)) { await _next(context); return; } var requestKey BuildRequestKey(context, keyValues.ToString()); var pendingEntry new IdempotencyEntry { Key requestKey, Status IdempotencyStatus.Pending, CreatedAt DateTimeOffset.UtcNow }; if (!_store.TryCreatePending(requestKey, pendingEntry)) { var existing _store.GetEntry(requestKey); if (existing?.Status IdempotencyStatus.Completed) { await ReplayResponseAsync(context, existing); return; } context.Response.StatusCode StatusCodes.Status424FailedDependency; await context.Response.WriteAsync(Request with the same idempotency key is still in progress.); return; } try { await _next(context); if (context.Response.StatusCode 200 context.Response.StatusCode 300) { var capturedBody await CaptureResponseBodyAsync(context.Response); _store.MarkCompleted(requestKey, new IdempotencyEntry { Key requestKey, Status IdempotencyStatus.Completed, ResponseStatusCode context.Response.StatusCode, ResponseBody capturedBody, CompletedAt DateTimeOffset.UtcNow }); await WriteBodyAsync(context.Response, capturedBody); } } catch { _store.TryRemove(requestKey); throw; } } private static string BuildRequestKey(HttpContext context, string idempotencyKey) { var method context.Request.Method; var path context.Request.Path.ToString(); // 生产环境建议读取请求体并计算SHA256摘要这里简化为空串 var bodyHash string.Empty; return ${idempotencyKey}:{method}:{path}:{bodyHash}; } }这段骨架代码展示了核心逻辑但响应体捕获、包头处理等细节都简化了。真实工程里下面几个点必须处理好。3.3 阻塞等待还是直接返回424Pending状态出现时第二个请求该做什么我在不同项目里见过两种做法一是直接返回冲突或依赖失败的状态码让客户端隔一小段时间再重试二是在中间件里阻塞等待第一个请求完成然后直接把结果返回给第二个请求。第二种体验更好因为对客户端来说整个过程就是一次正常请求。但实现复杂度明显上了一个台阶你要用SemaphoreSlim或者Redis的阻塞读去等第一个请求的结果还要处理等待超时、第一个请求中途崩溃等异常情况。我觉得前期可以先做第一种直接返回424让客户端在捕获到这个状态码后延迟重试逻辑简单也不容易出问题。注意如果API已经有比较成熟的客户端SDK一定要在SDK层面对424做重试处理。曾经有合作方对接我们的接口收到424后没有重试逻辑用户眼睁睁看着页面转圈。后来我们专门在接入文档里写了这个约定。4. 生产级细节响应捕获、清理任务、并发边界的坑怎么填4.1 响应体捕获与重放的正确姿势中间件要缓存响应体就绕不开Response.Body的捕获问题。ASP.NET Core的Response.Body在默认情况下是直通的写了就直接发给客户端中间件没法再读一遍。所以要在调用_next之前把Response.Body替换成一个MemoryStream业务执行完从MemoryStream里读取内容作为缓存保存然后再把内容写到原始的Body流里。这个替换过程有几个细节要处理好。第一响应头里的Content-Length必须删掉因为缓存和重放过程中响应体的长度可能变化留着会让客户端解析出错。第二如果后续管道里有Gzip压缩中间件要在捕获完成后统一处理压缩不要让压缩发生在捕获之前。第三内存占用问题不能无脑把所有响应都缓存下来。我的实践是只缓存Content-Type为application/json的文本响应文件下载、Stream流这类响应就不做幂等重放了因为重放大二进制流的性价比太低。贴一段响应捕获的伪码供参考private static async Taskbyte[] CaptureResponseBodyAsync(HttpResponse response) { if (response.Body is MemoryStream ms) { ms.Position 0; var bytes await ms.ReadBytesAsync(); ms.SetLength(0); ms.Position 0; response.ContentLength null; return bytes; } return Array.Emptybyte(); }响应的ContentType也要重新设置因为MemoryStream上的ContentType和原始响应体需要的ContentType可能不一致。4.2 幂等记录表的设计与建表语句如果采用数据库兜底方案幂等记录表的结构建议这样设计CREATE TABLE idempotency_records ( id BIGINT AUTO_INCREMENT PRIMARY KEY, idempotency_key VARCHAR(128) NOT NULL, request_fingerprint VARCHAR(64) NOT NULL, status TINYINT NOT NULL COMMENT 0Pending, 1Completed, response_status_code INT NULL, response_body MEDIUMBLOB NULL, created_at DATETIME NOT NULL, completed_at DATETIME NULL, expire_at DATETIME NOT NULL, UNIQUE KEY uk_idempotency_key_fingerprint (idempotency_key, request_fingerprint), KEY idx_expire_at (expire_at) );这个表的核心是联合唯一索引uk_idempotency_key_fingerprint它能在数据库层面兜住并发下的重复插入。status字段用于区分请求是否已完成这样即使一个请求还在处理中第二个请求来了也能通过状态判断出来。expire_at字段是给后台清理任务用的索引加上后删除过期记录的SQL能走索引避免全表扫描。如果是Redis方案存储结构没那么复杂Key就是idempotency_key加request_fingerprint拼接后的字符串Value用JSON存状态、响应码、响应体等信息TTL设置成业务允许的重试窗口就够了。4.3 过期机制与清理策略幂等记录不能无限期保留否则表会越来越大Redis内存也会被占满。但过期时间设得太短又可能放过真正需要拦截的重复请求。怎么取舍我的建议是过期时间要大于客户端可能发起重试的最长时间窗口。你可以从两个地方推断这个窗口一是网关的超时重试配置二是客户端SDK里的重试策略。两者取最大值再留一些余量。比如网关超时2秒客户端最多重试3次总窗口也就几十秒但我通常会把幂等记录的TTL设为15分钟到24小时之间宁可让记录多活一阵也不愿在客户端一个晚到的重试请求上翻车。数据库方案需要配合定时清理任务。可以用ASP.NET Core的IHostedService写一个后台服务每隔一段时间执行一次DELETE FROM idempotency_records WHERE expire_at NOW()。清理频率不用太勤5到10分钟一次就够。还有一种思路在写入新记录时顺带清理同一个业务键前缀下的历史记录可以减小表膨胀的速度。4.4 多副本部署和并发边界的坑微服务多副本部署的情况下同一个幂等请求可能落在两个不同的实例上。这时候如果用的是内存存储每个实例各存一份防重效果为零。所以生产环境别用ConcurrentDictionary做跨节点幂等存储要么用Redis要么用数据库唯一索引存储必须是所有实例共享的。就算换了共享存储还有一个隐藏的边界要处理业务处理时间超过Pending超时时间。假设第一个请求还在处理中它的Pending记录到了TTL就被删了第二个请求进来查不到记录又创建了一个新的Pending并开始执行业务逻辑副作用就会重复产生。解决办法有两个一是Pending状态的记录过期时间设得足够长明显大于业务慢请求的上限二是在清理任务里只清理Completed状态的记录Pending记录宁可留着也不要提前删。并发场景下还有可能是两个完全相同的请求同时到达数据库想靠先查再插的逻辑防重是防不住的因为两个请求可能同时查到没有记录然后都插入成功。正确的做法就是靠数据库的联合唯一索引或Redis的SETNX原子操作从约束层面阻止并发插入。5. 线上排查实录三个让我印象深刻的幂等事故5.1 问题速查表先把高频问题整理成一张表方便遇到线上问题时快速对照现象可能原因处理建议重复请求偶尔穿过幂等层多副本部署下使用了内存存储改用Redis或数据库共享存储同一请求发两次第二次一直424第一个请求还在Pending客户端未处理SDK捕获424后延迟重试重放的响应体内容为空响应体捕获时机不对或ContentType过滤掉了检查ContentType限制放开JSON响应Redis重启后幂等记录全丢持久化策略未配置或AOF策略过于宽松按业务一致性要求调整持久化配置数据库幂等表增长极快没有定时清理或者TTL设置过长增加后台清理任务按窗口设置过期压缩后的响应缓存后无法解压缓存捕获发生在压缩之后调整中间件顺序先缓存再压缩5.2 坑一中间件注册顺序导致错误重放这个坑是我自己踩过的最深的坑。当时把幂等中间件注册在了ExceptionHandlerMiddleware之前结果业务抛异常后异常处理中间件先把响应换成500错误幂等中间件一看响应状态码不是2xx就没做缓存。但这只是运气好方向反过来的场景才致命如果你把幂等中间件注册在异常处理之后异常处理器已经把响应改成了200幂等中间件就会把错误响应当成成功响应缓存下来后面的重试请求全都会重放这个错误结果。所以注册顺序非常关键建议放在路由之后、异常处理中间件之前并且确保它只缓存真实的成功响应。5.3 坑二先查再插的竞态漏洞有一版代码我用的是先Get记录没有就Insert的逻辑。单机调试一切正常用并发工具压测时偶发出现两条相同幂等键的记录。原因在上一节说过两个线程同时Get到null然后都执行Insert结果都插成功了。后来改成给数据库加联合唯一索引Redis改成SETNX这个问题才彻底消失。这个教训让我养成了一个习惯凡是涉及并发防重的逻辑绝不依赖先查后写要么用原子操作要么用数据库约束。5.4 坑三响应缓存把表撑爆了之前有个上传接口也接入了幂等中间件上传成功返回的响应体非常小但里面内嵌了一条Base64的缩略图。单条记录没多大数据量上去了表还是迅速膨胀。后来我限定只对JSON文本且大小不超过一定阈值比如64KB的响应做缓存超过阈值就只记录状态不缓存响应体。这个约束对大多数业务接口都够用了文件上传、大图下载这类接口本来就该走ETag和条件请求不是幂等中间件的职责。写在最后聊聊我的体会做了这些年后端我越来越觉得幂等性是分布式系统设计里门槛不高但水很深的领域。表面上是加一个拦截、存一条记录实际上牵扯到HTTP语义、数据一致性、运维容错、客户端契约设计任何一层想简单了线上都会用事故教训你。如果你正准备在项目里引入幂等保护我给的建议是先别追求大而全的方案。挑一两个最重要的写接口比如订单创建、支付回调把Redis加数据库的组合方案跑通让重复请求从源头被拦住。跑稳之后再逐步覆盖到其他接口。做技术方案最怕一步到位分步推进才能在每个环节都积累足够多的现场经验。最后再分享一个小技巧上线前一定要做并发压测和故障演练关掉Redis模拟一次宕机看看数据库兜底能不能撑住。这一步能帮你提前发现很多看似正确实则脆弱的假设。