跳至主要內容

asyncflow-03-设计剖析

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

asyncflow-03-设计剖析

学习核心

  • 设计剖析
    • 多机竞争
    • 重试间隔
    • 排序 (任务处理顺序)、优先级
    • 多阶段任务
    • 连接池(优化)
    • 任务治理

学习资料

多机竞争

1.概念核心

何为多机竞争?

​ asyncflow采用的是分布式架构:支持flowsvr、worker的多节点部署(可以是1个flowsvr-1个worker、1个flowsvr-多个worker、多个flowsvr-多个worker)。由于获取的任务集合有排序,如果在多worker部署的情况下有可能在同一时刻拿到相同任务,进而存在多机竞争问题。

​ 针对多机竞争问题,一般有两种方向去处理:一种是加锁(只有获取锁成功的进程才能操作资源)、一种是架构优化(从架构上进行业务解耦)

2.解决方案

方向1:加锁

(1)MySQL行级锁(for update)
# 1.查询的时候for update上锁
select task_id from t_lark_task_1 where status = 1 limit 100 for update;

# 2.按任务ID更新
update t_lark_task_1 set status = 2 where task_id = 'xxx';
image-20240731133759795

​ 实际上,行级锁并不会直接锁隔壁行。‌行级锁的主要目的是锁定单个行记录,‌以防止其他事务同时修改同一行数据,‌从而确保数据的一致性和完整性。‌行级锁的概念还包括了间隙锁和临键锁,‌这两种锁类型扩展了行级锁的范围,‌以处理更复杂的并发控制需求。‌在某些情况下如果使用间隙锁或者临键锁,可能会间接影响到”隔壁行“,但这并不是直接锁定隔壁行本身,‌而是通过锁定索引记录之间的间隙或结合行锁和间隙锁来确保数据的一致性和防止幻读现象。

​ 结合图示理解,假设此处通过语句1检索出来3个任务(分别为t1、t2、t3,任务状态均为1),而t4任务状态为2(已经在执行中),如果在语句1执行时产生间隙锁,就有可能影响到其他行记录的操作(虽然语句可能没有关联,但由于受到锁的影响也会阻塞操作)。例如此处语句1的检索操作可能需要很长一段时间,如果在这个执行期间语句2执行受阻,造成整体性能下降,甚至可能出现执行超时失败的情况

实际案例拆解

# 测试数据(置空数据表,插入测试数据)
insert into t_lark_task_1(task_id,status) values ('taskid1',1);
insert into t_lark_task_1(task_id,status) values ('taskid2',1);
insert into t_lark_task_1(task_id,status) values ('taskid3',1);
insert into t_lark_task_1(task_id,status) values ('taskid4',2);

# T1:事务1:查询的时候for update上锁
begin;
select task_id,`status` from t_lark_task_1 where status = 1 limit 100 for update; -- 场景:针对非唯一索引等值查询,查询的值存在的情况
-- 先不执行commit,启动事务2操作确认其是否成功
-- commit;

# T2:事务2:按任务ID更新
update t_lark_task_1 set status = 2 where task_id = 'taskid4';

# T3:事务1:提交事务,释放锁
commit;

# 查询锁状态
select * from performance_schema.data_locks; -- MySQL8.0 查看锁状态

-- MySQL5.7.44 查看锁状态(待确认.....todo)
SELECT * FROM information_schema.innodb_trx;
SELECT * FROM information_schema.innodb_locks; 

​ 从上述结果分析,启动事务1检索出来3条数据,此时启动事务2会看到事务2的执行受到阻塞,随后执行事务1的commit操作,可以看到事务2继续执行成功(如果事务1阻塞时间很长,事务2的执行超出一定时间范围也会失败)。基于上述结果,可以看到虽然事务1查询虽然针对的是taskid1、taskid2、taskid3这三个任务记录,但却对taskid4这条任务记录的修改操作产生影响。可以查看每个过程的锁状态来进行理解分析:

​ 对于事务1,查询status=1的记录时,首先,会对二级索引status=1的记录都加上 next-key 锁(左开右闭,只会对满足status=1的第一条记录加next-key 锁,即(-00,1]; 其他满足status=1的记录若也是加next-key 锁的话,那区间为(1,1],感觉这个next-key 锁无效,相当于没有加next-key 锁),对于二级索引status=2的第一条记录加上间隙锁(这里status=2的记录也只有1条),区间为(1,2)。同时,在符合查询条件的记录(t1,t2和t3)的主键索引上加记录锁。对于事务2,当更新任务t4的状态时,由于更新的是索引列,首先会删除这记录,此时间隙锁会变为-个特殊的间隙锁(还是事务1持有),即(1,+o0)。(删除了一个带有间隙锁的索引,这个间隙锁会跑到下一.索引的位置,并不会消失。)至此,next-key 锁(-00,1]和特殊的间隙锁(1,+o0)就相当于事务1锁住了(-oo, +oo),此时事务2不管插入的status的值为多少都不会成功,所以事务2更新索引的操作会阻塞,

(2)乐观锁方案:乐观冲突

​ 此方式需加1个owner字段(一般来说是UUID),用不同的owner标识不同的worker

# 1.获取任务(假设获取到t1、t2)
select * from t_lark_task_1 where status = 1 order by order_time limit 2 ;

# 2.占据任务(占据t1、t2,并设置owner:owner与worker对应)
update t_lark_task_1 set owner = 'A' and status = 2 where task_id in ('t1','t2') and status = 1;

# 3.将某个worker抢占的任务检索出来
select * from t_lark_task_1 where status = 1 and owner = 'A';

​ 针对语句2此处有个疑问:假设某一条语句执行成功了,那如果另一条语句执行会覆盖它吗?实际上是不会的,此处update设定了status=1的条件,只有满足条件才会修改。可以理解为多个worker并行执行语句2,只有某一个worker才会修改成功,其他worker的update操作实际上影响行数为0。其本质是一个乐观的思维,如果worker比较多的情况下冲突也是比较多的

​ 基于这种方式可以避免for update加锁方案中语句执行对其他无关语句的影响,但也有其相应的短板:

  • 要执行的SQL语句多,有额外的性能损耗
  • worker越多、冲突越大、无效写越多 (即worker越多,冲突越大,会出现很多一无所获的请求,占据性能消耗),此处的无效写主要针对语句2的update操作
image-20240731142028006
(3)分布式锁(Redis)

​ 考虑到基于MySQL实现的锁的短板,此处引入分布式锁(Redis、Etcd)概念来实现。

​ 基于Redis实现分布式锁,多个worker竞争锁资源,只有竞争成功的worker才能执行相关操作。这种方式避免了上述MySQL锁方案的一些短板,例如行级锁的相互影响问题、乐观锁的一些对CPU造成的无意义执行损耗等

image-20240731143050319

​ 但基于分布式锁方案也有相应的不足:

  • 冲突时闲置:例如worker1和worker2同时获取锁,worker1竞争成功,那么worker2竞争失败就会等待一段时间然后重试,这段等待的时间worker2是闲置状态
  • 锁释放不及时,其他worker闲置:例如worker拿到锁之后陷入了GC,则需等它这波动完之后才能释放(或者等到过期自动释放),就会让其他worker闲置等待,会对水平扩容造成一定的影响

​ 上述问题产生的核心原因是:拉取和执行耦合。因为每次都是针对同一批任务列表,本质上还是需要同步的(也就是说要排队),抢到锁资源的worker才获得这批任务的执行权。在同一时刻获取的任务列表都是同一批,不管多少个节点去做都是一样的

​ 结合一个实际场景案例分析:老板派发一批任务,告诉员工A、B先到先得,员工A抢占先机优先占据了这批任务,而员工B只能等待确认下一批任务了。那这样就很容易出现一种情况,旱的旱死涝的涝死,每次都是谁先抢到锁谁就有执行权,其他没抢到锁的worker只能闲置

​ 但实际上此处应该要解耦任务拉取和任务执行,虽然同一时刻获取的任务都是同一批,但对于每个worker而言都可以去执行这批任务列表的某个任务。此处可以理解为细化锁的粒度,将原来锁”一批任务“细化到锁”某个任务“,达到雨露均沾的目的。基于上述实际场景案例分析相当于老板下发一批任务,将这些任务放到一个池子中,有空的员工可以自行去这个池子中认领任务(锁定某个任务)、执行任务,基于此则联想到引入消息队列来实现。

方向2:架构优化

(1)队列化(引入MQ)

​ 引入同步服务:拉取任务 =》设置任务状态为执行中 =》丢入Kafka,由worker自行消费队列中的信息

