1、幂等性本质
幂等(idempotent、idempotence)是一个数学与计算机学概念,在编程中一个幂等操作的特点是其任意多次执行所产生的影响均与一次执行的影响相同。详见百度百科:幂等_百度百科
可以把这句话大白话一点,那就是对于幂等性字段值是一样的必须永远给出同一个结果,不能给出两个不同的结果。在我们的系统设计里面,所有的写操作都是需要具备幂等性的,否则非常容易出现故障。
2、幂等性的特性
2.1、原子性
即用于控制幂等性的逻辑必须要和业务处理逻辑在一个事务内,他们是需要做到同时成功或者同时失败。如果没做到这点是非常容易出故障的,典型就是通过redis来控制幂等性。
2.2、传递性
即A接口具备幂等性,那么他依赖的第三方服务也需要具备幂等性。否则重复请求的情况下,是无法返回同样的结果。
3、幂等性典型错误
3.1、幂等性字段选取错误
幂等性本质上是一个接口契约的约定,幂等性字段的选取首先是要必须是有业务语义,要首先在业务上确保是唯一的。细化下来有这几个典型的错误场景:
3.1.1、幂等性字段不从接口契约中获取
幂等性本质是一个契约约定,那么幂等性字段必须是从对方给出的报文/接口参数中选取,典型的错误做法就是对值进行逻辑转换,甚至是用逻辑上值一样的其他字段替代。
3.2、幂等性字段存储错误
如果说幂等性字段的选取没有问题,那么接下来一个可能出错的点就是幂等性字段存储问题。典型的错误场景:
3.2.1、幂等性字段值唯一性范围错误
目前幂等性实现方法都是数据库的唯一性索引,唯一性索引如果不是建立的全局索引而是分区索引,那么在跨分区(按照天,月,年分区,那就会在日终,月末,年末)发生问题。
3.2.2、幂等性字段值的考虑全局与局部
有很多系统的实现是前面选取的幂等性做字符串拼接,比如A,B两个字段的值拼接到一起,作为幂等性字段,存储到唯一性索引里面防止重复。这里的本质其实是是考虑局部幂等性和全局幂等性,比如A代表商户编码,B代表单号,如果只是用B是很容易出现重复的,因为不同的商户单号生成逻辑未必统一,可能会生成同样的单号,所以必须A+B作为幂等字段。
3.2.3、幂等性字段值的分库分表错误
幂等性字段在存储到DB中也是有非常容易出错的地方,那就是分库分表位的选取,避免的办法就是检查在重发的情况下,你的分库分表位是不是一致的。我记得有个非常严谨的写法,通过hash算法计算hash值并用hash值的后两位作为分库分表位,当时项目组把hash算法都从JDK中copy出来了,防止JDK升级导致算法变更,计算出来不同的结果。
3.3、幂等性控制逻辑错误
幂等性控制逻辑同样也有很多非常容易出错的点,有以下几种典型场景:
3.3.1、用redis做幂等
通过幂等性的特性可以知道,幂等性记录必须与业务处理在一个事务中,否则不满足原子性,得到的结果可能是错误的。因为redis一旦出现问题,比如A加锁成功执行后续业务逻辑,在A未执行结束时,redis发生了主备切换,B也加锁成功了。
3.3.2、跨库问题
有些系统做了垂直拆库,也就幂等性可能在A库,业务逻辑处理可能在B库。这种场景本质上就是两层幂等,先要保障在A库的幂等性正确,B库也有完整正确的幂等性逻辑,两者交互看成远程调用来严谨地对待。千万不要妄想还能跨库能够有事务操作来保障幂等性。
3.3.3、并发没控制好
这个是基本功,只要是写操作就必须用数据库的悲观锁,做到一锁,二判,三更新。
1、侥幸心理,觉得我这是低频操作,不需要复杂设计,幂等与否查下数据库就好了,不需要遵守一锁,二判,三更新。这是极其错误的做法,同样给后续的同学埋下无数的坑。
2、锁的对象不对:有些人觉得JVM锁就可以了,这个是极其错误的,我们是分布式应用,不能只是JVM锁;更加隐蔽的错法就是,一些场景锁的是对象A,一些场景是锁定的对象B,在并发的时候根本没法互斥。举个例子,支付的时候锁定支付单,退款的时候只锁定退款单,这就会导致多退款。所以我们都要求去锁定模型的根,而不是随便选择锁定对象。
3.3.4、无法返回一致性结果
要考察一个逻辑处理是不是有幂等性,最好的办法就是想象这一笔数据处理后再来一笔数据的逻辑处理是不是正确,幂等就是要做到不管来多少次都需要返回同样的结果。这里易错的盲区:
1、返回成功/失败的业务结果,但系统没有记录这个业务结果,导致下一笔请求重发过来的时候,系统根据当前情况处理返回了和上一次不同的业务结果。首先我们要有这样的意识,我们是分布式系统,重发就一定会发生而不是认为这有业务形态差异,有概率问题,我们的代码就要默认考虑是会重发。
我们再延伸下,以下处理手段都是有问题的:
-
在异步消息场景下,如果我们参数校验错误,不落地数据库单子就返回业务失败。因为参数校验错误只能代表系统处理失败,并不代表实际业务失败。极端场景,有同学修改了代码放宽了校验,此时消息再重投就可能会处理成功,这就会造成前后两次结果不一致带来的资损可能。
-
如果我们通过加载业务配置规则做请求校验,不落地数据库单子就返回业务失败。同样地,在重试的情况下,恰好业务配置规则变更了导致重试成功,这也是前后两次结果不一样,可能有故障。
2、我们依赖第三方服务,多次调用拿到不一样的结果。这其实是未幂等的传递性,如果你依赖的第三方不能保证幂等,其实你的接口也是不具备幂等性的。这是非常易错的点,必须要在方案时判定清楚。
3.3.5、结果码理解问题
对于结果码的处理其实是一个非常大的专题,从幂等性上来讲最重要的就是理解结果码含义到底是系统处理结果还是业务处理结果,有些系统设计是同步受理异步处理,返回的结果码只是系统处理结果,如果当成了业务处理结果这就可能会出故障。
另外一个关键点是对于幂等性结果码的识别,如果这个把非幂等性结果码识别为幂等性结果码就会出故障,反过来把幂等性结果码识别为非幂等性错误码就会导致业务重试不成功,引起其他业务问题。
4、幂等技术敏感性
从上面可以看到关于幂等性我们可能是有大约10种大类的错法,如果再和我们业务场景结合起来那要辨识起来可能就更加复杂,也更容易出错。所以列举典型的场景,看到这个场景/关键词就要想到幂等:
1、FO,提到就要考虑FO库与主库之间的幂等
2、切流,提到就要考虑新老系统之间的幂等,从技术控制的本质上讲切流就是改变了幂等性的控制。
3、机房切换,要考虑单个机房失效,如何做好全局性幂等性。
4、写操作,想到有写请求就要想到幂等性设计是不是合理。
5、一锁二判三更新
业务并发时,更新对应的状态型数据需要对资源进行锁定后进行判断后在进行更新,不然可能因为并发导致数据更新丢失等数据不符合业务预期的风险。锁定的单据,一般是领域模型的根单据。
-
一锁:锁定单点资源(单条单据),并发请求串行处理
-
二判:基于锁定的单据最新状态,判断状态、金额
-
三更新:第二步判断通过后的处理
5.1、一锁二判三更新的几种写法
-
直接在事务下使用DAO进行操作
-
DAO 的一锁
-
锁对象的二判,正常的业务逻辑是要识别出来对象的判断是符合业务语义的
-
DAO的三更新,更新的表要和lock的表是一样的
transactionTemplate.execute((TransactionStatus status) -> { // 1. 锁定单据 bizOrder = bizOrderDAO.loadWithLock(orderId); // 2. 状态检查;可退金额检查 bizOrder.refundCheck(); // 3. 模型持久化 bizOrderDAO.restroe(bizOrder); return true; });
-
-
对DAO在进行抽象,抽象出Repository仓储层接口和实现,在事务中使用该Repository进行操作,目前看基于领域驱动设计,这类是主要写法。
transactionTemplate.execute((TransactionStatus status) -> {
// 1. 锁定单据
bizOrder = bizOrderRepository.loadWithLock(orderId);
// 2. 状态检查;可退金额检查
bizOrder.refundCheck();
// 3. 模型持久化
bizOrderRepository.restroe(bizOrder); return true; });
public class BizOrderRepository implements IRepository{
@Autowire
private BizOrderDAO bizOrderDAO
}
-
进一步还需要考虑事务的传播属性,多个事务嵌套时,对于不同的传播属性需要有不同的逻辑,不然会导致噪音误告的情况
//传播属性: REQUIRED transactionTemplate1.execute((TransactionStatus status1) -> {
// 1. 锁定单据
bizOrder = bizOrderRepository.loadWithLock(orderId);
//传播属性: REQUIRED transactionTemplate2.execute((TransactionStatus status2) -> {
// 2. 状态检查;可退金额检查
bizOrder.refundCheck();
// 3. 模型持久化
bizOrderRepository.restroe(bizOrder); return true; }); return true; };
public class BizOrderRepository implements IRepository{
@Autowire private BizOrderDAO bizOrderDAO }