从一次重复扣款说起:如何设计真正可靠的幂等接口
在后端开发中,我们经常会遇到一种看似偶然、实际上无法避免的问题:同一个请求被执行了两次。
用户连续点击两次“提交订单”,浏览器因网络超时自动重试,消息队列重复投递消息,网关重新转发请求……这些情况都有可能让服务器收到内容完全相同的请求。
如果接口只是查询数据,重复执行通常没有影响;但如果接口涉及创建订单、扣减库存、发放优惠券或资金支付,重复执行就可能带来严重后果。
解决这类问题的核心,就是让接口具备幂等性。
一、什么是幂等性
幂等性是指:对同一个操作执行一次或执行多次,产生的最终结果相同。
例如,把用户状态设置为“已禁用”通常是幂等操作:
PUT /users/1001/status
{
"status": "disabled"
}
无论调用一次还是十次,用户最终都是禁用状态。
但“给用户余额增加100元”通常不是幂等操作:
POST /users/1001/balance/increase
{
"amount": 100
}
调用一次增加100元,调用两次就增加200元。因此,服务器必须判断后续请求究竟是新的业务操作,还是之前请求的重复提交。
需要注意的是,幂等并不意味着服务器只能收到一次请求,而是意味着即使收到多次请求,业务操作也只生效一次。
二、重复请求为什么无法完全避免
很多人会在前端给按钮增加防抖,或者在用户点击后将按钮禁用。这些措施确实能够减少重复请求,却不能从根本上保证幂等性。
因为重复请求可能来自多个环节:
- 用户连续点击按钮;
- 浏览器或客户端自动重试;
- 网关超时后重新转发;
- 服务之间的RPC调用触发重试;
- 消息队列发生重复投递;
- 服务处理成功,但返回结果前网络中断;
- 客户端没有收到响应,无法确认操作是否成功,于是再次提交。
其中最典型的是“结果未知”问题。
假设用户发起付款请求,服务器已经完成扣款,但在响应返回之前网络断开。此时客户端看到的是请求超时,却无法知道扣款是否已经发生。如果客户端直接重试,而服务端没有幂等机制,用户就可能被重复扣款。
因此,幂等性必须由真正执行业务操作的一方保证,不能只依赖前端或调用方。
三、使用幂等键识别同一个业务请求
一种常见做法是让客户端为每次业务操作生成唯一的幂等键,并在请求头中发送:
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秒后过期。
只有第一个请求能够写入成功,后续请求会被识别为重复请求。
这种方案性能较高,适用于秒杀入口、防止短时间重复提交或处理成本较低的接口。但它也有明显限制:
- Redis数据可能被淘汰或丢失;
- 过期时间过短时,原请求尚未完成,第二个请求就可能重新进入;
- 过期时间过长时,异常请求可能长期占用幂等键;
- Redis状态与数据库业务状态可能不一致;
- Redis操作成功后,应用仍可能在业务执行前崩溃。
因此,Redis更适合做快速拦截,而数据库唯一约束更适合作为最终一致性的保障。对于订单、支付等关键业务,通常不能只依赖一个带过期时间的Redis键。
九、幂等键应该保留多久
幂等记录的保留时间取决于业务允许重试的时间窗口。
例如:
- 普通表单提交:几分钟到几小时;
- 创建订单:数天;
- 支付请求:可能需要保留数月甚至更久;
- 消息消费记录:至少覆盖消息可能重新投递的最长周期。
清理记录之前,还需要确认相关业务数据本身是否具有唯一约束。例如支付系统可以使用业务订单号作为唯一标识。即使幂等记录被清理,数据库仍能阻止同一订单被重复支付。
不要随意设置统一的24小时过期时间。幂等窗口应当来自实际业务规则,而不是技术人员拍脑袋决定。
十、常见但不可靠的解决方式
1. 前端禁止重复点击
它只能改善用户体验,无法解决网络重试和服务间重复调用。
2. 在应用内加锁
单机锁只对当前进程有效。服务部署多个实例后,请求可能落到不同机器。
3. 只使用分布式锁
锁可以限制并发,却不一定记录历史结果。锁释放后,同一个请求仍可能再次执行。
4. 先查询数据库再决定是否执行
如果没有唯一约束或事务保护,就会存在并发竞争窗口。
5. 所有失败都直接允许重试
如果第一次请求已经产生部分副作用,盲目重试可能放大错误。
6. 把用户ID当作幂等键
一个用户可能合法地连续创建多个订单。幂等键应标识一次业务操作,而不是一个用户。
十一、一个实用的设计清单
在开发创建订单、支付、退款、发券或库存扣减接口时,可以逐项检查:
- 客户端是否为每次业务操作生成唯一幂等键;
- 重试时是否继续使用原来的幂等键;
- 数据库是否对幂等键或业务编号建立唯一约束;
- 服务端是否保存处理中、成功和失败状态;
- 重复请求是否返回第一次执行的结果;
- 相同幂等键对应的请求参数是否一致;
- 幂等记录与业务操作是否处于合理的事务边界;
- 服务中途崩溃后,系统是否能够识别并恢复未完成状态;
- 幂等记录的过期时间是否符合实际业务周期;
- 消息消费者是否能够安全处理重复消息;
- 监控系统是否能发现长期处于处理中状态的异常记录。
结语
幂等设计的难点,不是生成一个UUID,也不是简单地给接口加一把锁,而是正确处理并发、超时、重试、崩溃和部分成功。
一个真正可靠的幂等接口通常具备三个核心能力:
- 能够准确识别同一次业务操作;
- 能够通过唯一约束阻止并发重复执行;
- 能够保存并返回第一次操作的最终结果。
在可靠系统中,重复请求不是意外,而是一种必须被接受的正常情况。系统设计的目标不是幻想请求永远只出现一次,而是确保它无论出现多少次,业务都只产生一次有效结果。
- 点赞
- 收藏
- 关注作者
评论(0)