上周五下班前,客服群里直接炸了锅,反馈好几个大客户的结算单在后台出现了双重扣款。排查日志发现,由于弱网环境下客户端请求超时,前端干脆利落地触发了自动重试机制。因为后端没有做防重校验,两个一模一样的扣款请求直接穿透到数据库,导致系统傻乎乎地生成了双份交易流水。
这种生产事故在没有做幂等的支付系统里屡见不鲜。在分布式场景下,任何网络抖动或自动重试机制都会把这个大坑无限放大。
为了彻底解决这令人崩溃的资损隐患,我们团队连夜重构了接口的幂等控制设计,在 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 挂了,是直接降级放行还是报错拒绝?欢迎在评论区聊聊你们的取舍。