☰
ASP.NET Core 8.0幂等中间件从设计到落地,彻底拦截重复请求
2026/10/9 10:50:30 网站建设 项目流程

1. 重复请求的杀伤力,比你想的可怕得多

做高并发系统的人,几乎都经历过这一幕:客户端超时重试、消息队列重新投递、运维手动补单,结果用户账户被扣了两次钱、订单被创建了两条、积分被发了双份。我之前就处理过一次支付回调的线上故障,上游渠道因为网络抖动把同一笔成功通知重发了三次,等我们把三个请求全部处理完,用户的账户余额已经对不上了。最后查数据和人工核对现金流台账,整整折腾了一个下午。从那以后,我就下决心把所有会改变状态的接口都纳入幂等性保护范围。

所谓幂等性,简单讲就是同一个请求无论被客户端提交多少次,服务端只产生一次实际效果,或者说多次执行的结果与执行一次完全一致。放在ASP.NET Core 8.0的REST API场景里,这意味着接口设计必须能做到"重复请求不产生副作用"。这不是一个可选的加分项,而是金融交易、订单系统、支付回调、消息消费等核心链路里的硬性要求。

很多人觉得幂等性问题只是"前端防重复点击"就能解决,实际上前端按钮置灰、防重提交只是延迟了重复请求的到达时间,根本拦截不住超时重试、网关重放、消息总线重投这类服务端不可控的重复流量。真正可靠的方案必须在服务端构建一道屏障,用幂等键、分布式锁、状态存储来做多头控制。这篇文章我会从HTTP语义开始,一路讲到我在ASP.NET Core 8.0里实现的完整幂等中间件,包括源码拆分、Redis锁细节、测试用例和生产环境踩过的坑。如果你是后端开发、架构设计或者中间件维护者,这篇内容应该能帮你少走至少三个月的弯路。

2. 先理清边界:GET天然幂等,为什么还会出问题

2.1 HTTP方法语义里的幂等定义

RFC 7231对HTTP方法有明确的幂等区分。GET、HEAD、PUT、DELETE、OPTIONS、TRACE在语义上是幂等的,POST不保证幂等,PATCH也不保证。但请注意,这是协议层定义,不等于说你把一个接口标记为GET它就自动幂等了。如果一个GET接口内部偷偷更新了数据库的访问计数或者修改了缓存,那它就是有副作用的,只是客户端重复访问造成的伤害没有被量化出来。

更常见的反例是DELETE接口。协议上说DELETE是幂等的,但很多团队实现DELETE时会做"删除前必须存在"的校验,第一次删除返回200,第二次删除返回404,从客户端角度结果不同,从资源状态角度又都是"不再存在"。严格来说,资源层面的最终状态是一致的,所以这种实现也可以接受。但如果你在DELETE里面也读写了日志、更新了统计表,那就不再满足幂等语义。

所以我通常建议:接口是否幂等不能只看HTTP动词,要问一个核心问题——"这个操作如果重复执行N次,系统的最终状态是否等同于执行1次?"如果是,幂等;如果不是,就要额外保护。

2.2 哪些接口真正需要幂等中间件保护

实践中,真正需要做焦点保护的是POST和PATCH。POST天然不幂等,每次执行都会产生新资源,是重复下单、重复支付的罪魁祸首。PATCH因为是对资源做增量更新,重复执行可能叠加副作用,也需要保护。

有一种理解需要纠正:很多人觉得把POST改成PUT就幂等了。PUT是全量替换语义,理论上客户端重复提交同一个PUT请求,服务端应用后资源状态不变,但这有个前提——客户端必须提交完整的资源表示,且服务端不能有"重复提交就新建版本"之类的业务逻辑。如果PUT内部做了"每次请求都写一条操作流水",那它一样有副作用。所以幂等保护的对象不是某个动词,而是具体的操作语义。

在ASP.NET Core 8.0里,我习惯按这种表格来评估每个端点:

HTTP方法协议是否幂等常见实现能否保证幂等是否需要中间件保护
GET是看实现,查询类基本安全一般不需要
PUT是全量替换,状态一致视业务而定
DELETE是资源存在性校验会有状态码差异可选用
POST否每次都会创建新资源必须保护
PATCH否增量更新可能叠加必须保护
HEAD/OPTIONS是安全方法不需要

2.3 从请求管道看幂等拦截的最佳位置

