跳至主要內容

xtimer-04-核心梳理

holic-x...大约 80 分钟xtimerxtimer

xtimer-04-核心梳理

学习核心

  • 热门题型理解

学习资料

项目难点亮点方法论

​ 如果说项目没有难点,或者不知道怎么抽象,一般而言可以从下面几个点出发,来梳理或者构造:

(1)架构设计:比如微服务拆分,或者拆分过度的合并,这个点其实比较凑数,要展示思考和一些实际困难,比如拆分过度导致的数据同步困难,怎么做服务合并,合并的话迁移怎么让用户无感知。

  • 解耦,比如加个中间层
  • 服务拆分后数据怎么聚合
  • 如果需要同步数据为何要拆分

(2)精准性设计:比如定时微服务怎么保证触发的精准度

  • 容易造成精准度下降,增加误差的原因有哪些
  • 常见的定时框架为什么也无法完全解决精准度问题?
  • 现有方案有哪些亮点设计如何解决或提升精准度的?

(3)性能优化:哪些场景原来性能不够,怎么一步一步或者从几个方面优化到多少,用了多少机器成本,常见的思路有很多比如下面几点:

  • 同步转异步,提升同步响应时间
  • 聚合写,多次操作变一次,节约网络IO成本
  • 缓存读,高速存储缓存重要信息,Redis 10W每秒,MySQL5000/s,动则20倍提升
  • GC调优,如果发现GC频繁,可以调节GC参数
  • 日志库升级,可以对比不同日志组件性能,把低效日志组件换成高效日志组件
  • 预加载,重点缓存预先加载

(4)通用性设计:接口通用性、扩展性设计,功能抽象成框架等

​ 基于上述方法论,梳理项目难点、亮点

xtimer 难点亮点

1.项目难点

难点1:高负载

​ 参考【项目构建】-【定时微服务难点分析】之高负载

难点2:高精准

​ 参考【项目构建】-【定时微服务难点分析】之高精准

难点3:异常任务处理机制

​ 参考【项目构建】-【定时微服务难点分析】之其他关键问题

2.项目亮点

亮点1:数据存储设计

​ 定时任务服务中,会存在频繁的对数据进行增删改查操作,所以数据的处理性能,包括检索性能、写入性能等都会影响到最终的高精准,高负载问题的结果。好的数据存储方案设计,可以在数据的检索,维护,管理等方面都起到至关重要的作用。

​ 本文中通过基于mysql +redis的多级存储结构设计,以及分治策略的设计将数据进行合理的缓存、分片等处理,提高了数据的查询速度,也缓解了极端情况下高负载的问题。

策略总结:

  • (1)基于mysql+redis的多级存储设计+冷热分区:有效划分冷热数据,通过迁移模块migrator的设计,让动态变化的热点数据持续维护在查询效率最高的缓存里,也避免了过期的或未来的冷数据占用内存资源
  • (2)分治策略:横向+纵向数据分片设计,将大批量任务拆分到不同的分片之中,有效提升了数据处理的并发度
  • (3)有序性设计:使用Redis的ZSET的有序集合维护热点任务信息,有效降低数据检索复杂度,从O(n)降低到O(logn)

亮点2:性能优化

缓存+有序存储:使用redis将近期需要触发的任务数据缓存在Redis的ZSET中,既利用了内存的快速查询能力,也运用到了ZSET数据结构天然的有序性特性,比原始的纯基于mysql的遍历方案来说,让数据查询性能得到了提升

线程池:利用线程/协程池技术,充分提高了任务处理的并发度,对任务处理的高精准,高负载问题都有很大的提升

连接池:例如创建Timer接口,主要的执行逻辑就是写入数据库。而当有大量写入请求时,创建数据库连接会是一个非常耗时耗资源的操作,其不仅会反过来影响接口的qps,也会增加接口的耗时。通过引入druid连接池,将接口的性能提升了一倍多,并且耗时也减少了20%-30%

xtimer 问题总结分析

1.项目立意

✨为什么要做定时微服务?

​ 结合实际业务场景分析(定时场景,基于这些定时的需求设计定时微服务)

  • 公司业务场景(实际业务场景)

    • 对于知识库平台的文章推送,需要定时将最新的文章信息推送到统一存储平台(文章基础信息同步、文章关联附件信息同步)
    • 消息通知机制:fhyd 定时短信通知、待办事项通知等(事项提醒、催办推送等场景)
  • 参考业务场景

    • 某“学习平台”团队:每天早上10点需要给学员发送学习通知
    • 某“招聘平台”团队:需要在2023年9月10日-9月17期间,给所有待参加笔试的同学,每天下午14点、20点发送笔试通知
    • 某“电商”平台:订单下单15分钟后,用户如果没有付钱,系统需要自动取消订单
    • 微信红包业务:红包24小时未被查收,需要执行退还业务;
    • 某个活动指定在某个时间点生效&失效;

✨介绍一下这个定时微服务xtimer

解析思路1:

项目立意:xtimer 定时微服务是基于一个“闹钟”的设计理念,相当于每个业务共用一个闹钟,当时间到了则会唤醒相应的业务(任务),至于业务要执行什么逻辑和操作则由业务方进行控制(时间到了,闹钟会通过callback接口进行唤醒)

项目架构:xtimer 使用了mysql + redis 两种最常用的存储技术,在存储设计上运用了分治策略对数据分片和冷热数据分区,在落地实现上运用了模块化、异步化等思想,具备功能聚焦(只做定时)、高精准、高负载等特性

解析思路2:

项目立意:xtimer 是基于定时服务场景抽离出来的一个定时微服务框架,它的作用相当于一个“闹钟”,通过高频扫描、定时通知业务方触发相应的操作。例如业务方通过调用“创建定时器”接口创建一个“闹钟”,随后xtimer会根据这个闹钟配置自动生成定时任务、并定时触发、执行,所谓的执行即“时间到了,通过回调业务方通知业务是时候该执行相应的操作了”

个人职责:在xtimer项目中,主要服务xtimer微服务模块设计、存储设计、项目开发落地实现、性能调优、压测等工作

项目难点/亮点:项目采用MySQL+Redis二级存储接口设计,引入“分治策略”+“数据冷热分区”概念设计,采用模块化+异步化的实现思路,进一步提升系统检索性能,优化高精准、高负载等核心问题

项目代码量、工作量

​ 项目代码量在2000-4000行左右,方案设计和技术调研耗时比较长,大概花了一个多月,真正写代码的时间就20多天左右

🚀技术方案的对标(为什么不用xxxx?)

📌有调研一些主流的定时框架吗?(进一步说明为什么要写一个定时微服务框架)

调研过:主要调研过业界广泛使用的xxI-job,Quartz等任务框架

定位不同:对比定时微服务框架xtimer和主流的定时框架

(1)xxl-job,Quartz等框架属于完备的定时“任务”框架,框架与业务的耦合还是比较重的。业务方在使用时就需要将业务执行逻辑包装成一个“任务”(例如:定时吃饭),然后使用框架去调度执行这个任务来完成。基于这种方式,框架需要感知业务,例如调度并运行不同业务方的差异化运行逻辑,关注业务逻辑执行结果等(例如:A任务是定时吃饭,B任务是定时睡觉,框架都需要感知和负责),其核心是“定时”+“执行”,框架与业务的耦合较重

(2)定时微服务的定位并不是一个“任务”执行平台,而是一个功能聚焦的定时器平台(更倾向是“闹钟”,只提供“叫醒”服务),只处理定时,不负责业务逻辑的执行。基于这种方式,将“定时”和“执行”进行解耦(将定时这个通用逻辑与业务个性化的执行逻辑完全解耦)。(提供一个闹钟,负责叫醒服务,执行叫醒后业务方去干嘛,xtimer并不关心,也不用负责)

职责清晰,解耦,功能聚焦的好处

a.接入轻量,对业务代码的侵入性和限制更少:

  • xxl-job等框架耦合重:对业务系统是有很多要求和限制的,例如业务方需要理解框架的开发方式,即如何在代码里将一个定时执行逻辑包装成一个任务,理解相关注解,理解平台的任务管理流程等,会有很大的接入成本和要求;

  • 定时服务xtimer与业务完全解耦: 将“定时”和“执行”解耦、通过http交互打破跨语言的限制

    • 业务方的执行逻辑与定时逻辑是完全解耦,业务只需要通过http简单通信即可创建定时器,同时接受消息也是http callback;
    • 打破了跨语言的限制,即便业务方是个python系统,go系统等都可以通过http 直接使用这个基于Java开发的定时服务, 这就是完全解耦。

b.更易维护和优化,更好的利用资源:

​ 基于上述职责的拆分后,xtimer定时服务可以更好的针对定时这块聚焦的功能做设计和优化,不用去兼顾业务方的执行。 可以聚焦在定时的精准性,定时器本身的高负载问题上。考虑到每个项目组可调配的机器资源等也是有限的,如果减少一些冗余功能的维护(xxl-job等复杂功能),可以更好的利用有限的机器资源等来提供更高质量的定时服务。(举例:如果组内有5台机器,如果全部用来处理定时功能可以支持每分钟5万的吞吐量,误差1s。但按照以往的定时器开发模式,引入Quarz相关任务框架将定时和执行耦合在一起,机器还需要负责调度执行业务的逻辑的话,可能只能支持单位时间5000个任务的吞吐,误差3s);

