跳至主要內容

asyncflow-04-核心梳理

holic-x...大约 101 分钟asyncflowasyncflow

asyncflow-04-核心梳理

学习核心

项目核心自测:项目立意和核心问题自测,可以结合多个层面理解学习

  • 项目立意
  • 存储、接入、部署
  • 执行与调度
  • 分表
  • 性能
  • 其他扩展

学习资料

todo 数据缓存一致性?open in new window

todo:任务配置表中的任务类型是要自己手动在数据库中添加吗,或者改一下flowsvr代码,增加一个"添加配置"的web接口

todo:start for 死循环会堵住主线程吗?和定时器有啥区别?

asyncflow - 核心概念

1.项目立意

🚀项目介绍

​ 分别结合场景、架构两方面进行说明:

场景:asyncflow项目是一个轻量级异步任务处理框架,抽象了异步任务中任务管理、自动重试、优先级等非业务能力,使得异步任务开发成本低并且高效。

架构:asyncflow主要分为服务层flow server和执行层worker,flow server是负责提供任务创建、任务拉取、设置任务状态等接口。worker是负责调度的(可以看作消费者),它会从flow server拉取任务,然后执行任务

​ ==是个人还是团队?==这个框架是在业务开发时抽象出来的一个产物,基于现有场景开发、通过调研构建的一个框架用于复用,主要也是想提升自己的开发能力和效率。框架构建过程中也有和团队leader沟通交流其中的一些设计方案和实现

📌项目的代码量?框架的实现周期?

​ 代码量:2000-4000行,框架的实现周期大概一个多月(前期方案的设计、项目调用花费比较多的时间,写代码大概20多天,后续进行相应测试优化等)

📌为什么做这个框架,什么场景使用这个框架?

​ 在做业务开发的时候经常会涉及到一些外部接口的调用,这些接口的调用和处理有些是比较耗时的操作,因此一般会采用异步调用的处理思路来进行处理。例如一些智能溯源(文档比对)、智能复核(智能审核场景)、音视频处理等接口,涉及到任务的多阶段处理,发现每次处理这些逻辑代码的时候编写的核心逻辑都差不多,于是想着可否抛开业务逻辑,将这一套异步处理的逻辑抽离出来整合成框架进行应用,也便于后续的迭代和维护。这个框架主要面向的是异步耗时操作的处理场景

📌想请问什么异步场景下框架是不适合的?或者说具有什么特点的异步场景框架就不合适了?还是说只要异步场景框架都适用?

​ 框架面向的是耗时的异步任务调度场景,便于跟踪这些耗时任务的调度流程。针对一些执行周期比较短的任务就没必要引入,也看业务场景的容忍度,一些简单的异步流程一般都是在同一个节点中等待流转完成继续同步操作。目前框架暂时是不支持父子任务节点这种形式,考虑到大部分场景需求和实施成本,此处暂不复杂化场景

📌对于任务 A->(B,C,D)->E 第二个阶段要求并行执行B,C,D后,才能到E阶段,框架可以处理这种任务吗?如何判断前置执行状态?第二个阶段使用线程池或者CompletableFuture都可以做到同一个阶段多个任务并行,这是业务方要考虑的事,和框架无关对吗?

​ 目前框架实现的机制是:A->B->C->D->E,如果业务场景有A->(B,C,D)->E的需求实际也不冲突(可以将现有任务计划转化为A->B->C->D->E这种形式、也可以将BCD阶段当做一个大节点处理:A->O(B、C、D)->E,理论上套用框架调度逻辑也是可行的),至于使用什么技术栈则基于业务选择的应用方式和任务构建的流程是怎样的(属于业务逻辑处理,自由发挥,不在框架管辖范围,框架只是提供一个流程规范的概念)

目前框架不支持任务编排(例如父子任务),对于同一个阶段的并行子任务,可以考虑拆开做,将多个子任务在同一个任务执行阶段进行处理,等待所有子任务执行完成再进入下一阶段的执行

​ Q:父子任务怎么理解?

​ A:在任务调度中,父子任务的关系表现为一种依赖性子任务依赖于父任务的运行环境、父任务需待所有子任务完成才能结束)。例如多模块或者多子系统的集成测试,需要按照一定的顺序和逻辑进行集成和测试,以确保整个系统的正确运行。在这种情况下,父任务可以代表整个系统的集成和测试,而子任务则是系统下的各个模块或者子系统的验证和测试

​ Q:父子任务的概念是父任务由多个子任务组成,子任务完成父任务才算完成?那这个父子任务就是A->(B,C,D)->E这样的结构?只不过B,C,D只能作为整体存储在数据库中,只能被一个worker处理对吗?那为什么说不支持父子任务呢?

​ A:父子任务的概念是A(A1、A2、A3) 父子任务之间具有依赖性,从业务上理解是父任务下又包含了多个子任务概念。

之所以说不支持父子任务:看起来A->O(B,C,D)->E结构中的O和BCD的关系就是一个父子概念,A、O、E是同级概念,但框架现有实现只能跟踪到A、O、E的执行态,而无法跟踪到具体O阶段中子级别的B、C、D的各自的执行态(一些流程引擎框架是可以做到的),而针对父子节点的逻辑实现涉及到很多要素(考虑父子之间的依赖性),实施成本较高,目前版本暂不支持

之所以说支持A->O(B,C,D)->E这种思路,也是因为将BCD当做一个大节点处理了,将原来的父子任务节点概念转成一个平级的调调,让业务在一个阶段内去处理这种父子节点的逻辑,此处则是将框架无法支持的场景交由业务去把控

📌用这个框架的目的应该就是去调度服务?那么对于用框架的人来说,是否就希望说业务服务尽量不去修改代码,他本身就有对外提供的服务的能力,就像最开始介绍的用一些腾讯云的api接口进行调用?那么对于一个新服务是不是还是需要到框架里去再给对应服务开发一个worker的逻辑?

​ 此处还是回归框架立意,框架设计的初衷是抽离一些公共的内容,制定任务调度规范,框架已经帮我们做了任务调度这一块,但具体每个任务要执行什么逻辑这个不是框架可以控制的,也不属于框架管辖范围。就像我们引入MyBatis-plus,帮我们简化了CRUD的一些DB操作,但实际业务场景中不单纯是CRUD(可能还会嵌入其他业务逻辑),还是得由框架使用者来结合业务实际实现。也就是说,当引入了新的任务类型,业务的执行逻辑有变化的时候,还是需要相应调整业务执行逻辑的。

框架本身不提供服务 (一开始文档介绍的只是模拟调用腾讯云的API接口来引出耗时异步任务场景这个概念),套到现实业务场景中,如果框架要支持提供外部服务接口,那这个框架的立意就不仅仅是任务调度了,有点变成API接口调用平台概念了,又要适配各种业务的服务接口支持,反而脱离了原有项目立意,把场景复杂化了,此处关注的核心是针对多阶段的异步耗时任务的调度场景,而这个调度场景中里面的执行逻辑(例如要调用外部服务、做DB操作、或者其他一些耗时操作),都是框架以外的事情,需由使用asyncflow框架的业务方进行保证

🚀技术方案对比:asyncflow 和 xxx 有什么区别?为什么不用xxx?

​ 思路:对比asyncflow和其他的区别,主要强调asyncflow的场景应用和功能实现,凸显自身项目的优势,强调“成本”和“收益”

​ 核心:从上下文更新、任务管理、任务续做这些方面进行扩展,突出框架的灵活性,以最小的成本获取更多的效益。理解有些事谁可以做,但是谁来做更合适,可以结合实际的场景案例进行分析

(1)asyncflow和消息队列有什么区别?

​ 在框架构建的过程中,也参考了其他的技术栈和现有异步框架的实现。对比消息队列,主要结合”上下文更新“、”任务管理“这两部分来考虑:

强调消息队列的场景应用和其在这个场景中的不足,虽然消息队列可以通过“变通”实现功能,但对比之下稍显吃力

  • ”上下文更新“:消息队列的业务场景主要是用作消息传递,对于多阶段任务的中间状态存储会比较吃力(虽然可以通过不断流转的模型来达到上下文更新的目的,例如将上一阶段产生的数据通过消息发送给下一阶段,但是从实现上来看并不灵活)
  • ”任务管理“:消息队列并不支持任务管理(例如任务查询等),虽然有相应的后台可以提供消息查询,但是无法支持业务侧的灵活查询需求
(2)asyncflow 如果是基于线程池实现的话,它和JAVA自带的线程池有什么区别?

​ JAVA自带的线程池功能毕竟有限,无法支撑灵活的业务场景,而框架支持 自动续做更新任务的上下文任务管理

​ 如果一个任务有多个阶段,在中间某个阶段执行失败了之后,下次执行可以加载上下文,从执行失败的这个阶段继续续做。为了实现续做,每做完一个阶段都会把相关的信息持久化到数据库

(3)为什么不用业界已有的异步任务框架?(对比已有框架有什么优缺点或区别?)

​ 对比了业界已有的异步任务框架,JAVA方面有了解类似activiti、flowable这类工作流引擎、xxx-job这类的异步调用框架,主要考虑付出成本和效益。

​ asyncflow是基于现有业务场景抽离的轻量级框架,专注于异步任务处理,它的实现不会太复杂,维护成本低。而对于现有的一些异步任务框架,它的引用是比较重的,还会冗余一些其他的功能,框架整合成本和学习成本较高,且框架的定位也不同。

​ asyncflow 提供了灵活的重试策略和优先级策略,可以更加专注于异步任务场景。

2.项目架构

✨介绍一下项目框架、技术栈等(项目架构)

  • 项目技术栈:Springboot + mybatis + redis + MySQL

  • 项目架构:asyncflow项目模块拆分为async-common、async-deal、flowsvr、worker4个核心模块(PS:在梳理代码结构的同时,顺便对项目内容做了调整,将一些公共的内容抽离出来)

    • async-common:公共内容抽离
    • async-deal:服务/任务治理模块
    • flowsvr:提供任务管理、任务调度等web服务接口
    • worker:由业务方实现业务调度逻辑,与flowsvr进行交互

✨为什么要做模块拆分?

challenge:**场景单一不需要分层?**目前看感觉flowsvr、async-deal模块的用处太小了,为什么不干脆把这些逻辑都写到worker里面,让Redis交互就好了。为什么还要分层、分模块?

  • 模块拆分、架构分离:强调解耦,耦合度考虑、框架的可维护性
  • Redis 交互考虑:从数据库选型切入,结合业务场景说明
  • 成本和效益考虑:实际项目开发中面向的是微服务场景,模块拆分接入的成本并不高,且能很好地适配现有项目架构的设计核心

​ 如果将所有的内容都融到worker中实现,其耦合性太高了,框架的设计应该分层清晰、职责分明。server可以是通用的,worker则是需要不同业务自行实现,不可能把操作db的权限放给worker。flowsvr、async-deal提供的是非业务逻辑,可以理解为框架底层,会由专门的团队进行维护,而对于业务逻辑调度的实现来说业务接入只需要关注worker的逻辑,而不需要关注其他模块,且worker可能会因适配业务而频繁修改,如果将asyncflow的DB权限放给worker可能会存在潜在风险(例如删库跑路)

​ 针对数据库的选型,由于考虑到”多阶段任务“场景,如果使用redis一方面是考虑支持灵活的任务检索机制(flowvsr不仅仅是提供任务的新增的修改的接口,还会提供给外部用户进行查询一些信息的情况),另一方面则是考虑可靠性问题,如果任务执行过程中redis宕机则有可能丢失这部分的执行数据,则会影响任务的执行逻辑

✨模块交互为什么不使用RPC通信?HTTP通信则一个类两个服务都要写,为什么不考虑RPC?

​ 两种方式的应用场景侧重点不同,此处主要是对公共代码的抽离处理,有多种处理方式:jar依赖、http通信、RPC框架

