付一次钱记了三笔账:一次接口幂等设计的复盘

财务月底对账发现用户付一次钱、系统记了两三笔扣款,根子是写接口完全没有幂等保护:连点、超时重发、网关重试、MQ 重投全都会导致重复执行。几天补幂等设计:全局唯一幂等号、数据库唯一索引、防重表与 Token、Redis 幂等、MQ 消费幂等、注解 AOP 统一能力。

2024 年我们的支付系统出过一次对账对不平的事故:财务月底核账,发现某天有几十笔订单,用户明明只付了一次钱,系统里却记了两笔甚至三笔扣款。排查下来原因五花八门——有用户手抖连点了两下提交按钮的,有前端请求超时后自动重试的,有 MQ 消息重复投递的,还有网关层做了失败重试的。这些场景的共同点是:同一个"操作"被发起了多次,而我们的接口对此毫无防备,来一次就执行一次。说到底,是我们整个系统几乎没有"幂等"这个概念。投了几天给所有写接口补上幂等设计,本文复盘这次实战。

问题背景

业务:支付/订单系统,对外提供下单、支付、退款等写接口
事故现象:
- 用户付款一次,系统记录 2-3 笔扣款
- 对账对不平,财务月底才发现,已造成实际资损
- MQ 消费端偶发重复处理同一条消息

现场排查:
# 1. 统计重复的来源
SELECT biz_no, COUNT(*) c FROM payment_record
GROUP BY biz_no HAVING c > 1;
-- 几十个 biz_no 有 2~3 条记录

# 2. 重复请求的几种来源:
# - 用户连点提交按钮(前端没防重)
# - 前端请求超时,自动重发,但其实第一次已成功
# - 网关 / RPC 框架配了失败重试
# - MQ "至少一次"投递,消息天然可能重复
# - 用户对着失败页面手动刷新重试

# 3. 看接口代码:来一个请求就插一条、扣一次款
public void pay(PayRequest req) {
    deductBalance(req.getUserId(), req.getAmount());
    insertPaymentRecord(req);   // 没有任何重复判断
}

根因:
1. 写接口默认"调一次执行一次",完全没有幂等保护
2. 网络重试、MQ 重投、用户重复操作普遍存在,重复请求是常态
3. 把"防重复"寄希望于前端,而前端防重根本不可靠
4. 没有统一的幂等方案,各接口各写各的

修复 1:先想清楚什么是幂等

=== 幂等的定义 ===
一个操作,执行一次和执行多次,对系统产生的【最终影响相同】。
注意:不是"多次执行都成功",而是"多次执行的结果和一次一样"。

=== 哪些接口天然幂等,哪些不 ===
天然幂等(不用特殊处理):
- 查询(GET):查多少次数据都不变
- 删除指定 id(DELETE /order/123):删一次和删多次,
  结果都是"这条没了"
- 把字段设为绝对值(UPDATE SET status='PAID'):
  设多少次都是 PAID

天然【不】幂等(必须做幂等设计):
- 新增(INSERT 一条订单):调两次就两条
- 基于当前值做增减(UPDATE SET balance = balance - 100):
  执行两次就扣两次
- 发消息、发短信、调第三方支付:每次都真的发/扣

=== 关键认知 ===
重复请求不是异常,是【系统常态】 ——
网络会重试、MQ 会重投、用户会重复点击。
所以:不要假设"请求只会来一次",
所有【非幂等的写接口】,都必须主动做幂等设计。
"防重"不能依赖前端,前端能拦住连点,
拦不住网络层重试和 MQ 重投,服务端必须自己兜住。

修复 2:幂等的基石 —— 全局唯一的业务号

// === 幂等的前提:能识别出"这两个请求其实是同一个操作" ===
// 怎么识别?靠一个由【请求方生成】、全局唯一、
// 且同一次操作多次重试都【保持不变】的 id。
// 常见叫法:幂等号 / 业务流水号 / requestId。

// === 错误做法:服务端自己生成 id ===
// public void pay(PayRequest req) {
//     String id = UUID.randomUUID().toString();  // 错!
//     // 每次请求服务端都新生成一个 id,
//     // 重试的两个请求 id 不同 -> 根本识别不出是重复
// }

// === 正确做法:id 由发起方生成,重试时复用同一个 id ===
// 1. 下单场景:前端进入收银台时,先调一个接口拿一个
//    "支付流水号",这个号在这次支付的整个生命周期里不变,
//    用户连点、超时重试,带的都是同一个号。
// 2. 系统间调用:上游生成 requestId 放进请求,
//    重试时带同一个 requestId。
// 3. MQ 场景:用消息里业务自带的唯一键(如订单号)。

