业务幂等性设计的六种方案

本文已收录至GitHub,推荐阅读 👉 Java随想录

微信公众号:Java随想录

导读:现如今很多系统都会基于分布式或微服务思想完成对系统的架构设计。那么在这一个系统中,就会存在若干个微服务,而且服务间也会产生相互通信调用。

那么既然产生了服务调用,就必然会存在服务调用延迟或失败的问题。当出现这种问题,服务端会进行重试等操作或客户端有可能会进行多次点击提交。在存在重复请求的场景中(如支付交易),为确保系统最终处理结果的一致性并避免资损风险,必须通过业务幂等性设计保障数据操作的唯一性。

什么叫幂等

幂等(Idempotence) 是计算机科学和分布式系统中的核心概念,指在特定上下文中,对同一操作进行多次执行所产生的影响,与仅执行一次该操作的影响完全相同。无论该操作被调用一次还是多次,系统的最终状态始终保持一致,资源状态或业务结果不会因为重复调用而发生额外改变。

幂等用数学语言表达就是:f(f(x))=f(x)

在分布式系统和网络通信中,幂等性尤为重要,尤其是转账、支付等涉及金额交易的场景,如果出现幂等性的问题,造成的后果是非常严重的。

事故:转账无幂等、交易无幂等、发优惠券无幂等,都会造成不小的事故

幂等性设计主要从两个维度进行考虑:空间、时间

  • 空间:定义了幂等的范围,如生成订单的话,不允许出现重复下单。
  • 时间:定义幂等的有效期。有些业务需要永久性保证幂等,如下单、支付等。而部分业务只要保证一段时间幂等即可。

业务问题抛出

在业务开发与分布式系统设计中,有非常多的场景需要考虑幂等性的问题,如:

  • 当用户购物进行下单操作,用户操作多次,但订单系统对于本次操作只能产生一个订单。
  • 当用户对订单进行付款,支付系统不管出现什么问题,应该只对用户扣一次款。
  • 当支付成功对库存扣减时,库存系统对订单中商品的库存数量也只能扣减一次。
  • 当对商品进行发货时,也需保证物流系统有且只能发一次货。

但是一旦考虑幂等后,服务逻辑务必会变的更加复杂。因此是否要考虑幂等,需要根据具体业务场景具体分析。

此处以下单减库存为例,当用户生成订单成功后,会对订单中商品进行扣减库存。 订单服务会调用库存服务进行库存扣减。库存服务会完成具体扣减实现:

如果出现调用超时,如网络抖动,虽然库存服务执行成功了,但结果并没有在指定时间内返回,则订单服务会进行重试。那就会出现问题,此时出现库存扣减两次的问题。 对于这种问题,就需要考虑幂等性设计。

幂等设计实现

方案一:数据库唯一索引

在保存数据前,可以先 select 一下数据是否存在。如果数据已存在,说明是重复数据,则不再写入数据,如果数据不存在,则执行 insert 操作。如果 insert 成功,则直接返回成功,如果 insert 产生主键冲突异常,则捕获异常进行处理。

但在高并发的场景下,可能会出现两个请求 select 的时候,都没有查到数据,然后都执行了 insert 操作,所以此时会有重复数据产生,因此在数据库中,我们需要添加唯一索引来保证幂等,唯一索引是不会引起重复数据的兜底策略

方案二:防重表机制

防重表机制与唯一索引机制是相同的原理,只不过是单独建一个防重表,防重表也必须引入唯一索引,而且防重表与业务表必须在同一数据库,并且操作要在同一个事务中。

防重表机制的主要流程:把唯一主键插入防重表,再进行业务操作,且它们处于同一个事务中。当重复请求时,因为防重表有唯一约束,导致请求失败,可以避免幂等问题。

注意防重表和业务表应该在同一个库中,这样就保证处在一个事务中,即使业务操作失败,也会把防重表的数据回滚。保证了数据的一致性。

该方案也是比较常用的,防重表跟业务无关,很多业务可以共用同一个防重表,只要规划好唯一主键即可。

方案三:数据库乐观锁

乐观锁实现的方式有两种:基于版本号、基于条件。但是实现思想都是基于行锁来实现的。

基于版本号实现

通过为表增加一个 "version" 字段来实现。读取出数据时,将此版本号一同读出,之后更新时,对此版本号加一。此时,将提交数据的版本号与对应记录的当前版本号进行比对,如果提交的版本号等于当前版本号,则予以更新,否则认为是过期数据。

基于条件实现

版本号控制在并发场景中虽然能保证数据一致性,但在高并发库存扣减的场景下存在体验问题:当多个用户同时查询到可售库存后,只有基于版本号的最新请求能扣减成功,这会导致一些用户看似有库存却最终下单失败。

从业务角度而言,只要确保库存实际不发生超卖即可,此时更推荐直接通过数据库条件控制:

SQL 复制代码
update tb_stock set amount=amount-#{num} 
where goods_id=#{goodsId} and amount-#{num}>=0"

