背景
在如应用打包和广告投放等场景都有明显的流程分多步骤处理过程,在开发时除了要考虑业务逻辑外,还需要考虑其他业务无关的通用功能,比如编排,异常处理,重试,监听回调等等,耗费较多精力和成本。
目标
建设通用流程基础框架,提供编排、重试、监听回调等基础能力,这样开发时只需要关心业务逻辑即可,有效提高开发效率。
框架特点
- 基于责任链模式把流程内的多个步骤串联起来,通过流程上下文传递信息。
- 拦截器能力,在流程或步骤前后执行,可以实现统一校验或统一异常处理等通用功能。
- 不同的流程之间通过转换器串联起来,实现流程间的松解耦。
- 通过配置线程池,同类流程允许并行执行,提高效率。
- 可以设置步骤级别异步重试策略。
- 支持流程的超时重试,一般用于服务重启后流程自动恢复。
- 步骤可以注册监听策略,使步骤拥有异步监听的能力,常用于那些异步创建,需通过不断反查来确定最终创建状态的场景。
- 与 Spring 框架相结合,方便利用 Spring 框架的强大特性,例如依赖注入等等。
方案设计
类&接口设计
- BaseStep 为步骤基类,继承该类类封装步骤逻辑。里面提供基础能力,可以控制流程执行。
- Flow 类主要用于装配步骤和连接下一流程。
- FlowContext 为流程上下文基类,里面包含流程的基本信息,通过该类的实例决定执行哪一流程。
- FlowKeyGenerator 接口,流程 key 生成器,每个流程实例都有一个全局唯一的 key。
- FlowFactory 接口,流程工厂,主要用于生产流程,定义运行流程的线程池以及应用的流程名。
- FlowManager 接口,流程管理器,用于注册流程工厂,流程拦截器等等,还是流程调用的入口。
- FlowExecutor 接口,流程执行器,控制流程执行的核心。
- FlowInterceptor 接口,在流程执行前后提供埋点。
- StepInterceptor 接口,在步骤执行前后提供埋点。
- FlowContextConverter 接口,FlowContext 转换器,流程结束后通过转换器连接下一流程。
流程拦截器调用顺序
流程拦截器提供三个埋点,分别是:
- 流程提交到线程池前(beforeSubmit):由于流程从提交到线程池到被线程池调度有一定的时延,所以特地提供此埋点。
- 流程执行前(beforeRun)
- 流程执行完后(afterRun):该埋点会有一个 Exception 类型的参数,接收步骤或 StepInterceptor 或 FlowInterceptor#beforeRun方法产生的异常
具体调用过程如下图:
流程转换器
流程转换器相当于一个中转站,把不同流程连接起来,支持分裂(一对多)。
加上流程转换器后的流程执行模型就变成这样了:
异步重试机制
调研了 Spring-retry 与 Guava-retry 开源框架发现都是同步阻塞重试,浪费线程资源,影响性能。
于是重新设计了一个异步重试模块,默认实现基于时间轮+线程池,
支持多种延时策略:
- 固定延时
- 随机时间延时
- 递增式延时
- 指数级递增延时
和多种重试判断器:
- 最大重试次数判断
- 特定异常判断
- 混合判读
- 自定义判断
并且支持注解的方式配置。
异步监听步骤
考虑一个常见的场景:一个步骤申请了某个请求,请求需要审核比较耗时,通常需要不断重试监听请求的审核状态,直到审核通过或者失败,此时步骤才算完成。
流程框架也提供这种步骤的能力。前面提到的重试监听通常需要到另外的线程中处理,此时流程会暂停往下进行,直到重试结束,再根据监听结果来自行决定流程是否继续。
处理模型大致如下:
流程执行超时重试
常用于服务器重启后自动恢复流程。
设计方案分为两大模块:
第一个模块:记录流程上下文和过期时间
- 可以在流程处设置默认的步骤重试策略,也可以在步骤处配置特定的超时重试策略。
- 利用流程拦截器和步骤拦截器,在流程提交时和步骤执行前,把当前流程上下文保存到 Redis hash 类型key里,以及把步骤的过期时间存到 Redis 的有序队列里。
- 流程结束时删除 Redis 里的流程上下文和有序队列里的记录。
第二个模块,轮询监听过期流程
-
超时监听器轮询 Redis 有序队列,获取第一条记录(第一条记录代表最先过期的流程)
-
判断这条记录是否已经过期
- 如果未过期,忽略,睡眠等待下一次轮询
- 如果已过期,从有序队列删除记录,获取流程上下文并提交给流程管理器,该流程将从超时的步骤开始重新执行
-
开始下一次轮询
答疑
- 疑问:超时的流程上下文可能会被多个实例同时发现,如何保证只有一个实例拿到这个流程的重试权?
答: 发现超时流程上下文时,对该流程上下文从有序队列里删除,利用 Redis 单线程执行命令的特性,只会有一个实例删除成功,删除成功的实例才会去重试流程。
- 疑问:假如步骤仍在运行,但是由于运行时间过长超过了超时时间,导致超时监听器认为该流程超时并且重新分发该流程,此时会不会出现同一流程(flowKey 一样)多处运行的情况?如何解决的?
答: 这种场景下,确实会出现同一流程多处运行,但仅限当前步骤,从下一步骤开始只会单处运行。框架里尽最大程度避免出现这种问题:
- 步骤拦截器会在步骤执行前:更新当前流程上下文的更新时间。步骤执行后:比较当前流程上下文的更新时间与远端(Redis)保存的流程上下文的更新时间,如果当前流程的较小,说明当前流程已经过时,直接抛出异常 结束流程;
- 步骤内每次重试都会更新流程上下文的更新时间,避免重试太多太久让监听器误以为流程已经超时。
需要注意的点
- 因为有重试的存在,所以要求每个步骤的逻辑必须保证幂等。
- 因为整个流程可能不在同一线程里执行,所以可以不使用 ThreadLocal 就不要使用 ThreadLocal,可以使用 FlowContext 来代替。
- 如果某些场景只能使用 ThreadLocal,也有开源方案可以解决,例如下面这个: github.com/alibaba/tra...