📌为什么不直接使用消息队列的延时消息呢?

​ 场景立意不同:xtimer定时微服务是在模拟一个闹钟的功能,消息队列更偏消息单次流转

​ 对于第三方组件的引入需要考虑相应的维护成本和其可带来的效益提升,为什么不使用消息队列:

  • 增加依赖:需要额外维护一个支持延时消息消息队列系统,对于后期维护也是成本
  • 不支持长时间的定时:消息队列延迟消息不支持长时间的定时消息。例如2个月,半年这种
  • 不支持周期定时等:消息队列也不支持周期定时这种消息,只能单次触发
  • 不支持闹钟的开启与暂停等常见功能

🚀项目设计相关

📌说一下“微服务整体设计”是怎么设计的?

​ 对于xtimer的微服务整体设计,基于现有的微服务框架模型,抽离一个定时微服务模块。

技术选型:基于SpringCloud体系构建微服务,采用Open Fegin、Nacos 等技术提供微服务能力

存储设计:采用MySQL+Redis构建二级存储,设计上采用了Redis ZSET的有序性设计,引入分治策略+数据冷热分区概念进行存储设计

  • MySQL + Redis 的二级存储:用户创建的定时其会存储在MySQL中,程序根据timer自动生成任务会被存储在MySQL、Redis中等待调度执行
  • 数据分片:基于分治策略(横向分时,纵向分桶)进行数据分片,对于同一分钟(T)+ 同一桶号(N)的任务会被划分到同一个分片中,每个分片采用Redis的ZSET进行存储
  • 有序性:考虑到任务根据执行时间的先后是天然有序的,考虑到这种“天然有序的数据”存储,因此选择了Redis的ZSET来确保任务排列的有序性,借助其查询能力的支持获取“到点待执行任务”(底层基于跳表,检索效率从O(n)提升到O(log(n)))

落地实现:采用模块化+异步化设计思路,整体落地基于Springboot+线程池+数据库连接池实现,让代码层次更加分明、模块职责清晰

🚀过度设计挑战

挑战过度设计,可以从服务的定位、投入产出比进行分析

📌业务场景的任务量是多大?任务量比较小的话为什么要做冷热分区、分片?是不是一种过度设计?(贝壳)

​ 目前的任务量并不算大(上线参考:定时任务量几百-几千/天),但任务会存在聚集在少部分时间点的情况

(1)通用服务的定位:项目设定是作为平台通用服务进行设计的,并不是与某一个具体业务相绑定。所以设计不能只局限于单个现有业务的量级,还需要考虑后续有更多定时场景的接入时,框架应该如何支持?至少从架构层面需要预留可扩展性。

(2)分片:分片只是将同一单位时间任务进行了划分,例如目前默认的是每一分钟的任务N再分成了5份,然后每一份都存入单独的Redis ZSET(每一个zset视作一个分片)。基于这种分片思路具备如下优势:

  • a.支持高频扫描(提升高频扫描效率):项目采用Redis ZSET进行存储分片,redis本身的性能加上ZSET的有序性存储 都对频繁的范围检索提供了强有力的支持。 如果采用传统的数据库存取方式,只能通过高频扫描数据库,外加任务量比较大又没有做拆分,这会给数据库带来不小的压力
  • b.支持多机部署,水平扩容: 单个分片只能被一台机器获取和执行,可以引入多台机器通过竞争分片的“执行权”实现任务的调度分配,根据实际任务灵活选配资源
    • 针对任务量很小业务,设定少量分片,分配少量机器即可
    • 针对任务量大的业务,可以增加分片数、增加机器数量,既能减轻单个机器压力,同时也能加快任务执行速度,也就是具备了水平扩容的能力

(3)冷热分区:业务场景面向的是定时任务,所以可以根据触发时间进行很方便的冷热划分。规则就是,越靠近当前时间的任务越热,越久远则越冷。所以把最热的任务放入redis缓存,同时延迟久远的任务生成,达到一个冷热分区的效果。即节省了存储资源(没把所有冷任务都生成出来存缓存),同时放缓存之后也间接提供了热任务高频扫描的能力

(4)投入产出比:考虑综合投入产出比,结合公司团队现有技术栈选型(redis、mysql这些都是最基础的能力支持),不管是人力还是组件维护成本都是相对较低的。至于一些更复杂的潜在优化设计,例如分库分表、引入MQ做回调、或者引入webSocket做回调等,只是初步构思个技术方案,暂时没有往这边靠,也是考虑等业务量上来了再迭代优化,避免过度设计

📌项目性能瓶颈在哪里?(是哪里限制了性能?redis还是mysql?还是具体其他原因?否则为什么要进行冷热分区、redis的分片优化呢?)(字节)

先阐述项目的难点(高精准、高负载),然后针对这些难点提出解决方案 =》mysql 是高频扫描的性能瓶颈,引入redis的冷热分区和分片优化是为了突破数据库瓶颈

MySQL是项目瓶颈:因为任务触发需要保证很高精准性,所以需要对任务进行高频扫描捞取到达目标时间点的任务。第一版方案设计是将任务直接按照创建顺序存入数据库,直接对数据库进行高频扫描。但基于这种方案考虑当任务量比较大时又没有做拆分时,会给数据库带来不小的压力。

​ 因此考虑将扫描数据库换成并发性能更高的Redis,Redis分片优化和冷热分区是为了支持高负载,基于redis本身的高性能和zset的有序性存储,将“近期少量的热任务”生成出来存入redis(冷热分区),并对这批任务进行了分片(分片),最后用一个顺序存储结构ZSET来存储。这样的存储设计方式可以毫无压力的支持高频扫描,也就突破了数据库的瓶颈。

📌只是压测mysql的写入性能,但是这个并不是项目瓶颈所在,得到的写入性能qps好像参考意义也不大?(字节)

挑战过度优化,可以从服务定位、投入产出比进行说明

(1)通用服务的定位:xtimer是作为平台通用服务进行设计的,并不是与某一个具体业务相绑定。 所以设计不能只局限于单个现有业务的量级,还需要考虑后续有更多定时场景的接入,应该如何支持。虽然目前场景还不需要2000/s,但通过压测至少让进一步对项目性能做到心中有数

(2)优化的投入产出比:对于数据库连接池的优化压测这个组件和人力成本是比较低的,基于引入druid数据库连接池优化之后,系统的数据库交互性能得到有效提升(不仅仅局限于MySQL的写入性能),考虑到投入和产出比,这个操作还是值当的。至于对于一些更复杂的潜在优化设计例如增加历史任务的查询缓存、分库分表、回调方案优化(例如引入MQ或者WS做回调)等优化方向目前还没有实施,也是考虑等业务量上来了再迭代优化,避免过度设计。

(3)其他接口的压测:数据库连接池的性能提升是对数据库交互整体的提升,并不局限于某个写入操作。项目主要压测了创建任务接口(Mysql的写入性能),这对于其他接口的性能提升也是一个参考。为了进一步对项目性能有个清晰的认知,对“任务激活”接口、“任务查询”等接口也相应做了压测(激活接口800->3000;查询接口1500->4000)

2.创建、存储、调度 (业务流程分析,关键字解析)

🚀业务流程(如何接入?业务逻辑相关)

📌xtimer 服务如何接入使用?

​ xtimer 作为一个通用的定时微服务模块,就很容易被关注,要考虑这个服务如何被业务开发团队快速接入?对于业务方需要完成什么步骤:

  • (1)申请appld:给新业务分配一个appld,作为业务的唯一标识;
  • (2)定义好业务回调接口:业务方提前定义好http回调接口,用于在接收到“到点回调通知”之后,处理相应的定时任务逻辑;
  • (3)调用接口创建定时器:业务调用xtimer提供的“创建定时器接口”(请求参数:appId、任务运行时间配置,任务回调接口参数)
  • (4)激活定时器: 当定时器创建好后,并不会开始运行,需要业务方手动开启(调用激活定时器接口)
  • (5)业务方等待接收回调并处理:待“闹钟成功创建并激活”,业务方只需等待“时间到了”,随后接收回调并处理

问题:如何理解appId的作用?

​ 针对“appld分配”可以理解为一个通用的动作,业务方通过这个appld可以接入体系内的通用模块(业务唯一标识,可针对具体业务做统一校验、限流等特殊限制或者处理)

​ 例如在微服务场景中,业务访问通过提供appId,通过网关层进行统一的校验、限流、服务染色等特殊操作,然后转发到对应的业务模块,appId可以理解为业务模块交互的一个敲门砖,也用于跟踪业务访问的链路流程。

🚀定时任务是如何存储的?(存储设计相关)

存储设计关键字

  • 二级存储(MySQL+Redis)
  • 有序性(Redis的ZSET)
  • 分片存储 =》分治策略(横向分时、纵向分桶):“同一分钟(T)+ 同一桶号” 决定一个分片,一个分片对应一把分布式锁
  • 数据冷热分区
📌定时任务的存储设计
  • 存储选型:采用MySQL + Redis 的二级存储结构
  • 存储设计:借助Redis ZSET的有序性进行有序性存储设计(数据有序性),采用“分时+分桶”概念对任务进行分片(数据分片),并基于任务冷热概念对数据进行了冷热分区(数据冷热分区,一些近期的过热数据会被加载到缓存中)

