Skip to content

WangTimer 定时微服务 已完结

立意

为什么要做这个定时项目

提示

用途:定时更新排行榜、预约时间结束的通知

Java Timer 是串行运行的,任务直接会相互影响,且不支持分布式。

在 OJ 项目和学校项目组之前做的实验室预约系统中,都有定时任务相关的需求。

比如OJ的排行榜,需要定时更新;实验室预约系统中需要在预约结束时发送通知。

Java 自带的 Java Timer 定时任务是串行执行的,任务之间会相互影响,且在分布式环境会造成多次执行。

所以我们做一个通用的模块,所以设计和实现了微服务的定时系统。

有没有调研过相关框架?

提示

Java Timer 是串行运行的,任务直接会相互影响,且不支持分布式。

Rocket MQ 需要维护中间件。

xxl-job 框架与业务耦合高。

  1. Java Timer:串行执行,任务间会相互影响;需要创建新线程,不适合长时间的任务。
  2. Rocket MQ:需要独立维护中间件。
  3. xxl-job:对于简单的任务,维护成本较高;框架与业务耦合较高。

定时微服务是一个定时平台,只负责回调,不关心具体执行结果。通过 API 接口创建任务, 与业务完全解耦。

介绍一下定时微服务项目

定时微服务相当于一个闹钟。可以通过接口创建任务,时间到了之后,会通过回调接口发送请求,叫醒其他的业务。

这个项目使用了 Redis 和 MySQL 数据库。在数据存储的设计上,运用了数据二维分片和数据冷热分区。业务上使用了异步化的思想,实现了高精准和高负载。

整个系统后端的架构设计,有哪些模块以及各模块之间的关系?

对于任务调度,一共有 3 个模块和 2 个线程池。通过职责划分为了调度器模块、触发器模块、执行器模块。模块之前通过线程池的方式异步启动子模块进行工作。

  • 调度器模块负责二维分片(分时 + 分桶)的分配
  • 触发器模块负责遍历当前分片,并唤醒定时任务
  • 执行器模块负责执行定时任务

对于任务生成模块,分为 web 模块和迁移模块。

  • web 模块提供 api 接口用来提交、激活定时任务。
  • 迁移模块会定时批量创建定时任务,保存到 Redis 和 MySQL 中。

你们的任务量多大?算过度设计吗?

我认为不算过度设计。

  1. 定时任务任务量每天大约几百到几千,但是很多任务会聚集在一个时间,造成瞬时的高负载。
  2. 定时服务作为一个通用服务,不能局限于我们使用任务量,应该留有更高的可拓展性

哪里限制了我们的性能呢,是 Redis 还是 MySQL?

MySQL 是性能瓶颈。

任务触发需要对表进行高频扫描,这会对数据库造成较大压力。所以使用冷热分区,将近期需要被触发的任务保存到 Redis。同时将数据分片存储,可以进行更好的支持高频扫描。

项目的设计有什么亮点

  1. 存储结构
    1. Redis + MySQL 二级存储结构,冷热分区。使用迁移模块将即将需要触发的任务放到缓存中。
    2. 将数据分片存储,通过分时 + 分桶进行分治。提高了处理的并发度。
    3. 通过 Redis 的 ZSet 数据结构,能提高检索效率。
  2. 性能优化
    1. 使用了 Redis 缓存和 MySQL 二级存储结构,实现了数据的冷热分区和二维分片。
    2. 使用了线程池技术,提高了并发度
    3. 迁移模块生成任务需要大量写入数据库,项目中使用了 Druid 德鲁伊连接池,并通过压测调整参数,提高了近一倍的性能

介绍

介绍一下定时微服务这个项目

项目功能:定时微服务 WangTimer 的功能可以类比手机上的闹钟进行理解,只是它是一个独立部署的微服务。当业务方有定时需求时,就可以通过接口创建一个定时器(比如创建一个每天早上9点的叫醒服务),并留下回调方式(通过 HTTP 回调接口)。后续具体定时跟进处理全部交给定时服务,定时服务会负责每天早上9点触发定时器,然后通过回调接口通知业务方。

个人职责:主要负责微服务整体设计,存储设计,项目开发,部分压测调优等工作。

难点/亮点:并且项目中用到二维分片,二级存储等设计,运用模块化 + 异步化的实现思路等,有效解决了定时服务高精准和高负载的问题。

微服务整体设计具体是怎么设计的?

  1. 微服务:定时微服务是一个独立的微服务项目,使用了 Nacos、Feign 等组件
  2. Web:定时微服务通过 Web 接口实现任务创建和激活,使用了 SpringGateWay 作为网关层
  3. 存储:使用了 MySQL + Redis 二级存储结构,冷热分区,二维分片等设计,使用了 MySQL、Redis、Mybatis
  4. 业务实现:使用了模块化和异步化的设计思路,主要用了 SpringBoot 和线程池等技术。

