ASP.NET Core API 幂等性设计深度实战:从一次重复申领事故说起

本文配套船舶PMS(计划保养体系)系统真实场景。文中涉及的业务代码均做脱敏处理,不出现具体船名与客户信息。

适用环境:.NET 8 / ASP.NET Core、Redis、PostgreSQL、MassTransit + RabbitMQ、Hangfire。


0. 开篇:凌晨两点的重复申领单

那是系统上线后第三个月。某船船员在海上提交一张备件申领单,点击"提交"后页面转圈------卫星链路延迟 12 秒,船员以为卡死了,连点了三次提交按钮,又切到 4G 热点重试了一次。

第二天岸端审批人打开系统,发现同一艘船、同一份备件清单,挂着 4 张申领单,单号连续,审批流已经各走各的。其中一张已经一级审批通过,库存冻结了 4 份......

事后复盘,问题链条是这样的:

复制代码
船员点击提交 ──► 请求在卫星链路中耗时 12s
      │
      ├─ 第1次点击:请求正常到达,服务端处理成功,但响应在回程丢失
      ├─ 第2次点击:客户端 SDK 超时自动重试(Polly),服务端又收到一次
      ├─ 第3次点击:用户手动刷新页面后重填提交
      └─ 第4次点击:切网络后 App 重发本地队列
                    │
                    ▼
            服务端:4 张申领单,4 条审批流,4 次库存冻结

根因不是某一行代码写错了,而是整个链路没有幂等性设计。

这篇博客就把"幂等性"这件事讲透:从 HTTP 语义到中间件实现,从 Redis 分布式锁到数据库唯一约束,从 API 层到消息消费层,最后给出 PMS 船岸同步场景的完整落地方案。

💬 互动一下:你们系统里有没有遇到过"用户多点了一下按钮,数据就重复了"的事故?当时是前端防抖解决的,还是后端兜底的?评论区聊聊。


1. 先把概念钉死:什么是幂等

1.1 数学定义与工程定义

数学里的幂等(Idempotence):f(f(x)) = f(x),执行一次和执行 N 次,系统状态相同。

工程里更精确的表述:同一个操作,用同样的参数执行一次或多次,产生的业务效果与只执行一次完全相同,且服务端返回的结果也一致(或语义一致)。

注意两个要件:

  1. 业务效果相同------不能扣两次库存、不能生成两张单;
  2. 返回结果可预期 ------重复请求要么返回首次的结果,要么返回明确的"重复请求"提示,不能报错 500,更不能假装成功却重复落库

1.2 HTTP 方法的天然幂等性

方法 语义 天然幂等? 说明
GET 查询 无副作用,重复读结果一致
PUT 整体替换 PUT /orders/123 用同样的 body 替换 N 次,结果一样
DELETE 删除 删第一次返回 200/204,删第二次应返回 404 或仍 204(取决于设计),但资源状态都是"已删除"
HEAD/OPTIONS 探测 无副作用
POST 创建/动作 POST /orders 每次都可能创建新资源
PATCH 局部更新 ⚠️ 取决于操作类型:set status=1 幂等,balance -= 100 不幂等

关键认知:幂等不是 HTTP 方法的属性,而是操作语义的属性。 即便是 POST,如果我们给它配上幂等键,它也能变成幂等的;即便是 PUT,如果 body 里带着"数量 +1"这种增量语义,它也不幂等。

1.3 PMS 系统里的幂等性矩阵

我们盘点了 PMS 里所有写操作,按幂等风险分级:

业务操作 接口 风险 重复执行后果
备件申领提交 POST /api/spare-part-applies 🔴 高 重复生成申领单、重复冻结库存、重复走审批
物料采购申请 POST /api/material-requisitions 🔴 高 同上
审批通过/驳回 POST /api/approvals/{id}/approve 🔴 高 状态机紊乱、重复通知、重复出库
船岸数据同步上报 POST /api/sync/batch 🔴 高 岸端数据重复、报表翻倍
工单完工报告 POST /api/work-orders/{id}/complete 🟡 中 重复记工时、重复消耗备件
库存盘点调整 POST /api/stock-takes 🟡 中 库存数量被调整多次
设备运行小时数上报 POST /api/equipment/runtime 🟡 中 增量累加变翻倍(若为绝对值覆盖则幂等)
修改联系人电话 PUT /api/users/me 🟢 低 覆盖写,天然幂等
查询库存 GET /api/stock 🟢 无 只读

结论很清晰:所有"创建单据"和"状态推进"类接口必须做幂等,增量累加类接口要么改成绝对值语义、要么带幂等键。


2. 幂等性的三道防线

不要指望某一层单独解决问题。生产级方案是三道防线纵深防御:

复制代码
┌─────────────────────────────────────────────────────────────┐
│ 第一道:客户端防线(减少无效重试)                              │
│  - 提交按钮防重(点击后置灰 + loading)                         │
│  - 请求序列号 / 本地去重                                       │
│  - 客户端 SDK 重试时自动携带幂等键                              │
│  局限:防不住网络重试、多端操作、恶意重放                        │
└─────────────────────────────────────────────────────────────┘
                          │ 拦不住的请求继续往下
                          ▼
┌─────────────────────────────────────────────────────────────┐
│ 第二道:API 网关 / 应用层防线(核心)                           │
│  - Idempotency-Key 请求头                                      │
│  - 幂等中间件:键校验 + 请求指纹 + 结果缓存 + 并发互斥           │
│  - 业务状态机校验("已审批的单不能再审批")                     │
└─────────────────────────────────────────────────────────────┘
                          │ 漏网的写操作继续往下
                          ▼
┌─────────────────────────────────────────────────────────────┐
│ 第三道:数据层防线(最后兜底)                                  │
│  - 数据库唯一索引(业务唯一键 / 幂等键唯一约束)                │
│  - UPSERT / INSERT ON CONFLICT                                │
│  - 乐观锁 RowVersion / 状态机条件更新                          │
│  - 消息消费去重表                                              │
└─────────────────────────────────────────────────────────────┘

为什么必须三层都做?

  • 只做前端防抖:网络重试、Postman 重放、脚本攻击全部穿透;
  • 只做幂等中间件:中间件有 Redis 故障、键过期、配置疏漏的可能;
  • 只做数据库唯一索引:能挡住重复数据,但用户体验是"第二次请求报 500/409",且拿不回第一次的响应(船员不知道单到底建没建成);

