从一次重复扣款说起:如何设计真正可靠的幂等接口

举报
yd_232225224 发表于 2026/09/10 13:39:02 2026/09/10
【摘要】 在后端开发中,我们经常会遇到一种看似偶然、实际上无法避免的问题:同一个请求被执行了两次。用户连续点击两次“提交订单”,浏览器因网络超时自动重试,消息队列重复投递消息,网关重新转发请求……这些情况都有可能让服务器收到内容完全相同的请求。如果接口只是查询数据,重复执行通常没有影响;但如果接口涉及创建订单、扣减库存、发放优惠券或资金支付,重复执行就可能带来严重后果。解决这类问题的核心,就是让接口具...

在后端开发中,我们经常会遇到一种看似偶然、实际上无法避免的问题:同一个请求被执行了两次。

用户连续点击两次“提交订单”,浏览器因网络超时自动重试,消息队列重复投递消息,网关重新转发请求……这些情况都有可能让服务器收到内容完全相同的请求。

如果接口只是查询数据,重复执行通常没有影响;但如果接口涉及创建订单、扣减库存、发放优惠券或资金支付,重复执行就可能带来严重后果。

解决这类问题的核心,就是让接口具备幂等性。

一、什么是幂等性

幂等性是指:对同一个操作执行一次或执行多次,产生的最终结果相同。

例如,把用户状态设置为“已禁用”通常是幂等操作:

PUT /users/1001/status

{
  "status": "disabled"
}

无论调用一次还是十次,用户最终都是禁用状态。

但“给用户余额增加100元”通常不是幂等操作:

POST /users/1001/balance/increase

{
  "amount": 100
}

调用一次增加100元,调用两次就增加200元。因此,服务器必须判断后续请求究竟是新的业务操作,还是之前请求的重复提交。

需要注意的是,幂等并不意味着服务器只能收到一次请求,而是意味着即使收到多次请求,业务操作也只生效一次。

二、重复请求为什么无法完全避免

很多人会在前端给按钮增加防抖,或者在用户点击后将按钮禁用。这些措施确实能够减少重复请求,却不能从根本上保证幂等性。

因为重复请求可能来自多个环节:

  1. 用户连续点击按钮;
  2. 浏览器或客户端自动重试;
  3. 网关超时后重新转发;
  4. 服务之间的RPC调用触发重试;
  5. 消息队列发生重复投递;
  6. 服务处理成功,但返回结果前网络中断;
  7. 客户端没有收到响应,无法确认操作是否成功,于是再次提交。

其中最典型的是“结果未知”问题。

假设用户发起付款请求,服务器已经完成扣款,但在响应返回之前网络断开。此时客户端看到的是请求超时,却无法知道扣款是否已经发生。如果客户端直接重试,而服务端没有幂等机制,用户就可能被重复扣款。

因此,幂等性必须由真正执行业务操作的一方保证,不能只依赖前端或调用方。

三、使用幂等键识别同一个业务请求

一种常见做法是让客户端为每次业务操作生成唯一的幂等键,并在请求头中发送:

POST /api/orders
Idempotency-Key: 7db5418e-8114-4d0d-84d5-89cf52aee781
Content-Type: application/json

{
  "productId": 10086,
  "quantity": 1
}

这里的关键不是UUID本身,而是它所代表的业务含义:

同一个幂等键出现多次,表示这些请求属于同一次业务操作。

客户端第一次提交失败后,可以使用原来的幂等键重试。服务器发现该幂等键已经处理过,就不再重复创建订单,而是返回第一次请求的处理结果。

如果用户稍后主动购买同一件商品,则应生成一个新的幂等键,因为这是一次新的业务操作。

四、为什么“先查询再插入”并不可靠

最容易想到的实现方式通常是:

if (!requestRepository.existsByIdempotencyKey(key)) {
    createOrder();
    requestRepository.save(key);
}

这段代码在单线程环境下似乎没有问题,但在并发场景下存在竞争条件。

假设两个相同请求几乎同时到达:

请求A:查询幂等键,不存在
请求B:查询幂等键,不存在
请求A:创建订单
请求B:创建订单

两个请求都通过了检查,最终仍然创建了两笔订单。