image-20240731150105418

​ 结合上述图示分析,可以看到将任务拉取、任务执行进行解耦。同步服务是单节点、而执行可以是多节点。因为基于这种场景的性能瓶颈本身并不在于任务拉取,而是在于任务执行,因此这个解耦方案是一个正常的方向

​ 引入队列化也相应具备其优缺点:

  • 优点
    • 架构清晰,将拉取和执行解耦
    • 压力稳定,可支持水平扩容
  • 缺点
    • 容灾成本增加(需要多维护一个同步服务)
    • 执行链路变长
    • 引入消息队列(依托于第三方组件,具有一定的维护成本)

3.多机竞争方案总结

​ 锁方案、架构优化方案

方案优点缺点核心问题
方案1:MySQL行锁(for update)简单易实现压力不稳定
Worker互相干扰
DB压力大,CPU消耗剧烈
由于间隙锁的存在,可能会对其他行操作造成影响
方案2:乐观锁(乐观冲突)简单易实现压力不稳定
Worker互相干扰
DB压力大,CPU消耗剧烈
虽然避免SQL执行对其他语句的影响,但也增加额外的SQL执行消耗
worker越多、冲突越大、无效写越多
方案3:分布式锁(Redis、Etcd)降低DB消耗压力不稳定
Worker互相干扰
引入新组件,容灾成本增大
由于拉取和执行的耦合导致worker闲置问题:
冲突时闲置:未抢到锁的worker需等待重试,等待的这段时间是闲置的
锁未及时释放导致其他worker闲置
方案4:队列化架构清晰,将拉取和执行解耦
压力稳定,可支持水平扩容
引入了第三方,开发和维护成本增加,执行链路变长需考虑同步服务的容灾问题、第三方组件的维护、异常问题跟踪等

扩展问题补充

(1)是否可以避免多机竞争?

​ 结合上述场景分析,多机竞争问题主要在于多个worker去抢占同一批任务(此处的“同一批概念”强调的同一个任务类型,不同任务类型的操作的是不同的表,在表设计上基于业务解耦设计了)导致。换个角度想,如果不采取抢占的方式,而是由flowsvr主动分配的话(由flowsvr主动唤醒worker),是不是就可以避免多机竞争问题?=》对比区分推拉模式open in new window,结合业务场景去回答

​ 推模式和拉模式是两种常见的信息传递模式,在许多场景中被广泛应用(如消息队列、网络通信、数据传输等),可以从原理、应用场景、优缺点等方面切入对比分析

​ 推模式:发布者主动推送消息给订阅者(例如此处flowsvr主动分配任务给worker),核心是实时性、主动性

​ 拉模式:订阅者主动发起请求获取消息(与推模式相反),核心是个人化、针对性

​ 从应用场景分析来说,在实时性要求较高、广播性较强的场景中,推模式较为适用;而在需要根据个性化需求获取信息的场景中,拉模式则更具优势。而针对此处的框架场景来说,如果采用推模式,还需关注推送信息的数量和质量,避免给worker带来负担。采用拉模式更适配个性化获取信息的场景,更具备针对性。

(2)针对MySQL锁方案,会让CPU不稳定

​ 在MySQL中,死锁检测机制是默认开启的,当一个线程新加入到某个资源的阻塞队列时,会检测它的加入是否与其它正在发生阻塞的线程存在资源的相互依赖,从而导致死锁的发生。

​ 如果这是一个高并发的资源,阻塞队列里有大量排队的线程,那么每个线程都要把其它线程检查一遍,每个线程要检查的时间复杂度就是O(N)。比如有1000个并发线程,那么要总共要检测的数量就是1000*1000=100W,即O(N^2),这种数量级的检测就会导致消耗大量的CPU资源,其现象体现出CPU占用率很高,却处理不了多少事务。或是发现理处的事务很少,但CPU占用率却很高。

​ 补充学习:行锁功过:怎么减少行锁对性能的影响?open in new window

(3)分布式锁在占据任务之前加锁,占据任务完成之后就解锁,为什么说拉取和执行耦合?

【1】时间间隔问题(冲突时闲置):定时操作有时间间隔,竞争失败的worker要等待这个间隔,这段时间worker就闲置了

【2】锁未及时释放影响其他worker:因为worker能否工作取决于是否抢到锁资源,如果拿到锁的worker1没有及时释放锁(例如遇到GC、占据任务时出现异常、等到过期才释放锁),在worker1未释放锁的这段时间,其他worker只能闲置干等着等待锁资源释放

​ 因此理想情况下可能这个时间和操作复杂度看起来并不高,但是实际分析是存在缺陷的。此处说拉取和执行耦合针对的是拉取任务、占据任务(占据成功也就获得执行权)这两个操作耦合,理想情况下每个worker应独立工作、各不影响,但此处引入分布式锁,则出现了上述”相互干扰“的场景。

​ 每个worker的任务处理模块之间本该各干各的,但worker需要先拉取任务才能执行任务,由于引入了锁,拉取任务就变成了一个同步操作(需要排队),因此worker之间就会相互影响,而与之耦合的任务处理模块也会相互受到干扰

重试间隔

​ 通常而言,普遍的重试间隔需求有均匀重试和渐进式重试。此处的重试概念:针对的是任务执行失败之后,将其丢回存储(丢回待执行列表)以让任务可以再次被调度的时间。即重试的过程是worker任务执行失败,worker间隔多少秒之后将任务状态更新为”待执行“??(todo 需思考 重试是当前worker独占这个任务?一直重试 还是会将其丢入任务列表,让所有的worker都有机会重新执行???这个需理解清楚,对概念的说明很重要)

​ asyncflow框架默认使用一个interval字段用于支持渐进式重试,interval表示最大的间隔秒。考虑到有部分场景希望支持均匀重试,有两种方案可以用于设计重试策略:

  • 采用新增一个标记字段来配合(例如重试规则、重试配置,其中重试规则表示不同的重试方案)
  • 此处为了节省字段的设计,选择了一种巧妙的方式,即使用负数表示均匀重试时间

​ 对于一些更加复杂的场景来说应该支持更丰富的重试策略,更进一步可能需要列表、lua这种解析策略,但通过调研了业界类似celery等竞品都不会做这么复杂,因此目前asyncflow框架只提供两种最常见的重试策略方案参考

​ 结合实际案例理解均匀重试和渐进式重试两种策略对比:

  • 如果interval为负数(则为均匀重试策略):例如-10,其表示重试间隔为 [10, 10,10..10]直到最大重试次数
  • 如果interval为整数(则为渐进式重试策略):例如10,其表示重试间隔为 [1,2,4,8,10...10]直到最大重试次数
    • 在JAVA中,可通过位运算实现渐进式重试:order_time = System.currentTimeMillis() + Math.min((1L << retryTime - 1),maxRetryInterval)
  • 后续可补充扩展更丰富的重试策略:
    • 按列表:也就是interval变成字符串,值直接是每次时间间隔,比如[1,10,25,5,100],支持灵活的重试时间间隔设定
    • 按lua:即将配置逻辑放到lua文本(所谓lua重试策略,即将间隔时间写成lua脚本,加载的时候将其解析出来。也可以用json、xml来替代,但实际上不需要做太复杂)

​ 如果达到最大重试次数,执行还是失败的话,这个任务就会被设置为失败。

​ 任务拉取涉及优先级相关,引入重试间隔还需考虑还没过重试时间的数据怎么过滤:此处概念指的是说如果拉到的任务列表中,对于还没重试时间的任务需要过滤掉,通过orderTime 和目前时间比较 =》 如果 orderTime>目前时间就不拉取(相当于过滤掉)

排序设计

​ 实际业务中,任务处理总会有个先后顺序。例如在拉取任务、执行任务的时候不可能是随机处理,而是按照一定顺序规则处理任务。因此,需要设计一种排序规则来决定任务执行的先后顺序。基于此,可以结合多个方面、任务的多阶段性来分析不同排序规则(排序方案)的优缺点

方案特点多阶段任务异常任务情况分析
按照创建时间排序先进先出本阶段先执行完成的,下一阶段还是会被优先调度到如果优先执行的这批任务存在大量异常,会阻塞其他任务
按照修改时间排序任务的每个阶段都可以重新排序,让其他任务有机会执行基于这种方式会使得设定的重试间隔时间失去作用
且如果重试失败的任务量很多,下次拉取又是一大批异常任务
抽象出order_time排序整合排序影响因素

1.按照创建时间(先进先出)

-- 按照创建时间排序,每次拉取创建时间最早的任务
select * from t_lark_task_1 where status = 1 order by create_time; 

