1. 项目起源:为什么叫"ax",它到底解决什么问题
先说名字。很多第一次看到这个仓库名的人都会问,ax 是什么意思。其实没有什么高深含义,就是我在整理任务调度这块代码时随手起的代号——"agile executor"的缩写,后来项目越滚越大,团队成员都叫顺口了,也就没再改。名字本身不重要,重要的是它背后承载的那类问题。
我把它定义为一套轻量级分布式任务调度内核。你可以把它理解成一个"闹钟中枢":业务系统里散落着大量的定时任务、延迟任务、周期任务,以前都是各写各的,有的用数据库轮询,有的用操作系统自带的 crontab,有的直接塞进消息队列里硬等。时间一长就开始出问题:机器重启丢任务、时间不准、重复执行、任务堆积、不好观测。ax 要做的就是把这些乱七八糟的调度逻辑统一收口,提供一个可靠、可扩展、可观测的调度底座。
这套东西适合谁?如果你在维护一个中小规模的后端系统,任务量大概在每天几万到几十万条这个量级,又不想引入太重型的商业调度产品,那 ax 这类自研调度内核就非常值得参考。它不依赖任何外部调度组件,只需要一个数据库和几台普通机器,十分钟就能跑起来。
现在的技术圈有个习惯,一提分布式调度就想到各种重量级框架。但实际上很多业务场景根本用不到那么复杂的东西,反而是一套把细节抠到位、把边界想清楚的自研调度器,用起来最顺手。ax 就是这个思路的产物:不追求大而全,但求把每个环节做扎实。
2. 设计思路拆解:架构选型背后的取舍逻辑
2.1 先想清楚三个核心问题
动手写第一行代码之前,我花了很长时间在纸上推演一个问题:一个调度系统,最核心的职责是什么?
拆开来看,其实是三件事。第一件事是时间管理:什么时候该触发哪个任务,这是调度器存在的基本价值。第二件事是任务管理:触发了之后做什么,任务的状态怎么流转,怎么保证不重不漏。第三件事是故障管理:机器挂了怎么办,任务失败了怎么办,系统怎么自愈。
这三个问题对应到架构上,就自然分化出三条链路。时间管理需要一个高效的定时触发组件,我参考了时间轮(Timing Wheel)算法的思想;任务管理需要一个清晰的状态机,我用数据库行锁加乐观锁来控制并发;故障管理则需要一套完善的协调机制,包括心跳检测、故障转移和补偿策略。
很多人在设计调度系统时容易掉进一个陷阱,一上来就想搞复杂的分布式共识算法、想上一致性哈希。但实际上,调度系统的关键在于把简单的事情做到极致。触发时间算准、状态流转不出错、故障恢复不丢任务,这三点做到位,系统就已经能扛住绝大多数业务场景了。
2.2 为什么不选现成的调度框架
身边不少朋友问过我:市面上明明有 XXL-Job、Elastic-Job 这些成熟的调度框架,为什么还要自己造轮子?
说实话,一开始我也没想过自研,毕竟现成框架确实省事。但实际调研下来发现几个痛点。第一,这些框架大多依托于特定的注册中心,比如 ZooKeeper、Nacos,意味着你部署一套调度系统还得额外维护一套中间件,这对小团队来说成本并不低。第二,框架的行为是固定的,某些个性化需求很难从框架层去改动。第三,也是最重要的一点,作为一个技术人员,我希望能深入到内核层面去理解调度的每个细节,这样出了问题才能快速定位。自己写一遍,比读十篇文档都管用。
ax 在架构上刻意保持了极简:不做独立的调度中心节点,而是把调度能力做成了嵌入式的内核。任何需要调度能力的服务,引入 ax 依赖后就是一个调度节点。多个节点之间通过数据库分布式锁来协调,谁抢到锁谁就负责扫描并派发任务。这个设计让我在部署时只需要考虑业务服务本身的集群情况,完全不需要额外部署任何调度服务。
2.3 极简架构背后需要付出的代价
ax 把架构做简单了,不代表实现也简单了。相反,越简单的架构,细节越需要抠。比如,多个节点都通过数据库锁去抢任务,那数据库就成了核心瓶颈。虽然量小的时候没什么感觉,但任务量一上来,数据库的 IO 压力、锁等待的时间都会成为问题。
我采取的思路是分级缓冲。调度器不直接操作任务表,而是先把触发的任务写入一个待执行队列表,然后由执行线程池异步处理。这样就把"触发"和"执行"解耦了:触发环节只需要保证时间准确、写入及时,至于任务什么时候真正执行完,由执行环节去控制。这个设计后来被证明非常关键,它让我可以独立地扩执行能力,而不影响调度能力的稳定性。
另外一个付出代价的地方是,没有独立的调度中心,意味着每个业务服务都要承担一部分调度职责。如果某个服务重启了,它负责的那部分任务就没人触发了。我的解决方案是支持调度的多节点故障转移,任何一个任务都可以被集群中任意一个健康的节点捡起来执行,不绑定特定节点。这背后的机制,我放在后面实操章节详细说。
3. 核心机制解析:调度内核的关键路径与实现细节
3.1 时间轮触发机制:如何做到准点触发且不积压
时间管理部分,我采用的是分级时间轮方案。简单解释一下时间轮是怎么回事。
想象一个一圈只有 60 个格子的表盘,每个格子代表 1 秒。一个 60 秒后要执行的任务,就挂在第 60 个格子上。一个 90 秒后要执行的任务,在 60 秒那一圈放不下,就放到第二层的更大表盘上,比如一圈 60 个格子每个代表 1 分钟。这样每过 1 秒,第一层表盘的指针就前移一格,走到某个格子时,就把该格子上挂的所有任务拿出来执行;如果格子上的任务还需要更长时间,就升级到上一层表盘。
这个算法的优点在于,任务的插入和删除都是 O(1) 时间复杂度的,不会像传统的最小堆那样,插入一个任务需要 O(logN) 的调整成本。对于高吞吐的触发场景来说,差距是非常明显的。
ax 的 time wheel 核心实现大约两百行代码,我尽量保持了它的简洁性。初始设置是:
- 第一层 512 个槽位,每个槽位代表 100ms 的精度
- 第二层 64 个槽位,每个槽位代表 1s
- 第三层 64 个槽位,每个槽位代表 60s
这样组合下来,可以覆盖到最长约 2.7 天的延迟任务而不需要额外的存储结构。超过这个范围的,就落到数据库里的一个大周期表中,由后台扫描线程读取。
我在实际测试中发现,时间轮的精度受限于驱动它的 tick 线程。如果 tick 线程被阻塞了,所有任务都会延迟。所以我把 tick 线程设置为最高优先级,并且让它只做一件事:推进指针、取出当前槽位的任务放入触发队列。至于任务具体怎么执行,全部交给其他线程池,绝不占 tick 线程的时间。这个细节非常关键,很多自研调度器卡顿,就是因为 tick 线程里干了太多不该干的活。
3.2 任务状态机:如何保证不重不漏
调度系统的另一个核心难题是任务状态的流转控制。我设计了一套六态模型,每个任务从创建到终结,状态变化是单向且明确的:
| 状态 | 含义 | 可流转到的状态 |
|---|---|---|
| PENDING | 已创建,等待到达触发时间 | RUNNING, CANCELLED |
| RUNNING | 正在执行中 | SUCCESS, FAILED, TIMEOUT |
| SUCCESS | 执行成功 | 终结态 |
| FAILED | 执行失败 | PENDING(重试), 终结态 |
| TIMEOUT | 超时未完成 | FAILED |
| CANCELLED | 已取消 | 终结态 |
这里最核心的设计是乐观锁更新。每次状态流转时,SQL 语句里带着期望的当前状态作为条件,比如要从 PENDING 转到 RUNNING,就执行:
UPDATE task SET status = 'RUNNING', worker_id = ?, start_time = NOW() WHERE id = ? AND status = 'PENDING'这条语句返回的影响行数如果是 1,说明提交成功;如果返回 0,说明有另一个节点先把任务抢走了,当前节点就放弃。这种方式的魅力在于,它利用数据库的行锁天然地解决了并发竞争问题,不需要额外的分布式锁组件。只要数据库的隔离级别是默认的 Read Committed 或 Repeatable Read,这个机制就能正常工作。
我在实践中还发现一个容易踩的坑:状态机里必须要有超时保护。一个任务被标记为 RUNNING 之后,如果执行节点突然宕机,这个任务就会一直停留在 RUNNING 状态出不来。所以 ax 专门有一条守护线程,定期扫描那些 RUNNING 时间超过了执行超时阈值的任务,把它们强制改写到 FAILED 或重新触发 PENDING。否则只需要几次宕机事故,你的任务表里就会堆积大量永远在 RUNNING 的僵尸任务。
3.3 任务分派策略:多节点下的负载均衡与隔离
刚才提到过,ax 没有独立的调度中心,每个业务节点都可以参与任务调度。这带来一个必须解决的问题:同一个任务,到底由哪个节点来执行?
我的方案是"先扫后抢"的策略。所有节点每隔一个固定周期(默认是 1s)去扫描一次任务表,从时间轮的下一层表里抓一批即将到期的任务,然后尝试用乐观锁把它们从 PENDING 更新为 RUNNING。谁抢到了,谁就执行。
为了让各节点抢到的任务量尽量均衡,我在任务表里增加了一个route_key字段。扫描的时候,每个节点并不是全表扫,而是根据自己的节点 ID 对应的哈希范围去扫。比如集群里有 4 个节点,节点 A 的 route_key 哈希落在 0-3 区间的任务优先被它扫;节点 B 扫 4-7 区间,以此类推。这样每个节点天然地只竞争自己那部分任务,冲突的概率大大降低,数据库锁的等待也就少了。
当然,这种静态哈希分片的方式,在节点数量变化时会面临重新哈希的麻烦。我的处理比较直接:每次节点数量变化后,不是立即重新哈希,而是通过乐观锁的兜底机制慢慢平滑迁移。任务如果被原属节点抢不到,其他节点也能抢,只不过需要等下一次扫描周期。这种"慢迁移"设计虽然不如一致性哈希那么优雅,但胜在实现简单、逻辑清晰,而且不会出现瞬间大量任务重排导致的雪崩。
4. 实操过程:从零搭建一套 ax 调度内核
4.1 基础配置与初始化
ax 的使用方式非常简单,我把它打成了一个标准的 jar 包,业务方只需要引入依赖,配置一个数据源,然后启动时调用初始化方法即可。核心配置参数如下:
ax: enabled: true node-id: node-001 tick-interval: 100 # tick 周期,单位 ms,默认 100 scan-interval: 1000 # 数据库扫描周期,单位 ms,默认 1000 thread-pool-size: 16 # 执行线程池大小 max-retry: 3 # 失败最大重试次数 retry-interval: 60 # 重试间隔,单位 s task-timeout: 300 # 任务超时时间,单位 s pending-threshold: 86400 # 数据库待执行表最大预取时间范围,单位 s这里解释一下pending-threshold这个参数。之前提到时间轮只能覆盖约 2.7 天的延迟范围,超过这个范围的任务,ax 会在创建时直接扔到数据库里一层"延迟队列表"。每当时间轮指针越过一个较大的时间刻度(比如 1 分钟级刻度),后台扫描线程就会从这个表里把即将在接下来几分钟内到期的任务捞出来,放入时间轮。这个参数就是告诉扫描线程,最多预取未来多长时间的任务,防止一次捞太多导致内存浪费。
我建议按实际业务量调整这几个参数。如果任务量大而执行速度快,线程池可以适当调大,但务必要配套调整数据库连接池的上限,否则线程都在等数据库连接,就会有大量线程空转。
4.2 核心代码实现演示
时间轮的驱动部分,核心就是 tick 线程不断的推进指针并触发到期任务:
public class TimingWheel { private final int[] ticksPerLayer = {512, 64, 64}; // 各层槽位数 private final long[] unitPerLayer = {100, 1000, 60_000}; // 各层单位 private final List<Queue<TimerTask>>[] layers; private final long[] currentTick; public void start() { Thread t = new Thread(this::tickLoop, "ax-timing-wheel"); t.setPriority(Thread.MAX_PRIORITY); t.setDaemon(true); t.start(); } private void tickLoop() { while (true) { long start = System.currentTimeMillis(); advance(System.currentTimeMillis()); long cost = System.currentTimeMillis() - start; // 控制 tick 间隔,避免空转 long sleep = Math.max(0, tickInterval - cost); LockSupport.parkNanos(sleep * 1_000_000L); } } }任务触发后的执行流程相对简单。触发线程从时间轮里取出任务时,并不会直接执行,而是把任务包装成一个 WorkItem,放进一个无序的线程池队列里。线程池里的 worker 线程拿到 WorkItem 后,先尝试执行上面说的乐观锁更新,标记为 RUNNING,再真正调用业务回调方法。如果更新锁失败,说明任务已经被别的节点拿走了,worker 线程直接丢弃这个 WorkItem,不做后续动作,避免同一个任务被重复执行。
很多人在写调度系统时会犯一个错误:把锁跟执行业务逻辑放在同一个线程里。看起来没什么问题,但遇到慢任务时,线程池里的 worker 全被慢任务占满,后续任务全部排队等待,调度器就像一个堵塞的下水道一样越来越卡。我采取的策略是,把锁的获取和业务执行拆成两个阶段。
第一阶段,worker 线程只负责快速获取任务执行权,拿到后把任务信息丢给一个独立的业务线程池。第二阶段,业务线程池真正去执行回调逻辑,执行完再回调 ax 的通知接口更新状态。这两个线程池完全隔离,即使某个慢业务任务卡住了,顶多占用业务线程池里的一个线程,不会影响调度器本身的吞吐。
4.3 接入示例:一个简单的延迟任务
业务方接入 ax 的流程也非常直观,只需要一个注解加一个实现类。
@AxSchedule( taskCode = "order.close", triggerType = TriggerType.DELAY, delay = 30_000 // 30 秒后执行 ) public class OrderCloseHandler implements AxJobHandler { @Override public void execute(AxJobContext context) { Long orderId = Long.valueOf(context.getPayload()); // 关闭超时未支付的订单 orderService.closeOrder(orderId); } }创建这个延迟任务的时候,业务侧只需要调用 ax 的 API 传入 payload 和延迟时间即可:
axScheduler.submit("order.close", orderId, 30_000);这里有一个细节值得说一下。submit方法内部会先计算任务的绝对到期时间,然后根据到期时间与当前时间的差值,判断是放入时间轮内存结构还是放入数据库延迟队列。所有的时间运算都统一使用系统时钟的毫秒值,避免使用System.currentTimeMillis()和System.nanoTime()混用带来的时间一致性问题。这个问题很隐蔽,nanoTime 严格来说是相对时间,不能用于跨节点的时间比较,新手容易踩这个坑。
4.4 数据库表结构设计与索引优化
ax 的持久化依赖两张表:ax_task和ax_task_log。前者存任务本身,后者存每次执行的历史记录,用于审计和排障。
建表的核心 SQL 如下:
CREATE TABLE ax_task ( id BIGINT PRIMARY KEY AUTO_INCREMENT, task_code VARCHAR(64) NOT NULL, payload TEXT NOT NULL, status VARCHAR(16) NOT NULL, route_key INT NOT NULL, trigger_time BIGINT NOT NULL, expire_time BIGINT NOT NULL, retry_count INT NOT NULL DEFAULT 0, max_retry INT NOT NULL DEFAULT 3, create_time DATETIME NOT NULL, update_time DATETIME NOT NULL ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4; ALTER TABLE ax_task ADD INDEX idx_trigger_status (trigger_time, status), ADD INDEX idx_route_status (route_key, status), ADD INDEX idx_status_expire (status, expire_time);索引设计是整个数据库方案里最值得琢磨的部分。idx_trigger_status帮助扫描线程快速找到"时间到了但还没执行"的任务;idx_route_status帮助各节点快速锁定自己负责的分片;idx_status_expire则是为了让超时守护线程高效找到长时间 RUNNING 的任务。
实际测试中,当任务表数据量达到千万级别时,这三个索引配合得很好,单次扫描耗时基本稳定在 50ms 以内。但千万级以下的小表,反而存在索引使用不当的问题,MySQL 优化器有时候会选择全表扫描而不是走索引,这会让扫描时间飙升到几百毫秒。我的应对措施是用 FORCE INDEX 强制指定索引,实测下来效果立竿见影。
4.5 部署架构与高可用方案
由于 ax 是嵌入式调度内核,它的部署形态跟业务服务完全一致:你可以把 ax 内嵌在一个常规的后端服务里,也可以做一个独立的调度服务专门跑 ax。我自己在项目里采用的是后者——把 ax 单独做成一个调度中心服务,业务服务通过 RPC 来提交任务和接收状态回调。
这样做的好处是运维层面的。调度中心的机器挂了,只需要保证至少 2 台实例构成小集群,就能利用前面提到的乐观锁竞争机制实现故障转移。某台机器宕机后,它之前 RUNNING 状态的任务会在超时后被其他健康节点接管并重试,业务侧不会感知到明显的任务中断。
我强烈建议大家不管规模多小,调度中心都要至少部署 2 台,并且不要让调度中心跟核心业务服务混布在同一台物理机上。否则一旦业务流量暴涨把机器资源吃光,调度器也会跟着遭殃,导致任务大面积延迟。这类事故我在早期踩过好几次,都是血泪教训。
5. 常见问题与排查技巧实录
5.1 任务一直停留在 PENDING 状态
这是接入 ax 后最常见的第一个问题。如果你发现任务到了触发时间却始终是 PENDING,首先不要去看业务逻辑,先检查扫描线程是否正常工作。
我的排查套路是三步。第一步,确认数据库里有没有那些即将到期的任务。第二步,打开 ax 的 DEBUG 日志,观察扫描线程每轮扫描返回的任务数量和锁竞争情况。第三步,确认时间轮有没有真的把任务接住。
这个问题的根源,十次里有七次是pending-threshold设置不合理。如果你把任务的延迟时间设置为 1 天,但pending-threshold默认只预取 1 小时内的任务,那这个延迟 1 天的任务就会在数据库延迟队列里躺着,等扫描线程下一次把时间范围扩大后才会被捞出来。解决方式很简单,把参数调大即可。参数调大之后要注意内存占用,如果一个任务就带几十 KB 的 payload,预取几万个任务就是几百 MB 的内存,GC 压力会直线上升。
5.2 任务重复执行问题
调度系统最怕的就是"重复执行"。ax 的乐观锁机制已经在绝大多数场景下保证了同一个任务同一个时刻只有一个节点能拿到执行权,但还是有漏网之鱼。
最典型的漏洞场景是任务超时后,守护线程强制把 RUNNING 改成 PENDING 打算重试,但此时原来执行任务的那个线程其实并没有真的死掉,它只是网络抖动导致心跳延迟了。结果就是原线程还在跑,重试线程也开始跑,同一个任务就被并发执行了。
要根治这个问题,不能只在任务状态上下功夫,还得在业务层的幂等设计上发力。我通常会给每个任务绑定一个唯一的execution_id,每次执行生成一个全新的 ID。业务回调方法里,先用这个 ID 去查一下有没有处理过,处理过就直接返回,否则才真正执行业务逻辑。这种幂等方案虽然会给业务侧增加一点编码成本,但在高可靠场景下几乎是必须的。ax 的文档里专门有一章讲如何做幂等,我建议接入方都仔细看一遍。
5.3 执行超时导致任务堆积
另一个常见现象是,某类慢任务(比如调用外部接口超时)在执行线程池里堆积,后面的任务全都排队等待。业务侧感知就是任务完成时间明显延迟。
排查时先用 ax 提供的一个内置观测接口看线程池的情况:
curl http://localhost:8080/ax/metrics这个接口会返回当前执行线程池的活跃线程数、队列积压数、最近一分钟完成的任务数等指标。如果活跃线程数长时间等于线程池大小上限,说明线程池已经被占满了;如果队列积压数持续增长而完成数几乎不动,说明任务执行速度跟不上触发速度。
我遇到这种情况,第一步不是加线程池,而是先找到那个拖后腿的慢任务。把任务按 task_code 统计平均执行耗时,快速定位哪些类型的任务耗时异常。如果是某个外部依赖导致的,先考虑给任务加上超时熔断,不让它无限期地占着线程。真正解决了慢任务的阻塞问题,线程池的压力自然就下来了。单纯调大线程池就像给堵塞的下水道加宽管径,只是延缓了问题爆发的时间。
5.4 数据表膨胀与归档策略
跑了一段时间之后,任务表里的历史数据会快速增长。虽然 ax_task_log 是审计用的,但完全不清理也不行。我在项目里采用按天分区的策略,每天新增一个分区,保留最近 30 天的数据。删除历史数据时就只删除整个分区,比逐条 DELETE 高效得多。
ALTER TABLE ax_task_log PARTITION BY RANGE (TO_DAYS(create_time)) ( PARTITION p20240101 VALUES LESS THAN (TO_DAYS('2024-01-02')), PARTITION p20240102 VALUES LESS THAN (TO_DAYS('2024-01-03')) );这里有个操作细节需要注意:MySQL 的分区键必须是主键或唯一键的一部分。如果你在创建表的时候就把主键设为id,那按create_time分区就会报错。我的做法是把主键改成(id, create_time)联合主键,然后再分区,这样就没有问题。这个坑很容易在刚开始设计表结构时埋下,等到数据量大了再改就非常麻烦。
6. 性能压测与调优实录
6.1 单机压测基准数据
ax 完成主体开发后,我对它做了一轮比较完整的性能压测。压测环境是:
- 4 核 8G 的普通云主机,MySQL 8.0 数据库独立部署在同一内网段
- 压测任务类型是纯内存型的空任务,不涉及外部 IO
测试结果如下:
| 场景 | 数据量 | 吞吐量(任务/秒) | 平均触发延迟 |
|---|---|---|---|
| 时间轮内触发(秒级) | 10 万条 | 约 8,000 | < 50ms |
| 时间轮内触发(毫秒级精度) | 10 万条 | 约 5,000 | < 50ms |
| 数据库延迟队列触发(跨天任务) | 100 万条 | 约 2,000 | < 200ms |
| 混合场景(内存+数据库混合) | 50 万条 | 约 3,500 | < 150ms |
这个数据说明,时间轮内触发的吞吐量明显高于依赖数据库扫描的触发方式,差距大约在 3-4 倍。原因很好理解,数据库扫描涉及磁盘 IO、网络传输、行锁竞争,链路比纯粹的内存操作长得多。所以一个优化思路就自然而然地浮现出来:尽量让任务在时间轮内触发,减少数据库的参与。
我把pending-threshold参数在压测中从默认的 86400 秒(1 天)调整到 604800 秒(7 天),结果数据库扫描触发占比从 40% 降到了 12%,整体吞吐量提升了将近 40%。代价是内存占用增加了约 15%,对于 8G 内存的机器来说完全可以接受。
6.2 锁粒度调优
另一个明显影响性能的因素是锁竞争。当集群节点数量从 2 台扩展到 6 台时,我一度观察到数据库的锁等待时间明显增长,单次扫描耗时从 30ms 涨到了 120ms。分析后发现,是节点数量多之后,每个节点都想抢同一批任务,冲突概率大大增加。
我用的是一个比较朴素的调优方案:把扫描范围按 route_key 做更细的切分。原来 4 节点时,每个节点扫描 25% 的数据;扩到 6 节点后,每个节点扫描 16.7% 的数据,理论上冲突概率应该下降才对。但实际因为哈希分布的随机性,某些热门 route_key 可能集中在同一台节点上,导致这台节点持续争抢失败。
最终的解决办法是引入"虚拟分片"的概念。把 route_key 的取值空间固定为 1024 个逻辑分片,节点跟具体分片的映射关系由一张映射表维护。每次扫描时,节点只需要处理归属于自己的那部分分片即可,而映射关系可以根据节点的健康状况动态调整。这个方案把锁冲突的概率又降了一个数量级,6 台节点压测时,单次扫描耗时稳定在 40ms 左右。
虚拟分片的问题是映射表本身需要保证高可用。我把映射表放在了配置中心,由调度服务启动时加载并定期刷新。增加节点时,只需要在配置中心更新映射关系,新节点会自动感知。
6.3 内存中的任务对象优化
压测时我注意到一个不太起眼的瓶颈,是任务对象的创建和回收。当每秒触发 8000 个任务时,每个任务至少会创建 3 个对象(Task、TimerTask、WorkItem),加上线程包装和上下文信息,一秒就是 3 万多个短生命周期对象。对这些对象的 GC 处理,会成为高频触发 Full GC 的导火索。
我用两个手段来缓解。第一个是对象池复用,对于 WorkItem 这种轻量对象,用线程安全的对象池来缓存,用完了还回去,减少重复创建的损耗。第二个是减少字段冗余,把原本 20 多个字段的任务对象精简到 12 个核心字段,其他可选字段全部使用 Map 放在单独的地方,降低每个对象的内存占用。
这两步优化之后,压测时一次 10 分钟持续高吞吐的运行,Full GC 次数从原来的 7 次降到了 1 次,停顿时间从几百毫秒降到了几十毫秒。触发的任务本身并没有变,变的只是数据结构和对象生命周期管理的方式。
7. 运维经验与扩展思考
ax 上线运行半年多,整体稳定,但也有过几次教训。有段时间我发现调度服务的 JVM 线程数不断增长,排查到最后是业务侧的回调方法抛异常时没有正确释放连接池里的连接,导致线程越积越多。这个问题的锅不在 ax 本身,但也提醒了我:调度系统的运维,很大程度上取决于接入方的代码质量。所以我后来在处理调度任务失败时,都会先跟业务侧确认他们的异常处理逻辑是否正确,再回头查调度器本身。
现在回头看这个项目,最让我满意的一点,不是它的性能指标有多亮眼,而是它把"调度"这件事的逻辑理得很顺。触发、执行、状态、恢复,每个环节都有清晰的边界和兜底方案。哪怕是集群中一台机器被强制 kill -9,最多就是让部分任务延迟一个扫描周期,后续自动恢复。这样的可靠性,对于绝大多数业务场景来说已经非常够用了。
如果你也想自己在项目里沉淀一套类似的调度能力,我的建议是不要一上来就硬套某个开源方案,先把需求本质拆清楚:你的任务类型有哪些?需要什么级别的可靠性?团队能接受的维护成本是多少?想明白这三个问题,再去设计架构、选方案,往往比直接拿一个框架就开始用要扎实得多。
这篇文章算是 ax 项目的一个阶段性总结。未来如果有精力,我打算在调度策略上增加更智能的分区平衡算法,以及在触发链路中加入分布式追踪支持。但这些都是锦上添花的事情,眼下这套内核已经可以支撑我负责的业务系统稳定运行了。做基础组件,有时候就是这样——不需要多炫酷,扛得住事,就是最大的本事。