存储设计是怎么设计的?

  1. MySQL + Redis 二级存储结构。将定时器记录在 MySQL,基于定时器生成的定时任务存储在 MySQL 和 Redis 中,以供后续触发。
  2. 将存放在 Redis 中的数据进行二维分片,将所有的定时任务按照时间和桶分片存储。触发时一个线程轮循一个分片,通过线程池并发执行。
  3. 使用了 Redis 的 ZSet 结构存储分片,因为每个分片存储一分钟的任务,每个任务是按照每秒排序的。ZSet 结构保证有序性,能够提高查询能力。

只用 MySQL 可以实现吗?

我认为使能行的。但是会受限于 MySQL 的性能。

为了保证触发任务的高精准,需要每秒多线程遍历待触发的定时任务,高频扫描对 MySQL 有较大的压力。

除了 Redis 作为缓存,还有别的方案吗?

除了 Redis 缓存还有本地缓存方案。

优点是没有了 Redis 的网络开销。

缺点是之前使用了 Redis 作为分布式锁争抢执行权,但是不使用 Redis 不方便实现分布式锁,会造成资源浪费;本地缓存没有持久化机制,在宕机时会丢失任务。

介绍一下“数据分片”这种存储方式的作用?

  1. 支持高精准:数据分片之后每个分片的任务会减少,降低查询压力,适用于高频扫描。
  2. 支持高负载:多个分片可以降低每个分片的任务量,降低单个线程的压力。
  3. 支持多级部署:使用 Redis 分片后,通过抢占分布式锁获得执行权。更多分片可以让更多线程一起并行工作。

数据有序性如何实现的

有序性是使用了 Redis 的 ZSet 数据结构,通过触发时间排序。ZSet 底层结构是跳表,能够提高查询访问速度。

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

将热点任务(即将需要被触发的任务)定期加载到 Redis 缓存中,提高存取效率。

将冷数据存储在 MySQL 中,并定期对历史记录进行清理。

这个“模块化+异步化”设计思路是怎么考虑的?

模块化:将一个流程根据不同的功能,分为多个模块。

异步化:不同模块之间调用通过线程池异步进行的,能够实现并发运行。

"高精准"是指的是什么?

设定的触发时间和实际触发时间之间的误差值。

创建、存储、调度

定时任务是如何创建的?

用户通过 api 接口创建定时器,再通过 api 激活定时器。

迁移模块会通过定时器提供的 cron 配置生成一定范围事件的定时任务。后续任务通过迁移模块的定时脚本生成后续任务。

定时任务是如何存储的?

使用了 MySQL + Redis 二级存储结构。

定时器创建的时候会存在 timer 表中,通过迁移模块生成的定时任务存在 task 表中,同时也会存在 Redis 中。

  1. 存入 Redis 的数据进行通过分时和分片进行二维拆分,将任务存到分片中。
  2. 使用 ZSet 数据类型存储每个分片,提高检索效率。
  3. 基于迁移模块实现数据冷热分区,定时生成最近的任务,保存到数据库和 Redis 缓存中。将之前的任务只保存到数据库,未来比较久远的任务基于迁移模块延迟生成。

定时任务是如何调用和触发的?

任务的调度和触发使用到了 3 个模块和 2 个线程池。

  1. 调度模块:通过定时程序,每分钟将当前时间的所有分片放入线程池进行触发,每个线程对应一个分片。
  2. 触发模块:每秒进行一次轮循,将需要被执行的任务放入线程池进行执行,每个线程对应一个需要被执行任务。
  3. 执行模块:执行模块拿到任务,通过回调接口通知调用方。并记录日志。

三个模块之前通过线程池异步调用,保证当前线程不被阻塞。

如果你们调用回调接口延时很大,会导致消息推送有误差的,并且过多 http 连接也会导致资源耗尽的问题,你们是怎么解决的?

高延迟:设置最大超时时间、使用 WebSocket 回调减少连接耗时

资源问题:限制线程池的大小、使用多机部署

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

Worker 崩溃后,会影响所在分片的任务触发。 但是下一分钟会有重试机制,重试调度上一分钟的分片。不能做到对业务方的精准一次回调。

为什么采用 ZSet 数据结构?

  • 为什么不用 MySQL:触发模块需要每秒轮循访问,MySQL 处理高负载高并发能力较差,Redis 更适合高频扫描。
  • 为什么不用 List:定时任务是有序的,通过时间顺序来触发任务,能够提高访问速度。

Worker 如何找到自己要轮询的分片?

Worker 指的是负责单个分片的一个线程

所以线程通过抢占分布式锁的方式获得执行权。

系统支持多级部署触发的线程可以来自于任何一台实例的一个线程。

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

每秒都需要对 Redis 进行轮循,会造成较大的压力。

可以使用本地缓存,将当前分片存到 ArrayList 中在本地遍历。