​ 模块拆分是为了解耦,现有框架worker和flowsvr的是基于http交互,对于公共的类部分抽离一个单独的jar进行共享,提升代码的可维护性。虽然flowsvr、async-deal两个服务可能会用到公共的类,但每个服务的职责不同,底层DB操作实现也不尽相同,这部分复用代码逻辑的重合度比较小,现有技术方案基本适用。如果采用PRC技术,引入RPC框架则还需考虑第三方技术的接入和额外的维护成本,因此对于技术栈的选择还要考虑业务团队技术栈的对齐以及框架的接入成本。

🚀存储选型问题

📌为什么存储用MySQL不用Redis?

​ 注意此处的问题是针对的是业务数据的存储,而非“多机资源竞争问题”的锁概念

如果是“多机资源竞争问题”的锁概念

​ 此处涉及到一个”多机资源竞争问题“,之前在设计的时候考虑过,如果是基于MySQL存储,需要考虑多个worker竞争资源的并发问题,可以通过MySQL锁(for update行锁、悲观锁)来解决,但是性能较差。后续考虑引入Redis来实现分布式锁,但实际上仍然存在多个worker相互影响的问题(拉取与执行耦合导致)。为了避免多个woker相互影响,考虑借助消息队列来对拉取和执行操作进行解耦。

​ 从方案选择上来看,借助消息队列看起来是最优的方案。从成本和收益上看,考虑到实际上针对耗时的异步框架的性能瓶颈其实并不在拉取,而是在于实际执行(调用底层服务的QPS),因此单机MySQL也足以支撑业务场景,而不需要再引入一个Redis组件的维护场景。

针对业务数据存储的数据库选择

结合MySQL 与 Redis 的区别、应用场景进行说明

​ 业务数据存储的数据库选择主要考虑的是数据的可靠性,MySQL是关系型数据库,支持持久化和灵活的数据管理化场景。Redis是关系型数据库,相对MySQL并没有那么可靠、不支持关系型查询。

📌为什么存储用MySQL不用MongoDB?

​ 众多选型中,MongoDB无疑是最有竞争力的,MongoDB可以说是最接近关系型的非关系型数据库,性能好、可扩展、支持文档数据库。MongoDB的局限在于不支持事务和不支持Join,但现有场景几乎也不用事务和Join,其实MongoDB还挺适合现有场景应用的。

​ MySQL和MongoDB都比较适合现有场景设计,但结合实际场景考虑,现有团队的技术栈基本都是用MySQL(是很多业务团队的基础设施),因此初步方案考虑暂时不引入新的存储组件,以减少运维和容灾成本。后续也可以根据需要可以接入MongoDB存储,支持不同的存储组件

3.存储、接入、部署

todo 存储相关 接入相关 部署相关

✨框架怎么接入呢?

​ 明确框架定位:只提供框架代码、部署推荐方案,秉承“谁用框架、谁部署、谁负责”的原则,框架不提供异步任务处理服务给外界调用,只是提供异步调度的规范

​ 业务接入框架需要创建三个表结构,启动server并且完成任务参数的定义,这里完成了生成者的部署,第二个就是消费者worker的部署,我们框架执行任务需要自己注册任务进入框架,这里你可以简单的理解为一个worker对应一个类型的任务,那么这个worker注册任务就是通过实现我们提供的任务抽象接口进行注册,具体就是我们提供了一个抽象的AsyncExecutable接口,然后接入方实现这个接口(Lark)

注册任务??只提供框架、制定规范、不强制接入方法如何使用

确定部署架构

​ todo

基础环境构建

  • 框架依赖环境构建(例如MySQL、Redis,数据库表相关配置等)
  • 框架定位:提供框架代码、部署推荐方案进而实现功能复用,而不是提供异步调用服务给外界调用(倾向提供一个jar概念),秉承谁用框架、谁部署、谁负责的原则
  • 数据表:位置信息表、任务配置表是共有的,而任务表是根据业务类型进行拆分

部署、启动、应用

​ 从创建任务(启动flowsvr)、消费任务(实现worker业务逻辑、启动worker程序消费任务)两个方面进行扩展说明

  • 创建任务:部署flowsvr服务(启动flowsvr程序),可以使用脚本或者访问接口调用flowsvr创建任务的接口,与MySQL进行交互,将任务存储到对应的任务消息表
  • 消费任务:业务工程导入worker依赖 =》实现接口方法(多阶段任务执行逻辑)=》启动worker程序
    • 编写任务执行逻辑、注册任务,待拉取到对应类型的任务,则可执行任务(worker提供SDK的方式提供了3个接口方法,需实现相应接口的业务逻辑,将这类任务注册进框架),下述操作需由框架使用者(即用户、开发者)来执行
      • ContextLoad:定义如何解析上下文(比如如何将上下文字符串解析到程序中使用)
      • HandleProcess:自定义一种任务类型的真正处理逻辑
      • HandleFinish:用于处理任务完成后的逻辑,也就是在任务成功或失败后,需要执行的一些后续操作(例如可以在这个方法中发送任务完成的通知、记录任务的执行日志等。具体的逻辑根据业务需求而定)
    • 部署worker,消费任务:启动worker程序,消费任务

✨框架怎么部署呢?

​ 框架的部署方案有多种,支持单机、多机部署模式,例如:

  • 1个flowsvr、1个worker
  • 1个flowsvr、n个worker
  • m个flowsvr、n个worker

​ 具体部署模式取决于实际业务场景,可以单独部署,也可以整合进现有的部署架构(例如挂靠现有架构)

单独部署:一般推荐flowsvr、worker都是多机部署,MySQL或Redis使用主从模式即可。所谓flowsvr、worker都是多机部署即启动多个flowsvr、worker程序,他们都是无状态的(worker之间或者flwosvr之间无交互),由于flowsvr是提供web服务,因此flowsvr可以做负载均衡架构

挂靠场景:提供asyncflow框架,业务方可以根据自己的需要部署,例如区块链场景使用的部署模式是提供两台16c 32G的主机,在腾讯云上k8s部署,flowsvr和worker都部署在集群中,flowsvr有3个pod,worker也是3个pod,MySQL、Redis使用主从模式,flowsvr通过CLB的能力对外提供接口

多个flowsvr端没有主从区分吗?例如主端负责调用?

​ 项目提供了多种部署方案,可以是1个flowsvr/1个worker、1个flowsvr/多个worker、多个flowsvr/多个worker,具体的部署方案是取决于业务架构设计。此处针对flowsvr的1和多理解需区分是共享逻辑和表空间概念还是部署概念

​ 针对1个flowsvr,实际上可以理解为1个或多个worker共用1个flowsvr(共用一套逻辑和表空间,多个不同的worker访问同一个flowsvr),但在部署架构上会部署多个flowsvr节点(但实际上都是跑同一套代码、共享表空间),这种部署方式主要是基于对单点故障问题的一种容灾方案实现

​ 此处需理解“多个flowsvr”的含义,不要混淆了多个flowsvr的场景含义,要理解此处“多个flowsvr”的概念是指flowsvr是业务团队共有的还是每个业务团队独立的?还是说一个业务方案中的架构部署了多个flowsvr

  • 如果flowsvr是业务团队共有,则使用的都是同一个flowsvr,访问的都是同一个数据库
  • 如果flowsvr是业务团队私有,则使用的是各自不同的flowsvr(就会有“多个flowsvr概念”),访问的是各自的业务数据库

​ 但不管是业务团队共有还是业务团队私有,部署的都是同一套代码,只不过管理的业务数据库、业务部署架构可能有所不同,并不矛盾。而实际的部署则取决于业务团队的架构设计选择,例如主从架构、负载均衡

  • 主从架构:部署节点有主备之分,从节点作为备用节点是一种容灾方案的实现,当主节点挂掉则由从节点顶上
  • 负载均衡:CLB 部署,每个节点按照策略承载相应的流量

✨worker 的接入(业务逻辑需要自行实现吗?是在worker里面写业务代码?还是在业务工程中引入worker?)

​ 任务流转是在worker中实现的,通过http调用(可以看下rpc vs http的优缺点对比)。业务主要核心关注每个阶段的业务执行的处理逻辑,设置好流转参数、调用方法执行即可。

​ Q:worker中具体的任务流转实现需要直接在worker中实现吗,还是说需要另外再拆rpc服务去做远程调用,两者的取舍或者差别?假设任务的各种状态的处理都是自己本身业务的,也就是都需要自己去实现的?

​ A:框架提供了worker相关jar,业务可以通过引入worker依赖,然后实现接口方法填充多阶段任务的执行逻辑,启动相应的程序即可进入消费

​ Q:无论是rpc和http都是调用外部的接口/方法,如果都需要自己实现,是直接封装在worker中(不需要调外部)还是另外单独封装成http/rpc应用,因为感觉worker本身就是用来处理任务的,直接实现在woker中的话应该也遵循 SOLID 的单一职责?

​ A:问这个问题是想问doSomething(任务执行逻辑)的DB操作这个点,而非操作asyncflow的DB。一开始在学习这个项目的过程中有想过如果这个框架要嵌入到实际的业务场景中要怎么用?它的核心步骤是构建表结构、接入框架(实现AsyncExecutable接口)、部署。在接入框架这块说的是实现接口方法,但实际上好像并没有强制一定要用worker这个工程。 所以说接入worker的方式可以像使用其他的框架一样,在原有业务项目中通过引入jar的方式来实现接入,这样实际上也并不影响原有的业务工程DB交互。 感觉好像一开始也被worker这个抽象的概念带偏了思路,worker接入、worker部署,感觉业务逻辑都要嵌到这个worker里面,有种worker成为主工程的概念,虽然理解了任务流转的过程,但突然发现业务不知道怎么更好地嵌入进去。但实际上对于worker的定位感觉更倾向于框架提供的SDK概念,可以理解为基于框架规范的实现,只要基于框架提供的类实现接口逻辑、启动程序消费任务即可

worker接入的方式:

  • 方式1:在业务工程里引入worker相关依赖,实现接口逻辑,启动程序消费任务
  • 方式2:worker作为一个工程专门负责异步任务调度(它与业务工程之间的交互:把业务嵌入到worker、通信(http/rpc等))

​ 工程实践的选型取决于业务如何使用这个框架,一般选择方式1进行实践,如果有新任务类型接入相应也要关注新任务的执行逻辑,框架的接入成本也体现在这一块。

​ 如果选择方式2(相当于把worker当做一个专门处理异步任务的主工程,需考虑其和业务程序的交互):如果直接把业务逻辑集成在worker内部(强耦合,但不需要远程调用),如果是业务独立出服务/应用,则需要worker去调用(解耦,但面临第三方服务宕机的重试问题)

✨worker 的实现逻辑:代码如何实现多阶段任务的调度

阐述实现的思路,结合现有框架实现说明(worker 如何执行每个阶段的任务,代码实现的核心思路)

​ worker框架的任务流转有多种方式实现:switch...case...、反射等

方式1(最原始的if..else...方式):switch case

​ 框架抽离的是核心任务调度的公共内容,回归最基础的任务调度思路,一开始业务最基础对于多阶段任务的执行,一般是前后端进行搭配,根据任务状态选择要执行的操作,最常见的处理方式就是switch...case...

方式2(反射):

​ 将任务配置和代码实现进行关联绑定,例如taskType对应任务实现类、taskStage对应任务当前阶段要执行的方法。可以理解为任务类型 taskType = 对应任务实现类、任务阶段 taskStage = 方法名、上下文taskContext存储方法执行所需参数及其他业务信息

​ 以执行任务类型为“Lark”的“handleProcess”为例,实际上对应调用“packageName.Lark”类的“handleProcess”方法,其中反射执行方法所需的参数则通过解析上下文进行获取。

​ 此处上下文定义存储的格式是json格式,主要包含3个字段:clazz(函数执行参数列表类型)、params(函数执行参数列表值)、envs

{"clazz":["java.lang.String","int"],"envs":[],"params":["啦啦啦啦啦",10086]}

方式3(自定义任务流转逻辑):灵活适配,业务自行改造

