面试官最烦的答案:“我用 Redis + Token”!接口幂等 8 种方案 + 四层防御,把重复扣款工单干到 0

0 阅读24分钟

用户连点两下支付按钮,钱扣了两次;网络超时客户端自动重试,订单建了两笔——这类"重复扣款"工单,我以前每个月要处理十几单。后来落地了一套完整的幂等防御体系,这个数字直接归零。

这篇文章把接口幂等一次性讲透:从概念辨析到方案选型,从完整代码到踩坑指南。面试备战还是生产落地,看这一篇就够了。

一、别急着背方案,先把幂等是什么讲清楚

面试里 90% 的人上来就是"我用 Redis + Token",但面试官真正想听的第一个点是你对幂等的判定口径。

标准定义只有一句话:

同一个请求,执行一次和执行 N 次,对系统产生的副作用完全相同。 数学表达:f(f(x)) = f(x)。

这里有两个特别容易误解的地方。

幂等保证的是"副作用相同",不是"只被调用一次"。 接口可以被调 100 次,但结果只能生效一次。所以别总想着在网络层"拦住所有重复请求",那既做不到,也没必要。

幂等 ≠ 去重,更 ≠ 并发控制。 这三个概念混为一谈,追问一轮就露馅:

概念含义关注点典型手段
去重过滤掉重复到达的请求"不执行"过滤器、拦截
幂等执行多次结果也一致防重复副作用(不重复扣款、不重复下单)唯一索引、状态机、幂等表
并发控制同一时刻只允许一个执行防竞态(超卖、库存击穿)锁、CAS

再强调一句:分布式锁 ≠ 幂等。锁只能拦住"同时进来"的请求,拦不住"隔了 3 秒重试"的请求。锁是手段之一,幂等是目标,两者经常搭配出现,但不是一回事。

顺带补一个 RESTful 视角:按规范 GET、PUT、DELETE 都是天然幂等的,POST 不是。而实际业务里大部分写操作(创建订单、支付)恰恰都是 POST——所以幂等设计,本质上就是给"非幂等的 POST 业务"打补丁。

按接口类型看,侧重点也不一样:

接口类型幂等情况处理姿势
查询接口天然幂等无需处理,多次查询结果一致
删除接口容易做到幂等删除不存在的资源也返回成功(幂等删除)
创建接口重灾区唯一业务单号 + 唯一索引
更新接口重灾区状态机 / 乐观锁

概念清楚了,接下来看看重复请求到底从哪来——比你想的多得多。

二、重复请求从哪来?先搞清来源,再谈方案

实际开发中,重复请求的来源至少有六类:

来源典型场景特征
用户重复点击下单、支付、领券按钮连点前端可控,最低级
网络超时重试客户端、网关、Nginx 自动重试请求可能已到达服务端并执行成功
MQ 重复消费消费失败重投、分区 rebalance"至少一次"投递语义决定
RPC 框架重试Dubbo、Feign 的重试策略中间件层面重发
定时任务重复触发多节点同时跑批分布式调度竞争
页面回退浏览器回退后再次提交表单浏览器行为

image.png

只要系统里存在"网络重试"或"至少一次投递",就必须在业务层做幂等,别指望调用方只发一次。这是分布式系统的铁律。

我之前就踩过这个坑:以为"前端按钮置灰就没事了",结果用户网络差,浏览器自己超时重试,照样产生重复订单。前端防重只是体验优化,幂等必须做在服务端。

三、黄金公式与四层防御体系

3.1 幂等的黄金公式

所有幂等方案,剥掉外壳之后都是三件事:

幂等 = 唯一标识 + 去重存储 + 原子操作

image.png

  • 唯一标识:怎么判定"这是同一个请求"?业务单号,或客户端生成的 requestId;
  • 去重存储:把"已处理"记录下来,Redis、MySQL 或状态字段都行;
  • 原子操作:判断 + 写入必须在一个原子步骤里完成,否则并发下两个请求都会判断"没处理过",然后双双写入。