针对多阶段任务:是一个任务对应多个子任务记录?还是一个任务对应一个任务记录,通过字段区分不同的任务阶段?

​ 如果针对多阶段任务,如果保存的是同一条记录,针对每个阶段的执行状态都设置"阶段1-执行中、阶段2-执行中"这种调调,那么如果任务的阶段很多,那这个状态就会很多,从设计的角度上考虑有点繁琐。因此现有框架的实现是针对多阶段任务从始至终从存储一条数据,通过stage + status 区分任务不同阶段的不同状态,例如任务的每个阶段的状态都统一为"待执行、执行中、执行失败、执行成功"这几个状态。

任务多阶段:对于同一任务类型的任务而言,如果是多阶段任务,基于这种方案,正常情况下先创建的任务先执行,其执行完成后又被丢到任务池然后继续进行下一阶段的任务执行操作。如果每个任务在每个阶段的执行时间都是差不多的话,那么理想情况下先创建的任务在每个阶段执行完成之后回到队列中进入下一阶段,此时它的create_time还是最小的那批,很快又能被调到,无法让出调度给更饥饿的任务。虽然这种情况是正常的,但如果希望排队机制更加公平一点,理论上任务在执行的每个阶段都需要重新排队

异常任务列表占据资源:如果按照任务创建时间来批量获取任务列表,假设单批获取1000条,有可能存在创建时间在比较前面的异常任务(或者是由于业务希望通过足够的重试来执行任务以提高成功率),那么很有可能每次都会拉到一大批“异常任务”,进而导致这批异常的任务反复执行,阻塞了其他普通任务的执行

​ 基于上述分析,对于排序规则的设定需要考虑任务执行的均衡性和容错性,这是任务排序设计的一个基础理念。在一些异常情况下,可能某些任务的执行跟中毒一样反复失败,排序设计的机制要尽量确保worker能够正常运作,提升任务的成功效率。

2.按照更新时间

​ 基于方案1的设计,希望可以有一种雨露均沾的模式,让多阶段任务在每个阶段都重新排队,也可有相应的重试机制,尽量让每个任务都有可执行机会。因此可以考虑引入更新时间进行排序。

select * from t_lark_task_1 where status = 1 order by update_time; 

​ 对于多阶段任务,每个任务在当前阶段执行完成、更新记录,在进入下一阶段时应该重新排队,因此基于update_time排序,则是起到一个堆尾的作用。就算任务执行失败,也提供执行机会,只要其按照指定规则重新排序规则

​ 针对重试机制,失败了提供重新执行的机会即可。但是此处如果引入重试间隔概念,就会出现歧义了。(引入重试间隔是为了避免任务被频繁执行、调度,这是结合业务经验设定的概念,例如异步调用一些付费接口,如果不设定重试间隔、最大重试次数这些配置,那么可能出现频繁地重试去调用这些付费接口,就会带来巨大的成本)

​ 如果按照更新时间进行排序,则flowsvr会过滤掉这些重试间隔还没到的失败任务,一旦重试失败的任务很多,那么每次拉的可能都是这些重试失败的任务,但是又会在flowsvr中过滤掉重试间隔还没到的数据,周而复始做无用功的同时还阻塞了其他普通任务的执行

思考:按照更新实现排序,会导致重试间隔无意义?

​ 因为重试失败也会更新记录的修改时间,如果重试的异常记录很多,那么下次拉取就会把这批重试失败的记录重新拉取出来,但是拉取到的可能是最近尝试失败的一批数据(理论上是不能做的,在flowsvr会进行过滤),失去了拉取的意义,因此无法用更新时间做排序

3.按照order_time

​ 综合上述场景分析,可以看到一个排序字段的设计太不纯了,需要考虑到很多的场景因素。由于排序的影响因素有很多,不能仅仅和单个字段耦合,因此思考是否可以进一步分析原因,试着将这个问题抽象出来,形成一套解决的体系。即先要分析有哪些因素可能会影响到排序,将这些影响因素罗列出来,然后针对场景去拆解。

​ 通过**引入“中间层”**的概念,抽象出一个order_time字段来整合这些排序因素的影响,在相应的执行节点中去设定这些影响效果,无论后续是否新增了字段对排序规则有影响,都可以通过相应的方法将其影响效果在对应场景中整合进来即可(包括优先级的引入,本质上也是一种排序的影响因素)

排序策略的可行性

针对排序规则的设定,实际上不管是采取什么策略,如果任务永远出现在最前面,那么只要这种任务有一定数量出现问题,这种策略就会垮掉。因此此处不管是排序还是优先级概念都是一个相对的概念,需要结合实际业务场景进行配置设定,如果这些数值设定被设置的太“极限”,那么这种所谓的相对优先也会变成“绝对”(“绝对优先”容易形成阻塞)

​ 而对于这种“绝对优先”的异常场景,则需要引入相应的机制去发现、解决问题,例如引入及时的告警机制,或者提供异常处理的功能(可以通过人为介入去修复错误、或者单独把这些异常任务捞出来)

​ order_time 的值会被相关的排序影响因素影响,参考下述思路:

  • 任务创建时:order_time 字段的值等于创建时时间戳(即创建时间)
  • 任务更新时:order_time 字段的值等于任务更新时候时时间戳(即修改时间),如果任务执行失败,还需要加上计算得到的重试时间间隔
  • 此外,针对优先级的设计方案,需将优先秒数在不同场景也考虑进去

最终排序策略:按照引入了优先级的order_time进行排序,然后拉取任务(优先级相关的说明可以参考【优先级设计】)

优先级设计

​ 针对t_lark_task_1数据表,其中有个priority字段的设计,基于此设计出两种优先级方案

  • 方案1:priority 排序,构建联合索引
  • 方案2:priority 与 order_time

1.方案1(优先级为等级划分)

方案核心

​ 基于priority进行排序,提供优先级概念(此处的优先级是一个等级划分的概念,例如123:1-低优、2-中优、3-高优)

select * from t_lark_task_1 
where status = 1
order by priority desc,modify_time limit 100;

​ 其核心是将priority加入排序参数用作联合排序(为提高检索效率,针对这种高频操作需要建立联合索引)

​ 此处需注意联合索引的创建,使用的时候一个是倒序、一个是正序,相应创建联合索引的时候也要保持和使用一致,否则就会出现索引失效的场景

方案优缺点

  • 优点:简单易实现
  • 缺点:
    • 需将priority加入联合索引,影响性能(且很多时候priority的值会有很多重复,使得这个联合索引的构建有点亏)(因为索引的维护需要一定代价,因此需考虑联合索引构建优势是否大于其维护成本)
      • 所谓priority会有很多重复是因为大部分业务场景中大多数的任务是普通任务
    • priority是固定的(不够灵活),例如优先级高的任务失败之后其又是可以优先被重新调度,如果这个任务一直失败的话就容易占据资源导致堵死普通任务
      • 针对灵活性这个概念,可以考虑支持priority的设置(通过人工介入设定任务在每个阶段的优先级),当出现异常情况时可以将任务执行置后

2.方案2(优先级为优先多少秒,结合order_time应用)

​ 此方案是将 priority 优先级的概念进行抽象,变成优先多少秒。基于这种思路更倾向一个相对优先的概念,如果有些业务就只想要方案1那种低优、中优、高优的概念,此处可以将 priority 极限化,将优先级定的足够大、差距也拉大,因此将相对变为绝对,让其也可以支持所谓的低优、中优、高优。

​ order_time 可以理解为影响实施任务调度的一个字段,所有的任务调度都通过 order_time 来进行。而创建时间、更新时间、重试间隔等都会影响到任务本身的调度,因此会相应影响order_time的设置

方案核心

​ 在项目中抽象了order_time作为排序字段,order_time可以包含创建时间、更新时间、重试间隔的影响,因此可以考虑将优先级的影响也纳入其中。此处的优先级不仅仅依赖于priority,它会随着order_time的变化而灵活调整,相当于通过order_time影响了优先级,只能优先一定秒数,但不会一直都优先,因此解决了方案1的问题

​ 此处 priority 的设计不再是方案1中单纯的数字排序,而是给它设定一个语意,表示优先几秒,最大优先1年(足够大),方案设计如下:

  • 创建时:order_time=当前时间(创建时间) - priority,表示优先priority秒排序,比如priority=10,相当于是提前10s创建的任务
  • 调度之后:orderTime=当前时间(更新时间)-priority,始终维持着优先秒数
    • 失败重试 & 重试间隔不等于0,此时orderTime = 当前时间(更新时间)+ 算出来的重试间隔(重试机制的目的在于隔多长时间才调度,因此此处取消优先级概念)