业务阶段的流转如何实现取决于对worker的实现,框架只是提供一个调度规范,业务可以在框架约定范围内灵活适配,上下文记录的定义取决于业务实现任务执行逻辑存储的格式和内容,由业务决定存储和解析,例如上下文记录(可以记录阶段执行的要素,然后根据上下文来调用业务逻辑)

{
    "stageName": "printMsg",
    "param":
    {
        "arg1": "hahah"
    },
    "nextStage":
    [
        {
            "stageName": "printMsg1_1",
            "param":
            {
                "arg2": "hahah"
            }
        },
        {
            "stageName": "printMsg1_2",
            "param":
            {
                "arg3": "hahah"
            }
        }
    ]
}

✨worker是托管在框架这边的服务器吗?还是在对于异步任务的模块?如果worker托管在异步任务处理框架的服务器,那处理完之后的数据怎么回写到业务方的数据库?

谁来部署?谁来维护?模块之间如何协作调用?

​ 问这个问题,说明对框架的定位还不是特别理解,项目设计的核心是提供框架,而非提供异步处理任务的接口调用服务。如果一个业务团队想接入,需要自行部署flowsvr、worker。如果业务下有几个子业务,推荐业务提供共用的flowsvr,子业务都调用这个flowsvr,worker的话可以子业务自己部署

​ 可以理解为如果flowsvr公用则主要涉及任务配置、任务状态等信息统一控制,而worker的执行拆分两部分逻辑,一部分是业务逻辑处理、一部分则是任务状态更新(可以直接连接数据库进行状态更新,也可通过调用flowsvr暴露的服务来控制数据的更新)。可以参考flowable这些流程引擎框架的设计,其可以是业务单独部署,也可以是业务共用一套后台,而对于业务的处理部分则是自定义实现。

​ 其实本质上,一个worker可以调度多种任务类型,只要有对应任务类型的接入但一个worker调度一种任务类型也是可以的,可以让架构更加鲜明,分离业务

✨user_id 如何理解?不同worker接入如果设置?(todo:待确认)

​ 对这个user_id的设置概念也有点模糊,不太清楚user_id存的应该是业务开发相关的用户ID记录,还是说使用这个框架的worker的唯一标识概念?还是说根据场景灵活适配?

​ 从后面的检索需求场景来看userid好像对应的是业务关联的用户ID记录,如果有一些场景下不同业务使用不同的用户表的话,就没有一套统一的用户权限了,那flowsvr根据user_id关联检索的话就会变得繁琐

​ 后面完善下user_id的设置应该ok。但也考虑到如果有不同的单体项目接进来,如果用的都是不同一套的用户体系,感觉这块逻辑就会涉及调整。可能会让flowsvr这个根据用户关联检索变得复杂,不过应该也无关紧要,我们只要提供根据用户ID检索的记录,如果非要关联查询就让业务在自己的用户逻辑自行关联下,暂不复杂化场景

​ 如果 user_id 存储的是业务关联的用户ID,则需考虑分布式场景下这个user_id的唯一性。可能不同业务系统并不共用一套RBAC体系,那么可能存在 user_id 重复导致的数据混乱问题,因此不能单纯靠业务用户表的主键存储,可以加上唯一系统标识构建user_id,用以区分不同系统下的不同用户的任务数据

​ 如果 user_id 存储的是就"接入框架的业务的唯一标识"(可以理解为app_id概念,用于区分不同系统的业务接入,一个系统可以有多个任务类型的任务接入)

✨上下文怎么存储的,存不下怎么办?

先阐述一般正常场景下不会出现这个问题?然后分析如果出现存不下的问题该如何处理?

​ 上下文的存储是在相应业务的task表中的一个task_context字段存储(这个字段的配置比较灵活,可以结合实际业务需求设定存储格式,例如json、xml等),最大存储8192个字节。一般业务场景下上下文字段只存储关键信息,所以存储内容不会太大。如果真的遇到存不下的情况,可以考虑其他方案进行处理:

  • 【方案1】用url进行传递(类似引用云存储:将文本数据丢到存储容器中,然后通过url访问。一些云服务提供了专门适用于存储静态资源的容器),因为字段内容太大不管是对存储还是网络传输都不太友好
  • 【方案2】如果上下文过大,可以当作是文档,可以考虑借助其他更加合适存储组件,例如MongoDB

✨对于多阶段任务流程,如何去优化?

​ 多阶段任务流程的实现是由业务实现决定的,这部分并不由框架决定。可以基于AOP的思想,先记录任务执行前后的时间(确认任务执行耗时),然后针对性地对每个任务进行优化

✨上下文存储的内容是什么?为什么这样设计?

​ 上下文存储的是任务流转的中间参数,主要结合框架的任务流转处理逻辑进行设计。

​ 对于多阶段任务的执行,可能阶段之间需要依赖上一阶段的产物,这个上下文可以理解为阶段传递的一个中间参数(例如阶段1生成一个内容,这个内容下一个阶段会用到,那么阶段1执行完成就会把它的产物存起来,当任务进入下一阶段就可以拿到这个内容继续执行了),这个上下文的格式是可以自定义的(json、xml等都可,取决于业务怎么存储和解析)

✨项目的平滑升级如何考虑?

例如引入了新的业务类型,worker升级的话是要停掉之前的服务然后进行升级吗?这样会不会对执行中的任务产生影响?

​ 如果是基于业务类型考虑,如果每个worker执行各自业务类型的任务,实际上这个问题就回归到微服务场景“多模块上新”的问题,可以理解为A和B模块拆分,各自的迭代升级是不会影响彼此的。基于此,如果每个worker处理各自业务类型的任务,那么新增一种任务类型的woker的升级是不会影响到现有的worker执行状态的。

​ 但是如果一个worker中需要处理多种不同业务类型的任务,那么worker更新实际就回归到模块更新的内部影响问题,如果像是本地调试的热部署概念,每次更新通过替换代码的形式来做到“无感升级”,可能这个时间段会造成服务的短暂不可用,但可以尽量把影响压到最低。主要还是要结合实际部署升级的方式来考虑,这部分的平滑升级一方面是业务代码的迭代升级、一方面是运维方的平滑部署。

​ 除此之外还有一种是通过人工介入的硬核方式来进行升级,例如项目要进行部署的时候会选择一个系统使用低峰期进行升级,升级之前考虑到各种影响也会通知相关的业务部门提前做好准备。或者通过代码介入,在worker更新的时候“暂停新任务的接入”等等,但这种思路可能相对复杂

​ 因此基于项目的平滑升级考虑,可以结合两个方面说明:

  • 如果是每个worker处理各自任务类型的业务逻辑,那么不同任务类型的worker的升级是互相不受影响的(建议,类似微服务场景的模块概念)
  • 如果是1个worker需要处理多种不同任务类型的业务逻辑,则涉及worker内部的代码迭代,升级时不可避免会对现有系统状态产生影响,可以考虑热部署或者其他方式降低这种影响(运维层考虑),就算任务执行受到影响,框架中提供了相应的重试和兜底机制来保证任务的正常执行
    • 降低影响的方案考虑:例如容器部署,A容器依旧正常运行、B容器迭代升级过渡,待系统部署确认无误就把节点切换过去

4.任务创建与调度

🚀任务创建相关

📌任务创建的流程

实际上就是调用flowsvr创建任务接口的过程分析:接口调用 =》接口响应(参数校验、创建任务、构造响应数据)

​ 调用flowsvr服务接口(http请求调用),flowsvr接收请求找到对应的接口(controller接口),解析参数并检查其合法性。然后开始创建任务(查询目前插入的表号,将数据插入数据库),最后构造返回包给调用方

📌是否支持批量创建任务?

​ 目前框架设定是单个常见任务,需确认有什么场景需要支持批量创建任务。由于任务初始化可能需要上下文,如果批量创建的请求太大,可能会导致请求超时异常等问题, 因此要结合实际业务场景考虑技术实施的可行性和必要性

🚀任务调度相关

📌拉取任务接口的流程

​ worker发起占据任务请求,flowsvr接收请求、根据路由找到占据任务函数并进行检査参数,随后从数据库拉取一批任务,将这批任务设置为执行中(将这批任务的任务状态设置为执行中,表示占据了这批任务),填充返回包并返回给worker

任务拉取的规则是什么?是做完一批拉一批(等任务执行完后拉取)?还是定时拉取? =》定时同步拉取,异步调用执行方法:此处定时同步拉取的概念是框架引入了Redis分布式锁,只有抢到锁的worker才可以拉取,所以有一个“同步拉取”概念(即没抢到锁的worker需排队等待下一个拉取操作);任务拉取成功后,worker会将这些任务丢到线程池中,并发执行这些任务。

​ 需注意此处任务是定时同步拉取、异步并发执行,与worker现存任务执行状态完成与否无关(如果要等任务执行完成再拉取,这种操作就变成同步概念了,并不是异步调度,与设计理念违背)

📌worker调度的流程

​ 执行worker启动类,启动AppLaunch类,worker拉取指定任务类型后开启线程并发执行任务。多阶段任务的调度是通过taskType、taskStage字段分别获取到对应的任务类和执行方法,然后通过反射调用并执行代码逻辑,并相应保存上下文+阶段信息,更新任务的执行状态(回写数据库)。当前阶段执行完成,更新任务状态,随后任务进入下一阶段等待下次被调度。

​ 例如案例中taskType:Lark、taskStage:handleProcess,则表示调用Lark类的handleProcess方法,这种方式是通过反射实现多阶段任务调度

📌项目里面worker是手动还是自动发请求拉任务?(美团一面)

​ 自动发请求拉任务("拉模式"),自动按照一定时间间隔(例如30s)拉取(时间间隔作为任务配置表的参数,用户可以灵活配置)

​ 此处拉取的阈值需结合业务进行配置,例如要一个业务类型每s最多执行100个,则配置100左右即可(目的是为了做限制),如果阈值设定太大也没什么意义,底层服务调度不起来

一个worker是一个进程,一个worker可以拉取一个任务并发执行

📌MySQL中查询是查一大批还是查一个?

​ 拉取任务(占据任务)是批量拉取,减少对MySQL的请求,节约性能的同时可以提高效率,具体单次拉取多少可以通过任务配置表进行灵活配置

📌任务拉取按什么顺序?

结合排序顺序和优先级概念进行说明:一开始考虑过用创建时间、修改时间作为任务拉取顺序,但是均存在弊端。最终选择基于order_time + 优先级的综合来作为任务拉取顺序参考

​ task 表中有一个order_time字段,这个字段是一个影响任务执行排序的因素的整合字段,通过这个字段进行排序、拉取任务。如果任务设计了相应的优先级,还需整合优先级因素进行排序。可以扩展说明为什么要用order_time字段?(这个字段的构建分析)

  • 创建时间(对于多阶段任务来说不公平):按照创建时间进行排序,类似队列的概念,先拉先执行,但对于多阶段任务而言,可能会出现拉取的某一批任务执行完成后进入下一阶段的执行,由于其创建时间会早于其他任务,则下次拉取可能又会拉到这批数据,进而无法让出调度给更饥饿的任务,看起来并不太公平,理论上每一阶段都要进行重排的
  • 修改时间(虽然任务每阶段都会重新排序,但是与重试间隔概念冲突):按照修改时间进行排序,任务在某个阶段执行完成会更新状态和更新时间,可以让其他普通任务也有相应的调度机会。但是由于重试机制的引入,如果设定了重试间隔,基于修改时间的排序就会出现歧义,本质上是业务希望任务执行失败后缓一缓再执行,如果按照修改时间拉取,则执行失败的任务很快又会被拉到,反复重试、反复失败的话就会阻塞其他普通任务的执行,成功率并不高
  • order_time + 优先级:对于排序规则的设定需要考虑任务执行的均衡性和容错性,这是任务排序设计的一个基础理念。在一些异常情况下,可能某些任务的执行跟中毒一样反复失败,排序设计的机制要尽量确保worker能够正常运作,提升任务的成功效率。因此抽象出一个order_time字段(整合了所有可能影响任务排序的因素)结合优先级概念来完成任务排序
    • 创建时:order_time = current_time(create_time) - 优先多少秒
    • 修改时:order_time = current_time(modify_time) - 优先多少秒
      • 任务执行失败时,如果设置了重试间隔,则优先级失效(避免出现优先过大而导致重试无效):order_time = current_time(modify_time) + 当前计算的重试时间
