幂等性设计实战:分布式系统防重复处理完整指南

深入讲解幂等性设计原理与实战,覆盖 Token 机制、状态机、去重表、Outbox 模式等方案,含 Java/Node.js/SQL 完整代码示例,助你构建可靠的分布式系统。

API 设计 2026-05-29 12 分钟

在分布式系统中,一次「支付 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 存储
  • 🔧 PostgreSQLINSERT ... ON CONFLICT 天然支持去重
  • 🔧 Debezium — CDC 工具,配合 Outbox 模式实现事件去重
  • 🔧 jsjson.com JSON 工具 — 在线格式化和校验 API 请求体,开发调试必备

📚 相关文章