本文配套船舶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 次,系统状态相同。
工程里更精确的表述:同一个操作,用同样的参数执行一次或多次,产生的业务效果与只执行一次完全相同,且服务端返回的结果也一致(或语义一致)。
注意两个要件:
- 业务效果相同------不能扣两次库存、不能生成两张单;
- 返回结果可预期 ------重复请求要么返回首次的结果,要么返回明确的"重复请求"提示,不能报错 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 都在用):
-
客户端在发起可能不幂等 的请求时,生成一个全局唯一字符串(UUID v4 推荐),放在请求头
Idempotency-Key里; -
服务端收到请求后,以这个键为索引记录"请求-响应"映射;
-
第一次请求:正常处理,把响应状态码、响应头、响应体持久化;
-
后续携带相同键的请求:
- 如果参数(请求指纹)一致 → 直接返回首次缓存的响应,业务逻辑不再执行;
- 如果参数不一致 → 返回
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 请求指纹:防止"同键不同参"
光有键不够。考虑这个攻击/误用场景:
- 请求 A:
Key=K1,创建申领单 10 个滤芯 → 成功; - 请求 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. 十个真实踩过的坑
- GET 请求也挂幂等键------纯浪费,还污染存储。只对 POST/PATCH 挂。
- 幂等键放在 URL Query 里------会被日志、Referer、浏览器历史泄露。放 Header。
- 指纹把 Authorization Header 算进去了------Token 刷新后同业务请求指纹变化,误判 422。指纹只算业务要素(路径 + 查询 + body + 租户)。
- 响应体没缓存,重放时重新执行业务 ------某项目重放路径里忘了 return,又走了一遍
_next,重复落库。重放分支必须短路。 - 业务失败也缓存 DONE------用户库存不足被拒,改了数量用同键重试,直接返回上次的"库存不足"响应,用户抓狂。只缓存 2xx。
- PROCESSING 键没有 TTL------进程崩溃后键永久占位,该笔业务永远重试不了。租约必须有。
- Redis 和数据库不是同一租户维度 ------键没带 tenant_id,A 船的键和 B 船冲突。多租户系统键必须
tenant:key复合。 - 幂等键用自增序号 ------可枚举、可预测,有人拿
key=1001重放别人的请求。用 UUID/GUID。 - 大响应体直接塞 Redis------导出类接口响应几十 MB,打爆 Redis 内存。响应超过阈值(如 256KB)只缓存"结果指针"(如单据 ID + 状态),重放时按指针重新查询组装。
- 只在 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 次来设计,系统才真正可靠。
💬 互动一下:
- 你们的接口现在带幂等键了吗?如果没有,最先想给哪个接口加上?
- 幂等键你会选 UUID 随机生成,还是用业务单据 ID?为什么?
- 有没有遇到过"幂等键方案本身出 bug 导致正常请求被误杀"的情况?欢迎分享排查过程。