ASP.NET Core 8.0的请求处理是一个管道模型,每个中间件可以决定是直接短路返回还是继续往下传。如果你自己写过中间件,会知道中间件的注册顺序决定了执行顺序。幂等拦截应该放在什么位置?一句话:要在真正执行业务逻辑之前完成检查和加锁,同时还得能够捕获业务执行后的响应结果。

WebApplication构建的管道中,app.UseRouting()之后会匹配到端点,MapControllers()这类终端中间件真正执行Controller Action。如果我把幂等中间件注册在app.UseRouting()之前,那么它看不到已经匹配的端点信息,但能拦截所有请求;如果注册在MapControllers()之后,它就来不及了,业务已经执行完了,响应已经发出,再取幂等键已经没有意义。

我的做法是在注册Controller之前、路由之后注入幂等中间件,这样Controller Action还是管道中的下一步,中间件既能在执行前检查幂等记录,又能在Action执行完成后捕获响应流缓存起来。不过如果你用app.UseMiddleware<IdempotencyMiddleware>()写在最前面也是可以的,只是端点元数据拿起来会麻烦一点,需要额外的Feature获取。具体注册代码在下一节的实现里会展示。

3. 生产级幂等中间件的完整实现:协议、存储与源码剖析

3.1 幂等协议怎么设计才不坑

整个幂等方案的第一步是定义客户端要传什么、服务端要存什么。行业内最常用的协议是Idempotency-Key请求头,客户端在发起会改变状态的请求时,带上一个唯一标识这个操作请求的键。服务端以这个键为粒度做去重。

这里有几个关键决策:

  • 键的生成责任在客户端。服务端无法判断两个请求是否"同一个业务意图",只有客户端知道这是不是同一笔交易的重复提交。所以客户端必须为每一次"预期只执行一次的业务操作"生成唯一键,例如支付单号本身、UUID、数据库主键,都可以作为Idempotency-Key。
  • 键的可用范围。我通常把键的作用域限定为"客户端标识+用户身份+接口路径"。也就是说,同一个客户端、同一个用户、同一个URL下,使用相同幂等键的请求共享同一份处理记录。这样可以防止某个客户端用自己的键U-A去干扰另一个客户端U-B的请求。实现时可以把这些信息拼接后做一个哈希,作为Redis存储的key后缀。
  • 有效期。不能无限期保留缓存,因为存储会膨胀。我一般设置24小时。超过有效期的旧记录会被清除,但这意味着超过24小时的超时重试有概率再次执行业务。如果业务要求彻底杜绝,就得靠数据库唯一索引,这个问题我会在后面的章节讲。

请求流程设计成三个分支:第一次请求,键不存在,执行正常业务并把响应缓存;第二次请求,键存在且状态为已完成,直接返回缓存的响应;请求正在处理中,则等待或者立即返回409,我选择等待锁释放后读取结果返回,这样客户端并发重试能得到一致的结果。

3.2 中间件核心源码逐段拆解

以下是我在ASP.NET Core 8.0里的核心实现,我把它拆成了三个类:配置项、存储抽象、中间件本体。先看配置项:

public class IdempotencyOptions { public string HeaderName { get; set; } = "Idempotency-Key"; public TimeSpan Expiry { get; set; } = TimeSpan.FromHours(24); public List<string> Methods { get; set; } = new() { "POST", "PATCH", "PUT" }; public int MaxCachedResponseSize { get; set; } = 256 * 1024; public bool RetryAfterEnabled { get; set; } = true; }

然后是存储抽象。这里的重点是读写幂等记录必须支持分布式环境,单机的ConcurrentDictionary只能用于本机测试,生产环境一定要用Redis或者数据库。

public interface IIdempotencyStore { Task<IdempotencyRecord?> GetAsync(string key, CancellationToken ct); Task<bool> TryCreateProcessingAsync(string key, IdempotencyRecord record, TimeSpan expiry, CancellationToken ct); Task CompleteAsync(string key, IdempotencyRecord record, TimeSpan expiry, CancellationToken ct); Task DeleteAsync(string key, CancellationToken ct); }

IdempotencyRecord至少包含三个部分:请求的哈希(防止同一个幂等键被塞进不同的请求体)、响应状态码、响应头的白名单集合、响应体字节数据,以及一个状态标记(Processing或Completed)。

