Yujun's Blog

Microservice(三):幂等性方案

June 12, 2025 (1mo ago)SD

Microservice(三):幂等性方案

不要只停留在全局唯一ID,唯一索引...这些零零散散的知识点,要对幂等保证方案有一个体系的认识。

什么是幂等性?

简单来说,幂等性(Idempotence) 就是对同一个资源的一次操作和多次操作,产生的影响完全相同。 用数学语言表达是:f(x) = f(f(x))。 用大白话说就是:一个操作,执行一遍和执行N遍,结果都一样。

这主要针对的是写操作(Create, Update, Delete)。查询操作天然就是幂等的,我们后面会详细说。

如果没有幂等性保障,我们的系统会变得很危险,比如对于电商系统,会出现重复下单、库存超卖、优惠券被刷。对于金融系统,会出现重复转账、重复扣款。任何一个问题都可能导致用户投诉、公司资损,甚至法律风险。

幂等性问题从何而来?

重复的请求主要源于两大类场景:

  • 不可靠的网络 :网络波动、请求超时,客户端没收到响应,就会触发重试机制(Nginx重试、RPC框架重试、消息队列重试等)。
  • 用户的不正确操作:用户快速连击提交按钮、App闪退后重新操作、浏览器刷新或后退。

解决方案

幂等性设计不是一个单一的技术点,而是一个贯穿于系统设计中的分层保障策略。我们的核心思想是:“为每一次写操作的意图,创建一个全局唯一的凭证,并在系统的不同层面进行校验与设防。

具体来说,主要分为四层:

  • 第一层:用户体验层
  • 第二层:接入/网关层
  • 第三层:业务逻辑层
  • 第四层:数据存储层

接下来,我们逐个研究下再这些层面如何保证幂等性?


首先,用户层面我们可以做那些操作? ​这一层是距离用户最近的防线,其主要目标是优化用户体验,从源头减少无效重复请求的产生,而非提供严格的安全保障。由于客户端环境完全不可信,此层措施绝不能作为唯一的幂等性手段。注意:这一层不可靠,只能作为辅助。

  • 瞬时反馈与状态锁定:在用户执行提交操作(如点击按钮)后,前端应立即通过视觉变化给予反馈。例如,将按钮置为disable并显示loading动画,防止用户下意识的重复点击。
  • PRG模式(Post-Redirect-Get):在表单成功提交后,服务端应返回一个重定向(Redirect)响应,引导浏览器跳转到一个新的GET请求页面。这能有效防止用户通过浏览器的“刷新”或“后退”功能,重新提交相同的POST数据。

第二层,网关层,如何操作?

作为系统流量的入口,网关层负责处理协议层面的问题,能够拦截一部分因网络抖动、客户端或代理重试机制引发的重复请求。即网关层处理协议问题,能拦截一部分网络层面的重试。

  • 网络重试控制:例如,在Nginx等反向代理中,可以配置重试策略,明确哪些类型的错误(如502, 504)可以触发对上游服务的重试,而对于已经将请求体发往上游的POST请求则应避免重试,以减少重复执行的可能性。

第三层,业务逻辑层,这是实现幂等性保障的最核心的地方,在这里,我们不再依赖外部行为,而是通过严谨的内部流程控制来确保操作的唯一性。我们采用一个健壮的通用处理范式,可以概括为“令牌校验、状态锁定、持久化更新”。 即常说的一锁二判三更新。这里我们后面会细讲。

  • 全局唯一ID(The Token):任何需要幂等保障的请求,都必须携带一个全局唯一的ID(如 request_id, transaction_id, idempotency_key)。这个ID代表了本次操作的唯一意图,是后续所有校验的基石。
  • 幂等性校验(The Check):在执行核心业务逻辑前,系统必须基于此唯一ID进行检查,以判断该操作意图是否已被处理。这通常通过查询一个共享的、持久化的存储(如Redis、数据库)来完成。
  • 处理结果持久化(The Persistence):一旦业务逻辑成功执行,其结果(或至少是“已成功处理”的状态)必须与该唯一ID关联并被持久化存储。这样,后续的重复请求在校验步骤就能被准确识别并拦截。