​ 当一个定时器创建好之后,会首先放入mysql 的 timer 表之中。后续根据定时器timer生成出来的定时任务会首先被放入mysql的time task表中,同时也会将这些等待触发的任务放入redis中。

  • 数据分片存储:基于Redis存储设计运用了数据分片策略(分时+分桶),将大量定时任务根据横向(触发时间)和纵向(分桶数)维度进行了二维拆分,将任务落到了一个个分片之中
  • 数据有序性存储:为了提升单个分片的检索速度,采用 redis zset 作为分片的存储结构(由于zset自带的有序性,让数据检索速度不再是全表扫描的O(n),而是跳表查找的O(logn)复杂度)
  • 数据冷热分区存储:定时任务存在天然的冷热区分,即近期的任务才是系统需要关注的(属于热数据),而之前或未来久远的任务都属于冷数据。合理的为冷热数据分配存储资源可有效提升系统性能。
    • 对于冷热数据的处理:系统通过migrator迁移模块的实现,延迟了未来久远冷定时任务的生成,并定期将新生成的热点任务数据放入了redis缓存之中
📌冷热数据处理:对于冷数据的处理是如何的?

​ 冷数据由两个部分组成,一方面是未来久远的定时任务、一方面已经生成了很久的古老任务:

  • 对于“未来久远的定时任务”:延迟了未来久远的定时任务的生成
  • 对于“已经生成了很久的古老任务”:可以考虑单独归档存储(将其与近期热点任务分开),或者直接清理掉(已经执行过的定时任务于现有业务实际上用处已经不大了)
    • 可以考虑开启“定期清理”,但需注意挑选一些任务较少的时段去执行这些清理脚本(例如半夜、凌晨),因为这种筛选清理的方式本身会对正常任务的查询有影响

📌为什么采用ZSet数据结构?

为什么不直接用Mysql?=》任务触发精度依赖于高频扫描,单纯的MySQL数据库支撑高负载要求,因此引入Redis来处理这种高并发的频繁的范围检索(Redis更合适做高频扫描)

为什么不用Redis List?=》因为定时任务根据触发时间,天然就是有序的,需要由近及远的触发这些任务,所以ZSET的有序性相对于其他List等无序结构更合适

为什么不用“时间轮”?=》时间轮等结构与本地优先队列类似,其一般会用在一个单独进程应用的内部定时实现在分布式环境下,少有能直接使用的时间轮存储方案

📌如果只用“MySQL”进行存储不行吗?

​ 如果从功能实现上只用MySQL进行存储是可行的,例如生成timer,随后根据cron配置逻辑提前生成相应的待执行任务,将其全部存入MySQL的task表,随后通过“定时扫描”的方式获取到MySQL中“到点的待执行任务”。可以通过创建索引等方式来提升检索效率

​ 但考虑到需要支持任务“高频扫描”的场景,对于频繁的范围读取操作来说如果单纯引入MySQL可能会存在一定的性能瓶颈,考虑到Redis基于内存读写(天然的高性能支持)以及ZSET的有序性(底层基于跳表实现,有效提升范围检索效率),因此还是选用MySQL+Redis的二级存储方式进行存储设计。考虑到成本和效益,对于团队技术栈而言,MySQL、Redis是相对比较基础的技术支撑,接入的成本很低,但性能提升确实非常明显的

补充说明:为什么不仅仅只用MySQL实现?考虑到数据的冷热特性、高频扫描

没有利用到数据的冷热特性:单纯使用MySQL虽然可以实现功能,但是随着表里任务量的累加,这种查询会越来越慢,这种方式也很难利用到任务天然的冷热特性。其实场景中每一次都只需要关注近期时间段的任务(例如5分钟内),对于其他时间的任务是暂时不需要不关心的,但是如果将任务都冗余在一张表里,势必会互相拖累查询效率。 所以针对任务做时间维度的有效的拆分是很有必要的

不支持高频扫描:为了保证定时的精准性,需要对任务进行高频的扫描判断,找出“到点执行的任务”,高频扫描会给MySQL数据库带来很大访问压力,无法解决高负载问题

📌除了"Redis"还有进一步的存储优化方案吗?(圈量科技实习一面)

​ 由于场景中的触发阶段需要支持“高频扫描”操作,因此会对Redis进行频繁的扫描操作,每一次操作都会涉及到网络IO等操作,这部分的网络耗时会最终累加到任务实际触发的时间误差上,如果想要优化这部分的内容,可以从减少网络IO这方面进行优化考虑。例如引入“本地缓存”方案

本地缓存方案:例如将近1分钟待触发的任务直接全部查询放到本地缓存,然后在本地缓存中进行高频扫描+触发,以减少大量的网络IO

​ 但相应地,本地缓存方案也具备不足之处(需要解决几个关键问题):

  • 多机调度问题的复杂性:redis 是分布式存储系统,原本的方案是借助了此能力来实现多机调度的(不同的机器通过竞争不同的分片执行权,来实现任务调度能力,达到任务多机并行执行、互相兜底、支持动态扩容等效果)。但是如果将数据都缓存在应用服务器的本地缓存,要实现上述效果则要复杂得多
  • 服务宕机问题导致数据丢失:本地缓存的任务是没有持久化机制的,如果任务还未执行完成服务宕机,需要考虑任务如何恢复执行。而redis提供了完备的高可用+持久化机制作为容灾处理
  • 内存有限,存储空间无法扩展:本地缓存方案局限于单机存储,由于多机数据不共享,导致无法方便扩容;而redis 支持集群,在内存不足时可以方便进行扩容支持
📌介绍一下“数据分片”这种存储方式的作用?

​ “数据分片”这种存储设计的作用主要体现在:支持高精准支持高负载支持多机部署+动态扩容

  • 支持高精准:数据分片之后,单个分片内的任务会减少,查询压力会更小,为高频扫描提供基础(高频扫描是保证高精准的一个重要前提,核心是减少触发时延)减少触发时延,支持高精准
  • 支持高负载:数据分片之后,单个分片内的任务会减少,负责分片执行的机器可以更快、压力更小的完成任务的执行 将任务拆分为单个分片,机器可以更快、压力更小地执行任务
  • 支持多机部署,动态扩容: 借助Redis能力做数据分片之后,我们通过竞争分片的执行权来达到多机调度的效3.果。也就是抢到分片的机器就拥有了分片执行权,这样如果我们分片有10个,那最多就可以支持10台机器并行执行。 后续也可以通过增加分片数来为动态扩容增加机器提供支持。
📌介绍一下“数据有序性”是怎么做的?为什么需要保证有序性?

数据有序性如何实现?:数据有序性是使用Redis ZSET 来做的,ZSET底层是跳表结构,可以保证数据的有序性。排序的字段使用的是任务的待执行时间的时间戳。

为什么要保证有序性?:因为定时任务根据执行时间的差异,其本身就是天然有序的,对于场景而言每次都只需要关注最近的任务,久远的任务可以暂时不用关心,所以将数据有序存储可以有利于频繁获取近期待执行的任务,提升检索效率

📌介绍一下“数据冷热分区”是怎么做的?

说明数据冷、热的划分概念,然后分析冷热分区的实现

​ 对于定时任务而言,过热数据指的是“近期到点要执行的任务数据”,过冷数据指的是“已经执行完的定时任务数据”或者是“非近期到点要执行的任务数据”

  • 对于过热数据,会将“最近1分钟到点要执行的任务数据”加载到Redis缓存中,支撑触发阶段的高频扫描,提高热点数据的存取效率
  • 对于过冷数据,则是将数据存储在MySQL中,除此之外针对“已经执行完的定时任务数据”做相应的归档或清理处理(回收存储资源)

✨定时任务是如何创建的?

​ 对于定时任务的创建,首先需要理清“定时器timer”和“定时任务timer_task”的关系。“定时器timer”是一个闹钟,每一条timer记录存储着定时任务的相关配置信息,通过存储的cron规则自动生成相应的“定时任务timer_task”

  • 用户通过“创建定时器接口”创建的是“定时器timer”(理解为设定一个闹钟)
  • “定时任务timer_task”是通过“定时器timer”中的cron等相关配置自动触发生成的一条条记录,其触发的逻辑有两点:
    • 手动激活:用户在手动“激活”一个定时器timer的时候,会触发一次定时任务task的批量生成,生成的是后一个step时间范围内的tasks(例如2小时内),并放入mysql和redis
    • 定时生成(依赖于migrator迁移模块):后续定时任务的生成,依赖migrator迁移模块,每隔一个间隔时间(例如1小时)生成一次,提前生成好下一个step时间范围内的任务

✨定时任务是如何调度和触发的?(todo 确认每个模块的职责概念)

​ 参考【架构设计】-【调度流程模块化设计详情】

image-20240819104649121

​ 创建并激活定时器,定时任务timer_task定时生成会被放入mysql和redis,后续的调度和触发流程将会涉及3个模块和2个线程池,具体流程分析如下

(1)调度模块scheduler module:首先是由调度模块对所有需要跟任务进行统筹分配,分配的单位就是一个二维分片,将分好的分片放入线程池之中,由一个线程使用trigger 模块来负责一个分片的跟进