三层配合:前端减少 90% 的误操作,中间件优雅处理重试并返回首次结果,数据库唯一索引在极端情况下兜底保证数据不脏。


3. 核心武器:Idempotency-Key 模式

3.1 模式原理

这是业界事实标准(Stripe、GitHub API、Shopify 都在用):

  1. 客户端在发起可能不幂等 的请求时,生成一个全局唯一字符串(UUID v4 推荐),放在请求头 Idempotency-Key 里;

  2. 服务端收到请求后,以这个键为索引记录"请求-响应"映射;

  3. 第一次请求:正常处理,把响应状态码、响应头、响应体持久化;

  4. 后续携带相同键的请求:

    • 如果参数(请求指纹)一致 → 直接返回首次缓存的响应,业务逻辑不再执行;
    • 如果参数不一致 → 返回 422 Unprocessable Entity,提示键被复用但参数不同;
    • 如果首次请求还在处理中 → 返回 409 Conflict(或排队等待,见 3.5)。

    客户端 服务端 存储(Redis/DB)
    │ │ │
    │── POST /apply Key=K1, body=B1 ──►│ │
    │ │── SET K1 status=processing ──►│
    │ │── 执行业务(创建申领单) │
    │ │── SET K1 status=done,resp=R1 ─►│
    │◄──── 201 Created, R1 ─────────────┤ │
    │ │ │
    │ (网络超时,SDK 自动重试) │ │
    │── POST /apply Key=K1, body=B1 ──►│ │
    │ │── GET K1 ────────────────────►│
    │ │◄── status=done, resp=R1 ──────│
    │◄──── 201 Created, R1(重放)──────┤ (业务逻辑完全不执行) │

3.2 幂等键的生成与传递

键由谁生成? 必须是客户端。因为重试可能发生在请求到达服务端之前(客户端 SDK 超时重试),服务端生成的键无法在重试间保持一致。

客户端(船端 App / Web)的做法:

csharp 复制代码
public class IdempotentHttpHandler : DelegatingHandler
{
    // 同一笔业务操作的整个重试周期内,键保持不变
    private readonly AsyncLocal<string?> _currentKey = new();

    public IDisposable BeginIdempotentScope()
    {
        _currentKey.Value = Guid.NewGuid().ToString("N");
        return new Scope(() => _currentKey.Value = null);
    }

    protected override async Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request, CancellationToken ct)
    {
        // 只对写操作挂键;GET 不需要
        if (request.Method != HttpMethod.Get && request.Method != HttpMethod.Head
            && _currentKey.Value is { } key
            && !request.Headers.Contains("Idempotency-Key"))
        {
            request.Headers.Add("Idempotency-Key", key);
        }
        return await base.SendAsync(request, ct);
    }

    private sealed class Scope(Action dispose) : IDisposable { public void Dispose() => dispose(); }
}

PMS 船端的特殊做法:键在单据本地创建时就生成并持久化在船端本地库,随单据一起上报。这样哪怕 App 重启、换网络、甚至换设备登录同一账号,同一笔业务的键都不变:

csharp 复制代码
// 船端:申领单本地实体
public class LocalSparePartApply
{
    public Guid Id { get; set; }
    // 建单时即生成,随单据生命周期存在;上报、重发、补传都用它
    public string ClientRequestId { get; set; } = Guid.NewGuid().ToString("N");
    // ... 业务字段
    public SyncStatus SyncStatus { get; set; } // 未上报/已上报/已确认
}

💡 经验:用"业务单据本地 ID(GUID)"直接当幂等键,比每次请求随机生成 UUID 更稳------它天然与业务一一对应,调试时日志里看到键就能查到本地单据。

3.3 请求指纹:防止"同键不同参"

光有键不够。考虑这个攻击/误用场景:

  1. 请求 A:Key=K1,创建申领单 10 个滤芯 → 成功;
  2. 请求 B:Key=K1,创建申领单 100 个滤芯 → 服务端该怎么办?

如果直接返回 A 的响应,B 的发起方会以为自己 100 个滤芯的申请成功了;如果重新执行,键就失去了去重意义。正确做法是拒绝:参数不匹配,键被冲突复用。

所以服务端要存"请求指纹"------对请求的关键要素做哈希:

csharp 复制代码
public static class RequestFingerprint
{
    public static async Task<string> ComputeAsync(
        HttpContext context, string? body, string tenantId)
    {
        // 指纹要素:租户 + 方法 + 路径 + 查询串 + 请求体哈希
        // 注意:不要把易变的 Header(如 Authorization、时间戳)算进去
        var raw = string.Join('|',
            tenantId,
            context.Request.Method,
            context.Request.Path.Value?.TrimEnd('/'),
            context.Request.QueryString.Value,
            body ?? string.Empty);

        var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(raw));
        return Convert.ToHexString(bytes); // 64 位十六进制
    }
}

3.4 状态模型

一个幂等键在服务端有三个状态:

复制代码
                  首次请求到达
                       │
                       ▼
                 ┌───────────┐   业务处理成功    ┌──────────┐
                 │ PROCESSING│ ───────────────► │  DONE    │
                 │ (处理中) │                  │ (已完成)│
                 └─────┬─────┘                  └────┬─────┘
                       │ 业务失败                      │ 永久(TTL内)
                       ▼                              │
                 ┌───────────┐                        │
                 │  FAILED   │ 键释放/删除,允许重试    │
                 │ (失败)   │ ◄──────────────────────┘
                 └───────────┘
  • PROCESSING:首次请求正在执行。并发重复请求到达时的处理见 3.5。
  • DONE:业务成功,响应已缓存。重复请求直接重放响应。
  • FAILED :业务执行失败(返回 4xx/5xx)。键应删除或标记失败,允许客户端用同一个键重试------因为业务没生效,重试是安全的。

⚠️ 关键细节 :只有业务成功才占用幂等键。业务失败(如校验不通过、库存不足)要释放键,否则用户修正数据后用同一个键重试会被误判为重复。Stripe 的做法是:只缓存 2xx 响应;4xx/5xx 不缓存。

3.5 并发重复请求:PROCESSING 期间来了第二个请求怎么办

两个场景:

场景 A:用户手快,双击按钮,两个请求间隔 200ms 同时到达。

