这是DDD&微服务系列的第17篇,欢迎持续关注~
1. 幂等概述
1.1 深入理解幂等性
在计算机领域中,幂等(Idempotence)是指任意一个操作的多次执行总是能获得相同的结果,不会对系统状态产生额外影响。在Java后端开发中,幂等性的实现通常通过确保方法或服务调用的结果具有确定性,无论调用次数如何,结果都是可预期的。
上面的定义是目前大多数文章和书籍对幂等的描述,然而,在实际的互联网服务开发中,幂等性的理论定义与业务逻辑间的冲突是常见的。
例如,考虑查询操作,当A系统调用B系统的查询接口时,如果首次调用由于B系统中的程序错误而导致业务逻辑失败,即使在程序修复后系统A重新使用相同参数进行重试,B系统可能仍然返回相同的失败响应。尽管这符合幂等性的定义,却与实际业务逻辑不符。同样,以订单支付为例,首次调用由于账户余额不足而返回"余额不足"提示,用户充值后再次使用相同参数发起支付请求,服务仍然返回"余额不足"响应,也符合幂等性的定义,但同样不符合业务逻辑。
因此,在实现幂等性方案时,应该遵循幂等性方案的目标,而不仅仅是严格遵循幂等性的定义。尤其是涉及写操作的服务,应当更关注防止重复请求带来的不良副作用,例如重复扣款或退款。
1.2 幂等性的必要性
在微服务和分布式架构中,一个请求可能需要多个服务协作才能完成。在这个过程中,网络抖动、系统运行异常等不确定因素使得请求的成功率不可能达到100%,一旦发生失败或未知异常,最常见的处理方式就是重试,而重试必然会导致重复请求问题。
幂等设计主要是为了处理重复请求而生的,好的幂等方案可以保证重复请求获得预期结果,而不产生副作用。 在实际开发中,以下场景会产生重复请求:
- 用户不可靠:用户通过客户端发起请求,由于手抖或有意重复点击,很容易造成导致极短时间内发起多次重复请求。
- 网络不可靠:网络抖动、网关内部抖动有可能触发重试机制,这个在使用消息队列投递消息时经常会遇到。
- 服务不可靠:在需要保证数据一致性的场景中,如果调用下游服务超时,在无法确认执行结果的情况下,常用的处理方法是重试。
在我的SpringCloud微服务专栏《使用RocketMQ实现分布式事务》一文中就遗留了个幂等导致的bug,由于消费端没做幂等处理,所以在整个消息链路中,如果broker没有收到消费者发送的ack响应就会发起重试,从而产生数据一致性问题。
1.3 幂等与并发的关系
在具有并发写操作的场景下,通常需要考虑幂等问题。例如,当用户在极短时间内多次提交表单或者使用特殊手段同时提交多个表单时,这就是典型的并发场景,需要进行幂等性处理。为了防止重复请求被执行,服务端需要实施幂等性控制,以避免产生不符合预期的结果。
虽然并发场景大都存在幂等问题,但幂等问题却并非并发场景所特有。幂等设计是为了识别并处理重复请求,而并发仅仅是重复请求的一种特殊情况。 事实上,只要重复请求涉及写操作,无论是否并发,都需要做好幂等处理。举个例子,用户在pc端同时开了两个窗口,间隔10分钟分别提交表单,所有参数完全相同,这显然不属于并发,但仍需要进行幂等处理。
在互联网领域,并发处理与幂等性问题紧密相关,这也导致了一些人认为解决幂等性就是解决高并发的问题。
2. 幂等号的设计
幂等性设计的目的是确保即使在多次接收相同请求的情况下,也只执行一次操作,防止重复处理。要实现这一点,通常需要事先约定一个具有唯一性的标识符,如Token或业务流水号,我们称之为幂等号(Idempotency Key)。
幂等号有三个关键特性:唯一性、不变性和传递性。
唯一性确保每个请求都能被准确识别,不变性保证在请求处理期间幂等号保持不变,传递性则确保在多系统处理同一请求时,幂等号能够被传递和保持。
幂等号通常有两种设计方式:
-
非业务幂等号:通过唯一标识符(如UUID、时间戳或业务流水号)在调用方和被调用方之间明确实现幂等性。由于非业务幂等号难以通过业务上下文追溯,因此调用双方都必须将其持久化,从而保证请求与幂等号的关系有迹可循。
例如,在DailyMart案例中,订单服务在调用库存服务时会传递订单流水号作为幂等号,以便在多次请求时识别重复操作。
-
业务幂等号:由业务元素组合构成的幂等号,如"用户ID+活动ID"。使用此方法时,调用方无需单独持久化幂等号,被调用方可以根据请求参数和业务上下文直接获取并组合这些参数。例如,通过设置"用户ID"和"活动ID"的联合唯一索引来实现幂等性。
3. 幂等的实现方案
幂等性的实现关键在于确保相同的请求仅被处理一次,这通常可以通过设置唯一性约束和检查来实现。实践中有六种常见的方案:唯一索引、Token机制、悲观锁、乐观锁、分布式锁和状态机。
3.1 唯一索引方案机制
唯一索引方案依赖于数据库表中不允许存在具有相同索引值的重复行。这种策略在关系型数据库中广泛支持,并且能有效利用唯一性约束来确保幂等性。在高并发场景中,唯一索引能保证当多个线程尝试同时插入相同记录时,只有一个线程能成功执行,而其他线程将会因违反唯一性约束而抛出异常。
通常,业务流水表的建立是基于以下核心字段:
id
(bigint 类型):作为主键,唯一标识每条记录。gmt_create
(datetime 类型):记录的创建时间。gmt_modified
(datetime 类型):记录的最后修改时间。user_id
(varchar(32) 类型):用户ID,这个字段也可以作为分表的依据。out_biz_no
(varchar(64) 类型):外部业务流水号,即调用方的幂等号。biz_no
(varchar(64) 类型):内部业务流水号,用于系统内部追踪。status
(char(1) 类型):记录执行状态。
在这种设计中,user_id
和out_biz_no
通常会组合成一个联合索引,这样做能有效避免在并发情况下的数据重复插入问题,从而保障了业务操作的幂等性。
3.2 Token机制
Token机制是用于防止客户端重复提交的一种特殊机制,特别适用于客户端创建订单等提交表单场景。其执行流程如下:
1)当用户访问表单页面时,客户端请求服务端接口以获取唯一的Token(可以是UUID或全局ID),服务端生成的Token会被存储在Redis或数据库中。
2)用户首次提交表单时,将Token与表单一起发送至服务端,服务端会验证Token的存在性,如果Token存在,则执行业务逻辑,并在完成后销毁Token。
3)用户再次提交表单时,同样携带Token一起发送至服务端。但由于Token已被销毁,服务端无法找到对应的Token,从而拒绝重复提交请求。
3.3 悲观锁机制
悲观锁依赖数据库提供的锁机制来实现,整个数据处理过程中,数据处于锁定状态,并与事务机制配合,能够有效实现业务幂等性。操作示例如下:
JAVA
// 1. 开启事务
begin;
// 2. 基于幂等号查询
record = select * from tbl_xxx where out_biz_no = 'xxx' for update;
// 3. 根据状态进行决策
if(record.getStatus() != 预期状态){
return;
}
// 4. 更新记录
update tbl_xxx set status = '目标状态' where out_biz_no = 'xxx';
// 5. 提交事务
commit;
悲观锁主要适用于更新场景,通过串行化请求处理来确保幂等性,但需要小心使用,因为在并发场景下,重复请求可能会导致线程长时间处于等待状态,浪费资源且降低性能。
3.4 乐观锁机制
乐观锁主要依靠"带条件更新"(update with condition)来确保多次外部请求的一致性。在系统设计中,可以在数据表中添加版本号字段,用于标识当前数据的版本。每次对该数据表的记录进行更新时,都需要提供上一次更新的版本号,示例操作如下:
sql
//1. 取出要更新的对象,带有版本versoin
select * from tablename where id = xxx
//2. 更新数据
update tableName set sq = sq-#{quantity},version = #{version}+1 where id = xxx and version=#{version}
乐观锁主要适用于更新场景,确保多次更新不会影响结果的一致性。
3.5 分布式锁机制
分布式锁与悲观锁本质上相似,都通过串行化请求处理来实现幂等性。与悲观锁不同的是,分布式锁更轻量。在系统接收请求后,首先尝试获取分布式锁。如果成功获取锁,则执行业务逻辑;如果获取失败,则立即拒绝请求。
分布式锁的核心是识别重复请求,实现串行化处理。但要注意,获取锁成功后,业务逻辑的执行并没有可靠保证。因此,在实际应用中,分布式锁需要结合事务机制和重试机制,以形成完整的幂等性解决方案。
3.6 状态机机制
在许多业务单据中,存在有限数量的状态,并且这些状态之间的流转顺序是固定的。如果状态已经处于下一个状态,那么再次应用上一个状态的变更逻辑是不会产生任何效果的,这就确保了有限状态机的幂等性。
例如,库存状态通常包括"预扣中"、"扣减中"、"占用中"和"已释放"等状态。如果系统重复调用扣减接口,而库存状态已经是"扣减中",则可以直接返回结果。
状态机可以与乐观锁机制结合使用,示例操作如下:
SQL
update tableName set sq=sq-#{quantity},status=#{udpate_status} where id =#{id} and status=#{status}
3.7 小结
上面介绍了幂等方式的6种实现方案并简单介绍了每周方案的适合场景,这些方案的技术路线可以总结成三条:唯一索引、唯一数据、状态机约束。
唯一索引是指数据库唯一索引,唯一索引大部分是基于业务流水表建立,也可单独建表实现;唯一数据是指悲观锁、乐观锁、分布式锁等机制;状态机约束,对于存在状态流转的业务,通过状态机的流转约束,可以实现有限状态机的幂等。
需要注意的是:在实际开发中,这些方案单独使用很难奏效,比如悲观锁、分布式锁只是将请求串行化处理,对于出现异常后的重试并没有什么抵御能力,需要搭配唯一索引才能形成完整的幂等方案。而在唯一索引方案中也还需要搭配事务机制才能生效。所以需要结合具体的业务场景灵活运用上面的实现方案。
以上介绍了六种实现幂等性的方式,每种方式的适用场景和关键信息。这些方式可以总结为三个技术路线:唯一索引、唯一数据和状态机约束。
然而,需要注意的是,在实际开发中,单独使用这些方法往往效果有限。 例如,悲观锁和分布式锁只是将请求串行处理,对于异常情况的重试并没有足够的防御能力,因此需要结合唯一索引来实现完整的幂等性解决方案。同样,唯一索引方案也需要与事务机制结合使用。因此,在实际应用中,需要根据具体的业务场景灵活选择、合理的运用上述实现方法。
4. 代码实现
在Dailymart项目中,实现了除悲观锁以外的五种幂等方案。为了方便使用,我将分布式锁机制和Token机制封装在一个单独的幂等组件dailymart-idempotent-spring-boot-starter
中。
在业务模块中,只需在pom文件中引入依赖即可使用封装好的幂等功能。
XML
<dependency>
<groupId>com.jianzh5</groupId>
<artifactId>dailymart-idempotent-spring-boot-starter</artifactId>
<version>${project.version}</version>
</dependency>
幂等组件的核心是利用Spring的AOP机制实现。在使用时,只需在需要实现幂等的方法上添加自定义注解@Idempotent
,并指定幂等方案IdempotentTypeEnum
。
在自定义幂等组件中,分布式锁方案依赖于Redis。因此,在SpringBoot配置文件中需要加上Redis的相关配置,并添加一些自定义配置,如Redis key的自定义前缀以及分布式锁key的前缀。
yaml
spring:
data:
redis:
host: 10.7.205.81
password: dailymart
port: 29359
dailymart:
cache:
redis:
prefix: "inventory:"
idempotent:
token:
prefix: "token-"
timeout: 30000
接下来,结合具体应用场景,演示在DailyMart中如何实现这些幂等方案。
4.1 基于唯一索引实现
用户下单时需要调用库存预扣接口,在这种新增场景下,可以使用唯一索引结合事务机制实现幂等方案。
1、在扣减流水表中给业务流水字段transactionId
加上唯一索引。
2、在Service层让库存扣减和库存修改在同一个事务中,确保出现重复请求时事务回滚,从而保证幂等性。
这部分代码已在上篇文章中展示,源码位于com/jianzh5/dailymart/module/inventory/application/service/impl/InventoryServiceImpl.java
4.2 基于乐观锁实现
用户付款时会调用库存扣减接口,这种更新场景可以使用乐观锁机制来实现幂等方案。在Dailymart中,有两种实现方式。
4.2.1 基于原生SQL实现
java
public interface InventoryItemMapper extends BaseMapper<InventoryItemDO> {
/**
* 基于乐观锁实现更新
* @param inventoryItemDO 库存实体
*/
@Update("UPDATE inventory_item SET sellable_quantity = #{sellableQuantity},withholding_quantity = #{withholdingQuantity}, occupy_quantity = #{occupyQuantity} ,version = #{version} + 1 , update_time = NOW() WHERE id = #{id} AND version = #{version} ")
void updateByVersion(InventoryItemDO inventoryItemDO);
}
4.2.2 使用mybatis-plus提供的乐观锁插件
1、在DO对象中使用@Version
注解对乐观锁字段进行标注。
java
public class InventoryItemDO extends BaseDO {
...
@Version
private Integer version;
}
2、在mybatis-plus的配置类中添加乐观锁插件
java
public class DailyMartDsAutoConfiguration {
/**
* 设置mybatis-plus拦截器
* 1. 分页拦截器
* 2. 乐观锁拦截器
*/
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
//分页
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
// 乐观锁
interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
return interceptor;
}
这样,当使用inventoryItemMapper.updateById(inventoryItemDO);
方法时会自动实现乐观锁。
4.3 基于状态机实现
用户退货时需要调用库存释放接口,可以基于有限状态机来实现幂等。
JAVA
@Override
@Transactional
public void releaseInventory(Long transactionId) {
...
//如果已经是释放状态直接返回结果
if(inventoryRecord.getState() == InventoryRecordStateEnum.RELEASE.code()){
return;
}
...
}
单一的状态机机制不能很好地保证幂等性,因此需要结合乐观锁机制才更有效。
4.3 基于Token实现
用户在创建订单时需要调用后台接口提交表单,像这种客户端提交表单的操作就很适合使用token机制。
1、在客户端进入页面时调用幂等组件提供的/token
方法,后端自动生成token并存储到Redis中。
java
@Override
public String createToken() {
String token = Optional.ofNullable(Strings.emptyToNull(idempotentProperties.getPrefix())).orElse(TOKEN_PREFIX_KEY) + UUID.randomUUID();
log.info("Generated Idempotency Key is: {}", token);
distributedCache.put(token, "", Optional.ofNullable(idempotentProperties.getTimeout()).orElse(TOKEN_EXPIRED_TIME));
return token;
}
2、在创建订单接口加上自定义幂等注解,指定幂等类型为Token机制。
java
@PostMapping("/api/order/create")
@Idempotent(
type = IdempotentTypeEnum.TOKEN,
message = "订单正在创建,请勿重复提交"
)
public void create(@RequestBody OrderDTO orderDTO) {
orderService.save(orderDTO);
}
Token机制也需要结合唯一索引才能形成完整的幂等方案。
4.3 基于分布式锁实现
使用分布式锁幂等方案很简单,在方法上加上幂等注解即可。有两种使用方式:
1、指定type为IdempotentTypeEnum.PARAM
,此时幂等组件会将整个表单的参数做MD5摘要后作为分布式锁的key
JAVA
@Idempotent(
type = IdempotentTypeEnum.PARAM,
message = "订单正在创建,请勿重复提交"
)
@PostMapping("/api/order/create")
public void create(@RequestBody OrderDTO orderDTO) {
orderService.create(orderDTO);
}
2、指定type为IdempotentTypeEnum.SpEL
,此时幂等组件会根据key的值选取参数作为分布式锁的key,幂等key可以使用SpEL表达式选择参数中的字段。
java
@Idempotent(
key = "#lockRequest.transactionId", type = IdempotentTypeEnum.SpEL
)
@PostMapping("/api/order/update")
public void update(@RequestBody OrderDTO orderDTO) {
orderService.update(orderDTO);
}
通过以上实现,Dailymart项目成功应用了多种幂等性方案,确保了系统的可靠性和稳定性。
5. 小结
本文详细介绍了在分布式系统中幂等性实现方案,同时着重讲解了幂等和并发之间的区别。一般而言,并发都会伴随幂等,而幂等又并非并发独有。文章中提供了多种关于幂等的实现方案,不过需要记住,单一使用某种幂等方案往往很难奏效,需要组合多种方式才能形成完整的解决方案。
DailyMart是一个基于 DDD 和Spring Cloud Alibaba的微服务商城系统,同时还会在该系统中整合其他专栏的精华内容,譬如分库分表、设计模式、老鸟系列,开发实践等......希望能通过此专栏为开发者提供一个集成式的学习体验,并将其无缝地运用于实际项目中。如果你对这个系列感兴趣可以在本公众号回复关键词 DDD 以获取完整文档以及相关源码。