上周五下班前,客服群里直接炸了锅,反馈好几个大客户的结算单在后台出现了双重扣款。排查日志发现,由于弱网环境下客户端请求超时,前端干脆利落地触发了自动重试机制。因为后端没有做防重校验,两个一模一样的扣款请求直接穿透到数据库,导致系统傻乎乎地生成了双份交易流水。

这种生产事故在没有做幂等的支付系统里屡见不鲜。在分布式场景下,任何网络抖动或自动重试机制都会把这个大坑无限放大。

为了彻底解决这令人崩溃的资损隐患,我们团队连夜重构了接口的幂等控制设计,在 RESTful API 中引入了通用的 Idempotency-Key 幂等校验拦截。这套拦截方案基于 Redis 分布式锁与结果缓存实现,以下是完整的 Express/TypeScript 中间件实现方案:

import { Request, Response, NextFunction } from 'express';
import Redis from 'ioredis';
import crypto from 'crypto';

const redis = new Redis({
  host: process.env.REDIS_HOST || '127.0.0.1',
  port: Number(process.env.REDIS_PORT) || 6379,
  maxRetriesPerRequest: 3
});

interface CachedResponse {
  statusCode: number;
  body: any;
  headers: Record<string, string | string[] | undefined>;
}

export function idempotencyMiddleware(ttlSeconds = 86400) {
  return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
    const idempotencyKey = req.header('Idempotency-Key');

    // 只有非幂等的写操作需要强行约束
    const targetMethods = ['POST', 'PUT', 'PATCH'];
    if (!targetMethods.includes(req.method)) {
      return next();
    }

    // 客户端没有传 Key,视业务敏感度,这里我们直接拒绝请求
    if (!idempotencyKey) {
      res.status(400).json({ error: 'Missing Idempotency-Key header.' });
      return;
    }

    // 格式校验,必须是 UUID 规范,防止恶意注入或碰撞
    const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
    if (!uuidRegex.test(idempotencyKey)) {
      res.status(400).json({ error: 'Invalid Idempotency-Key format. Must be UUID.' });
      return;
    }

    // 计算请求体的 Hash 校验码,防止客户端换了参数却用同一个 Key 来骗取缓存
    const bodyHash = crypto.createHash('sha256').update(JSON.stringify(req.body || {})).digest('hex');
    const lockKey = `idempotency:lock:${idempotencyKey}`;
    const dataKey = `idempotency:data:${idempotencyKey}`;

    try {
      // 1. 抢占排他锁,防止并发重试导致穿透
      const acquiredLock = await redis.set(lockKey, 'LOCKING', 'NX', 'PX', 10000);
      if (!acquiredLock) {
        // 抢不到锁,说明同一个 Key 的请求正在处理中,返回 409 冲突
        res.status(409).json({ error: 'Request with this Idempotency-Key is already in progress.' });
        return;
      }

      // 2. 检查是否有执行完的历史缓存记录
      const cached = await redis.get(dataKey);
      if (cached) {
        await redis.del(lockKey); // 释放锁
        const savedResponse: CachedResponse = JSON.parse(cached);
        res.status(savedResponse.statusCode).json(savedResponse.body);
        return;
      }

      // 3. 重写 res.send,在响应发送前自动拦截并缓存
      const originalSend = res.send;
      res.send = function (body: any): Response {
        res.send = originalSend;

        const responseData: CachedResponse = {
          statusCode: res.statusCode,
          body: JSON.parse(body),
          headers: res.getHeaders()
        };

        // 仅在成功响应或客户端错误(排除5xx后端崩溃)时缓存结果
        if (res.statusCode < 500) {
          redis.set(dataKey, JSON.stringify(responseData), 'EX', ttlSeconds).catch(err => {
            console.error('Failed to cache idempotency result:', err);
          });
        }

        redis.del(lockKey).catch(err => {
          console.error('Failed to release idempotency lock:', err);
        });

        return originalSend.call(this, body);
      };

      next();
    } catch (error) {
      // 容灾处理:如果 Redis 挂了,这里可以选择降级放行或者直接抛 500
      await redis.del(lockKey).catch(() => {});
      res.status(500).json({ error: 'Internal system error checking request state.' });
    }
  };
}

在这套设计下,前端的重试请求只要带着相同的 Idempotency-Key,哪怕是在后端业务逻辑还没有跑完时发过来的,也会直接被拦截在 409 Conflict。如果已经跑完,就能无缝拿到原汁原味的响应报文,彻底规避了重复调用的心智负担。

不过这里有个细节折腾了我们很久:如果上游在请求里改了 body 参数,但依然顶着之前的 UUID 发请求,我们是否应该直接报错?目前的做法是通过 bodyHash 比对,若不匹配直接返回请求参数不一致的错误。

大伙在做高并发支付幂等时,这个 Key 的过期时间一般定多久?5分钟还是24小时?要是遇到 Redis 挂了,是直接降级放行还是报错拒绝?欢迎在评论区聊聊你们的取舍。