0 前言
本文所介绍的项目已于 github 开源:github.com/xiaoxuxians…
个人搭建的体验网页地址:http://43.143.168.5:5173/
体验账号:test 登录密码:test
1 背景
1.1 需求背景
笔者 2021-2022 年期间工作部门为工效部研发协同效率中心,面向公司内各研发团队提供研效规则实施服务,规则灵活自由,大量规则涉及定时定点或周期执行.
Examples:
- 手Q android 团队每周五晚上8点准时开启发版流程
- 每天上午11点对研发工程师的需求单状态进行检查,并通过企微推送消息提示
由于涉及使用处较多,因此将时间相关的能力统一抽离出来,搭建分布式定时器基础组件统一提供能力。
1.2 方案调研
方案 | 不足点 |
---|---|
crontab、java timer 等 | 不支持集群;缺少监控报警、失败统计. |
rocketMQ 延时队列 | 时间精度和灵活度不足. |
微信定时器 | 面向业务团队内部;重度依赖 Redis;不支持超长过期时间处理. |
QQ Ticker | 面向业务团队内部;重度依赖 Redis;不支持超长过期时间处理. |
1.3 workflow.timer
从 MQ 走向协程池 基于以上种种,前团队曾采用 golang 语言,依赖于 apache pulsar,redis,myql 等基础组件,从零到一搭建了分布式定时器服务:workflow.timer,其对应的设计思路可见笔者的另一篇文档:juejin.cn/post/711632….
上图为 workflow.timer 架构图,可以看出这套架构重度依赖于 MQ,MQ 在整套系统中实现了承上启下、异步解耦、确保 at least once 的职责. 此前由于在前公司内部运营,无需过多考虑成本问题,workflow.timer 所依赖的 mq 组件由腾讯云封装好 apache pulsar 后作为成熟的 TDMQ 产品提供.
1.4 xTimer
如今,笔者在离开公司后,一方面希望能够降低使用 timer 项目的成本,另一方面也希望能够减少组件依赖以提高项目的可拓展性,因此对 timer 架构整体做了一轮变革,最终完成了一款的新的 xTimer 项目,并已于 github 上开源:github.com/xiaoxuxians….
xTimer 基于协程池实现,仅依赖于 mysql 和 redis,用户准备好 mysql、redis,配置好鉴权信息和关系性数据表后可做到开箱即用,对应的架构如下图所示:
1.5 从 workflow.timer 到 xTimer
xTimer 对标 workflow.timer 的异同:
相同点
:
- xTimer 和 workflow.timer 均采用去中心化架构,系统整体根据职责边界,拆分为 调度器 scheduler、触发器 trigger、执行器 executor 三个模块,各模块各司其职,相互配合串联起整个调度流程.
不同点
:
- 模块串联方式不同:
workflow.timer 基于 mq 实现模块串联,是真正意义上的解耦;xTimer 则基于协程池实现模块间的异步调用,本质上还是单进程内的通信交互;
- 性能表现不同:
workflow.timer 中每个模块可以由独立的物理机部署,只聚焦职责边界内的工作内容,假如 MQ 集群配置足够优秀不成为瓶颈的话,服务整体性能会表现更佳; 此外,各模块交互时也通过 MQ 的中转流程得以实现负载均衡,同一个定时任务流程最终会经过不同节点的共同努力,串联完成; 相比之下,xTimer 将三个模块内聚于同一进程,上述能力不能满足.
- 可用性表现不同:
高可用主要体现在定时任务的执行延时上; 相比于 workflow.timer,xTimer 串联的定时任务流程均在本地执行,减少和外部组件MQ的交互以及不同模块之间的 io 交互,因此一套调度流程执行下来,在执行耗时上会有更佳的表现;
- 接入成本不同:
重新设计 xTimer 的核心目的就是降低接入成本,由于 MQ 组件得到抽离,xTimer 接入时只需要有常用的 mysql 和 redis 组件的支持即可,成本上更低.
- 其他优化项
xTimer 在 workflow.timer 的基础上作了以下几点优化: I 建立多级存储模型,增设 migrator 模块用以实现实现不同存储介质间的数据迁移; II 基于 migrator 实现了定时任务批量打点的能力; III 实现了动态分桶的能力.
2 术语表
术语 | 英文名 | 含义 |
---|---|---|
分布式定时器服务 | workflow.timer | 前团队在工作实践中建设的定时器基础组件 |
定时器 | timer | 包含一组定时任务的定义的存储介质. 和定时任务是一对多关系,定时任务按照定时器的定义定时执行. 可以类比为一个闹钟 |
定时任务 | timer task | 定时器的一次执行实例,可以类比为闹钟提供的一次唤醒服务. |
时间分片 | time slice | 基于一定长度将时间线切割为一系列分片,各定时任务基于执行时间从属于不同时间分片. |
二维分片 | time_bucket slice | 在时间分片基础上,增加分桶维度,将每个时间范围内的定时任务尽可能均衡地划分到一系列二维分片中. |
调取器模块 | scheduler module | 分布式定时器服务三大模块之一,负责在全局统筹分配定时任务集合. |
调度器协程 | scheduler goroutine | 调度器模块中的并发执行单位,一个模块会根据配置开启多个协程并发工作. |
触发器模块 | trigger module | 分布式定时器服务三大模块之一,负责主动轮询唤起达到执行条件的定时器. |
触发器协程 | trigger goroutine | 触发器模块中的并发执行单位,一个模块会根据配置开启多个协程并发工作. |
执行器模块 | executor module | 分布式定时器服务三大模块之一,负责执行定时任务. |
执行器协程 | executor goroutine | 执行器模块中的并发执行单位,一个模块会根据配置开启多个协程并发工作. |
迁移器模块 | migrator module | 负责将热点定时任务数据沿 关系型数据库-> 缓存组件 -> 节点内存 这一顺序进行迁移的模块.(目前还未实现) |
一级时间步 | time step1 | 迁移器批量将定时器任务从关系型数据库迁移到缓存组件的时间间隔. |
二级时间步 | time step2 | 迁移器批量将定时器任务从缓存组件迁移到节点内存的时间间隔. |
3 核心思路
3.1 主动轮询
一句话总结:实现定时器最简单粗暴的方式:轮询 + 触发.
(1)注册定时器:解析并将一系列定时任务平铺直叙地展开,每笔定时任务明确展示执行时间这一指标
(2)节点自轮询:每间隔一个微小的时间范围,对定时任务列表进行全量查询
(3)过滤&触发:以 执行时间小于等于当前时刻 作为过滤条件,摘出满足执行条件的定时任务进行执行.
如此这般,一个乞丐版定时器就已经实现了. 然而能优化的点还有很多,例如这一过程中每次查询都需承担 O(N) 的线性时间复杂度为代价,显然还存在进一步改进的空间.
3.2 存储结构优化:有序表
一句话总结:基于有序表提升查询效率.
对于无序表结构而言,插入记录固然可以达到O(1)的时间复杂度,然而每次查询时需要承担O(N)的线性时间复杂度.
基于木桶效应,存储结构优化的方向是将时间复杂度均摊到每一笔操作当中,从而补齐短板.
例如使用红黑树(RBTree) 或者跳表(Skip List) 这样的数据结构,能以将插入记录的时间复杂度由 O(1) ->O(logN)为代价,换取查询时间复杂度由O(N) -> O(logN) 的优化.
在 xTimer 的实现中,存储介质选型上选择使用 Redis ZSet,以定时任务执行时间为 Score 进行有序结构的搭建,当定时任务数量达到一定量级时,ZSet 底层基于跳表作为有序表的实现. 一些更细致的实现流程如下:
(1)以 Redis ZSet 作为存储介质;
(2)每次添加定时任务时,执行 ZAdd 动作,以执行时间的时间戳作为排序的键(Score) 进行有序结构的搭建;
(3)每次查询定时任务时,执行 ZRangeByScore 动作,以当前时刻的时间戳加上一个微小偏移量作 score 的左右边界.
于是,木桶的短板由 O(N) 成功提升到 O(logN),这是属于时间复杂度模型的优化,接下来还可以通过数据分治的方式对查询的任务数量 N 进行优化.
3.3 存储结构优化:纵向分治
一句话总结:通过时间范围分片,减少查询涉及的任务数量.
每次查询时,真正的目标是那部分 "即将执行" 的定时任务,而另一部分 "更晚执行" 的任务实际上在本轮查询中是作为干扰项,徒然增大了数据规模却无实质用途.
此处使用到两个比较含糊的用词: “即将执行” 和 “更晚执行” . 因此需要有一个时间范围分片的概念,将整条时间切分为一个又一个边界清晰的时间片,于是,我们可以把和当前时刻同属于一个时间片内的任务视为高优先级的“即将执行”的定时任务,而从下一个时间片开始往后的任务都视为低优先级的“更晚执行”的定时任务.
在 xTimer 的实现中,选用 1 分钟作为分片的时间范围,更细致的实现流程如下:
(1)插入每笔定时任务时,根据执行时间推算出所属的分钟级时间范围表达式,例如:2022-09-17-11:00:03 -> 2022-09-17-11:00:00_2022-09-17-11:01:00
(2)以分钟级时间范围表达式为 key,将定时任务任务插入到不同 ZSet 中,组成一系列相互隔离的有序表结构.
(3)每一次查询过程中,同样根据当前时刻推算出对应分钟级时间范围表达式,并以此为 key 查找到对应的有序表进行 ZRange 查询.
至此,每一次查询的任务量级就从全量数据 N 进一步减小到分钟级数据 N',毋庸置疑 N' << N.
3.4 存储结构优化:横向分治
一句话总结:通过定时任务分桶,提高并发度.
截止到 3.3 小节为止,我们都基于单核的视角出发,对查询和存储模型进行优化. 然而我们的主题是生产环境中 golang 分布式定时器的实现方案,因此必然是集群模式,且单个节点也可以基于 goroutine 实现高并发.
为了避免引起多协程介入导致对临界资源的竞态问题,xTimer 在实现上以分片作为最小的资源粒度,每一个分片对应的任务集只会由一个goroutine负责作轮询,因此相应的要求是,需要将时间分片拆解为更细的粒度,即在横向上额外增加一个分桶的维度,从而保证每个时间范围内能有对应于分桶数量的goroutine并发参加工作.
更细致的流程如下:
(1)插入定时任务时,首先根据执行时间,确定其从属的时间范围;
(2)其次,根据定时任务的唯一标识 id,结合服务对最大桶数的设置参数,随机将定时任务划分到一个桶中;
(3)以时间范围和桶号组装形成一个新的 key,形成一个二维分片,实现对定时任务有序表的隔离;
(4)后续流程与 3.3 小节相同.
至此,分布式定时器核心方案的轮廓已经呈现,本质上是一个主动轮询的模型,过程中辅以有序表结合分时分桶的方式进行轮询效率的优化.
4 服务架构
4.1 总架构
4.1.1 定时任务调度流程
一句话总结:服务架构: 3 个模块 + 2 个协程池.
xTimer 是一个去中心化体系的定时任务调度框架,根据职责边界,将服务拆分为调度器模块(Scheduler module)、触发器模块(Trigger module)和执行器模块(Executor module)三个模块,各模块之间存在依赖关系,父模块通过协程池的方式异步启动子模块进行工作:
4.1.2 定时任务创建流程
一句话总结:定时任务创建与 webServer 和 migrator 2 个模块有关
xTimer 提供了 webServer 模块,面向用户提供 api 用于定时器的创建;用户激活定时器后,会根据定时器 cron 表达式批量创建定时任务,用于执行; 此外,迁移器模块(migrator module)会定期将 db 中的定时任务提前加载的 redis 中,采用 zset 有序表进行组织,供触发器模块轮询触发.
4.2 调度器模块
一句话总结:负责二维分片(分时+分桶)的全局统筹分配.
第 3 节介绍到,每一笔定时任务会从属于一个基于时间范围和桶号组成的二维分片. 每一个二维分片内会存储一个基于执行时间排好序的有序定时任务集合.
调度器的作用就是负责在分布式场景中,将一系列二维分片资源进行统筹分配,最终保证一个分片会由一个单独的触发器角色进行负责,既不能被遗漏,也不能被重复执行.
xTimer 的实现中,调度器模块核心流程如下:
(1)基于 time ticker 每隔 1s 进行主动轮询,基于当前时刻推算出对应的分钟级时间范围表达式;
(2)读取配置获得最大桶数的信息,基于时间范围拼接桶号,获得当前需要关心的一系列二维分片的 key;
(3)尝试抢占对应于每一个二维分片的分布式锁,并将锁的过期时间设置为一个小于分片时间范围的值;
(4)抢锁成功,则从协程池中取出一个协程调度一个触发器进行作业;
4.3 触发器模块
一句话总结:按时唤醒二维分片内的定时任务.
(1)触发器模块承上启下,上承调度器模块,作为调度器的子模块;下接执行器模块,作为执行器的父模块;
(2)当触发器被调度器异步启动后,在对应分片的时间范围内,按照第 3 节介绍的思路对分片进行持续轮询;
(3)将达到执行条件的定时任务取出,不断从协程池中取出对应于任务数量的协程,在协程中启用执行器模块,用于执行定时任务.
(4)当触发器协程完成任务后,会将该分片对应的分布式锁的过期时间更新为一个大于分片时间范围的值.
At least Once:
综上可见,在调度器模块中,只能保证到 at least once 的语义. 这是因为实际上没有手段能实现百分之百的分布式事务,即无法保证 (1)触发器完成时间分片作业 + (2)延长分布式锁过期时间 这两个动作整体具有原子性.
一种常见的场景是,触发器在分片对应时间范围内轮询时出现宕机,导致一部分任务完成,另遗留少量任务未执行. 此时由于触发器不会更新分布式锁的过期时间,导致锁会被其他调度器协程抢占,最终启动新的触发器重复执行分片,从而导致部分任务重复.
这个重复问题会留到后续执行器模块进行兜底处理,但至少在调度器和触发器模块的范围内,需要明白,目前这样的处理方式只能做到 at least once,而非 exactly once.
4.4 执行器模块
一句话总结:真正执行定时任务.
(1) 执行器由触发器异步启动,一个执行器协程对应于一个定时任务
(2) 执行器首先对定时任务进行幂等去重,具体在下文中展开
(3) 根据消息中的定时器 id,查询到定时器的完整信息
(4)执行定时任务
(5)在 bloomFilter 中进行定时任务记录的打点
(6)更新 mysql 定时任务执行记录的状态为已执行
幂等去重
到这里,终于要对 4.2 4.3 小节中遗留的 at least once 的问题进行填坑.
(1)在执行器模块的视角中,对于二维分片的概念是无感知的,接收到的每一笔定时任务可以通过其定时器 id 和执行时间戳拼接出一个全局唯一的标识 id,简称为 定时任务 id;
(2)每个执行器协程在执行定时任务前,以定时任务 id 为 key,通过 bloomFilter(依赖 redis 组件) 进行第一轮查重,由于 bloomFilter 机制,假如未命中,则一定能保证定时任务未重复,可以继续往下执行;假如命中,由于其存在一定的失误率,需要进入步骤(3)复核;
(3)基于定时任务 id 检索 mysql 中的定时任务执行记录表,准确查询出定时任务是否属于重复执行,若已重复则停止执行.
补充说明:到此为止,兜底措施已经阐述完毕,然而仍然有异常边界情况可能导致这个流程失效. 事实上,在分布式场景中,exactly once 的语义永远无法完美触达,我们只能尝试通过各种手段进行补偿和兜底,尽可能降低出现错误的概率以及发生错误所带来的损失.
4.5 migrator 模块
4.5.1. redis 重度依赖问题
目前 xTimer 中最核心的数据结构是基于 redis zset 实现的定时器有序分片,但是把 redis 这种基于内存实现的存储组件当作数据库使用,其数据可靠性在一定程度上存在风险. 此外,定时任务无论执行时间远近均添加到 Redis 中,可能导致缓存迟迟得不到释放,造成资源浪费.
4.5.2. 三级存储模型 + 迁移器模块
因此,xTimer 中基于 mysql 数据库 + redis 缓存 + 节点内存 建立三级存储模型,并增设一个迁移器模块(migrator module)根据任务执行时机由远及近进行存储介质之间的迁移同步.
(1)一级迁移器模块每间隔一个一级时间步的时长,处理下一个时间步的内容:比如设定一级时间步长为 60 min,当前是 11 点,则迁移器提前开始处理 12 点 0 分-13 点 0 分 这个步长范围内的内容
(2)具体工作为全量扫一遍 mysql 中的定时器定义数据库,解析出 12 点 0 分- 13 点 0 分 这个范围内应该执行的定时任务点,在 mysql 执行记录表中添加记录,并在 Redis 中进行批量打点. 后续触发器模块在轮询过程中先查询 Redis,倘若 Redis 集群出现分片数据丢失,则可以查询 mysql 进行兜底.
(3)二级迁移器模块(位于执行器模块)每间隔一个二级时间步的步长,将二级时间步内的获取二级时间步内的定时任务列表,提前将定时器完整定义缓存在节点内存中. 后续执行器模块执行任务时,可以先查询内存中是否已有定时器定义数据,若查询内存 miss,再进一步到 mysql 中获取完整定义.
4.5.3. 数据一致性问题
由于同一份数据可能分别位于 mysql 和节点内存中,如何保证数据一致性?
解决方案:
定时器定义数据会发生变更的内容只有定时器状态(激活<->未激活).可以通过和用户方约定,保证当调整定时器状态时,可能存在一定滞后,但承诺状态最晚会在一个二级时间步的时长内完成同步,来规避这一问题.
4.5.4. 打点问题
workflow.timer 实现方案:串行打点
在 workflow.timer 的实现中,定时器激活时会首先推算出首次执行的定时任务并存储到 Redis ZSet 当中,完成首次打点的过程. 在这之后,定时器的每一次打点动作会在前一个点的定时任务执行完成后,由执行器模块执行完成.
串行打点存在问题
当前串行打点的方案是比较节省存储资源的,在一定程度上也可以缓解超长过期时间所导致的缓存资源浪费的问题. 然而,在上一个点被触发器模块唤起到执行器模块完成定时任务执行并打好下一个点的过程是需要一定的耗时,假如用户的诉求是创建出一个短间隔高频执行的定时器(例如:1s 执行一次),那么串行打点的方案是无法实现的.
优化方案:批量打点
基于定时器中关于执行时间的定义(cron expr),推算出一整个批次的执行时间并进行批量打点,这样即可满足短间隔场景的诉求.
批量打点存在问题
(1)基于 cron 表达式推算出来的执行时间点可能漫无边际
解决方案: 因此需要明确批量打点的时间范围边界,将其定义为一级时间步 step1;因此可以每隔一个步长,完成对下一个一级时间步内定时任务的批量打点.
(2)用户可能反复对定时器状态进行修改,这样要求频繁对批量的点进行修改,复杂度和工作量都大大提升.
解决方案:
首先明确定时器创建后不允许修改定义,只允许作激活和去激活的操作;倘若需要修改定义,可以先删除旧定时器,并重新创建一个新的.
其次,遵循只打点不删点的原则. 倘若用户将已经完成打点动作的定时器置换为去激活态,我们同样不删除 ZSet 中对应的点,而是留待定时任务被唤起触发后,由执行器协程在查询定时器详细定义时,检查一次状态是否合法,来规避这一问题.
4.6 webServer 模块
xTimer 中提供 webServer 模块,提供了一组 restful api 用于支持用户对定时器的 crud 操作.
4.6.1 创建定时器
请求方式: HTTP POST + JSON
路径: /api/timer/v1/def
请求参数:
字段 | 类型 | 是否必填 | 备注 |
---|---|---|---|
app | string | 是 | 应用名 |
name | string | 是 | 定时器名称 |
cron | string | 是 | cron 表达式 |
notifyHTTPParam | struct | 是 | http 通知协议参数 |
notifyHTTPParam.url | string | 是 | 目标 url |
notifyHTTPParam.method | string | 是 | http 方法:GET/POST/DELETE/PATCH |
notifyHTTPParam.header | map[string][]string | 否 | http 请求头参数 |
notifyHTTPParam.body | string | 否 | http 请求体 json 字符串 |
响应参数:
字段 | 类型 | 备注 |
---|---|---|
code | int | 错误码,0:正常; 其他:有错误 |
msg | string | 错误描述信息 |
id | int | 定时器唯一 id |
4.6.2 激活定时器
请求方式: HTTP POST + JSON
路径: /api/timer/v1/enable
请求参数:
字段 | 类型 | 是否必填 | 备注 |
---|---|---|---|
id | int | 是 | 定时器唯一 id |
app | string | 是 | 所属应用名 |
响应参数:
字段 | 类型 | 备注 |
---|---|---|
code | int | 错误码,0:正常; 其他:有错误 |
msg | string | 错误描述信息 |
tips: 在激活定时器后,webServer 模块会对定时器表达式进行解析,批量创建出定时器在接下来两个一级时间步 step1 范围内的定时任务,添加到 db 和 缓存 (redis zset) 当中.
4.6.3 去激活定时器
请求方式: HTTP POST + JSON
路径: /api/timer/v1/unable
请求参数:
字段 | 类型 | 是否必填 | 备注 |
---|---|---|---|
id | int | 是 | 定时器唯一 id |
app | string | 是 | 所属应用名 |
响应参数:
字段 | 类型 | 备注 |
---|---|---|
code | int | 错误码,0:正常; 其他:有错误 |
msg | string | 错误描述信息 |
4.6.4 删除定时器
请求方式: HTTP DELETE + JSON
路径: /api/timer/v1/def
请求参数:
字段 | 类型 | 是否必填 | 备注 |
---|---|---|---|
id | int | 是 | 定时器唯一 id |
app | string | 是 | 所属应用名 |
响应参数:
字段 | 类型 | 备注 |
---|---|---|
code | int | 错误码,0:正常; 其他:有错误 |
msg | string | 错误描述信息 |
4.6.5 查询定时器
请求方式: HTTP GET + FORM
路径: /api/timer/v1/def
请求参数:
字段 | 类型 | 是否必填 | 备注 |
---|---|---|---|
id | int | 是 | 定时器唯一 id |
app | string | 是 | 所属应用名 |
响应参数:
字段 | 类型 | 备注 |
---|---|---|
code | int | 错误码,0:正常; 其他:有错误 |
msg | string | 错误描述信息 |
data | struct{} | 定时器数据 |
data.app | string | 应用名 |
data.name | string | 定时器名称 |
data.cron | string | cron 表达式 |
data.notifyHTTPParam | struct | http 通知协议参数 |
data.notifyHTTPParam.url | string | 目标 url |
data.notifyHTTPParam.method | string | http 方法:GET/POST/DELETE/PATCH |
data.notifyHTTPParam.header | map[string][]string | http 请求头参数 |
data.notifyHTTPParam.body | string | http 请求体 json 字符串 |
5 讨论项&待优化项
5.1 动态分桶问题
静态分桶不够优雅:
此前 workflow.timer 的实现中,最大分桶数量是集群启动时作为配置参数传入的,一经确认即不可调整; 在 xTimer 的实现中,会通过集群的资源情况以及不同时间范围下定时任务的数量规模,实现对分桶数量的动态扩缩调整.
实现方案:
(1)迁移器模块在将定时任务数据由 mysql 同步到 redis 的过程中,会对一个时间步长的定时任务进行解析落表. 可以在这个过程中统计出时间步范围内的任务数量,根据预先定义的数学模型计算得到一个分桶数量,再以此为基础进行 Redis 缓存数据的建立。
(2)额外建立一张映射表,记录不同分钟级时间范围对应分桶数量的映射数据.
小广告 欢迎老板们关注我的个人公众号:小徐先生的编程世界,会持续更新个人原创的编程技术博客,技术栈以 golang 为主,让我们一起点亮 golang 技能树吧~