中间件本体的核心逻辑放在InvokeAsync里。我简化掉日志和异常处理的部分,突出主线:

public class IdempotencyMiddleware { private readonly RequestDelegate _next; private readonly IdempotencyOptions _options; private readonly IIdempotencyStore _store; private readonly IIdistributedLockFactory _lockFactory; public static readonly byte[] EmptyBody = Array.Empty<byte>(); public async Task InvokeAsync(HttpContext context) { if (!_options.Methods.Contains(context.Request.Method, StringComparer.OrdinalIgnoreCase)) { await _next(context); return; } var key = GetIdempotencyKey(context); if (string.IsNullOrWhiteSpace(key)) { // 不带幂等键的写请求直接拒绝,防止绕过保护 context.Response.StatusCode = StatusCodes.Status400BadRequest; await context.Response.WriteAsync("Missing Idempotency-Key header for non-idempotent request."); return; } var storageKey = BuildStorageKey(context, key); await using var handle = await _lockFactory.AcquireLockAsync(storageKey, TimeSpan.FromSeconds(30)); var record = await _store.GetAsync(storageKey, context.RequestAborted); if (record is { Status: Status.Completed }) { // 命中了已完成的响应缓存,直接回放 await ReplayResponseAsync(context, record); return; } if (record is { Status: Status.Processing }) { // 正在处理中,这里我选择等锁后继续轮询,实际实现会做有限等待 record = await WaitForCompletionAsync(storageKey, context.RequestAborted); if (record is not null) { await ReplayResponseAsync(context, record); return; } } // 新建处理中的记录,然后执行真正的业务逻辑 var newRecord = new IdempotencyRecord { RequestHash = ComputeHash(context.Request), Status = Status.Processing }; await _store.TryCreateProcessingAsync(storageKey, newRecord, _options.Expiry, context.RequestAborted); // 捕获响应 var originalBody = context.Response.Body; await using var buffer = new MemoryStream(); context.Response.Body = buffer; try { await _next(context); // 执行业务逻辑成功,读取响应字节并缓存 var responseBytes = buffer.ToArray(); if (responseBytes.Length <= _options.MaxCachedResponseSize) { var completedRecord = new IdempotencyRecord { RequestHash = newRecord.RequestHash, Status = Status.Completed, StatusCode = context.Response.StatusCode, ResponseHeaders = CaptureHeaders(context.Response), ResponseBody = responseBytes }; await _store.CompleteAsync(storageKey, completedRecord, _options.Expiry, CancellationToken.None); } else { // 响应太大,放弃缓存,但保留已处理标记,允许客户端重试但是拿不到旧响应 await _store.CompleteAsync(storageKey, IdempotencyRecord.NoResponse(), _options.Expiry, CancellationToken.None); } buffer.Position = 0; await buffer.CopyToAsync(originalBody); } catch { // 业务逻辑执行过程中抛异常,必须删除幂等记录,否则客户端重试会被永远挡住 await _store.DeleteAsync(storageKey, CancellationToken.None); throw; } finally { context.Response.Body = originalBody; } } }

这里有一个容易被忽略的生产级细节:在_next(context)执行之前,我先在Redis里写入了一条Processing状态的记录。这非常重要——如果两个并发请求同时到达,都通过了锁检查,由于锁的存在,第二个请求会等在AcquireLockAsync这里,等第一个请求释放锁后,第二个请求读取到的记录状态已经是Completed或Processing,于是直接复用结果。

3.3 Redis锁与存储的原子性细节

中间件里我直接用了IIdistributedLockFactory,来自DistributedLock库,这是一个比裸写Redis Lua脚本更可靠的选择。如果你不想引入额外依赖,也可以直接在Redis上用SET key value NX EX做互斥锁,但要注意几个坑:

  • 必须用NX和EX同时设置。如果先SETNX再EXPIRE,进程在两步之间崩溃会导致锁永远不释放。
  • 释放锁时要检查value是不是自己的持有标识,防止误删了别人的锁。这个要用Lua脚本保证原子性,或者用DistributedLock库封装。
  • 锁的过期时间怎么定。我建议根据业务最长执行时间动态计算,比如正常接口一般在5秒内完成,就把锁超时设30秒,并且用看门狗做自动续期。DistributedLock库的AcquireLockAsync可以传入超时时间并自动续期,这个用起来最省心。

存储我最初用的是IDistributedCache,因为.NET内置了对Redis和内存缓存的支持,接口统一。但后来我发现IDistributedCache有个坑:它没有提供"只有当键不存在时才写入"的原子方法。要实现TryCreateProcessingAsync这种"占坑"行为,你必须在Redis上用Lua脚本:

if redis.call('exists', KEYS[1]) == 0 then redis.call('set', KEYS[1], ARGV[1], 'ex', ARGV[2]) return 1 else return 0 end

我最后的实现是直接封装了一个RedisIdempotencyStore,底层用StackExchange.Redis,把"创建Processing记录""替换为Completed记录""删除"都做成Lua脚本,避免竞态。存储结构用一个Redis Hash来保存记录字段,key类似idem:{storageKey}。

3.4 注册进ASP.NET Core 8.0并暴露到OpenAPI

注册中间件的代码很简单,但有两个细节值得说。第一,中间件需要依赖注入IHttpContextAccessor和选项,所以在Program.cs里要先配置服务:

builder.Services.AddHttpContextAccessor(); builder.Services.AddIdempotency(options => { options.HeaderName = "X-Idempotency-Key"; options.Expiry = TimeSpan.FromHours(24); }) .AddDistributedLocking((sp, o) => { o.Uri = new Uri(builder.Configuration["Redis:ConnectionString"]!); }) .AddStackExchangeRedisCache(o => { o.Configuration = builder.Configuration["Redis:ConnectionString"]; });

这里的AddIdempotency扩展方法,内部其实就是services.AddSingleton<IIdempotencyStore, RedisIdempotencyStore>()和services.AddSingleton<IdempotencyMiddleware>()。

第二,终端路由注册要放在中间件之后:

var app = builder.Build(); app.UseIdempotency(); app.MapControllers(); app.Run();

如果你用WebApplication的自动路由,MapControllers()之前加上UseIdempotency()就行。中间件会包裹所有的Controller Action,但只拦截配置的HTTP方法。

OpenAPI集成方面,我写了一个IOperationFilter,自动给所有非幂等方法加上Idempotency-Key请求头参数,并标注为必选。这样前端和调用方看Swagger页面就知道要传什么了。

public class IdempotencyHeaderOperationFilter : IOperationFilter { private readonly IdempotencyOptions _options; public void Apply(OpenApiOperation operation, OperationFilterContext context) { var methods = context.ApiDescription.HttpMethod; if (methods != null && _options.Methods.Contains(methods, StringComparer.OrdinalIgnoreCase)) { operation.Parameters ??= new List<OpenApiParameter>(); operation.Parameters.Add(new OpenApiParameter { Name = _options.HeaderName, In = ParameterLocation.Header, Required = true, Schema = new OpenApiSchema { Type = "string" }, Description = "客户端生成的唯一幂等键,用于表示这是同一次业务操作" }); } } }

4. 并发与状态恢复的魔鬼细节:响应流捕获、失败重试、锁超时

4.1 捕获响应流的正确姿势,以及为什么不能直接读

很多人在写中间件捕获响应时,会下意识地这样做:var body = context.Response.Body;,然后next之后再StreamReader去读body。这在ASP.NET Core里通常读不到内容,因为Response.Body默认是一个直接流向下游的Stream,不是MemoryStream,你读的时候它已经是空的了。

正确做法是像上面的代码一样,在执行next之前,把context.Response.Body替换成一个MemoryStream。业务代码向这个MemoryStream写入数据,我们后续才能从buffer.ToArray()里取回来。注意MemoryStream默认是动态扩容的,要限制缓存大小,防止超大响应打爆Redis,所以每个响应我都检查了MaxCachedResponseSize。

还有一个细节:捕获响应头。我只缓存白名单里的几个头,比如Content-Type、Location、ETag、Retry-After。如果一股脑把Set-Cookie也缓存会出问题,因为不同时间返回的Cookie可能不同,回放旧响应会把过期的Cookie发给客户端。建议把缓存头限定在少量几个业务关键头,其余都丢弃。

4.2 请求处理失败时,幂等记录要删还是留

这是幂等中间件设计里争论最多的问题。我的原则是:

  • 如果业务逻辑抛异常,删除幂等记录。因为这次执行没有产生有效的业务结果,客户端重试时应该允许再次执行。