📌优先级是怎么做的?

​ 针对优先级的考虑有两个方向:

  • 方案1:"等级制度"概念,priority存储数字表示优先级别(例如业界常规的方案是0-9这种优先等级的设定),数字大小决定优先顺序
    • 一般通过构建联合索引提升检索效率,具备一定的维护成本
  • 方案2:"优先具象化"概念,赋予priority一个业务含义表示优先多少秒,结合order_time共同决定排序顺序
    • 需注意重试的场景中,如果设定了重试间隔,理应让优先级失效(否则可能会因优先级设定过大进而导致重试间隔的设置无效了)
    • 且对于处于重试间隔的数据,如果从数据库中取出的数据,按照重试间隔进行筛选,如果还没到重试间隔则不会运送给worker

​ 为什么选择方案2:因为方案1中虽然满足业务基本场景需求,但实际上对于业务场景而言,priority大部分都是重复的(因为普通任务是比较常见的,占据大部分),所以联合索引的构建需要考虑成本和效益(开销大于效益);其次优先级不够灵活,优先级高的任务失败之后很快又能被调度到,失败重试场景中容易占据资源堵死普通任务。

​ 因此选择采用方案2优先多少秒的思路,并将这个字段融入排序规则,这个方案耦合小、成本低、足够灵活。且当秒数设定为一些界限的大、小时,又可以具备"级别"的作用

📌这个优先级和实现方式,有没有想过应用场景?如何定义优先级A和优先级B是多少,A比B为什么优先(阿里云二面)

主要考察的是优先级的设计是否有业务场景支撑?

​ 框架的优先级,表示优先多少秒。业务可以自行抽象,比如一个视频处理团队,他可以定义超级VIP,优先级10小时,也就是说正常情况下,他都是排到队头,但是如果任务已经阻塞了几天,他并不是最先执行的,这时候还是优先照顾已经等很久的任务。

​ 也可以定义一个SSVIP,这种VIP就是优先1年,也就是说基本无视时间,即使队列积压了几天的任务,他也直接最先处理。

📌优先级设计是绝对优先还是相对优先?(阿里云二面)

相对优先:"绝对"容易造成堵塞,此处采用的是相对优先的方案(优先多少秒),在某些特殊场景下也可通过设置非常大的优先秒数来变相达到绝对优先的需求

📌重试间隔是什么机制?

​ 重试间隔:是指任务执行失败后需要等待多长时间再次尝试。主要是用于一些任务失败后不希望立刻重试的场景,以免造成不必要的资源浪费(例如一些收费的接口调用可能执行失败了,如果不设定合适的重试机制,就会反复重试以确认成功,就会造成资源浪费)

​ 重试间隔一般有两种方法:均匀重试、渐进式重试,框架设计中采用interval字段来支持这两种重试方案(为了不单独引入额外的标记字段区分不同的重试方案,此处设定interval如果为负数表示均匀重试时间、正数表示渐进式重试时间),如果需要额外引入其他的重试方案,则可以考虑支持其他更丰富的重试策略(例如lua文本解析等)。但考虑到回归框架本身,现有方案已基本足够支撑业务场景,不需要进行复杂化(业界的现有框架celery这类竞品都不会考虑这么复杂)

📌flowsvr怎么知道任务超时了,定时器轮询吗,判断任务是否超时,有无更好办法?(B站二面)

分析:首先此处需对齐所谓任务超时是什么情况,这里容易有歧义的。此处所谓的任务超时,是指一个任务被占据之后,长时间处于执行中,这个长时间的判断标准,是由业务来设置的

  • 例如调用腾讯云图片审核能力,即发起一个接口调用,那这个阶段的占据时间,通常不会超过1分钟,1分钟就是最大执行时间;
  • 如果是下载一个文件,那时间可能就是5分钟或者更久

任务是否超时由任务治理模块去定时轮询确认:框架中使用定时器轮询,此处抽象了一个任务治理模块,去定时扫描这些超过最大执行时间的任务,最大执行时间也是业务根据经验设置的,这类任务一般都是由于异常原因,worker没有上报异常结果导致的,可能是worker挂掉等原因,需要任务治理去发现,可能会导致“同一个任务被拉取多次并执行”的情况,需要通过确保业务调用的下游服务确保业务的幂等性。即“woker 拉到任务执行时,调用的那个服务能满足幂等,这样即使一个任务被多个worker 拉取到了也没关系”(一般是第三方服务考虑,不是框架设计范畴)

​ 引入通知机制是一个方向,但实现会复杂化。例如视频处理一般是 30 分钟,业务设置 2小时为过期,如果期间执行因某些问题停滞了,例如worker挂了,此时就算通知worker也没用,只需确保业务的幂等性足以。

​ 所谓幂等:两次相同的请求导致一样的结果,比如a转账给b 1元钱,这笔交易假设有个订单号是100001,只要请求的订单id是一样的,就算这个请求来了多次,结果也应该是一样的

✨幂等性问题

问题1:如何理解需业务本身要保证业务的幂等性?

​ 现有框架实现版本可能会出现“同一个任务被再次拉取执行的场景”(例如任务执行因某些问题超时而被治理服务发现并重新丢回池子,则任务会被重新调度,但如果前面的worker无法感知任务状态的变化而继续执行,则可能出现问题),因此要确认同一个任务被重复拉取执行的情况,框架本身只是提供业务实现规范,而具体的调度逻辑要由业务实现,应当由下游业务保证其业务的幂等性

问题2:如何理解“作为框架不应该完全指望业务本身要保证业务的幂等性,那很多情况下任务执行多次得到的结果是不同的”这一问题?

​ 首先理解幂等性的定义,框架的设计其实和消息队列的定位类似,消息队列的定位是消息的流转,框架的定位是任务的流转,最后的一致性是需要配合业务幂等去保证的。如果要由框架去保证幂等,则任务只能执行一次,基于此则无法实现重试功能

​ 此处业务上做幂等就是指在worker拿到任务之后的执行逻辑里面做幂等校验,首先worker中的任务执行逻辑都是业务自行实现的,框架只能保证任务至少一次执行,不能保证唯一一次(因为有一些异常情况下、或者是基于重试机制等都会导致任务被重复调度执行)。如果业务要求不能重复,那就只能由业务拿到任务后执行判重逻辑。 类似消息队列也只能保证消息至少一次,保证不了精准m次, 因此要去重的话,一般都是消费方去做幂等;

🚀多机竞争相关(Redis 分布式锁方案)

📌worker怎么竞争任务?

从多机竞争问题,引出现有方案实现说明,结合问题阐述解决方案的演变

​ worker 涉及到多机竞争问题,此处采用的是Redis分布式锁来解决并发资源占用问题。多个worker会竞争锁,竞争成功的worker则可拉取任务,等待任务占据成功后释放锁,以减少并发影响

📌有多个flowsvr的话,怎么让不同的flowsvr不要拉到同样的任务?

考察并发场景共享资源的调度问题,此处说明多机竞争的解决方案

​ 拉取任务的时候worker加了分布式锁,所以同一时间只会有一个worker来发一个拉取并占据任务的情况,占据之后对应的一批任务状态也变成了执行中,下一次拉取占据就是另一批任务了,所以flowsvr不会拉取到同样任务。

📌为什么不用MySOL自带的锁?(滴滴2面)

多机竞争问题:并发资源占用问题的解决方案

​ 使用MySQL锁有两种实现思路,但存在弊端,为了尽可能较少并发影响,所以不用MySQL锁

  • for update(行锁):由于创建了索引,在一些场景下行锁升级为间隙锁,可能会对隔壁行记录的操作(对其他SQL语句)造成影响(例如出现workerA拉取任务时锁住了隔壁的记录,使得workerB更新不了记录状态导致阻塞问题)
  • 悲观锁:为了避免行记录的影响,采用悲观锁的设计方案,新增owner字段标记记录归属于哪个worker。对于竞争写锁成功的worker而言其更新操作是有效的操作,但对于竞争写锁失败的worker而言其更新操作时徒劳无功的操作。基于这种场景,如果worker多一点碰撞就会很激烈,就会多出很多无效的写操作,白白浪费资源

​ 综合考虑下引入Redis分布式锁来解决并发资源占用问题,减少worker之间的并发影响、减少SQL调用次数以提高性能。但不可避免worker还是可能存在影响,例如此处拉取和占据是耦合的,就会导致在拉取任务期间其他worker闲置了。此处通过减少多机竞争提升了QPS,其根本原因是通过降低锁的粒度

📌用了分布式锁,一个任务就一定不会被多个worker同时拿到吗?

考察对分布式锁的理解

​ 不一定,不同的分布式锁实现方案的执行效果有所不同。分布式锁只能说在绝大数情况下,都能让一个任务被一个worker拿到,但是如果发生一些异常,比如worker陷入gc,锁又过期了,这时候就会被其它worker拿到,这时候头一个worker恢复过来了就会变成同时执行同一个任务。因此此处业务自身是需要做幂等的。

📌竞争分布式锁这种方式,会不会有什么问题?

从多机竞争问题思考,分析锁的引入会导致什么问题,是否存在性能瓶颈?

​ 主要从抢锁操作会成为系统瓶颈导致worker无法水平扩容的角度出发,分析具体逻辑,再进一步抽象这个问题本质上是由于同步拉取和并发执行两个操作的耦合导致的,最后可以再提出使用单独服务拉取任务到消息队列的解耦方案

抢锁这个过程会导致其他没有抢到锁的worker空转,且worker数量越多,这个空转发生的概率越大,空转时间就越久,多机竞争问题就越明显,所以分布式锁方案会限制水平扩容,也就是worker数量不能够设置很多,整体的性能会受到一个限制

分布式锁的引入限制了worker的水平扩容,因此考虑引入消息队列方案,将同步拉取和并发执行两个操作进行解耦 ​ 加入分布式锁之后,分布式锁的抢锁操作可能成为一个瓶颈,影响worker的水平扩容,因为每个worker在拉取任务之前都需要抢分布式锁,当worker越多,抢分布式锁的竞争就越激烈,如果锁持有时间是一定的,则一个时间段可以抢到锁的worker数量是有限的,这也限制了worker的水平扩容。同时,worker在抢锁失败之后需要等一段时间再次抢锁,使得大量worker可能只是空转,浪费了资源总体上来说,导致这个问题的本质原因是在设计中将同步拉取和并发执行两个操作耦合,如果要进一步解决这个问题还是要思考将两个操作解耦。

​ 例如引入消息队列方案,不需要抢锁拉取任务,将任务放到消息队列中,不同的worker直接从这个消息队列里去拿任务执行,只要消息队列的任务量管够,worker执行任务就不需要抢锁,自然就解决了多个worker抢锁导致的问题

📌不使用Redis,如何解决多机竞争问题?设计一个新方案

本质上是Redis锁的性能瓶颈问题,一步步引出优化方案

​ 分布式锁会带来worker无法水平扩容的问题,本质是同步拉取和并发执行的耦合,所以这里可以引入一个拉取服务,专门来拉取并占据任务,然后扔入kafka,通过kafka多分区特性worker就可以并发消费,这样就实现了解耦。

📌如果采用的是多台worker竞争任务的方式,那这样会不会出现有些worker在执行大量任务但是一部分worker处于空闲的状态呢?(滴滴一面)

Redis锁解决多机竞争存在的不足:worker之间会互相影响,但影响的时间并不长(Redis锁的加锁范围是在任务拉取这一步,任务拉取完成后就释放了锁,而真正调度耗时大头在任务执行这一步)