最后一层,数据存储层 数据存储层是幂等性保障的最后一道,也是最坚固的防线。它利用数据存储系统自身的特性(如事务、约束)来强制保证数据的一致性,作为最终的兜底措施。

  • 唯一索引(Unique Index):在数据表层面,为幂等性关键ID(如订单号、支付流水号、业务请求ID)建立唯一索引。这是最简单、最高效的防重手段,任何试图插入重复记录的操作都会被数据库直接拒绝,从根本上杜绝了脏数据的产生。
  • 乐观锁(Optimistic Locking):对于更新操作,通过引入版本号(version)或时间戳字段。更新时,必须确保所操作数据的版本与之前读取时一致(UPDATE ... WHERE id = ? AND version = ?)。这能有效防止因重复请求导致的数据覆盖和状态错乱问题。
  • 状态机(State Machine):通过在业务实体上定义严谨的状态字段和状态转移规则。任何操作都必须遵循预设的状态流转路径(如订单状态只能从“待支付”变为“已支付”)。对于一个已完成状态的实体,重复的操作请求因不满足状态前置条件而被自然拒绝,从而实现了幂等。

总结一下,防重复体系是一个层层递进的过程。

首先前端先友好的劝退大部分用户的普通操作,接着网关再拦截下由于网络问题导致的重复,然后业务层用一次性号码牌机制进行核心把关。最后,数据层再作为最终保障。

防止重复操作,要看是防止重复执行一个动作,还是防止重复执行一套连招。防止重复盖章很简单,但要防止重复办理开卡这整个任务包,就需要一个能在任务开始前就识别出来的总开关。

单CRUD 操作

判断一个操作是否幂等,最简单的方法就是问自己:“把这个操作执行1遍,和执行10遍,最终数据库中的数据是不是会有任何不同?”

  • 如果结果完全一样,那么它就是幂等的(安全的),或者说具备天然幂等性。
  • 如果结果不一样,那它就是不幂等的(危险的,需要特殊处理)。

现在,我们用这个标准来检查增(Create)、删(Delete)、改(Update)、查(Retrieve/Query)这四种基本操作。

  • 查询类动作:重复查询不会产生或变更新的数据,查询具有天然幂等性。
  • 新增类动作:不具备幂等性。
  • 基于主键的Delete具备幂等性。一般业务层面都是逻辑删除(即update操作),而基于主键的逻辑删除操作也是具有幂等性的。
  • 更新类动作:...(这里最复杂,我们分三种情况讨论)

更新类操作

更新操作是最微妙的,它是不是幂等,完全取决于我们怎么写SQL。

一种更新情况是非计算式赋值,也就是直接赋值。比如:

UPDATE goods SET number = 10 WHERE id = 1

即把ID为1的商品的库存数量直接设置为10,你执行一次,库存变为10,你再执行一次,它还是把库存设置为10。一万次之后,库存依然是10。结果一样,所以这种直接赋值式的更新是幂等的。


另一种更新情况是计算式更新,也就是在原值的基础上进行计算。比如:

UPDATE goods SET number = number - 1 WHERE id = 1

即把ID为1的商品的库存数量在当前值的基础上减1,假设库存原来是10,第一次执行,库存变成9,第二次执行,在9的基础上再减1,库存变成8。每执行一次,结果都会变。所以,这种“计算式”的更新是不幂等的。这是另一个我们需要重点防护的危险操作。


第三种情况是基于条件查询的更新,比如

UPDATE tickets SET owner = '张三' WHERE owner IS NULL LIMIT 1;

这个SQL的意思是找一张还没有主人的票,把它分给张三。第一次执行,系统找到了第一张空闲票,分给了张三。第二次执行,系统会找到第二张空闲票,又分给了张三。结果不一样, 张三得到了一张票 vs 张三得到了两张票。所以这种更新是不幂等的。

但如果SQL是UPDATE users SET level = 'VIP' WHERE consumption > 10000;,这个操作就是幂等的,因为它只是把所有满足条件的用户的等级设置为VIP,重复执行并不会改变最终结果。所以,基于条件的更新需要具体分析,看条件本身是否会因为操作的执行而改变

所以,对于单数据的CRUD操作,只需要在如下三个场景保证幂等即可:

  • 主键的计算式Update
  • 基于条件查询的Update
  • 新增类动作

多数据并发操作

多数据并发操作的幂等性问题,指的是防止你把一整套连环动作重复做一遍。这一套动作会同时影响到好几个东西,而且可能还分布在不同的地方。

  • ​单数据操作:通常指一个简单的、原子性的数据库命令,比如UPDATE users SET age = 31 WHERE id = 123;。它的影响范围很小,只涉及一行数据的一个或几个字段。
  • ​​多数据并发操作:这通常是一个完整的业务流程(Workflow)。这个流程由多个步骤组成,每个步骤都可能操作不同的数据表,甚至调用不同的微服务。