此时第一个请求还在 PROCESSING。两种策略:

  • 快速失败(推荐默认) :第二个请求返回 409 Conflict,提示"请求处理中,请勿重复提交"。配合前端防抖,这种情况极少发生,发生了给明确提示也合理。
  • 等待重放(体验更好):第二个请求轮询/等待第一个完成,然后拿到同样的响应返回。Stripe 采用这种策略(等待最多短暂时间)。实现复杂一些,需要订阅键状态变更。

PMS 的选择:API 层快速失败,船岸同步层等待重放。船员手动操作走快速失败(前端本来就有防重);船端自动同步任务走等待+重试(自动任务不能因为 409 就丢弃报文)。

csharp 复制代码
// 等待重放的实现片段(同步场景用)
public async Task<IdempotentResult?> WaitForCompletionAsync(
    string key, TimeSpan timeout, CancellationToken ct)
{
    var deadline = DateTime.UtcNow + timeout;
    while (DateTime.UtcNow < deadline)
    {
        var record = await _store.GetAsync(key, ct);
        if (record is { Status: IdempotencyStatus.Done })
            return new IdempotentResult.Replay(record.StatusCode, record.ResponseBody!);
        if (record is { Status: IdempotencyStatus.Failed })
            return null; // 首次失败,允许重新执行
        await Task.Delay(200, ct);
    }
    return new IdempotentResult.Conflict(); // 等超时了
}

场景 B:第一个请求处理到一半进程崩了(OOM、部署重启),键永远卡在 PROCESSING。

这是分布式系统的经典坑。解法:PROCESSING 状态必须带租约(lease)过期时间。比如键写入时 TTL 设 60 秒,业务正常完成会更新为 DONE;如果进程崩溃,60 秒后 PROCESSING 键自动过期,重试请求可以重新执行。

复制代码
SETNX idem:K1 {status:processing, fingerprint:F1, lease:60s}
  ├─ 设置成功 → 你是第一个,执行业务
  └─ 设置失败 → 键已存在
       ├─ 键是 DONE → 重放
       ├─ 键是 FAILED → 重新执行
       ├─ 键是 PROCESSING 且未过期 → 409 / 等待
       └─ 键是 PROCESSING 但租约已过期 → 视为崩溃残留,抢占重新执行

4. 存储选型:Redis vs 数据库去重表

4.1 对比

维度 Redis 数据库去重表
性能 极高(10w+ QPS) 中(受数据库连接数限制)
持久化 RDB/AOF,极端情况可能丢键 与业务数据同库,强一致
TTL 原生 EXPIRE 需定时任务清理
响应体大小 受 value 大小限制(大响应要压缩或拆存) 无限制(TEXT/JSONB)
与业务事务一致性 弱(Redis 写成功但 DB 事务回滚会不一致) 强(可与业务在同一事务内提交)
成本 需要 Redis 高可用 无额外组件

4.2 PMS 的混合方案

我们采用 Redis 做高速判定 + 数据库表做权威兜底 的双层存储:

复制代码
请求到达
   │
   ▼
Redis 查键 ──► DONE?──是──► 重放响应(快路径,~1ms)
   │否
   ▼
数据库去重表查键(唯一索引)──► DONE?──是──► 回填 Redis + 重放(Redis 刚过期/被淘汰的兜底)
   │否
   ▼
Redis SETNX 占位 PROCESSING(TTL 60s)
   │
   ▼
数据库事务内:业务写入 + 写入去重表(同一事务!)
   │
   ▼
事务提交成功 → Redis 更新 DONE + 缓存响应(TTL 24h)
事务回滚     → 删除 Redis 占位键

去重表结构:

sql 复制代码
CREATE TABLE idempotency_record (
    id              BIGGENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    idempotency_key VARCHAR(64)  NOT NULL,
    tenant_id       VARCHAR(32)  NOT NULL,          -- 多船舶隔离,键必须带租户
    request_path    VARCHAR(256) NOT NULL,
    fingerprint     CHAR(64)     NOT NULL,          -- SHA256 请求指纹
    status          VARCHAR(16)  NOT NULL,          -- PROCESSING / DONE / FAILED
    response_status INT,                            -- 缓存的 HTTP 状态码
    response_body   TEXT,                           -- 缓存的响应体(JSON)
    created_at      TIMESTAMPTZ  NOT NULL DEFAULT now(),
    completed_at    TIMESTAMPTZ,
    expires_at      TIMESTAMPTZ  NOT NULL,          -- 到期可清理
    CONSTRAINT uq_idem_key_tenant UNIQUE (idempotency_key, tenant_id)
);

-- 清理任务用
CREATE INDEX idx_idem_expires ON idempotency_record (expires_at) WHERE status = 'DONE';

为什么去重表和业务写在同一个事务里? 这是避免"Redis 说成功、数据库回滚"不一致的关键。看这个反面案例:

复制代码
❌ 错误顺序:
1. Redis 标记 DONE
2. 数据库插入申领单
3. 数据库事务回滚(库存不足)
→ Redis 里键已 DONE,客户端重试时拿到"成功"响应,但单据根本没建成!

✅ 正确顺序:
1. 数据库事务 { 插入申领单 + 写去重表 DONE }  ← 原子提交
2. 事务成功后,再写 Redis(缓存而已,丢了有数据库兜底)

Redis 在这里只是缓存,权威事实在数据库去重表里。Redis 挂了、键淘汰了、甚至不用 Redis,功能依然正确,只是重放路径慢一点(多一次数据库查询)。


5. 完整实现:幂等中间件

下面给出生产可用的 ASP.NET Core 中间件完整实现。

5.1 抽象与模型

csharp 复制代码
public enum IdempotencyStatus { Processing, Done, Failed }

public record IdempotencyRecord(
    string Key,
    string TenantId,
    string Fingerprint,
    IdempotencyStatus Status,
    int? ResponseStatusCode,
    string? ResponseBody,
    DateTimeOffset CreatedAt,
    DateTimeOffset? CompletedAt);

public interface IIdempotencyStore
{
    Task<IdempotencyRecord?> GetAsync(string key, string tenantId, CancellationToken ct);

    // 尝试占位:返回 true 表示抢占成功(我是第一个);false 表示键已存在
    Task<bool> TryAcquireProcessingAsync(
        string key, string tenantId, string fingerprint, string path,
        TimeSpan lease, CancellationToken ct);