​ 以目前实现的分布式锁方案分析,worker调度的流程分为两部分:任务拉取、任务执行,worker调度需先拉取到任务才能执行。Redis加解锁是针对任务拉取这一过程(即在这一过程worker之间会相互影响),当抢到锁的worker在拉取时,其他没抢到锁的worker只能被动等待下一次拉取操作。假设workerA优先抢到锁占据任务之后(即任务拉取完成后)会释放锁(在任务执行前就释放了),这个过程是相对比较短的(对比之下执行任务才是耗时大头)。此时workerA可能还在执行中,但并不影响其它worker也可以拉到任务,正常情况下不会出现一些worker执行大量任务而一部分worker处于空闲,如果出现了则可能是任务太少而worker太多了

占据任务之后就会释放锁,这个锁是在执行之前,假设资源配置合理,那多台机器都会有任务去做。如果出现有机器闲置,说明可以缩容。另外,为了避免任务比较少时,一个worker老是竞争成功这种不均衡的情况,加入了一个随机的间隔时间以增加更多随机性(schedule_intervel拉一次任务比如每 5s,这时候每次刻意波动-10-10ms)

📌怎么得出分布式锁的性能很差,然后要引入MQ的结论的呢?是理论上觉得比较差,还是实际上观察到的比较差。你应该是先遇到问题,然后才想着去优化吧,而不是拍脑袋的一个决定(腾讯云智2面)

​ 主要是通过理论设想到这个优化,属于做完项目的一个review(复盘)。同时也确实考虑到分布式锁方案足以支撑大部分的应用场景,且也没有实际场景因为分布式锁遇到性能瓶颈,所以还没有这么去做,原则就是不过度去实现。

📌Redis 分布式锁(Redisson)的看门狗机制,如果任务发生异常阻塞时锁是会一直自动续期吗?会不会把其他任务堵死?

如果一直续,会堵死,这也是这个方案的弊端之一,解决方案:

  • 分布式锁+人工干预:从获取到锁开始计算一个时间阈值上限,触发报警,人工删除KEY,但业务有损,破坏了共享数据同步访问的特性
  • 引入MQ:核心原因是worker中任务的同步拉取和并发执行耦合了(都在同一个woker中,得先拉取到任务才能执行任务,如果同步拉取这步gg就会阻塞执行阶段),要将其进行解耦,同步塞任务到MQ,并发读取MQ消费执行
📌asyncflow 分布式锁如何实现?

​ 主要考察分布式锁的概念核心,基于分布式锁的核心实现自定义分布式锁(参考网络资料学习案例),还有一种方式是基于Redisson框架实现分布式锁

  • 自定义分布式锁的核心步骤

    • 引入Jedis 相关依赖
    • 构建锁参数配置(LockParam)
    • 构建锁实现(加锁、解锁等操作)
    • 在业务场景中调用方法实现加解锁
  • 基于Redisson框架实现分布式锁

    • 引入Redisson相关依赖
    • 在业务场景中调用Redisson提供的方法实现加解锁

​ 结合Redis分布式锁的设计核心进行分析:

  • 互斥性:同一时刻只能有一个进程拿到同一把锁(setnx)
  • 安全性:确保锁可以正常释放(引入过期机制:setnx + expire,还需确保操作的原子性:SET lock_key lock_value NX PX 10000)
  • 对称性:谁加锁谁释放(设置owner)
  • 可靠性:确保锁的可靠性,结合架构分析(主从容灾:主从架构、哨兵模式、多机部署)

🚀任务执行异常相关问题(如何处理异常情况)

​ 项目设计采用的时**”拉模式“**进行任务的调度,flowsvr并不会去实时监测worker的情况,因此无法关注到worker的状态。引入async-deal服务/任务治理模块来处理特殊的情况,例如定时检测任务的执行状态,如果任务长期停留在执行中的状态,则相应需要介入处理(例如将状态重置为”待执行“,等待下次重新调度),但这种重置任务状态的介入处理方式可能会出现同一个任务的阶段被重复执行,可能会导致业务问题(需考虑业务调用的幂等性,而这个幂等性无法通过框架保证,而是要由调用的下游接口服务进行保证)。

📌任务如果执行中worker挂掉怎么办?

分析:如果worker挂了,那么之前拉取的任务就处于执行中的状态,不会再被调度,就会出现任务卡死的现象。任务治理服务会定期的检测是否有长期处于执行中卡死的任务如果有,任务治理服务会获得这些卡死的任务,并将他们的状态重置为待执行:select * from A where status=2 and now()> modify time+max processing time

回答:任务会停留在执行中的状态,框架提供最大执行时间的配置,超过执行时间就会由任务治理重置任务,由于保存了上下文,所以下次再做时候会从最新阶段(根据taskStage)开始做,这样一部分已经做过的事情就不会重复做。

📌任务如果执行中失败了怎么办?

考察失败情况处理

​ 框架支持重试机制,如果任务执行失败会按照配置的重试策略进行重试:

  • 如果重试次数没有超出最大重试次数,则会将该任务状态修改为待执行,等待下次重试 (拉取任务时未达到重试间隔的任务会自动过滤掉)
  • 如果重试次数超出了最大重试次数,则认为该任务彻底执行失败,将任务状态修改为失败
📌幂等性问题:任务执行超时,被任务治理服务发现并重置为等待中,如果任务状态被重置为等待中后,超时任务执行完了,这种情况怎么解决?

分析:worker执行某个任务超时(可能是执行过程中出现问题导致,但并没有抛出异常,执行时间超出了最大执行时间),恰好被任务治理服务发现则会将该任务状态重置为”等待中“(相当于重新将这个任务丢回任务池,等待下次被调用),那么这个任务可能就会被worker重新拉取并调度,但此时刚好那个超时任务执行完成了,则可能出现同一个任务被worker(可能是同一个也可能是不同worker)重复执行的情况,如何解决这种幂等性问题?

解决:目前框架无法检测worker的执行状态,基于上述分析可能会出现同一个任务被执行多次的情况,参考下述方案:

  • 首先要确保最大执行时间(根据业务设定),选择合适的参数配置,避免出现这种情况(最大执行时间设置太小容易超时,设置太大则出现异常情况阻塞时就会一直占着资源堵死)
  • 如果真的出现这种情况,则需考虑“幂等性”,即如果多次执行这个任务的幂等性保证 =》由下游业务保证

为什么发生?

​ 可以结合代码理解:由于任务执行超时导致同一个任务被执行多次的情况可能会出现在下面两种场景:

  • worker执行任务超时(worker还在执行这个任务),基于重试机制,会调用flowsvr服务更新任务状态(会根据重试次数决定是待执行/失败),如果为”待执行“则其可以再次被调度,则可能出现同一个任务被多次调度的情况
  • worker执行任务超时(worker还在执行这个任务),基于服务治理,被任务治理服务扫描到,则会更新任务状态为”待执行“,则可能出现同一个任务被多次调度的情况