当这个完整的业务流程因为重试而被重复执行时,所引发的问题,就是我们所说的“多数据并发操作的幂等性问题”。

这里我们用大家最熟悉的下单和支付场景,看看具体会发生什么?

比如,一个高并发创建订单的场景,一个创建订单的业务流程,绝对不是简单地在订单表里加一条记录。它可能包含这样一套连环的动作节点:

  • 【动作1】 在 orders(订单表)里插入一条新订单记录。
  • 【动作2】 在 stock(库存表)里扣减对应商品的库存。
  • 【动作3】 如果用户用了优惠券,就要在 user_coupons(用户优惠券表)里把这张券标记为“已使用”。
  • 【动作4】 可能还要在 user_points(用户积分表)里给用户增加本次购物的积分。 ​​ 假设用户点击“提交订单”,请求发到服务器。服务器成功完成了【动作1】和【动作2】(创建了订单,也扣了库存),但就在准备执行【动作3】时,网络突然超时了,或者服务器恰好重启了。

再看一个场景,比如处理支付回调,这个场景更复杂,因为它涉及多个微服务之间的协作。

假设用户支付成功,支付网关(如支付宝)会回调我们的系统。我们的系统收到回调后,需要执行一套连环动作:

  • 【动作1】 调用支付服务,将支付流水状态更新为“成功”。
  • 【动作2】 调用订单服务,将订单状态更新为“已支付”。
  • 【动作3】 调用物流服务,通知仓库可以准备发货了,创建一个待发货任务。

支付网关的回调机制通常都有重试。如果我们的系统处理完回调,但在给支付网关返回“成功”响应时网络断了,支付网关就会认为我们没收到,于是它会再次发送同一个支付成功的回调通知。

如果系统没有幂等设计,那么第二次回调请求就会:

  • 又一次调用订单服务,尝试将订单状态更新为“已支付”(这个可能无害,但也是重复操作)。
  • 又一次调用物流服务,又创建了一个待发货任务。

结果就是,用户只付了一次钱,仓库却收到了两个发货指令,给用户发了两次货。这是巨大的资损。

多数据并发操作的幂等性的解决方案更复杂:不能依赖单一的数据库约束,必须在业务流程的入口处,使用像“全局唯一ID”、“分布式锁”、“状态机”等组合策略,来保证整个流程的唯一性。这也就是下面将要提到的“一锁二判三更新”的用武之地。

基础幂等性解决方案

基于上述的分析,这里先介绍五种非常经典且实用的幂等性实现技术。

全局唯一ID (The Universal Token)

核心思想是凭票入场,一票一用。每次执行一个危险操作(比如下单、支付),都必须先生成一张独一无二的“票”(全局唯一ID),这张票就代表了这一次的操作意图。流程如下:

操作开始前,先搞到一个全局唯一的ID。接着去一个“登记处”(比如Redis或数据库)查一下,这个ID是否已经被登记过。 ​如果没登记过,就说明是第一次来。先把这个ID登记下来,然后去执行真正的业务操作。​如果已登记过,说明是重复请求。直接拒绝,或者返回之前处理好的结果。

​这个方案的强大之处在于它的通用性,不管你是新增、更新还是删除,只要能给这个操作配上一个唯一ID,就能用这套方法来保证幂等。

那么这个凭证从哪来?也就是ID怎么获得?有以下几种方式:

  • UUID:简单粗暴,几乎不会重复,但太长、无序,不适合做数据库主键。
  • Snowflake(雪花算法):业界主流方案。生成的是趋势递增的64位整数,性能高,ID里还包含了时间戳和机器ID,很适合分布式系统。​
  • 数据库自增ID:只能在单个数据库实例中使用,不适合分布式环境。
  • 业务本身的唯一约束:比如订单号、支付流水号。这是最理想的,天然具有业务含义。
  • 业务字段+时间戳拼接:一种简化的自制方案,比如 userid_productid_timestamp。在并发量不高时可用,但有潜在的碰撞风险。

唯一索引 / 去重表 (The Database Gatekeeper)

这个方案是利用数据库自身提供的“不许重复”的强大约束来保证幂等。它特别适合处理新增(INSERT)操作。实现流程如下:

