WHCSRL 技术网

可重入分析

引言:
微服务中,网络调用随处可见,也带来了很多问题,对于底层搬砖程序员,最明显的影响就似乎分布式事务、网络波动异常等。接口可重入以及接口无状态往往是解决这些问题的关键。

1. 什么是“可重入”

一般情况下,可重入指的是接口(函数)可以重复调用且不发生异常。
个人认为,与幂等相比,可重入是一个业务概念。

幂等指的是相同输入必定有相同输出,而可重入不一定要保证每次都是相同输出,只要保证逻辑正确即可。

比如登录接口,第一次登录成功后进行重复登录,若返回“登录成功”此时是幂等的(也是可重入的),若返回“您已登录”此时是可重入的但非幂等。

个人觉得没必要纠结幂等与可重入的概念细节差异。

注:重入问题和不可重入问题和可重入问题,都是同一个问题,都是指:可能存在不可重入的情况,但是想要保证可重入。

2. 可能出现重入问题的接口

非幂等接口必定存在重入问题,但该粒度不是本文分析重点。

2.1 单个网络调用

单个网络调用也会发生不可重入问题,这是极其容易被忽视的情况。

举一个简单的例子,以mysql为例。

需求:给张三的余额增加10元,并记录流水。
分析:两条数据变动,需要使用事务,没有其他复杂逻辑。
SQL语句1:update t_account set balance=balance+10 where user_name='张三';
SQL语句2:insert into t_balance_detail values('张三', '+10');

问题:如果给mysql发送了udpate请求,但mysql长时间未返回,直到触发超时异常,这时候,无法确定这10元是否增加,且无法重入进行补偿。

解决:前端传入一个seq_id(其实就是订单号),专门用于解决重入问题。
假设seq_id=‘SEQ’:
SQL语句1:select count(*) from t_account where seq_id='SEQ'
若结果不为零,说明已经执行过,直接返回成功。
SQL语句2:update t_account set balance=balance+10 where user_name='张三';
SQL语句3:insert into t_balance_detail values('张三', '+10', 'SEQ');

(SQL语句1也可以略去,改成有条件插入,若存在相同seq_id的,则取消本次事务)。

2.2 多段网络调用

假设需求依旧是“给张三的余额增加10元,并记录流水”,但是流水和余额不在同一个库里面,且无法使用分布式事务。
那么执行流程如下:
方式一:
1.给张三的余额增加10元
2.记录流水
方式二
1.记录流水
2.给张三的余额增加10元

对于方式一,显然,如果1成功但是2失败了,就陷入了数据不一致的困境。

但是,使用方式二,当1成功但是2失败了,这时候seq_id也没用了,我们发现seq_id存在,但是实际上余额并没有加上。

上述问题看似无解,且实际上,光靠该系统本身,确实是无解的,必须引入其他解决方案。

简单且常用的方案就是对账,定期检查流水与余额,若不一致,则告警人工接入(或者以流水为准触发自动充值)。
上述方案时延较长,对于充值这种实时业务不友好。

其次就是引入分布式事务(可参考网上方案)。

2.3 有状态接口

有状态接口可分为“内存有状态接口”和“持久化有状态接口”。
内存有状态接口指的是状态保存在内存中,比如类的静态属性。
持久化有状态接口指的是状态保存在外部存储中,比如mysql中存着订单付款状态。

异常示例:
接口状态流转为0->1->2->0,期望状态是调用完一次之后,状态回到0,但是如果执行到中间时发生异常,导致状态没有扭转,会影响后续调用。

假设1状态转为2状态时crush,重入时,会重新执行1->2的逻辑,这时候需要保证1->2之间的逻辑时可重入的。

3. 可重入保证的关键场景

1.发生异常后重试(链路未执行完毕)
2.正常情况下的重复调用(链路已执行完毕)

4. 可重入的判断依据

“可重入”的判断,难以覆盖全面且完全正确,因为有些“重入”是纯业务的概念,比如“给张三增加10元”,假如改成“记录一次点击事件”,那么无论mysql是否返回,其实不影响重入业务。

理论上,程序的任何一步都可能crush,因此,只要存在数据变更的接口,就可能存在重入问题

对于必定发生的情况,此处做了罗列。

必定可重入: 无数据变更(幂等接口)必定可重入

必定不可重入: 存在多段网络调用进行数据变更的接口,前段逻辑的返回值受数据变化影响,且该数据会被当前接口修改(重入时可能出现后段逻辑不可达的情况)

推荐阅读