需求场景
调用方访问公共服务平台的接口,会有三种可能的结果:成功、失败和超时。前两种结果非常明确,调用方可以自己决定收到结果之后如何处理。结果为“成功”,万事大吉;结果为“失败”,一般情况下,调用方会将失败的结果,反馈给用户(移动端 App),让用户自行决定是否重试。但是,当接口请求超时时,处理起来就没那么容易了。有可能业务逻辑已经执行成功了,只是公共服务平台返回结果给调用方的时候超时了,但也有可能业务逻辑没有执行成功,比如,因为数据库当时存在集中写入,导致部分数据写入超时。总之,超时对应的执行结果是未决的。
如果接口只包含查询、删除、更新这些操作,那接口天然是幂等的。所以,超时之后,重新再执行一次,也没有任何副作用。不过,这里有两点需要特殊说明一下。
删除操作需要当心 ABA 问题。删除操作超时了,又触发一次删除,但在这次删除之前,又有一次新的插入。后一次删除操作删除了新插入的数据,而新插入的数据本不应该删除。不过,大部分业务都可以容忍 ABA 问题。对于少数不能容忍的业务场景,我们可以针对性的特殊处理。除此之外,细究起来,update x = x + delta 这样格式的更新操作并非幂等,只有 update x = y 这样格式的更新操作才是幂等的。不过,后者也存在跟删除同样的 ABA 问题。
如果接口包含修改操作(插入操作、update x = x + delta 更新操作),多次重复执行有可能会导致业务上的错误,这是不能接受的。如果插入的数据包含数据库唯一键,可以利用数据库唯一键的排他性,保证不会重复插入数据。除此之外,一般我会建议调用方按照这样几种方式来处理:
- 调用方访问公共服务平台接口超时时,返回清晰明确的提醒给用户,告知执行结果未知,让用户自己判断是否重试。不过,你可能会说,如果用户看到了超时提醒,但还是重新发起了操作,比如重新发起了转账、充值等操作,那该怎么办呢?实际上,对这种情况,技术是无能为力的。因为两次操作都是用户主动发起的,我们无法判断第二次的转账、充值是新的操作,还是基于上一次超时的重试行为;
- 调用方调用其他接口,来查询超时操作的结果,明确超时操作对应的业务,是执行成功了还是失败了,然后再基于明确的结果做处理。但是这种处理方法存在一个问题,那就是并不是所有的业务操作,都方便查询操作结果;
- 调用方在遇到接口超时之后,直接发起重试操作。这样就需要接口支持幂等。我们可以选择在业务代码中触发重试,也可以将重试的操作放到 Feign 框架中完成。因为偶尔发生的超时,在正常的业务逻辑中编写一大坨补救代码,这样做会影响到代码的可读性,有点划不来。当然,如果项目中需要支持超时重试的业务不多,那对于仅有几个业务,特殊处理一下也未尝不可。但是,如果项目中需要支持超时重试的业务比较多,我们最好是把超时重试这些非业务相关的逻辑,统一在框架层面解决;
对响应时间敏感的调用方来说,它们服务的是移动端的用户,过长的等待时间,还不如直接返回超时给用户。所以,这种情况下,第一种处理方式是比较推荐的;但是,对响应时间不敏感的调用方来说,比如 Job 类的调用方,我推荐选择后两种处理方式,能够提高处理的成功率。
需求分析
前面多次提到“幂等”,那“幂等”到底是什么意思呢?放到接口调用的这个场景里,幂等的意思是,针对同一个接口,多次发起同一个业务请求,必须保证业务只执行一次。那如何判定两次接口请求是同一个业务请求呢?也就是说,如何判断两次接口请求是重试关系?而非独立的两个业务请求?比如,两次调用转账接口,尽管转账用户、金额等参数都一样,但我们也无法判断这两个转账请求就是重试关系。
实际上,要确定重试关系,我们就需要给同一业务请求一个唯一标识,也就是幂等号。如果两个接口请求,带有相同的幂等号,那我们就判断它们是重试关系,是同一个业务请求,不要重复执行。幂等号需要保证全局唯一性。它可以有业务含义,比如,用户手机号码是唯一的,对于用户注册接口来说,我们可以拿它作为幂等号。不过,这样就会导致幂等框架的实现,无法完全脱离具体的业务。所以,我们更加倾向于,通过某种算法来随机生成没有业务含义的幂等号。
这里我们也借助用户用例和测试驱动开发的思想,先去思考,如果框架最终被开发出来之后,它会如何被使用。我写了一个框架使用的 Demo 示例,如下所示:
1 | ///////// 使用方式一:在业务代码中处理幂等 //////////// |
结合刚刚的 Demo,从使用的角度来说,幂等框架的主要处理流程是这样的:接口调用方生成幂等号,并且跟随接口请求,将幂等号传递给接口实现方。接口实现方接收到接口请求之后,按照约定,从 HTTP header 或者接口参数中,解析出幂等号,然后通过幂等号查询幂等框架。如果幂等号已经存在,说明业务已经执行或正在执行,则直接返回;如果幂等号不存在,说明业务没有执行过,则记录幂等号,继续执行业务。
对于幂等框架,我们再来看下,它都有哪些非功能性需求:
- 在易用性方面,我们希望框架接入简单方便,学习成本低。只需编写简单的配置以及少许代码,就能完成接入。除此之外,框架最好对业务代码低侵入松耦合,在统一的地方(比如 Spring AOP 中)接入幂等框架,而不是将它耦合在业务代码中;
- 在性能方面,针对每个幂等接口,在正式处理业务逻辑之前,我们都要添加保证幂等的处理逻辑。这或多或少地会增加接口请求的响应时间。而对于响应时间比较敏感的接口服务来说,我们要让幂等框架尽可能低延迟,尽可能减少对接口请求本身响应时间的影响;
- 在容错性方面,不能因为幂等框架本身的异常,导致接口响应异常,影响服务本身的可用性。所以,幂等框架要有高度的容错性。比如,存储幂等号的外部存储器挂掉了,幂等逻辑无法正常运行,这个时候业务接口也要能正常服务才行;
幂等处理正常流程
调用方从发起接口请求到接收到响应,一般要经过三个阶段:
- 调用方发送请求并被实现方接收;
- 执行接口对应的业务逻辑;
- 将执行结果返回给调用方;
为了实现接口幂等,我们需要将幂等相关的逻辑,添加在这三个阶段中。正常情况下,幂等号随着请求传递到接口实现方之后,接口实现方将幂等号解析出来,传递给幂等框架。幂等框架先去数据库(比如 Redis)中查找这个幂等号是否已经存在。如果存在,说明业务逻辑已经或者正在执行,就不要重复执行了。如果幂等号不存在,就将幂等号存储在数据库中,然后再执行相应的业务逻辑。
正常情况下,幂等处理流程是非常简单的,难点在于如何应对异常情况。在这三个阶段中,如果第一个阶段出现异常,比如发送请求失败或者超时,幂等号还没有记录下来,重试请求会被执行,符合我们的预期。如果第三个阶段出现异常,业务逻辑执行完成了,只是在发送结果给调用方的时候,失败或者超时了,这个时候,幂等号已经记录下来,重试请求不会被执行,也符合我们的预期。也就是说,第一、第三阶段出现异常,上述的幂等处理逻辑都可以正确应对。但是,如果第二个阶段业务执行的过程出现异常,处理起来就复杂多了。
业务代码异常处理
对于这个问题,我们要分业务异常和系统异常来区分对待。我举个例子解释一下,比如,A 用户发送消息给 B 用户,但是查询 B 用户不存在,抛出 UserNotExisting 异常,我们把这种业务上不符合预期叫做业务异常。因为数据库挂掉了,业务代码访问数据库时,就会报告数据库异常,我们把这种非业务层面的、系统级的异常,叫做系统异常。
遇到业务异常(比如 UserNotExisting 异常),我们不删除已经记录的幂等号,不允许重新执行同样的业务逻辑,因为再次重新执行也是徒劳的,还是会报告异常;相反,遇到系统异常(比如数据库访问异常),我们将已经记录的幂等号删除,允许重新执行这段业务逻辑。因为在系统级问题修复之后(比如数据库恢复了),重新执行之前失败的业务逻辑,就有可能会成功。
实际上,为了让幂等框架尽可能的灵活,低侵入业务逻辑,发生异常(不管是业务异常还是系统异常),是否允许再重试执行业务逻辑,交给开发这块业务的工程师来决定是最合适的了,毕竟他最清楚针对每个异常该如何处理。而幂等框架本身不参与这个决定,它只需要提供删除幂等号的接口,由业务工程师来决定遇到异常的时候,是否需要调用这个删除接口,删除已经记录的幂等号。
业务系统宕机处理
如果幂等号已经记录下了,但是因为机器宕机,业务还没来得及执行,按照刚刚的幂等框架的处理流程,即便机器重启,业务也不会再被触发执行了,这个时候该怎么办呢?除此之外,如果记录幂等号成功了,但是在捕获到系统异常之后,要删除幂等号之前,机器宕机了,这个时候又该怎么办?
如果希望幂等号的记录和业务的执行完全一致,我们就要把它们放到一个事务中。执行成功,必然会记录幂等号;执行失败,幂等号记录也会被自动回滚。因为幂等框架和业务系统各自使用独立的数据库来记录数据,所以,这里涉及的事务属于分布式事务。如果为了解决这个问题,引入分布式事务,那幂等框架的开发难度提高了很多,并且框架使用起来也复杂了很多,性能也会有所损失。
针对这个问题,我们还有另外一种解决方案。那就是,在存储业务数据的业务数据库( 比如 MySQL)中,建一张表来记录幂等号。幂等号先存储到业务数据库中,然后再同步给幂等框架的 Redis 数据库。这样做的好处是,我们不需要引入分布式事务框架,直接利用业务数据库本身的事务属性,保证业务数据和幂等号的写入操作,要么都成功,要么都失败。不过,这个解决方案会导致幂等逻辑,跟业务逻辑没有完全解耦,不符合我们之前讲到的低侵入、松耦合的设计思想。
实际上,做工程不是做理论。对于这种极少发生的异常,在工程中,我们能够做到,在出错时能及时发现问题、能够根据记录的信息人工修复就可以了。虽然看起来解决方案不优雅,不够智能,不够自动化,但是,这比编写一大坨复杂的代码逻辑来解决,要好使得多。所以,我们建议业务系统记录 SQL 的执行日志,在日志中附加上幂等号。这样我们就能在机器宕机时,根据日志来判断业务执行情况和幂等号的记录是否一致。
幂等框架异常处理
对于限流来说,限流框架执行异常(比如,Redis 访问超时或者访问失败),我们可以触发服务降级,让限流功能暂时不起作用,接口还能正常执行。如果大量的限流接口调用异常,在具有完善监控的情况下,这些异常很快就会被运维发现并且修复,所以,短暂的限流失效,也不会对业务系统产生太多影响。毕竟限流只是一个针对突发情况的保护机制,平时并不起作用。如果偶尔的极个别的限流接口调用异常,本不应该被放过的几个接口请求,因为限流的暂时失效被放过了,对于这种情况,绝大部分业务场景都是可以接受的。毕竟限流不可能做到非常精确,多放过一两个接口请求几乎没影响。
对于幂等来说,尽管它应对的也是超时重试等特殊场景,但是,如果本不应该重新执行的业务逻辑,因为幂等功能的暂时失效,被重复执行了,就会导致业务出错(比如,多次执行转账,钱多转了)。对于这种情况,绝大部分业务场景都是无法接受的。所以,在幂等逻辑执行异常时,我们选择让接口请求也失败,相应的业务逻辑就不会被重复执行了。毕竟接口请求失败(比如转钱没转成功),比业务执行出错(比如多转了钱),修复的成本要低很多。
虽然幂等框架要处理的异常很多,但考虑到开发成本以及简单易用性,我们对某些异常的处理在工程上做了妥协。交由业务系统或者人工介入处理,这样就大大简化了幂等框架开发的复杂度和难度。
V1 版本功能需求
幂等框架的设计思路是很简单,主要包含下面这样两个主要的功能开发点:
- 实现生成幂等号的功能;
- 实现存储、查询、删除幂等号的功能;
幂等号用来标识两个接口请求是否是同一个业务请求,换句话说,两个接口请求是否是重试关系,而非独立的两个请求。接口调用方需要在发送接口请求的同时,将幂等号一块传递给接口实现方。幂等号一般有两种生成方式:
- 集中生成并且分派给调用方:需要部署一套幂等号的生成系统,并且提供相应的远程接口(RESTful 或者 RPC 接口),调用方通过调用远程接口来获取幂等号。这样做的好处是,对调用方完全隐藏了幂等号的实现细节。当我们需要改动幂等号的生成算法时,调用方不需要改动任何代码;
- 直接由调用方生成:调用方按照跟接口实现方预先商量好的算法,自己来生成幂等号。这种实现方式的好处在于,不用像第一种方式那样调用远程接口,所以执行效率更高。但是,一旦需要修改幂等号的生成算法,就需要修改每个调用方的代码;
权衡来讲,既考虑到生成幂等号的效率,又考虑到代码维护的成本,我们选择第二种实现方式,并且在此基础上做些改进,由幂等框架来统一提供幂等号生成算法的代码实现,并封装成开发类库,提供给各个调用方复用。除此之外,我们希望生成幂等号的算法尽可能的简单,不依赖其他外部系统。实际上,对于幂等号的唯一要求就是全局唯一。全局唯一 ID 的生成算法有很多。比如,简单点的有取 UUID,复杂点的可以把应用名拼接在 UUID 上,方便做问题排查。总体上来讲,幂等号的生成算法并不难。
从现在的需求来看,幂等号只是为了判重。在数据库中,我们只需要存储一个幂等号就可以,不需要太复杂的存储结构,所以,我们不选择使用复杂的关系型数据库,而是选择使用更加简单的、读写更加快速的键值数据库,比如 Redis。在幂等判重逻辑中,我们需要先检查幂等号是否存在。如果没有存在,再将幂等号存储进 Redis。多个线程(同一个业务实例的多个线程)或者多进程(多个业务实例)同时执行刚刚的“检查-设置”逻辑时,就会存在竞争关系(竞态,Race Condition)。比如,A 线程检查幂等号不存在,在 A 线程将幂等号存储进 Redis 之前,B 线程也检查幂等号不存在,这样就会导致业务被重复执行。为了避免这种情况发生,我们要给“检查-设置”操作加锁,让同一时间只有一个线程能执行。除此之外,为了避免多进程之间的竞争,普通的线程锁还不起作用,我们需要分布式锁。
引入分布式锁会增加开发的难度和复杂度,而 Redis 本身就提供了把“检查-设置”操作作为原子操作执行的命令:setnx(key, value)。它先检查 key 是否存在,如果存在,则返回结果 0;如果不存在,则将 key 值存下来,并将值设置为 value,返回结果 1。因为 Redis 本身是单线程执行命令的,所以不存在刚刚讲到的并发问题。
最小原型代码实现
我们先不考虑设计和代码质量,怎么简单怎么来,先写出 MVP 代码,然后基于这个最简陋的版本做优化重构。V1 版本的功能非常简单,我们用一个类就能搞定,代码如下所示:
1 | public class Idempotence { |
Review 最小原型代码
尽管 MVP 代码很少,但仔细推敲,也有很多值得优化的地方。现在,我们就站在 Code Reviewer 的角度,分析一下这段代码:
1 | public class Idempotence { |
总结一下,MVP 代码主要涉及下面这样几个问题:
- 代码可读性问题:有些函数的参数和返回值的格式和意义不够明确,需要注释补充解释一下。genId() 函数使用了缩写,全拼 generateId() 可能更好些;
- 代码可扩展性问题:按照现在的代码实现方式,如果改变幂等号的存储方式和生成算法,代码修改起来会比较麻烦。除此之外,基于接口隔离原则,我们应该将 genId() 函数跟其他函数分离开来,放到两个类中。独立变化,隔离修改,更容易扩展;
- 代码可测试性问题:解析 Redis Cluster 地址的代码逻辑较复杂,但因为放到了构造函数中,无法对它编写单元测试;
- 代码灵活性问题:业务系统有可能希望幂等框架复用已经建立好的 jedisCluster,而不是单独给幂等框架创建一个 jedisCluster;
重构最小原型代码
实际上,问题找到了,修改起来就容易多了:
1 | // 代码目录结构 |
接下来,我再总结罗列一下,针对之前发现的问题,我们都做了哪些代码改动:
- 在代码可读性方面,我们对构造函数、saveIfAbsent() 函数的参数和返回值做了注释,并且将 genId() 函数改为全拼 generateId()。不过,对于这个函数来说,缩写实际上问题也不大;
- 在代码可扩展性方面,我们按照基于接口而非实现的编程原则,将幂等号的读写独立出来,设计成 IdempotenceStorage 接口和 RedisClusterIdempotenceStorage 实现类。RedisClusterIdempotenceStorage 实现了基于 Redis Cluster 的幂等号读写。如果我们需要替换新的幂等号读写方式,比如基于单个 Redis 而非 Redis Cluster,我们就可以再定义一个实现了 IdempotenceStorage 接口的实现类:RedisIdempotenceStorage。除此之外,按照接口隔离原则,我们将生成幂等号的代码抽离出来,放到 IdempotenceIdGenerator 类中。这样,调用方只需要依赖这个类的代码就可以了。幂等号生成算法的修改,跟幂等号存储逻辑的修改,两者完全独立,一个修改不会影响另外一个;
- 在代码可测试性方面,我们把原本放在构造函数中的逻辑抽离出来,放到了 parseHostAndPorts() 函数中。这个函数本应该是 Private 访问权限的,但为了方便编写单元测试,我们把它设置为成了 Protected 访问权限,并且通过注解 @VisibleForTesting 做了标明;
- 在代码灵活性方面,为了方便复用业务系统已经建立好的 jedisCluster,我们提供了一个新的构造函数,支持业务系统直接传递 jedisCluster 来创建 Idempotence 对象;