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

大家好,我是苏三,又跟大家见面了。

前言

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

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

他就卡住了。

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

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

最近想快速提升项目实战能力(包含多个AI项目),或者最近找工作,或者想学习AI的小伙伴,可以看看下面👇🏻的这个链接(或许真的能够帮到你)susan.net.cn/project

一、什么是幂等性?

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

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

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

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

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

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

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

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

三、常见幂等性方案

下面我们逐一剖析 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) {
        thrownew RuntimeException("请勿重复提交");
    }
    try {
        // 幂等检查 + 业务逻辑
        if (orderMapper.exists(orderNo)) {
            return;
        }
        orderMapper.insert(order);
    } finally {
        redisTemplate.delete(lockKey);
    }
}

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

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

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

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

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

一个典型的幂等处理流程

六、总结

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

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

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

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

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

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

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

最近想快速提升项目实战能力(包含多个AI项目),或者最近找工作,或者想学习AI的小伙伴,可以看看下面👇🏻的这个链接(或许真的能够帮到你)susan.net.cn/project

相关推荐
不知名的忻1 小时前
Dijkstra算法(朴素版&堆优化版)
java·数据结构·算法··dijkstra算法
精益数智小屋1 小时前
设备维护方案核心功能拆解:一套好的设备维护方案如何解决设备突发故障
大数据·运维·网络·数据库·人工智能·面试·自动化
phltxy1 小时前
Redis 常见数据类型之全局通用命令详解
数据库·redis·bootstrap
yaoxin5211231 小时前
402. Java 文件操作基础 - 读取二进制文件
java·开发语言·python
沐浴露z1 小时前
面试官:静态变量与非静态成员变量的区别?别再死记硬背了!
java·jvm
Java&Develop1 小时前
pgsql 根据一个查询sql 生成 修改sql
数据库·sql
极创信息1 小时前
信创软件快速适配信创改造,实战落地思路
java·大数据·数据库·人工智能·mvc·软件工程·hibernate
摇滚侠2 小时前
Java 项目教程《尚庭公寓》标签管理、自定义 converter 14 - 18
java·elasticsearch·架构
@小柯555m2 小时前
MySql(高级查询--查找GPA最高值)
数据库·sql·mysql