问题在于“查询”和“写入”是两个独立操作,无法保证原子性。仅靠应用程序中的判断,通常挡不住真正的并发请求。

更可靠的做法是为幂等键建立数据库唯一约束:

CREATE TABLE idempotency_record (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    idempotency_key VARCHAR(64) NOT NULL,
    request_hash VARCHAR(64) NOT NULL,
    status VARCHAR(20) NOT NULL,
    response_body TEXT,
    created_at TIMESTAMP NOT NULL,
    updated_at TIMESTAMP NOT NULL,
    UNIQUE KEY uk_idempotency_key (idempotency_key)
);

当两个请求同时插入相同的幂等键时,数据库只允许其中一个成功。另一个请求会触发唯一键冲突,从而被识别为重复请求。

数据库唯一约束不是锦上添花,而是并发环境下最后一道可靠防线。

五、记录处理状态,而不只是记录“处理过”

一个相对完整的幂等记录通常至少包含以下三种状态:

  • PROCESSING:请求正在处理;
  • SUCCESS:请求已经成功;
  • FAILED:请求处理失败。

基本流程如下:

收到请求
   ↓
尝试插入幂等记录
   ↓
插入成功 → 标记 PROCESSING → 执行业务逻辑
   ↓
业务成功 → 保存响应结果 → 标记 SUCCESS

如果插入失败,说明这个幂等键已经存在,此时需要根据已有状态决定如何响应。

1. 状态为SUCCESS

说明请求已经成功处理。服务器不再执行业务逻辑,而是直接返回第一次请求保存的结果。

例如,第一次请求创建了订单:

{
  "orderId": "202609100001",
  "status": "created"
}

后续使用相同幂等键的请求也应返回同一个订单编号,而不是创建新订单。

2. 状态为PROCESSING

说明相同请求仍在处理中。服务器可以返回明确的处理中状态:

HTTP/1.1 409 Conflict
Retry-After: 2
{
  "code": "REQUEST_PROCESSING",
  "message": "请求正在处理中,请稍后查询"
}

也可以让后续请求短暂等待,但不宜无限阻塞,否则容易占用大量连接和线程。

3. 状态为FAILED

失败后的处理需要结合业务语义设计。

如果失败发生在业务执行之前,可以允许客户端重试;如果业务已经部分执行,则不能简单地重新执行,而应先确认业务状态,必要时通过补偿流程恢复。

“失败”不是一个足够精确的描述。系统还需要知道失败发生在哪个阶段,以及当前结果是否能够安全重试。

六、必须校验请求内容是否一致

服务端不能只检查幂等键,还应验证同一个幂等键对应的请求内容是否一致。

例如,客户端第一次请求为:

{
  "productId": 1001,
  "quantity": 1
}

第二次却使用同一个幂等键提交:

{
  "productId": 2002,
  "quantity": 5
}

如果服务器直接返回第一次的结果,调用方可能会误以为第二个请求也已成功。

一种常见做法是对规范化后的请求参数计算摘要:

requestHash = SHA-256(method + path + canonicalRequestBody)

随后将摘要与幂等键一起保存。收到重复请求时,服务器比较摘要:

  • 摘要相同:视为同一次操作的重试;
  • 摘要不同:拒绝请求,并提示幂等键已被其他请求使用。

需要先规范化请求内容。例如JSON对象的字段顺序不应影响计算结果,否则语义相同的两个请求可能产生不同摘要。

七、事务边界决定了系统是否真正可靠

幂等记录与业务数据最好在同一个数据库事务中提交:

@Transactional
public OrderResponse createOrder(String key, CreateOrderRequest request) {
    IdempotencyRecord record = insertProcessingRecord(key, hash(request));

    Order order = orderRepository.save(buildOrder(request));

    OrderResponse response = toResponse(order);
    record.markSuccess(toJson(response));
    idempotencyRepository.save(record);

    return response;
}

这样可以尽量避免以下不一致情况:

  • 幂等记录显示成功,但订单并未创建;
  • 订单已经创建,但幂等记录仍然不存在;
  • 业务事务回滚,幂等记录却被永久保留。