    // 业务成功:同事务内调用(DB 实现)或事务后调用(Redis 实现)
    Task MarkDoneAsync(string key, string tenantId, int statusCode, string body,
        TimeSpan retention, CancellationToken ct);

    // 业务失败:释放占位
    Task MarkFailedAsync(string key, string tenantId, CancellationToken ct);

    // 租约过期后抢占(崩溃恢复)
    Task<bool> TryTakeOverExpiredAsync(string key, string tenantId,
        string fingerprint, TimeSpan lease, CancellationToken ct);
}

5.2 中间件主体

csharp 复制代码
public class IdempotencyMiddleware
{
    public const string HeaderName = "Idempotency-Key";
    private const int MaxKeyLength = 128;

    private readonly RequestDelegate _next;
    private readonly IIdempotencyStore _store;
    private readonly ITenantContext _tenant;       // 见多租户博客
    private readonly ILogger<IdempotencyMiddleware> _logger;
    private static readonly TimeSpan Lease = TimeSpan.FromSeconds(60);
    private static readonly TimeSpan Retention = TimeSpan.FromHours(24);

    public IdempotencyMiddleware(RequestDelegate next,
        IIdempotencyStore store, ITenantContext tenant,
        ILogger<IdempotencyMiddleware> logger)
    {
        _next = next; _store = store; _tenant = tenant; _logger = logger;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        // 只对 POST/PATCH 生效;GET/PUT/DELETE 天然幂等或无需处理
        if (HttpMethods.IsGet(context.Request.Method) ||
            HttpMethods.IsPut(context.Request.Method) ||
            HttpMethods.IsDelete(context.Request.Method) ||
            HttpMethods.IsHead(context.Request.Method) ||
            HttpMethods.IsOptions(context.Request.Method))
        {
            await _next(context);
            return;
        }

        if (!context.Request.Headers.TryGetValue(HeaderName, out var keyValues)
            || string.IsNullOrWhiteSpace(keyValues.ToString()))
        {
            // 没带幂等键:放行,但记日志便于排查"为什么没幂等保护"
            _logger.LogDebug("Non-idempotent request without key: {Path}", context.Request.Path);
            await _next(context);
            return;
        }

        var key = keyValues.ToString().Trim();
        if (key.Length > MaxKeyLength || !key.All(c => char.IsLetterOrDigit(c) || c is '-' or '_'))
        {
            context.Response.StatusCode = StatusCodes.Status400BadRequest;
            await context.Response.WriteAsJsonAsync(new { error = "Invalid Idempotency-Key format." });
            return;
        }

        var tenantId = _tenant.ShipId ?? "shore"; // 岸端默认租户
        var body = await ReadBodyAsync(context);
        var fingerprint = await RequestFingerprint.ComputeAsync(context, body, tenantId);

        var existing = await _store.GetAsync(key, tenantId, context.RequestAborted);
        if (existing is not null)
        {
            await HandleExistingAsync(context, existing, fingerprint, key);
            return;
        }

        // 抢占 PROCESSING 占位
        if (!await _store.TryAcquireProcessingAsync(
                key, tenantId, fingerprint, context.Request.Path, Lease, context.RequestAborted))
        {
            // 并发抢占失败:可能是并发请求刚写入,再查一次
            existing = await _store.GetAsync(key, tenantId, context.RequestAborted);
            if (existing is not null)
            {
                await HandleExistingAsync(context, existing, fingerprint, key);
                return;
            }
            context.Response.StatusCode = StatusCodes.Status409Conflict;
            await context.Response.WriteAsJsonAsync(new
            {
                error = "A request with the same Idempotency-Key is being processed.",
                retryAfterSeconds = 5
            });
            return;
        }

        // 我是第一个:执行业务,并通过包装 Response.Body 捕获响应
        var originalBody = context.Response.Body;
        await using var captured = new MemoryStream();
        context.Response.Body = captured;

        try
        {
            await _next(context);
        }
        catch
        {
            await _store.MarkFailedAsync(key, tenantId, context.RequestAborted);
            context.Response.Body = originalBody;
            throw;
        }

        var responseBody = Encoding.UTF8.GetString(captured.ToArray());

        if (context.Response.StatusCode is >= 200 and < 300)
        {
            // 成功:缓存响应。注意 DB 存储的 MarkDone 由业务事务内的拦截器/工作单元完成,
            // 这里 MarkDone 主要写 Redis 缓存层
            await _store.MarkDoneAsync(key, tenantId,
                context.Response.StatusCode, responseBody, Retention, context.RequestAborted);
        }
        else
        {
            // 业务失败(4xx/5xx):释放键,允许客户端修正后用同键重试
            await _store.MarkFailedAsync(key, tenantId, context.RequestAborted);
            _logger.LogWarning("Idempotent request {Key} failed with {Status}, key released.",
                key, context.Response.StatusCode);
        }

        // 回放响应体给真实客户端
        context.Response.Body = originalBody;
        captured.Position = 0;
        await captured.CopyToAsync(originalBody);
    }

    private async Task HandleExistingAsync(
        HttpContext context, IdempotencyRecord existing, string fingerprint, string key)
    {
        if (existing.Fingerprint != fingerprint)
        {
            _logger.LogWarning(
                "Idempotency-Key {Key} reused with different fingerprint. Path={Path}",
                key, context.Request.Path);
            context.Response.StatusCode = StatusCodes.Status422UnprocessableEntity;
            await context.Response.WriteAsJsonAsync(new
            {
                error = "Idempotency-Key was already used with a different request payload."
            });
            return;
        }

        switch (existing.Status)
        {
            case IdempotencyStatus.Done:
                // 优雅重放:返回首次响应
                context.Response.StatusCode = existing.ResponseStatusCode ?? 200;
                context.Response.Headers["Idempotency-Replayed"] = "true";
                if (existing.ResponseBody is not null)
                    await context.Response.WriteAsync(existing.ResponseBody);
                _logger.LogInformation("Idempotent replay for key {Key}, path {Path}",
                    key, context.Request.Path);
                break;

            case IdempotencyStatus.Processing:
                context.Response.StatusCode = StatusCodes.Status409Conflict;
                context.Response.Headers.RetryAfter = "5";
                await context.Response.WriteAsJsonAsync(new
                {
                    error = "The original request is still being processed. Please retry in a few seconds."
                });
                break;

            case IdempotencyStatus.Failed:
            default:
                // 上次失败:理论上键应被删除,走到这里说明清理失败,引导重试
                context.Response.StatusCode = StatusCodes.Status409Conflict;
                await context.Response.WriteAsJsonAsync(new
                {
                    error = "Previous attempt failed. Please retry with a new Idempotency-Key."
                });
                break;
        }
    }

