定时微服务

定时微服务

前置准备

背景和现状

市面上已有的定时微服务技术:Java Timer、Rocket MQ、xxl-job、Quartz、Robfig/Cron

  • 很多公司的业务只需要一个简单的“定时”功能。为了满足一个简单定时功能而引入过于强大而且臃肿的任务调度组件是不合适的
  • 设计一个功能聚焦、轻量级、维护成本低的定时微服务组件是很有必要的

定时微服务LyyTimer

  1. 依赖简单:只需要提供MySQL、Redis和Kafka组件的支持即可,接入和维护的成本低
  2. 学习成本低:整个定时微服务功能聚焦,容易上手
  3. 特性优:LyyTimer具有高精准、高负载、异常处理等特性,能基本满足大部分的定时需求

难点

高精准

相对于单点定时(例如Java.Timer)基本能做到毫秒级触发,几乎所有定时任务框架或者消息队列,都只能保证秒级别误差

常见导致时间误差大的原因:

  • 定时脚本运行耗时
  • 等待定时脚本运行
  • 消息队列中消息堆积
  • 分布式定时器延时受网络影响
  • 数据库中查找任务数据耗时
  • 特殊时间段任务过多

高负载

能够支持同时处理大量任务,并且尽可能少的堆积任务

高负载解决思路和方法:

  • 应用层方面减少任务量,选择将同一时刻的多个独立任务合并成一个“计划列表”
  • 分库分表:将一个大型数据库分成多个较小的数据库,并将每个数据库进一步分成多个较小的表,每个表只包含部分数据
    • 垂直分表:保持列完整
    • 水平分表:只保留部分列
    • 分库分表组合
  • 数据分区:将大数据拆分成多个小数据分区,将这些小分区存储在不同的物理设备或服务器上,提供数据的存取效率和处理速度
  • 线程池技术
    • 复用线程,降低了资源消耗
    • 高效地管理多个线程

任务异常处理

定时任务需求可以允许有一定的误差,但是不能不触发

任务异常的原因:

  • 创建(激活)任务接口异常:在创建任务阶段发生异常,系统可以直接将异常原因返回给用户。用户感知到异常后,可通过主动重试解决问题,即该阶段的异常不应该由框架解决

  • 任务流转异常:一旦任务成功存储在数据库中,就认为该定时任务创建成功,微服务框架就要负责该任务后续的流转(查询数据库、发送消息队列、缓存等)。因此,框架需要解决任务流转过程中发生的异常

  • 任务触发阶段异常:当任务执行后,系统需要回调告知业务方任务结果。对于业务方来说,如果回调失败导致结果丢失,就相当于该任务没有执行过

常用的解决方案

  • (立即)重试机制:可以应付一些偶发性异常,例如数据库抖动、Redis抖动
  • 失败兜底机制:一般来说,兜底策略都是采用一些“无脑”脚本,全表扫描数据库,针对失败记录进行重试操作。但是兜底策略一般在性能上不占优势,所以需要选择合适的时间间隔执行兜底脚本
  • 失败报警机制:当短时间内有大量任务失败时,需要及时报警
  • 人工干预

架构设计

实现思路

定时器本质实现思路:查询 + 触发,以及暴露给用户一个注册任务接口

  • 注册定时器:接受用户的创建请求,明确每一个任务的执行时间
  • 查询数据库:每隔一段时间间隔就查询任务列表,筛选出未触发的任务
  • 触发任务:触发执行时间小于当前时间(时刻)的任务

存储结构优化:有序表

基于有序数据结构,加快数据查询

常见的有序数据结构:红黑树、跳表、平衡树等,这些数据结构通过在插入操作均摊查询操作的时间复杂度实现**查询时间复杂度$$O(N) —> O(logN)$$**的优化

存储结构优化:横向切片

根据时间区间对数据分片,减少查询时涉及的数据数量

每次查询数据的时候,真正的目标是即将执行的任务,而稍后执行的任务属于无关任务。基于这个理念,我们可以将数据进行横向切分,例如每一分钟划分一个数据切片,然后从包含目标数据的数据切片中查询数据

这种做法可以将大大降低每一次查询的任务数量级:N —> N’,其中N’ << N,即使没有改变查询的时间复杂度,但减少了查询时涉及的数据数量

存储结构优化:纵向切片

对数据分区进行分桶,提高数据操作的并发度

截止到上一步,时间数据分区是定时器中的最小资源单位,每一个数据分区都由一个goroutine(或线程)控制。为了提高数据操作的并发度,可以对分区中的数据进一步分桶,把每一个桶交付给一个goroutine(或线程)处理,在纵向增加分桶的维度,提高并发度

总结:依然是“查询 + 触发”的定时器机制,只不过相较于丐版定时器有如下变化:每次查询都只查询一个(当前)时间范围内的数据,这些数据由ZSet数据结构存储。接着基于并发的考虑,令多个goroutine负责从分区中的多个桶中查询数据,最后将触发获取到的任务。

整体架构

定时任务调度流程

任务调度流程服务架构:三个模块 + 两个协程池

LyyTimer是一个去中心化的定时任务调度框架,定时任务调度服务根据职责边界可以将服务拆分为:调度器模块触发器模块执行模块,这三个模块存在着依赖关系。父模块通过协程池异步启动子模块执行相应工作

image-20241125183915443

定时任务生成流程

定时任务的创建设计到Web Server和Migrator Scheduler两个模块

  • 用户通过Web Server提供的API创建定时任务
  • Migrator Scheduler定时根据cron表达式预热定时任务数据

