ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

从零搭建分布式任务调度平台:架构设计与实践总结

从零搭建分布式任务调度平台:架构设计与实践总结 项目代号ax对外说的“ax调度”其实就是我带着几个后端同学从零搭出来的一套分布式任务调度平台。折腾了几个月踩了不少坑也沉淀了不少可以复用的经验。如果你正好在做定时任务、批量处理或者工作流编排这篇文章值得先码后看。1. ax调度到底解决什么问题1.1 从crontab到调度平台我们经历了什么最早的时候团队里的定时任务基本都是crontab 一堆shell脚本散落在不同机器上。每个业务线自己维护一套谁改谁心里有数但出了问题谁都脱不了干系。典型的几个场景数据同步任务挂在某台跳板机上机器重启任务就丢没人发现。凌晨跑批的脚本互相抢资源谁先谁后靠人工改cron表达式协调。任务挂了只能靠同事“早上进群艾特我”来发现日志还要去机器上一行行翻。想临时跑一次历史数据得手动改脚本参数跑完再改回来。这些场景堆到一定规模后靠人工已经撑不住了。我们统计了一下高峰期光定时脚本就有两百多个每天人工盯着看都看不完更别提排查历史执行记录和做数据回溯。所以当项目立项时我们把“调度”当作一个独立系统来做代号就叫ax。ax解决的并不是“怎么写任务”而是“任务怎么被管理、被触发、被执行、被追踪”。按我的理解它把原来散落在脚本里的调度逻辑全部收拢到一个平台让任务从提交、触发、执行、重试到告警的整个生命周期都在掌控之中。1.2 核心场景与边界ax调度能覆盖的场景包括定时执行类每天凌晨跑数据统计每小时刷缓存每周生成报表等对应cron触发。延时触发类订单支付后15分钟未支付自动关单用户注册后延时发送欢迎短信等对应延时队列。依赖编排类A任务跑完才能跑B任务B和C并行跑完再跑D对应DAG工作流。手动补跑类数据修复时需要把之前失败的历史任务重新执行对应手动触发与重跑。边界也很清楚ax不负责业务逻辑业务流程是任务自身的代码ax也不做分布式计算它只负责把任务“正确地、按顺序地、不重不漏地”调度起来。想明白这条边界后面做架构设计时就不会给自己挖坑。2. 整体架构拆解调度中心如何撑起分布式任务2.1 三大核心模块ax整体拆成三块调度中心、执行器、管理端。调度中心是大脑负责解析触发规则、生成调度指令、分发任务、接收心跳与执行结果。执行器是手脚部署在业务机器上收到调度指令后创建任务线程执行执行过程中上报日志和状态。管理端是脸面提供Web页面让用户创建任务、查看执行记录、配置告警。模块划分的原则是“调度与执行分离”。如果调度和执行都在一台机器上任务一多机器就扛不住且无法做到高可用。分离之后调度中心可以独立横向扩展执行器也可以按业务线分批部署互不干扰。2.2 调度中心的选型逻辑为什么用时间轮调度引擎最核心的数据结构我们对比过最小堆和定时扫描两种方案。最小堆按触发时间排序每次取堆顶即可复杂度O(1)取任务O(logN)插入效率高但实现难度大且处理“取消任务”时需要遍历定位。定时扫描则简单粗暴每秒钟扫一遍所有任务把到期任务拿出来跑但任务量一大这每秒一次的全表扫描既是CPU杀手也扛不住毫秒级触发需求。我们最终选了时间轮方案。时间轮本质是一个环形数组每个槽位挂一个任务链表。指针按固定间隔跳动跳动到哪个槽位就把槽位上的所有任务取出来执行。我举个例子方便理解设定槽位数为60每个槽位代表1秒那么指针转一圈就是1分钟。一个任务5秒后触发就挂在第5个槽位1分半后触发就挂在第2圈的某个槽位并记录剩余圈数。查询和插入都是O(1)非常适合延迟一秒级的定时调度。当然时间轮也不是万能的它的短板在于不太方便实现“每天凌晨3点执行”这类绝对时间cron。我们的做法是双重判断外层用cron表达式生成下一次触发时间内层把“当前时间到下次触发时间”换算为相对延迟塞进时间轮。这样既保留了cron的灵活性又享受了时间轮的效率。2.3 为什么坚持用数据库存任务而不是纯内存第一批版本我们偷懒任务注册信息全放在内存Map里结果一升级重启全丢还得手动重新注册。后来改成MySQL持久化元数据Redis只做缓存和分布式锁才彻底解决这个问题。任务表里记录了任务ID、名称、cron表达式、执行器定位信息、超时时间、重试策略、创建人、启用状态等。每次调度指令生成前先查库拿任务元数据再交给执行器去处理。数据库也就是任务事实的唯一来源。这里有个小经验调度记录和任务元数据可以放一张表但执行日志一定要单独放。任务元数据是“现在该怎么办”执行日志是“当时发生了什么”。两者生命周期完全不同日志量级大、查询频率高混合在一起会让主表的索引和写入性能快速恶化。3. 实操落地从零部署一套ax调度平台3.1 环境准备与依赖清单部署ax前先把依赖列清楚。我们用的是经典组合JDK 1.8、MySQL 5.7、Redis 5.0构建工具Maven部署方式推荐Docker Compose便于快速拉起一套测试环境。依赖项清单如下组件版本用途JDK1.8运行调度中心与执行器MySQL5.7存储任务元数据与执行记录Redis5.0分布式锁、缓存调度状态Nginx1.18反向代理管理端也可直接暴露端口如果只是本地体验MySQL和Redis可以用官方镜像跑起来。生产环境建议MySQL主从、Redis哨兵调度中心至少部署两个节点这是高可用的底线。3.2 配置要点调度中心调度中心的核心配置文件是application.yml我把关键项和对应的坑一起说明。server: port: 8080 spring: datasource: url: jdbc:mysql://127.0.0.1:3306/ax_scheduler?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: ax_user password: ax_pass hikari: maximum-pool-size: 32 minimum-idle: 8 connection-timeout: 30000 ax: scheduler: # 调度线程池大小建议为CPU核心数的2倍 thread-pool-size: 16 # 触发精度单位秒时间轮每个槽位的时间跨度 tick-size: 1 # 任务结果回调超时时间单位秒 callback-timeout: 30 # 执行器心跳超时时间单位秒超过则标记为离线 heartbeat-timeout: 60 alarm: webhook: https://your-alarm-webhook.example.com有一点必须提醒HikariCP的maximum-pool-size不要盲目调大。很多同学觉得数据库连接池越大越好其实连接数一旦超过数据库本身能承受的并发排队耗时反而会拖垮任务回调。我们的32路连接足够支撑几千个调度任务实际数据库负载一直很低。3.3 创建任务的三种触发方式第一次用ax的人最容易把“定时任务的写法”和“调度任务的配置方式”搞混淆。在ax里你不需要在业务代码里写while循环加sleep只需要在管理端配置一条任务记录。cron触发是最常见的用法。界面里选择cron类型填上表达式例如0 30 2 * * ?表示每天凌晨2点30分触发。要注意的是ax的cron是基于Quartz语法星期和月之类的字段规则与Linux crontab不同。比如Linux里周日是0Quartz里则是1写错一个数字就是整个任务的调度偏移这种问题排查起来特别费劲。延时触发适用于延迟任务场景。配置时填延迟秒数调度中心会在指定时间后下发执行指令。这个能力替换掉了我们原来自己维护的Redis延迟队列省了好几次事故。依赖触发则是通过任务组实现。我们在管理端新建任务时可以勾选上游任务ID只有上游全部执行成功下游任务才进入待调度状态。比如同步订单数据之后才做汇总统计就用这种方式编排。3.4 参数与资源估算线程池、超时、重试初次部署时最容易犯的错是线程池和超时时间全凭感觉填。我给出我们测下来的参考逻辑线程池大小与任务类型强相关。如果是IO密集型比如调用远程接口、读写数据库线程可以多一些公式为CPU核心数 * 2 1如果是CPU密集型比如大量计算、压缩解压则建议CPU核心数 1。我们机器是8核IO密集任务居多所以就设了16。超时时间按任务执行时长的P95来定而不是P999。取P95是为了覆盖绝大多数正常情况如果直接按最大耗时设置任务挂死时会拖很久才能被判定失败告警不实时。我们有个数据同步任务平时2秒跑完偶尔网络抖动跑15秒超时设置的是30秒。真挂死时30秒后能收到告警刚好来得及人工介入。重试策略默认是失败后重试2次每次间隔3秒。这个场景多数用于网络抖动导致的临时失败。但重试必须配合幂等否则任务执行到一半失败重试时又会重复插入数据。我们内部在SDK层做了幂等检查以任务实例ID为幂等键这样即使同一任务重跑多次也不会产生脏数据。4. 调度系统的十大常见问题与排查实录这里分享一下我们上线以来持续处理的几类高频问题很多都是数据库和线程层面的细节不深入排查根本看不到。4.1 任务不执行或延迟执行现象任务配置没问题时间到了却不执行或者比预期时间晚了几分钟。排查思路按照先看调度日志、再看执行器日志、最后看数据库的顺序。如果调度中心日志里根本没有生成调度指令说明问题出在调度引擎本身。常见原因有两个一是调度线程池被打满时间轮指针在跳动但线程全被长时间运行的任务占用新任务只能排队。这种情况要把调度任务和执行任务的线程池彻底分开调度中心的线程只负责分发指令不负责执行业务代码。二是任务被管理员误操作禁用或者执行器被标记为离线。有一个很容易被忽略的点执行器时间与调度中心时间不一致。比如调度中心按当前时间比较cron的触发点如果执行器时钟慢了5分钟日志上看就会延迟执行。我们在所有机器上统一配置NTP同步并监控各机器的时间偏移这是花钱最少但收益很稳的一步。4.2 同一任务重复执行现象任务明明只配置了一次执行记录里却出现了两条且执行时间非常接近。根源多半出在调度中心集群节点同时抢到同一个任务上。在没有分布式锁的情况下两个节点同时扫描数据库都判断当前时间大于等于触发时间于是各自发出调度指令任务就执行了两次。解决方案是在Redis里加一个分布式锁锁的key是任务ID加触发时间value是机器标识加锁成功的节点才能下发指令。注意锁的过期时间必须大于调度指令下发到执行器的整个链路耗时否则锁提前过期另一个节点又会抢进来。我们设定锁过期时间为30秒实际链路耗时一般几百毫秒余量足够。另外补一个细节执行器收到指令后执行前也要做幂等判断以任务实例ID为核心判断当前实例是否已经执行过。双重判断可以把风险压到最低。4.3 任务堆积与队列阻塞现象某个上游任务卡住下游积压了一堆任务整个链路持续拥堵。这里容易犯的错是只看到“任务积压”这个表象拼命加大线程池结果线程越来越多数据库连接先被抢光整个系统雪崩。正确的做法是梳理任务依赖和执行顺序给每个执行器配置独立的队列容量超出的部分直接拒绝并告警而不是无限排队。比如一个执行器的队列容量设为线程池大小的5倍数据超出这个量说明系统已经过载需要人工介入而不是让任务继续堆积。另外在DAG编排里设置阻塞策略上游任务失败下游任务可以选择跳过、等待或者触发告警不要让其无限等待挂起。我们默认策略是失败即停快速暴露问题。4.4 日志文件与磁盘占满现象执行器机器提示磁盘空间不足查看日志目录发现单个任务日志就有好几个G。排查后发现每个任务执行都会输出日志执行失败时还会打印堆栈长时间运行下来日志增长速度极快。解决方法是日志按时间滚动加上按大小滚动双策略我们设置单个文件最大100MB保留最近30天超过自动清理。还需要注意日志与任务结果要解耦。不要把任务的执行结果放在日志里解析调度中心只看回调上报的success/fail状态。日志只是辅助排查不参与核心调度逻辑这样即使日志写满磁盘也不会影响调度状态的准确性。4.5 数据库连接池被偶然拖垮现象某天突然大量任务执行失败错误日志显示无法从连接池获取连接但数据库本身负载并不高。一开始我们怀疑是慢查询通过慢日志排查发现完全没有。后来定位到是某几个任务在短时间内高频查询执行记录表且每次查询都用了非索引字段导致单次查询虽然不慢但并发量一大每条连接都长时间占用不释放。解决办法分两步第一步给执行记录表加查询频率最高的索引组合我们加了任务ID加触发时间的联合索引第二步把管理端的列表查询都改为只查最近7天的数据历史数据走归档表避免一次扫描全表。4.6 常见问题速查表现象排查入口推荐动作任务到点不执行调度中心日志检查线程池是否打满、任务是否被禁用任务延迟执行服务器时间统一NTP同步对比各节点时间偏移任务重复执行分布式锁失效检查锁过期时间确认执行器端幂等任务堆积执行器队列检查上游是否失败配置阻塞策略磁盘占满日志文件大小开启日志滚动与自动清理获取连接失败连接池日志优化慢查询添加联合索引执行器离线心跳日志检查网络与心跳超时配置告警不触发告警配置确认告警webhook是否可达重试策略是否过多掩盖了失败5. 一次完整的数据集群迁移实操记录讲一个我们真实经历的场景能串联起前面所有的知识点。那周我们决定把某业务线的调度任务从旧集群整体迁移到新集群。原计划是逐一停旧任务再逐个建新任务后来发现步骤太多容易遗漏。最终采用的方式是“切换执行器定位地址”。具体做法任务配置保持不变只把执行器路由策略从旧集群切换到新集群。调度中心在分配任务时会优先选择列表靠前的执行器地址于是新任务自然偏向新集群。切完后先观察新集群日志确认数据同步正常再逐步下线旧集群执行器。整个过程用一个配置开关控制回滚也方便不需要改动任务本身。迁移中遇到的问题旧集群部分执行器因为参数配置不同时钟偏慢迁移后出现任务补跑的现象。排查后确认不是新集群的问题是旧集群最后一批任务触发时间早已超出当前时间调度中心检测到“过期任务”后做了补偿执行。这里我们给补偿执行加了开关只允许手动开启且默认不补偿超过1小时的任务避免类似因为时钟偏差或停机维护导致的成批补跑。我个人从这次迁移中得到的经验是调度系统的迁移核心不是搬代码而是搬状态。任务配置可以重建执行记录和状态必须保留否则出了问题连去哪儿排查都不知道。我们迁移前先做了一次全量备份迁移完成后对比新旧集群的执行记录数确认对得上才真正收工。6. 关于ax调度的几点切身总结ax整套做下来回头看最大的感受是调度系统不是“写一个定时器”那么简单它实际是在管理系统的可用性边界。如果你想在自己的团队落地类似的东西我建议先不要急着铺开所有功能从小闭环开始。第一版只做定时触发加执行记录第二版再加失败重试和告警第三版再做依赖编排和分布式部署。循序渐进比一次性搭个大而全的系统稳妥得多。还有两个小技巧想分享给正在做调度系统的朋友。第一任务元数据和执行日志永远分开存储否则当执行记录膨胀时你的整个调度系统都会跟着变慢这不是存储问题是架构问题。第二报警通知要区分“任务失败”和“任务连续失败N次”后者要升级到值班群或者电话否则一次网络抖动就能把你的告警群刷屏真出大问题时反而没人看。ax这个名字我们一直沿用至今连文档都没改过。所谓“调度”本质上就是用一套可靠的机制把不确定的现场变成确定的流程。“确定性”这三个字才是调度系统给业务带来的最大价值。
返回列表