方案优缺点

  • 优点:
    • 解耦(原来order by需要根据不同条件配置,还可能受到新增字段的影响,此处抽象出一个order_time字段单独用于排序,又能整合其他字段的影响)
    • 不用将priority加入联合索引(order_time 是一个综合影响因素,会受到多方面的影响而调整,体现的是一个整体的效果,如果有引入新的影响因素(例如此处的priority)也只需要通过业务代码层进行调整,不用引入类似方案1那样需要额外的联合索引构建成本)
    • 足够灵活适合框架
  • 缺点:
    • 多阶段任务的优先级一样(如果说此处对应priority的字段是固定的,则多阶段任务的优先级也是一样的,可以考虑更加灵活一点,例如将priority配置为列表形式,用于对照不同阶段的优先级设定)

​ 方案2的设计 性能友好,同时多阶段情况下,让低优任务也有被更多被调度的机会,整体更加灵活,耦合更小。用户可以灵活使用优先级,比如用户可以定义高优是优先就是优先1天,转换成priority参数即可

扩展思考

问题1:方案2中为什么设定了重试间隔就要让优先级概念失效

​ 针对这一问题的思考,首先要理解为什么要重试间隔,这点是基于业务考虑的,引入重试间隔是希望任务执行失败之后隔一段时间再被调度,如果此处引入优先级的话,理想状态下是可以发挥优先的设定。但是极端情况下考虑,如果出现优先>大于重试,那么此处的重试作用就会被覆盖掉,而失去其业务意义(优先是做减法、重试是做加法),会有点自相矛盾的感觉,就会复现方案1的极限场景问题,由于优先级的“超前”影响使得任务一直占据资源,堵死了其他普通任务的执行。

​ 可以结合数据分析:假设任务A和任务B在某一时刻都失败重排(假设这两个任务重试间隔都一样为10s),结合不同优先级设定分析如下

  • 重试间隔时间>优先秒数(A优先6s、B优先3s):如果通过order_time = current_time + 重试间隔时间 - 优先秒数,那么A优先于B执行,这是正常的场景分析
  • 优先秒数>重试间隔时间(A优先100s、B优先50s):如果还是基于上述公式计算,此处A虽然也是优先于B执行,但通过计算结果可知此处的重试已经被失效了,脱离了业务含义,不仅没有了重试等待这一过程,还可能因为优先级设置过高而堵住了其他普通任务的执行

​ 因此在基于重试这一场景中,需让优先失效,才能避免一些异常的场景情况,即当重试间隔不为0,调度之后的order_time应为current_time + 计算得出的重试间隔时间

问题2:方案2中解耦的概念如何理解?

​ 所谓耦合从模块设计层面可以理解为模块A依赖于模块B,因此模块A会受到模块B的影响,所以说模块A和B耦合,解耦则可以理解为为降低模块间的依赖关系。

​ 则针对引入order_time为啥能做到解耦?首先从order_time的作用去理解,其设计核心是用于任务排序,可以对比引入前和引入后的区别:

  • 引入前:任务的排序字段可能会受到很多因素影响,例如创建时间、修改时间、优先级、重试间隔时间等,如果有新增的字段也可能对任务调度的排序规则有影响,相应实现的SQL也要去做适配
  • 引入后:抽象出一个order_time字段用于排序(可以理解为用于整合排序规则的影响因素),就算新增了影响排序规则的字段,也只需要将其整合到order_time,而不影响原有的SQL实现

​ 即通过抽象出order_time字段作为排序字段,用于统一整合影响排序规则的影响因素,降低排序规则对代码设计的联动影响

问题3:多阶段任务的优先级如何实现?是否有必要实现?

​ 针对上述场景分析,可能优先级设定是一个定值,对于多阶段任务,如果希望任务的每个阶段的都不同,则可以考虑灵活设计 priority 字段,将其由原来的定值形式调整为字符串(存储每个阶段的优先级,以特殊符号划分),基于此每个阶段都重新计算,根据灵活的优先级来拉取数据。

​ 虽然这种场景很灵活,但还是要考虑其实践应用,虽然可行但实际上并不太常用,因为对于很多业务场景的多阶段任务而言,这些阶段的任务本身可能就具备强依赖关系(或者可以理解为在现实生活生产中有一定的依赖顺序),所以对于多阶段任务而言可能更加关注的是任务的顺序执行,相当于用户把这件事情交代下去,只要不出意外做完即可,至于每个阶段的是否要进行不同的优先级设定,还得考虑实际执行的情况来决定,这也不是此处设计的重点,

​ 框架在追求灵活性的同时也要考虑其应用场景,尽量不要去复杂化一个设计的实现,用最小的成本获取更多的效益

问题4:优先级的设计是绝对优先?还是相对优先?

​ 从字面概念上理解,绝对优先就是每一次都是该它最优先(类似于方案1等级划分的优先级设定),asyncflow采用的是方案2的相对优先概念(order_time - 优先多少秒),因为绝对优先容易形成一个阻塞(例如在囤积了一大批优先级高的异常任务需要执行,就算提供重试机会,也会因其优先级高而拉取到大批异常任务,进而阻塞了其他普通任务的执行,相当于占了其他任务的调度机会)

问题5:分表之后优先级失效如何处理?

​ - todo : 优先级和重试间隔 问题答疑(调优和问题补充待梳理)open in new window

​ asyncflow中实现的逻辑是:分表之后直接认为优先级就失效了,为什么这样做? 是因为此处的分表其实是滚表,认为这是一个短暂的一个行为,如果是超过一个时间,可以增加一个逻辑,超过这个时间之后,一定的调度能力,就新表也调度一些,让这种高优任务不要被饿死就可以了。我们就做到这一步,过滤。如果是通过判断当前时间是不是等于这个失败重试任务的排序时间来过滤的?为什么是等于?要判断也是一个一个大于小于正常拉取的时候不是等于是一个小于。就比如说现在这个间隔时间,加上之前更新时间是未来的一个值。比如说我们举个例子可能大一点,你好理解一点,这个时间是 100 年后的时间戳,那你当前时间没有达到就是没有达到,这很好理解。

​ -1 是经营重试(可以实践一下)。性能调优其实是可以的,但通常而言的话就是调优以下连接池啊, GC 那些参数其实效果非常的不明显,(忽悠向),在某些特定场景下也许能测出来,但每一次测出来都是不一样,所以没什么意义。 worker 怎么去压测?(待实践)

​ 那调优,这里主要其实就是 MySQL、连接池调优这块的话已经在排期中了,就你会发现你连接池如果特别小的话,你的性能确实会低非常多。这其实已经不算是一个普通意义上调优,是因为你连接池如果太小的话,你会有非常多的报错,所以你的 QPS 肯定上不去,有点取巧,像 GC 那种改了一点点哪看得出来对,失败的时间是未来的我们每一次如果是想不通的地方,你直接去想极限一点。一百年后压测有的可以。那个找诸葛琴要一下,他是放在资料里面的。

问题6:MySQL的最大的连接数量可以设置为多少?

​ MySQL的最大的连接数量是多少? 2000 没问题的(实践得出),但是我们这里是客户端,我觉得这里是不是说的有点歧义?不是, MySQL 最大连接数量是多少?是咱们客户端去访问 MySQL hold 住的这个连接数量是多少?分表逻辑现在是改了吗?看有没有可能。对本来也没有代码,所以这个其实是只要想到就能去改。是这样的,就比如说我们之前分表其实是可以跨多张表,但是这样答的话容易被挑战的更多,那我们不如就限定两个,两张表我们就咬死,我们这个策略就是你跨多张表就是不正常的,我们本来就是滚表你要是连续做的话,那明显是前面积压了。那这个时候我们为啥一定要分表呢?所以我们就保持两张表而且两张表的话就很这种聚合查询也就好做了。如果多张表的话,基本是不能做多张表,如果两张表就好查,第一张不够的再去查第二张。比如说我们分页查询,你假设查 10 万个任务,然后第一张表查完之后有个标记说第二张表还有你去查就行了,

问题7:ES数据接入?(todo)

