主题
WangTimer 定时微服务 已完结
立意
为什么要做这个定时项目
提示
用途:定时更新排行榜、预约时间结束的通知
Java Timer 是串行运行的,任务直接会相互影响,且不支持分布式。
在 OJ 项目和学校项目组之前做的实验室预约系统中,都有定时任务相关的需求。
比如OJ的排行榜,需要定时更新;实验室预约系统中需要在预约结束时发送通知。
Java 自带的 Java Timer 定时任务是串行执行的,任务之间会相互影响,且在分布式环境会造成多次执行。
所以我们做一个通用的模块,所以设计和实现了微服务的定时系统。
有没有调研过相关框架?
提示
Java Timer 是串行运行的,任务直接会相互影响,且不支持分布式。
Rocket MQ 需要维护中间件。
xxl-job 框架与业务耦合高。
- Java Timer:串行执行,任务间会相互影响;需要创建新线程,不适合长时间的任务。
- Rocket MQ:需要独立维护中间件。
- xxl-job:对于简单的任务,维护成本较高;框架与业务耦合较高。
定时微服务是一个定时平台,只负责回调,不关心具体执行结果。通过 API 接口创建任务, 与业务完全解耦。
介绍一下定时微服务项目
定时微服务相当于一个闹钟。可以通过接口创建任务,时间到了之后,会通过回调接口发送请求,叫醒其他的业务。
这个项目使用了 Redis 和 MySQL 数据库。在数据存储的设计上,运用了数据二维分片和数据冷热分区。业务上使用了异步化的思想,实现了高精准和高负载。
整个系统后端的架构设计,有哪些模块以及各模块之间的关系?
对于任务调度,一共有 3 个模块和 2 个线程池。通过职责划分为了调度器模块、触发器模块、执行器模块。模块之前通过线程池的方式异步启动子模块进行工作。
- 调度器模块负责二维分片(分时 + 分桶)的分配
- 触发器模块负责遍历当前分片,并唤醒定时任务
- 执行器模块负责执行定时任务
对于任务生成模块,分为 web 模块和迁移模块。
- web 模块提供 api 接口用来提交、激活定时任务。
- 迁移模块会定时批量创建定时任务,保存到 Redis 和 MySQL 中。
你们的任务量多大?算过度设计吗?
我认为不算过度设计。
- 定时任务任务量每天大约几百到几千,但是很多任务会聚集在一个时间,造成瞬时的高负载。
- 定时服务作为一个通用服务,不能局限于我们使用任务量,应该留有更高的可拓展性
哪里限制了我们的性能呢,是 Redis 还是 MySQL?
MySQL 是性能瓶颈。
任务触发需要对表进行高频扫描,这会对数据库造成较大压力。所以使用冷热分区,将近期需要被触发的任务保存到 Redis。同时将数据分片存储,可以进行更好的支持高频扫描。
项目的设计有什么亮点
- 存储结构
- Redis + MySQL 二级存储结构,冷热分区。使用迁移模块将即将需要触发的任务放到缓存中。
- 将数据分片存储,通过分时 + 分桶进行分治。提高了处理的并发度。
- 通过 Redis 的 ZSet 数据结构,能提高检索效率。
- 性能优化
- 使用了 Redis 缓存和 MySQL 二级存储结构,实现了数据的冷热分区和二维分片。
- 使用了线程池技术,提高了并发度
- 迁移模块生成任务需要大量写入数据库,项目中使用了 Druid 德鲁伊连接池,并通过压测调整参数,提高了近一倍的性能
介绍
介绍一下定时微服务这个项目
项目功能:定时微服务 WangTimer 的功能可以类比手机上的闹钟进行理解,只是它是一个独立部署的微服务。当业务方有定时需求时,就可以通过接口创建一个定时器(比如创建一个每天早上9点的叫醒服务),并留下回调方式(通过 HTTP 回调接口)。后续具体定时跟进处理全部交给定时服务,定时服务会负责每天早上9点触发定时器,然后通过回调接口通知业务方。
个人职责:主要负责微服务整体设计,存储设计,项目开发,部分压测调优等工作。
难点/亮点:并且项目中用到二维分片,二级存储等设计,运用模块化 + 异步化的实现思路等,有效解决了定时服务高精准和高负载的问题。
微服务整体设计具体是怎么设计的?
- 微服务:定时微服务是一个独立的微服务项目,使用了 Nacos、Feign 等组件
- Web:定时微服务通过 Web 接口实现任务创建和激活,使用了 SpringGateWay 作为网关层
- 存储:使用了 MySQL + Redis 二级存储结构,冷热分区,二维分片等设计,使用了 MySQL、Redis、Mybatis
- 业务实现:使用了模块化和异步化的设计思路,主要用了 SpringBoot 和线程池等技术。
存储设计是怎么设计的?
- MySQL + Redis 二级存储结构。将定时器记录在 MySQL,基于定时器生成的定时任务存储在 MySQL 和 Redis 中,以供后续触发。
- 将存放在 Redis 中的数据进行二维分片,将所有的定时任务按照时间和桶分片存储。触发时一个线程轮循一个分片,通过线程池并发执行。
- 使用了 Redis 的 ZSet 结构存储分片,因为每个分片存储一分钟的任务,每个任务是按照每秒排序的。ZSet 结构保证有序性,能够提高查询能力。
只用 MySQL 可以实现吗?
我认为使能行的。但是会受限于 MySQL 的性能。
为了保证触发任务的高精准,需要每秒多线程遍历待触发的定时任务,高频扫描对 MySQL 有较大的压力。
除了 Redis 作为缓存,还有别的方案吗?
除了 Redis 缓存还有本地缓存方案。
优点是没有了 Redis 的网络开销。
缺点是之前使用了 Redis 作为分布式锁争抢执行权,但是不使用 Redis 不方便实现分布式锁,会造成资源浪费;本地缓存没有持久化机制,在宕机时会丢失任务。
介绍一下“数据分片”这种存储方式的作用?
- 支持高精准:数据分片之后每个分片的任务会减少,降低查询压力,适用于高频扫描。
- 支持高负载:多个分片可以降低每个分片的任务量,降低单个线程的压力。
- 支持多级部署:使用 Redis 分片后,通过抢占分布式锁获得执行权。更多分片可以让更多线程一起并行工作。
数据有序性如何实现的
有序性是使用了 Redis 的 ZSet 数据结构,通过触发时间排序。ZSet 底层结构是跳表,能够提高查询访问速度。
介绍一下“数据冷热分区”是怎么做的?
将热点任务(即将需要被触发的任务)定期加载到 Redis 缓存中,提高存取效率。
将冷数据存储在 MySQL 中,并定期对历史记录进行清理。
这个“模块化+异步化”设计思路是怎么考虑的?
模块化:将一个流程根据不同的功能,分为多个模块。
异步化:不同模块之间调用通过线程池异步进行的,能够实现并发运行。
"高精准"是指的是什么?
设定的触发时间和实际触发时间之间的误差值。
创建、存储、调度
定时任务是如何创建的?
用户通过 api 接口创建定时器,再通过 api 激活定时器。
迁移模块会通过定时器提供的 cron 配置生成一定范围事件的定时任务。后续任务通过迁移模块的定时脚本生成后续任务。
定时任务是如何存储的?
使用了 MySQL + Redis 二级存储结构。
定时器创建的时候会存在 timer 表中,通过迁移模块生成的定时任务存在 task 表中,同时也会存在 Redis 中。
- 存入 Redis 的数据进行通过分时和分片进行二维拆分,将任务存到分片中。
- 使用 ZSet 数据类型存储每个分片,提高检索效率。
- 基于迁移模块实现数据冷热分区,定时生成最近的任务,保存到数据库和 Redis 缓存中。将之前的任务只保存到数据库,未来比较久远的任务基于迁移模块延迟生成。
定时任务是如何调用和触发的?
任务的调度和触发使用到了 3 个模块和 2 个线程池。
- 调度模块:通过定时程序,每分钟将当前时间的所有分片放入线程池进行触发,每个线程对应一个分片。
- 触发模块:每秒进行一次轮循,将需要被执行的任务放入线程池进行执行,每个线程对应一个需要被执行任务。
- 执行模块:执行模块拿到任务,通过回调接口通知调用方。并记录日志。
三个模块之前通过线程池异步调用,保证当前线程不被阻塞。
如果你们调用回调接口延时很大,会导致消息推送有误差的,并且过多 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
- 可以通过消息队列回调,作为生产者投递消息,业务方作为消费者。但是需要消息队列的依赖,而且会造成网络消耗。
- 使用 WebSocket 进行回调,ws 避免了回调时建立 TCP 连接的延时。但是有着维护大量连接的压力,并且业务方也需要 ws 的接入
难点问题
项目遇到最大难点是什么?你是怎么解决的?
我认为是如何利用到线程池进行并行优化,因为发送 HTTP 请求需要较长的时间,并行能够大幅提高效率。通过参考其他开源项目的思路,选用了分时分桶的二维分片,提高了项目的并发度和准确度。
高精准是什么?是怎么解决的?
高精准:任务触发时间精准
- 高频扫描(每秒 1 次):基于 ZSet 结构和二维分片,使用多线程高频扫描。单个分片遍历耗时不会很长,能应对高频扫描,单个任务误差不会很高。
- 并发执行:如果同一时间有大量任务需要执行。若串行运行,前面的延迟会影响后面的任务执行。项目中使用了线程池并行执行,提高了触发的精准度。
高负载是什么?是怎么解决的?
高负载:多个定时任务下能够稳定运行
主要是利用了任务的二维分片和线程池,解决高负载。
通过分时和分桶的策略将定时任务分片,和线程池进行并发处理,提高了执行效率。
Java线程池对高精准具体有什么作用?
高精准是指预定触发事件与实际出发时间误差较小。
比如在同一时间需要处理的任务量很大,不使用线程池就需要串行执行,形成任务排队状态,对于后面的任务延时就会很高。使用了线程池就可以让任务并行运行,提高整体的精准度。
简历上写的最大误差 1s,这里是什么误差?这个误差是怎么计算的?
误差是触发时间与预计事件的误差,不计算 HTTP 回调的耗时。HTTP 回调受限于网络和业务方的性能。
如果需要计算精准的误差,需要业务方来计算出 HTTP 耗时。
基于Redis ZSET对数据进行分片是怎么做的?
分片逻辑:从时间维度和桶维度进行了二维划分。
ZSet 实现逻辑:单个分片使用 ZSet 结构,分片的 key 为 "分钟 + 桶号",value 为 "timerID + 触发的时间戳",Score 为触发的时间戳
ZSet 基于跳表数据结构,具有天然的有序性,可以按照时间戳查询。同时具有 value 的唯一性,可以保证同一个定时器在同一个时间只有一个任务,避免重复执行。
Java 线程池对高负载有什么作用?
提示
提高并行度、减少线程创建和销毁的开销
高负载问题的核心是同一时间有大量任务需要执行,线程池可以提高任务执行的并发度,加快任务执行速度。
同时线程池还有线程管理的能力,能够并行执行,同时减少了线程创建和销毁的开销,避免线程泛滥拖垮系统的问题。
迁移模块的设计怎么做的?
迁移模块主要负责定期生成定时任务的工作,定时器创建后不会立即生成所以任务,这个任务是无限的,所以使用迁移模块定期生成最近两小时的任务。
迁移模块是周期运行的,生成最近两小时的任务,存入 MySQL 和 Redis 中。
"迁移模块migrator"对数据冷热分区的作用是什么?
定时任务具有冷热特性,越靠近执行时间的任务越热,需要重点关注。历史任务或者距离触发时间久远的任务就是冷数据,短期不需要关注。
通过迁移模块定期生成最近两小时的任务,实现生成热任务,延迟冷任务,并对已经执行过的历史任务定期删除。
其他
对于异常是如何处理的
- 对于执行失败的任务会有重试机制,并错误信息记录在数据库中
- 使用兜底方案,使用定时脚本每五分钟将没被执行的定时任务触发重试执行。
兜底脚本 5 分钟轮询这个时间怎么定的?
采取的是经验值,没有确切要求。可以考虑异常出现的频率和业务方的要求更改,更快的执行频率会消耗更多的资源。