3.2 四层纵深防御:越靠前越快,越靠后越稳

生产上不会把宝押在单一方案上,而是分层布防:

image.png

  • 前端层拦掉 80% 的低级重复,但只解决体验问题;
  • 网关 / 框架层通用拦截,业务零侵入;
  • 业务层用状态机、CAS 做业务语义级兜底;
  • 数据层的唯一索引是最后一道防线,解决 100% 的正确性问题。

我的原则是:任何写接口,先问自己一句——这张表有没有能挡住重复的唯一约束? 如果没有,这个接口就是不安全的。

3.3 幂等键设计:这一步错了,全盘皆输

幂等的第一步是定义 key。幂等键必须是业务唯一标识——同一个业务意图,无论重试多少次,key 必须一模一样。

常见来源有三个:

Idempotency-Key: 7f1f6c2f-5b8a-4a7b-9d9f-9c5f2a5c0b1e
  • 前端生成:通过 Idempotency-Key 请求头统一传递;
  • 业务生成:订单号、支付流水号、业务单号(最可靠);
  • 网关生成:全局 requestId,比如从 X-Request-Id 请求头统一提取,全链路透传。

哪些键能用、哪些键千万别用:

推荐不推荐
bizType + requestIduserId + 时间戳(会漂移,重试时对不上)
订单号 + 操作类型每次现场生成的 UUID(重试一次换一个,根本去不了重)
外部支付流水号 externalTradeNoMQ 的 messageId(重投时可能变化)
String idemKey = "pay:callback:" + externalTradeNo;

键设计原则:业务域:操作:业务唯一标识(+参数指纹),比如 idem:pay:ORDER_20261008_001。key 太粗会误拦不同请求,太细又拦不住重复。

还有一点很多人忽略:对上游调用方要强制要求重试时携带同一个幂等键,最好由调用方生成,并贯穿整个调用链落到你的唯一索引上。如果调用方保证不了 key 一致,你这边做得再漂亮也形同虚设。

四、方案选型:一张对比表 + 一张决策图

主流方案一共 8 种,一张表讲清楚:

方案核心原理适用场景优点缺点 / 坑
数据库唯一索引唯一约束拦截重复插入插入型:下单、支付流水、消费兜底简单粗暴、强一致、最终兜底只防插入不防更新;冲突语义要区分
Token 机制预发令牌,提交时原子校验并删除前端表单、下单页业务执行前拦截,拦截率高需前端配合,多一次交互
状态机只允许合法状态流转,CAS 更新订单、审批、支付回调贴合业务、天然幂等、零额外组件仅适用于有状态流转的业务
乐观锁 versionWHERE version = ? 条件更新库存、余额等更新无锁阻塞、性能好仅适用更新类;失败要有重试策略
悲观锁SELECT ... FOR UPDATE 行锁强一致要求极高的低并发场景强一致性保证性能差、有死锁风险
分布式锁Redis / Redisson 抢占串行化多步复杂业务编排通用性强锁过期、误删风险;只防并发,不是幂等
幂等表专用去重表 + 唯一索引 + 状态 + 结果快照通用场景、资金类核心链路业务表无侵入、可回放结果多一张表,需定期清理
MQ 去重表消费端按消息业务唯一键去重MQ 消费简单有效必须与业务同事务

选型口诀可以直接背:

插入用唯一索引,更新用乐观锁,流转用状态机,通用用幂等表 + Redis 注解,前端加 Token / 置灰兜底。

拿到一个写接口,按这张决策图走:

image.png

不同场景的推荐组合也列一下:

场景推荐组合
简单插入(创建订单)唯一索引
前端表单提交Token + 按钮置灰
支付回调状态机 CAS + 唯一索引,并发高再加分布式锁
库存 / 余额更新乐观锁 + 条件更新
MQ 消费业务唯一键去重表(与业务同事务)
复杂分布式业务全局唯一 ID + 幂等表 + 全链路透传幂等键

