美团二面:高并发下如何保证接口幂等性?

0 阅读7分钟

前言

最近有位小伙伴去美团面试,被问到一个高频题:“高并发场景下,如何保证接口的幂等性?

他答出了“唯一索引”和“token机制”,但被追问“如果重复请求并发执行,两个请求同时发现token有效怎么办?”

他就卡住了。

事实上,幂等性设计是高并发系统中最容易被忽视、出事后果却最严重的环节之一。

今天就专门跟大家一起聊聊接口幂等性的话题,希望对你会有所帮助。

更多项目实战在我的技术网站:susan.net.cn/project

一、什么是幂等性?

幂等(Idempotent):同一个接口,无论调用一次还是多次,对系统产生的副作用都是一样的。

举个反例:支付接口。如果用户点了一次“确认支付”按钮,前端因为网络超时重试了三次,结果银行扣了三次钱,这就是典型的非幂等。

幂等性要解决的核心问题:在网络抖动、用户误操作、消息队列重复消费、RPC重试等场景下,防止数据重复处理,保证业务数据最终一致。

二、高并发下幂等性的难点

在低并发场景,用唯一索引或悲观锁就能解决。

但高并发下,两个请求可能同时“查无记录”,然后同时插入重复数据,形成“并发穿透”。

image.png

这就是高并发下幂等性设计的最大挑战:多个相同请求同时到达,基于“查询-插入”的判断会失效

所以,我们不能靠“防君子不防小人”的检查,而要在架构层面建立可靠的防重机制。

三、常见幂等性方案

下面我们逐一剖析 6 种主流方案,每种都给出代码示例、优缺点和适用场景。

方案原理优点缺点适用场景
1. 唯一索引数据库唯一约束简单可靠性能较低,不适用于分表单库单表,对冲突容忍度低
2. Token 令牌请求前获取token,执行时删除token无锁,适合分布式需额外一次调用,需处理token失效表单提交、敏感操作
3. 乐观锁版本号更新无锁,高并发支持好只能防重复更新,不能防插入更新类接口(如状态变更)
4. 防重表专用去重表+唯一索引业务表无侵入增加一次DB写操作任意幂等场景,可灵活控制
5. 状态机根据业务状态流转天然幂等,语义清晰局限性大,只适合有状态流转的业务订单、工单生命周期
6. Redis 分布式锁请求id加锁性能高,分布式友好需考虑锁超时、释放问题高并发、对DB压力敏感

下面对每种方案做详细拆解。

四、方案详解

方案一:唯一索引(数据库层防重)

实现:在业务表上的幂等字段(如订单号、流水号)建立唯一索引。

重复插入时数据库会抛异常,应用层捕获后返回“请勿重复操作”。

ALTER TABLE order ADD UNIQUE INDEX uk_order_no (order_no);
try {
    orderMapper.insert(order);
} catch (DuplicateKeyException e) {
    return "订单号已存在,请勿重复提交";
}

优点:绝对可靠,实现简单。
缺点:每次插入都要写索引,高并发下性能下降;分库分表后唯一索引难以维护。
适用场景:单库单表、并发量不高、数据强一致要求场景。

方案二:Token机制(前端携带令牌)

流程:用户点提交前,先调用获取token接口 → 服务端生成token存入Redis → 提交请求时携带token → 服务端删除token并执行业务。

若第二次请求带相同token,删除失败(已不存在),直接拒绝。

转存失败,建议直接上传图片文件

@PostMapping("/pay")
public Result pay(@RequestParam String orderNo, @RequestParam String token) {
    Boolean deleted = redisTemplate.delete("pay_token:" + token);
    if (!deleted) {
        return Result.error("请勿重复提交");
    }
    // 执行支付逻辑(幂等)
    orderService.pay(orderNo);
    return Result.success();
}

优点:无锁,适合分布式,性能好。
缺点:需要额外一次获取token的调用,可能增加网络开销。
适用场景:表单提交、重要操作(支付、下单)。

方案三:乐观锁(基于版本号)

适用:更新类操作,而不是插入。例如更新订单状态“从待支付→已支付”。

UPDATE order SET status = 'PAID', version = version + 1 
WHERE order_no = #{orderNo} AND version = #{oldVersion};
int rows = orderMapper.updateByVersion(orderNo, oldVersion);
if (rows == 0) {
    throw new BusinessException("订单已被更新,请刷新重试");
}