  • 如果业务逻辑已经执行成功,但是响应流在回写过程中发生IO异常,这个情况很尴尬,因为业务状态可能已经变化了。我建议保留Completed状态,并记录日志,让运维介入。客户端如果再带同一个幂等键过来,会拿到一个缓存的状态码和空响应,客户端无法区分这是"处理成功但响应丢失",所以还得靠业务侧提供查询接口来对账。

  • 如果是因为客户端断连导致RequestAborted,这时候业务可能还在跑。我建议在catch里判断context.RequestAborted.IsCancellationRequested,这种情况不要立刻删除记录,而是保留Processing并设置一个短暂的TTL,等锁超时后自动清除。否则客户端可能因为网络抖动重连后同键重试,因为记录已删,会执行第二次业务逻辑,造成重复。

上面的代码为了简洁只写了catch { delete; throw; },生产实现要细化异常类型。这是我在实际项目中总结出来的教训:幂等中间件最怕的不是响应写不回去,而是异常处理逻辑把一个已经"半成功"的状态给清掉了。

4.3 锁超时、看门狗与时钟漂移

Redis锁最常见的故障是锁过期导致两个请求同时进入临界区。默认锁过期时间如果你设置30秒,业务执行超过30秒,锁就自动释放了,第二个请求会拿到锁进入业务逻辑,于是并发重复执行发生了。分布式锁库通常提供自动续期(看门狗)机制,DistributedLock会自动续期直到整个using作用域结束。我在生产环境一定要确认这点,否则就必须把锁超时设得比业务最大耗时大很多,但这样又会在业务进程崩溃时长时间占用锁。

另一个坑是多个Redis节点上的RedLock算法存在时钟漂移问题,严格可靠性场景下用独立外部协调服务(如数据库唯一约束)兜底更安全。我个人的经验是:如果业务容忍极小概率的重复,单节点Redis锁就够了;如果想做到绝对不重复,必须把幂等唯一键落到数据库的唯一索引上,中间件只是第一道网。

关于"正在处理中"的请求的等待逻辑,我没有写无限循环,而是用有限重试。客户端如果同时发了3个相同幂等键的请求,第一个处理完后,第二个拿到锁后看到Completed,直接回放;如果第二个请求在第一个处理过程中到来,它在锁里等待,直到锁释放,然后看到Completed。如果第一个业务要花很久,锁释放后第二个看到的还是Processing,此时我选择返回409 Conflict并带Retry-After响应头,让客户端稍后重试。这比无限轮询好,避免连接被主流程阻塞。

5. 用集成测试证明:同一请求重放不会触发副作用

5.1 WebApplicationFactory跑通并发重试场景

代码写完了不能只看逻辑,得用测试说话。我使用Microsoft.AspNetCore.Mvc.Testing里的WebApplicationFactory<TProgram>构造测试客户端,然后启动一个本地测试接口——这个接口每次被调用都会递增静态计数器,模拟有副作用的写操作。

测试用例分三类。第一类是顺序重放:同一个Idempotency-Key连续发两次POST,第一次返回201 Created,第二次返回201 Created,而且响应体完全一致,计数器只加了一次。第二类是并发重放:用Task.WhenAll同时发送5个相同幂等键的POST,最终只处理了1次。第三类是失败重试用例:第一次请求模拟业务抛异常返回500,中间件删除了记录,第二次用同一个键重试返回成功,验证"失败可以重试"的语义。

下面是一个顺序重放测试的核心代码:

[Fact] public async Task Same_IdempotencyKey_Same_Request_Executes_Once() { var factory = new WebApplicationFactory<Program>(); var client = factory.CreateClient(); client.DefaultRequestHeaders.Add("Idempotency-Key", Guid.NewGuid().ToString()); var payload = new StringContent("{\"name\":\"test\"}", Encoding.UTF8, "application/json"); var first = await client.PostAsync("/api/orders", payload); var second = await client.PostAsync("/api/orders", new StringContent("{\"name\":\"test\"}", Encoding.UTF8, "application/json")); Assert.Equal(first.StatusCode, second.StatusCode); var firstBody = await first.Content.ReadAsStringAsync(); var secondBody = await second.Content.ReadAsStringAsync(); Assert.Equal(firstBody, secondBody); Assert.Equal(1, Counter.Value); }

并发测试更关键,代码可以这样写:

var tasks = Enumerable.Range(0, 5) .Select(_ => client.PostAsync("/api/orders", payload)) .ToArray(); await Task.WhenAll(tasks); Assert.Equal(1, Counter.Value); foreach (var task in tasks) { Assert.Equal(HttpStatusCode.Created, task.Result.StatusCode); }

实际跑下来,这个测试在没有分布式锁时,Counter会变成2到5不等。加了锁之后稳定为1。

5.2 测试结果里暴露出的隐藏问题

第一版实现跑了这些测试后,我发现了两个有意思的问题。

第一个是响应流缓冲和内存增长。当并发测试发送大量请求时,每个请求的处理都先写入MemoryStream,如果Controller返回的响应体比较大(比如几十KB),缓存到Redis时会有明显的CPU和内存消耗。为了缩小测试和生产的不一致,我后来对响应体做了压缩判断——对超过阈值的响应不缓存body,只缓存状态码。这可能让客户端在幂等重放时拿不到具体响应体,但至少保证了不重复执行。

第二个是返回状态码语义的争论。对于第一个请求还在处理中时,第二个同键请求返回409 Conflict是否合适?有人倾向返回202 Accepted或者425 Too Early。我在生产系统里最终选择了409加Retry-After: 5,因为409传达的是"当前资源状态与请求冲突",非常符合幂等键正在被占用的语义。这个问题没有标准答案,团队内部定好规范就行。

6. 幂等不等于万能:数据库唯一约束、业务状态机与团队规范

6.1 为什么中间件之外还要有数据库兜底

中间件方案再谨慎,也无法100%消除重复。Redis主从切换导致锁丢失、幂等记录写入失败、业务自己在代码里绕过中间件直连别的服务,每一种情况都可能让重复请求漏网。

数据库层面的兜底依然是最保险的那道防线。以支付回调场景为例,我通常会在业务表上建立一个唯一约束,直接使用payment_request_id或者transaction_no作为唯一键。如果两个并发的SQL插入试图写入相同主键,数据库会拒绝第二个,应用层捕获到唯一约束冲突就当作"重复请求"处理,返回已经存在的结果。

这种方案的好处是确定性极强:只要数据库没有降级,重复就不存在。坏处是要改业务表结构,而且在写入之前需要多一次查询来判断是创建还是回放。正常的做法是中间件负责99%的重复流量拦截,数据库唯一约束负责那1%的极端情况。两个一起用,才能做到"最终状态绝对一致"。

6.2 幂等键的生成和使用规范,建议写进团队API规范

代码层面的实现再漂亮,如果客户端不按规矩传键,一切等于零。所以我强烈建议把幂等性规范写进团队的API设计文档。