分布式锁在什么时候加锁解锁?

  • 加锁:通过分布式锁抢占分片触发权,线程对某个分片加锁成功,表示已经拿到了触发权。
  • 解锁:系统没有主动解锁,使用 Key 的过期时间实现解锁。
    • 比如单个分片是 1 分钟。首先加锁时间是略大于 1 分钟。
    • 当分片触发成功之后,将锁的时间延长到略大于 2 分钟,用于重试机制,避免重复调度已经成功的分片。

创建和激活任务可以合并吗?

分开是为了更好的适应应用场景。对于部分任务,可能不需要立即执行,或选择性关闭。就不用重复创建删除任务了,可以通过是否激活来实现。

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

这里使用了主键自增 id,主要是为了设计方便。

后续可以优化成分布式 id,比如 UUID、雪花算法等。能够更好地支持高并发和分库分表。

服务支持水平扩容吗?

水平扩容:使用更多的机器提高性能。

一个分片只能被一个线程执行,分片是通过分时和分桶生成的。 所以可以根据部署实例的个数,修改分桶的桶数即可。

考虑过其他的回调方式吗?

提示

消息队列、WebSocket

  1. 可以通过消息队列回调,作为生产者投递消息,业务方作为消费者。但是需要消息队列的依赖,而且会造成网络消耗。
  2. 使用 WebSocket 进行回调,ws 避免了回调时建立 TCP 连接的延时。但是有着维护大量连接的压力,并且业务方也需要 ws 的接入

难点问题

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

我认为是如何利用到线程池进行并行优化,因为发送 HTTP 请求需要较长的时间,并行能够大幅提高效率。通过参考其他开源项目的思路,选用了分时分桶的二维分片,提高了项目的并发度和准确度。

高精准是什么?是怎么解决的?

高精准:任务触发时间精准

  1. 高频扫描(每秒 1 次):基于 ZSet 结构和二维分片,使用多线程高频扫描。单个分片遍历耗时不会很长,能应对高频扫描,单个任务误差不会很高。
  2. 并发执行:如果同一时间有大量任务需要执行。若串行运行,前面的延迟会影响后面的任务执行。项目中使用了线程池并行执行,提高了触发的精准度。

高负载是什么?是怎么解决的?

高负载:多个定时任务下能够稳定运行

主要是利用了任务的二维分片和线程池,解决高负载。

通过分时和分桶的策略将定时任务分片,和线程池进行并发处理,提高了执行效率。

Java线程池对高精准具体有什么作用?

高精准是指预定触发事件与实际出发时间误差较小。

比如在同一时间需要处理的任务量很大,不使用线程池就需要串行执行,形成任务排队状态,对于后面的任务延时就会很高。使用了线程池就可以让任务并行运行,提高整体的精准度。

简历上写的最大误差 1s,这里是什么误差?这个误差是怎么计算的?

误差是触发时间与预计事件的误差,不计算 HTTP 回调的耗时。HTTP 回调受限于网络和业务方的性能。

如果需要计算精准的误差,需要业务方来计算出 HTTP 耗时。

基于Redis ZSET对数据进行分片是怎么做的?

分片逻辑:从时间维度和桶维度进行了二维划分。

ZSet 实现逻辑:单个分片使用 ZSet 结构,分片的 key 为 "分钟 + 桶号",value 为 "timerID + 触发的时间戳",Score 为触发的时间戳

ZSet 基于跳表数据结构,具有天然的有序性,可以按照时间戳查询。同时具有 value 的唯一性,可以保证同一个定时器在同一个时间只有一个任务,避免重复执行。

Java 线程池对高负载有什么作用?

提示

提高并行度、减少线程创建和销毁的开销

高负载问题的核心是同一时间有大量任务需要执行,线程池可以提高任务执行的并发度,加快任务执行速度。

同时线程池还有线程管理的能力,能够并行执行,同时减少了线程创建和销毁的开销,避免线程泛滥拖垮系统的问题。

迁移模块的设计怎么做的?

迁移模块主要负责定期生成定时任务的工作,定时器创建后不会立即生成所以任务,这个任务是无限的,所以使用迁移模块定期生成最近两小时的任务。

迁移模块是周期运行的,生成最近两小时的任务,存入 MySQL 和 Redis 中。

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

定时任务具有冷热特性,越靠近执行时间的任务越热,需要重点关注。历史任务或者距离触发时间久远的任务就是冷数据,短期不需要关注。

通过迁移模块定期生成最近两小时的任务,实现生成热任务,延迟冷任务,并对已经执行过的历史任务定期删除。

其他

对于异常是如何处理的

  1. 对于执行失败的任务会有重试机制,并错误信息记录在数据库中
  2. 使用兜底方案,使用定时脚本每五分钟将没被执行的定时任务触发重试执行。

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

采取的是经验值,没有确切要求。可以考虑异常出现的频率和业务方的要求更改,更快的执行频率会消耗更多的资源。

Released under the MIT License.