在分布式系统中,一次「支付 100 元」的请求因为网络超时被重试,用户就被扣了两次钱——这不是段子,而是每个后端开发者都可能踩的坑。据 Datadog 2025 年的调查,超过 37% 的线上事故与请求重复处理有关,其中支付和库存扣减是重灾区。幂等性(Idempotency)设计是解决这类问题的核心武器:无论同一操作被执行多少次,结果都与执行一次相同。本文将从原理到实战,系统讲解幂等性设计的完整方案。
🔐 一、幂等性的本质与分类
1.1 什么是幂等性
数学中的幂等性定义很简单:f(f(x)) = f(x)。映射到 HTTP 语义,GET、PUT、DELETE 天然是幂等的——读取资源、更新资源、删除资源,重复执行结果不变。而 POST 天然是非幂等的——每次 POST /orders 都可能创建一条新订单。
但现实远比 HTTP 语义复杂。即使你用 PUT 更新库存,SET stock = stock - 1 执行两次也会少扣一件。幂等性的核心不在 HTTP 方法,在于业务逻辑的设计。
1.2 幂等性的三个层次
| 层次 | 说明 | 示例 | 推荐场景 |
|---|---|---|---|
| 传输层幂等 | HTTP 方法级别的幂等 | GET/PUT/DELETE 语义 | 简单 CRUD |
| 业务层幂等 | 业务逻辑级别的幂等 | 订单创建、支付扣款 | ✅ 大多数场景 |
| 端到端幂等 | 从客户端到存储层全链路幂等 | 分布式事务、跨服务调用 | ✅ 核心金融场景 |
⚠️ **警告:**不要依赖 HTTP 方法语义来保证幂等性。真正的幂等性必须在业务逻辑层实现。
1.3 需要幂等设计的典型场景
- ✅ 支付回调通知(支付网关会重复通知)
- ✅ 消息队列消费者(At-least-once 投递语义)
- ✅ 前端重复提交(用户连点按钮、网络重试)
- ✅ 分布式事务补偿(Saga 模式的重试)
- ✅ Webhook 事件处理(第三方可能重复推送)
💡 **提示:**一个简单的判断标准——如果一个操作涉及「从 A 到 B 的状态转移」(扣款、减库存、改状态),就必须考虑幂等性。
🚀 二、五大幂等方案实战
2.1 方案一:Token 机制(防止重复提交)
这是最常见的前端防重复提交方案。核心流程:服务端生成唯一 Token → 客户端携带 Token 提交 → 服务端校验并删除 Token → 重复请求因 Token 不存在而被拒绝。
Java + Redis 实现:
// 服务端:生成幂等 Token
@RestController
public class IdempotentController {
@Autowired
private StringRedisTemplate redisTemplate;
// 获取 Token(用户进入表单页面时调用)
@GetMapping("/api/token")
public String generateToken() {
String token = UUID.randomUUID().toString();
// Token 有效期 5 分钟,存入 Redis
redisTemplate.opsForValue().set(
"idempotent:token:" + token, "1", 5, TimeUnit.MINUTES
);
return token;
}
// 提交订单(携带 Token)
@PostMapping("/api/orders")
public ResponseEntity<?> createOrder(
@RequestHeader("X-Idempotent-Token") String token,
@RequestBody OrderDTO order) {
String redisKey = "idempotent:token:" + token;
// 原子性检查并删除 Token(Lua 脚本保证原子性)
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
"return redis.call('del', KEYS[1]) else return 0 end";
Long result = redisTemplate.execute(
new DefaultRedisScript<>(script, Long.class),
List.of(redisKey), "1"
);
if (result == null || result == 0L) {
return ResponseEntity.status(409)
.body(Map.of("error", "重复请求,请勿重复提交"));
}
// Token 校验通过,执行业务逻辑
Order saved = orderService.create(order);
return ResponseEntity.ok(saved);
}
}
📌 **记住:**Token 的检查和删除必须是原子操作。如果先检查再删除,在并发场景下两个请求都可能通过检查。
2.2 方案二:唯一请求 ID + 去重表
这是最通用的服务端幂等方案。每次请求携带唯一 ID,服务端通过去重表判断是否已处理过。
数据库表设计(PostgreSQL):
-- 请求去重表
CREATE TABLE idempotency_keys (
idempotency_key VARCHAR(64) PRIMARY KEY, -- 唯一请求 ID
request_hash VARCHAR(64), -- 请求体哈希,用于检测内容变化
response_body JSONB, -- 缓存的响应结果
status VARCHAR(20) NOT NULL, -- PROCESSING / COMPLETED / FAILED
created_at TIMESTAMP DEFAULT NOW(),
expires_at TIMESTAMP NOT NULL -- 过期时间,用于自动清理
);
-- 自动清理过期记录(建议用定时任务或 pg_cron)
CREATE INDEX idx_idempotency_expires ON idempotency_keys (expires_at);
Node.js + PostgreSQL 实现:
// idempotency-middleware.js
import crypto from 'crypto';
import { db } from './database.js';
export function idempotencyMiddleware(options = {}) {
const { ttlHours = 24 } = options;
return async (req, res, next) => {
const key = req.headers['idempotency-key'];
if (!key) {
return res.status(400).json({ error: '缺少 Idempotency-Key 请求头' });
}
// 校验 Key 格式(UUID v4)
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(key)) {
return res.status(400).json({ error: 'Idempotency-Key 格式无效' });
}
const requestHash = crypto
.createHash('sha256')
.update(JSON.stringify(req.body))
.digest('hex');
try {
// 尝试插入去重记录(利用唯一约束实现原子检查)
await db.query(`
INSERT INTO idempotency_keys (idempotency_key, request_hash, status, expires_at)
VALUES ($1, $2, 'PROCESSING', NOW() + INTERVAL '${ttlHours} hours')
ON CONFLICT (idempotency_key) DO NOTHING
`, [key, requestHash]);
// 查询记录状态
const { rows } = await db.query(
'SELECT status, request_hash, response_body FROM idempotency_keys WHERE idempotency_key = $1',
[key]
);
const record = rows[0];
if (record.status === 'COMPLETED') {
// 已完成:检查请求体是否一致
if (record.request_hash !== requestHash) {
return res.status(422).json({
error: '相同的 Idempotency-Key 但请求内容不同'
});
}
// 直接返回缓存的响应
return res.status(200).json(record.response_body);
}
if (record.status === 'PROCESSING') {
// 正在处理中(可能是上次请求中途崩溃)
// 策略一:返回 409 告知冲突
// 策略二:等待一段时间后重试
return res.status(409).json({ error: '请求正在处理中,请稍后重试' });
}
// 拦截 res.json 以缓存响应
const originalJson = res.json.bind(res);
res.json = async (body) => {
await db.query(`
UPDATE idempotency_keys
SET status = 'COMPLETED', response_body = $2
WHERE idempotency_key = $1
`, [key, JSON.stringify(body)]);
return originalJson(body);
};
next();
} catch (err) {
console.error('幂等性检查失败:', err);
next(); // 降级放行,不要因为幂等检查失败而阻塞业务
}
};
}
// 使用方式
app.post('/api/payments', idempotencyMiddleware({ ttlHours: 48 }), async (req, res) => {
const payment = await paymentService.charge(req.body);
res.json({ success: true, payment });
});
⚠️ **警告:**去重表方案中,「正在处理中」的状态处理是最大的坑。如果服务在
INSERT之后、业务完成之前崩溃,该 Key 会一直处于 PROCESSING 状态。必须设置合理的超时机制。
2.3 方案三:状态机约束
对于有明确状态流转的业务(如订单:待支付→已支付→已发货→已完成),利用数据库的状态约束天然实现幂等。
-- 订单状态机幂等:只在「待支付」状态才能变更为「已支付」
UPDATE orders
SET status = 'PAID',
paid_at = NOW(),
payment_no = 'PAY20260530001'
WHERE order_no = 'ORD20260530001'
AND status = 'PENDING'; -- 关键:状态条件
-- 如果 affected rows = 0,说明订单不在 PENDING 状态
-- 可能是已支付(幂等成功)或其他状态(业务异常)
Java 实现:
@Service
public class OrderService {
@Transactional
public PayResult payOrder(String orderNo, PaymentInfo payment) {
// 方式一:带状态条件的更新
int affected = orderMapper.updateStatus(
orderNo, OrderStatus.PENDING, OrderStatus.PAID, payment
);
if (affected == 0) {
// 查询当前状态,区分「已支付」和「非法状态」
Order order = orderMapper.findByOrderNo(orderNo);
if (order == null) {
throw new BizException("订单不存在");
}
if (order.getStatus() == OrderStatus.PAID) {
// 幂等:已经支付过了,返回成功
return PayResult.success(order.getPaymentNo());
}
// 非幂等异常:订单状态不允许支付
throw new BizException("订单状态异常: " + order.getStatus());
}
return PayResult.success(payment.getPaymentNo());
}
}
// MyBatis Mapper
@Update("UPDATE orders SET status = #{newStatus}, " +
"payment_no = #{paymentNo}, paid_at = NOW() " +
"WHERE order_no = #{orderNo} AND status = #{oldStatus}")
int updateStatus(@Param("orderNo") String orderNo,
@Param("oldStatus") OrderStatus oldStatus,
@Param("newStatus") OrderStatus newStatus,
@Param("paymentNo") String paymentNo);
💡 **提示:**状态机方案是最优雅的幂等实现——它把幂等性内化到了业务模型中,不需要额外的去重表或 Redis。
2.4 方案四:数据库唯一约束
利用数据库的唯一约束(Unique Constraint)来防止重复插入,简单粗暴但有效。
-- 订单号全局唯一,天然防重复
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
order_no VARCHAR(32) UNIQUE NOT NULL, -- 唯一约束
user_id BIGINT NOT NULL,
amount DECIMAL(10,2) NOT NULL,
status VARCHAR(20) DEFAULT 'PENDING',
created_at TIMESTAMP DEFAULT NOW()
);
// Node.js 实现
async function createOrder(orderData) {
try {
const order = await db.query(`
INSERT INTO orders (order_no, user_id, amount)
VALUES ($1, $2, $3)
RETURNING *
`, [orderData.orderNo, orderData.userId, orderData.amount]);
return { success: true, order: order.rows[0] };
} catch (err) {
// PostgreSQL 唯一约束错误码
if (err.code === '23505') {
// 重复请求,查询已有订单返回
const existing = await db.query(
'SELECT * FROM orders WHERE order_no = $1',
[orderData.orderNo]
);
return { success: true, order: existing.rows[0], idempotent: true };
}
throw err;
}
}
2.5 方案五:Outbox 模式 + 消息去重
在消息驱动的架构中,Outbox 模式结合消息 ID 去重可以实现端到端的幂等性。
-- Outbox 表:业务操作和消息发布在同一事务中
CREATE TABLE outbox_events (
id BIGSERIAL PRIMARY KEY,
aggregate_type VARCHAR(50) NOT NULL,
aggregate_id VARCHAR(50) NOT NULL,
event_type VARCHAR(100) NOT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
published BOOLEAN DEFAULT FALSE
);
-- 消费者去重表
CREATE TABLE processed_events (
event_id BIGINT PRIMARY KEY, -- 与 outbox_events.id 对应
processed_at TIMESTAMP DEFAULT NOW()
);
// 消费者幂等处理
async function handleOrderEvent(event) {
// 原子性检查:如果已处理过则跳过
const { rowCount } = await db.query(`
INSERT INTO processed_events (event_id)
VALUES ($1)
ON CONFLICT (event_id) DO NOTHING
`, [event.id]);
if (rowCount === 0) {
console.log(`事件 ${event.id} 已处理,跳过`);
return;
}
// 执行业务逻辑
await inventoryService.deduct(event.payload.productId, event.payload.quantity);
}
💡 三、方案选型与避坑指南
3.1 五种方案对比
| 方案 | 实现复杂度 | 适用场景 | 性能影响 | 推荐 |
|---|---|---|---|---|
| Token 机制 | 低 | 前端防重复提交 | 低(一次 Redis 调用) | ✅ 前端场景首选 |
| 唯一请求 ID + 去重表 | 中 | API 级别通用幂等 | 中(每次请求多一次 DB 查询) | ✅ 通用后端首选 |
| 状态机约束 | 低 | 有明确状态流转的业务 | 低(利用已有 UPDATE) | ✅ 业务模型允许时首选 |
| 数据库唯一约束 | 低 | 防重复插入 | 低(依赖 DB 约束) | ✅ 简单插入场景 |
| Outbox + 消息去重 | 高 | 消息驱动架构 | 中(额外存储和查询) | ✅ 事件驱动架构首选 |
3.2 常见坑点与避坑策略
坑点一:幂等 Key 的生成时机不对
// ❌ 错误:在前端生成幂等 Key
const idempotencyKey = uuid(); // 每次页面刷新都会重新生成
// ✅ 正确:在进入页面时生成,提交成功前不变
// 保存到 sessionStorage 或组件状态中
const idempotencyKey = sessionStorage.getItem('order_token') || (() => {
const key = uuid();
sessionStorage.setItem('order_token', key);
return key;
})();
坑点二:幂等性只检查了请求 ID,没检查请求内容
// ❌ 错误:只要 ID 相同就返回缓存结果
const cached = await db.get(key);
if (cached) return cached.response;
// ✅ 正确:校验请求体是否一致
const cached = await db.get(key);
if (cached && hash(req.body) === cached.requestHash) {
return cached.response;
} else if (cached) {
return res.status(422).json({ error: '相同 Key 但请求内容不同' });
}
坑点三:并发请求的竞态条件
两个携带相同幂等 Key 的请求同时到达,都检查「不存在」,然后都执行了业务逻辑。
// ❌ 错误:先查后插,存在竞态窗口
const existing = await db.get(key);
if (existing) return existing;
await db.insert(key, data); // 两个请求可能都走到这里
// ✅ 正确:利用数据库唯一约束或 Redis SETNX 的原子性
const acquired = await redis.set(`lock:${key}`, '1', 'NX', 'EX', 30);
if (!acquired) return res.status(409).json({ error: '重复请求' });
3.3 幂等性的过期策略
幂等记录不能永久保存,必须有合理的过期策略:
- ✅ 支付类操作:保留 48-72 小时(覆盖对账周期)
- ✅ 订单创建:保留 24 小时
- ✅ 普通 API:保留 1-24 小时
- ❌ 不要设置过短的过期时间(可能在重试窗口内就过期了)
- ❌ 不要永不过期(存储成本会无限增长)
⚠️ **警告:**过期时间必须大于客户端最大重试周期。如果客户端的重试策略是「最多重试 3 次,间隔 30 秒」,幂等记录至少要保留 2 分钟。
🔧 四、不同技术栈的最佳实践
4.1 Spring Boot 统一幂等注解
// 自定义幂等注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
long timeout() default 3600; // 秒
TimeUnit timeUnit() default TimeUnit.SECONDS;
String message() default "重复请求,请勿重复提交";
}
// AOP 切面实现
@Aspect
@Component
public class IdempotentAspect {
@Autowired
private StringRedisTemplate redis;
@Around("@annotation(idempotent)")
public Object around(ProceedingJoinPoint joinPoint, Idempotent idempotent) throws Throwable {
// 从请求头获取幂等 Key
HttpServletRequest request = ((ServletRequestAttributes)
RequestContextHolder.getRequestAttributes()).getRequest();
String key = request.getHeader("Idempotency-Key");
if (key == null || key.isBlank()) {
throw new IllegalArgumentException("缺少 Idempotency-Key 请求头");
}
String redisKey = "idempotent:" + key;
Boolean acquired = redis.opsForValue()
.setIfAbsent(redisKey, "PROCESSING", idempotent.timeout(), idempotent.timeUnit());
if (Boolean.FALSE.equals(acquired)) {
String cached = redis.opsForValue().get(redisKey);
if (!"PROCESSING".equals(cached)) {
return JSON.parseObject(cached, Object.class);
}
throw new BizException(idempotent.message());
}
try {
Object result = joinPoint.proceed();
redis.opsForValue().set(redisKey,
JSON.toJSONString(result), idempotent.timeout(), idempotent.timeUnit());
return result;
} catch (Exception e) {
redis.delete(redisKey); // 失败时删除,允许重试
throw e;
}
}
}
// 使用:一行注解搞定
@Idempotent(timeout = 7200)
@PostMapping("/api/payments")
public Result createPayment(@RequestBody PaymentDTO dto,
@RequestHeader("Idempotency-Key") String key) {
return paymentService.process(dto);
}
4.2 性能考量
幂等性检查会引入额外的存储查询开销,但通常可以忽略不计:
| 操作 | 耗时 | 说明 |
|---|---|---|
| Redis GET + SETNX | 0.1-0.5ms | 网络延迟为主 |
| PostgreSQL INSERT ON CONFLICT | 0.5-2ms | 取决于索引大小 |
| 业务逻辑(支付/库存) | 10-500ms | 远大于幂等检查开销 |
⚡ **关键结论:**幂等性检查的开销通常不到业务逻辑的 1%,但能避免 100% 的重复处理风险。这笔账怎么算都划算。
✅ 总结
幂等性不是可选的「高级特性」,而是分布式系统的基础设施。选择哪种方案取决于你的场景:
- 前端防重复 → Token 机制,简单高效
- API 级别通用幂等 → 唯一请求 ID + 去重表,最灵活
- 有状态流转的业务 → 状态机约束,最优雅
- 简单防重复插入 → 数据库唯一约束,最简单
- 消息驱动架构 → Outbox + 消息去重,最可靠
📌 **记住:**幂等性设计的核心原则是——把「是否已处理」的判断交给一个有原子性保证的存储层(数据库约束、Redis SETNX、Lua 脚本),而不是在应用层用 if-else 判断。
推荐工具:
- 🔧 Redis — 高性能幂等 Key 存储
- 🔧 PostgreSQL —
INSERT ... ON CONFLICT天然支持去重 - 🔧 Debezium — CDC 工具,配合 Outbox 模式实现事件去重
- 🔧 jsjson.com JSON 工具 — 在线格式化和校验 API 请求体,开发调试必备