后端接口幂等性:从 0 到 1 的落地指南(Redis + 唯一索引 + 去重表 + 分布式锁)
本文聚焦后端接口幂等性,解析重复请求来源与幂等键设计(Idempotency-Key/业务唯一号/指纹)。提供三类方案:Redis SETNX+TTL 快速挡重、数据库唯一约束/去重表(同库事务,强一致,推荐)、分布式锁(补充)。附 Node.js 中间件与 Spring Boot AOP 示例、选型建议、常见坑与上线自测清单,助力支付/下单/发券等场景稳健落地。
后端接口幂等性:从 0 到 1 的落地指南(Redis + 唯一索引 + 去重表 + 分布式锁)
适合人群:后端/全栈开发者、支付/下单/发券等“只能成功一次”的业务。
阅读收获:理解幂等性的本质与常见方案;拿走可直接用的 Redis 与 数据库唯一约束 实战代码、上线自测清单。
目录
- 为什么需要幂等性
- 幂等键怎么设计
- 三种常用落地方案
- 方案选择建议
- Spring Boot AOP 快速落地
- 常见坑与规避
- 上线前自测清单
- 结语与模板获取
1. 为什么需要幂等性?
常见重复请求来源:
- 用户重复点击、前端重试、网关/客户端自动重试;
- 支付回调/消息系统“至少一次”投递;
- 服务内部超时重试或故障恢复。
目标:同一个业务请求执行一次或多次,副作用一致(只生成一张订单、只扣一次库存、只发一次券)。
注意:幂等性是业务语义,不是某个框架开关。你需要先定义“同一个请求”的判定规则(幂等键)。
2. 幂等键怎么设计?
推荐优先级(越靠上越通用):
- 客户端生成
Idempotency-Key(UUID 或业务唯一号,如out_trade_no、order_no)。 - 服务端指纹:
method + path + canonical(body) + user_id做哈希(SHA-256)。 - 业务唯一键:如
(user_id, coupon_id)、out_trade_no,用数据库唯一约束保证只落一次。
最好显式传入
Idempotency-Key,便于跨服务链路追踪与重放返回相同结果。
3. 三种常用落地方案(择一或组合)
3.1 Redis SETNX + TTL(快速挡重复)
思路:首次请求 SET key value NX EX ttl 成功 → 放行;否则拒绝/返回上次结果。
优点:简单高性能,跨多实例可用。
缺点:非强一致。若业务已成功但尚未写入结果缓存即宕机,短时间内可能重复执行。
Node.js/Express 中间件示例(ioredis)
// pnpm add ioredis crypto
import Redis from 'ioredis'
import crypto from 'node:crypto'
const redis = new Redis(process.env.REDIS_URL)
function hashBody(b) {
return crypto.createHash('sha256').update(JSON.stringify(b || {})).digest('hex')
}
export function idempotency(opts = { ttlSec: 600 }) {
return async (req, res, next) => {
const key = req.get('Idempotency-Key')
|| `${req.method}:${req.path}:${hashBody(req.body)}:${req.user?.id || 'anon'}`
const lock = await redis.set(`idem:${key}`, 'pending', 'NX', 'EX', opts.ttlSec)
if (!lock) {
const cached = await redis.get(`idem:resp:${key}`)
if (cached) return res.type('application/json').status(200).send(cached)
return res.status(409).json({ message: 'Duplicate request' })
}
const orig = res.send.bind(res)
res.send = async (body) => {
if (res.statusCode >= 200 && res.statusCode < 300) {
await redis.multi()
.set(`idem:resp:${key}`, typeof body === 'string' ? body : JSON.stringify(body), 'EX', opts.ttlSec)
.set(`idem:${key}`, 'done', 'EX', opts.ttlSec)
.exec()
} else {
await redis.del(`idem:${key}`) // 失败释放
}
return orig(body)
}
next()
}
}
生产提示:用 Lua 脚本封装占位/读写为原子操作,避免并发间隙。
3.2 数据库“去重表”+ 唯一约束(强语义,推荐)
思路:用幂等键建一张表或在业务表上加唯一索引,同一事务内先写幂等记录再执行业务。
优点:与业务写在同一事务,强一致;掉电也不会重复落库。
缺点:需要落库,高并发下注意索引竞争与重试。
SQL 示例(PostgreSQL/MySQL)
CREATE TABLE idempotency_log (
id BIGSERIAL PRIMARY KEY,
idem_key VARCHAR(128) NOT NULL UNIQUE,
status SMALLINT NOT NULL DEFAULT 0, -- 0 pending, 1 done
response JSON,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 业务表例:orders(out_order_no UNIQUE)
伪代码(以订单为例)
BEGIN;
INSERT INTO idempotency_log(idem_key, status) VALUES (:key, 0);
-- 若 UNIQUE 冲突:SELECT response WHERE idem_key=:key 并返回 -> 结束
INSERT INTO orders(out_order_no, user_id, amount) VALUES (...) -- 受 UNIQUE 保护
UPDATE wallet SET balance = balance - :amt WHERE user_id = ... -- 结合乐观锁/库存校验
UPDATE idempotency_log SET status=1, response=:result WHERE idem_key=:key;
COMMIT;
要点:
INSERT idempotency_log与后续业务写必须在同一事务;遇到唯一冲突直接读历史 response返回,实现“返回同样结果”。
3.3 分布式锁(补充型)
用 Redis 分布式锁(如 Redlock)在同一幂等键范围串行化处理,适合短事务。
不建议单独作为幂等方案,常与 3.2 搭配减少数据库冲突重试。
4. 选择建议
- 强一致 + 核心落库(支付/下单/发券):首选 3.2(唯一约束/去重表 + 同库事务);可加短期 Redis 响应缓存提升重放效率。
- 高并发读多写少,容忍极小窗口:3.1 Redis 快速挡重复,关键写用唯一索引兜底。
- 回调/消息处理:消费端以业务唯一键去重(唯一索引或去重表),不要指望“exactly-once”。
5. Spring Boot AOP 快速落地(可复制)
注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
String key() default ""; // SpEL: "#req.idempotencyKey ?: #req.orderNo"
long ttlSec() default 600;
}
切面
@Aspect
@Component
@RequiredArgsConstructor
public class IdempotentAspect {
private final StringRedisTemplate redis;
@Around("@annotation(anno)")
public Object around(ProceedingJoinPoint pjp, Idempotent anno) throws Throwable {
String key = SpelUtil.parse(anno.key(), pjp); // 从参数解析幂等键
String lockKey = "idem:" + key;
Boolean ok = redis.opsForValue().setIfAbsent(lockKey, "1", Duration.ofSeconds(anno.ttlSec()));
if (Boolean.FALSE.equals(ok)) {
String resp = redis.opsForValue().get("idem:resp:" + key);
if (resp != null) return Jsons.toObj(resp, MethodUtil.getReturnType(pjp));
throw new DuplicateRequestException("Duplicate request");
}
try {
Object ret = pjp.proceed();
redis.opsForValue().set("idem:resp:" + key, Jsons.toStr(ret), Duration.ofSeconds(anno.ttlSec()));
return ret;
} catch (Throwable t) {
redis.delete(lockKey); // 异常释放
throw t;
}
}
}
真实项目仍应在核心业务表加唯一键(如
out_order_no),保证最终只落一条。
6. 常见坑与规避
- TTL 过短:客户端慢速重试命中失败;TTL 应覆盖最大重试窗口。
- 成功未缓存:成功后未写入
idem:resp,导致重复请求被拒但取不到原响应。 - 只上锁不落库:宕机后仍会重复执行,必须有唯一约束兜底。
- 幂等键设计不当:漏掉关键字段(如
coupon_id),把不同请求当同一个。 - 外部幂等:调用第三方(支付、下游服务)要有外部幂等键,否则重复扣费。
- “Exactly-once”错觉:分布式世界不追求绝对一次,而是唯一约束 + 可重试的“业务层 exactly-once”。
7. 上线前自测清单(逐条勾)
- 并发 10–100 同一幂等键压测(JMeter/k6),不会落多条记录;
- 注入 网络超时/502/应用重启,重放仍返回相同结果;
- 在“业务成功但未写缓存”时刻 kill -9,重试由唯一约束兜底;
- TTL 到期后重放,对已完成单据仍由唯一索引保证不重复;
- 监控:统计
idem:hit、idem:reject、唯一约束冲突次数,异常升高报警。
8. 结语与模板获取
- 没有银弹,但 “唯一约束 + 去重表(事务)” 是最稳的底座,Redis 是高并发下的加速器。
- 先把 回调/下单/发券 改造成“幂等键 + 唯一约束”,再视情况加上 Redis 快速挡重与响应缓存。
需要的话,我可以把文中的 Node.js 中间件 / Spring Boot 切面与压测脚本打包为最小可运行示例(zip),方便你直接落地。
更多推荐


所有评论(0)