接口重试与幂等性设计
校招考察频度:★ 高频(核心必背·高频生死线) 一句话速记:网络不可靠必须靠重试兜底,而重试必然带来重复;防重试风暴靠指数退避加随机抖动,抗重复请求靠业务幂等性(防重 Token、数据库唯一索引、状态机条件更新、乐观锁版本号)。
在分布式微服务架构中,“网络永远是不可靠的”。一次 RPC 或 HTTP 请求超时,并不代表服务端没有执行(可能请求到了且执行成功,只是响应报文在回传途中丢失)。此时若客户端发起重试,就会导致用户被重复扣款或生成两个重复订单。重试与幂等是一体两面的孪生兄弟。
一、0 基础生动类比:去餐厅点餐模型
| 概念 | 现实点餐场景 | 分布式系统对应逻辑 |
|---|---|---|
| 超时(Timeout) | 顾客叫服务员点单,等了 1 分钟服务员没理会。 | 客户端发起请求后,不能无限期挂起线程,必须设置最长等待时限。 |
| 重试(Retry) | 顾客再次呼叫服务员:“服务员,刚才那道菜下了吗?” | 请求失败或网络超时时,自动化再次发起调用进行补偿。 |
| 重试风暴(Retry Storm) | 厨房着火上菜慢,全餐厅 200 个顾客每隔 1 秒疯狂催菜一次,后厨直接彻底崩溃! | 下游服务响应慢时,成千上万个客户端同时无脑高频重试,把原本微弱恢复的下游彻底打死。 |
| 幂等(Idempotence) | 不管顾客催单多少次,服务员拿看菜单上的唯一桌号 + 订单编号:“您放心,这道菜只会炒一份,不会重复上菜”。 | 同一个操作无论执行 1 次还是 100 次,系统产生的最终业务状态与副作用完全相同。 |
二、超时控制:调用链路的“层层递减原则”
在多层微服务调用链中(客户端 ➔ 网关 ➔ 服务 A ➔ 服务 B ➔ 数据库):
[客户端超时: 5s] ──> [网关超时: 4s] ──> [服务 A 超时: 3s] ──> [服务 B 超时: 2s] ──> [DB 超时: 1s]- 反面教材(倒挂雪崩):若服务 A 超时设为 1 秒,而下游服务 B 设为 3 秒。当 B 执行了 1.5 秒时,A 早就认定超时并掐断连接;而 B 依然毫无知觉地继续拼命消耗 CPU 读写数据库。这会导致上游已经放弃并报错,下游还在无意义空耗资源。
- 铁律:调用链路上的超时时间必须从上游到下游“层层递减”。
三、重试策略:指数退避与随机抖动(Full Jitter)
指数退避 + 随机抖动防惊群重试
1. 为什么不能“立即重试”或“固定间隔重试”?
下游一旦发生毛刺宕机,不可能在几毫秒内立刻恢复。如果几万个客户端以固定的 1 秒间隔同时发起重试,所有重试请求会在同一毫秒整齐划一地轰炸下游,形成**“惊群效应”与重试风暴(Retry Storm)**。
2. 指数退避加抖动公式(Full Jitter)
delay = random(0, min(max_backoff, base * (2 ^ attempt)))- 间隔时间成倍拉长(
1s -> 2s -> 4s -> 8s),给下游留出宝贵的重启与 GC 恢复时间。 - 引入随机抖动(Jitter),将原本重合的重试流量在时间轴上彻底打散抹平。
四、接口幂等性 5 大工业级落地套路
1. 防重 Token 机制(针对前端表单重复点击)
- 时序流程:
- 用户进入提交页面,前端先调用后端接口获取一个全局唯一的临时令牌
token(存储在 Redis 中,如SET token_123 1 EX 300)。 - 用户点击“立即付款”,请求携带该
token。 - 后端执行 Redis 原子删除:
DEL token_123(或 Lua 脚本先查后删)。 - 若删除成功(返回值 1),说明是首次请求,放行执行业务;若删除失败(返回值 0),说明该 Token 已被消费过,直接拒绝!
- 用户进入提交页面,前端先调用后端接口获取一个全局唯一的临时令牌
2. 数据库唯一主键 / 唯一索引(最坚硬的底线兜底)
- 依据业务属性提取业务唯一约束键(例如:订单表的
order_no,支付流水表的pay_serial_no,防重表的req_id)。 - 多次重复提交时,底层数据库直接抛出唯一键冲突异常(MySQL
1062 Duplicate entry,Java 抛出DuplicateKeyException),事务天然回滚,数据坚如磐石。
3. 业务状态机天然幂等
- 针对订单、工单等生命周期严格的状态流转,在更新 SQL 中带上前置状态约束:
-- 只有当前处于 UNPAID 状态时才允许变更为 PAID UPDATE orders SET status = 'PAID', updated_at = NOW() WHERE order_id = 'ORD_999' AND status = 'UNPAID'; - 若重复请求到达,由于状态已经变成了
PAID,该 SQL 执行的affected_rows影响行数为 0,直接判定为重复操作并返回成功即可,天然幂等。
4. 版本号机制(乐观锁)
- 在数据表中增加
version字段:UPDATE account SET balance = balance - 100, version = version + 1 WHERE user_id = 888 AND version = 5; - 只要版本号不匹配,更新直接失败,防止并发写冲突。
5. 分布式锁(配合状态存储)
- 针对高并发瞬时请求,先尝试以业务 ID 加 Redis 分布式锁(
SET order_id NX EX 30)。 - 抢到锁的线程才能执行业务,未抢到锁的线程自旋等待或直接返回“正在处理中”。
五、知识脉络关联网(Knowledge Highway)
- ⬅️ 消息队列应用:消息重复投递时消费端如何做到防重?参见 消息队列可靠性与防重设计。
- ➡️ 分布式锁底层:如何用 Redis 安全实现分布式锁?参见 Redis 分布式锁底层与 RedLock。
- 🔄 高可用防御网络:重试超出上限后如何触发保护?参见 微服务熔断与降级策略。
六、校招高频面试追问
- HTTP 协议中哪些方法是天然幂等的?哪些不是?
- 天然幂等:
GET:纯只读查询,查 100 次结果一致且对服务端无副作用。PUT:全量替换资源(如把年龄改成 18 岁,执行 10 次年龄依然是 18 岁)。DELETE:删除特定 ID 资源(第 1 次删除返回成功,第 2 次返回 404 或已删除,系统数据状态相同)。
- 非幂等:
POST:创建新资源或追加数据(发 10 次会创建 10 条重复记录)。PATCH:局部修改资源(如相对累加balance = balance + 10)。
- 天然幂等:
- 幂等接口如果判定为重复请求,应该给客户端返回成功还是报错?
- 必须返回成功(或原首次调用的业务结果)!
- 切忌直接给客户端抛 500 或业务错误提示:“订单已存在”。因为从客户端视角看,发起重试往往是因为网络抖动没拿到上次的成功响应。如果给它报错,前端会以为支付失败,诱导用户换方式重复操作;正确做法是服务端在幂等表中查到旧结果,原样包装成功并返回。