(2)触发模块trigger module:触发模块与分片是1对1的,它需要负责跟进一个分片中的所有任务,确保执行完成。trigger模块通过每隔1秒查询一次的方式,频繁的从redis zset中获取“时间到点的任务”,并将任务放入线程池之中然后交由执行模块executor执行

(3)执行模块executor module:executor 模块才是真正执行任务的模块,线程池里的线程获取到任务之后,会调用executor模块进行任务处理(核心处理逻辑:调用callback接口,通知业务方),最后将执行的结果信息写入到 timer_task 对应的MySQL记录之中

✨如果调用回调的接口延时很大(例如接口是个国外的API),肯定会导致消息推送存在时间误差,且过多的http连接也会导致资源耗尽的问题,基于这些问题点如何考虑?(B站)

思路:梳理回调逻辑,明确目前的回调方式存在的问题,然后提出相应的解决方案

​ 【1】现有回调方式:通过创建了一个执行线程池,然后线程池去负责每一个待执行任务的回调工作。整个回调方式是通过http进行回调的,也就是每一个待执行的任务都会为其创建一个http连接进行回调。

​ 【2】明确这种回调方式存在的问题:资源限制问题(http连接导致资源耗尽)、延时问题(接口调用受网络等因素影响导致时延)

  • (1)资源限制问题

    • 问题描述:
      • http是基于TCP实现的,每一次调用都会涉及TCP连接的建立,而每一个连接也都会消耗机器资源(内存、端口)等等,所以系统能够同时创建的连接数是有限制的,这点受限于机器资源(解决方案:限制线程池线程数量;扩容机器)
    • 解决方案:
      • 限制线程池线程数量:为了保护机器不会因为过多的http请求而超载,可以通过限制线程池数量的方式,限定同一时间内可发起的http请求数量
      • 扩容机器(受限于分片策略):将任务分散到不同的机器上进行处理,减少单台机器的压力。定时微服务的架构设计是支持多机部署的,通过引入多机竞争分担单台机器的压力。但具体部署多少台机器也会受到相应限制,如果说同一时间内只有一个分片(例如分时1分钟、分桶只有1个),那么就算引入多台机器,也只能是其中某一台机器抢占到任务的触发权,其他机器只能空闲等待。因此考虑基于“动态扩容机器”和“动态分桶”来解决资源限制问题
  • (2)延时问题:导致延时的问题可以从两方面考虑

    • 排队执行导致:如果能够同时建立的连接数是有限制的(例如1w个),如果9:00:00这一秒有2w个任务待回调,那后面的1w个任务必定需要排队等待前1万个回调完才能继续执行,那前一批任务的回调时间就会是后一批任务的延时(解决方案:增加机器分摊任务执行;采用消息队列回调)

      • 增加机器分摊任务执行:即上面说到的“扩容机器”方案,多增加几台机器

      • 采用消息队列回调:将待回调的任务转信息转化成一条消息队列的消息进行投递,业务方通过消费消息队列的方式来获取到点通知。(优点:可以明显减轻服务器的回调压力因为不用再为每一个任务建立http链接了,对资源和耗时都有好处;缺点:这种方式需要增加对消息队列的依赖,并且依然不能解决网络延时长的问题,因为这里消息的流转过程中,投递消息&消费消息等中间反倒会增加网络传递次数,可能网络本身的延时会叠加)

    • 单次调用耗时导致(接口网络耗时长):http 回调存在的问题就是每次都需要建立连接,而建立连接的耗时也会成为任务的回调延时(解决方案:设定接口超时时间;采用WebSocket进行回调用)

      • 设定接口超时时间:为http回调接口设置超时时间,当某一次调用异常时,不阻塞后面的任务执行
        • 优点:可以减少少量偶发的延时问题
        • 缺点:如果大量的接口延时都很高,这种方式也无法改善问题
      • 采用WebSocket进行回调用:使用WS时,肯定不能再为每个任务创建一个WS连接了,需要改为为每个业务方创建一个WS链接。业务方数量肯定跟任务数不是一个量级的(一般也就个位数),所以建立连接也没啥压力。 后续同一业务方的timer_task就通过具体的一个WS连接进行回调通知
        • 优点:解决服务器维护大量连接的压力(因为WS是长连接,不用每次发送task都经历一次三次握手这些过程,原本网络延时导致的任务延时问题,可以得到明显改善)
        • 缺点:增加了业务方接入和开发以及后续的维护成本

​ 【3】**同一时间有大量任务的问题:**上述的场景分析中是分析同一时间有大量任务时(例如几百几千个),xtimer应该作何优化。但对于“定时微服务难点-高负载”这点分析来看,也可以通过在应用层面减少任务量来规避这个问题

​ 理论上针对同一业务&同一时间有大量任务这个问题,是需要从使用层面去让业务方规避的(业务方需对xtimer的认识和使用有正确的理解)。从使用的最佳实践来看,同一业务同一时间的任务数应该是个位数(N),并且业务方数量一般也不会太多(基本也就个位数),所以同一时间的要处理的任务量按道理也是很有限的(N*M)

✨redis或worker崩溃重启后,如何避免任务重复执行或者遗漏?

worker概念:此处的worker可以看看作是负责分片触发执行的一个线程

worker崩溃场景:当worker获得分片触发权之后就挂掉了,那这个分片的任务触发就会受影响;

解决崩溃的策略:下一分钟会有一次重试,即完整重试调度上一分钟的分片,但这种策略可能会出现“重复”调度的情况

为什么会重复?=》重试上一分钟分片的这个逻辑,可能上一分钟的任务已经触发过一部分了,所以完整重试一个分片就会造成分片中已经触发过的任务被重复触发

​ 现有xtimer的重试策略在上述场景中的确会造成部分任务的重复触发:

  • 目前是通过在执行最后阶段,判断任务状态来进行重复过滤的(如果一个任务状态不是not run,则不会再执行),当一个任务执行过之后状态会发生改变。但这个策略存在“原子性操作问题”,即xtimer作为http回调信息的发送方,无法保证“http调用成功”和“更改数据库任务状态”这两个操作的原子性(即该策略对业务方而言做不到“精准一次”,虽然目前实现没有引用消息队列,但这个逻辑和使用消息队列时的情况是类似的)
    • 如果先更新数据库状态(修改为执行成功),但发送回调时失败(http调用或者发送队列消息),则会导致业务方收不到回调信息(出现遗漏问题)
    • 如果先发送回调消息成功,但更新数据库状态时操作失败,则会导致任务可能会被重复执行,即业务方会受到重复的回调消息(出现重复问题)

​ 因此,如果“遗漏”和“重复”之间要择选的话,目前实现先回调后更新状态的设定可以至少保证1次回调(不遗漏)。如果业务方幂等性很高,则幂等性需由业务方进行保证(例如在回调消息中加入task的唯一标识,业务方在接受http回调的时候做相应的幂等处理),进而保证不重复(不重复)。

✨Worker 如何找到自己要轮询的分片?如果当前 Worker 的吞吐量不足,需要新增 Worker,如何为新增的 Worker 安排分片?