不过,如果业务操作涉及多个系统,例如数据库、支付平台和消息队列,就无法简单依靠一个本地事务解决所有问题。此时通常需要结合事务消息、Outbox模式、状态机、补偿机制或业务查询接口。

分布式系统很难保证每条消息只投递一次,因此工程上更常见的策略是:

允许消息至少投递一次,同时保证消费者重复处理不会产生额外副作用。

八、Redis能不能实现幂等

Redis可以利用原子命令快速抢占幂等键:

SET idempotency:{key} PROCESSING NX EX 60

其中:

  • NX表示仅当键不存在时才写入;
  • EX 60表示键在60秒后过期。

只有第一个请求能够写入成功,后续请求会被识别为重复请求。

这种方案性能较高,适用于秒杀入口、防止短时间重复提交或处理成本较低的接口。但它也有明显限制:

  1. Redis数据可能被淘汰或丢失;
  2. 过期时间过短时,原请求尚未完成,第二个请求就可能重新进入;
  3. 过期时间过长时,异常请求可能长期占用幂等键;
  4. Redis状态与数据库业务状态可能不一致;
  5. Redis操作成功后,应用仍可能在业务执行前崩溃。

因此,Redis更适合做快速拦截,而数据库唯一约束更适合作为最终一致性的保障。对于订单、支付等关键业务,通常不能只依赖一个带过期时间的Redis键。

九、幂等键应该保留多久

幂等记录的保留时间取决于业务允许重试的时间窗口。

例如:

  • 普通表单提交:几分钟到几小时;
  • 创建订单:数天;
  • 支付请求:可能需要保留数月甚至更久;
  • 消息消费记录:至少覆盖消息可能重新投递的最长周期。

清理记录之前,还需要确认相关业务数据本身是否具有唯一约束。例如支付系统可以使用业务订单号作为唯一标识。即使幂等记录被清理,数据库仍能阻止同一订单被重复支付。

不要随意设置统一的24小时过期时间。幂等窗口应当来自实际业务规则,而不是技术人员拍脑袋决定。

十、常见但不可靠的解决方式

1. 前端禁止重复点击

它只能改善用户体验,无法解决网络重试和服务间重复调用。

2. 在应用内加锁

单机锁只对当前进程有效。服务部署多个实例后,请求可能落到不同机器。

3. 只使用分布式锁

锁可以限制并发,却不一定记录历史结果。锁释放后,同一个请求仍可能再次执行。

4. 先查询数据库再决定是否执行

如果没有唯一约束或事务保护,就会存在并发竞争窗口。

5. 所有失败都直接允许重试

如果第一次请求已经产生部分副作用,盲目重试可能放大错误。

6. 把用户ID当作幂等键

一个用户可能合法地连续创建多个订单。幂等键应标识一次业务操作,而不是一个用户。

十一、一个实用的设计清单

在开发创建订单、支付、退款、发券或库存扣减接口时,可以逐项检查:

  • 客户端是否为每次业务操作生成唯一幂等键;
  • 重试时是否继续使用原来的幂等键;
  • 数据库是否对幂等键或业务编号建立唯一约束;
  • 服务端是否保存处理中、成功和失败状态;
  • 重复请求是否返回第一次执行的结果;
  • 相同幂等键对应的请求参数是否一致;
  • 幂等记录与业务操作是否处于合理的事务边界;
  • 服务中途崩溃后,系统是否能够识别并恢复未完成状态;
  • 幂等记录的过期时间是否符合实际业务周期;
  • 消息消费者是否能够安全处理重复消息;
  • 监控系统是否能发现长期处于处理中状态的异常记录。

结语

幂等设计的难点,不是生成一个UUID,也不是简单地给接口加一把锁,而是正确处理并发、超时、重试、崩溃和部分成功。

一个真正可靠的幂等接口通常具备三个核心能力:

  1. 能够准确识别同一次业务操作;
  2. 能够通过唯一约束阻止并发重复执行;
  3. 能够保存并返回第一次操作的最终结果。

在可靠系统中,重复请求不是意外,而是一种必须被接受的正常情况。系统设计的目标不是幻想请求永远只出现一次,而是确保它无论出现多少次,业务都只产生一次有效结果。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。