优点:高并发下无锁等待,性能好。
缺点:只适用于更新场景,不能用于新增。
适用场景:订单状态流转、库存扣减。

方案四:防重表(独立去重记录)

原理:在业务操作前,先插入一条记录到“去重表”(唯一索引),插入成功才执行业务;业务失败时删除记录。

利用数据库唯一索引保证同一请求id只能成功一次。

CREATE TABLE idempotent_record (
    id BIGINT AUTO_INCREMENT,
    request_id VARCHAR(64) NOT NULL,
    biz_type VARCHAR(32),
    PRIMARY KEY(id),
    UNIQUE KEY uk_request_id (request_id)
);
@Transactional
public void createOrder(Order order, String requestId) {
    // 1. 插入防重记录
    try {
        idempotentMapper.insert(requestId, "order_create");
    } catch (DuplicateKeyException e) {
        throw new RuntimeException("重复请求");
    }
    // 2. 核心业务
    orderMapper.insert(order);
}

优点:业务表无侵入,可以灵活控制有效期,适合跨系统防重。
缺点:增加一次DB写操作。
适用场景:MQ消费端、RPC接口幂等。

方案五:状态机驱动

原理:业务状态有严格流转路径(如 待支付→已支付→已发货)。

当一个状态已经进入“已支付”,再接收“待支付→已支付”的请求时,状态不满足,直接返回成功(已经是目标状态)。

public void pay(String orderNo) {
    Order order = orderMapper.selectByNo(orderNo);
    if (order.getStatus() == OrderStatus.PAID) {
        return; // 已经是支付状态,直接返回成功
    }
    if (order.getStatus() != OrderStatus.INIT) {
        throw new BizException("状态异常");
    }
    // 执行支付逻辑
}

优点:业务语义清晰,天然幂等。
缺点:有限状态机设计复杂,不适合无状态业务。
适用场景:订单、工单、审批等有明确状态流转的场景。

方案六:Redis 分布式锁

原理:以业务唯一标识(如订单号、请求id)为锁key,加锁成功后执行业务,释放锁。

相同请求并发时,只有一个能获取锁。

public void createOrderWithLock(String orderNo) {
    String lockKey = "lock:order:" + orderNo;
    Boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
    if (!locked) {
        throw new RuntimeException("请勿重复提交");
    }
    try {
        // 幂等检查 + 业务逻辑
        if (orderMapper.exists(orderNo)) {
            return;
        }
        orderMapper.insert(order);
    } finally {
        redisTemplate.delete(lockKey);
    }
}

注意:锁超时时间要大于业务执行时间,否则可能出现“锁提前释放,业务还未完成”的并发问题。可结合 Redisson 看门狗自动续期。

优点:性能高、分布式友好。
缺点:依赖Redis高可用;锁超时需要谨慎设计。
适用场景:高并发、可容忍极短时间锁等待。

五、实际项目中的组合策略

在实际系统中,往往根据业务重要性、并发量、接口类型选择组合方案。

  • 支付接口:Token + 数据库唯一索引(双保险)。
  • 更新订单状态:乐观锁(version) + 状态机。
  • MQ 消费端:防重表(基于消息id)。
  • 通用写接口:Redis 分布式锁 + 防重表。

一个典型的幂等处理流程

image.png

更多项目实战在我的技术网站:susan.net.cn/project

六、总结

接口幂等性是高并发系统中数据准确性的生命线。

没有万能的“银弹”,只有适合业务场景的合适方案:

  • 简单低并发:唯一索引足以。
  • 表单类提交:Token 方案最直观。
  • 更新操作:乐观锁或状态机。
  • 分布式高并发:Redis 分布式锁 + 防重表。

记住一句话:幂等性的核心不是“防止多个请求同时到达”,而是“无论来多少次,最终结果只生效一次”。

在设计时,务必从数据存储层(唯一约束、状态机)和应用层(锁、token)双管齐下,才能在高并发下真正做到“万无一失”。

希望这篇文章能帮你彻底拿下幂等性面试题。

你在实际项目中还用过哪些有趣的幂等方案?欢迎评论区分享!