worker概念:此处的worker可以看看作是负责分片触发执行的一个线程

  • 触发线程如何获取自己的分片? =》1个分片对应1个分布式锁,哪个线程抢到锁则获取到分片

    • 每一个分片都会有一个redis实现的分布式锁(分片:分布式锁 = 1:1
    • 所有触发线程都是通过抢占分片的对应的分布式锁来获得“执行权”
    • 系统支持多机部署,触发线程可以是来自于任意一台机器的线程,谁抢到锁分片就是谁的
  • 新增worker如何安排分片?(指新增一台机器)=》新增worker即新增机器(新增线程),平等策略:哪个线程抢到锁就代表其对应的机器抢到了分片

    • 基于上述逻辑,新增的一台机器之后,相应也会新增一些worker线程。目前的分配策略就是所有worker都是平等的,直接参与“分片的分布式锁”的竞争即可,哪个线程抢到,也就代表对应的机器抢到了。

✨每次轮询 redis 都需要解析请求、查询 ZSet、打包发送响应,有没有办法在不牺牲定时精度的情况下减少 redis 的开销?

明确问题:问题确实存在,根据现有的触发逻辑,触发线程1分钟内会查询60次ZSET获取到点任务,确实每一次都会有网络开销,如果能减少单次查询的耗时的话,还是会有一些提升的

潜在优化方案

  • 要想没有网络开销,那就只能采用**“本地缓存”**的方案
    • **“本地缓存”**的方案:当一个线程获取到分片分布式锁之后,直接按照触发时间顺序,获取一个分片的所有任务,然后将其缓存在本地内存中(例如java的ArrayList)。因为返回值本身“有序”,后续不需要完整扫描List,按照每秒的频率从List中取出待执行的任务即可

✨分布式锁什么时候加锁和解锁?

  • 分布式锁的引入
    • 任务的分片和分布式锁是1对1进行绑定,线程通过竞争分布式锁的方式获取分片的“触发权”
  • 分布式锁的加解锁
    • 加锁:线程通过竞争分布式锁的方式获取分片的“触发权”,加锁成功则代表获得分片的“触发权”
    • 解锁:xtimer系统中没有主动解锁的操作,解锁是通过Redis Key的过期机制来完成(时间到了则自动解锁),目前过期时间的设定有两种考虑:
      • 设定1:略大于分片时间范围的时长(例如分片是T,那过期时间就是>T);
      • 设定2:当某个分片内的任务全部触发成功后,还会延迟分布式锁的时间为>2T。(这与增加的“失败重试调度上一分钟的分片”这个策略有关,避免重复调度已成功的分片)

✨创建任务和激活任务能合并吗?(指的是timer)

​ 从理论上是可行的,此处的拆分更多地结合实际场景考虑,有一些场景是创建了一个“闹钟”之后并不要求立刻启动,因此此处设定了一个开关概念,灵活适配业务场景。(例如一个手机可能会提前设置很多闹钟,但并不会立刻启动闹钟,引入开关设定更加灵活)

✨任务id是什么?为什么用主键自增id?不用分布式id?

​ 目前任务id是采用MySQL的自增主键ID,在分布式场景下可能会对后续的分库分表设计产生一定限制。因此后续优化方案考虑引入分布式ID(例如雪花算法、UUID等)

✨这个服务是可以水平扩容的吗?

此处的水平扩容指的是扩充机器

​ xtimer支持水平扩容,通过扩充机器引入更多的线程来分摊任务的压力。xtimer服务是支持多机部署的,多台机器通过竞争分布式锁的方式来获得分片的触发执行权,同样也通过竞争分布式锁的方式来获得任务生成脚本(migorator)的执行权。

​ 但需注意的是,扩容数量会受到分片数量的限制(单个分片只能被一个机器获取到,所以同一时间的的分片数量决定了最大机器数。例如如果同一时间的分片数量只有1,就算引入多台机器,该分片也只能由某台机器的某个线程获取到,其他机器抢不到锁则进入等待)。根据现有的分片规则(时间+分桶),桶数就是限制机器数量的参数,如果想要支持动态扩容,需要引入“动态分桶”策略。

✨为何选择回调不是拉取?还考虑过别的回调方式吗?

推拉模式的选择:首先得对齐这个问题点,强调项目立意,xtimer是作为一个“闹钟”,应当做到主动通知业务方“到点了,要执行一些操作了”,这个定时通知应该是xtimer进行控制,业务方不可能一直询问“要不要做任务?”(就像是实际生活中创建闹钟,应该等待闹钟响起然后去做一些事情,而不是三不五时看看闹钟响了没?(业务方“拉取”这个概念有点脱离项目立意了))

其他回调方式:消息队列、WebSocket(WS)

  • 消息队列:将待回调的任务转信息转化成一条消息队列的消息进行投递,业务方通过消费消息队列的方式来获取到点通知
    • 优点:可以明显减轻服务器的回调压力,因为不用再为每一个任务建立http链接了,对资源和耗时都有好处
    • 缺点:这种方式需要增加对消息队列的依赖,并且依然不能解决网络延时长的问题(因为消息的流转过程中,投递消息&消费消息等中间反倒会增加网络传递次数,可能网络本身的延时会叠加)
  • WebSocket(WS):如果引入WS,需要为每一个业务方常见一个WS连接(而不是为每个任务创建一个ws连接),后续对于同一业务方的task则通过具体的一个WS连接进行回调通知
    • 优点:解决服务器维护大量连接的压力
      • 业务方数量肯定跟任务数不是一个量级的(一般也就个位数),因此建立连接也没啥压力
      • WS是长连接,因此不用每次发送task都经历一次三次握手这些过程(对比http的TCP连接过程),原本网络延时导致的任务延时问题,可以得到明显改善
    • 缺点:增加了业务方接入和开发以及后续的维护成本

✨项目中用到MySQL是比较多的,有用到事务吗?(美团优选1面)

​ 目前xtimer定时微服务模块设计了两张表(timer、timer_task),定时器创建和任务的生成都是用单独的SQL进行处理,没有额外加事务控制处理

✨介绍下落地实现时这个“模块化+异步化"设计思路是怎么考虑的?

  • 模块化(解耦):核心目标是让代码实现层次分明,模块职责清晰,有效提升了后续的扩展性和可维护性(调度模块、触发模块、执行模块)

  • 异步化(提速):模块之间通过线程池/协程池进行异步化,目标是对模块之间进行解耦,能够提升任务不同模块阶段执行的并发度,也能更好的针对模块的不同职责特性进行配置和优化

✨项目中提到“最大误差1s”,这里是什么误差?这个误差是怎么计算的?

​ 目前误差是计算的是“触发时间”的误差,没有包含最终的业务回调http的耗时,考虑到这个耗时是受网络或者业务方接口性能的影响,所以用来评估定时微服务的性能是不合理的。所以如果想要精准计算用户收到叫醒通知时的误差,这个需要在业务方的http callback接口来做(即业务方用当前时间-预期时间 =T)T = 定时微服务触发误差 + http请求耗时

✨项目中提到的“迁移模块migrator的设计"怎么做的?

​ 迁移模块migrator核心工作是要解决任务周期生成的问题,用户在创建完一个定时器之后(例如 timer 频率为每分钟运行一次),并不会一次性生成所有时间的任务,这个任务量是无限的,而每一次只会生成一个步长的任务,例如步长2小时,那一次就会生成最近120个任务。 那基于此就需要一个功能模块来解决2小时之后的任务的自动生成问题,所以migrator就是这么来的

​ migrator是一个周期运行的脚本,它会扫描数据库中所有“激活“状态下的定时器timer,然后基于timer的cron表达式定义,生成最近两小时内需要触发的任务tasks,并存入MySQL的timer_task表和Redis的ZSET中

✨“迁移模块migrator”对数据冷热分区的作用是什么?

先分析数据冷热的概念,然后说明迁移模块是如何处理数据冷热的

​ 定时任务的设定本身具有先天的冷热特性,即越靠近当前时间的任务“越热”(是当下需要重点关注的),而越远离当前时间的任务越冷(例如历史任务或者未来比较久远的任务,这些任务是短期内不需要关注的)。

​ 基于这样的任务特性,xtimer并没有一次性生成所有未来待执行的任务,而是对冷热数据做分别的处理

  • 对于热数据:定期生成少量最近的热任务
  • 对于冷数据:对于未来要执行的任务,采用延迟冷任务的生成; 对于已经执行完成的定时任务,采用定时清理或者归档,释放存储资源

​ 而migrator就作为一个不断运行的定时脚本,实现这个定期生成任务的功能

3.难点、亮点

✨项目遇到最大难点是什么?是怎么解决的?

​ xtimer 的核心难点是大任务量场景下,如何做到高效触发(高精准+高负载)。正常来说,业界解决类似问题可能会采用**“时间轮”算法**。 时间轮的优势在于,通过层级存储,可以将一个跨度时间范围很大的批量任务,进行合理的拆分,使得时间近的任务可以得到优先处理,提高扫描频率,让任务触发误差尽可能的小。 时间轮算法本质还是数据冷热分区思想的使用,然而这个方案也存在一定的问题:

  • (1)时间轮算法一般会用在一个单独进程应用的内部定时实现,在分布式环境下,少有能直接使用的时间轮存储方案
  • (2)如果要自行实现的话,则需要自己处理数据分片,数据落地存储等问题。且如果数据冷热分区做得合理,将任务提前按照时间做好拆分的话(比如只处理最近一分钟的数据),那时间轮算法层级存储优势将不复存在

​ 基于以上考虑,目前设定的方案是自己做数据的“冷热分区”和“数据分片”,利用MySQL+Redis的二级存储,将久远的冷任务存储在MySQL中,将时间最近的待执行的热任务存储在高效存取的Redis ZSET中,同时也可以利用到MySQL 数据持久化能力 + redis天然的的分布式存储能力来提供可靠性保证。

​ 最终的效果:xtimer 定时服务可以实现秒级误差控制,同时大批量任务的情况下,依然可以得到高效触发。当然这个方案还可以进一步提升,比如Redis虽然快,但高频扫描的存取会带来一定的网络开销,如果再结合“本地缓存”方案做一层redis数据的冷热拆分,将最热门的数据存储在本地缓存,则可进一步减少网络开销带来的时间误差,让任务更高效的触发(但目前“本地缓存”这个方案还没落地,因为秒级误差能满足大部分需求了)

🚀难点1:高精准问题

📌说一下项目难点中的高精准是什么?怎么解决的?

高精准问题

定时场景中的高精准问题:预期设定的触发时间与实际的触发时间之间的误差值

业务容忍度:虽然不同业务场景对这个误差值容忍度不一样,但从整体来说,越高的精准度适用的业务范围会越广,服务可信度越高,业务也越不容易出问题

  • 比如定时发通知,可能有的日常通知延迟个分钟级别都不会有问题
  • 而如果是定时开始考试,可能容忍度就只能是秒级甚至毫秒级等

影响高精准的因素

  • 脚本无法高频触发:基于定时脚本的方式,脚本无法高频触发,脚本运行间隔大小就是误差时间大小
  • 消息堆积:基于消息队列实现的定时器,当任务很多,甚至出现消息堆积的时候,那排队等待被消费的时间就会导致延时增加
  • 网络问题:基于分布式实现的定时器,当整个任务的调度过程需要通过网络在不同组件之间流转的时候,网络IO、网络隔离等都会任务延时的增加
  • 存储不合理:当一个任务的触发过程中,可能会涉及多次任务状态的查询,修改等操作,如果数据存储得不合理,导致每一次检索复杂度很高,那整个触发流程的耗时自然就会增高(例如数据库索引不合适导致全表扫描又或者数据库任务很多导致检索难度加大,又或者本该使用缓存的没用到等等)。因此定时任务的存储方式,检索复杂度等也是一个影响误差大小的关键因素
  • 任务排队:同一时间需要处理的任务量很大,例如10:59:00这个时间点大量定时任务需要触发,由于系统的处理能力是有限的,任务处理必定有先有后,形成一种任务排队的状态,那后面处理的任务延时肯定是更高的

如何解决高精准问题?

高频扫描(每隔1秒):采用基于Redis ZSET的存储设计(有序性存储,底层基于跳表实现,优化检索效率),针对同一时刻大任务量的执行采用分片策略(分时+分桶)拆分任务,让更多的线程可以参与工作。基于单个分片的单次判断耗时不会太长,即便进行短时高频的查询扫描(例如每个分片每隔一秒进行定时扫描),也不会带来太大的性能开销。(任务的触发时间误差是跟扫描间隔直接相关的,例如扫描间隔1秒,那么最差情况单个任务至少误差1秒)

并发执行任务:如果同一时间有大量任务需要执行,如果所有任务都排队执行,那最后执行的任务的误差需要加上前面所有任务执行时间之和,这个时延是存在很大问题的。xtimer项目采用了并发执行任务的方案,让批量任务可以尽可能的并发执行,减少等待时间,提高触发精准度

📌介绍下“高频扫描”是什么意思? 跟高精准有啥关系?

分析说明“高频扫描”的含义,然后进一步说明其和高精准的关系

扫描:待执行任务是带有“待触发时间”的(例如20240101 09:00:00 精确到秒),它被存储在数据库或者redis之中。任务存储后并不能自动触发,需要有程序去主动发现这些“待执行任务”才能完成触发,所以把捞取"到点任务"这个过程叫做"扫描"高频:对于定时任务场景,任务的发现时机非常关键,如果是在20240101 09:00:05时发现这个任务,误差就至少达到5s。因此需要通过将扫描频率提高(例如1s扫描一次,那这个发现时的误差最大就1s),进而减少“时延”(即减少预期设定的触发时间与实际的触发时间之间的误差值)

高频扫描与高精准关系:根据高精准的定义(预期设定的触发时间与实际的触发时间之间的误差值),既然高频扫描能缩小误差,那自然也就是成为了高精准的基础

🚀难点2:高负载问题

📌说一下项目难点中的难点高负载是什么?是怎么解决的?

影响高负载的原因(高负载问题)

​ 在真实的业务场景中,定时任务的运用是很多的。随着业务的不断接入,任务量也会随之暴涨,任务的增长会给数据存储、数据检索、任务执行、任务触发时效等带来影响。而针对项目中的“高负载”问题,主要就是解决任务量增加之后带来的问题

如何解决高负载问题?(xtimer已有实现)

  • 分治策略(分时+分桶):将数据分散到不同的分片里面,让不同的线程去跟进分片的处理,提高了并发度(不同分片内的任务可以并发处理)
  • 线程池:对比1个线程1个任务的方案,此处引入线程池对线程进行有效管理,使得任务能够尽可能并发快速执行的同时,又不会带来线程泛滥拖垮系统的问题
  • 合适创建定时器:xtimer 的最佳实践(一个定时器“闹钟”,不关注业务),从使用层面的最佳实践来说,如果一个业务方同一时间有多个任务要执行,那它应该将多个任务列成一个“计划列表”,然后为该”计划列表创建1个定时任务,而不是为计划里每一项创建一个定时器。基于此对于同一业务方在同一时间需要创建的定时任务基本就是个位数(N),且考虑到接入的业务方一般也不会太多(基本也是个位数或者十位数(M)),因此同一时间的任务量极端就是(N*M),也是有限、可控的

潜在的优化点

(1)动态分桶:目前的分桶数是静态设定的(不太灵活),分桶数决定了同一分钟任务的分片数,所以当同一分钟有大量任务时,每个分片的任务就会很多,没办法进一步打散。所以考虑引入“动态分桶策略“

  • 动态分桶方案1:迁移器模块在将定时任务数据同步到 redis 的过程中,会对一个时间步长的定时任务进行解析落表。可以在这个过程中统计出时间步范围内的每一分钟的任务数量N,根据预先定义的数学模型计算得到一个分桶数量(例如策略:m个任务一个桶),再以此为基础进行 Redis 缓存数据的建立(桶数= N / m)
    • 假设每一分钟的任务数量是10000,根据数学模型得到一个分桶数(假设是500),则可进一步得到每个桶的数量(10000/500=20个任务)
  • 动态分桶方案2:额外建立一张映射表,记录不同分钟级时间范围对应分桶数量的映射数据(例如9:01 =》20个桶 ;9:02=》10个桶)

​ 在后续调度的时候,根据实际的桶数进行调度

(2)动态扩容能力(增加机器):动态分桶提供了对动态扩容能力的支持,当某一时间的任务都分散在不同的分片中以后。例如提前感知有1000个分片(这种本身不常见,属于异常高峰),可以提前增加机器来抗过这一波高峰,不同机器可以通过竞争分片对应的分布式锁来获得触发执行权,来分摊不同分片的执行任务,达到并行执行目的,既加速了任务执行速度,同时也能分摊机器压力

✨Java线程池/Go协程池对高精准、高负载具体有什么作用?

高精准

首先理解什么是高精准,然后分析线程池的作用,理解线程池对高精准问题的优化

​ 定时场景中的高精准指的是:任务预期触发的时间和任务实际触发的时间差

​ 对于同一时间需要处理的任务量可能会很大,但考虑到系统的处理能力有限,如果采用的是同步方式执行,则任务执行有先有后会形成一种排队的状态,那后面的任务执行会受到前面任务执行的时延影响。而引入Java线程池是为了提高任务触发、执行的并发度,减少排队耗时,进而提升任务整体的触发精准度(即提升任务触发执行效率,减少预期执行时间与实际执行时间的时间差)

高负载

​ 高负载问题的核心是要解决“同一时间有大量任务需要执行”的问题。通过引入线程池技术,可以有效提高任务执行的并发度,加快任务的执行速度,减少任务挤压。

​ 此外,对比1个任务1个线程的方案,引入线程池可以有效管理县城资源,使得任务可以在快速并发执行的同时,又不会带来线程泛滥拖垮系统的问题

✨说一下项目有什么亮点设计?

亮点1:存储设计的亮点

​ 定时任务服务中,会存在频繁的对数据进行增删改查操作,所以数据的处理性能(包括检索性能,写入性能)等都会影响到最终的高精准、高负载问题的结果。一个好的数据存储方案设计,可以在数据的检索,维护,管理等方面都起到至关重要的作用

​ xtimer 采用基于mvsgl +redis的多级存储结构设计,引入分治策略、冷热分区概念对数据进行合理的缓存分片,一方面提高了数据的查询速度,另一方面也有效缓解了极端情况下高负载的问题

  • (1)基于mysql+redis的多级存储设计+冷热分区:有效划分冷热数据,通过迁移模块migrator的设计,让动态变化的热点数据持续维护在查询效率最高的缓存里,也避免了过期的或未来的冷数据占用内存资源
  • (2)分治策略:横向+纵向数据分片设计,将大批量任务拆分到不同的分片之中,有效提升了数据处理的“并发度
  • (3)有序性设计:使用Redis的ZSET的有序集合维护热点任务信息,有效降低数据检索复杂度(从O(n)降低到O(logn))

亮点2:性能优化

缓存 + 有序存储:对比原始的纯基于MySQL的遍历方案,xtimer 使用redis将近期需要触发的任务数据缓存在Redis的ZSET中,既利用了内存的快速查询能力,也运用到了ZSET数据结构天然的有序性特性,大大提升检索效率

线程池:利用线程/协程池技术,充分提高了任务处理的并发度,对任务处理的高精准,高负载问题都有很大的提升

连接池:通过引入druid数据库连接池,通过复用连接、管理连接的方式提升数据库交互性能(例如创建Timer接口、生成任务等涉及到数据库相关的操作,如果频繁创建数据库连接则相应会影响性能)。例如通过引入druid连接池,通过压测对连接池参数进行调优,将接口的性能提升了一倍多,并且耗时也减少了20%-30%

✨比如说发现 12 点整这个任务量特别大,机器不够了需要增加机器,但是12 点整的这一堆任务被分在了5个分片上,就算加了机器他们也没办法拿到绑定的分片,没有办法拿到任务,那这个问题怎么解决?

问题分析:目前现有的方案设计的确会存在这个问题,由于分桶数是固定的,分桶数限制了worker的扩容能力(因为分片:分布式锁1对1的,分桶数固定则说明同一时刻的分片数是固定的,多个worker抢占分布式锁的情况下,只有抢占成功的worker才会获取到任务的触发权,其他worker只能被动等待)。所以此处当同一时段大量任务被分到这5个固定的分片上,新增机器也无法拿到分片、也就无法执行任务了,如果worker的处理能力不足就很有可能堵塞住了

解决方案考虑引入动态分桶策略来提供对动态扩容的支持

​ 例如生成任务在放入分片之前,先根据数据模型得到一个合理的分桶数量,基于此进行动态分片,将数据合理动态打散(例如1w个任务,通过模型计算得到分桶数量为500个任务1个桶,则可以得到相应的桶数为20,即这批任务会被放到20个分片中)。基于动态分桶策略,针对同一时间的大量任务会被打散到更多的分片中,因此可以通过增加更多的机器来进行锁的竞争,进而支撑动态扩容的能力

4.其他问题

✨介绍下“性能调优”这块做了什么事情?

​ 针对核心接口做“性能压测”,对比引入数据库连接池前后的性能优化的效果提升。主要测试接口有:创建任务接口、激活任务接口、查询任务接口。参考机器配置:2核4G的云虚拟机

  • 创建定时器timer接口:500 -> 2000
  • “激活”定时器timer接口:800 -> 3000
  • 查询接口:1500 -> 4000

✨定时任务出现异常怎么处理?

针对系统中可能出现的异常任务的问题,主要从以下几个方面来考虑解决:

  • (1)记录任务执行状态:这个是前提,只有知道任务处于什么状态,才能据此判断一个任务是否异常
    • 一个任务状态是“执行失败
    • 一个任务处于“待执行“状态,同时又超过了预期运行时间一定时长(例如30秒)
  • (2)定时脚本:单独运行一个定时脚本(例如每5分钟跑一次),脚本逻辑是扫描MySQL数据库,将异常任务检索出来后触发重试执行
  • (3)人工介入:如果一个任务超过预期执行时间(例如30分钟后还处于失败状态),那就人工介入判断处理(例如定位问题后,通过维护接口修改任务状态,或者调整任务信息等)

✨兜底脚本5分钟轮询这个时间怎么定的?

​ 兜底5分钟是结合业务场景得到一个经验值。针对兜底时间,有一些限制,太长或太短都会对定时任务有影响:

  • 兜底轮询时间间隔不能太短,不能短于脚本运行时间(如果短于脚本时间,就会出现脚本还没执行完成就又重复轮询的情况),同时太短也会增加服务器或数据库的压力
  • 兜底轮询时间间隔不能太长,太长会导致这个兜底的运行的时间误差会大于用户的接受程度

​ 因此根据经验取了一个默认5分钟的兜底,这个是一个动态配置的参数。 可以根据真实的业务做调整,例如单次脚本运行很快,业务也比较在意兜底时间的话,可以将其调小(例如30s)

✨项目用的什么垃圾回收算法?

分析:先确认项目引用的JDK版本,然后分析垃圾回收算法的知识点

​ 对于垃圾回收算法没有去做特殊调整,使用的 Java 1.8JVM 的默认配置,其中年轻代是 Parallel Scavenge 使用标记复制算法的多线程收集器,老年代是 Parallel Old 使用多线程和“标记-整理”算法

5.真实场景典型问题

✨任务为什么要进行分桶,同一分钟全部存一个ZSET不行吗?redis是单线程的,分桶之后还不是排队执行吗?

​ 对于同一分钟的任务量可能会很大,引入分桶一方面是为了避免Redis的大key问题,另一方面则是将数据打散、拆分更多分片,进而让更多的线程可以并发加入工作。引入分桶是为了支持任务的多机调度,提供对多机扩容的支持。

​ 虽然Redis是单线程,但业务场景中可以通过集群部署,数据分片之后可以支持并发读写(如果ZSET都落在同一节点上, 确实在执行阶段只能串行)。分片之后可以方便基于redis实现任务的多机调度,多台机器可以通过争抢不同的分片,来达到任务分发的目的

✨定时微服务中做的工作有linux 的cron表达式不能完成的吗?(百度社招一面)

​ Linux cron 是Linux 系统下一个基于时间的作业调度工具,用于自动化执行定时任务。用户可以通过 crontab文件定义任务的调度时间;Linux cron 与 Java Timer这种类似,也是单进程内运行,不具备任务多机调度处理能力等

  • Linux cron 默认仅支持到分钟级;xtimer 支持秒级的精准度
  • Linux cron 异常处理不足,不具备重试机制等;xtimer 提供异常处理机制,例如重试、告警等
  • Linux cron 不具备分布式处理能力,当单机执行任务能力不足时,没办法通过扩容机器来分担单机压力;xtimer则可以

✨这个项目是自己做的吗,做的时候有高工带吗(百度社招1面)

​ 系统整体实现来说是比较简单的,不建议包装成几人合作的形式(有些小伙伴的心理可能出于担心,会说是团队合作,遇到不会的问题就推出去给同伴,这种思路是错误的)。如果是在公司工作,肯定会需要组织方案评审会的所以高工更多是参与方案评估、给出建议,但是最终方案落地都是自己一个人完成的。

项目是自己从0-1构思的,在项目构建的过程中,高工更多是参与方案评审、或者给出一些方案建议,但方案的产出和最终的落地都是自己完成的

✨定时微服务线程池是怎么配置,动态监控是怎么做的,没有实际做过吧?(百度社招1面,京东社招1面)

首先要有一个明确的答案和数值,避免被质疑项目的真实性,然后再去探讨优化问题。可以是一个不太好的答案,但是必须是个明确答案

​ 线程池目前是配置的一个经验值:核心线程数10,队列大小默认不限。大概分析了每个线程要完成的工作,都会存在一些IO操作,根据机器2核4G的配置,所以给出了一个经验值10

​ 线程池参数的配置业界都有比较多常见方案,包括静态虑任务IO密集或CPU密集等特性进行的公式计算,以及动态调优等策略,这块都有了解过。 但是目前考虑线程池这块并不是系统的瓶颈点,所以没有提前做过多优化。考虑后期需要再进行动态优化

✨迁移模块的时候,会不会重复存入,有没有并发的问题(百度社招1面)解析:

分析迁移模块逻辑和存储逻辑

​ 会有并发,但最终不会重复,也不会有并发问题,迁移模块确实是有一些异常情况下的重试兜底机制,以及与激活接口触发的迁移等并发生成任务的可能。这可能会导致任务的重复产生,但是在MySQL的 timer_task 表中加了唯一索引(timerId+执行时间戳),同时Redis的ZSET中的value 存储的是timerId+触发时间戳,也保证了唯一性

​ 基于上述设计,虽然过程中有重复任务的产生,但是最终存入数据库和redis的时候,重复的任务都会被过滤掉

✨扫描zset的时候会不会重复执行之前的任务(百度社招1面)

需理解扫描过程

​ 扫描过程其实是类似滑动窗口的方式进行任务提取的。每个ZSET包含1分钟的任务,每秒获取一次,并且也只获取一秒的任务(获取的方式是ZRangeByScore):

  • 第一次获取第0秒的任务,第二次获取第1秒的任务...... 依次类推,最后获取第59秒的任务,所以扫描过程中不会重复获取

✨一直执行会创建很多个zset,zset什么时候删除,过期任务什么时候删除(百度社招1面)

​ 基于项目特点(定时任务),历史的定时任务对于项目运行来说是没有用的,理论上是要归档或者清掉的:

  • 针对Redis存储,内存空间有限,需要清理数据以腾挪存储资源,xtimer中是直接使用Redis的过期机制来实现自动删除
    • 过期时间的设定主要考虑到分片策略和兜底机制,ZSET是和一个具体分钟时间绑定的(它是分钟数+桶号确定的),以及异常情况下的两个兜底机制(重试上一分钟、最终的兜底机制)
      • 分片策略对过期时间设定的影响:预期的分片存活时间设定为1分钟,例如9:00开始,则大于9:01可进行清理
      • 兜底机制对过期时间设定的影响
        • 重试上一分钟:重试上一分钟预期也要1分钟的时间,因此在上述基础上最晚会运行到9:02-9:03
        • 最终的兜底扫描:兜底扫描判断需要加限制(例如只扫描1个小时以内的任务,超过一个小时的任务再触发可能意义不大了),基于此分片可能最晚被调度的时间为10:00
      • 综合上述分析,过期时间的设定受到分片策略和兜底机制的影响,可以看到一个分片最晚的调度时间为(T=分片的分钟数+1小时???),因此Redis的分片过期时间可以设置为大于T的值即可
  • 针对MySQL存储,可以把历史任务清理或归档

todo:1h是否覆盖了影响,选择一个最长的时间兜底即可?还是说要进行累加?

✨线上的服务器配置是怎么样的?

  • 校招:2台2核4g的腾讯云服务器
  • 社招:2-5台8核16G的腾讯云服务器

✨如果有个新的定时任务要求五分钟内执行怎么办?(字节飞书1面)

任务生成的触发逻辑有两个,用于保证任务的提前生成,准点执行

  • 定时器timer激活:定时器timer激活会触发该定时器关联的任务生成
  • 周期生成逻辑:例如每小时运行一次的周期生成逻辑

✨如果一直调用创建任务接口,并且不断增加QPS,项目最先崩溃的是哪部分?(MySQL?Redis?内存?)(字节飞书1面)

创建接口瓶颈分析(需注意此处是创建定时器,任务是根据定时器配置自动生成的)

​ 创建定时器接口的主要逻辑是写MySQL数据库,所以最先崩溃的是MySQL数据库。此前对这些做过接口的压测,引入druid数据库连接池进行优化:

  • 创建定时器timer接口:500 -> 2000
  • “激活”定时器timer接口:800 -> 3000
  • 查询接口:1500 -> 4000

​ 针对上述问题的系统优化:

  • 引入druid数据库连接池:连接复用、连接管理
  • 后续优化考虑分库分表,通过MySQL的水平扩容来支撑更大的请求压力(但是考虑到创建定时器接口本身在现有业务场景下一般不会有这么大的量,因此目前没有性能足以支撑,没有过度优化)

✨为什么要这么复杂,直接一个程序在内存里存任务拿取不就好了,比如一个堆放进去每次取时间最小的那个任务(携程校招2面)

对比内存和Redis的场景应用和技术选型,理论上都是可以实现的,但考虑到xtimer的微服务的分布式场景,还是选用Redis(其选用Redis不仅是因为其高性能、有序性等特点,还考虑到后期的优化迭代要借助Redis支撑分布式场景下的水平扩容以支持多机并发竞争,提升任务执行的并发度和项目的高精准度)

​ 核心:单进程内存的实现方案和分布式存储Redis相比还是存在很多不足的,对比之下选择Redis更为合适:

结合 持久化、灵活扩容、执行能力 三方面进行阐述

  • (1)持久化机制
    • 对于单机内存存储方案来说,机器一旦挂了,数据就丢失了
    • 对于Redis来说,提供了持久化方案来支撑宕机发生时的数据补救措施
  • (2)内存有限,存储能力无法扩展
    • 对于单机内存存储方案来说,单机内存是有限的,就算增加机器后任务调度也是个问题(需考虑大批任务如何在多机进行分配,无法支持方便扩容)
    • 对于Redis来说,Redis支持集群部署,内存不足时可以方便进行扩容支持
  • (3)执行能力受限
    • 如果采用本地缓存方案,任务缓存与任务执行是在一起的话,因为前面无法增加机器的限制,也就导致任务本身的执行能力也收到了限制,无法多机并行执行
    • 如果采用Redis,通过多机扩容可以增加任务的并发度,提升任务执行效率(但需注意这点同时也受到分片策略的影响,假设分片只有1个,就算引入多机也会有1个机器抢到任务的触发权,其他机器只能被动等待)

✨如果是个周期任务,是不是要每次都要重新创建任务?(B站二面)

抓住关键字,阐述任务创建的核心流程

​ xtimer 支持周期任务,目前设定的流程是创建一个cron配置(可自行指定周期任务运行的周期间隔,例如每天早上9点),然后通过解析这个配置来定时生成任务。定时器只需要创建1次,后续xtimer会根据定时器配置来自动唤醒业务方执行相应的业务逻辑操作(通过回调实现)

创建1个定时器(闹钟)=》任务自动生成,到点执行唤醒业务

✨高精准秒级,但秒级不算高精准(B站二面)

讲清楚导致误差的一些客观存在的原因,消除误解

​ 对于单进程内的定时处理,类似]ava Timer这种确实基本能做到毫秒级。但考虑到项目设定是一个分布式的微服务定时器场景,期间的网络调用、数据IO、触发时机间隔等耗时加起来,导致没办法去承诺秒级的精准度。所以大部分分布式定时工具都没办法承诺毫秒级的精准度,虽然实测平均可能是几百毫秒以内,但是整体承诺只能是秒级别。对于一些对于高精准的要求较高的业务(例如强制要求毫秒级别的)一般来说不建议接入xtimer。