在数据表里,选一个必须唯一的字段(比如订单号order_no),然后给它创建一个唯一索引(UNIQUE INDEX)。​当要插入一条新订单时,直接执行INSERT语句。第一次请求:order_no不存在,INSERT成功。 第二次,带着相同的order_no再次INSERT,数据库会发现违反了唯一索引的规矩,直接抛出一个错误(Duplicate entry),操作失败并回滚。

当然,有时候我们不想在主业务表上增加额外的索引,或者幂等校验的逻辑比较独立,就可以专门建一张“去重表”。这张表结构很简单,可能就一个字段request_id(并设为唯一索引)。每次操作前,先往这张去重表里插一条记录,如果成功了就继续,如果失败了(说明重复)就停止。

实现简单,性能好,非常可靠,是防重复插入的首选方案。

Upsert - The Smart Insert

这个方案是“唯一索引”的变种和延伸,它在发现数据重复时,不是报错,而是执行更新操作。核心思想是“有则更新,无则插入”。

数据库提供了类似 INSERT ... ON DUPLICATE KEY UPDATE ... (MySQL) 或 INSERT ... ON CONFLICT ... DO UPDATE ... (PostgreSQL) 这样的原子命令。

比如关联商品和品类。一个商品只能属于一个品类一次。

INSERT INTO product_category (product_id, category_id) VALUES (101, 2) ON DUPLICATE KEY UPDATE category_id = 2;

如果,数据库里没有(product_id=101, category_id=2)这条记录,就插入。发现主键或唯一索引冲突了,它不会报错,而是执行UPDATE部分,把category_id再次更新为2。

因为多次执行的结果都是“product_id=101的商品和category_id=2的品类关联上了”,所以它是幂等的。这个方案在处理“关系映射”或“状态同步”这类场景时非常方便。

多版本控制 (Optimistic Locking - The Version Check)

这个方案主要用于解决更新(UPDATE)操作的幂等性问题,特别是前面提到的“计算式更新”(如number = number - 1)。它通过引入一个“版本号”来实现。

在数据表里增加一个version字段,默认值是0或1。当要更新数据时,先把这条数据连同它的version一起读出来。比如读到商品A的库存是10,version是5。执行更新时,在WHERE条件里带上刚刚读到的版本号。

UPDATE goods SET number = 9, version = 6 WHERE id = 1 AND version = 5;

(注意:更新成功后,version也要加1)

乐观锁机制,性能较高,是解决“ABA”问题和更新幂等性的经典方案。

状态机控制 (State Machine - The Orderly Flow)

这个方案利用业务流程中状态的单向流转特性来天然地实现幂等。流程只能向前,不能倒退或停留。

为业务实体(如订单)定义一套有序的状态,并且用数值大小来代表顺序。比如:待支付(10), 已支付(20), 已发货(30), 已完成(40)。在更新状态时,WHERE条件里要加上对当前状态的判断。

UPDATE goods_order SET status=#{new_status} WHERE id=#{id} AND status < #{new_status}

假设一个订单当前是待支付(10),现在收到了支付成功的消息,想把它更新为已支付(20)。

UPDATE goods_order SET status = 20 WHERE id = 123 AND status < 20;

第一次请求:当前status是10,满足status < 20,更新成功,状态变为20。它再次尝试执行这个SQL。但此时订单的status已经是20了,不满足status < 20的条件,所以更新失败(0 rows affected)。 这样,就利用状态的不可逆性,优雅地实现了幂等。

一锁二判三更新

最后我们来学习一个综合性的解决方案,不再是零散的技术点,而是将基础方案组织成一个健壮、可复用的处理范式(Pattern),尤其适用于高并发、业务逻辑复杂的场景。

规定了我们在面对一个复杂的,高并发的写操作时,应该先做什么?再做什么,最后做什么,以确保整个过程既高效又安全。

这个流程的核心目标是解决两个关键问题:

  • 并发问题:防止多个一模一样的请求同时冲进来,互相干扰。
  • 幂等问题:防止同一个请求因为重试而被处理多次。

第一步:Lock--抢占处理权

目的:序列化处理。在高并发场景下,对于同一个业务目标(比如“给订单A付款”),可能会瞬间涌入大量相同的请求。锁的作用就是确保在同一时刻,只有一个请求能够获得处理这个业务目标的“通行证”,其他请求要么等待,要么直接失败返回。它将并发问题变成了串行问题。