总结:在竞争不激烈,出现并发冲突几率较小时,推荐使用乐观锁。但是,乐观锁的每次冲突检测都需要与数据库交互,频繁的更新操作仍会对数据库产生一定压力。此外,在高并发场景下,大量事务竞争可能导致数据库连接池耗尽或成为性能瓶颈。

方案四:悲观锁

悲观锁的实现,往往依靠数据库提供的锁机制,具有强烈的独占和排他性。

通过 for update 可以实现排它锁;

SQL 复制代码
select * from account where id = 123 for update;

悲观锁在同一事务操作过程中,锁住了一行数据。别的请求过来只能等待,如果当前事务耗时比较长,就很影响接口性能。所以一般不建议用悲观锁做这个事情。

方案五:防重 Token 令牌

采用 Token 机制确保幂等性是一种广泛应用的解决方案,能够覆盖绝大多数业务场景。该方案通过前后端协作实现。此方案包含两个请求阶段:

  1. 客户端请求服务端申请获取 token。
  2. 客户端携带 token 再次请求,服务端校验 token 后进行操作。

整体流程如下:

  1. 服务端提供获取 token 接口,供客户端进行使用。服务端生成 token 后,如果当前为分布式架构,将 token 存放于 redis 中(一般会设置一个过期时间),如果是单体架构,可以保存在本地缓存。
  2. 当客户端获取到 token 后,会携带着 token 发起请求。
  3. 服务端接收到客户端请求后,首先会判断该 token 在 redis 中是否存在。如果存在,则完成进行业务处理,业务处理完成后,再删除 token。如果不存在,代表当前请求是重复请求,直接向客户端返回对应标识。

存在问题

但是现在有一个问题,当前是先执行业务再删除 token。在高并发下,很有可能出现第一次访问时 token 存在,完成具体业务操作。但在还没有删除 token 时,客户端又携带 token发起请求,此时,因为 token 还存在,第二次请求也会验证通过,执行具体业务操作。

针对该问题,我们提出两种解决方案进行探讨:

第一种方案:对于业务代码执行和删除 token 整体加线程锁。 当后续线程再来访问时,则阻塞排队。

第二种方案:借助 redis 单线程和 incr 是原子性的特点。当第一次获取 token 时,以 token 作为 key,对其进行自增。然后将 token 进行返回,当客户端携带 token 访问执行业务代码时,对于判断 token 是否存在不用删除,而是对其继续 incr。 如果 incr 后的返回值为 2。则是一个合法请求允许执行,如果是其他值,则代表是非法请求,直接返回。

前面提到的都是先执行业务再删除 token,那如果先删除 token 再执行业务呢?其实也会存在问题,假设具体业务代码执行超时或失败,没有向客户端返回明确结果,那客户端就很有可能会进行重试,但此时之前的 token 已经被删除了,则会被认为是重复请求,不再进行业务处理

这种方案无需进行额外处理,一个 token 只能代表一次请求。 一旦业务执行出现异常,则让客户端重新获取令牌,重新发起一次访问即可。推荐使用先删除 token 方案

但是无论先删 token 还是后删 token,都会有一个相同的问题。每次业务请求都会产生一个额外的请求去获 token。但是,业务失败或超时,在生产环境下,一万个里最多也就十个左右会失败,那为了这十来个请求,让其他九千九百多个请求都产生额外请求,就有一些得不偿失了。虽然 redis 性能好,但是这也是一种资源的浪费。

方案六:分布式锁

分布式锁实现幂等性的逻辑就是,请求过来时,先去尝试获得分布式锁,如果获得成功,就执行业务逻辑,反之获取失败的话,就舍弃请求直接返回成功。

分布式锁可以使用 Redis,也可以使用 ZooKeeper,Redis 相对来说会更加轻量级。

Redis 分布式锁,可以使用命令SETNX + 唯一流水号 实现,分布式锁的 key 必须为业务的唯一标识。

Redis 执行设置 key 的动作时,要设置过期时间,这个过期时间不能太短,太短拦截不了重复请求,也不能设置太长,会占存储空间。

相关推荐
无名指的等待71215 分钟前
SpringBoot实现一个Redis限流注解
spring boot·redis·后端
张志翔的博客35 分钟前
RK3588 openssl-3.4.1 编译安装
开发语言·后端·scala
计算机-秋大田38 分钟前
基于Spring Boot的小区疫情购物系统的设计与实现(LW+源码+讲解)
java·vue.js·spring boot·后端·课程设计
李长渊哦1 小时前
Spring Boot 约定大于配置:实现自定义配置
java·spring boot·后端
Asthenia04121 小时前
分析 Full GC 如何排查:详细步骤指南
后端
dmy1 小时前
go最便捷的http请求包resty
后端·http
白晨并不是很能熬夜2 小时前
【JVM】性能监控与调优概述篇
java·jvm·经验分享·后端·面试·求职招聘
coderZT2 小时前
Django REST Framework 中 ModelViewSet 的接口方法及参数详解,继承的方法和核心类方法,常用查询方法接口
后端·python·django
总是学不会.2 小时前
什么是 MyBatis?
java·后端·mybatis·开发
努力的搬砖人.2 小时前
Kafka相关的面试题
java·后端·kafka