✨如果是分布式的话,每个机器的时间是存在相对误差的,很可能达到秒级会有问题,比如重新获取上一分钟的锁时由于误差没获取到的可能性(拼多多校招一面)

​ 针对重试上一分钟的方案,分布式锁过期时间的设置建议为(1T<n<2T),一般建议时间是留一点buffer的(例如65s,而不是精准60s,这样5s buffer可以解决一些时间上的误差问题)。但与此同时带来的问题可能就是重试不够精准,会有5s的误差。但是考虑“重试上一分钟”这是一个异常情况下的兜底策略,有几秒误差是可以接受的。

​ 机器时间误差也是一个需要考虑的因素,参考“服务器时间校准”概念:可以借助服务器校准方案,定期校准服务器时间open in new window

✨这个项目会不会遇到Redis的缓存相关问题?(拼多多校招一面)

​ Redis的缓存相关问题(缓存穿透,击穿,雪崩,一致性等问题)。目前xtimer项目中是没有涉及的

​ 使用Redis缓存的任务都是提前生成好的(例如提前生成下一分钟要触发的任务),后续对这些缓存也仅仅是做读取而不做修改,所以不会存在上述Redis缓存相关问题。

潜在问题(不用主动cue,扩展思考):如果用户创建了一个执行时间很近的任务,例如100毫秒之后要求触发,这种可能会导致任务写入数据库,但是还没来得及写入redis,那这个其实算缓存一致性导致的问题。 最终会导致这个任务不会正常触发,只能依靠最终兜底机制来触发,误差就跟兜底脚本运行间隔时间一致了。