public class PayRequest {
    private String idempotentNo;   // 幂等号,由发起方生成并保证重试不变
    private Long userId;
    private BigDecimal amount;
}

// === 这个号要满足三个条件 ===
// 1. 全局唯一:不同业务操作的号一定不同
// 2. 重试不变:同一操作的多次重试,带同一个号
// 3. 发起方生成:服务端不能自己造,否则识别不了重复

修复 3:数据库唯一索引 —— 最可靠的兜底

// === 最朴素也最可靠的幂等:给业务表加唯一索引 ===
// 让数据库来保证"同一个幂等号只能成功插入一条"。

// 1. 给业务表的幂等号字段建唯一索引
//    ALTER TABLE payment_record
//      ADD UNIQUE KEY uk_idempotent (idempotent_no);

// 2. 插入时,靠唯一索引拦截重复
public void pay(PayRequest req) {
    PaymentRecord record = buildRecord(req);
    try {
        paymentMapper.insert(record);     // 重复的幂等号会插入失败
    } catch (DuplicateKeyException e) {
        // 唯一索引冲突 = 这个操作之前已经处理过了
        // 直接当成功返回,不要再执行扣款
        log.info("重复请求,幂等号已存在: {}", req.getIdempotentNo());
        return;
    }
    deductBalance(req.getUserId(), req.getAmount());
}

// === 为什么唯一索引是最可靠的 ===
// 唯一约束由数据库引擎在底层保证,
// 即使两个重复请求【完全并发】地走到 insert,
// 数据库也只会让其中一条成功,另一条必定报冲突。
// 不依赖应用层的判断时序,没有并发窗口。

// === 注意:扣款和插记录要放在同一个事务里 ===
// 否则可能"记录插了、扣款失败"或反之。
@Transactional(rollbackFor = Exception.class)
public void payTx(PayRequest req) {
    try {
        paymentMapper.insert(buildRecord(req));
    } catch (DuplicateKeyException e) {
        return;     // 已处理过,幂等返回
    }
    deductBalance(req.getUserId(), req.getAmount());
}

修复 4:防重表 / Token 方案

// === 场景:业务表本身不方便加唯一索引,用独立的"防重表" ===
// 单独建一张幂等记录表,专门记"哪些幂等号已经处理过"。

// CREATE TABLE idempotent_record (
//   idempotent_no VARCHAR(64) PRIMARY KEY,   // 幂等号做主键
//   status        VARCHAR(16),               // PROCESSING / DONE
//   result        TEXT,                      // 缓存第一次的处理结果
//   create_time   DATETIME
// );

public Result pay(PayRequest req) {
    String no = req.getIdempotentNo();
    try {
        // 先抢占:插入一条 PROCESSING 记录
        idempotentMapper.insert(no, "PROCESSING");
    } catch (DuplicateKeyException e) {
        // 插不进去 = 这个号已经有人处理过或正在处理
        IdempotentRecord r = idempotentMapper.get(no);
        if ("DONE".equals(r.getStatus())) {
            return parse(r.getResult());   // 已完成,直接返回上次结果
        }
        // 还在 PROCESSING:说明前一个请求还没跑完
        throw new BizException("请求处理中,请勿重复提交");
    }
    // 抢占成功,执行真正的业务
    Result result = doPay(req);
    idempotentMapper.updateDone(no, toJson(result));
    return result;
}

// === Token 方案(防"用户连续点击提交")===
// 1. 用户进入表单页时,服务端发一个一次性 token
// 2. 提交时带上 token,服务端校验并【原子地删除】token
// 3. 删除成功才放行;token 不存在 = 重复提交,拦掉
// 校验+删除用 Redis 的 Lua / DEL 返回值保证原子,
// 否则并发下两个请求会同时通过校验。

// === 防重记录要设过期/定期清理 ===
// 幂等表会一直涨,给老记录设 TTL 或定时归档,
// 过期时间要长于"业务可能重试的最大时间窗口"。

修复 5:Redis 做幂等与 MQ 消费幂等