    private static async Task<string> ReadBodyAsync(HttpContext context)
    {
        context.Request.EnableBuffering();
        using var reader = new StreamReader(
            context.Request.Body, Encoding.UTF8,
            detectEncodingFromByteOrderMarks: false, leaveOpen: true);
        var body = await reader.ReadToEndAsync();
        context.Request.Body.Position = 0;
        return body;
    }
}

注册:

csharp 复制代码
// Program.cs
app.UseMiddleware<IdempotencyMiddleware>(); // 放在认证之后、业务端点之前
builder.Services.AddSingleton<IIdempotencyStore, RedisIdempotencyStore>();
// DB 兜底存储通过工作单元在业务事务内写入,见 5.4

5.3 Redis 存储实现

csharp 复制代码
public class RedisIdempotencyStore : IIdempotencyStore
{
    private readonly IDatabase _db;
    private const string KeyPrefix = "idem:";

    public RedisIdempotencyStore(IConnectionMultiplexer redis)
        => _db = redis.GetDatabase();

    private static string RedisKey(string key, string tenant) => $"{KeyPrefix}{tenant}:{key}";

    public async Task<IdempotencyRecord?> GetAsync(string key, string tenantId, CancellationToken ct)
    {
        var json = await _db.StringGetAsync(RedisKey(key, tenantId));
        return json.IsNullOrEmpty ? null : JsonSerializer.Deserialize<IdempotencyRecord>(json!);
    }

    public async Task<bool> TryAcquireProcessingAsync(
        string key, string tenantId, string fingerprint, string path,
        TimeSpan lease, CancellationToken ct)
    {
        var record = new IdempotencyRecord(key, tenantId, fingerprint,
            IdempotencyStatus.Processing, null, null, DateTimeOffset.UtcNow, null);

        // SET key value NX PX leaseMs ------ 原子操作,抢不到就是并发
        return await _db.StringSetAsync(
            RedisKey(key, tenantId),
            JsonSerializer.Serialize(record),
            lease, When.NotExists);
    }

    public async Task MarkDoneAsync(string key, string tenantId, int statusCode,
        string body, TimeSpan retention, CancellationToken ct)
    {
        var existing = await GetAsync(key, tenantId, ct);
        if (existing is null) return;
        var done = existing with
        {
            Status = IdempotencyStatus.Done,
            ResponseStatusCode = statusCode,
            ResponseBody = body,
            CompletedAt = DateTimeOffset.UtcNow
        };
        await _db.StringSetAsync(RedisKey(key, tenantId),
            JsonSerializer.Serialize(done), retention);
    }

    public Task MarkFailedAsync(string key, string tenantId, CancellationToken ct)
        => _db.KeyDeleteAsync(RedisKey(key, tenantId));

    public async Task<bool> TryTakeOverExpiredAsync(string key, string tenantId,
        string fingerprint, TimeSpan lease, CancellationToken ct)
    {
        // PROCESSING 键带 TTL,过期即自动消失,直接重新占位即可
        return await TryAcquireProcessingAsync(key, tenantId, fingerprint,
            path: "(takeover)", lease, ct);
    }
}

5.4 数据库兜底:在业务事务内写去重表

光靠 Redis MarkDone 有一致性缺口(Redis 写成功但业务事务回滚的情形在中间件顺序里已规避,但 Redis 键过期后数据库需要兜底)。我们在 EF Core 工作单元里加一段:业务事务提交时,幂等记录与业务数据同库落盘。

csharp 复制代码
public interface IUnitOfWork
{
    Task<int> SaveChangesWithIdempotencyAsync(
        string? idempotencyKey, string? tenantId, string? fingerprint,
        int? responseStatusCode, string? responseBody,
        CancellationToken ct = default);
}

public class UnitOfWork<TDbContext> : IUnitOfWork where TDbContext : DbContext
{
    private readonly TDbContext _db;
    public UnitOfWork(TDbContext db) => _db = db;

    public async Task<int> SaveChangesWithIdempotencyAsync(
        string? idempotencyKey, string? tenantId, string? fingerprint,
        int? responseStatusCode, string? responseBody, CancellationToken ct = default)
    {
        if (!string.IsNullOrEmpty(idempotencyKey) && !string.IsNullOrEmpty(tenantId))
        {
            // 同事务写入去重表:业务回滚则去重记录一起回滚,绝不出现"标记成功但业务没成"
            await _db.Database.ExecuteSqlInterpolatedAsync($@"
                INSERT INTO idempotency_record
                    (idempotency_key, tenant_id, request_path, fingerprint,
                     status, response_status, response_body, completed_at, expires_at)
                VALUES ({idempotencyKey}, {tenantId}, '', {fingerprint},
                     'DONE', {responseStatusCode}, {responseBody}, now(), now() + interval '24 hours')
                ON CONFLICT (idempotency_key, tenant_id) DO NOTHING", ct);
        }
        return await _db.SaveChangesAsync(ct);
    }
}

应用服务在创建单据后调用:

csharp 复制代码
public async Task<Result<Guid>> CreateApplyAsync(
    CreateApplyCommand cmd, string? idempotencyKey, string? tenantId, string? fingerprint,
    CancellationToken ct = default)
{
    var apply = SparePartApply.Create(cmd.ShipId, cmd.Items, cmd.ApplicantId);
    _db.SparePartApplies.Add(apply);

    await _uow.SaveChangesWithIdempotencyAsync(
        idempotencyKey, tenantId, fingerprint,
        StatusCodes.Status201Created,
        JsonSerializer.Serialize(new { id = apply.Id, number = apply.ApplyNumber }),
        ct);

    return Result<Guid>.Ok(apply.Id);
}

⚠️ 顺序再强调一次ON CONFLICT DO NOTHING 保证重复键插入不报错;但真正的防重依赖唯一索引。中间件在入口已挡掉绝大多数重复,到达这里的重复请求会被唯一索引兜住。三层防御到此闭环。


6. 业务层幂等:状态机是最硬的护城河

中间件和唯一索引防的是"重复请求",但有一种重复它们防不住:语义重复------键不同(比如客户端换了种方式重试、用户重新填了一遍单),但业务上就是同一件事。

这时候靠状态机:让业务操作只能从合法的前置状态转移,重复推进直接被拒。

备件申领审批的例子:

csharp 复制代码
public class SparePartApply : AggregateRoot<Guid>
{
    public ApplyStatus Status { get; private set; }
    private readonly List<ApprovalRecord> _approvals = new();

