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