​ ES 是可以的,我们之前多张表的考虑一下,就是说把数据导到ES,这个不用想得太复杂,其实就是MySQL 做不了这个事情,它不适合做这个事情。多维度查询,就我们的数据去接入ES,MySQL的数据就会导入到ES,就另一个存储,你就理解为它就是一个另一种类型的MySQL,然后它可以比较方便去查这种聚合的数据。这个刚才说了会出一个视频,但其实主要就是连接词调优,其他的像 GC 什么的都是看不出效果的。多表其实不是连查,是这样的,就如果我们是跨多张表,那么我们就不能去连查,其实还是要ES,不然那个根本就不好做,也没有什么可实践性但是如果我们只有两张表,就我们分表,我们最大就限制两张表,你可以看一下我们上一期这个 flow 夜市里面讲的,我们分表其实就可以限制为只有两张表这样子的话两张表都好查,你第一张没有,你第二张去查就行了。

​ 这里可能是有点偏差,不是跟 MySQL 没有关系。什么意思?就比如说你自己这里的端口数不够用了,或者说你的连接是用完了,你其他东西在那等待,这种情况下是你客户端你访问任何的东西都是,就比如说Java 里面是等待, Golang 里面是连接,是端口耗尽,那这样子的话你自己客户端就会报错,MySQL自己还没到瓶颈。当连接池解决掉之后,MySQL 的性能其实就几千,这就是我们最终的瓶颈,我们这里就是设计,我们指两张引入 ES 就可以查了,你就可以做了。但这里的话,如果你要这样回答的话,你可以讲这个思路。然后如果你想把 ES 的操作也能打上来的话,你需要去学一学 ES 的基本操作,就像 MySQL 一样,你要知道它的命令怎么用。对,去看一看 ES 的教程是可以的,通常而言的话一般就达到把它导入到ES,如果它真的是真的跟你深究的话,你说 ES 我知道它是一个劣势,查询它支持这种聚合能力,但是具体的没有,这只是我一个设想的一个方案。

其他问题

​ 从问题上理解这个问题,最终是有歧义的。就不是 MySQL 的瓶颈,这个时候是客户端连接池导致的这种长短连接的问题,或者说连接等待问题造成了一个瓶颈。就MySQL这个时候远远还没有达到一个瓶颈 ​ 像调优这种它其实是一个综合性的能力,建议就点到即止,就如果是越线容易,就说在面试的时候越弹这个容易弹得越深,就可能要找到对应的应该所谓糖的点。就比如说我们,就我们实际上也是连接池调好之后,它就是能从几百到几千,那这样的话我们咬死这个就行了,这个的话后面也会出一个视频,这视频里面其实也是了,你就调一个连接时参数就是可以几百到几千,其他效果都不太明显。因为你连接池低了的话,其实你会直接报错的,对你的数据都进不去明白,所以这里优化这里确实是一个综合性的能力。而这里的话我也建议如果是经常被优化这里就导致这里也被挑战,比较凶的其实可以优化一下简历,把优化那个性能优化写薄弱一点,就把其他的一些优点写上去。

​ 针对调优要有自己的方向和见解,可以说出自己的一套逻辑,要能圆回来。在对话的过程中更倾向综合能力的考察,要体现自身思考的过程,而不是死记硬背,体现对问题的认知和理解即可。在这个过程中更重要的是表现出一些对技术的掌控力,对问题的思考力,沟通和协作能力,最常见的是系统设计(完整方案解读),不要因为没有想出完整的方案就不敢尝试,不要用事前事事完美的学生思维去逃避问题,只要能说出自己的方案、理解,经由对方循循善诱进一步完善方案即可。

​ 针对版本优化:例如计算总行数的时候,是用 count 星去算,还是用我们去组件ID,就因为它的顺序是递增的,我们去排序,逆向排序去找到它的最大值哪种好。那你如果说第一种好的话,那就是它更加的规范,也更加的准确,不受限于这种ID,不产生这种耦合,它结构设计肯定更好。同时第二种方案产生不了明显的,在我们大局上产生不了明显的收益投入,产出比也不见得够,那为啥要做这种妥协呢?那第二种我们后面简历里面就写到我们做这件事情来提高了性能,那提高多少呢?能够抗得性 500 万,起码也要将近 500 秒到 500 毫秒到1秒。那你这种如果进行一个简单排序啊,其实就是 100 毫秒,200 毫秒估计就能搞定,有时候快点哈几十毫秒也是可能的。 ​ 那这样子的话,我们性能其实提高了不少的,也是可以写的,所以是可以正向和逆向来说,对,这个我们的这个时间是秒,这个其实可以去想它应该是什么,那它应该是毫秒吗?毫秒可能太小了,那如果是秒的话其实更加的合理一些,但如果是分钟呢?那它力度会不会太大了?对,因为有一些任务,它如果一些框架处理得比较快,那它可能这种也是几秒钟就能出一个结果。这个时候你如果不支持到一个秒是相对比较合适的一点,但你这个如果是往毫秒走其实也行。那这里也就要考虑一下。 ​ ​ 但其实很多调优都是那个,都是没有什么效果的,但是可以拿来说一说,真正有效果还是连接池那个调优最立竿见影。

​ 项目交流主要是分析项目难点和缺点,就是难点以及缺点是什么呢?缺点就是我们可以优化的地方,就比如说我们之前说的我们的竞争是可以进行-个解耦的消息队列解耦的,我们的任务是可以支持更复杂的结构的,我们不会去做,但是我们可以这样去说,我们任何的一个东西都可以说成一个难点,最难解决的bug,这个其实也是可以去编一个嘛。

​ 比如说我们刚才在设计的时候不就能想到嘛?咱们有一个 bug 是不是就是分表的时候,那我们优先级就完全失效了?那在极端情况下,我们的新创建的高优任务很长时间不能解决,不能执行,那我们做怎么样的优化,对吧?不就我们之前说那个嘛?那对应的,还有我们之前说我们一开始用的是更新时间去进行排序,他最后出现了什么问题?是我们出现了阻塞的这种问题,所以我们抽象成了 word time,就把自己想讲的点给抽出来了。

​ 引入分布式锁使竞争冲突减少90%,这个数据是这样的, 90% 是个谦虚的说法,我们这里的竟争是说减么对 MySQL 的竞争,那一引入分布式锁的话,其实是可以说是解决了这个问题,那你可以说得更高一些,你可以跟他说其实这个基本就是解决这个问题。那什么情况?就是我们分布式锁失效的时候还是会去竟争,所以我们这里可以加一个 90% 以上的这个竟争率,实际上它是不会竞争的,除非我们分布式锁失效

​ 例如为啥分表以后优先级会失效?你两张表,你只我们只调投老表,所以就失效了?就你拆分之后!我们去拿任务的时候,不就是只拿到只拿老表的嘛,所以我们就失效了。我们并不想去做这种聚合,但是现在我们限制的只有两张表,所以去做一些聚合的可能性会更大一些,就能做可能性,你这个可以再思考-下。对,然后我们原来解决方案就是当出现这种跨表之后,他一定时间都还没有做完,就说明有点问题,那这个时候就会分一部分调度去给新表,这样这种高优任务不至于饿死,达到这么一个兜底效果。

多阶段任务

核心概念

多阶段任务:一个任务需要拆分为多个阶段执行,阶段之间最好是同步的(这点是结合业务实践得出的,现实场景中很多业务都是的阶段执行存在依赖关系,下一阶段的执行需要依赖上一阶段的执行产物),例如最典型的视频处理案例:需先审核、然后进入下一步转码操作(中间可能还有获取元信息,此处案例说明简化为2阶段任务)。

​ 对于视频处理这个案例场景,上述是将其构建成多阶段任务,每个阶段都是异步操作,阶段之间是同步的。

​ 当然,也可以将视频处理这个过程直接当做是两个任务同步操作,例如审核是一个AI操作很快,那么可以在调用完后就可以快速更新状态,直接进入下一步转码操作(回归到传统设计的顺序执行概念)

核心设计

多阶段任务task_stage varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', 记录阶段名字

image-20240802092031403

​ 当worker拉取任务,执行完第1阶段,则更新任务状态和信息,将任务丢回池子,等到下一轮拉取执行任务的第2阶段

上下文:针对多阶段任务的执行,当一个阶段执行完成需要将其丢回任务集合然后再拉回来,需要依赖上一阶段的执行结果,因此需要通过上下文来存储上一阶段的执行信息。

task_context varchar(8192) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '任务上下文,用户自定义',

​ 至于这个task_context的存储格式则取决于业务需求,因为解析这一操作是自定义的(通过实现ContextLoad接口),因此可以针对不同业务的需求进行适配:

  • JSON格式
  • 纯文本格式
  • URL(拉回来解析)
  • XML格式等

​ 可以通过这个上下文字段存储每一阶段的执行信息,如果希望更加灵活则可拆分两个字段:一个字段存储上一阶段的执行信息、一个字段存储所有阶段的历史信息,基于此每个阶段可以灵活获取前面阶段的执行信息。这种思路有点类似工作流概念设计,每个流程有相应的执行进程(任务),存储各个进程信息和中间元数据,又设定了历史进程用于存储历史数据信息。

