幂等性指多次执行同一请求对系统状态的改变效果等价于执行一次;C# Web API 必须考虑它,因 POST 等非幂等操作(如创建订单)易因重试或连点导致重复提交,仅前端防抖不足,需后端通过 Idempotency-Key、分布式缓存与数据库约束协同保障。

什么是幂等性,为什么 C# Web API 必须考虑它
幂等性不是“调用一次和调用十次结果一样”,而是“多次执行同一请求,对系统状态的改变效果等价于执行一次”。在 C# Web API 中,如果一个 POST /orders 接口没做幂等控制,用户连点两次提交按钮,就可能生成两条重复订单——这不是前端防抖能解决的,后端必须兜底。
http 方法本身有语义约定:GET、PUT、delete 天然应是幂等的;但 POST 不是。而现实中大量业务操作(创建订单、发起支付、扣减库存)都用 POST,所以得自己实现幂等逻辑。
用 Idempotency-Key + 缓存实现最简可靠方案
主流做法是客户端在请求头带上唯一标识 Idempotency-Key,服务端用它作为键,缓存该请求的响应结果或执行状态。关键不在“怎么存”,而在“什么时候存、存什么、存多久”。
-
Idempotency-Key应由客户端生成(如 UUID v4),服务端只校验格式和长度,不生成 - 缓存建议用
IDistributedCache(如 redis),避免单机内存缓存导致负载均衡下失效 - 缓存值推荐存
(status code, response body, timestamp)三元组,而非仅“已执行”,否则无法正确返回原始响应 - 过期时间要大于业务最大处理耗时(比如支付回调最长 10s,那就设 30s),但不宜过长(防止 key 泄露占用资源)
public class IdempotencyFilter : ActionFilterAttribute { private readonly IDistributedCache _cache; public IdempotencyFilter(IDistributedCache cache) => _cache = cache; public override async Task OnActionExecutionasync(ActionExecutingContext context, ActionExecutionDelegate next) { var key = context.HttpContext.Request.Headers["Idempotency-Key"].FirstOrDefault(); if (string.IsNullOrWhiteSpace(key)) { context.Result = new BadRequestObjectResult("Missing Idempotency-Key header"); return; } var cacheKey = $"idempotent:{key}"; var cached = await _cache.GetAsync(cacheKey, context.HttpContext.RequestAborted); if (cached != null) { var result = jsonSerializer.Deserialize(cached); context.Result = new ObjectResult(result.Body) { StatusCode = result.StatusCode }; return; } var exec = await next(); if (exec.Exception == null && context.Result is ObjectResult obj && obj.Value != null) { var response = new IdempotentResponse { StatusCode = obj.StatusCode ?? 200, Body = obj.Value }; await _cache.SetAsync(cacheKey, jsonSerializer.SerializeToUtf8Bytes(response), new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(30) }); } }
}
数据库层面的幂等保障不能只靠 UNIQUE 约束
光靠给订单表加 UNIQUE (user_id, client_order_id) 是不够的:约束触发时抛的是 sqlException,直接 500,且无法区分是“重复提交”还是“其他冲突”。必须把约束失败转化为可控的业务响应。
- 用
TRY...catch捕获 SQL Server 的错误号 2627(唯一键冲突)或 2601(主键重复) - postgresql 对应捕获
23505(unique_violation) - EF Core 中更推荐用
ExecuteSqlRaw执行带ON CONFLICT DO NOTHING(PG)或MERGE(SQL Server)的语句,避免异常路径 - 注意:数据库幂等只保证“写入不重复”,不保证“响应一致”——仍需配合缓存返回原始成功响应
别忽略分布式锁和并发边界
当幂等校验 + 写入需要原子性(比如先查缓存、再写 DB、再存缓存),单纯用 IDistributedCache 的 GetAsync/SetAsync 无法防止竞态。两个相同请求几乎同时到达,都发现缓存无值,都会去执行业务逻辑。
这时候得加一层轻量级分布式锁:
- 用
redis SET key value NX EX 10(NX=不存在才设,EX=10秒过期)抢锁 - 抢到锁的请求走完整流程,释放锁前把结果写入缓存;没抢到的请求等待后重查缓存
- 锁超时必须小于业务最大耗时,否则会误释放;也不宜太短(如 1s),否则频繁重试加重压力
真正难的不是代码怎么写,而是幂等键的设计粒度:是按用户+操作类型+时间戳?还是绑定具体业务实体 ID?一旦选错,要么锁住不该锁的请求,要么放行本该拦截的重复请求。