    public Result Approve(string approverId, int level, string opinion)
    {
        // 状态机守卫:只有"审批中"且轮到本级的单子才能审批
        if (Status != ApplyStatus.Approving)
            return Result.Fail($"当前状态 {Status} 不允许审批操作");

        if (_approvals.Any(a => a.Level == level && a.Result == ApprovalResult.Approved))
            return Result.Fail($"第 {level} 级已审批,请勿重复操作");  // ← 幂等点

        var expectedLevel = _approvals.Count(a => a.Result == ApprovalResult.Approved) + 1;
        if (level != expectedLevel)
            return Result.Fail($"审批层级错乱:当前应审批第 {expectedLevel} 级");

        _approvals.Add(new ApprovalRecord(level, approverId, ApprovalResult.Approved, opinion));

        if (level >= MaxApprovalLevel)
            Status = ApplyStatus.Approved;  // 四级审完 → 已批准

        return Result.Ok();
    }
}

对应的数据库条件更新(乐观锁 + 状态条件,双保险):

csharp 复制代码
// 即使并发两个审批请求穿过了应用层,数据库条件更新也只有一个能成功
var affected = await _db.Database.ExecuteSqlInterpolatedAsync($@"
    UPDATE spare_part_apply
       SET status = 'APPROVED', opdate = now()
     WHERE id = {applyId}
       AND status = 'APPROVING'
       AND approve_level = {currentLevel}", ct);

if (affected == 0)
    return Result.Fail("审批失败:单据状态已变化,请刷新后重试");

状态机幂等的精髓:不依赖客户端传什么键,而是"业务状态本身不允许重复推进"。 审批按钮点 10 次,第一次之后的 9 次全部在状态守卫处返回失败,且数据纹丝不动。

💬 互动一下:状态机守卫 vs 幂等键,你觉得哪个更可靠?我们的经验是------幂等键管"传输层重复",状态机管"业务层重复",两个都不能少。你们项目里有没有状态机被绕过导致数据错乱的经历?


7. 数据层幂等:唯一索引与 UPSERT

7.1 业务唯一键

每张可能重复创建的单据表,都要有业务语义上的唯一约束,不能只靠自增主键:

sql 复制代码
-- 船端单据:同一艘船、同一个客户端单据号,只能有一条
ALTER TABLE spare_part_apply
    ADD CONSTRAINT uq_apply_client_ref
    UNIQUE (ship_id, client_request_id);

-- 船岸同步报文:同一报文 ID 只能消费一次
ALTER TABLE sync_message_log
    ADD CONSTRAINT uq_sync_msg
    UNIQUE (ship_id, message_id);

-- 审批记录:一张单的同一审批级别只能有一条结论
ALTER TABLE approval_record
    ADD CONSTRAINT uq_approval_level
    UNIQUE (apply_id, level);

7.2 UPSERT 的正确用法

"有则更新、无则插入"天然幂等。PostgreSQL:

sql 复制代码
INSERT INTO equipment_runtime (ship_id, equipment_id, report_date, runtime_hours, updated_at)
VALUES (@ship, @eq, @date, @hours, now())
ON CONFLICT (ship_id, equipment_id, report_date)
DO UPDATE SET runtime_hours = EXCLUDED.runtime_hours,
              updated_at = now();

设备运行小时数上报------注意这里的语义转换:如果接口传的是"今日累计运行小时数"(绝对值),UPSERT 覆盖写就是幂等的,重传 100 次结果都一样;如果传的是"今日新增 8 小时"(增量),就必须靠幂等键/去重表,否则重传一次累加一次。

设计原则:能设计成绝对值覆盖的,就不要设计成增量累加。 PMS 里船端上报的所有计数器(运行小时、油耗、淡水产量)我们都要求船端传"截至当前的累计绝对值",岸端 UPSERT 覆盖,重传天然安全。

7.3 EF Core 里的并发令牌

csharp 复制代码
public class SparePartApply
{
    public Guid Id { get; set; }
    public ApplyStatus Status { get; set; }
    public uint RowVersion { get; set; }  // PostgreSQL xmin / SQL Server rowversion
}

// 配置
modelBuilder.Entity<SparePartApply>()
    .Property(x => x.RowVersion)
    .IsRowVersion();

并发更新时 EF 抛出 DbUpdateConcurrencyException,捕获后转化为友好的"单据已被他人处理"提示。


8. 消息消费幂等:与 MQ 篇衔接

《消息队列实战》一篇讲过 At-least-once 语义下消费端必须幂等,这里给出与 API 幂等体系统一的方案。

核心:消息 ID 复用为幂等键,消费去重表与业务写入同事务。

csharp 复制代码
public class SyncBatchConsumer : IConsumer<SyncBatchMessage>
{
    public async Task Consume(ConsumeContext<SyncBatchMessage> context)
    {
        var msg = context.Message;
        var dedupKey = msg.MessageId.ToString("N");   // 消息 ID 即幂等键

        // 同事务:去重表 + 业务写入,原子提交
        await using var tx = await _db.Database.BeginTransactionAsync();
        try
        {
            var already = await _db.Set<InboxMessage>()
                .AnyAsync(x => x.MessageId == msg.MessageId && x.ConsumerName == nameof(SyncBatchConsumer));
            if (already)
            {
                // 重复投递,直接 ACK,不做任何业务
                return;
            }

            await ProcessBatchAsync(msg);   // 业务处理

            _db.Set<InboxMessage>().Add(new InboxMessage
            {
                MessageId = msg.MessageId,
                ConsumerName = nameof(SyncBatchConsumer),
                ProcessedAt = DateTime.UtcNow
            });
            await _db.SaveChangesAsync();
            await tx.CommitAsync();
        }
        catch
        {
            await tx.RollbackAsync();
            throw; // 抛出让 MassTransit 重试;去重表随事务回滚,下次可重新消费
        }
    }
}

Inbox 表:

sql 复制代码
CREATE TABLE inbox_message (
    message_id    UUID NOT NULL,
    consumer_name VARCHAR(128) NOT NULL,
    processed_at  TIMESTAMPTZ NOT NULL,
    PRIMARY KEY (message_id, consumer_name)
);

这就是经典的 Inbox Pattern:和 API 层的去重表是同一个思想,只是键的来源从 HTTP Header 变成了消息头。


9. PMS 船岸同步场景:最复杂的幂等实战

船岸同步是 PMS 里幂等性要求最高的链路,值得单独讲。

9.1 场景特点

复制代码
船端(卫星/4G,高延迟、易断连、可能离线数天)
   │
   │  报文带 message_id(UUID,船端生成,持久化)
   │  批量打包上报,每批一个 batch_id
   ▼
岸端接收 API
   │
   ├─ 可能重复到达:超时重发、网络切换重发、岸端 ACK 丢失后船端重发
   ├─ 可能乱序到达:先报的后到
   ├─ 可能部分成功:批内 50 条,第 30 条失败
   └─ 可能跨天补传:离线 3 天后一次性补传

9.2 三层幂等设计

第一层:报文级幂等 ------message_id 去重。船端每条报文生成时写入本地 Outbox 表,状态为"待发送";岸端成功处理后返回 ACK,船端收到 ACK 才标记"已确认"。未确认的报文会重发,重发时 message_id 不变。

csharp 复制代码
// 岸端接收端点
[HttpPost("sync/batch")]
public async Task<IActionResult> ReceiveBatch(
    [FromBody] SyncBatchDto batch,
    [FromHeader(Name = "X-Ship-Id")] string shipId,
    CancellationToken ct)
{
    // batch.BatchId 由船端生成,天然幂等键
    var result = new SyncAckDto { BatchId = batch.BatchId, Results = new() };

    foreach (var msg in batch.Messages)
    {
        // 单条报文幂等:INSERT ... ON CONFLICT DO NOTHING
        var inserted = await _syncLog.TryMarkReceivedAsync(
            shipId, msg.MessageId, msg.PayloadHash, ct);

        if (!inserted)
        {
            // 该报文已处理过 → 直接返回当时的处理结果(幂等重放)
            var previous = await _syncLog.GetResultAsync(shipId, msg.MessageId, ct);
            result.Results.Add(new(msg.MessageId, previous.Status, previous.ErrorCode));
            continue;
        }

        try
        {
            var (status, error) = await _dispatcher.DispatchAsync(shipId, msg, ct);
            await _syncLog.MarkProcessedAsync(shipId, msg.MessageId, status, error, ct);
            result.Results.Add(new(msg.MessageId, status, error));
        }
        catch (Exception ex)
        {
            // 单条失败不影响整批 ACK:返回明确错误码,船端决定重发还是跳过
            await _syncLog.MarkFailedAsync(shipId, msg.MessageId, ex.Message, ct);
            result.Results.Add(new(msg.MessageId, ProcessStatus.Failed, "INTERNAL_ERROR"));
        }
    }

    return Ok(result); // 整批始终返回 200,逐条结果在 Results 里
}

第二层:业务实体级幂等 ------每条报文内部携带业务唯一键(如申领单的 client_request_id),落库时靠唯一索引兜底。即使报文去重表被清理、报文以另一种方式重放(如人工导入),业务数据也不会重复。

第三层:版本号防乱序覆盖------同一实体的更新报文带版本号/时间戳,旧版本不能覆盖新版本:

sql 复制代码
UPDATE spare_part_apply
   SET status = @newStatus, sync_version = @version, ...
 WHERE id = @id
   AND sync_version < @version;   -- 旧版本报文直接丢弃

9.3 离线补传与"对账"

离线数天后补传,光靠幂等还不够,还要能对账:船端定期(如每日)上报本地数据的摘要清单(单据号 + 最后修改时间 + 行哈希),岸端比对发现差异后主动拉取缺失数据。这套机制保证"哪怕幂等键全部失效,数据最终一致"。

这属于最终一致性范畴,和《消息队列实战》的 Outbox、《可观测性实战》的同步监控看板配合,构成船岸数据可靠性的完整闭环。


10. 幂等性测试:怎么证明你的实现真的幂等

幂等性最容易"以为做了其实没做"。必须有自动化测试兜底。

10.1 并发重发测试

csharp 复制代码
[Fact]
public async Task CreateApply_ConcurrentSameKey_OnlyOneSucceeds()
{
    // Arrange
    var key = Guid.NewGuid().ToString("N");
    var cmd = new CreateApplyCommand(ShipId: "SHIP_001", Items: SampleItems());

    // Act:同一幂等键,并发发 10 个请求
    var tasks = Enumerable.Range(0, 10).Select(_ =>
        _apiClient.PostAsJsonWithKeyAsync("/api/spare-part-applies", cmd, key));
    var responses = await Task.WhenAll(tasks);

    // Assert:恰好 1 个 201,其余 200/201 重放或 409,数据库只有 1 张单
    responses.Count(r => r.StatusCode == HttpStatusCode.Created).Should().Be(1);
    var count = await _db.SparePartApplies.CountAsync(a => a.ShipId == "SHIP_001");
    count.Should().Be(1);
}

10.2 指纹冲突测试

csharp 复制代码
[Fact]
public async Task SameKey_DifferentPayload_Returns422()
{
    var key = Guid.NewGuid().ToString("N");
    var r1 = await _apiClient.PostAsJsonWithKeyAsync(url, CmdWith(qty: 10), key);
    r1.StatusCode.Should().Be(HttpStatusCode.Created);

    var r2 = await _apiClient.PostAsJsonWithKeyAsync(url, CmdWith(qty: 999), key);
    r2.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity);
}

10.3 崩溃恢复测试(Testcontainers)

csharp 复制代码
[Fact]
public async Task ProcessingLease_ExpiresAfterCrash_RetrySucceeds()
{
    var key = Guid.NewGuid().ToString("N");
    // 模拟崩溃:直接在 Redis 写入一个 PROCESSING 键,TTL 1 秒
    await _redis.StringSetAsync($"idem:SHIP_001:{key}",
        JsonSerializer.Serialize(ProcessingRecord(key)), TimeSpan.FromSeconds(1));

    await Task.Delay(1500); // 等租约过期

    var r = await _apiClient.PostAsJsonWithKeyAsync(url, cmd, key);
    r.StatusCode.Should().Be(HttpStatusCode.Created); // 重试成功,单据正常创建
}

10.4 必测清单