​ 例如此处的视频处理业务场景,它的上下文数据可以设定为ContextData:包括源视频URL、第三方视频检查任务的ID、第三方视频转码任务的ID,拆解如下:

public class ContextData{
  private String sourceUrl; // 源视频URL
  private String vodCheckTaskId; // 第三方视频检查任务的ID
  private String vodTransCodeTaskId; // 第三方视频转码任务的ID
}

// 如果对ContextData做阶段拆分,则可以拆分为不同阶段的请求数据概念(这个概念的实现由开发者结合业务场景来进行设计,不局限于某种模式)
// 根据每个阶段的请求来进行拆分,则可将ContextData拆分为ReqData、MiddleData
public class ReqData{
  private String sourceUrl; // 源视频URL
}
public class MiddleData{
  private String vodCheckTaskId; // 第三方视频检查任务的ID
  private String vodTransCodeTaskId; // 第三方视频转码任务的ID
}
public class ContextData{
  private ReqData reqData; // 请求包
  private MiddleData middleData; // 中间信息
  // 其他阶段扩展信息等
}

单阶段并发(并发如何处理?)

​ 以上述视频处理场景为例,针对多阶段任务,例如在stage1中审核完成需要进入下一步转码操作,如果此时针对转码需求希望将视频转为不同分辨率,则相应在stage2中可能需要调用多次转码接口,相应地stage3中也需要对stage2中的执行结果进行分析整合,只有stage2中所有操作成功才认为是成功,这是一种将并发放到单阶段并发的设计。

image-20240802095816957

分表

1.概念说明

为什么要分表?

​ 分表设计:引入分表的目的是防止单表数据过大,影响对表操作的性能。该设计设计表t_schedule_pos表,关注其中的核心字段:

  • schedule_begin_pos 表示热点任务数据从几号表开始的位置(消费位置)
  • schedule_end_pos 表示热点任务数据从几号表结束(新增任务插入位置)

​ 一开始两者都等于1,表示只有一张表。

热点任务数据:指的是还需要worker去调度的任务(未完成的任务),因为于一般场景而言,已完成的任务慢慢会随着时间变冷

​ 此处将分表流程放在任务治理模块(也可认为其为flowsvr的一部分,只不过此处做为服务概念拆分将任务治理模块单独拆出来解耦、独立),可以理解为单独开发线程进行扫描

如何分表?

​ asyncflow 的分表概念实际有两点:根据任务数量分表根据任务类型分表

  • 根据任务数量分表:单表存储存在性能瓶颈,结合业务经验按照500w的阈值进行水平分表
  • 根据任务类型分表:在表设计的时候,针对不同的业务场景引入业务类型进行划分,不同业务类型的数据存储在不同的表中。根据任务类型拆分表,不仅可以隔离业务,也是一种性能优化的方案(分表)
    • 此处也进一步解释了此前不将所有的任务都堆到一张表中,然后按照业务类型进行区分,一方面是业务隔离、其次则是便于分表管理,可以进一步结合图示理解

image-20240802132940962

2.核心设计

分表流程说明

  • 【1】当1号表达到阈值
  • 【2】任务治理通过扫描,发现了1号表记录数量达到了阈值(例如500w分一次),触发分表 =》创建了2号表
  • 【3】经过步骤2,此时的状态是占据任务请求走1号表(schedule_begin_pos为1),但新创建的任务,是放入2号表(schedule end pos为2)
  • 【4】worker持续占据任务、消费任务,直到1号表任务都消费完成
    • 此时,worker再去占据任务,就会发现1号表没存活任务(未完成的任务),就会将schedule_begin_pos变成2,此后,占据任务请求和写入任务请求都是到2号表
    • 同样,任务治理循环也可能发现1号表没存活任务,也会将schedule_begin_pos变成2,此后,占据任务请求和写入任务请求都是到2号表
image-20240802102650630image-20240802140716838

问题1:为什么要设计分表过渡概念?

基于此流程的设计主要也是考虑到实际场景中任务执行操作会比任务创建操作慢,因此要提供一个过渡的概念,让请求平滑过渡到新表,而不是直接硬核切换

问题2:如何判断任务都消费完成?

​ 所有的任务状态都为终态(任务失败、任务成功)则认为消费完成。有可能会出现一种情况是1号表的最后一批待执行数据都处于执行中,此时其他worker还是会继续从1号表拉取(可能就会出现短暂做无用功的情况),即新表的消费必须要等到旧表所有的数据都被执行完成才能进行切换。对于一般场景的正常流程来说,这个设计是可行的,除非遇到一些比较紧急的特殊情况需要特定去消费新表。

问题3:消费完成的发现时机?:即什么是否会发现老表的任务数据消费完成?然后进行请求切换,将所有请求推到新表

​ 首先理解“消费完成”的概念,即老表中的所有任务都达到终态,可以有两个时机节点去发现:

  • 占据任务时:占据任务时发现没有存活任务
  • 任务治理时:任务治理循环

问题4:谁来执行分表操作?

​ 理论上flowsvr可以专门开个线程去做一个分表的事情。但是从解耦的角度上来看,flowsvr面向的应该是业务层的访问,对于分表操作应该单独抽象出一个服务(例如此处的任务治理服务)来做

​ 任务治理服务可以是多节点部署,实际上业务并不关注哪个节点做分表。但实际上可以不用去强行构建多节点,也可以构建稳定的主从状态构建单机模式,其他从机作为兜底即可

image-20240802135804849

问题5:如果在切换过程中出现任务执行时间长+消费失败不断重试的情况如何处理?(分表过程中因为某些原因老表迟迟没完成,新表的高优先任务会不会饿死):这一点需要回归到“任务排序顺序和优先级问题”去分析,如果在切换过程中出现任务执行时间长+消费失败不断重试的情况,可能worker会不断拉取到这批失败的数据,导致卡死阻塞了其他任务的调度(体现出来的场景就是滑动的旧表不滑了)。可以针对任务排序机制去说明(create_time、update_time、order_time),针对三种不同的排序机制分析这种情况可否得到相应的解决。

​ 但就算是基于order_time做排序,在一些异常场景下还是不可避免会出现短暂卡死的情况(order_time和优先级的设计是为了让其他普通任务也能得到调度机会,如果剩下的都是一批失败重试数据(例如某些任务可能需要重试几天,但是因为某种原因一直重试失败),此时也不可避免需要等到worker消费完所有的任务直至其完全成功或失败才能过渡到新表,因此就可能出现短暂卡死的现象,因此也要考虑好重试机制的业务设定)

​ 针对上述这种可能出现的短暂卡死的异常场景,也有相应的解决方案(考虑兜底方案):

  • 根据业务设定合理的重试机制(重试间隔、最大重试次数等),不能无限重试失败
  • 因为预期的滚表操作是一个比较短暂的过程,上述这个情况主要出现在分表过渡尾声时,可以设定一个时间阈值,如果分表超过这个时间阈值,则根据不同权重调整调度策略(新表、老表都调度一些任务),尽量让worker都动起来,不要出现卡死的情况
  • 或者在拉取任务的时候发现拉不到任务,则此时考虑提前切换到新表(拉取任务校验任务列表为空,且begin_pos和end_pos不同,则可执行begin_pos++,切到新表)

数据表状态流转

​ 从数据库的视角理解:

  • 【1】初始时候,schedule_begin_pos和schedule_end_pos都是1,此时占据请求、写入请求都走1号表
  • 【2】1号表纪录数量达到阈值,并创建2号表之后,schedule begin_pos依然是1,但schedule end pos变成2,此时占据请求走1号表,写入请求,走2号表
  • 【3】消费完1号表后,schedule_begin_pos就会变成2,此时占据请求、写入请求都走2号表
image-20240802104153230

分表数据管理

​ 对于分表而言,可能会出现一个任务存在于多个表中?(确认是否每个阶段都会生成一条记录),因此如果提供任务管理概念的话,就会涉及到分表的跨表查询问题,基于这个场景可以考虑引入类似动态SQL的方式做一些逻辑转化,对于跨表逻辑,虽然实现复杂、性能也相对比较差,但作为任务管理接口而言容忍度会更高

​ task_id 的生成规则和表有关,根据解析后缀可以定位任务位于哪个数据表,进而快速检索数据信息。如果希望支持更加丰富的范围检索,可以选择借助ES这种第三方组件,依托其灵活的范围检索机制可能会更加合适。

