简单加锁无法解决分布式API幂等性,因多实例下锁不共享;可靠方案需客户端提供Idempotency-Key,服务端结合Redis原子校验与数据库唯一约束实现全局幂等。

为什么简单加锁不能解决分布式API幂等性
单机环境用 lock 或 ConcurrentDictionary 拦截重复请求看似可行,但实际在负载均衡、多实例部署下完全失效——两个请求可能被分发到不同服务器,各自加锁互不影响。更糟的是,若请求超时重试,而第一次调用已写库但响应未返回,第二次进来就会重复落库。
真正可靠的幂等性必须依赖「全局唯一标识 + 存储层校验」,且该标识需由客户端生成(如 Idempotency-Key HTTP Header),服务端不可自动生成或推导。
- 客户端每次发起业务请求时,必须提供一个稳定、可重试的
Idempotency-Key(例如 UUID v4) - 服务端收到后,先查数据库/Redis 是否已存在该 key 对应的处理结果(成功/失败/进行中)
- 若存在且状态非“进行中”,直接返回上次结果;若不存在,则写入“进行中”状态再执行业务逻辑
- 业务完成后更新该 key 状态为“成功”或“失败”,并附带响应体快照(供后续直接返回)
用Redis实现高并发幂等控制的关键细节
Redis 是最常用的选择,但不是简单 SETNX 就完事。常见错误是只存 key 不设过期时间,导致异常中断后状态卡死;或没用 Lua 脚本保证原子性,造成状态覆盖。
推荐使用以下原子操作组合:
if redis.call("EXISTS", KEYS[1]) == 0 then
redis.call("SETEX", KEYS[1], ARGV[1], ARGV[2])
return 1
else
local val = redis.call("GET", KEYS[1])
if val == "processing" then return 2 end
return 0
end
-
KEYS[1]是客户端传来的Idempotency-Key -
ARGV[1]是过期时间(建议 24h,覆盖最长业务链路+重试窗口) -
ARGV[2]初始值设为"processing",避免空值误判 - 返回 1 表示首次进入,可执行业务;返回 0 表示已有终态结果;返回 2 表示正在处理中(此时应阻塞或返回 409)
数据库层面如何防止同key多次写入
即使 Redis 层拦截了大部分重复,仍需在 DB 写入时做最终防护。不能依赖应用层判断,必须靠数据库约束。
- 在业务表中增加唯一字段,如
idempotency_key NVARCHAR(64) NOT NULL - 为该字段建唯一索引:
CREATE UNIQUE INDEX IX_Order_IdempotencyKey ON Orders(idempotency_key) - 插入前不查库(避免查-插之间的竞态),直接
INSERT INTO ... VALUES (...) ON CONFLICT DO NOTHING(PostgreSQL)或MERGE(SQL Server) - 若 DB 报唯一键冲突,说明该 key 已存在,此时应回查该记录状态并返回对应结果,而非抛异常
注意:MySQL 的 INSERT IGNORE 或 ON DUPLICATE KEY UPDATE 也可用,但需确保更新语句不会意外覆盖正确数据。
ASP.NET Core 中间件封装幂等逻辑的坑点
很多人把幂等逻辑写在 Controller 里,结果每个接口都要重复写校验、状态更新、结果返回,极易漏掉异常分支。中间件更合适,但要注意几个硬伤:
- 不能在中间件里直接 await DB 操作——
HttpContext.Request.Body流只能读一次,需提前缓冲(Request.EnableBuffering()) - 幂等 key 必须从 Header 提取,别从 Query 或 Body 解析,否则重试时可能因参数微小差异(如空格、时间戳)导致 key 不一致
- 中间件捕获到“已存在结果”时,不能简单
context.Response.WriteAsync(...),要复用原接口的序列化器、状态码、Content-Type - 若业务逻辑含外部 HTTP 调用,其响应也应缓存进幂等存储(比如 Redis 中存 JSON 字符串 + 状态码),否则二次返回时格式不一致
最易被忽略的是:幂等 key 的生命周期管理。它不该永久存在,也不能过早删除——必须等整个业务流程确认完成(包括异步补偿)后再清理,否则可能造成“假重复”。










