1. 为什么接口会重复调用,以及幂等性为何能救命
先聊一个场景:你在凌晨三点被电话叫醒,线上支付系统的回调接口收到了同一笔支付结果通知——第一次处理成功,第二次、第三次继续处理同一笔订单。如果接口没有幂等保护,第二三次调用可能会把订单金额再次加进用户余额,或者重复扣除库存。更常见的是,用户手速快,连续点击了两次“提交订单”按钮,后台收到两个内容一模一样的POST请求。
REST API里,POST天然不幂等,因为每次调用都可能创建新资源。但在真实业务里,我们恰恰需要用POST处理支付、下单、转账这类敏感操作,这就会造成一个矛盾:客户端可能因为网络重试、超时重发、用户手动刷新导致同一请求被发送多次,服务端如果来一次处理一次,轻则产生垃圾数据和重复记录,重则造成资金损失、库存超卖。
所谓幂等性,简单说就是:同一个请求执行一次和重复执行多次,最终产生的结果是一致的。举个例子,GET /api/products/1这个接口天然幂等,因为无论你调用多少次,返回的都是同一件商品的信息,客户端和服务器都不会因为重复调用而产生副作用。而POST /api/orders这种接口就不一样,调用一次创建一笔订单,调用两次就是两笔相同订单——这就产生了副作用。
真正需要幂等保护的,不只是POST,还包括PUT、PATCH、DELETE。PUT是幂等的,因为它是对资源的整体替换,同一个请求发两遍结果不变;DELETE也是幂等的,删除一个不存在的资源返回404或者200都行,但你不能因为重复删除就把业务状态搞乱。问题在于,很多团队的接口设计并没有把HTTP语义和业务状态结合起来,光靠PUT/DELETE的语义是远远不够的。核心矛盾在于:重复请求进入业务处理代码之前,你就需要把它拦住。
所以,幂等性不是一个可选项,而是一个生产级API的必选项。一个没有幂等保护的支付回调接口,在流量高峰期、网络抖动频发的环境下,就是一颗定时炸弹。而本文要解决的,就是如何在ASP.NET Core 8.0中,通过一套完整、可落地的方案,从请求层面拦截重复调用,从源头终结重复请求带来的灾难性后果。
在动手写代码之前,我们要先明确一个关键认知:幂等性不是一个Filter就能解决的小功能,它需要和请求生命周期、缓存机制、并发控制、异常处理做深度整合。换句话说,它是横切关注点,是架构层面的能力,而不是某某Controller里的一个辅助方法。
2. 幂等性的核心矛盾:客户端重试与服务端状态的信息不对称
2.1 客户端为什么一定会重复发请求
在分布式系统里,网络是不可靠的,这个前提必须接受。客户端调用一个支付接口,请求到达服务端之后,响应在回传的过程中超时了。客户端等了很久没收到结果,它会怎么做?大概率是重试。但问题是,服务端到底有没有处理成功?有三种可能性:请求压根没到达服务端;请求到达了但处理过程报错;请求处理成功了但响应丢失。
如果是第三种情况,客户端重试就会导致同一个请求被服务端重复处理。在支付、转账场景里,这意味着重复扣款;在下单场景里,这意味着重复建单。还有一种更隐蔽的情况:微服务之间的调用链很长,上游服务调用下游服务超时后重试,下游服务可能已经处理成功,上游并不知道,这一重试,就直接把业务数据写重复了。
所以,幂等性的本质是:在客户端和服务端之间,建立一套对“请求唯一性”的共识机制。服务端必须有能力识别“这个请求我已经处理过了”,从而直接返回上一次的结果,而不是再处理一遍。
2.2 幂等键:客户端必须诚实提交的唯一凭证
要实现上面的共识,光靠服务端自己判断是做不到的。你不能靠请求体内容的Hash来判断是不是重复请求,因为内容相同的两个POST请求完全可能是两次合法的不同操作——比如用户购买了两件完全一样的商品,两笔订单的内容一模一样,但它们是两笔不同的业务。所以本质上,需要客户端在发起请求时,带上一个唯一的幂等键。
这个幂等键,业界通常放在自定义请求头里,比较常见的命名是Idempotency-Key、X-Request-ID、X-Idempotency-Key。它的生成规则没有统一标准,但要求是在业务上下文内全局唯一。比如支付回调里,可以用支付平台的流水号;下单操作里,可以用GUID;消息队列消费场景里,可以用消息ID。核心原则是:同一个业务操作,即使重试多次,幂等键必须一模一样;不同的业务操作,幂等键必须不一样。
举个例子,客户端提交订单时生成一个GUID放在Idempotency-Key请求头里,第一次请求超时了,客户端再用同一个GUID重试,这时候服务端查一下就知道这个订单已经创建成功了,直接返回第一次的结果即可。如果客户端重新生成一个GUID来重试,那在服务端看来这就是一笔全新的操作,会再次创建订单——这依然是合理的,因为客户端明确表示这是新操作。
2.3 用DB还是用Redis实现——先看两个方案的底层逻辑
这里要回应一个热门讨论:幂等性检查用DB实现好还是Redis实现好。很多人在网上争论不休,实际上答案不是绝对的,要看你的业务场景、数据一致性的要求、成本预算。
DB方案(最常用的是去重表):创建一张幂等记录表,主键或唯一键直接使用Idempotency-Key字段。请求进来时先INSERT一条记录,如果INSERT成功,说明这是第一次请求,继续处理业务;如果INSERT报唯一键冲突,说明这个请求已经处理过了,直接返回之前的结果。它的核心优势是强一致,因为数据库本身提供了唯一约束的原子性保证,不存在并发下的竞态条件。缺点是:每次请求都多一次数据库写操作,在高并发下会增加数据库压力;而且你需要额外设计一张表,越复杂的业务,这张表要记录的字段越多,查询历史结果还需要把响应内容也存进去,表会越来越大,必须配套做定期清理。
Redis方案:用SETNX命令来实现,同样的原理,SETNX成功说明第一次请求,SETNX失败说明重复请求。Redis方案的优势是性能极高,单次操作在毫秒以内,而且可以通过设置过期时间自动清理,不用操心表数据膨胀的问题。但它的弱点是:Redis是AP系统,极端情况下可能丢数据;如果Redis服务抖动,你的幂等保护也就跟着失效。另外,Redis的数据没有事务保证,处理完业务之后还要额外的操作去关联“幂等键→响应内容”的映射关系,设计不好容易产生数据不一致。
我个人的建议是:优先用Redis,原因有三。第一,幂等检查本质上是一个短时间窗口内的去重问题,业务操作完成后,幂等键的保存时间不需要很长,通常保留10分钟到24小时就够了,这正是Redis过期机制的强项。第二,现代生产环境基本都有Redis,运维成本已经存在,多用一个key的开销微乎其微。第三,Redis配合分布式锁可以很好地解决并发场景下的幂等问题。但是,如果你的核心业务是资金、账务这类对一致性要求极其苛刻的场景,并且并发量在可接受范围内,使用数据库唯一约束的方案会更稳妥。所以我这篇文章里的代码示例,选用Redis作为主实现,同时会给出DB方案的对照思路。
3. 生产级幂等中间件的完整设计与源码解析
3.1 整体架构与核心思路
在设计这套幂等方案时,我遇到了两个实际问题:
第一个问题是,ASP.NET Core的请求管道很灵活,Middleware和Filter都能做这件事,选哪个?我用的是Middleware,原因很简单:Filter只能作用在一部分接口上,而幂等中间件作为横切关注点,需要覆盖所有需要保护的接口,在管道更前置的位置统一处理,才能让Controller和业务代码完全不感知幂等逻辑的存在。
第二个问题是,拿到重复请求之后直接返回什么?如果你的业务是创建订单,第一次请求成功返回订单号,那第二次重复请求应该返回什么?很多人简单地返回409 Conflict或者返回“重复请求”的提示,这是不对的。正确的做法是:保存第一次请求的响应内容,后续重复请求直接返回完全一样的响应。这样客户端重试时会发现自己拿到的结果和第一次一致,就不会再纠结是请求失败还是成功,这就是“无副作用”这个概念的核心。
整体设计分为四个部分:
- 幂等键提取器:从请求头提取幂等键,作为判断重复请求的唯一凭证。
- Redis分布式锁中间件:并发场景下保证同一直幂等键同时只有一个请求在处理。
- 响应缓存机制:保存第一次请求的响应内容,供后续重复请求直接返回。
- 异常兜底处理:幂等键缺失、Redis连接失败等异常情况,不能影响正常业务流程。
3.2 搭建环境与必备包
代码基于.NET 8.0,需要安装以下NuGet包:
dotnet add package StackExchange.Redis -v 2.7.3 dotnet add package Microsoft.AspNetCore.Mvc.NewtonsoftJson -v 8.0.0第一个包是Redis官方推荐的客户端库,第二个包是为了在响应序列化时有更多控制权,后面的代码会用到。如果不想用NewtonsoftJson,只用系统自带的System.Text.Json也完全可以,但要注意泛型序列化和匿名对象的处理方式有些差别,后面的代码我会按照NewtonsoftJson的方式写,你如果换成System.Text.Json需要微调。
然后准备好你的Redis实例,本地用Docker启动最简单:
docker run -d --name redis-dev -p 6379:6379 redis:7.2-alpine3.3 幂等键提取器:统一的请求唯一性入口
幂等键的提取是第一步,也是最容易被忽略的一步。很多团队把幂等键直接硬编码在Controller里,每个接口提取方式都不一样,最终维护成本很高。我的做法是做一个单独的提取器,支持多个来源的幂等键提取:先用Idempotency-Key头的值,没有就用X-Request-ID,再没有就尝试从查询字符串IdempotencyKey或者请求体里某个字段取。这样对不同客户端接入方式都有一定的兼容性。
public class IdempotencyKeyExtractor { private static readonly string[] HeaderNames = { "Idempotency-Key", "X-Request-ID", "X-Idempotency-Key" }; public string? Extract(HttpContext httpContext) { var request = httpContext.Request; foreach (var headerName in HeaderNames) { if (request.Headers.TryGetValue(headerName, out var value) && !string.IsNullOrWhiteSpace(value)) { return value.ToString(); } } if (request.Query.TryGetValue("idempotencyKey", out var queryValue) && !string.IsNullOrWhiteSpace(queryValue)) { return queryValue.ToString(); } return null; } }这段代码本身不复杂,但有一个地方要注意:幂等键必须具备“业务语义唯一性”,即它必须能代表一个具体的业务操作。如果客户端忘了带这个Header,你需要有一个明确的策略,我建议是:出于安全考虑,直接返回400 Bad Request,拒绝请求,因为一个没有幂等键的POST请求,等于让服务端进入“裸奔”状态,无法保证不产生重复副作用。
但是如果你的接口是给内部老系统用的,升级改造比较难,可以把策略调整为“无幂等键则跳过幂等检查、照常处理”。这个开关我建议做成可配置项,而不是写在代码里。
3.4 Redis幂等中间件核心代码:从设计到实现
接下来是核心部分——幂等中间件。代码逻辑可以拆成以下几个步骤:
- 提取幂等键,如果为空走“跳过”策略。
- 拼接Redis中操作这个幂等键的Key,例如
idempotency:{key}。 - 使用SETNX命令尝试写入一个初始标记,如果写入成功说明是第一次请求,继续往下走;写入失败说明是重复请求,进入等待响应结果的分支。
- 第一次请求处理完成后,把响应状态码和响应内容保存到Redis中。
- 重复请求来临时,直接从Redis里取缓存的结果,拼装为HTTP响应返回。
在这段逻辑中,最大的坑其实在“并发”和“缓存过期”这两个细节,我先直接给出完整的中间件代码,再逐段讲解关键点。
public class IdempotencyMiddleware { private readonly RequestDelegate _next; private readonly IConnectionMultiplexer _redis; private readonly IdempotencyKeyExtractor _keyExtractor; private readonly IdempotencyOptions _options; public IdempotencyMiddleware( RequestDelegate next, IConnectionMultiplexer redis, IdempotencyKeyExtractor keyExtractor, IOptions<IdempotencyOptions> options) { _next = next; _redis = redis; _keyExtractor = keyExtractor; _options = options.Value; } public async Task InvokeAsync(HttpContext context) { if (HttpMethods.IsGet(context.Request.Method) || HttpMethods.IsOptions(context.Request.Method)) { await _next(context); return; } var idempotencyKey = _keyExtractor.Extract(context); if (string.IsNullOrEmpty(idempotencyKey)) { if (_options.RequireIdempotencyKey) { context.Response.StatusCode = StatusCodes.Status400BadRequest; await context.Response.WriteAsJsonAsync(new { error = "Missing idempotency key. Please include Idempotency-Key header." }); return; } await _next(context); return; } var db = _redis.GetDatabase(); var redisKey = $"idempotency:{idempotencyKey}"; var token = Guid.NewGuid().ToString("N"); var acquired = await db.StringSetAsync(redisKey, token, _options.LockTimeout, When.NotExists); if (!acquired) { await HandleDuplicateRequestAsync(context, db, redisKey); return; } var originalBodyStream = context.Response.Body; var responseBuffer = new MemoryStream(); context.Response.Body = responseBuffer; try { await _next(context); if (context.Response.StatusCode >= 200 && context.Response.StatusCode < 300) { var bodyBytes = responseBuffer.ToArray(); var responseBody = Encoding.UTF8.GetString(bodyBytes); await db.HashSetAsync(redisKey, new[] { new HashEntry("statusCode", context.Response.StatusCode), new HashEntry("body", responseBody), new HashEntry("processedAt", DateTimeOffset.UtcNow.ToUnixTimeSeconds()) }); await db.KeyExpireAsync(redisKey, _options.CacheDuration); await responseBuffer.CopyToAsync(originalBodyStream); } else { await responseBuffer.CopyToAsync(originalBodyStream); await db.KeyDeleteAsync(redisKey); } } catch (Exception) { await db.KeyDeleteAsync(redisKey); await responseBuffer.CopyToAsync(originalBodyStream); throw; } finally { context.Response.Body = originalBodyStream; } } private static async Task HandleDuplicateRequestAsync(HttpContext context, IDatabase db, string redisKey) { for (var i = 0; i < 50; i++) { var redisValue = await db.HashGetAsync(redisKey, "statusCode"); if (!redisValue.IsNull) { context.Response.StatusCode = (int)redisValue; var body = await db.HashGetAsync(redisKey, "body"); await context.Response.WriteAsync(body.HasValue ? body.ToString() : string.Empty); return; } await Task.Delay(100); } context.Response.StatusCode = StatusCodes.Status409Conflict; await context.Response.WriteAsync("Request is still being processed. Please retry later."); } }我逐段拆一下这里的逻辑。
首先,GET和OPTIONS请求直接放行。原因很简单,GET是查询操作,天然幂等,不需要也不应该用幂等中间件去拦截,否则会让正常的查询带上不必要的锁开销和缓存占用。OPTIONS是CORS预检请求,也不能拦截。
然后,用SETNX做原子性的“登记”操作。这里的key是idempotency:{幂等键},写入的value是一个GUID随机token,过期时间是锁超时时间。When.NotExists保证了原子性:多个并发请求同时到达时,只有一个能成功写入,其他全部进入重复分支。这也就是前面说的“让中间件替你锁住整个请求”。
很多人在实现幂等时忽略了一个重要问题:如果两个一模一样的POST请求同时到达,服务端会怎么处理?如果没有锁保护,两个请求都能通过幂等键检查,都会进入业务代码,重复错误照样发生。所以这里我用了Redis SETNX来保证,同一个幂等键的并发请求里,只有一个能拿到处理的资格。
接着,保存第一次的响应内容。这里我把context.Response.Body替换成了一个MemoryStream,这样Controller和中间件之后生成的响应都会先写入内存缓冲流,而不是直接发送到客户端。请求完全处理完之后,再从缓冲流里拿响应内容,存到Redis的Hash结构中,然后把缓冲流里的内容复制到原始的Response.Body。第二个请求来的时候,直接读Hash里的statusCode和body,原样返回。
为什么要用Hash而不是简单的String来缓存响应?因为你要保存的信息不是一个维度——“状态码”和“响应体”是两个字段,用Hash存储结构更清晰,也方便后续单独更新某个字段,不需要整个字符串反序列化。这里还可以扩展存储“响应头Content-Type”等字段,思路是一样的。
错误处理上,非2xx响应直接删除Redis key。这是比较关键的一个判断,很多实现是把错误响应也缓存起来,导致同一个错误请求永远返回同样的错误,客户端根本没法通过重试解决问题。正确的策略是:如果第一次请求返回了500,那说明这次处理实质上是失败的,没有产生业务副作用,不能占用幂等缓存,必须把key删掉,让客户端重试时能重新进入业务逻辑。而4xx(参数错误、校验失败)的情况,严格来说也不算成功,也应删除key或根据具体业务决定,我的建议是统一删除,原因后面在避坑部分会细说。
超时兜底。如果重复请求进来时,第一次请求还没有处理完成(缓存的响应还没写入),那就等待,每100ms轮询一次,最多等待5秒。如果5秒还没等到结果,返回409让客户端稍后重试。这里还有一点要注意:如果想更稳健地判断“第一次请求是否正在处理中”,可以把SETNX成功时写入的token当作线程标识,轮询时检查Hash里是否已经有数据,如果没有则看这个key还存在不存在,如果key都不存在了说明第一次请求已经失败并且清除了key,这时候可以直接放行重试。
为了让上面这套逻辑更经得起真实流量考验,我在中间件之外设计了IdempotencyOptions配置类,便于按需调整参数:
public class IdempotencyOptions { public bool RequireIdempotencyKey { get; set; } = true; public TimeSpan LockTimeout { get; set; } = TimeSpan.FromSeconds(5); public TimeSpan CacheDuration { get; set; } = TimeSpan.FromMinutes(15); public int MaxRetryCount { get; set; } = 50; }LockTimeout控制Redis里锁占用的时长,不能太短,否则长请求还没处理完锁就自动释放了导致重复请求进入;也不能太长,否则Redis内存白白占用。我一般根据业务接口P95响应时间乘以2。CacheDuration控制缓存过期时间,这个取决于你的客户端最长会等多长时间才发起重试。如果客户端在10分钟之内一定会重试完,那缓存设15分钟就够了,不用设成1天。缓存时间过长,会导致Redis内存里堆积大量无用的幂等记录。
3.5 注册中间件与精细化配置
中间件写好之后,注册的代码也很重要,有几个细节会影响最终效果。
public static class IdempotencyMiddlewareExtensions { public static IApplicationBuilder UseIdempotency(this IApplicationBuilder builder, Action<IdempotencyOptions>? configure = null) { if (configure != null) { builder.ApplicationServices.GetRequiredService<IOptions<IdempotencyOptions>>(); } return builder.UseMiddleware<IdempotencyMiddleware>(); } }在Program.cs里注册:
var builder = WebApplication.CreateBuilder(args); builder.Services.AddSingleton<IdempotencyKeyExtractor>(); builder.Services.AddSingleton<IConnectionMultiplexer>(sp => { var configuration = builder.Configuration.GetConnectionString("Redis"); return ConnectionMultiplexer.Connect(configuration); }); builder.Services.Configure<IdempotencyOptions>(builder.Configuration.GetSection("Idempotency")); var app = builder.Build(); app.UseIdempotency(); app.MapControllers(); app.Run();UseIdempotency中间件的注册位置有讲究:必须在Routing和Endpoint中间件之后、在Controller执行之前。因为ASP.NET Core 8.0里,Endpoint的确定发生在Routing中间件里,如果幂等中间件注册在Routing之前,你就在该中间件里无法感知当前请求对应哪个Endpoint,也就很难做“按接口级别”的精细控制。而注册在UseRouting()之后、UseEndpoints()之前,既不会影响路由匹配,又能保证在进入Controller之前完成幂等检查。
配置文件appsettings.json里加上对应的Redis连接串:
{ "ConnectionStrings": { "Redis": "localhost:6379,abortConnect=false" }, "Idempotency": { "RequireIdempotencyKey": true, "LockTimeout": "00:00:05", "CacheDuration": "00:15:00" } }abortConnect=false是我强烈建议加上的一项配置。它表示即使Redis暂时无法连接,也不会让调用方直接抛异常,而是先尝试在后台自动重连。这样即便Redis出现短暂故障,也不至于让业务请求直接挂掉。但要注意,这只是“容忍故障”的方式,Redis连接恢复后,幂等保护的语义可能短暂失效,所以在最极端的场景下,如果你的接口一个请求都不能重复执行,你必须在中间件里对Redis连接状态做检查,发现异常直接返回503,而不是放行业务逻辑。这块的取舍每个团队都不一样,我见过不少出事的案例都是因为Redis挂掉之后,“放行”策略导致数据库写入了大量重复数据。
4. 并发控制与Redis故障场景的弹性问题
4.1 第一次请求尚未完成,重复请求到达怎么处理
这是幂等中间件最容易翻车的一个场景。我用一个例子来说明:
用户在这台手机上点击“提交订单”,请求到达服务端。中间件用SETNX成功获取了锁,进入Controller执行业务逻辑,这个过程比较慢,比如调用外部支付网关用了3秒。用户在1秒后见没反应,又点了两次“提交订单”。这时候这两个重复请求到达中间件,SETNX失败(因为第一次的锁还没释放),进入了HandleDuplicateRequestAsync方法。
此时如果代码写得不严谨,直接返回“重复请求”,那就是坑用户了——因为第一次请求明明还没有处理完,你告诉用户“处理中”或者“重复请求”,用户完全没有安全感。我上面的轮询逻辑就是干这个的:重复请求先等着,每隔100ms查询Redis里的Hash是否已有缓存数据,一旦发现第一次请求写入了响应,立即原样返回。如果等了很久还没缓存,说明第一次请求要么还在极慢处理中,要么Redis连接出了异常。在这个超时时间的选择上,我的经验是用RequestTimeout + 2秒,给足前排请求的缓冲余量。
4.2 Redis故障时的策略选择:向保留系统可用性低头还是坚决拒绝
在设计幂等中间件时,有一个绕不开的决策:Redis不工作了,你的接口怎么办?
回退方案A:直接返回503 Service Unavailable。这种方式最安全,因为你知道现在无法识别重复请求,所以宁可直接拒绝所有请求,也不让任何重复请求漏进业务层。缺点很明显:Redis挂了,所有需要幂等保护的接口全部不可用,系统可用性直接受到影响。
回退方案B:跳过幂等检查,直接把请求交给Controller处理。这种方式保证系统的可用性,但代价是可能产生重复数据。如果你的业务本身对重复数据有兜底机制(比如数据库层有唯一约束),这是一种可接受的策略。
回退方案C:降级到本地内存锁。Redis挂了,那就用进程内的ConcurrentDictionary记录最近处理过的幂等键,然后利用本机的.NET内存缓存做短期去重。优点是快速,缺点是多实例部署时每台机器各自为战,幂等保护自然就不全局一致了。
我个人推荐的策略是:根据接口的重要性分级处理。资金类的核心链路用方案A,宁可牺牲可用性也不能出错;非核心的普通业务用方案B,接受小概率的重复数据并让业务层兜底。要实现这个分级,可以在配置里按路径匹配规则加一个IdempotencyMode的属性,不同路径或Controller下可以使用不同的策略。
4.3 清理策略与内存膨胀问题
Redis里的幂等Key会持续增加,必须有一个清理机制。我用的方式主要有三层:
第一层是过期时间。写入缓存响应时,设置KeyExpireAsync(redisKey, CacheDuration),15分钟或30分钟之后自动过期,这是最基础的兜底。
第二层是主动清理。可以写一个后台定时任务,每小时扫描一次idempotency:*前缀的Key,删除“处理时间超过一天”的记录。虽然Redis自动过期机制已经工作了,但主动删可以更早释放内存,尤其在Key数量很大的场景下,能让内存水位更稳定。
第三层是容量限制。加一个监控指标,统计Redis里幂等Key的总量,超过阈值时就预警。生产环境里你不能让一个“辅助功能”无上限地消耗Redis内存,否则最后会因为内存淘汰策略误伤其他重要数据。
5. 落到真实Controller:从订单创建到支付回调的幂等实战
5.1 订单创建接口的无缝接入
有了中间件,Controller端代码几乎不用改,只保留正常业务逻辑即可。下面是一个简单的订单创建接口示例:
[ApiController] [Route("api/[controller]")] public class OrdersController : ControllerBase { private readonly IOrderService _orderService; public OrdersController(IOrderService orderService) { _orderService = orderService; } [HttpPost] public async Task<ActionResult<OrderResponse>> CreateOrder(CreateOrderRequest request) { var order = await _orderService.CreateAsync(request); return Ok(new OrderResponse { OrderId = order.Id, TotalAmount = order.TotalAmount, Status = order.Status }); } }Controller没写任何幂等相关代码。客户端第一次请求携带Idempotency-Key: 8f14e45fceea167a5a36dedd4bea2546,中间件正常接收、处理并缓存响应。如果客户端在超时后携带完全相同的幂等键重发,中间件会直接返回缓存的响应,Controller根本不会进入第二次。这就是横切关注点的价值——业务代码不需要知道幂等是怎么实现的,只需要知道“我处理一次就够了”。
5.2 支付回调接口的脆弱链路如何被保护
支付回调的特殊性在于:第三方支付系统会按照自己的重试策略,在几秒到几天内反复推送同一条回调通知。如果你的回调接口不幂等,第一笔订单对账完成后,后续的重复通知可能造成重复入账或对账异常。
用支付回调来验证中间件再合适不过。支付平台推送回调时,请求里通常自带transaction_id或event_id,这个值天然适合做幂等键。可以在Controller里显式地从请求体或Request头提取该值设置到响应中,或者配合前面写的提取器逻辑。重要的是:同一个支付流水号的回调通知,不管推送多少次,业务上只能成功处理一次。这个需求几乎是教科书级别的幂等应用场景。
防御之外的另一个问题是“顺序”问题:如果第一条回调消息还在处理中,第二条就来了,中间件的锁机制会让第二条进入等待轮询,直到第一条完成后直接返回第一条的结果。这样保证了同一个流水号的处理顺序是确定的,不会出现两笔并发处理同一订单而互相覆盖状态的情况。
5.3 消息队列消费场景同样适用
用ASP.NET Core做后台消费者(比如RabbitMQ、Kafka消费者)时,消息消费也可能因为消费者宕机、消费超时等原因被重复投递。消费端幂等可以在消息进业务逻辑之前做一次“以消息ID为幂等键”的检查。不过这里的实现和HTTP中间件有一点差别:消息消费者不能直接复用HTTP中间件,需要把幂等检查的Redis操作抽成一个可复用的Service。
public class IdempotencyService { private readonly IDatabase _db; private readonly TimeSpan _cacheDuration; public IdempotencyService(IConnectionMultiplexer redis, TimeSpan cacheDuration) { _db = redis.GetDatabase(); _cacheDuration = cacheDuration; } public async Task<bool> TryAcquireAsync(string idempotencyKey) { return await _db.StringSetAsync( $"idempotency:{idempotencyKey}", Guid.NewGuid().ToString("N"), _cacheDuration, When.NotExists); } public async Task ReleaseAsync(string idempotencyKey) { await _db.KeyDeleteAsync($"idempotency:{idempotencyKey}"); } }这样,不管是HTTP接口还是消息消费者,都能在核心链路之前做一次幂等检查,架构完全统一。
6. 实测:并发压测、异常排查、性能损耗与固有权衡
6.1 并发场景下怎么压测幂等效果
要验证自己的幂等实现是不是真的“并发安全”,用JMetter或自定义的并发工具把同一个请求用多个线程同时发出去。
设想的压测方法是:设计一个测试端点,模拟业务处理耗时3秒,然后用100个并发线程同时发送携带同一个幂等键的POST请求。预期结果应当是:100个请求里,只有1个请求真实进入Controller执行业务逻辑,其余99个请求返回的状态码和响应体一模一样,且整体耗时不超过5秒(因为其他人都在轮询等待)。如果在压测中发现有多个请求进入了业务逻辑,那你就要排查SETNX那段代码是否被改出了问题,或者Redis部署是否出现了主从切换之类的故障。
另外一个值得压测的点是无幂等键的POST请求。如果按前面推荐的策略设置RequireIdempotencyKey=true,那么所有没带幂等键的POST都会被拦截,返回400。这个策略在压测中可能表现为大量400响应,你要能区分这是预期行为还是配置错误。
6.2 常见问题排查与处理顺序
问题一:重复请求返回了500而不是缓存的响应。
排查思路:打开Redis客户端,手动查询idempotency:{key}这个Hash是否存在。如果不存在,说明第一次请求没有成功写入缓存;如果存在,说明中间件在读取Hash到返回响应之间可能出了异常,重点检查context.Response.WriteAsync那段代码和后置的Body恢复逻辑。
问题二:拿不到的幂等键导致正常用户无法下单。
大多数情况是客户端没有按规范传Idempotency-Key请求头。排查时可以打印请求头列表,看看实际传的Header名是不是拼错了,比如传了idempotency-key(小写)还是Idempotency-Key。ASP.NET Core的Header查找不区分大小写,但Reverse Proxy层有时会把Header改名、吞掉,一定要在第一个中间件里就做一次请求头日志记录来定位问题。
问题三:Redis内存涨得很快。
排查两个方向:是不是幂等Key没有设置过期时间;是不是业务量太大且缓存时间太长。前者直接把Key删掉看自动过期是否生效;后者调短CacheDuration的配置即可。Redis内存如果淘汰策略是allkeys-lru,那幂等Key可能会被淘汰掉,导致重复请求漏过检查,这个在压测时也要当成负面用例来测。
问题四:lock超时时间太短导致长请求重复执行。
这是比较隐蔽的问题。假设你的业务接口P99响应时间是6秒,而LockTimeout设置的是5秒,那么在第5秒时Redis key自动过期,一个原本没资格执行的重复请求会在这个窗口期成功SETNX,进入Controller执行第二次业务逻辑。解决方式很简单,LockTimeout必须大于接口的P99响应时间,留足余量。压测时可以故意把LockTimeout调得很短来验证这个问题是否会被触发,然后再调回到合适的值。
6.3 性能损耗到底有多大
先说结论:在绝大多数场景下,这个损耗可以忽略不计,前提是你用的Redis在同一局域网/VPC内。
每个请求的额外开销主要是:一次Redis SETNX(毫秒级)、请求成功后再一次HashSet和KeyExpire(毫秒级)、重复请求到来时一次或几次HashGet(微秒级)。如果封装得当,每个请求平均增加约0.5~2ms的延迟,换回来的是资金安全和数据一致,这笔交易非常划算。
真正需要警惕的开销不是Redis操作本身,而是无幂等键拒绝请求时产生的错误日志洪峰,以及响应缓存写入大响应体时对Redis带宽的占用。如果接口响应体动辄几百KB,你还把它存进Redis,那么在流量高的时候,网络IO本身就可能成为瓶颈。此时可以考虑只缓存状态码和摘要信息(比如处理成功与否的标记),不存储完整响应体;但这样重复请求返回给客户端的内容就不是第一手响应了,需要业务层去决定返回什么。这是一对矛盾,我建议先存完整的响应体,等流量大了再优化不迟。
7. 生产环境还需考虑的三个细节,与我的最终建议
7.1 配置一体化:把幂等性的开关交给运维控制
中间件里我预留了RequireIdempotencyKey等配置项,生产环境中强烈建议把幂等的开关放进配置中心或环境变量,而不是代码里写死。上线初期要灰度一部分接口,直接把某些路由设置为“跳过幂等检查”;出了问题时,又要能一键关闭幂等保护来快速止损。这些需求都指向一个方向:幂等保护机制本身也必须是可运维、可观测的。配置项统一收口后,一个开关就能控制全部或局部接口的幂等策略。
7.2 响应头返回幂等键回执,方便客户端对齐
客户端在重试时,需要知道服务端是根据哪个幂等键返回的这次结果。建议在响应头里加上X-Idempotency-Key-Replayed之类的字段:如果请求被幂等缓存命中返回旧结果,这个Header值为true;如果是第一次处理,则为false。这样客户端可以明确判断,这是不是一次重放响应,避免客户端自己出现重复展示。实现也非常简单,在中间件处理重复请求分支时添加一个响应头即可。
7.3 从“拦重复”到“幂等业务设计”的进阶思考
中间件方案解决的是“HTTP请求层”的幂等,但真正复杂的业务还有“业务状态层”的幂等。比如一个订单状态机:订单从“待支付”到“已支付”再流转到“已发货”,如果回调重复通知把已经“已支付”的订单重新设置为“待支付”,这就算请求层幂等了,业务状态也被破坏了。所以生产级的做法是,在请求层幂等之外,业务层也要有状态迁移校验,同一个状态的流转要保证唯一路径。这两个机制缺一不可,请求层幂等是门卫,业务状态校验是内控,配合起来才能万无一失。
就我个人的实践经验来说,幂等性是一个典型的“做起来简单、做好很复杂”的架构主题。从简单的“请求去重”到并发控制,再到Redis故障降级、响应缓存策略、业务状态流转约束,每一个环节在真实流量下都会暴露新问题。建议大家先从核心接口接入中间件方案,把所有可观测数据都打点出来——每个被拦截的重复请求、每个返回缓存响应的请求、每个等待轮询超过2秒的请求,都记上日志——然后再慢慢优化处理策略。这么走完一圈,你的API离“无副作用”就不再是梦想,而是用代码堆出来的事实。