如何优化? =》应该限制很近任务的创建和生成,例如5秒内的定时任务不支持,否则业务就得接受误差;

✨项目这么依赖redis,redis的高可用怎么保证?redis挂掉了怎么办?(易行网-社招)

先分析Redis挂掉可能导致的问题,然后说明xtimer项目中如何保证Redis的高可用

​ 如果说Redis挂掉,会导致调度流程失效,这点只能依靠兜底机制来运行任务(两种兜底方案:重试上一分钟、最终的兜底扫描方案)

​ Redis的高可用通过部署架构来保证,采用集群部署方式(Redis也有哨兵等高可用机制),一般来说是不会全部挂掉的。如果说真的出现全部挂掉的情况,甚至“重试上一分钟”都无法生效,xtimer服务提供了兜底机制,可以依靠这个周期运行的脚本去扫描数据库中的任务进行触发。但需注意基于这种最终的兜底扫描方案,任务触发的时延则会受限于周期脚本运行的间隔时间(例如周期脚本每隔30s运行一次,那这个误差时延就是30s),但基于兜底机制可以保证任务可以正常被触发

✨创建了一个定时器后,分布式场景有多台机器的话,该交给那一台机器处理?(圈量科技实习一面)

任务处理的模式(推拉模式):拉模式,xtimer是通过任务占据的方式来处理任务(不是主动分发的逻辑),占据逻辑通过抢占分布式锁来实现

