幂等设计
分布式幂等问题解决方案三部曲
https://mp.weixin.qq.com/s/3PAM2bSFFaeIBCWMc_pftQ
服务端生成流水号来控制幂等性本身是一个常见的做法,并且在很多场景下是完全可行的。这样做可以确保每个请求都有一个全局唯一的标识,从而帮助识别和处理重复请求。但是,提出“为什么不能服务端来生成流水号来控制幂等”的问题,可能是指在某些特定情况下存在的挑战或限制,这些情况包括但不限于:
-
分布式环境的协调:在分布式系统中,如果多个服务节点独立生成流水号,需要确保这些编号的全局唯一性,这可能需要复杂的分布式协调机制,比如使用分布式ID生成服务(如Twitter的Snowflake算法)。
-
一致性问题:服务端生成流水号并依赖它来确保幂等时,必须确保这个过程是原子的,即生成流水号和处理请求必须作为一个不可分割的操作。在高并发环境下,维护这种原子性和顺序性可能很复杂。
-
客户端行为:如果客户端不正确地处理流水号(例如丢失、重复使用或误用),即使服务端生成了流水号,也无法保证幂等性。需要客户端的配合,确保每次请求都携带正确的流水号。
-
性能考量:生成全局唯一且递增的流水号可能会对服务端造成额外的性能负担,尤其是在高吞吐量的系统中。虽然这通常不是主要障碍,但在设计时仍需考虑。
-
故障恢复:如果服务在生成流水号后但在完成业务逻辑前发生故障,需要有机制确保故障恢复后幂等性的维持,这可能涉及到事务管理、日志记录和复杂的状态恢复策略。
综上所述,服务端生成流水号控制幂等并非不可行,而是需要细致设计以应对上述挑战。在实践中,结合使用如数据库主键、乐观锁、悲观锁、分布式锁或基于Token的机制等,可以有效地解决这些问题,实现高效可靠的幂等控制。
客户端生成流水号来控制幂等也是可行的方案之一(建议用这种 ,
在服务端 数据表设计时,设计两个字段:source、reqNo,source表示调用方,seqNo表示调用方发送过来的请求号。source和reqNo设置为组合唯一索引,保证单据不会重复落两次。如果调用方没有source和reqNo这两个字段,可以根据业务实际情况将请求中的某几个业务参数生成一个md5作为唯一性字段落到唯一性字段中来避免重复落库。)
它有其特定的优势和应用场景,但也伴随着一些挑战:
客户端生成流水号的优点:
-
简化服务端逻辑:将生成唯一标识的任务交给客户端,可以减少服务端的负担,尤其是对于分布式系统来说,不需要集中处理ID生成,降低了系统的复杂度。
-
提高可用性:客户端生成流水号意味着服务端对这个过程的依赖降低,即使服务端部分节点故障,只要客户端拥有唯一标识,理论上仍然可以发起幂等请求。
-
减轻网络开销:在某些场景下,如果服务端生成流水号需要额外的网络往返(如请求ID生成服务),客户端自动生成可以减少这种开销。
客户端生成流水号的挑战:
-
全局唯一性:客户端需要确保生成的流水号具有全局唯一性,这在分布式客户端环境中可能比较困难,需要客户端之间有一定的协同机制或使用时间戳、UUID等技术来辅助生成唯一ID。
-
客户端的责任加重:要求客户端正确管理幂等标识,包括生成、存储(必要时)和在重试时准确传递,增加了客户端实现的复杂度,也容易因客户端实现不当导致问题。
-
安全性考虑:客户端生成的流水号可能需要服务端验证其有效性,防止恶意构造的流水号被用于攻击或滥用,增加了安全验证的需求。
-
一致性问题:客户端和服务器之间可能存在数据不同步的问题,特别是在网络延迟或请求失败重试的情况下,需要有机制确保幂等逻辑的一致性执行。
总的来说,是否选择客户端生成流水号来控制幂等,取决于具体的应用场景、系统架构、性能需求以及对可用性和一致性的要求。实际应用中,有时也会采用混合策略,比如客户端生成请求ID作为初步标识,服务端根据业务逻辑进一步处理或转换为内部唯一标识,以达到更好的控制效果。
Token机制是一种常用的策略,用于识别和阻止前端的重复请求,从而防止例如表单重复提交等问题。以下是使用Token机制的基本流程和原理:
-
生成Token: 当服务器准备响应客户端的请求(通常是GET或POST请求页面)时,服务器会生成一个唯一的Token字符串。这个Token可以是随机字符串、时间戳加密串或者是基于某种算法生成的唯一标识。Token通常具有一定的时效性。
-
发送Token到前端: 生成的Token会通过页面加载时嵌入到HTML中,或者作为API响应的一部分直接发送给前端。前端接收到Token后,通常会将其存储在JavaScript变量中,或者作为隐藏字段嵌入到表单中。
-
前端携带Token提交请求: 当用户提交表单或进行其他需要控制重复提交的操作时,前端会将Token作为请求的一部分发送回服务器。这通常通过HTTP POST请求的参数、HTTP头部(如
X-CSRF-Token
)或Cookie来实现。 -
服务器验证Token: 服务器接收到请求后,会检查请求中携带的Token与服务器端(如存储在Session中)对应的Token是否匹配,以及是否过期。如果匹配且未过期,则认为请求是首次提交,处理该请求;如果不匹配或已过期,则认为是重复请求,拒绝处理。
-
处理请求与更新Token: 一旦服务器确认请求有效并完成处理,通常会执行以下操作:
- 删除或更新当前Session中的Token。
- 如果需要,生成新的Token,并在响应中返回给前端,以便前端更新存储的Token,为下一次请求做准备。
通过上述机制,即使用户快速连续点击提交按钮,后续的请求因为携带的是已经处理过的Token,会被服务器拒绝,从而有效避免了重复处理同一请求的问题。此机制不仅适用于Web表单,也广泛应用于API调用等场景,是保障数据一致性和系统稳定性的有效手段。
数据库乐观锁/悲观锁:在更新数据库记录时,使用乐观锁或悲观锁机制来防止并发更新问题,确保同一笔数据不会被重复修改。
悲观锁机制: 悲观锁持有一种保守的态度,假定在数据处理过程中会发生并发冲突,因此在数据被读取或修改前就将其锁定,阻止其他事务并发访问。这种方式可以有效避免并发冲突,但可能会增加系统的锁争用,导致性能下降,甚至产生死锁。在数据库中,悲观锁通常通过select ... for update语句实现,或者在应用程序层面使用如Java中的synchronized
关键字或Lock
接口来实现同步控制。
乐观锁机制: 乐观锁则持有较为乐观的态度,假设数据一般不会发生并发冲突,只在数据提交更新时检查在此期间是否有其他事务修改了数据。乐观锁不实际锁定数据,而是在数据表中增加一个版本字段(version)或者时间戳字段,当事务准备更新数据时,(1)会先读取该字段(版本号/时间戳)的值,(2)更新时将此值与之前读取的值进行比较,如果值相同则允许更新,否则拒绝更新或重新读取数据再尝试
。
如果在更新时发现版本号(或时间戳)已经改变,说明数据已被其他事务修改过,这时更新操作会被放弃,通常会抛出一个异常或者返回一个错误码,告知调用者数据已发生冲突,需要重新读取数据并重试整个操作。
这种机制确保了每次更新都是基于最新且未被更改的数据进行的,从而在不频繁加锁的情况下维护了数据的一致性。然而,这也意味着在高并发且冲突频发的场景下,可能需要多次重试才能完成更新,影响系统的吞吐量。因此,选择乐观锁还是悲观锁,需要根据具体的应用场景和并发特性来决定。
乐观锁适用于读多写少的场景,能够减少锁的开销,提高系统的并发能力。在Java中,乐观锁的一种典型实现是使用Compare-And-Swap (CAS) 原子操作。
总结来说,悲观锁倾向于牺牲一定的并发性能来保证数据的一致性,而乐观锁则在保持较高并发性的同时,通过数据版本控制等机制来保证数据的一致性,选择哪种锁机制需根据实际应用场景和并发特性来决定。