// === 用 Redis 做轻量级幂等(高并发、能容忍极小概率失败)===
// 利用 SET NX:幂等号不存在才设成功,存在即重复。
public Result payWithRedis(PayRequest req) {
    String key = "idem:pay:" + req.getIdempotentNo();
    // SET key 1 NX EX 3600:抢占,成功才往下走
    Boolean first = redisTemplate.opsForValue()
        .setIfAbsent(key, "1", Duration.ofHours(1));
    if (!Boolean.TRUE.equals(first)) {
        throw new BizException("重复请求");   // 没抢到 = 重复
    }
    try {
        return doPay(req);
    } catch (Exception e) {
        // 业务失败要把 key 删掉,允许用户重试,
        // 否则用户这次失败了却再也提交不了
        redisTemplate.delete(key);
        throw e;
    }
}
// 注意:Redis 幂等是"性能优先"的方案,
// 极端情况下(Redis 宕机/key 过期)仍可能漏。
// 涉及资金的核心链路,要叠加修复 3 的数据库唯一索引兜底。

// === MQ 消费端幂等:消息一定会重复,消费者必须幂等 ===
// Kafka/RocketMQ 都是"至少一次"投递,重复消息是必然的。
@KafkaListener(topics = "order-paid")
public void onMessage(OrderPaidEvent event) {
    String msgKey = "mq:consumed:" + event.getOrderNo();
    Boolean first = redisTemplate.opsForValue()
        .setIfAbsent(msgKey, "1", Duration.ofDays(3));
    if (!Boolean.TRUE.equals(first)) {
        log.info("消息已消费过,跳过: {}", event.getOrderNo());
        return;                       // 重复消息,直接 ack 跳过
    }
    handleOrderPaid(event);
}
// 更稳的做法:消费端的业务操作本身就落一张带唯一索引的表,
// 让数据库兜底,Redis 只做快速过滤。

修复 6:幂等设计的统一规范与监控

// === 用注解 + AOP 把幂等做成统一能力,避免每个接口各写各的 ===
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
    long expireSeconds() default 3600;   // 幂等记录保留时长
}

@Aspect
@Component
public class IdempotentAspect {
    @Around("@annotation(idempotent)")
    public Object around(ProceedingJoinPoint pjp, Idempotent idempotent)
            throws Throwable {
        String no = resolveIdempotentNo(pjp);   // 从入参取幂等号
        if (no == null || no.isEmpty()) {
            throw new BizException("缺少幂等号");
        }
        String key = "idem:" + no;
        Boolean first = redisTemplate.opsForValue()
            .setIfAbsent(key, "1",
                Duration.ofSeconds(idempotent.expireSeconds()));
        if (!Boolean.TRUE.equals(first)) {
            throw new BizException("重复请求,请勿重复提交");
        }
        try {
            return pjp.proceed();
        } catch (Throwable t) {
            redisTemplate.delete(key);   // 失败放行重试
            throw t;
        }
    }
}
// 业务侧只需在写接口上加一个注解:
// @Idempotent
// public Result pay(PayRequest req) { ... }
# 幂等相关监控:重复请求是常态,但量级异常要告警
groups:
- name: idempotent
  rules:
  # 1. 重复请求拦截量突增(可能有异常重试风暴)
  - alert: DuplicateRequestSpike
    expr: rate(idempotent_duplicate_total[5m]) > 50
    for: 5m
    annotations:
      summary: "幂等拦截量突增,排查上游是否有异常重试风暴"

  # 2. 缺少幂等号的请求(上游没按规范传)
  - alert: MissingIdempotentNo
    expr: increase(idempotent_missing_no_total[10m]) > 0
    annotations:
      summary: "出现缺少幂等号的写请求,排查上游调用方"

  # 3. 同一业务号出现多条记录(幂等被击穿,严重)
  - alert: IdempotentBreached
    expr: increase(idempotent_breach_total[5m]) > 0
    annotations:
      summary: "检测到同一业务号多条记录,幂等被击穿,立即排查"

优化效果

指标                      治理前              治理后
=============================================================
写接口幂等                几乎没有             写接口全部接入幂等
幂等号                    无,服务端各自生成   发起方生成,重试不变
重复扣款                  月底对账才发现       0(实时拦截)
连点提交                  每点一次执行一次     Token/幂等号拦截
网络重试                  重复执行             识别为同一操作,幂等返回
MQ 重复消息               重复处理             消费端幂等跳过
核心链路兜底              无                   数据库唯一索引
幂等实现方式              各接口各写各的       注解 + AOP 统一
幂等可观测                无                   重复拦截/击穿监控

治理过程:
- 统计重复来源 + 梳理所有写接口:1 天
- 核心资金链路加数据库唯一索引:1 天
- 防重表 / Redis 幂等方案落地:1.5 天
- 注解 + AOP 统一幂等能力:1 天
- MQ 消费端幂等 + 监控接入:1.5 天