3.扩展问题说明

分表方式介绍

​ 定时检查任务数量,当任务数量达到一定阈值就会发生分表,分表过程限制最多同时2张表存在,此处将请求分为占据请求和写入请求

  • 初始状态:定时检查任务数量是都达到阈值,当超出阈值则进行分表(创建2号表),在分表之前所有的请求都是走1号表
  • 扩展时状态(1号表仍有存活任务):如果1号表仍有存活任务,占据请求还是继续走1号表(将1号表的任务消耗掉),新的写入请求(创建任务请求)则是走2号表
  • 扩展后状态(1号表无存活任务):如果1号表中所有任务都被消耗完成,则后续所有的请求都走2号表,此时分表操作完成(所有请求从1号表切到2号表)

​ 这个过程中消费几号表、写入几号表的信息配置通过一张单独的任务位置表记录的(可以将这个t_schedule_pos表理解为分表映射概念,用于记录当前分表的状态信息)

为什么按照大小分表(例如设定500w阈值,滚表概念)

为什么是500w?:单表查询如果数据量达到 500w行(业务场景的一个经验值)就会影响事务的执行效率,从而导致查询效率降低,因此为了提高查询性能需要进行分表。分表提高查询效率的原理是通过降低返回的数据行数来提高查询效率

​ 基于现有的业务场景,任务执行完了基本就冷了(和账单那种随时间变冷其实很像,即任务做完之后很快就成了比较冷的数据,甚至某些业务1、2月前的历史数据都可以删除掉),此处的分表概念其实更偏向于滚表(滚动分表),不同于“传统的分表”,因此此处对于旧表是不会再复用了,对于旧表只提供查询功能(类似历史数据概念)。

​ 分表之后,任务消费、任务创建这种主要操作都是发生在热表上,通过任务id查询任务信息这种相对低频操作则可以通过后缀快速去对应的表查(无论冷热)。这种策略对性能友好、也对后面存储友好,同时也非常适合一般的业务场景(例如对冷数据删除可以考虑根据业务需求进行整表删除,因为冷热数据边界明显,并不会互相掺杂混合)

滚动分表:概念类似于网页的滑动窗口,每张表的大小一样,1号表用完了就会滚动到2号表

且由于分表逻辑限制了最多同时2张表存在,可以将分表是当成一个过渡状态,预期就是短时间内能完成,因此最多跨一张表分,即不会出现schedule beign pos和schedule end pos跨不止1张表的情况。如果说出现“跨了不止2张表”的情况,则需要考虑更加复杂的场景,例如多张表的流量分配、调度排序等问题

为什么不选择按照时间分片?

​ 这种方式的分片规则本质上也是遵循时间分片概念(因为老表的数据总是会比新表早),但主要还是考虑到触发分表的时机,按照大小分表兼顾的是数据的均匀性和及时性。如果仅仅是基于时间分片的话,每个时间阶段的任务增速和流量大小都有所不同,所以很难去控制分片的度,如果仅仅按照平均频率去预估一个分片大小,有可能出现旱的旱死涝的涝死的场景,如果某个时间段的任务超标,则可能还没到时间就超出阈值了。

分表为什么要自己写?为什么不使用组件?

​ 有关“为什么?”都可以结合成本和效益去分析,为什么做?谁可以做?谁做更合适?此处结合分表场景(分片规则)、分表实现进行分析

​ 首先是基于分表场景的规则,目前asyncflow框架是按照数据量大小来起迭代新表,它的构建思路是区分冷热数据概念,老表逐渐作废,不是多个分片都长期提供服务,倾向滚表的概念。对于老数据仅提供检索功能(因为任务一旦完成,基本就是过冷了,业务场景对这些老数据的复盘场景其实并不多,且一些老数据到后期都会直接删掉)。

​ 常见的分表组件有Sharding、MyCat等分表,它是按照业务字段进行hash分表(例如根据userId进行hash,将不同用户的数据分到不同切片)

​ 此外,基于现有的分表逻辑场景,这个分表逻辑并不复杂,自定义实现是可行的,且不依赖于第三方组件,降低维护成本

分表难在哪里?

分表场景的设计和方案的选择:要结合业务场景、调研现有分表组件,设定合适的分表方案。例如此处asyncflow的分表则是基于两个维度进行拆分(按照任务类型分表、按照任务数据量大小分表)

分表的细节:例如分表实际、分表规则、分表执行者、分表性能影响等

​ 例如性能考虑的话,可以结合下述方向分析

  • 数量统计是通过count(*)、还是max(id),提高检索性能还有什么方案?

    • **count(*)**可保证数据的准确性

    • **max(id)**虽然检索性能稍微较好,但是需确保id起始值和步长,否则数据统计就会有问题

    • 如果对数据准确性要求不高的话,可以考虑统计一个估值(explain),参考一些搜索引擎搜索词条时给出的是一个大概的检索结果记录数(检索大约多少条数据)

  • 降低分表检查的频率,例如设定合适的定时方案,容忍一定程度的延迟

  • 为了快速检索数据,可以设置任务ID的生成规则,让其关联相关的分表规则,以支持快速检索

为某个项目做框架,量这么少为什么要分表?

从收益和成本考虑:首先框架的抽离是基于对业务场景的抽象,框架的构建并不拘泥于某个项目,需要应对不同的情况(即框架不是单单服务于某个场景),其次考虑相应的成本效益来分析这个功能点实现的必要性。

​ 从收益考虑:虽然现有的业务场景(项目)的数据量比较小,但其他异步场景一天几十万也是正常的(比如区块链场景、音视频场景),所以提供分表于开发者而言让框架不那么玩具化。

​ 从成本考虑:现有的分表方案其实偏向滚表,到500w就产生新的表,旧表还有存活任务就继续消费旧表,新任务写入新表,实现起来代码量也比较少,思路也比较清晰

​ 成本较低综合收益和成本来看,这个分表设计算是前瞻性设计而不是过度设计

如果读写请求达到单个MySQL数据表无法处理的情况怎么解决?

水平扩展问题:**此处无法水平扩展(增加MySQL数量)主要是基于上述的“多机竞争”问题,如果为了水平扩展引入分布式锁,那么多个worker之间就会互相干扰,无法兼容水平扩展。**因此拉取任务都是单机拉取,而执行任务则由多个worker执行。且不引入水平扩展主要在于异步场景的目标设定不高(例如达到2000/s基本满足)因此此处使用的是单个MySQL数据表,故而无法通过增加服务器节点的形式来解决单个MySQL的性能瓶颈为题,可以考虑从两个方向切入,业务场景是否可以达到性能瓶颈?如果达到性能瓶颈如何应对?

业务场景是否可以达到性能瓶颈?(底层调度是否真的需要这么大的QPS?)=》实际业务场景中的异步任务需求并不会特别大(结合业务场景说明,例如区块链比特币底层调度能力每秒10多笔、音视频场景也类似,如果底层调度都没有这么大的QPS,其调用异步场景达到再高的QPS也是过渡设计),因此基于一般的配置也足以支撑,除非是一些特定场景才会达到性能瓶颈,普通的MySQL配置如8核16G,每秒6000是没什么问题的,一小时可以支持2160W任务,在异步场景下瓶颈通常也不在这里,而是在于执行(也就是调用的底层)

​ 这点可以从“洗衣店”场景进行分析:所谓异步场景,客户将脏衣服放到洗衣店,交给老板就走了,过一段时间再来取。洗衣店就那么两三台洗衣机(worker),每台洗衣机每次洗衣服需要1小时,且一次只能洗 10kg 衣物(相当于一个worker处理音视频转码的过程很慢)。洗衣店每分钟接收 20 公斤的衣物需求(高 qps),意味着每小时需要处理更多的衣物。所以每分钟再多的衣服送到洗衣店,只能堆积在那里。这是的瓶颈就在于洗衣机洗衣服的速度了。所以在异步场景下,单个节点处理的慢(洗衣机洗衣服的速度比较慢),业务层再高的 qps 也没用(待洗衣服会持续堆积在队列)

image-20240802152712634

如果达到性能瓶颈如何应对?=》对于热点请求都是任务调度相关(例如拉取任务、更新任务状态),这些热点操作可以通过其他方案进行优化(例如数据库层的性能优化,走索引等);此外,还可考虑支持根据用户Hash进行分片(如果单个MySQL性能确实不够支撑,则考虑引入自动分片机制),例如比较简单的是接入tdsql这种完全兼容mysql的方式,或者提供mongodb这种天然支持分片的文档数据库用作任务管理(根据场景选择)

任务管理概念

