- 后端
- 文档
- 教程
【免费下载链接】system-design-101
Explain complex systems using visuals and simple terms. Help you prepare for system design interviews.
本文是开源仓库 System Design 101 中「Top 6 Cases to Apply Idempotency」指南的深度展开版,围绕"操作可能被重试、可能被多次执行"这一前提,逐一拆解 RESTful API、支付、订单、数据库、账号管理与分布式消息六大场景中的幂等性诉求,并结合仓库内 如何避免双重扣款、失败重试策略、消息投递语义 等配套文档,给出可直接落地的幂等键、重试退避、去重与补偿方案。读完本文,你将能够判断一个操作"是否必须幂等"、选择正确的幂等实现层次,并为一套面向生产环境的可靠系统设计出"重试安全、扣款不重、下单不重、消息不重处理"的完整防线。
什么是幂等性:先理解"一次成功、多次无害"
幂等性(Idempotency)是指:同一个操作被执行一次与被执行多次,最终产生的状态是一致的。它尤其重要于那些"可能被重试、可能被多次执行"的场景——网络超时后的客户端重试、消息队列的重复投递、支付网关的回调重放,都会让一个操作在物理上执行不止一次。
从数学上讲,一个操作是幂等的,当且仅当f(f(x)) = f(x):第一次执行改变了状态,第二次及以后的执行不再产生新的变化。
仓库文档 如何避免双重扣款 给出了一个更工程化的拆解方式:"恰好一次(exactly-once)" = "至少一次(at-least once)" + "至多一次(at-most once)"。这两半分别由两种机制保证:
- 重试(Retry):提供"至少一次"保证——因为网络可能随时失败,客户端必须敢于重发,确保操作最终被送达;
- 幂等性检查(Idempotency Check):提供"至多一次"保证——服务端收到重复请求时能识别并丢弃,确保操作只生效一次。
配套的 消息投递语义 对这三档语义做了进一步界定:
| 投递语义 | 含义 | 典型适用场景 |
|---|---|---|
| At-most once | 消息最多送达一次,可能丢失但不重复 | 监控指标等可容忍少量丢失的场景 |
| At-least once | 消息不丢失,但可能被重复投递 | 数据重复影响不大、或消费端可去重的场景 |
| Exactly once | 既不丢失也不重复,实现成本最高 | 支付、交易、记账等金融场景,且下游不支持幂等时 |
幂等性正是"在 at-least once 的基础上,用应用层去重把重复执行挡在门外",从而让系统整体呈现出 exactly-once 的行为。
幂等键(Idempotency Key):幂等实现的通用载体
在客户端与服务端之间,幂等性通常通过幂等键落地。依据 如何避免双重扣款 的说明:
- 幂等键是由客户端生成的唯一值,且在一定时间后过期;
- UUID 是最常用的幂等键,并被 Stripe、PayPal 等多家公司推荐使用;
- 发起幂等请求时,把幂等键放进 HTTP 请求头:
<idempotency-key: key_value>。
服务端收到请求后,先以幂等键查询是否已处理过:若未处理则执行并记录;若已处理则直接返回首次执行的结果(或确认状态),不再重复执行。
场景一:RESTful API 请求——重试不改变资源状态
核心诉求:确保重试一次 API 请求不会导致同一操作被执行多次,从而维持资源状态的一致。
实现要点:
- 选择幂等的 HTTP 方法。REST 语义中,
GET用于读取、PUT用于整体替换资源、DELETE用于删除资源,这些方法天然是幂等的:同一请求重发任意多次,资源最终状态不变。POST用于创建,通常不幂等——这正是需要幂等键保护的典型对象。 - 为不幂等的操作绑定幂等键。对
POST这类创建型请求,客户端在请求头携带idempotency-key,服务端按键去重,即可把"不幂等的 POST"变成"业务上幂等的 POST"。 - 保持幂等方法的语义纯粹。
PUT必须做到"全量替换"而不是"追加修改",否则第二次重放会基于已变化的状态产生错误结果;DELETE重复执行应返回"已删除/不存在"的一致结果而非报错。
场景二:支付处理——绝不重复扣款
核心诉求:网络抖动、网关超时、回调重放都不能让用户被扣两次钱。支付网关本身就经常需要重试交易,幂等性保证"一笔订单只产生一次扣款"。
这是整个分布式系统中幂等性最关键的战场。结合 如何避免双重扣款,支付侧的标准做法是:
- 重试保证"至少一次"。客户端因网络差、超时而重试支付请求(例如图中第四次的尝试才成功),请求可能被多次送到服务端。
- 幂等键保证"至多一次"。每次支付请求都携带客户端生成的 UUID 幂等键(
<idempotency-key: key_value>),支付服务按键落库并去重,无论请求到达几次,扣款动作只执行一次。 - 重试要配合退避策略,而不是无脑风暴式重试。仓库文档 失败重试策略 归纳了四种常见策略:
| 策略 | 行为 | 优点 | 缺点 |
|---|---|---|---|
| Linear Backoff(线性退避) | 每次等待固定递增间隔 | 实现简单、易理解 | 高并发下易引发资源争用与"重试风暴" |
| Linear Jitter Backoff(线性抖动退避) | 线性间隔外加随机抖动 | 随机性打散各实例的重试时间,降低同步重试概率 | 基础间隔线性增长,仍可能触发同步重试 |
| Exponential Backoff(指数退避) | 间隔按 1s、2s、4s、8s……指数增长,通常设上限 | 显著降低系统负载与重试碰撞概率,适合高负载 | 本可快速重试解决的场景反而被拖慢 |
| Exponential Jitter Backoff(指数抖动退避) | 指数间隔叠加随机抖动(加性/乘性) | 兼具指数退避优点,进一步降低重试碰撞 | 抖动较大时可能产生过长等待 |
支付场景应优先采用带抖动的指数退避,既保证重试最终成功,又避免同时重试打垮下游。
支付链路本身也要在每一环防重。参照 支付系统 的典型流程:用户点击购买后生成支付事件并入库;一个支付事件可能包含多个支付订单(如一个购物车内多家卖家的商品);支付执行器逐个调用外部 PSP 完成扣款,成功后更新钱包、追加账本(Ledger),夜间再由 PSP/银行下发对账文件。这条链路上,事件、订单、扣款、钱包入账、账本追加都需要各自的幂等保护,否则任何一个环节的重放都会造成金额不一致。
此外,支付对账 明确指出:即便系统实现了 exactly-once 语义,仍可能存在各种意外差异,对账系统是必须的安全网——通过比较电商订单、支付渠道交易记录、账本借贷记录,发现并消除不一致。幂等性负责"事前不重",对账负责"事后兜底"。
场景三:订单管理系统——下单多次只成一单
核心诉求:用户(或客户端重试、前端重复点击)多次提交同一订单,系统只创建一个订单,并防止库存被重复扣减。
实现要点:
- 客户端生成订单幂等键。下单请求携带
idempotency-key(UUID),服务端先查幂等表:键已存在则直接返回原订单,否则创建新订单并记录键。 - 库存扣减必须幂等或有唯一约束兜底。库存流水表以"订单 ID + 商品 ID"作为唯一约束(详见下文场景四),重复的扣减请求被数据库直接拒绝;或让"扣减"以订单维度做去重,保证同一订单只扣一次。
- 超时重试与下单去重配合。下单请求超时后客户端重试是常态,幂等键让"第二次请求"命中第一次的结果,从源头杜绝重复下单、重复扣库存。
场景四:数据库操作——事务重放不改变最终状态
核心诉求:一条事务/一条 SQL 被重新执行(重放)时,数据库状态不应发生超出首次执行的改变。
实现要点:
- 善用"原子性幂等写入"。将"先查后写"改为"条件写入/冲突覆盖":
INSERT ... ON CONFLICT DO NOTHING / DO UPDATE(UPSERT)保证同一行数据只被初始化一次;INSERT IGNORE等数据库方言同理。重复执行对已有数据无影响。 - 用唯一约束做最终防线。这是最硬核的幂等保障。消息投递语义 指出:在 at-least once 语义下,给每条消息(或业务记录)一个唯一键,写入数据库时遇到重复直接拒绝——唯一索引将"重复写入"变成"必然失败"的确定性结果。
- 注意与事务隔离级别的配合。幂等写入在高并发下要防止两个请求同时"都判定未存在、都去写入",唯一约束与适当的事务隔离级别(可参考仓库 数据库隔离级别)能共同保证并发下仍只有一个生效。
场景五:用户账号管理——注册不重复、重置只一次
核心诉求:重试注册请求不会创建多个用户账号;多次密码重置请求最终只产生一次重置动作。
实现要点:
- 注册场景用"唯一标识 + 幂等键"双保险。邮箱、手机号、用户名上加唯一索引,是数据库层面"不可重复注册"的硬约束;客户端携带的
idempotency-key则保证同一次注册请求即使被重试 N 次,也只走一遍注册流程、只发一封验证邮件。 - 密码重置场景"以重置令牌为核心"。重置请求生成一次性令牌(token),令牌使用即失效——后续任何重复的重置请求要么命中同一个令牌、要么因令牌已失效被拒绝,从而保证"多次请求、一次重置"。同时可对同一账号的重复重置请求按幂等键去重,避免短信/邮件验证码被重复下发。
- 写操作配合唯一键落库。账号表、重置令牌表均可通过唯一约束承接重复写入(同场景四),让数据库成为幂等性的最后一层保障。
场景六:分布式系统与消息——队列重放不产生重复处理
核心诉求:消息队列中的同一条消息被重新投递/重新处理时,不应造成重复处理或重复副作用。需要实现能"多次处理同一条消息而不产生副作用"的处理器。
这是分布式系统幂等性的核心应用,可以分成三层来看:
- 投递侧:理解 at-least once 是常态。以 Kafka 为例,仓库文档 Kafka 会丢消息吗 指出:生产者侧需要配置合理的
acks与retries才能确保消息送达;消费者侧不同的提交(commit)方式会影响语义——自动提交可能在消息真正处理完之前就提交了 offset,一旦消费者在中间宕机,部分消息会"未被处理但已提交",或反过来被重新消费。因此消费端必须假定"同一条消息可能被处理多次"。 - 消费侧:处理函数必须幂等。最直接的做法是在消费端去重:给每条消息携带唯一键(业务 ID 或消息 ID),消费时先查重(如通过唯一索引写入、幂等表、或 布隆过滤器 这类空间高效的概率结构做大规模去重)再执行业务逻辑。布隆过滤器可回答"URL/消息是否已存在",false negative 不会出现,false positive 可用唯一索引二次兜底。
- 状态侧:副作用要可重放。更新类消费(写库、扣库存、发通知)都要按场景一至四的方式做成幂等写,或采用"本地事务表 + 消息表"(如 Outbox 模式)保证消息与业务状态同时落库,避免"状态变了但消息丢了"或"消息重了但状态没跟着重"的错位。
小结:六大场景的幂等方案速查
| 场景 | 风险点 | 推荐方案 |
|---|---|---|
| RESTful API 请求 | POST 重试导致重复创建/变更 | 用 PUT/DELETE 等幂等方法;POST 绑定幂等键 |
| 支付处理 | 重试导致重复扣款 | 幂等键 + 指数抖动退避 + 对账兜底 |
| 订单管理 | 重复提交产生重复订单、重复扣库存 | 订单幂等键 + 库存流水唯一约束 |
| 数据库操作 | 事务重放改变状态 | UPSERT、唯一索引、唯一键去重 |
| 用户账号管理 | 重复注册、重复重置 | 邮箱/手机号唯一约束 + 一次性令牌 + 幂等键 |
| 分布式消息 | 队列重放导致重复处理 | 消息唯一键 + 消费端去重(唯一索引/幂等表/布隆过滤器)+ 幂等处理器 |
幂等性不是一个独立的功能模块,而是一套贯穿"客户端重试、网关透传、服务端去重、数据库约束、消息消费"的设计纪律。把"至少一次"的重试与"至多一次"的幂等检查组合起来,你的系统就能在不确定的网络世界里给出确定的业务结果——这正是 System Design 101 仓库想传达的核心理念:用简单清晰的工程手段,构建可靠的大规模系统。
本文的六类场景与配套方案均可回到仓库对应文档继续深挖:如何避免双重扣款(幂等键与 exactly-once 拆解)、失败重试策略(四种退避算法)、消息投递语义(三档语义与去重)、支付系统(支付链路各环节)、支付对账(对账安全网)、Kafka 会丢消息吗(消息生命周期与提交策略)、布隆过滤器去重(大规模去重)。
- 后端
- 文档
- 教程
【免费下载链接】system-design-101
Explain complex systems using visuals and simple terms. Help you prepare for system design interviews.
相关推荐
System Design 101:负载均衡算法与应用场景
System Design 101:负载均衡算法与应用场景 你是否曾遇到过网站访问缓慢、服务频繁崩溃的情况?在高并发场景下,单一服务器往往难以承受巨大的流量压力
后端文档教程System Design 101 之 Kafka 101:用 8 个步骤掌握 Kafka 核心原理与实战要点
System Design 101 之 Kafka 101:用 8 个步骤掌握 Kafka 核心原理与实战要点 导读 Kafka 是当前分布式系统中最流行的分布
后端文档教程对象存储的 6 大核心使用场景(Object Store)|system-design-101 深度解析
对象存储的 6 大核心使用场景(Object Store)|system design 101 深度解析 对象存储(Object Storage)是当前云原生时
后端文档教程
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考