  • 同键同参串行重发 N 次 → 只落 1 条数据,响应一致
  • 同键同参并发 N 个 → 只落 1 条
  • 同键不同参 → 422
  • 首次业务失败后同键重试 → 成功(键被释放)
  • 首次成功后改参重发 → 422
  • PROCESSING 中重复请求 → 409
  • PROCESSING 租约过期后重试 → 成功
  • Redis 宕机(不重启应用)→ 数据库兜底层仍能防重
  • MQ 消息重复投递 → 消费 1 次
  • 响应体重放与首次响应字节一致

11. 十个真实踩过的坑

  1. GET 请求也挂幂等键------纯浪费,还污染存储。只对 POST/PATCH 挂。
  2. 幂等键放在 URL Query 里------会被日志、Referer、浏览器历史泄露。放 Header。
  3. 指纹把 Authorization Header 算进去了------Token 刷新后同业务请求指纹变化,误判 422。指纹只算业务要素(路径 + 查询 + body + 租户)。
  4. 响应体没缓存,重放时重新执行业务 ------某项目重放路径里忘了 return,又走了一遍 _next,重复落库。重放分支必须短路。
  5. 业务失败也缓存 DONE------用户库存不足被拒,改了数量用同键重试,直接返回上次的"库存不足"响应,用户抓狂。只缓存 2xx。
  6. PROCESSING 键没有 TTL------进程崩溃后键永久占位,该笔业务永远重试不了。租约必须有。
  7. Redis 和数据库不是同一租户维度 ------键没带 tenant_id,A 船的键和 B 船冲突。多租户系统键必须 tenant:key 复合。
  8. 幂等键用自增序号 ------可枚举、可预测,有人拿 key=1001 重放别人的请求。用 UUID/GUID。
  9. 大响应体直接塞 Redis------导出类接口响应几十 MB,打爆 Redis 内存。响应超过阈值(如 256KB)只缓存"结果指针"(如单据 ID + 状态),重放时按指针重新查询组装。
  10. 只在 API 层做,定时任务和消息消费者裸奔------Hangfire 重试、MQ 重投一样会重复执行。所有写入口(API、Job、Consumer、数据导入)都要过幂等逻辑。

12. 上线 Checklist

设计阶段