image-20241125185145008

为什么需要两种方式预热数据?

为了确保在定时脚本执行间隔内创建的任务生效。例如,定时脚本每两小时执行一次,每一次执行都会预热两小时后的数据。第一次定时脚本执行时刻是18:58,创建了18:58-20:58定时任务。倘若有用户在19:00想要创建一个19:58分的定时任务,那么只依靠定时脚本是无法实现的,因为下一次定时脚本的执行时间是20:58。综上所诉,仅仅有定时脚本是有缺陷的,还需要一个Web Server模块完成任务的第一次创建,确保用户在任何时候创建的任务都能生效。

调度流程模块化

调度器模块

确定二维数据分区目标对象,统筹分配触发器

调度器的作用就是要确保在分布式场景下,每一个二维数据分区都能被一个触发器负责处理,既不能遗漏,也不能重复

  • 调度器基于time ticker每秒钟进行一次工作,根据当前时刻推算出分钟级别的字符串表达式
  • 根据配置中最大桶数量,依次拼接分钟Key值得到若干个二维数据分区的Key值。例如20:47:39_020:48:11_1
  • 尝试抢占数据分区的分布式锁,并把锁的过期时间设置为大于1倍分片时间,小于2倍分片时间
  • 如果抢锁成功,就调度一个触发器进行作业

触发器模块

按时唤醒二维数据分区中的定时任务

  • 触发器被创建后,每秒对负责的数据分区进行扫描,以当前时刻为基准,将符合条件的定时任务取出并交付给执行器执行
  • 当触发器协程完成任务后,需要将分布式锁的过期时间延期至大于两个分区时间

为什么要对分布式锁进行延期?

回答这个问题之前,需要补充的一点:为了对前一分钟的数据分区执行失败做兜底,每次触发器对当前分区进行扫描时都会对前一分钟的数据分区进行重试扫描。

因此,如果前一个数据分区的任务执行失败,那么该分区的分布式锁无法得到延时,必然会在当前分钟内再次获得进而进行重试操作。反之,分布式锁得到了延时,那么当前分钟内就不会重复执行已成功执行的任务。

也正是出于这个机制考虑,延迟的时间必须大于两个分区时间

问题在于很难实现百分百的分布式事务,即无法保证完成分区任务 + 延迟分布式锁操作是原子的,因此这里只能是At least once语义。

执行器模块

执行定时任务

  • 一个执行器由触发器调度,对应一个具体的定时任务
  • 执行器首先对定时任务进行幂等检查
  • 执行任务
  • 更新定时任务的状态

任务迁移模块化

LyyTimer项目使用MySQL + Redis二级存储模型,并设置了一个Migrator根据时间由近及远通过处于激活状态的定时器(cron表达式)生成一系列定时任务,并迁移同步至两个存储介质中

Migrator模块

  • 每隔一段时间步长就扫描所有的定时器,根据处于激活状态的定时器生成未来两个时间步长的定时任务(本质上就一个时间步长,都是为了失败兜底😂)
    • 例如,每隔一小时就进行一次全表扫描,若14:45:34对所有的定时器进行一次扫描,迁移模块就会生成14:45:34 - 16:45:34这两个小时的定时任务并迁移至MySQL和Redis中。但14:45:34 - 15:45:34这个时间段的任务已经在13:45:34被Migrator模块生成并存储了,所以本质上迁移模块只生成了一小时的任务。重复的任务将会由于唯一约束而插入失败,所以不必担心任务重复插入
串行打点还是批量打点?

串行打点就是指每次根据cron表达式只生成最近的一次定时任务,然后在执行器执行完这个任务后就立马生成下一次的定时任务,如此循环。

串行打点比较节约资源,可以缓解生成任务过多导致的短时间内资源浪费问题,但是针对于短间隔高频执行的定时器(例如:1s执行一次)就有些力不从心了。

优化方案:批量打点

每隔一段时间程序就根据cron表达式生成一个时间步长的任务,这样可以有效解决短间隔高频率的任务生成问题。但如果用户对定时器进行频繁的修改,那为了保证数据一致性,就需要引入删除、修改已生成任务等复杂操作。为了规避这种情况,我们可以要求用户无法对已有的定时器进行修改,只允许激活去激活两种操作

此外,即使用户去激活某一个定时器,我们也无需修改已有的任务,只需要在回调之前检查定时器状态是否合法即可。

存储结构设计

定时器表

字段 类型 说明
timer_id bigint 自增主键ID(或有序UUID)
ctime bigint 创建时间(避免时区问题)
utime bigint 修改时间
app varchar(128) 业务方标识
name varchar(256) 任务名
status tinyint 任务状态:0新建、1激活、2未激活
cron varchar(256) cron表达式,用于生成定时任务
notify_http_param varchar(8192) 回调上下文

定时任务表

字段 类型 说明
task_id bigint 自增主键ID
ctime bigint 创建时间
utime bigint 修改时间
timer_id bigint 对应的定时器ID
app varchar(128) 业务方标识
output var(1024) 执行结果
status tinyint 任务状态:0待执行、1成功、2失败
run_timer bigint 开始运行时间
cost_time bigint 误差时间

单个分片结构

Key:时间 + bucket编号 = “2024121115:54:51_01”

Member:timerId + unix当前时间戳

Score:unix当前时间戳

一个可能的优化点:

在任务状态上建立索引 —–> 存在数据倾斜