举个真实项目里的组合拳例子——支付回调三重保险:

  1. 数据库对 out_trade_no 做唯一约束,兜底;
  2. 业务入口用 Redis 分布式锁以 out_trade_no 加锁,防并发回调;
  3. 更新订单状态时用状态机约束,确保只能从"待支付"变"已支付"。

三层叠在一起,万无一失。

觉得这张选型表有用的话,建议先收藏。顺手点个关注,后面我会持续更新这类一线面试场景题和落地方案,少走弯路。

五、核心代码实现(可直接抄)

下面按方案逐个给代码。如果你时间有限只看一节,务必看 5.7 的通用幂等组件——那是最能体现架构能力的一段。

5.1 数据库唯一索引:最硬的兜底

订单表建表时,订单号上加唯一索引:

CREATE TABLE `t_order` (
  `id`          BIGINT        NOT NULL AUTO_INCREMENT,
  `order_no`    VARCHAR(64)   NOT NULL COMMENT '业务订单号',
  `user_id`     BIGINT        NOT NULL,
  `amount`      DECIMAL(10,2) NOT NULL,
  `status`      TINYINT       NOT NULL DEFAULT 0 COMMENT '0待支付 1已支付 2已取消',
  `create_time` DATETIME      NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_order_no` (`order_no`)  -- 幂等的最后一道防线
) ENGINE=InnoDB;

业务代码直接插入,冲突了就当重复请求处理:

public Order createOrder(CreateOrderCmd cmd) {
    try {
        Order order = buildOrder(cmd);
        orderMapper.insert(order);          // 唯一索引兜底
        return order;
    } catch (DuplicateKeyException e) {
        // 不是报错,而是"这次是重复请求,直接返回已存在的订单"
        return orderMapper.selectByOrderNo(cmd.getOrderNo());
    }
}

面试官最爱追问:为什么不先 select 再 insert?因为 check-then-act 在并发下不安全——两个线程可能都查到"不存在",然后双双写入。正确姿势叫乐观插入:直接插,冲突了再查。唯一索引把"判断"下沉到了 DB 的原子操作里,这才是关键。

还有一个细节:唯一键冲突 ≠ 一定是幂等场景。DuplicateKeyException 要区分是"重复请求"还是"真实业务冲突"(比如两个人抢同一个单号),需要结合业务语义判断,不能无脑吞异常返回旧数据,否则会掩盖真正的数据问题。

5.2 Token 防重提交:前后端配合的经典方案

流程是这样的:

image.png

先看不加思索的写法错在哪:

// 错误写法:两步不是原子的,并发下两个请求都会通过校验
if (redisTemplate.hasKey(tokenKey)) {
    redisTemplate.delete(tokenKey);
    // 执行业务...
}

两个请求同时 hasKey 都返回 true,然后各自删除、各自执行业务——防重完全失效。正确做法是用 Lua 脚本把"校验 + 删除"合二为一:

@Service
public class TokenService {

    // Lua:校验值一致才删除,Redis 单线程执行,天然原子
    private static final DefaultRedisScript<Long> CHECK_AND_DEL = new DefaultRedisScript<>(
            "if redis.call('GET', KEYS[1]) == ARGV[1] then " +
            "return redis.call('DEL', KEYS[1]) else return 0 end", Long.class);

    @Resource
    private StringRedisTemplate redisTemplate;

    /** 申请 Token:SET 带过期时间一条命令完成,避免先 SET 后 EXPIRE 的非原子问题 */
    public String createToken() {
        String token = UUID.randomUUID().toString();
        redisTemplate.opsForValue().set("idem:token:" + token, token, 5, TimeUnit.MINUTES);
        return token;
    }

    /** 校验并删除 Token,返回 true 才放行业务 */
    public boolean checkToken(String token) {
        Long r = redisTemplate.execute(CHECK_AND_DEL, List.of("idem:token:" + token), token);
        return r != null && r > 0;
    }
}

技术亮点:GET + DEL 用 Lua 脚本合成一次原子操作,彻底杜绝"两个并发请求都校验通过"的经典坑。同理,发 Token 时也必须 SET 带过期时间一条命令搞定,如果先 SET 再 EXPIRE,中间宕机就会留下一个永不过期的僵尸 Token。

Token 为什么放 Redis 而不是数据库?高频校验、自动 TTL、性能好;真正的一致性由 DB 唯一索引兜底。

5.3 状态机 CAS:订单、支付回调的灵魂

有状态流转的业务,状态机是最优雅的幂等方案。订单状态只能单向流转:

image.png

规则很简单:只允许"未走过"的边再走一次,重复走同一条边直接忽略。

核心 SQL 是把"判断状态"和"更新状态"合并成一条原子语句:

UPDATE t_order
   SET status = 'PAID', update_time = NOW()
 WHERE order_no = #{orderNo}
   AND status   = 'WAIT_PAY';   -- 带期望状态的 CAS

支付回调是幂等的重灾区——第三方可能重复推 N 次。完整写法:

@Transactional(rollbackFor = Exception.class)
public void payCallback(PayNotify notify) {
    // 1. 唯一索引兜底:pay_no 唯一,重复回调直接键冲突
    payRecordMapper.insertIgnore(PayRecord.of(notify));

    // 2. CAS 更新,影响行数 = 0 说明已处理或状态非法
    int rows = orderMapper.casToPaid(notify.getOrderNo(), notify.getPayNo());
    if (rows == 0) {
        log.warn("订单已支付或状态异常,忽略回调: {}", notify.getOrderNo());
        return;
    }

    // 3. 后续派生动作(发券 / 积分 / 通知)走 MQ 可靠投递
    eventPublisher.publish(new OrderPaidEvent(notify.getOrderNo()));
}

为什么不先 SELECT 判断再 UPDATE? 因为两步之间有时间窗:两个回调线程同时看到 WAIT_PAY,就会各执行一次更新,导致积分、库存被扣两次。CAS 把判断下沉到 DB 行锁,天然原子。支付平台就算回调 10 次,也只处理一次。

5.4 乐观锁:库存、余额的更新姿势

更新类操作用版本号或余量条件做原子更新:

// 带 version 防 ABA,带 num >= delta 防超卖
@Update("UPDATE t_stock SET num = num - #{delta}, version = version + 1 " +
        "WHERE sku_id = #{skuId} AND num >= #{delta} AND version = #{version}")
int deductStock(@Param("skuId") Long skuId, @Param("delta") int delta,
                @Param("version") int version);

返回影响行数 0,说明要么库存不足、要么并发被抢,交由上层重试或返回"库存不足",绝不超卖。

有个进阶细节:rows == 0 其实有两种含义,要区分开——

if (rows == 0) {
    Order latest = orderMapper.selectByOrderId(orderId);
    if ("PAID".equals(latest.getStatus())) {
        return;                          // 已支付过:幂等,直接返回成功
    }
    throw new BizException("并发冲突,请重试");  // 版本冲突:可重试
}

WHERE 条件里 status 做业务幂等、version 做并发控制,一次数据库交互解决两个问题。

5.5 分布式锁:保护复杂业务块

涉及多步查询、计算、多表写入的复杂操作,唯一索引罩不住时,用分布式锁把整个业务块串行化:

RLock lock = redissonClient.getLock("lock:pay:" + orderNo);
try {
    if (!lock.tryLock(0, 10, TimeUnit.SECONDS)) {
        return Result.error("请求处理中,请稍后再试");
    }
    // 执行业务逻辑
} finally {
    if (lock.isHeldByCurrentThread()) {
        lock.unlock();
    }
}

用 Redisson 有两个好处:看门狗机制自动续期(锁获取成功后每隔 1/3 过期时间续一次,业务没跑完锁不会失效);释放时校验持有者身份,避免误删别人的锁。

但请时刻记住:锁只解决"同时",幂等解决"重复"。锁释放之后,隔几秒来的重试请求照样会重复执行,所以锁必须和唯一索引、状态机搭配使用,它自己扛不起幂等。

5.6 幂等表:结果兜底,让重复请求拿到第一次的结果

资金、支付、营销发奖这类核心链路,建议加一张幂等记录表,把状态和结果都存下来:

CREATE TABLE t_idempotent (
    id             BIGINT AUTO_INCREMENT PRIMARY KEY,
    biz_type       VARCHAR(64)  NOT NULL COMMENT '业务类型',
    idempotent_key VARCHAR(128) NOT NULL COMMENT '幂等键',
    status         VARCHAR(16)  NOT NULL COMMENT 'PROCESSING / SUCCESS / FAIL',
    response       TEXT COMMENT '业务结果快照',
    create_time    DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    update_time    DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    UNIQUE KEY uk_biz_key (biz_type, idempotent_key),
    KEY idx_create_time (create_time)   -- 用于定时清理
) COMMENT '幂等记录表';

处理流程:

image.png

public Object executeWithIdempotent(String bizType, String key, Supplier<Object> biz) {
    IdempotentDO record = new IdempotentDO(bizType, key, "PROCESSING");
    try {
        idempotentMapper.insert(record);   // 原子占位,唯一索引保证
    } catch (DuplicateKeyException e) {
        IdempotentDO old = idempotentMapper.selectByKey(bizType, key);
        if (old.isSuccess()) {
            return JSON.parse(old.getResponse());   // 回放历史结果
        }
        if (old.isProcessing()) {
            throw new BizException("请求正在处理中,请勿重复提交");
        }
        throw new BizException("上次请求失败,请稍后重试");
    }

    try {
        Object result = biz.get();
        idempotentMapper.markSuccess(bizType, key, JSON.toJSONString(result));
        return result;
    } catch (Throwable e) {
        idempotentMapper.markFail(bizType, key);
        throw e;
    }
}

技术亮点:Redis 负责快速拦截,幂等表负责结果留存。真正的幂等不只是"不重复执行",而是重复请求也返回第一次的结果——第一次下单成功了,第二次重复请求不应该报错,而应该原样返回那笔订单。这才是 f(f(x)) = f(x) 的完整语义:连返回值都要一致。

5.7 最大亮点:@Idempotent 注解 + AOP 通用组件

把"唯一标识 + Redis 去重 + Lua 原子 + 结果回放"封装成一个注解,业务方一行代码接入——这是简历上最值得写的一笔。

注解定义:

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
    /** 幂等键,支持 SpEL,如 "#req.orderNo" */
    String key() default "";
    /** 业务前缀 */
    String prefix() default "idem";
    /** 去重窗口(秒) */
    int expire() default 30;
    /** 重复请求提示语 */
    String message() default "请勿重复提交";
    /** 重复请求是否回放首次结果 */
    boolean replay() default true;
}

切面实现,注意 Lua 脚本的三态返回值:

@Aspect
@Component
@RequiredArgsConstructor
public class IdempotentAspect {

    private final StringRedisTemplate redis;

    /**
     * KEYS[1]=幂等key  ARGV[1]=参数指纹  ARGV[2]=过期时间(秒)
     * 返回: 1=首次请求放行 | 2=重复请求 | 0=同key不同参数
     */
    private static final DefaultRedisScript<Long> SCRIPT = new DefaultRedisScript<>(
            "if redis.call('SET', KEYS[1], ARGV[1], 'NX', 'EX', ARGV[2]) then return 1 " +
            "elseif redis.call('GET', KEYS[1]) == ARGV[1] then return 2 " +
            "else return 0 end", Long.class);

    @Around("@annotation(idem)")
    public Object around(ProceedingJoinPoint pjp, Idempotent idem) throws Throwable {
        // 1. SpEL 解析业务幂等键,拼上用户 + 方法维度,防止不同用户互相影响
        String bizKey = SpELUtil.parse(idem.key(), pjp);
        String redisKey = idem.prefix() + ":" + userId() + ":"
                + pjp.getSignature().toShortString() + ":" + bizKey;

        // 2. 参数指纹:同一个订单号换了金额,就是不同请求
        String fingerprint = DigestUtils.md5DigestAsHex(JSON.toJSONBytes(pjp.getArgs()));

        Long r = redis.execute(SCRIPT, List.of(redisKey),
                fingerprint, String.valueOf(idem.expire()));

        if (r == null || r == 0) {
            throw new BizException("相同幂等键的参数不一致,请检查请求");
        }
        if (r == 2) {
            // 重复请求:回放首次结果,而不是简单报错
            return idem.replay() ? replayResult(redisKey) : Result.fail(idem.message());
        }

        try {
            Object result = pjp.proceed();
            cacheResult(redisKey, result, idem.expire());   // 结果快照,供回放
            return result;
        } catch (Throwable e) {
            // 3. 只有"明确失败且可重试"的异常才释放占位
            if (isRetryableException(e)) {
                redis.delete(redisKey);
            }
            throw e;
        }
    }

    private boolean isRetryableException(Throwable e) {
        // 参数校验、普通业务异常可重试;网络超时、结果未知类异常不能轻易释放
        return e instanceof BizException;
    }
}

业务方使用,一行注解搞定:

@Idempotent(prefix = "order:create", key = "#req.orderNo", expire = 600,
            message = "订单正在处理中,请稍候")
public Order createOrder(CreateOrderRequest req) {
    return orderService.create(req);
}

这段代码有四个值得拿出来讲的亮点:

  1. SET key value NX EX 一条命令完成占位,杜绝了 SETNX 之后再 EXPIRE 之间宕机导致的死锁;
  2. 参数指纹防"伪幂等":同一个订单号换了金额再提交,直接判异常,而不是被当成重复请求静默放过;
  3. 结果快照回放:重复请求返回首次的成功结果,这才是幂等的正确语义;
  4. 业务失败删除占位 key:让真正失败的请求能被用户重试,而不是被永久拦截——这是很多人的盲区。

5.8 MQ 消费幂等:至少一次投递的救命稻草

MQ 的"至少一次"投递语义决定了消费端必须自己做幂等。去重表 + 同事务是最稳的写法:

CREATE TABLE mq_consume_log (
  msg_id     VARCHAR(64) NOT NULL COMMENT '消息业务唯一键',
  biz_key    VARCHAR(64) NOT NULL COMMENT '业务主键',
  gmt_create DATETIME    NOT NULL,
  PRIMARY KEY (msg_id)
) ENGINE=InnoDB;
@Transactional(rollbackFor = Exception.class)
public void onMessage(Message msg) {
    try {
        consumeLogMapper.insert(msg.getBizKey());   // 与业务同库同事务
    } catch (DuplicateKeyException e) {
        log.warn("重复消息,已消费过: {}", msg.getBizKey());
        return;   // 直接 ACK,实现幂等
    }
    doBiz(msg);
}

关键点:去重记录必须和业务操作放在同一个本地事务里。 否则插入成功、业务失败回滚,消息再投一次就被误判为"已消费",直接丢数据。

如果用 RabbitMQ 手动 ACK,模板是这样:

@RabbitListener(queues = "order.pay.queue")
public void onMessage(PayMessage msg, Channel channel,
                      @Header(AmqpHeaders.DELIVERY_TAG) long tag) throws IOException {
    // 幂等键 = 业务唯一 ID,不要依赖 MQ 的 messageId(重投时可能变化)
    String bizNo = msg.getOrderNo() + ":" + msg.getPayType();
    if (!tryInsertDedup(bizNo)) {
        channel.basicAck(tag, false);      // 重复消息,直接 ACK
        return;
    }
    try {
        payService.handlePaySuccess(msg);
        channel.basicAck(tag, false);
    } catch (Exception e) {
        channel.basicNack(tag, false, !isFatal(e));   // 只有可重试的才重回队列
    }
}

代码可以直接抄,但踩过的坑更值钱——下一章是全文最硬核的部分。

六、技术难点与踩坑指南(面试官最爱追问区)

幂等的坑基本都在细节里。我按类别整理成四张表,再单独拆解几个高频难点。

6.1 并发与原子性类

难点现象解决方案
check-then-act 竞态两个请求都查到"不存在",双双写入把判断下沉到原子操作:唯一索引 / SET NX / 条件更新
Token 先查后删非原子两个请求都 GET 成功,都放行Lua 脚本合并 GET + DEL
SETNX 后再 EXPIRE 非原子中间宕机,锁永不过期SET key val NX EX 一条命令,或 Lua
锁误删A 删了 B 的锁value 存 UUID,删除时用 Lua 校验值
多实例本地去重失效JVM 内存、本地缓存多实例不共享统一用 Redis / DB 等全局存储

6.2 过期与释放类

难点现象解决方案
锁过期但业务没跑完占位失效,重复请求进入合理评估 TTL;Redisson 看门狗自动续期
业务失败后占位不释放用户重试被永远拦截异常时删除 key,或幂等表标记 FAIL
释放不是无条件的超时结果未知,释放后可能重复执行只有"明确失败且可重试"才释放;支付、扣款类结果未知时不释放,靠 CAS + 唯一索引保证重试安全
先删占位还是后删先删:失败无法重试;后删:并发重复进入业务执行前占位,失败时补偿删除,最终一致性靠唯一索引兜底
幂等键过期窗口外的重复请求穿透到 DB有效期 ≥ 最大重试窗口(网关重试 + MQ 重投总时长);DB 唯一索引兜底

6.3 存储与性能类

难点现象解决方案
Redis 宕机拦截全部失效DB 唯一索引兜底;Sentinel / 集群保高可用
幂等表膨胀历史数据无限增长设 TTL + 定时归档(如保留 7~30 天)+ 冷热分离、分表
唯一索引热点高并发大量冲突,DB CPU 飙升前置 Redis 校验;INSERT ... ON DUPLICATE KEY UPDATE
分库分表后唯一约束失效唯一性只在单库单表内成立幂等键必须包含分片键;全局发号器;入口按分片键路由到固定库表
极致高并发性能唯一索引成为写入瓶颈唯一键尾号分片打散热点;布隆过滤器前置过滤;非金融业务可降级为 Redis 判重(最终一致)

6.4 链路与语义类

难点现象解决方案
重复请求返回值不一致第一次成功但响应超时,重试却收到"重复提交"报错结果快照:缓存首次结果,重复请求直接回放
MQ 至少一次投递消息被重复消费业务唯一键去重表 + 与业务同事务;状态机兜底
上下游幂等键不一致调用方重试换了个 key,去重形同虚设接口协议强制 Idempotency-Key;网关无键直接拒绝;全链路透传同一事务 ID
跨系统 / 跨库幂等本地事务管不住外部系统本地消息表 / 事务消息 + 下游幂等,走最终一致
批量操作部分成功批量请求无法整体幂等拆解为单条记录粒度去重,记录每条处理状态,支持部分失败重试
唯一键冲突 ≠ 一定幂等两人抢同一单号是业务冲突,不是重复请求结合业务语义判断,别无脑吞异常
非幂等 SQLnum = num - 1 重复执行结果不同改为绝对值写入 num = #{newValue};或流水表 + 唯一索引(account_id + biz_no)
异步处理中用户又重试请求已返回但处理慢,用户再点一次先落库返回"处理中",前端轮询 / 回调拿最终状态

6.5 四个高频难点拆解

难点一:重复请求要返回相同结果(真幂等)

这是高频追问。场景还原:前端提交下单 → 后端处理成功写库 → 网络超时前端没收到响应 → 用户点重试 → 后端返回"重复请求" → 前端显示"下单失败",但订单其实已经建好了。

解法是把第一次的结果缓存下来,重试时原样返回:

public Result createOrder(String requestId, OrderDTO dto) {
    // 1. 先查是否已有结果
    String cacheKey = "result:" + requestId;
    String cached = redisTemplate.opsForValue().get(cacheKey);
    if (cached != null) {
        return JSON.parseObject(cached, Result.class);   // 直接返回相同结果
    }

    // 2. 幂等占位、执行业务
    Order order = orderService.create(dto);
    Result result = Result.success(order);

    // 3. 缓存结果,过期时间覆盖最大重试窗口
    redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(result), 1, TimeUnit.HOURS);
    return result;
}

难点二:失败释放占位,要小心事务边界

"业务异常就删 key"听起来没问题,但如果切面在事务外层,可能出现:事务已提交、部分数据已落库,这时异常删了 key,重试就会在已有数据上再执行一遍。两个对策:一是用 TransactionSynchronization 在事务提交后再决定释放与否;二是接受"释放可能带来重试",由唯一索引和状态机保证重试安全——这也是为什么我反复强调 DB 兜底不能省。

难点三:全局唯一 ID 的时钟回拨

幂等键经常依赖雪花算法,而雪花算法依赖机器时钟。时钟回拨的三种处理思路:小幅度回拨直接抛异常等时钟追上;用历史最大时间戳继续发号;干脆换美团 Leaf 的号段模式,从数据库取号段,完全不依赖时间。

难点四:非幂等 SQL 的改造

UPDATE t_account SET balance = balance + 10 这类累加语句天然不幂等,执行两次就是加两次。两条改造路线:写成绝对值 SET balance = #{newValue};或者引入流水表,每笔变更插一条记录并对 account_id + biz_no 建唯一索引,累加变成"插流水 + 汇总",天然可去重。

七、面试追问预案

把最常被追问的 8 个问题整理成表,答案背熟:

追问回答要点
幂等键过期了怎么办?过期时间 ≥ 最大重试窗口;核心业务不依赖 Redis,唯一索引做最终兜底
Redis 挂了幂等就失效?Redis 是"性能层",唯一索引是"正确性层",两层职责分开
MQ 消费幂等怎么做?业务唯一 ID 做幂等键 + 去重表同事务;或用状态机判断是否已处理
高并发下 Redis 会成为瓶颈吗?SET NX 单命令 + 分片;核心链路仍由 DB 唯一约束兜底
Token 为什么放 Redis 不放数据库?高频校验、自动 TTL、性能高;一致性由 DB 唯一索引兜底
先查后插做幂等行不行?不行,check-then-act 有竞态;正确做法是乐观插入,冲突了再查
雪花算法时钟回拨怎么办?抛异常等时钟追上 / 用历史最大时间戳 / 换 Leaf 号段模式
怎么衡量幂等方案的效果?上监控:重复请求拦截率、重复扣款事故数(必须为 0)、去重表命中率

八、总结:一锁、二唯一、三状态、四结果

全文浓缩成一句话:

幂等不是某个组件的事,而是"前端防呆 → 网关拦截 → 业务 CAS → 数据库唯一约束"的四层纵深防御。前三层解决 99% 的性能和体验问题,最后一层唯一索引解决 100% 的正确性问题。

落地四件套:入口防重用 Redis Token / SETNX,存储兜底用唯一索引,业务兜底用状态机 CAS,结果兜底用幂等表存快照。没有银弹,按业务选型组合使用。

最后送一段 30 秒速记口诀:

表单重复点,token 先申请
接口要通用,指纹 + SETNX
兜底靠唯一,冲突即成功
状态用 CAS,别查了再更
消息必去重,同事务落地
锁只防并发,幂等另算账

给你的落地建议,按优先级排:

  1. 盘点存量写接口:所有创建类数据的表,先检查有没有唯一约束,没有的立刻补上;
  2. 核心链路上幂等表:资金、支付、营销发奖,结果快照 + 三状态一个都不能少;
  3. 封装团队统一的 @Idempotent 组件:统一标准,别让每个人各写一套;
  4. 加监控:拦截率、事故工单数、去重表命中率——幂等不是上线就完事,要能被度量。

最后留个互动问题:你们公司现在主要用哪种幂等方案?或者你在重复提交上踩过什么印象深刻的坑? 评论区聊聊,我看到都会回。觉得这篇有用的话,点赞、在看、转发走一波,你的支持是我持续更新的最大动力。