到点待执行任务会被放入分片等待被调度,调度逻辑如下:

  • 每一个分片都会有一个redis实现的分布式锁(分片:分布式锁=1:1
  • 所有触发线程都是通过抢占分片的对应的分布式锁来获得“执行权”
  • 系统支持多机部署,触发线程可以是来自于任意一台机器的线程,谁抢到锁分片就是谁的

✨如果收到请求的机器挂掉了,会发生什么?(圈量科技实习一面)

分析请求挂掉的时机在哪个阶段,以创建定时器接口为例

  • 如果是xtimer收到请求之前就挂掉了,不会有任何影响
  • 如果是xtimer接收请求成功,需考虑“数据是否落库”
    • 如果在定时器timer存入数据库之前挂了,什么影响都没有,调用方会收到创建失败的返回,调用方一般会选择再次重试重新创建
    • 如果在定时器timer存入数据库之后、返回响应之前挂了,调用方会任务这个调用是失败的,但实际上数据已经落库了,那此时数据库就会存在一个永远不会运行的待激活闹钟(除非设定了一些查询、清理机制)

✨说到高负载?那假设某一时刻一百万条数据打过来,服务会怎样?(圈量科技实习一面)

针对这个问题,首先做的不是想解决方案,而是对齐问题。需注意xtimer项目场景中的高负载概念针对的是“随着接入业务越来越多,任务量也会随之增多,需要考虑同一时间段处理大数据量任务的情况”

​ xtimer 的"高负载”不是指高并发请求量的意思,而是指同一时间点有大量任务需要运行的问题。但如果是问高并发请求量的情况的话,针对接口有做过性能压测,接口性能基本在2000-4000Qps之间。因为创建、激活等接口逻辑基本就是操作数据库,所以首先出问题的也会是数据库。

​ 目前是因为系统瓶颈点完全不在这些接口上,目前的QPS能力是完全足够的,所以没有过度优化设计这点。如果真需要继续优化,那就是对数据进行水平分库分表之后,通过扩容mysql机器来应对高并发的请求量。结合实际定时任务场景分析,如果真存在100w的数据打过来则不排除是业务方使用有问题、或者属于恶意调用,可以通过在网关层增加限流策略来过滤掉这些请求

✨了解过时间轮算法吗,为什么不用?(虾皮社招二面)

“时间轮”算法确实是在很多中间件中用的很多,也是在处理定时这方面,比较容易想到的方案

​ 时间轮算法的优势在于,通过层级存储,可以将一个跨度时间范围很大的批量任务进行合理的拆分,使得时间近的任务可以得到优先处理,时间轮算法本质还是数据冷热分区思想的使用。

​ 然而这个方案存在的问题就是:

  • 分布式场景适配问题:实践轮算法一般会用在一个单独进程应用的内部定时实现,在分布式环境下,少有能直接使用的时间轮存储方案
  • 实现成本:如果自己去实现的话,则需要处理数据分片、数据落地存储等问题。且如果数据冷热分区做得合理,将任务提前按照时间做好拆分的话(比如只处理最近一分钟的数据),那时间轮算法层级存储优势将不复存在(基于此则不如回归基础)
评论
  • 按正序
  • 按倒序
  • 按热度
Powered by Waline v3.1.3