避坑清单

  1. 幂等是"执行一次和多次最终影响相同",不是"多次都成功"
  2. 重复请求是系统常态:网络会重试、MQ 会重投、用户会连点,不是异常
  3. 查询/删除指定 id/设绝对值天然幂等,新增/增减/发消息必须做幂等设计
  4. 防重不能靠前端,前端拦不住网络层重试和 MQ 重投,服务端必须自己兜
  5. 幂等号必须由发起方生成、全局唯一、重试时保持不变,服务端不能自己造
  6. 数据库唯一索引是最可靠的幂等兜底,靠引擎保证无并发窗口
  7. 插记录和扣款必须在同一事务,否则会出现记录与扣款不一致
  8. Redis SET NX 幂等性能好但极端情况会漏,核心资金链路要叠加唯一索引
  9. Redis 幂等遇业务失败要删 key 放行重试,否则用户失败后再也提交不了
  10. MQ 是至少一次投递,消费端必须幂等,最好用带唯一索引的表兜底

总结

这次幂等专项治理,最让我警醒的不是某一个技术细节,而是一个被我们长期忽视的事实:重复请求根本不是异常,它是分布式系统里再正常不过的常态。我们过去写接口时,内心深处一直有一个未经审视的假设——"用户发起一个操作,这个请求就只会到达我这里一次"。可现实把这个假设撕得粉碎:用户会因为页面卡顿而连点两下提交按钮;前端会在请求超时后自动重发,而它根本不知道服务端其实第一次已经成功了;网关和 RPC 框架默认就配着失败重试;消息队列为了不丢消息,采用的是"至少一次"投递,这意味着同一条消息天然就可能来好几遍。这些重试机制每一个单独看都是合理的、甚至是必要的,但它们叠加在一起,就让"同一个操作被发起多次"成了每天都在发生的事。而我们的接口对此毫无防备,来一个请求就老老实实扣一次款、插一条记录,于是用户付一次钱、系统记三笔账这种事就成了必然,只是它不会立刻爆发,而是悄悄累积,直到月底财务对账才暴露出来,那时已经是实打实的资损了。想通了这一层,治理的方向就清晰了:所有非幂等的写接口,都必须主动做幂等设计,这不是锦上添花,是底线。而幂等设计的整个大厦,都建立在一块基石之上——一个能够标识"这两个请求其实是同一个操作"的全局唯一业务号。这个号有一个极其关键、又极其容易做错的要求:它必须由请求的发起方生成,并且在这次操作的多次重试中始终保持不变。如果服务端自己生成这个号,那重试过来的两个请求会拿到两个不同的号,服务端就永远无法判断它们是不是重复——这是很多人第一次做幂等时会踩的坑。有了这个号之后,具体的实现手段有很多:可以用数据库唯一索引,这是最朴素也最可靠的一种,因为唯一约束由数据库引擎在最底层保证,哪怕两个重复请求完全并发地冲进来,数据库也只会放一个进去;可以用一张独立的防重表来记录"哪些号处理过",还能顺便把第一次的处理结果缓存下来,让重复请求直接拿到一致的返回;可以用 Redis 的 SET NX 做高性能的快速过滤;还可以用一次性 Token 来专门对付用户的连续点击。这些手段各有各的适用场景,但有一条原则我想特别强调:对于涉及资金这种绝对不容出错的核心链路,不要把宝全押在 Redis 这种性能优先、但极端情况下会漏的方案上,一定要在它背后再压一道数据库唯一索引作为最终兜底。Redis 负责把绝大多数重复请求高效地、轻量地挡掉,而数据库唯一索引负责那万分之一的漏网之鱼——这又是一次"快速过滤层 + 可靠兜底层"的双层防护思路。最后,我们没有让每个接口各自去实现幂等,而是用一个自定义注解加上 AOP 切面,把幂等抽象成了一个统一的、声明式的能力,业务开发只需要在写接口上加一个注解,剩下的抢占、拦截、失败放行全部由切面统一处理。这次治理之后我形成了一个习惯:每写一个写接口,我都会先停下来问自己一句——如果这个请求被发两遍、三遍,会发生什么?如果答案是"会出问题",那它就必须有幂等保护,没有例外。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

库存卖超了 7 件:一次 Redis 分布式锁踩坑的复盘

2026-5-20 13:36:18

技术教程

服务假死数据库却很闲:一次连接池耗尽排查的复盘

2026-5-20 13:41:27

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索