Appearance
API 幂等性、超时重试与移动端数据最终一致性
回到总览:后台 / API / 数据层
相关模块:RESTful API 设计规范与移动端契约约定 · Token 鉴权体系、JWT、OAuth2 与移动端双 Token 自动无感刷新
一句话定义
API 幂等性(Idempotency)是指客户端使用相同的参数对同一个 API 发起一次或多次请求,所产生的系统服务端状态变更与影响均完全相同的属性;结合客户端指数退避重试与幂等号 (RequestId / Nonce) 是解决移动弱网环境下重复提交与数据一致性的核心基石。
代码索引
计划补齐的实验:
labs/shared/backend-data/api-idempotency-demo/— RequestId 幂等号拦截、指数退避重试与本地事务一致性测试
为什么需要
- 为什么在移动端发起“提交订单”或“扣款支付”请求时,即使网络超时抛出
TimeoutException,也不能盲目在 Catch 块中无脑重试?- 一句话答:网络超时可能发生在服务端处理成功后返回响应的链路中;此时服务端订单已成功创建,若客户端在没有带上幂等号的情况下盲目重试,会导致服务端重复创建订单或重复扣款。
- 为什么 10 年 Android 工程师做移动端架构时必须建立“客户端幂等防重”认知?
- 一句话答:移动网络(5G/4G/Wi-Fi 切换)具有天然的不稳定性;通过在 Header 注入唯一的
X-Request-Id,配合服务端的 Redis 防重锁,才能在保障弱网自动重试体验的同时防止数据污染。
- 一句话答:移动网络(5G/4G/Wi-Fi 切换)具有天然的不稳定性;通过在 Header 注入唯一的
底层机制
1. HTTP 方法规范中的天然幂等性
| HTTP 方法 | 是否幂等 | 是否安全 (Safe) | 语义说明 |
|---|---|---|---|
| GET | 是 | 是 | 仅查询数据,不改变服务端状态 |
| PUT | 是 | 否 | 完整替换覆盖对象,重复多次结果相同 |
| DELETE | 是 | 否 | 删除特定资源,重复删除返回 404 或已删除 |
| POST | 非幂等 | 否 | 创建新资源,重复请求会产生多个新资源 |
2. 幂等号 (RequestId / Nonce) 防重流程
text
[移动客户端] ─── (1. 生成 UUID: req_10086) ───► [后端网关 / API]
│ │
│ ▼
│ [Redis 防重锁/查重]
│ - 若 Token 存在: 返回历史缓存结果
│ - 若 Token 不存: 执行业务并缓存 Token
│ │
◄─── (2. 200 OK / 扣款成功) ─────────────────────┘- 唯一标识生成:客户端在进入“提交”页面或点击按钮时,生成全局唯一的
RequestId(如 UUID)。 - 请求携带:在 HTTP Header 中注入
X-Request-Id: req_10086。 - 服务端校验:服务端利用 Redis 的
SETNX尝试加锁。若已被处理过,直接返回上一次处理成功的 Result,不再重复执行扣款逻辑。
3. 客户端指数退避重试 (Exponential Backoff with Jitter)
在重试网络请求时,避免固定时间间隔重试引发服务端流量雪崩: 重试等待时间 = BaseInterval * 2^retryCount + RandomJitter
Android / Flutter / Web / Backend 对照
| 维度 | 移动客户端 (Android / Flutter) | 后端 Gateway / Service |
|---|---|---|
| 幂等实现 | 按钮防抖 (Debounce) + 注入 X-Request-Id | Redis SETNX 防重 + 数据库唯一索引 |
| 重试策略 | OkHttp Interceptor 指数退避加随机抖动 | gRPC Retry Policy / Resilience4j |
| 一致性 | 本地 Room 数据库与服务端异步最终一致 | 分布式事务 (Saga / TCC / 2PC) |
常见场景与代码实现
1. OkHttp 指数退避重试拦截器
java
public class ExponentialBackoffInterceptor implements Interceptor {
@Override
public Response intercept(Chain chain) throws IOException {
Request request = chain.request();
Response response = null;
boolean responseOK = false;
int tryCount = 0;
int maxRetries = 3;
while (!responseOK && tryCount < maxRetries) {
try {
response = chain.proceed(request);
responseOK = response.isSuccessful();
} catch (Exception e) {
tryCount++;
if (tryCount >= maxRetries) throw e;
// 指数退避抖动计算: 1s, 2s, 4s...
long backoff = (long) (Math.pow(2, tryCount) * 1000 + Math.random() * 200);
try { Thread.sleep(backoff); } catch (InterruptedException ignored) {}
}
}
return response;
}
}常见误配、事故后果与排障
1. 事故:网络超时无脑重试导致账户重复扣款
- 误配原因:在 POST
/api/v1/order/pay接口中,客户端捕获SocketTimeoutException后盲目重新发起相同的 POST 请求,且未带幂等号。 - 后果:实际上第一次请求服务端已成功完成扣款,仅响应回包超时。重试请求导致服务端再次扣款,引发严重线上资金事故。
- 排障与修法:所有涉及写数据的非幂等接口,必须增加全局唯一
RequestId;网络超时重试前先调用“订单状态查询”接口确认为未支付再发起重试。
与相近概念对比
| 概念 | 核心关注点 | 解决方案 |
|---|---|---|
| API 幂等性 | 防止重复请求导致数据重复修改 | 请求幂等号 X-Request-Id + 数据库唯一索引 |
| 强一致性 (Strong) | 读操作必定能读到最新的写结果 | 2PC / Raft 协议(性能较低) |
| 最终一致性 (Eventual) | 允许短时间数据不一致,最终达成一致 | 消息队列 (MQ) + 本地事务表 + 轮询补偿 |
对应实验
计划补齐的实验:
labs/shared/backend-data/api-idempotency-demo/— RequestId 幂等号拦截、指数退避重试与本地事务一致性测试
复习检查题
为什么 HTTP
POST请求默认是非幂等的,而PUT和DELETE是幂等的?答:因为
POST的语义是向服务端创建新资源,多次发起POST会产生多个不同的新资源(如创建多个新订单);而PUT的语义是用传入的数据完整替换覆盖指定 URI 的资源,DELETE的语义是删除指定 URI 的资源,无论执行一次还是十次,最终服务端的资源状态都是一样的。在移动端弱网环境下,为什么重试等待时间推荐使用“带随机抖动的指数退避(Exponential Backoff with Jitter)”?
答:如果采用固定时间间隔重试(如每 1 秒重试一次),当基站发生短暂停机恢复时,大量客户端会在同一时间点同时发起重试,造成服务端遭遇严重的“重试雷暴(Retry Storm)”冲垮数据库。指数退避(1s, 2s, 4s...)分散了重试时间,加上随机抖动(Jitter)可以彻底错开不同客户端的请求时间点,保护服务端安定。
速记
- 幂等防重:非幂等写接口必带
X-Request-Id,服务端 Redis 锁防重。 - 退避重试:网络超时重试加指数退避与随机抖动,切忌固定间隔抢铺。
- 一致性原则:移动端追求最终一致性,本地快照 + 后台异步补偿。