分布式系统健壮性核心:请求防重与接口幂等性是确保数据一致、操作正确的关键。文章详解唯一ID、分布式锁、状态机等策略,助你构建高可用系统。
原文标题:分布式系统的防重和幂等实现机制
原文作者:牧羊人的方向
冷月清谈:
**请求防重**旨在识别并丢弃重复请求,确保业务逻辑仅被有效执行一次。文章介绍了多种实现策略:
* **唯一请求ID与防重表**:利用全局唯一ID和数据库唯一约束,确保请求原子化处理。其优势是可靠性高,但可能成为数据库性能瓶颈。
* **分布式锁**:通过锁定关键业务资源,控制并发请求对同一资源的修改。适用于处理中状态的防重,但增加了系统复杂性。
* **令牌机制**:预先生成一次性令牌,用于防止前端重复提交,实现简单,但需前后端配合。
* **状态机约束**:利用业务对象明确的生命周期和状态流转,保证操作合法性与唯一性。逻辑清晰且可靠,但适用场景有限。
**接口幂等性**是指一个操作无论执行一次还是多次,其产生的影响和结果都是相同的,这对于应对网络重试至关重要。文章阐述了多种实现策略:
* **数据库唯一约束**:通过建立唯一索引,防止重复数据的插入,主要适用于INSERT操作。
* **乐观锁/版本号机制**:为数据添加版本字段,在更新时检查版本号,有效解决UPDATE操作的幂等性问题。
* **全局幂等令牌**:在业务操作前生成全局唯一令牌,通过原子化检查与锁定,保证操作的幂等。优点是灵活且不侵入核心业务表结构,可返回缓存结果,但依赖分布式缓存。
* **状态机流转控制**:严格控制业务实体的状态流转,只有在特定状态下才允许执行特定操作,确保操作的影响是唯一的,与防重中的状态机原理一致,但更加强调对最终状态的影响。
综上所述,请求防重是保障数据正确性的基础,接口幂等性则是确保重试操作不出错的核心。在分布式系统设计中,综合应用这些策略,是构建健壮、高可用系统的关键。
怜星夜思:
2、如果分布式系统的防重或幂等机制失效了,会带来哪些严重后果?我们应该如何在系统设计和运维层面,提前预防或者在失效后进行有效的补救呢?
3、文章区分了防重和幂等。在实际的业务开发中,我们是先实现防重再考虑幂等,还是两个同时考虑?有没有这样一种情况,某个场景我们只用防重就够了,或者只需要幂等就OK了?能举个例子说明吗?
原文内容
随着微服务与云原生技术的普及,分布式系统已成为现代软件架构的主流。然而,系统的分布式特性也带来了新的挑战,尤其是在网络延迟、请求重试、并发操作等复杂场景下,如何保证数据的一致性、操作的正确性以及服务的可用性,成为了系统设计的核心难点。本文将介绍分布式架构下的2个关键稳定性保障机制:请求防重和接口幂等性。
1、分布式请求防重策略与技术
在分布式环境中,由于客户端重试、网络抖动、网关超时重发或消息队列的重投机制,同一个请求可能会多次到达服务端。请求防重的核心目标是识别并丢弃这些重复的请求,确保一个业务逻辑在面对完全相同的多次请求时,仅被有效执行一次。这不仅可以避免数据冗余和错误,也是实现接口幂等性的重要前提。
这是一种基于数据库或持久化存储的强一致性防重方案,其核心思想是为每一次业务操作生成一个全局唯一的标识符,并在处理前进行校验。
在技术实现上要求调用方在发起请求时,携带一个全局唯一的请求ID。服务端在接收到请求后,首先会尝试将这个请求ID插入到一个专用的“防重表”中。这个表的关键在于为请求ID字段设置了唯一性约束。具体流程如下:
-
生成ID:客户端或上游服务在发起写操作请求前,生成一个全局唯一的request_id。
-
携带ID请求:客户端将request_id连同业务参数一同发送至服务端。
-
原子化插入校验:服务端在执行核心业务逻辑之前,尝试执行INSERT INTO deduplication_table (request_id) VALUES (?)。
-
结果判断与处理:
a) 如果插入成功,说明这是一个新的请求。服务端继续执行后续的业务逻辑。
b) 如果插入失败并抛出唯一键冲突的异常,则证明该request_id已被处理过。服务端捕获此异常,识别为重复请求,直接丢弃或返回先前处理的结果,不再执行业务逻辑 。
该技术的优势是在于其极高的可靠性,能够利用数据库的ACID特性,从根本上保证数据操作的唯一性。缺点就是每次请求都需要进行一次数据库写入操作,那么数据库容易成为整个系统的性能瓶颈,尤其是在高并发场景下 。同时,需要考虑防重表的清理机制,以避免其无限增长。通常不会单独设计防重表,而是在表设计的时候定义唯一键做防重。
分布式锁可以有效控制并发,通过确保在分布式系统的多个节点中某个关键代码块在同一时间只能被一个线程执行,从而间接实现请求防重。
实现原理也很简单,当请求到达时,服务端会根据请求中的业务关键信息组合成一个唯一的锁key。然后,服务尝试去获取这个key对应的分布式锁。具体流程如下:
-
构造锁Key:服务端根据请求参数生成一个唯一的锁标识,例如 lock:payment:order_id_123。
-
尝试加锁: 服务尝试使用Redis的SETNX命令或Zookeeper的临时节点等机制来获取该锁 。
-
结果判断与处理:
a) 如果成功获取锁,表明当前没有其他请求在处理此项业务。服务端开始执行业务逻辑,并在完成后释放锁。
b) 如果获取锁失败,则说明已有另一个线程或进程正在处理,当前请求被视为重复,应立即返回或丢弃
分布式锁的核心优势在于控制并发,特别适合“处理中”状态的防重,防止对同一资源的并发修改。技术实现上可以通过Redis,也可以通过Zookeeper的临时节点完成。但其缺点是增加了系统的复杂性,需要处理锁的超时、续期和安全释放等问题,否则可能导致死锁。锁的粒度设计也至关重要,过大的粒度会降低系统吞吐量。
令牌机制主要用于防止客户端因用户误操作(如快速点击提交按钮)或前端逻辑不完善导致的表单重复提交。在实现上分为两个阶段:获取令牌和使用令牌。服务端为即将发生的写操作预先生成一个一次性的、唯一的令牌。具体流程如下:
-
申请令牌:用户访问表单页面时,客户端向服务端发起一个获取令牌的请求。
-
颁发并存储令牌:服务端生成一个唯一Token(如UUID),将其存储在Redis等高速缓存中并设置较短的过期时间,然后将Token返回给客户端 。客户端通常将此Token存放在表单的隐藏域中。
-
提交时携带令牌:用户提交表单时,请求中必须携带此Token。
-
原子化验签与销毁:服务端接收请求后,会使用原子操作(如Redis+Lua脚本)来验证并删除该Token。
a) 如果Token存在且被成功删除,则处理业务逻辑。
b) 如果Token不存在(已被其他请求消耗或已过期),则判定为重复提交,拒绝处理 。
令牌机制能有效拦截来自前端的重复请求,实现简单。但它要求前端进行配合,增加了前后端的交互次数。
对于有明确生命周期和状态流转的业务对象(如订单、工单),可以利用状态机模型来实现防重。其核心思想是业务操作必须遵循预设的状态流转路径。任何不符合当前状态的迁移动作都被视为非法或重复操作。具体流程如下:
-
定义状态:为业务对象定义清晰的状态集(如订单状态:待支付、已支付、已发货、已完成、已取消)。
-
接收操作请求:服务端接收到一个改变状态的请求,例如“支付订单”。
-
校验当前状态:在执行操作前,从数据库或缓存中读取订单的当前状态。
-
判断状态迁移合法性:
a) 如果当前订单状态是“待支付”,则“支付”操作是合法的。服务端继续执行支付逻辑,并将状态更新为“已支付”。为了防止并发下的状态冲突,通常会结合乐观锁(版本号)进行更新。
b) 如果当前订单状态已经是“已支付”,则再次收到的“支付”请求就是重复请求,应直接拒绝 。
状态机方案与业务逻辑紧密结合,逻辑清晰,实现优雅且非常可靠 。它不仅能防重,还能保证业务流程的正确性。其主要局限在于适用场景,仅限于那些可以被清晰地建模为有限状态机的业务流程。
2、分布式接口幂等性实现策略与技术
幂等性是一个数学概念,是指一个操作无论执行一次还是执行多次,其产生的影响和结果都是相同的。在分布式系统中,由于网络不可靠导致的重试是常态,保证写操作的幂等性对于避免数据错乱、资金损失等严重问题至关重要。
通过在数据库表上为能够唯一标识业务的字段(或字段组合)建立唯一索引,来利用数据库自身的机制阻止重复数据的插入。具体流程如下:
-
识别唯一业务键:在设计表结构时,确定一个能唯一标识一笔交易或一个实体的字段。
-
创建唯一索引:为该字段创建UNIQUE INDEX。
-
执行插入操作:当服务需要创建一个新记录时,直接执行INSERT。
-
处理执行结果:
a) 第一次请求,INSERT成功,数据被创建。
b) 后续的重试请求,由于transaction_id已存在,INSERT会失败,数据库返回唯一键冲突错误。应用层捕获此错误后,即可判定这是一个重复的创建操作,从而保证了“创建”这一行为的幂等性 。
该方案简单、高效,且保证了最终一致性,但它主要适用于INSERT场景。对于UPDATE操作,需要借助其他策略。同时,在高并发写入场景下,唯一索引的冲突检查可能会对数据库性能造成一定压力。
乐观锁是实现UPDATE操作幂等性的经典方案。它假设在操作期间数据不会被其他事务所修改,直到提交时才进行检查。该策略通常通过在数据表中增加一个version(版本号)或timestamp(时间戳)字段来实现。具体流程如下:
-
读取数据与版本号:SELECT data, version FROM my_table WHERE id = ?;。
-
执行业务计算:在内存中根据读取的data进行业务逻辑计算。
-
带版本号更新:提交更新时,在UPDATE语句的WHERE子句中加入对版本号的检查:UPDATE my_table SET data = ‘new_data’, version = version + 1 WHERE id = ? AND version = ‘old_version’;。
-
检查更新结果:
a) 如果UPDATE影响的行数为1,说明在操作期间没有其他请求修改过数据,更新成功。
b) 如果影响的行数为0,说明version已被其他请求改变,当前操作基于的是旧数据。此时,可以判定为幂等冲突(或并发冲突),应放弃本次修改或重新读取数据重试 。
乐观锁避免了悲观锁长时间的资源锁定,因此在高并发读多写少的场景下有很好的性能表现。它能有效解决UPDATE操作的幂等问题。缺点是增加了业务逻辑的复杂性,应用层需要处理更新失败后的重试逻辑。
该策略将幂等校验逻辑与业务逻辑解耦,适用于INSERT、UPDATE和DELETE等多种操作。这里的令牌代表的是一个完整的业务操作,而不仅仅是一次HTTP提交。
-
生成令牌:调用方(客户端或其他服务)在发起一个需要保证幂等的业务操作前,需生成一个全局唯一的幂等令牌idempotency_key。
-
携带令牌请求:将idempotency_key通过请求头或请求体传递给服务端。
-
原子化检查与锁定:服务端收到请求后,以idempotency_key为键,使用原子命令(如Redis的SET key value NX EX)尝试在共享缓存中创建一个占位记录。
-
处理流程:
a) 首次请求:SET成功,表明这是此idempotency_key的第一次请求。服务开始执行业务逻辑。执行完毕后,可以将执行结果存入缓存,与idempotency_key关联,并为该key设置一个更长的过期时间 。
b) 重试请求:SET失败,表明该idempotency_key已存在。服务端可以直接从缓存中查询并返回上一次的执行结果,从而保证了幂等性 。
该方案非常灵活,不侵入核心业务表的结构,且能够通过返回缓存结果来优化重试请求的体验。它依赖于一个高可用的分布式缓存系统(如Redis)。令牌的生成、传递和存储管理也引入了额外的系统复杂性
通过严格控制业务实体的状态流转,确保操作只能在特定状态下执行,从而实现幂等。这与防重部分的状态机原理一致,但在幂等性语境下,更强调操作对最终状态的影响是唯一的。一个操作是否执行,取决于业务实体当前的状态是否允许该操作发生。
-
加载实体与状态:接收到操作请求后,加载业务实体及其当前状态。
-
验证状态转移:根据预定义的状态机模型,判断当前状态是否允许执行请求的操作。例如,只有在“待发货”状态下,才能执行“发货”操作。
-
执行与状态更新:
a) 如果状态转移合法,则执行业务操作,并原子性地(通常结合乐观锁)将实体更新到下一个状态。
b) 如果状态转移非法,则直接拒绝操作,返回错误信息。因为无论多少次非法的操作请求,都不会改变实体的当前状态,从而保证了幂等性 。
状态机是与业务领域模型高度耦合的幂等实现方式,逻辑严谨,一旦模型建立,幂等性就有了天然的保障。其缺点是适用范围有限,主要用于具有明确、有限状态的业务流程。对于无状态的通用接口,此方法不适用。
分布式系统的健壮性需要将防重和幂等作为架构设计的核心考量。请求防重是数据正确性的保障,通过唯一ID、分布式锁、令牌和状态机等手段,可以有效过滤掉意外的重复请求;接口幂等性保证了在不可靠网络环境下,利用数据库约束、乐观锁、全局令牌和状态机,可以确保重试操作不会产生非预期的副作用。