  • 盘点所有写接口,标注幂等风险等级(参考 1.3 矩阵)
  • 增量类接口评估能否改为绝对值覆盖语义
  • 所有创建类接口确定业务唯一键(不依赖自增 ID)
  • 所有状态推进类接口画全状态机,列出非法转移

实现阶段

  • 客户端:写操作自动携带 Idempotency-Key,键随业务单据持久化
  • 服务端:幂等中间件(键校验 + 指纹 + 三态 + 租约 TTL)
  • Redis 高速层(NX 占位 + DONE 缓存 + FAILED 释放)
  • 数据库去重表(唯一索引 + 与业务同事务写入)
  • 业务表业务唯一键唯一索引
  • 状态机守卫 + 条件更新(WHERE status=...)
  • MQ 消费者 Inbox 去重表
  • Hangfire Job 幂等(Job 参数带业务键,执行前检查)
  • 重放响应头加 Idempotency-Replayed: true,便于排查

运维阶段

  • 监控指标:重放率、409 率、422 率、PROCESSING 滞留数
  • 去重表定期归档清理(DONE 记录保留 24h~7d 后归档)
  • 告警:同一键短时间大量 422(可能是接入方 bug 或攻击)
  • 压测:同键并发场景验证只落一条
  • 混沌测试:Redis 宕机、应用崩溃在 PROCESSING 中途

13. 小结

幂等性不是一个中间件,而是一套贯穿客户端、网关、应用、数据库、消息队列的系统性设计

  • 概念上:执行 N 次 == 执行 1 次,业务效果与返回结果都可预期;
  • API 层:Idempotency-Key + 请求指纹 + 三态模型(PROCESSING/DONE/FAILED)+ 租约 TTL;
  • 存储上:Redis 高速判定 + 数据库去重表权威兜底,去重记录与业务同事务提交;
  • 业务层:状态机守卫,非法转移直接拒绝,不依赖客户端自觉;
  • 数据层:业务唯一索引 + UPSERT + 乐观锁,最后一道硬兜底;
  • 消息层:Inbox Pattern,消息 ID 即幂等键;
  • 船岸场景:message_id 去重 + 业务唯一键 + 版本号防乱序 + 对账兜底最终一致。

记住一句话:在分布式系统里,"请求只会来一次"是最奢侈的假设;把每一次写操作都当成可能被执行 N 次来设计,系统才真正可靠。

💬 互动一下

  1. 你们的接口现在带幂等键了吗?如果没有,最先想给哪个接口加上?
  2. 幂等键你会选 UUID 随机生成,还是用业务单据 ID?为什么?
  3. 有没有遇到过"幂等键方案本身出 bug 导致正常请求被误杀"的情况?欢迎分享排查过程。
相关推荐
QQ_21696290961 小时前
【源码编号:project79475】SpringBoot校内二手交易平台:商品发布、分类检索、留言交流、订单管理全流程实战
java·spring boot·后端
郑州光合科技余经理3 小时前
本地生活服务系统:模块边界与结算字段怎么拆
java·开发语言·前端·后端·系统架构·uni-app·php
IT_陈寒3 小时前
Vue的嵌套组件竟然吃掉了我的事件?
前端·人工智能·后端
大辉狼_音频架构4 小时前
进阶:从源码编译 SOF 固件与 topology
后端
大辉狼_音频架构4 小时前
上板:让 SOF 在 FRDM-i.MX8MP 上跑起来
后端
大辉狼_音频架构4 小时前
FRDM-IMX8MP UUU 烧录 eMMC 指南
后端
运行时异常5 小时前
【WMS 仓储系统集成 AI Agent 实战】第 3 讲:Spring Security 6 + JWT——addFilterBefore 一词之差,全站 401
java·后端
LinMINGJing0075 小时前
PageHeaderData:Page 的页面头
后端
2601_962069775 小时前
SpringBoot开发——初步了解SpringBoot
java·spring boot·后端