两个问题需要思考下:

为什么用Redis分布式锁?

  • 性能高:Redis基于内存,操作极快(纳秒/微秒级),远胜于基于磁盘的数据库锁(毫秒级)。
  • 可用性好 (AP):相比Zookeeper这种强一致性(CP)的锁,Redis通常部署为主从或集群模式,即使部分节点挂掉,通常还能提供服务,更适合需要高吞吐量的业务场景。而ZK为了保证强一致性,在网络分区时可能会暂时不可用。

如何应对锁性能瓶颈?

可以使用分段锁 (Segmented Lock)。这是一个非常高级并且常用的优化技巧。如果所有请求都来抢同一把锁(比如lock:order_123),这把锁就会成为热点。

分段锁的思想是化整为零:不再只用一把锁,而是准备一个“锁池”,里面有一堆锁。比如,我们可以准备100把锁,命名为 lock:order_123_0 到 lock:order_123_99。 当一个请求进来时,根据它的某个特征(比如用户ID、请求ID)做个哈希运算,hash(request_id) % 100,得到一个0到99之间的数字,然后去抢对应编号的锁。 这样,原本集中在一点的锁竞争压力,就被均匀地分散到了100个点上,系统的并发处理能力理论上可以线性提升近100倍。这是一种典型的空间换时间和分治思想。

第二步:Judge/CheckLock--双重保险检查

这一步的目的是确认操作的合法性。抢到锁只是获得了处理资格,不代表就应该执行操作。 在动手之前,必须进行严格的检查。这里的“二判”其实是一个概念,代表了至少两种维度的判断,通常是:

幂等性判断:这是核心。检查这个操作是不是已经被执行过了?

怎么判? 就是把前面介绍的基础方案用在这里。

  • 查去重表:SELECT COUNT(*) FROM deduplication_table WHERE request_id = '...'
  • 查业务状态(状态机):SELECT status FROM orders WHERE id = '...',看状态是否已经是“已支付”。
  • 查流水表:看是否存在对应的成功流水记录。
  • 如果判断结果是“已处理”,那么就直接释放锁,返回成功或之前的结果,流程结束。

业务规则判断(可选但常用):检查当前业务上下文是否满足执行条件。

  • 比如,支付操作前,要判断订单状态是否为“待支付”。如果订单已经因为超时被取消了,那就算幂等检查通过了(因为确实没支付过),也不能再支付了。
  • 再比如,扣库存前,要判断库存是否足够。

“二判”的精髓在于:它发生在加锁之后,真正执行业务逻辑之前。这确保了判断逻辑本身是在一个无并发干扰的环境下进行的,结果非常可靠。

第三步:Update--执行核心业务

这一步的目的是执行真正的业务逻辑并持久化结果。只有通过了前面所有的锁竞争和双重判断,才能走到这最后一步。

“三更新”也是一个概念,代表了多个数据的更新,通常包括:

  • 更新主业务数据:比如,修改订单表的状态为“已支付”。
  • 更新相关业务数据:比如,扣减库存、更新用户积分、核销优惠券等。这些可能涉及对其他表甚至其他微服务的调用。
  • 更新幂等凭证:这是为了让后续的重复请求在“第二步:幂等性判断”时能被挡住。比如,在去重表里插入request_id,或者更新支付流水表的状态为“成功”。

但要注意一个关键点:

  • 原子性:这一系列更新操作,最好能放在一个本地事务里,保证它们要么全都成功,要么全都失败。如果涉及跨服务调用,就需要引入分布式事务(如Seata、TCC、Saga)或基于最终一致性的可靠消息方案来保障。比如我们前面提到的事务发件箱模式。
  • 释放锁:所有操作完成后,必须在finally块中释放锁,确保无论成功还是异常,锁最终都能被解开,避免造成死锁。

最后,我们来回顾一下,幂等性是确保写操作执行N次与执行1次效果相同。这个听起来简单的问题,实际系统中可不是通过一些零散的技术就能解决,四层,用户前端,网关层,业务层,数据层都要有相应的解决策略。最核心的业务层,我们要分清要解决的是原子操作的幂等还是业务流程的幂等。能灵活运用五大基础方案(唯一ID、唯一索引、Upsert、乐观锁、状态机)。对于复杂流程,也要能熟练应用“一锁、二判、三更新”的范式。

Comments