​ 基于此流程分析,实际上不管是哪个”执行操作“执行,后执行的结果会覆盖前一个执行的结果,就会造成混乱的情况。如果重复执行的操作都成功自然是皆大欢喜,最终的结果是成功的,任务会进入到下一阶段的流程(此处注意对于多阶段任务的状态修改,要匹配taskStage和status(本质上就是对某个阶段的任务状态进行修改),否则如果针对这种同一个任务的多次执行状态更新可能会出现误修改了其他阶段的执行状态,导致业务异常

image-20240809180839744

​ 据此分析如下:如果修改是匹配taskStage进行状态更新,只要确保操作的幂等性,则现有逻辑是允许同一个任务重复执行的

  • 如果前一个执行1成功,则进入下一阶段的执行,后一个执行2的更新无效(因为任务已经进入下一个阶段了)
  • 如果前一个执行1失败,则任务执行结果则取决于后面的执行。如果执行2成功则顺利进入下一阶段,失败则继续重试等直至完全失败

​ 目前的方案在任务治理模块做超时检测存在不足,可能会导致同一个任务被多个worker重复执行。如果这个任务是比较消耗资源的,问题将会被放大。可以考虑优化一下超时检测方案:例如在worker的HandleProcess方法里,也加上超时检测,即这个任务超不超时由worker自己先说的算(即自己主动发现问题并处理,结束掉当前的执行操作,注意引入重试机制的影响),超时了返回err,等待下一次调度重试。如果异常阻塞未上报,这时再由任务调度模块的超时检测来处理。

场景分析:1.worker执行中阻塞导致超时=》2. 服务治理发现超时,重设该任务状态 =》3. workerB重新获取并执行该任务,此时如果 worker 调用的任务执行接口是非幂等的,就会由于任务重复执行,导致数据计算错误,如何解决该问题的呢?

​ 让下游业务兜底,框架无法保证业务的幂等性(如果要保证则任务只能被调用一次,那么重试机制的设置也没有意义了),只能让调用的下游服务确保业务的幂等性

扩展问题:为什么要让下游服务兜底?幂等性保证?

​ 基于上述场景问题分析可知,这个配置的超时时间是由业务方定的,这个时间一般也是远大于任务的平均执行时间,正常来说不会有问题。如果有问题,任务被重复执行,这个任务重复执行的副作用要业务方自己去兜底。项目的定位是框架,框架定标准定规则,规则以外的事情是没法面面俱到

✨在这个项目里用mysql是比较多的,其中有用到事务吗?(美团优选1面)

​ 目前框架中没有主动开启事务,因为没有涉及到多表更新操作、也没有主动加for update锁的场景,都是使用单独的SQL进行处理的

项目中有用到分布式事务嘛?具体是怎么处理的 =》 没用到分布式事务,因为没有需要保证不同节点操作原子性的业务逻辑

✨如果有很多个客户端一起使用这个框架,可能会发生什么问题?(米哈游)

​ 此处的"客户端"概念存在歧义,需结合实际分析:

  • 如果是"创建任务的用户":如果后续用户量增大,请求增多,则可考虑部署多套框架服务不同用户
  • 如果是"多个业务共用一个flowsvr":框架支持部署一个flowsvr供多个业务共用,但是worker必须每个业务自定义实现(实现业务逻辑)、部署,如果这些所有业务场景量不大的话还是可以支撑的,如果压力过大,则还是考虑每个业务单独部署自己的flwosvr

✨用户怎么知道任务的执行情况?有没有项目配套的可视化界面?(阿里)

​ 目前版本对外提供的是接口,任务的详情也是通过flowsvr接口来查询。用户可以通过接口灵活查询多阶段任务的执行信息,但目前可视化界面没有配套,后续会考虑引入。

✨一组worker可以执行一种类型的任务还是可以执行多种类型的任务?如果一组worker只支持一种类型的任务会不会不太灵活?当要接入新的任务类型时还需部署新的一组worker来做?或者在worker中添加该任务类型的执行逻辑,会不会很麻烦?(中科软)

​ worker是进程概念,框架可以支持多种任务放在一组worker进行混合调度,按照order_time和优先级拉取一批任务(这批任务可能是不同任务类型的),可以在一个worker中开多个线程处理不同类型的任务

​ 其次,框架提供的是模板,具体任务的业务执行逻辑还要根据任务类型不同进行区分处理,这点是由业务控制的,不管如何都需添加对应任务类型的执行逻辑,只不过看这个逻辑是放在同一个worker,还是拆分不同worker处理

✨可否设计出一组负载均衡方案,让worker可以根据不同类型任务的多少、worker资源的情况来动态分配worker?todo??

​ 多种任务放在一组 worker混合调度,这是框架已有的功能。在此基础上优化,可以根据任务类型的任务数量,以及记录历史完成时间,来调整调度权重。

✨如果异步任务调用第三方,第三方接口限制调用次数,对于这种限流的具体实现框架中要怎么做?

分析:首先这不属于框架问题,而是属于业务之间互相调用的问题。worker中的任务执行逻辑是属于业务,所以解决方案回归到平时业务处理时调用对方服务。如果调用方有或重试需求,而被调用方有限流控制,则需基于业务层和对方协商,看重试的调用频率是否可以得到支持,如果不能支持则只能业务按照要求调用,超过指定次数则可以将这个任务设置为失败(任务完全失败则框架不会再重试,取决于参数设定)

问题提出的范畴属于业务逻辑实现,而不是框架本身。如果从问题本身切入可能会被对方思路带进去,如果思考清楚切入点是框架本身提供的是一个规范性设计,而具体的交互还得业务实现方自行实现和保证。

​ 于框架而言,worker关注的是业务重试次数,则重试达到一定次数则将任务设置为完全失败,则不会再调用服务,这个重试次数则由业务决定。如果在调用第三方之前便知道第三方接口有次数限制,想兼容对方的限制次数,得从任务配置表的最大重试次数来入手。

​ 于业务本身而言,需与下游服务确认调用频次限制,通过限流的方式来处理,例如对于第三方接口限制一分钟只能调用100次的情况下,可以限制这个时间段内任务的执行数量,参考限流方式实现思路

  • 基于 guava 限流实现
  • 基于 sentinel 限流实现
  • 基于 redis+lua 限流实现

5.分表

✨分表为什么自己写 ,为什么不用组件 ?

​ 业内分表组件不支持按照大小分片(常见组件例如MyCat根据业务字段hash(例如根据用户ID进行hash将用户数据拆到不同分片中))

​ asyncflow 的分表策略有两个维度:根据任务类型、根据任务数据量大小(阈值500w)。由于分表逻辑比较简单,且考虑分表组件的场景适配性,对比成本和效益,决定自己写分表

  • 根据任务类型:不同类型的任务存储在不同的表中,用于业务隔离,便于维护
  • 根据任务数据量大小(阈值500w):处理方式类似滚表概念(数据分冷热,老表逐渐作废,不是多个分片长期提供服务,相当于一个窗口不断滑动),当数据量达到500w则会创建新表进行过渡,慢慢将请求进行切换
    • 定时监测数据表大小:如果超出500w则创建表2,任务拉取走表1、任务新建走表2
    • 如果此时表1仍有认为未完成,则持续从表1中拉取任务并执行
    • 待表1中所有任务已完成(所有任务的状态为终态),则切换请求,任务拉取和任务新建都走表2,此时表1中的数据可以理解为冷数据,后续如果无检索需求可以直接清理

✨分表难在哪里,介绍一下框架的分表方式?(滴滴一面)

​ 分表难度体现在分表设计(按照什么分表?技术栈选型?分表思路?分表后的联合查询如何简化?),此处分表有两个方向:

  • 一是按照任务类型taskType进行拆分,将不同的业务拆到不同的表,进行业务隔离
  • 二是根据任务数据量大小进行滚动分表
    • 约定taskId生成规则,将对应的数据表号嵌入其中,便于快速定位数据所在表,减少查询成本
    • 分表前后消费、生产的变化
image-20240802104153230

✨为什么按大小分表? 为什么不按照时间分表?为什么不按照用户ID分表?

​ 按照大小分表的话数据可能并不集中,例如如果按照用户ID分表则可能需要关联多张表。考虑到实际业务的场景,任务执行关注的是产出,数据有冷热之分,一般任务执行完成后就会变成”冷数据“,业务几乎很少关注以前的数据,按照大小拆分便于清理和管理;其次此处的分表是偏向滚表概念,taskId与对应表号关联,检索相对灵活

​ 疑问:为什么不按照时间分表?与其管理任务位置,为什么不通过时间分表呢? 按照大小这个实现方式应该挺复杂的,需要记录任务位置,可否预估个任务量,然后按照时间分就可以了。比如你一天10W条,10天100W,那你两个月分一次就可以了,不用这么麻烦?

分片应考虑数据的均匀性,如果仅仅按照时间维度去分表,一来是预估任务量不可控、二来是每个时间阶段的任务增速和流量大小不平均,如果单单靠预估任务量或者平均速率区分表,可能就会出现“数据热点”问题,且容易发生数据量超过分表阈值

​ 因此此处引入按照大小分表的方式,通过定时扫描任务量确保分表的及时性(可能会有超阈值的情况,但相对可控)和可靠性

✨为了xxx项目(机器人项目)做了一个框架?量这么小,弄500w分表?

​ 500w分表的阈值是一个业务场景实践的参考数值,虽然现有业务场景可能还没有达到这个业务体量,但框架设计应该具有前瞻性,要考虑到其他业务场景的需求(例如区块链场景),让框架不那么玩具化,增强框架收益。且这个设计点的实现也并不复杂,可以认为是一个前瞻性设计,而非过度设计

✨flowsvr怎么知道到达分表阈值,轮询?这里有细节吗? 介绍下代码实现细节(B站2面)

分表应该与业务模块拆分开来,因此此处通过任务治理模块来实现分表细节(考虑到任务治理模块内容比较简单,一开始是放在flowsvr项目中,后续需单独拆分开来)

​ 通过任务治理模块轮询,可以理解为开启一个线程定时检索count(*)任务记录总数是否达到阈值。且这个实现方式不会带来瓶颈,虽然不是最优但基本足够支撑。

​ 扩展:有考虑过其他方式

  • 例如在创建任务时增加判断(这样会降低创建的吞吐,且实际上对阈值检索的实时性要求其实并不需要太高);
  • 或者是借助Redis同步数据(例如创建时Redis同步加1),相当于Redis同步维护一张表的任务总数,但Redis可能会丢数据(宕机或者网络抖动导致),可以通过定时刷入来纠错

✨如果读写请求大到超过MySOL的处理能力呢,比如一个MySOL假设就是8000/s的处理能力,此时请求有2万,这个是怎么考虑的?

被抓住无法支持水平扩展的要点,首先解释业务场景一般不会出现这个大的请求量,现有配置基本支撑业务场景,其次解释框架的性能瓶颈并不在此处,不需要过度设计

此处的“无法水平扩展”主要体现在目前表的存储方式并没有设计水平分片策略,而是按照任务数量做滚动分表,就会导致热点数据始终在那两张表中,因此可以分多少表受到任务数量的限制,例如如果分表就两张,无论增加再多的机器也是一样。而设计了水平分片策略则可以根据分片键进行hash分片,增加机器则可以增加数据的分散度,分到不同的机器上。

​ 对于普通的MySQL配置(8核16G =》每秒6000基本没问题,这个数据参考腾讯云团队公布的数据能测到2w+,但考虑不乏是其测试时硬件配置血条拉满,结合网上资料分析6000QPS基本能顶)框架的热点请求在于任务拉取和任务状态设置,这些SQL都走了索引,优化了检索性能。且本身业务场景是面向耗时的一步操作,实际上的业务瓶颈并不在这里,而是在于调度异步服务,就算此处拉再多的任务,底层调度服务无法支撑的话,任务就会一直堵在那里。

​ 如果说真的超出MySQL处理能力,出现了上述的场景,那么可以考虑接入一些支持分片的组件,例如tdsql这种完全兼容mysql的数据库实现自动分片,也可以考虑切换底层存储(例如引入mongodb这种天然支持分片的文档数据库用做任务管理)

✨会不会出现schedule beign_pos和schedule_end_pos跨不止1张表的情况?

​ 分表逻辑限制了最多同时存在两张表,分表是一个过度状态的设计,预期是在短时间内就能完成,因此最多同时存在两张表。

​ 如果消费速度远远没有跟上生产速度则可能出现不只是跨一张表的情况,因此还是需要限制一下。如果真的出现跨两张表的情况,实际上就是worker能力不足,需要解决任务堆积的问题,也可以考虑给出告警提示

✨推荐分表的阈值是多少?为什么这么推荐?

​ 目前设定的阈值是500w,这个数据是基于业务场景实践得来的,阿里巴巴团队的一些开发手册中有相应说明,当单表数据量超500w,数据检索的性能受限,需考虑拆表处理,以优化检索性能

​ 网上资料推荐比较多的是将B+树层高维持在三层,即2000w左右,实践中这个数据应该更小些,且考虑到场景需要支持更加丰富的任务管理,可能很多查询都不走索引场景,因此综合设计考虑将数值设定在500w左右,足以支撑业务场景

✨分表时,如果任务全到1号表,0号表是清掉吗?用什么语句清,为什么,这里有做设计吗?(B站2面)

​ 分表时会将消费、生产的请求慢慢切换到新表,例如此处分表完成后所有的任务创建请求、任务占据请求都会走1号表,对于原0号表的处理则取决于业务设定,一般过冷的数据考虑按照时间周期清理掉(例如半年前的数据可以直接drop掉,或者全表truncate)

✨能否继续往01234号表滚动,这样不就支持更多任务了?(B站2面)

问题的陷阱在于:500w不够用,同时滚到更多表支持更多数据存储,需考虑这个设计的必要性

​ 现有业务场景500w足够支撑,且业务瓶颈并不在于任务生成而是在于任务执行。如果要考虑这种水平扩容的场景,可以选择接入todosql兼容mysql进行分片或者使用mongodb这种文档类型的存储组件

✨对任务信息表分表,为什么不直接按照任务id分表,比如大于500w一张表,大于1000W一张表?(美团优选1面)

考虑模块解耦、数据统计的准确性

​ 按照任务id分表,然后检索的时候通过max(id)>500w、max(id)>1000w 来进行判断,虽然查询效率有所提升,但是考虑这种情况必须注意初始值和步长问题,否则可能出现统计数据不准确的情况(例如中间部分数据被删除掉了),选用count(*)的方式是最准确的统计方式,且它是一个定时轮询的操作,对性能并不会有太大的影响。

​ 且如果将“阈值检查”放在创建任务时进行,则任务模块和分表逻辑耦合了,实际上分表逻辑属于中间层操作,应放在任务治理模块,与业务模块解耦

​ 此外,也可以参考“预估”的方案,允许统计数值存在一定差异,例如借助explain关键字检索到预估记录数,这种方式是非常快的,但需考虑和实际统计数据的差异值,一般实际业务场景不会考虑用这个方案

✨如果分表中,因为某些原因老表迟迟没做完,新表的高优任务会不会饿死?

​ 在现有的分表逻辑中,如果老表的任务因为一些原因迟迟没做完,则可能会阻塞新表的高优任务执行,因此可以考虑采用一些方案来进行优化:

  • 例如借助任务指令模块定期检查任务执行情况,及时介入处理
  • 滚表的预期处理时间是比较短的,可以设置一个滚表时间阈值,如果超出这个阈值则改变调度策略,避免新的紧急任务被饿死(例如原来从0表中拉取100个任务执行,调整为从0、1表中各拉取50个)

✨asyncflow分表之后 怎么解决的主键冲突?(美团)

​ 此处的id和taskId的业务概念不同,id设定为单表自增,单表内不会出现主键冲突。taskId设置为uuid不会重复,在现有业务场景中都是通过taskId检索数据,因此不需考虑主键ID全局唯一,只需确保单表中的主键ID自增、taskId全局唯一即可

✨分表如何保证数据一致性?

​ 关于分表这里,比如当前这张表的记录数超过了设定的阈值,会新建一张表,然后之后新建的任务都是在新表。这里会涉及到任务位置信息表的对应任务类型的endpos改变,基于此如何保证数据的一致性?

​ 分表的逻辑是基于滚表的概念,主要分为三个阶段:

  • 定时检查:当定时检查发现表记录超出设定阈值则创建新表
  • 分表过程:如果老表中还有未执行完的任务,则任务消费走老表、任务创建走新表
  • 分表后:如果老表中所有任务已执行,则任务消费和任务创建都走新表

​ 此处数据一致性问题在于这期间可能会出现一个问题:例如新表创建成功后而end_pos修改失败,那么就算新表创建成功,请求还是继续走老表。(一般来说这个endpos的更新是一个比较简单的数据库操作,失败率比较低,如果真的出现了endpos 更新失败,则重试即可,无非就是让请求多走一下老表,允许一定范围的数据超出,并不会影响原有的业务逻辑)

  • 新表创建成功后,end_pos修改失败:失败就重试,也可通过服务治理模块进行检查,及时修复
  • 新表创建成功后,end_pos修改失败,那下次服务治理检查会无限重复创建表吗?:例如初始begin_pos、end_pos都是1,如果end_pos修改失败还是1,则下次创建的表应该是t_xxx_task_2,建表逻辑优先判断这个表是否存在,如果已存在则不重复常见,也不会无限递增创建空表

✨分表后怎么具体查询判断数据在哪张表,如何事先知道是在哪张表?

​ 首先说明目前框架实现的分表规则(数据有冷热之分,基于滚表策略分表),taskId留了尾巴(记录了所在表序号),因此可以通过解析taskId字段值来确认记录所在表。

​ 其次如果有其他查询的需求,则说明目前的分表策略主要考虑到实际业务场景中执行数据有冷热之分,业务可能会清理掉一些执行完成的任务数据,如果要根据类似用户ID检索,则基于现有的分表策略,需要依次遍历所有相关的业务表进行检索。

​ 如果说业务涉及伊始有其他场景的查询需求,则需考虑框架的分表策略选择,例如如果要根据用户ID检索,则根据用户ID进行hash分片,或者接入其他存储组件(例如MongoDB这种天然适合分片的文档型数据库)

基于快速检索的思维发散

  • 任务创建有一定的时间顺序,可以基于“二分法查询”的思路进行快速检索
  • 如果要根据时间检索,则类似地创建一个时间坐标表的概念,把每个月1号的具体位置记录下来,如果按照时间查找,则可以根据这个坐标去定位

​ 基于快速检索的思考,首先要明确检索的目标,如果要提升检索性能,就要考虑资源换时间,空间换时间。空间是怎么处理的?肯定不能把原来的数据全部重新拆了,只能思考是否可以额外添加一张表来进行关联。即所谓的映射表,这个映射的规则是怎样的,类似这种思路去发散解决方案。例如现在是八月份,要查询三月份所有执行过的任务数据,怎么查?

  • 方式1:二分查找,快速找到这个时间段的数据都分布在什么表上
  • 方式2:做一张表专门记录时间节点

具体实现还是得结合业务场景,看查询的规则是什么来优化检索效率

✨怎么测试这个分表功能是否正常,要插入500w条数据吗?

​ async-deal 任务治理中有一个分表阈值(例如task.table-limit)的设定,可以通过调整这个配置来进行分表测试。先插入一部分数据观察一下表和数据的变化,然后逐步递增到阈值,确认分表策略实施是否正常

6.性能优化

✨框架的性能怎么样?

​ 压测方式选择:apipost、apifox、jmeter、wrk

不同机器配置性能测试不同,给定一个合适的测试数值范围即可(例如测出来是2000左右,则可说明优化从500提到了2000)

​ 主要测试了框架的核心接口:创建任务、任务查询、占据任务等:

  • 机器配置2核4G的云虚拟机(flowsvr、worker的机器配置为2核4G,MySQL单独用云数据库):原来是500QPS,优化后达到2000QPS,其中创建任务能达到2000、查询任务有redis(例如历史任务加缓存提速)可达到4000-5000,占据任务复杂一点大概1800
  • 请求在怎样的一个状态返回算是成功(纳入QPS计算):压测请求可以抗住,且响应正常(200),响应时间控制在单秒级别(一般延迟普遍在ms级别,偶尔超过1s也可接受)。性能压测借助wrk性能压测工具,通过编写lua脚本进行压测配置,可以在lua脚本中设置response响应判断

项目落地场景分析:如果是实际业务场景中应用到asyncflow框架,则可能需要对比使用前后的性能参数,例如可能会cue到CPU占用率、内存消耗等。例如多机竞争的3种解决方案,各自的性能指标:(每种方案的测试环境不同)

  • 方案1:基于MySQL行锁(压测时CPU飙升,例如超过90%,说高点进一步说明这个方案不太可靠)
  • 方案2:基于Redis分布式锁(压测时,CPU压力很低,20%不到,支撑方案的可行性)
  • 方案3:基于消息队列(是一个优化设想,暂时没有去落地)

✨选的是什么数据(接口)来测试?压测参数的选择?

结合实际业务场景说明:追问业务实现细节,选择什么数据、什么数据量下、什么接口/场景进行测试

​ 在装满500w数据情况下,测试了创建任务、占据任务、查询任务这几个核心接口,其中创建任务和占据任务在经过调优之后可以从500QPS提升到2000QPS,查询任务接口可以达到4000QPS以上(理论上Redis查询可达10WQPS)

此处需注意任务调度过程中并没有引入Redis缓存,而是针对终态的任务数据提供历史查询功能,引入Redis缓存加速查询

压测参数的选择

​ 在使用wrk进行压力测试时,选择合适的线程是非常重要的,这直接影响到测试的准确性和效果。一般来说线程数的选择考虑硬件资源配置(CPU核心数、内存和网络带宽)

​ 项目测试:例如8 (平平无奇的测试参数),50线程数是一个基础的参考设定值,如果线程数开太多可能会存在问题(开到100出错率比较高,自行配置选择合适的参数进行压测)

✨一开始的性能瓶颈是什么?

发现问题、解决问题 =》在性能调优的时候发现问题:druid数据库连接池参数设置、mysql数据库最大连接数配置max_connections、MySQL版本升级对并发能力的支持

​ 为了解决场景性能问题,此处构建单表创建任务测试:MacOS 32G、 MySQL5.7.44

连接池不够用,则进行等待,等待超时报错:测试时发现当并发上去之后,QPS大幅降低,控制台抛出大量连接异常。发现druid数据库连接池参数中的max-active默认是8,如果请求到达发现无连接可用则会等待,等待超出最大等待时间时则会抛出异常,因此适当调配max-active、max-wait的值,确保任务正常创建。且经过测试max-active在100以内的增加能体现比较大的QPS增效,超出100之后这个增效就不大了,将参数调配在max-active=100、max-wait=60s,性能有效提升

MySQL默认的max_connections设置:MySQL 5.7.44 版本默认的max_connections设定是151,如果并发请求连接超出这个限制,则会抛出too many connections错误,阻断了业务执行。一般业务场景下会将这个值设置得比较大,避免因数据库连接不足导致业务执行异常(1W+)

MySQL版本升级对并发能力的支持:测试的时候发现MacOS中brew不支持MySQL5.7.44版本的管理,因此偶然重新切换了一下版本升级到MySQL9.0.1,发现同等参数条件下QPS大幅提升,查阅了资料,是因为MySQL升级版本对并发能力的升级优化

压测参数连接池配置测试结果(MySQL5.7.44)测试结果(MySQL-9.0.1)
8 线程、50并发、持续10s1QPS:167/sQPS:1988/s
8 线程、50并发、持续10s8QPS:429/sQPS:8413/s
8 线程、50并发、持续10s50QPS:2531/sQPS:11003/s
8 线程、50并发、持续10s100QPS:2470/sQPS:10723/s
8 线程、50并发、持续10s500QPS:2501/sQPS:11403/s
8 线程、50并发、持续10s2000QPS:2493/sQPS:11093/s

​ 结合上述分析可知,针对数据库连接池参数调优:max-active在100以内的增加,其QPS有明显的增效,MySQL5.7.44版本QPS从160-2500,MySQL9.0.1版本QPS从2000-1.1W

基于上述优化策略,实际的业务场景QPS从500优化到2000左右(2000-6000都是一个正常的数值)

✨是怎么测试框架的?

功能测试、性能测试

​ 功能测试:通过curl 指令、postman/apifox 接口调用测试功能

​ 性能测试:借助压测工具例如jmeter、wrk工具,此处主要借助wrk轻量级性能压测工具(设置线程、并发数、持续时间)

✨如果突然有特别多数据怎么处理呢?不考虑对worker进行扩容,不考虑增加机器,比如就多了50%这样的需求,框架的的瓶颈在什么地方?这个模块会出什么问题(腾讯二面思考题)

​ 主要是数据库性能瓶颈,针对场景提出优化。

​ 从数据库操作上:拉取任务之前会判断这个任务类型是否达到当前可执行的最大个数,进而限制任务拉取操作,这个count操作容易成为瓶颈,可以考虑通过Redis缓存定时刷新,也能提升任务执行时的吞吐

​ 从设计上看:通过消息解耦的方式(将任务拉取、任务执行进行解耦),也能提高吞吐

​ 从worker程序本身来看:优化worker程序,提高执行效率(例如http连接池、gc回收阈值参数等程序调优)

✨你是如何调优的(Java、Golang、C++)

结合连接池调优、GC调优、缓存调优、工具包引用等方面切入

连接池调优:引入数据库连接池,设置合适的参数(max_active、max_wait)。并发压力上来时发现控制台提示MySQL连接相关异常,定位到是数据库连接池的问题,因此通过调整数据库连接池参数配置来解决问题并提升QPS

GC调优:因为对象频繁申请和释放,所以gc也过于频繁,此处通过调大年轻代来缓解,这样gc频率变小,性能提高30%

缓存调优:将一些查询使用Redis缓存,比如查询任务结果这个接口,通过加入Redis缓存提升到了5000qps,实际理论上还可以更大,但受限于其他资源配置(网络带宽)等,导致无法往上压

✨flowsrv性能是怎么优化的,瓶颈点是哪里?(快手)

分析:除了多机竞争,主要是针对flowsvr的性能调优,可以结合上述的连接池调优、GC调优、缓存调优进行说明。瓶颈点在于单机MySQL方案(采用的分表策略无法支持水平扩容)

✨为什么QPS要拉到2000?实际会用到吗?

(1)500QPS对于现在这个项目不够用吗?还需要提升到2000?(美团)
(2)不是说worker只有六台吗?六台woker拉取任务需要2000qps?(快手一面)

分析:这个问题是说6台worker,就算都是1s一次拉取,也只需要6qps,为啥要测到2000。从框架定位来说,也可以说一下测到2000并没有带来太多额外成本。

​ 项目设计的定位是框架,虽然现有的场景虽然不需要这么高的QPS,但是会测到一个相对高的数值。不止是这个接口创建任务、拉取任务、查询任务等都做了。虽然拉取任务不需要2000,但压测适度调优就达到了2000,属于一个非常够用的值,也是为了支撑其他的业务场景(例如区块链等)

🚀任务积压问题(任务堆积)

分析:积压是队列化场景常见的问题,解决思路可以从多个方向去说,这个功能属于细节加强(目前没有实现)

​ 针对积压问题的解决思路(调配新资源进行消费、保新、保老):1.增加资源去消费掉;2.优先保新任务,老任务慢都慢了,回头有余力再做;3.保老任务,不给新任务提供服务

(1)任务特别多怎么办?你的框架会怎么处理?(阿里)
(2)待处理的任务过多时,不允许worker扩容的情况下怎么处理?(快手)
(3)任务积压怎么解决,怎么发现,不能说积压了甩给worker不管了

思路(发现积压问题、解决挤压问题):首先可以通过日志和监控发现积压,同时在拉取任务时是会检查当前任务总数(与最大可执行任务个数作对比)

  • 调配新资源消费:如果积压不严重的话,可以扩容worker来解决(增加worker,优化worker的执行能力)

    • 除却增加worker执行不同任务类型的任务,worker可以考虑动态选择做某一类型的任务,或者可以开一个守护线程,后台监测消费速度,根据消费速度动态调整worker的数量,但这种设计方式比较吃性能,毕竟停止销毁或创建容器还是挺费时间和系统资源的
  • 优先保新任务:如果是积压太过严重,老任务已经受影响了,可以考虑保新任务。例如基于分表逻辑来处理,即当发现积压过重时,通过触发一个特殊的分表接口,主动分表,并且从新表开始调度,用户调用这个接口,老任务就不会被调度到,回头有余力再处理这些积压的任务。

✨现在有个需求要求从500qps提升到50000qps谈谈实现思路(美团)

  • 缓存优化:针对任务查询(终态任务查询),可引入Redis缓存提升检索效率

  • 分片优化

    • 可以考虑接入tdsql这种兼容mysql的组件来完成分片;
    • 或者调整存储组件,通过引入mongodb这种文档型数据库,以支持分片
    • 或者对flowsvr进行水平扩容,分表方式采用的是滚表,同时提供热点服务的是2张表,可以根据用户id的hash做水平分片(例如1个MYSQL为5000/s,则10个分片是5W/s)

✨有对项目进行SQL调优吗?

​ SQL调优:“联合索引的设计”、“数据表字段设计技巧”

  • 拉取任务是一个频繁的操作,在构建了上千万数据进行压测的时候发现这个接口耗时比较长,因此通过explain分析SQL执行计划,发现了其extra显示了file sort(即进行了额外排序),因此创建了(status,order_time)联合索引避免额外排序过程,提升检索效率,将查询时间从秒级别降到数十毫秒级别

???redis 缓存优化

  • 如何确保数据一致性?
  • Redis优化任务查询,缓存命中率大概有多少?说 80% 以上算一个合理但不离谱的指标数据

todo:数据库连接池为什么要用druid?(有些网络风评说druid并不好,为什么要选他)

考察技术平替的问题,对比其他数据库连接池为什么选择druid?

对比dbcp、c3p0等数据库连接池、还有springboot默认的hikaricp 速度很快为什么不用?(待确认 对比各个数据库连接池的性能对比等)

C3P0:C3PO是一个开源的Java数据库连接具有很好的稳定性和性能。它支持连接池的自动回收、自动重连和自动超时处理等功能,同时还提供了丰富的配置选项,可以根据实际需求进行灵活的配置。

Druid:Druid是阿里巴巴开源的一个高性能数据库连接池。它具有连接池监控、SQL监控、防SQL注入等丰富的功能,并且支持连接池的动态调整和故障切换等特性,适用于高并发的数据库访问场景。 HikariCP:HikariCP是一个轻量级的高性能据库连接池,它具有快速启动、低资源消耗和高并发性能的特点。HikariCP通过使用少量的线程和延迟初始化来提高性能,适用于高负载的数据库访问场景。Apache Tomcat官方提供的一个数据库连接池,它具有良好的稳定

Tomcat JDBC Pool: Tomcat JDBC Pool是性和可靠性,并且与Tomcat服务器集成得非常紧密,支持连接池的动态调整和监控等功能。

druid 社区活跃,遇到问题容易解决

1.开发场景的适配性:本质都是基于连接复用,对数据库连接池这部分的要求并不高,也不是框架的性能瓶颈,因此调研重点并不在此

2.第三方技术的引入成本和可维护性

3.考虑开发团队的技术栈对齐

扩展:中间件选型要多加考虑,防止被拷打

7.其他扩展问题

✨如果说要做一个流程,首先是它这个稳定性对不对?这个特性上面你是怎么去考虑并设计的?就是如果做流程系统的话,我觉得这这个点是肯定是要着重去考虑(文远知行)

​ 结合框架的稳定性和可靠性分析

容灾:支持集群化部署(Redis主从部署、MySQL主从部署、服务多节点部署)

服务无状态化:服务层和执行层都是无状态的,假设一个节点出现问题,其他节点还能正常工作

上下文存储:如果一个任务直行到一半节点崩溃了,可以从已保存的阶段继续执行,避免资源浪费

其他异常兜底:针对其他的异常情况,会考虑采用相应的兜底方案来处理异常,例如分表场景中,如果滚表时间超出预设,则可以考虑切换调度策略(例如在分表未完成的时候,任务消费走老表、任务生成走新表,如果分表超预期的话考虑让一部分的任务消费也走新表,避免因为一些异常原因堵住了新表中高优任务的正常执行)

✨asyncflow 用了哪些线程池?线程池的参数如何设置?

​ 线程池主要是在 Worker 里处理并发任务时使用,此处自定义了一个 ThreadPoolExecutor 线程池,用来并行处理拉下来的任务。另外在处理一些周期性的任务上,也是用了线程池的扩展类 ScheduledThreadPoolExecutor,比如去定时更新任务配置

​ 任务治理模块:任务治理模块里使用了自定义的 ScheduledThreadPoolExecutor 和 spring 的 @Scheduled 注解来处理分表和任务治理的逻辑

​ 线程池参数设置:核心线程和最大线程都是 5,阻塞队列使用了有界的 LinkedBlockingQueue 队列大小设置的是 1000,拒绝策略使用的默认 AbortPolicy

​ todo:在哪些地方用到了线程池 = 》 任务执行、定时更新配置表信息

✨数据库连接池如何合理地设置?

​ 常见的数据库连接池有DBCP、druid(阿里)、C3P0、Hikari

​ 针对数据库连接池需考虑核心参数(最大连接数max-active、空闲时间min-evictable-idle-time-millis),然后结合不同的数据库连接池产商提供的参数配置进行设定。

​ 框架中使用的是druid连接池(默认最大连接池大小为8),通过压测实践发现将max_active控制在100以内的增加有明显的效益,而超出100之后QPS基本没有明显提升,可能还下降了。因此盲目地增加连接池大小,系统执行效率反而有可能降低。因为对于对于每一个连接,服务端会创建一个单独的线程去处理,连接数越多,服务端创建的线程自然也就越多。而线程数超过CPU个数的情况下,CPU势必要通过分配时间片的方式进行线程的上下文切换,频繁的上下文切换会造成很大的性能开销。

​ Hikari官方给出了一个PostgreSQL数据库连接池大小的建议值公式,CPU核心数*2+1。假设服务器的CPU核心数是4,把连接池设置成9就可以了。这种公式在一定程度上对其他数据库也是适用的。

✨asyncflow 用什么垃圾回收算法?

​ 项目使用的是JDK1.8版本,对于垃圾回收算法这块没有特别去指定,使用的是默认配置。其中年轻代是 Parallel Scavenge 使用标记-复制算法的多线程收集器,老年代是 Parallel Old 使用多线程和“标记-整理”算法

✨asyncflow 用什么设计模式?

​ 项目中使用的设计模式比较单一,例如单例模式。

​ 项目中有个observers类似观察者模式的调调,但比较牵强,不建议生搬硬套

8.项目总结反思

🚀项目重点难点、优缺点

📌项目的难点?

​ 结合项目要点,阐述这个过程中遇到的问题和解决方案

性能调优涉及面广:初步定的目标是2000gps,但是一开始只有500qps。最终通过MySQL连接池Redis缓存、减少竞争等手段优化到2000gps。

分表方案选型难:我们框架所需的分表,是需要按照大小来迭代新表,不断向前推进的,而不是传统的分片长期稳定存在,而业界的方案都是按某个Key来水平切片,这个在选型取舍上有难度

处理多机竞争问题:多机时候可能会拉取到同一批任务重复做,造成资源浪费,引入分布式锁解决。

📌框架的优缺点是什么?(阿里)
  • 优点(可以结合框架核心设计说明)
    • 支持多阶段任务的异步调用和灵活的任务管理机制。低成本解决多机竞争、提供灵活的优先级设计、灵活的重试间隔设计、分表设计优化(基于滚表概念实现冷热数据分离)、简洁的架构设计,方便业务接入
  • 缺点
    • 目前的架构设计中使用的底层存储支持比较单一,采用的是MySQL,后续为了支持更加灵活的场景考虑接入MongoDB、tdsql等
    • 目前支持的任务模型比较简单(主要考虑现有常见的业务场景支持),支持多阶段任务,但任务之间没有关联,后续考虑结合业务实际拆分为父子任务概念
    • 任务治理功能设计比较基础,没有根据worker上报做一些动态调整的能力
📌项目的不足?待优化点(todo)

不同阶段执行时间不一样,但是超时只有一个时间 (此处细节设计不太好,可以根据不同的阶段设置相应的超时时间配置,例如超时字段存储的是一个列表配置:分别存储对应阶段的超时时间控制)

​ 任务调度框架被challenge说更像一个工作流,因为没有做到按资源分配不同数量的任务给worker,如何回答=》框架定位定位就是调度任务,并不是工作流,工作流通常而言是多个任务的串联,我们是单个任务

暂不支持父子任务的调度(但可以通过将A->B|C|D->E的形式转化为A->B->C->D->E 或者 A->O(B|C|D)->E 将父子任务转化为平级概念套用框架逻辑),父子任务的调度处理比较复杂,此处暂不复杂化场景

✨开发过程中有遇到什么bug?

  • 分析:首先要思考,通过这个问题确认什么?

    • 这个项目是不是自己做的,不可能开发过程中没遇到过bug,如果一个都说不出来,就可能怀疑你的真实性
    • 你是不是有归纳总结复盘的能力
    • 通过bug也能更深入理解业务,还可以通过bug发散提问
  • 应对方式:

    • 整体思路是找一些正常功能说得不正常
    • 同时这些功能最好是不太核心的(属于一些小细节),不要让人觉得你整体设计有问题
    • 尽可能往自己熟悉的领域引
  • 参考:

    • 比如worker框架的任务调度逻辑(反射的处理:参数类型和参数的校验等)
    • 比如数据库一开始context字段设置过小,只有32的长度,大一点的上下文插入失败
    • 比如优先级计算逻辑忘记考虑重试间隔,导致重试间隔失效
    • 比如忘记做参数检查,没有注册过的任务也插入了数据库,无法消费

9.项目实际场景落地

​ 有个实际业务场景的例子,行外汇款场景。由于一些原因会使得转账的流程拉长,流程分析如下:(1)客户发起转账——(2)头寸审核——(3)监管审核——(4)支付系统支付,整个流程是是串行执行。其中2、3、4都需要经过交易发送、状态查证。

​ 基于quartz或者xxl-job这种定时任务框架,会写三个定时任务,A任务专门做头寸审核结果查证(审核通过发监管或者发支付系统),B任务专门做监管审核结果查证(审核通过后发支付系统),C任务专门做支付系统支付结果查证,每个流程节点也都会更新单据的状态(如:待头寸审核、待监管审核、支付处理中、支付成功、支付失败)

​ 感觉Asyncflow也适用于这种场景,每一笔单据的处理都算是一个任务,2~4的流程也属于任务内部多阶段同步。 想请问如果基于Asyncflow来实现这种业务场景,对比上边我提到的,拆分多个功能更加单一的定时任务有什么优势呢?

​ 现在 async 多个任务之间是独立的,可以并行去消费,单个任务中多个阶段直接是串行消费

​ 我的例子中,abc三个定时任务整体可以对标我们async的任务,不同单据的处理都是互不影响的,abc分别又对应着async任务内部的多阶段,串行消费。

​ 如果 abc 有依赖关系,可以考虑用多阶段,分开去做就需要在别的地方去处理依赖的状态,并行相比较串行很明显的优势就是并发度高

​ 对于这个场景示例,我是觉得也符合我们async,只是不知道对比现有的单独多个定时查证任务的方案来讲,有什么特别的优势。目前这种做法是依赖于单据表单据本身的状态(头寸审核中、监管审核中…)来进行abc的流转。

​ 是不是可以这么理解,两种方案都能实现这种场景的需要,只是相对于单独的定时任务,asyncflow提供了一种更通用的架构方案,对于这种多阶段场景的的流转管理、调度的流程更加清晰可控,而且也更方便其他的同类型业务的接入

todo MySQL 为什么单机

1.现有分表实现:基于滚表概念,实际操作的热表还是pos表中指向的那两张表 无法通过水平扩容提升性能

2.基于多机竞争问题引入分布式锁,由于任务拉取和执行操作耦合,??? 性能瓶颈不在拉取......

选择主从模式部署,从只是单点容灾的一种方案

如何确保缓存和数据库同步的?

扩展知识点

  • 线程池相关知识点
  • 数据库连接池相关知识点
    • 数据库连接池核心概念(优势、解决什么问题?带来什么问题及解决方案?)
    • 核心参数配置、常见的数据库连接池框架
评论
  • 按正序
  • 按倒序
  • 按热度
Powered by Waline v3.1.3