  • 客户端生成幂等键的推荐方式是UUID v7,它拥有时间戳排序特性,对数据库索引友好。
  • 键必须对所有写操作唯一。同一个幂等键不能用于两个不同业务目的的请求。
  • 幂等键一旦创建,在该业务生命周期内不得改变。例如支付回调中的原始交易号,就是天然的幂等键。
  • 服务端要清晰区分"使用业务单据号作为幂等键"和"使用随机UUID作为幂等键"。前者利于业务对账,后者利于保护隐私。

我见过一个团队,客户端用时间戳加随机数的字符串当幂等键,结果因为序列化格式不统一,同一个请求在不同实例生成不同的键,导致重复执行。这属于客户端bug,但服务端可以在中间件里校验键格式,比如只允许UUID和特定规则。

6.3 最后再聊聊我的个人体会

做幂等性方案,最大的阻力往往不是技术,而是"到底由谁来承担幂等键生成责任"的争论。前端说后端做,后端说网关做,消息端说业务做。我的态度很明确:谁发起请求,谁负责生成幂等键。HTTP请求是客户端发起的,那客户端就必须生成并传递;消息队列重试是Broker控制的,那生产者就必须在消息体里带上消息ID,消费者以这个消息ID做幂等判断。

中间件方案虽然能统一拦截,但不能替你定义业务上的"唯一操作"。一个REST API只有先想清楚什么才是"同一笔业务操作",才能设计出可靠的幂等键语义。如果连这个都没定义清楚,加再多的锁和缓存都只是表面功夫。我在ASP.NET Core 8.0里落地的这套中间件,本身只是一个可复用的基础设施,真正让生产系统变稳的,还是团队对幂等语义的敬畏和统一理解。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询