​ 任务管理指的是什么呢?任务管理相关的功能取决于业务设定,例如通过任务ID 去查询任务、统计当前执行中的任务量、查询目前待执行的任务列表等。类似失败重试这些,可以将其归于调度管理。这些都可以通过前后端交互实现。

​ 此外,如果框架要支持父子任务的话(考虑的场景会更加复杂),需要考虑父任务和子任务之间的依赖,它的难度可能就会不止上一个量级,所以看起来任务的复用性可能并不太够,但是还是要结合业务场景考虑成本和效益

数据库连接池

​ 连接池优化——调优核心。此外还有一些调优过程,可以结合实践进行说明

数据库连接池解决什么问题?

​ 连接池的主要作用是管理数据库连接,避免频繁地创建和关闭连接,从而提高数据库的性能和稳定性

​ 创建数据库连接是一个很耗时的操作,MySQL短连接每次请求操作数据库都需要与MySQL服务器建立TCP连接(TCP连接需要3次网络通信),增加了一定的延时和额外的IO消耗,在并发量非常大的情况就会有影响

数据库连接池带来什么问题?如何解决这些问题?

  • 空闲连接带来的性能开销:MySQL需要一些资源去维持连接

    • 如何解决MySQL中存在空闲连接的问题:超时断开(设置”空闲时间“,超过这个时间阈值则自动断开)
  • 依赖于连接池参数的设置:如果设置不合理则无法发挥连接池的作用。例如最大连接数设置过小,会导致SQL无连接可用,等待一段时间后就会抛出异常,不当的配置会影响数据库的性能和响应时间;

数据库连接池工作流程

连接池如何保证每次获取的连接都是有效连接?:连接池在每次申请连接的时候会进行校验,检查连接是否为可用状态(mysql客户端提供查询连接状态的接口,根据这个函数返回的状态判断链接状态是否可用),如果不是则从池中再获取一个空闲连接。如果发现没有可用的,则阻塞等待或者异常报错。

1.概念核心

MySQL连接池是什么?

​ 连接池(存储连接的池子),本质是一种资源复用技术。MySQL连接池:使用方和MySQL创建了连接,单次操作之后并没有放回池子,而是临时保存起来,下次如果有请求过来直接再复用这个连接,节约连的成本,提升流程处理的效率。

​ 注意,连接池不是在MySQL服务那一侧的,是在MySQL的客户端(即调用MySQL的那侧),比如有一个服务A,A访问MySQL,A就是MySQL的客户端,那么连接池就在A这一侧

连接池的价值

短连接:和mysql交互时,每次需要建立一个连接,用完就会关掉

​ 连接池是实现连接复用的手段,如果在高并发场景,反复建立连接的成本是很高的,所以可以使用长连接,即连接用完后先不关闭,放到一个池子里等待复用,连接资源复用就是连接池的价值。

image-20240805094112672

连接池的常见参数

​ 连接池通过一系列参数控制了针对mysql的连接复用策略,一般是由客户端引擎实现,支持哪些参数也由客户端引擎决定,通常而言所有的客户端引擎都支持如下几个主要参数:

最大空闲连接数/连接池个数: 表示连接池中最多有多个空闲连接,某个连接做完事务之后暂时空闲,如果连接池中空闲连接数没有达到上限,可放入连接池。该参数其实可以理解为一共可维护多少个长连接来节约连接建立的成本

空闲时间: 连接池中连接使用完毕后会等到新的请求到来,表明了连接池中的连接在空闲时能在池子里摸鱼多久。如果长时间没有请求到来,说明请求量非常小,此时就需要释放掉连接来节省资源,等待多久,就是由该参数决定(通常情况下1s-10s就足够)

最小连接数: 连接池中最小空闲连接数,当连接数少于此值时,连接池会创建新的连接来进行补充,作用主要是保持连接池始终处于就绪状态。适用于需要的长连接特别多、且请求是周期性的(例如定时跑脚本触发的请求)场景

初始化连接数:初始化连接数目,一般来说实际意义不大,由最小连接数补齐即可

​ 基于上述分析,最大空闲连接数以及空闲时间这两个参数最为核心,最大空闲连接数决定了长连接的个数,空闲时间则决定了长连接的持续时间

不同客户端实现的连接池参数分析

  • Druid(JAVA)

​ 关注核心参数:最大连接空闲连接数、空闲时间,核心参数在任何数据库连接池框架都支持,一些其他参数的设定则是框架特有的,例如此处的maxWait是druid特定的设计,其他库不一定有

属性说明
maxActive连接池中的最大连接数(即最大空闲连接数)
minEvictableIdleTimeMillis连接在池中的最小生存时间(达到这个时间连接可能会被释放掉,本质是空闲时间)
maxWait某个请求到达时,如果连接池满了,就会陷入等待,这个参数指定最大等待多长时间(单位ms)
timeBetweenEvictionRunsMillis间隔多久进行一次检测(检测需要关闭的空闲连接,单位ms)
initialSize程序启动时,初始化连接池中的连接数量
minIdle连接池中最小连接的个数

  • GORM连接池(Golang):GORM连接池用于Golang技术栈场景,它和Druid有些许不同,两者框架定位不同,GORM更倾向一个连接数据库的框架,而Druid更像是一个专业的连接池框架

2.连接池应用场景

​ 访问MySQL可以理解为最基础的一个MySQL连接池的应用场景。例如访问一个电商数据库生成秒杀订单、执行任务插入等一系列场景,因此连接池优化的这个切入点理论上是可以融入几乎所有后端业务场景的。

连接池实战(任务插入场景)

  • 参考实战笔记:MySQL连接池性能优化实践(todo)

任务治理

1.概念核心

概念核心

任务治理任务治理就是处理框架里一些特殊流程,以确保其满足业务需求和合规要求。其实就是针对框架的一些特殊情况单独处理,而不是放在正常流程里面去做

​ 一般将任务治理作为独立的进程运行,它和flowsvr是同级关系

任务治理的作用

  • 分表场景:t_schedule_pos 表:调度走begin_pos、创建走end_pos

    • 创建新表(滚动分表):定时扫描所有任务类型对应的end_pos参数(对应为表号),查看是否需要分表(表记录数量是否超出阈值,如果超出则创建新表,并设置end_pos++)
    • 消费表下标移动:定时扫描所有任务类型对应的begin_pos参数(对应为表号),查询这个表中所有任务是否全部完成(是否已冷却:即所有任务为终态,只包含成功、失败状态),如果是则设置对应的begin_pos++
    • 分表超时检查:避免分表过程中因为一些异常的原因导致旧表任务一直失败重试阻塞了新表中高优任务的执行
      • 根据设定的分表时间阈值,预期分表是一个比较短暂的过度过程,如果发现分表时间超出阈值则切换调度策略(原调度策略是从老表中消费任务,切换为按照一定比例都调度老表、新表进行任务消费)
  • 任务超时检查:避免阻塞其他高优任务的执行,t_schedule_cfg 表中定义了max_processing_time

    • 定期扫描所有任务类型对应的begin_pos表,然后将处于长时间执行的任务(超出最大执行时间) 拉回正轨(任务状态置为pending,待调度/待执行)

任务治理的定时配置

​ 基于任务治理模块的定时任务配置结合实际业务进行设定,一般设定不同的线程,触发的间隔分开配置,一般是分钟级别(例如5分钟)

任务治理异常情况处理分析

​ 任务治理模块在进行任务超时检查时检查到某”执行中"任务超时后将其状态改为”待执行",之后该任务重新被拉取和执行,但与此同时上一次调度它的 worker 可能还在执行(可能是网络原因),就会出现同一个任务被两个 worker或两个线程)同时执行的情况。因此框架无法保证任务的精准一次执行,在异常兜底等情况下会存在重复的可能

2.问题思考

任务超时检查由谁去判断超时?server还是worker?

​ 任务超时检查可以理解为独立于worker的一个单独的线程,此处将放在任务治理模块(独立于flowsvr、worker)。作为独立线程运行一开始考虑是放在flowsvr模块,但后续考虑到框架中其他特殊流程的处理,所以将这块内容独立出来抽离成任务治理模块。

​ 虽然有一种方案可以让worker去判断超时,但worker可能会挂掉(例如网络出问题),无法及时响应,即不能给server发包,因此这种方案相对不可靠。因此选择通过server(即任务治理模块)来定时检查任务状态,体现出来的效果就是worker执行任务成功则发包更新任务状态,如果执行失败则由server定时检查任务状态进行确认

评论
  • 按正序
  • 按倒序
  • 按热度
Powered by Waline v3.1.3