后端接口幂等性:从 0 到 1 的落地指南(Redis + 唯一索引 + 去重表 + 分布式锁)

适合人群:后端/全栈开发者、支付/下单/发券等“只能成功一次”的业务。
阅读收获:理解幂等性的本质与常见方案;拿走可直接用的 Redis数据库唯一约束 实战代码、上线自测清单。


目录

  1. 为什么需要幂等性
  2. 幂等键怎么设计
  3. 三种常用落地方案
  4. 方案选择建议
  5. Spring Boot AOP 快速落地
  6. 常见坑与规避
  7. 上线前自测清单
  8. 结语与模板获取

1. 为什么需要幂等性?

常见重复请求来源:

  • 用户重复点击、前端重试、网关/客户端自动重试
  • 支付回调/消息系统“至少一次”投递;
  • 服务内部超时重试故障恢复

目标:同一个业务请求执行一次或多次,副作用一致(只生成一张订单、只扣一次库存、只发一次券)。

注意:幂等性是业务语义,不是某个框架开关。你需要先定义“同一个请求”的判定规则(幂等键)。


2. 幂等键怎么设计?

推荐优先级(越靠上越通用):

  1. 客户端生成 Idempotency-Key(UUID 或业务唯一号,如 out_trade_noorder_no)。
  2. 服务端指纹method + path + canonical(body) + user_id 做哈希(SHA-256)。
  3. 业务唯一键:如 (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. 上线前自测清单(逐条勾)

  1. 并发 10–100 同一幂等键压测(JMeter/k6),不会落多条记录;
  2. 注入 网络超时/502/应用重启,重放仍返回相同结果
  3. 在“业务成功但未写缓存”时刻 kill -9,重试由唯一约束兜底;
  4. TTL 到期后重放,对已完成单据仍由唯一索引保证不重复;
  5. 监控:统计 idem:hitidem:reject、唯一约束冲突次数,异常升高报警。

8. 结语与模板获取

  • 没有银弹,但 “唯一约束 + 去重表(事务)” 是最稳的底座,Redis 是高并发下的加速器
  • 先把 回调/下单/发券 改造成“幂等键 + 唯一约束”,再视情况加上 Redis 快速挡重与响应缓存。

需要的话,我可以把文中的 Node.js 中间件 / Spring Boot 切面与压测脚本打包为最小可运行示例(zip),方便你直接落地。

Logo

更多推荐