先说实话,"ax"这个标题我盯着看了半天,琢磨了各种可能——字母缩写、项目代号、库名,最后结合"ax调度"这个热词,我判断它大概率指的就是"async task scheduling":异步任务调度。这在我的实际工作中是个绕不开的话题,几乎每个上点规模的后端系统都会遇到:同步处理扛不住、定时任务不好管、任务一多就乱成一锅粥。这篇博文,我就从个人实操角度,完整拆解一个名为"ax"的轻量级异步任务调度框架的设计思路、核心实现和避坑记录,标题就叫"The ax Project",内容以ax调度贯穿始终,保证不跑偏。
1. 项目概述:为什么我需要一个叫"ax"的任务调度器
1.1 核心需求解析
做个"ax"的念头,最先源于一个被我称为"定时任务地狱"的夜晚。当时我在维护一套面向C端的内容分发服务,业务高峰期会有大批量推送、内容转码、积分结算这类任务,原先的实现方式非常朴素:每个任务单独起一个线程,定时器一响就触发。一开始服务量小,这套方案勉强能跑;等到日均任务量突破数十万级,问题就全冒出来了——线程数量失控导致CPU上下文切换开销巨大,任务积压时没有背压机制,服务一重启崩溃,内存里的待执行任务全部丢失。
我需要的东西很明确:
- 一个集中式的任务注册与分发中心,而不是散落在各个业务模块里的
Thread.sleep。 - 支持延迟任务、定时任务、周期性任务,至少要覆盖cron表达式和一键延迟。
- 执行失败要能自动重试,重试要有指数退避,不能失败就无限重试把系统打爆。
- 内部要有任务队列和线程池解耦,不能因为底层执行慢就阻塞上游提交。
- 对外暴露简洁的API,业务方接起来不能超过10行代码。
我给它起名叫"ax",没有太花哨的寓意——就是"async execution"的缩写,顺手、好记、在IDE里搜得到。
1.2 第三方方案调研之后我踩过的坑
立项前,我认真调研了当时市面上主流的任务调度方案:Quartz、XXL-JOB、Elastic-Job,以及基于Redis实现的一些延时队列方案。我在选型时踩过几个坑,这里必须如实分享。
首先是重量级框架的部署成本。XXL-JOB和Elastic-Job功能都很强,分布式调度、任务分片、控制台管理,样样齐全。但部署它们需要额外运维一套独立的调度中心集群,还要引入注册中心、数据库表结构初始化。坦白说,我的业务规模远没到需要这种重兵器的阶段,炮打蚊子,运维负担反而拖累了整体迭代节奏。
再看Quartz。Quartz的JavaAPI非常成熟,cron支持也很完善,但它的核心模型是"调度器+触发器+作业",概念抽象层次偏高,很多入门开发者容易在JobDetail和Trigger的关联关系上绕来绕去。而且Quartz默认单机部署,集群模式需要数据库锁表,这个在高并发下很容易成为瓶颈。
还有一类是基于Redis的ZSet延时队列,比如用Redis的ZADD+ZRANGEBYSCORE轮询取任务。我前期测试时发现一个很隐蔽的问题:任务重复消费。如果消费者取出任务后还没来得及从Redis删除,进程就宕机了,那这个任务重启后会再次被取出执行。要解决就得自己做幂等表或者引入分布式锁,复杂度一下就上来了。
最终我下定决心,只依赖一个基础设施——现有的MySQL数据库,自己实现一个核心足够干净、代码量控制在两千行以内的调度器。事实证明,这个决定在很多个小项目里都远比引入分布式调度中心更务实。
1.3 场景边界与最终效果对比
在这个"ax"演进过程中,我逐渐明确了它的适用边界:
- 适合场景:单机或少量节点内的异步任务,任务量在每秒几千级以内,对毫秒级延迟调度不是特别苛刻,CV领域常见的定时抓取、批量清洗、模型定时重训等。
- 不适合场景:跨地域大规模分布式调度、需要可视化任务编排、多租户隔离的复杂工作流。
最终实现完成后,我在一个模拟的高流量接口压测中做了对比:
| 方案 | 无调度器裸奔执行 | ax调度器接入后 |
|---|---|---|
| 实现方式 | 业务内直接new Thread / 原生FutureTask | ax统一线程池治理 |
| 500QPS下接口P95延迟 | 580ms | 210ms |
| 任务积压背压 | 无,直接内存溢出风险 | 队列缓冲+拒绝策略 |
| 失败重试 | 手动try-catch,没有统一逻辑 | 内置指数退避重试 |
| 服务重启后任务 | 全部丢失 | 持久化恢复/补偿 |
这个对比结果让我更坚定了信念:多数团队缺的核心不是功能更强,而是治理更规范。用"ax"之后,我代码里再也没出现过业务模块自己new线程池的乱象,这也为后续跟进的异步化改造铺好了路。
2. 工具选型与依赖取舍:为什么核心只用MySQL
2.1 基础设施选型逻辑
做技术选型时,我给自己定了一条铁律:能用现有基础设施解决的,绝不多引入一件新组件。运维团队对Redis很熟,MySQL数据库集群也有完善的监控报警和备份恢复机制。我之前在Redis ZSet方案上翻了车,所以这次核心存储就选定MySQL,由ax调度器直接持有数据源,业务无需感知底层细节。
那为什么要避开消息队列呢?Kafka也好,RocketMQ也好,在任务调度这个维度上都有天然的短板:
- 消息一旦被消费,默认就会被删除,不适合做"任务状态管理",而调度任务通常会经历pending → running → success / failed → retry的多次状态流转。
- 定时消息支持不够原生,比如RocketMQ虽有定时等级但粒度固定,Kafka更是完全没有内置延迟投递的概念。
- 消息链路天生是移动的,不适合回溯和重新触发。任务调度的核心诉求恰恰是"状态机+可控重放"。
所以"ax"的设计哲学一句话概括:用任务表作为业务记录的唯一事实源,用内存队列承ota瞬时的执行压力,用定时扫描器把数据库和内存执行器缝合起来。这个组合已经在线上扛过流量,后面细说。
2.2 数据库表结构设计思路
建表是整个调度器的最关键决策点。我先贴出核心表结构:
CREATE TABLE `ax_task` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '任务ID', `task_name` VARCHAR(128) NOT NULL COMMENT '任务名称,用于监控与检索', `payload` TEXT COMMENT '业务负载,JSON文本', `status` TINYINT NOT NULL DEFAULT '0' COMMENT '0-pending 1-running 2-success 3-failed 4-cancelled', `priority` INT NOT NULL DEFAULT '100' COMMENT '数字越小越优先', `delay_ms` BIGINT NOT NULL DEFAULT '0' COMMENT '延迟多少毫秒后执行', `run_after` BIGINT NOT NULL COMMENT '期望执行时间戳(ms)', `retry_count` INT NOT NULL DEFAULT '0' COMMENT '已重试次数', `max_retry` INT NOT NULL DEFAULT '3' COMMENT '最大重试次数', `handler` VARCHAR(255) NOT NULL COMMENT '执行器Bean名称', `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY `idx_run_after` (`run_after`), KEY `idx_status` (`status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='ax调度任务表';这个表的设计有几个容易被忽略的细节,我在踩坑后反复调整。run_after字段是关键索引,不存牛毛似的cron表达式解析结果到表里,而是统一换算成毫秒时间戳,让扫描器只用做一次范围查询。status和run_after的复合索引我一开始没建,导致表数据到百万级时扫描变慢,后来加上(status, run_after)组合索引,扫描速度提升了一个量级。handler字段存放执行器的Bean名称,彻底把任务数据和执行逻辑解耦,业务侧只需要在Spring容器里注册一个对应的Bean,调度器通过名字反射调用,这样后续切换执行器实现、平滑升级都变得简单。
2.3 与Spring生态的适配方案
我日常的主力技术栈是Spring Boot,所以"ax"从一开始就按Spring Boot Starter的规范来组织项目结构。最终工程被拆成三块:
ax-core:核心调度逻辑,不依赖Spring,纯Java实现,独立单元测试完全覆盖。ax-spring-boot-starter:基于Spring Boot自动装配机制,对外暴露注解和API,负责Bean注册和生命周期管理。ax-test:作为示例与集成验证工程,模拟真实业务接入。
核心逻辑和框架解耦这件事,我始终坚持。Spring的生命周期管理、属性注入确实方便,但调度器这种对性能敏感、对时序敏感的基础组件,最好保留一份不依赖框架的纯逻辑,这样可移植性、可测试性都更强,也方便在非Spring项目里复用。最终接入方式也符合预期,业务侧只需三步:
@EnableAxScheduler @SpringBootApplication public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); } }然后再声明执行器Bean:
@Component("pushHandler") public class PushHandler implements AxHandler { @Override public AxResult handle(String payload, AxContext context) { // 业务逻辑 return AxResult.success(); } }最后提交任务:
axTemplate.delay("pushHandler", payload, 3000); // 或者按cron表达式提交 axTemplate.cron("pushHandler", payload, "0 */5 * * * ?");这套API我用了快一年,内部改动频繁,接口几乎没让业务方感知过breaking change,这是当初解耦设计最大的红利。
3. 核心细节解析与调度策略设计
3.1 调度策略的技术选型:时间轮、最小堆还是轮询扫表
任务调度架构设计,业内最常见的有三条技术路线:时间轮、最小堆、数据库轮询。我在"ax"开发的初期就把这三种方案都做了原型验证,最终结果是组合使用。
时间轮(Timing Wheel)来自Kafka的经典实现,用环形数组加多层轮转来处理海量定时任务的到期触发。它的优点是插入和删除都近乎O(1)时间复杂度,但缺点是:如果任务被执行前的状态还需要事务性更新(比如从pending到running),时间轮单机内存模型很难和数据库状态强一致。我走到一半就放弃了纯时间轮路线。
最小堆(DelayQueue)非常直观——把所有任务的run_after塞进一个小顶堆,每次取堆顶判断是否到期。Java标准库就有DelayQueue,实现成本极低。但因为这个堆是全内存的,服务重启后整个状态全没,还得靠数据库层的全量恢复来兜底,那就依然绕不开数据库轮询这件事。
最终我选择了数据库轮询为兜底,加内存预热型的二级加速。思路是:调度器后台一个线程每隔500ms扫描一次当前时间窗口内run_after到期的任务,把任务批量捞到内存中的pending队列,再按优先级快排后依次投递到工作线程池。这个方案看似不酷,但它用最朴素的手段同时解决了持久化和延迟触发两个问题,数据强一致性由数据库事务保障,调度精度通过扫描频率和内存队列补偿,实测下来对绝大多数业务完全够用。
3.2 延迟任务与cron表达式的统一抽象
"ax"里有一个抽象概念叫做ScheduleExpr,它统一了两种任务提交方式。延迟任务用delay("handler", payload, ms),cron任务用cron("handler", payload, "0 */5 * * * ?")。在内部实现上,cron表达式会被解析并换算成一系列"下一次触发时间点",然后转换为对应的延迟任务。具体做法是把Quartz的CronExpression库压缩整合到ax-core里,裁剪掉一些用不到的高级特性,最后只暴露一个方法:
public long nextTimeAfter(Date afterTime) { // 计算下一次触发时间戳 }每次执行完cron任务后,调度器会自动算出下一次run_after并重新入库。这里有一个需要注意的边界情况:如果任务的执行时间过长,下一次触发时间已经过去了,此时调度器会立即触发一次补跑,而不是跳到下下个周期。这也是我在线上遇到过的一个实际问题——某个数据同步任务一次执行要跑90秒,cron配置的是1分钟一次,结果刚开始频繁补跑,最后靠加执行锁和任务防重才稳定下来。
3.3 重试与错误处理机制的最佳实践
任务调度器的另一半技术含量,在于失败之后怎么办。我在"ax"中内置了一套三级重试策略:
- 第一层:框架自动重试。捕获到业务异常后,若
retry_count < max_retry,自动把任务重新投递到队列,按指数退避算法计算延迟:delay = initialDelay * 2^retryCount,默认初始500ms,最大延迟不超过2分钟。 - 第二层:手动重试。如果业务侧判断"这次失败是暂时性的,但不想占用自动重试名额",可以调用
axTemplate.retry(taskId)。 - 第三层:补偿重试。如果任务失败且达到最大重试次数,任务状态变为
failed,但不会被丢弃,会进入一张可视化的失败任务表,通过后台管理接口可以批量重新触发。
很多人会忽略的一点是:重试一定要有延迟,绝不能失败后立刻重试。如果下游Redis抖动,立刻重试大概率再失败一次,浪费资源且加剧抖动。指数退避本质上是在给下游系统一个恢复窗口。
3.4 优先级队列与任务隔离方案
多业务共用一套调度器时,容易互相影响。积分结算任务一次跑10分钟,另一个实时消息推送任务延迟500ms就得发出去,如果共用一个工作线程池,推送任务会被结算任务阻塞,直接超时。
我在"ax"里实现了分组队列。任务提交时可以指定分组名,调度器为每个分组维护独立的线程池和队列。比如:
group=push:核心线程8,最大线程16,队列容量1000。group=settle:核心线程2,最大线程4,队列容量200。
同时在分组内支持优先级。每个任务带一个priority字段,数值越小优先级越高。当队列满时,不是直接拒绝所有任务,而是比较队尾最低优先级任务的优先级,如果新任务优先级更高,则挤掉队尾的低优任务,把它重新延迟投递一次。这个方案我命名成"低优挤兑",它的好处是优雅——在内存饱和时,不会粗暴丢弃高优任务,也不会让低优任务凭空消失。
4. 实操过程与核心环节实现
4.1 调度器核心主循环代码解析
直接上一段最核心的伪代码,这是"ax"调度器的灵魂,我把它打磨了很多遍:
@Component public class AxScheduler { private final ScheduledExecutorService scanner = Executors.newSingleThreadScheduledExecutor(); private final DelayQueue<AxTaskWrapper> readyQueue = new DelayQueue<>(); private final Map<String, WorkPool> groups = new ConcurrentHashMap<>(); @PostConstruct public void init() { scanner.scheduleWithFixedDelay(this::scan, 0, 500, TimeUnit.MILLISECONDS); } private void scan() { long now = System.currentTimeMillis(); List<TaskEntity> dueTasks = taskRepository.findDueTasks(now, scanBatchSize); for (TaskEntity task : dueTasks) { // 先改为运行中,防止重复调度 boolean lock = taskRepository.tryLock(task.getId()); if (lock) { readyQueue.put(new AxTaskWrapper(task)); } } } private void dispatchLoop() { while (true) { AxTaskWrapper wrapper = readyQueue.take(); WorkPool pool = groups.get(wrapper.getGroup()); pool.submit(() -> executeWithRetry(wrapper)); } } private void executeWithRetry(AxTaskWrapper wrapper) { AxResult result; try { result = handlerInvoker.invoke(wrapper); } catch (Throwable ex) { handleRetry(wrapper, ex); return; } if (result.isSuccess()) { taskRepository.finish(wrapper.getId()); } else { handleFailure(wrapper, result.getErrorCode()); } } }其中scan方法里最值得仔细考量的是tryLock。这是防重复调度的关键屏障。在集群部署环境中,多个节点同时扫描数据库,很可能一起把同一批任务查出来。我在tryLock内部用一个乐观锁机制实现:
UPDATE ax_task SET status = 1 WHERE id = ? AND status = 0受影响行数为1说明抢到,为0说明其他人已经处理。这个思路朴素且可靠,完全基于数据库行锁的原子性,不需要额外引入分布式锁组件。在任务量单秒几千的情况下,这个方案的生产表现非常稳定。
4.2 任务队列与线程池参数的精细调优
线程池参数是最容易拍脑袋决定的部分,但它的影响面非常大。"ax"在默认配置里给了一个相对保守的初始值:核心线程数按可用CPU核数2来算,最大线程数按核心线程数4,队列容量则按"单个任务平均执行耗时 * 目标QPS"估算。
我举个例子,假设每个任务平均执行时长200ms,业务目标QPS是2000,那队列容量的经验公式是:
队列容量 = 平均耗时(秒) * 目标QPS = 0.2 * 2000 = 400为什么要预留这个余量?因为线程池拒绝策略抛出异常后任务就丢了。在"ax"里这个情况会在日志里以ERROR级别记录,然后状态变更为failed,依靠重试机制挽救。但最佳效果永远是不触发拒绝。我测试下来,容量在300~500之间是合理区间,能吸收掉瞬时流量洪峰,又不至于堆积过多导致延迟不可控。
我还会建议配合一个动态线程池水位报警。当排队中的任务超过队列容量的60%时发出告警,给运维留出提前扩容的决策时间,而不是等被打垮了才看监控。
4.3 幂等性保障:重复执行不搞出乱子
做调度器最怕两件事:该执行的不执行、不该重复执行的重复执行。前者有任务丢失兜底,后者如果处理不好,会造成严重的数据不干净。
我在"ax"里做了一份通用的幂等机制,它基于业务方定义的IdempotencyKey。提交任务时,业务侧可以指定这个key:
axTemplate.idempotentSubmit("settle", "user:43324:settle:20250115", payload, delayTime);生成任务时,"ax"在ax_task表增加了一个唯一索引uk_idempotency_key,插入时如果发生主键冲突,则说明同一业务key已有在途任务,直接复用旧任务而非新建。这比单纯用Redis setnx更可靠——数据库唯一索引天然有持久化,不怕Redis宕机。
我在实际中使用这个机制处理过一次事件:C端用户连续点击了两次"批量结算"按钮,导致两条结算任务同时进入队列。有了这一层幂等约束,第二条任务直接被挡在门外,完美化解了可能产生重复账单的事故。
4.4 任务持久化与恢复的降级策略
服务重启后,内存队列里所有任务都会丢失。好在数据库表里每个任务还有状态。我设计了启动阶段的任务补偿逻辑:
- pending数量为0的:说明还没被扫描到,直接保留,等待下一轮扫描即可。
- running数量为1的:可能是执行中宕机,也可能正常执行但状态没来得及更新。这时候通过一个
start_time字段判断,如果已经超过超时阈值(默认执行超时5分钟),就把它回滚为pending,允许重新调度执行。这意味着业务侧执行器必须幂等,否则重放会酿成事故。 - success/failed的:数据归档,不参与补偿。
这个过程我称为"半途任务打捞"。它保证了系统重启后最坏情况下只丢失正在执行且超时未被标记的任务,而这些任务在指数退避重试机制下很快会被重新处理,用户侧基本无感。
5. 常见问题与排查技巧实录
5.1 高频问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 任务提交后长时间不执行 | 数据表轮询扫描时间间隔太长 | 查看扫描线程日志 | 调低扫描间隔到200ms,但注意DB压力 |
| 任务延迟执行但堆积如山 | 线程池队列满,触发降级 | 查看WorkPool队列水位 | 调大线程池或拆分组 |
| 任务被重复执行 | 缺少幂等控制或任务锁失效 | 检查tryLock操作 | 确保唯一索引与乐观锁 |
| 任务失败后重试仍然失败 | 下游系统持续不可用 | 查看指数退避纪律 | 检查重试退避上限设置 |
| 服务启动后任务全部丢失 | 数据库记录丢失或状态未恢复 | 检查持久化及补偿机制 | 检查启动补偿逻辑是否执行 |
| cron任务补偿跑频繁 | 单次执行时间超过cron间隔 | 查看单任务耗时统计 | 加任务锁或将cron间隔调大 |
5.2 三个让我印象深刻的线上问题
问题一:数据库连接池被打满。上线第一周就出现数据库连接池爆掉的情况。排查后发现,扫描线程每500ms执行一次范围查询,本身量不大,但每次执行器还顺带在事务里查询了业务表、更新了日志表,导致每次调度至少占用4个连接。高峰期多个扫描周期累积,连接池自然被榨干。解决方式是给调度器单独配置数据源,和业务数据源物理隔离,调度器独有的连接池大小只设10个,反而运行得极其稳定。这个坑在架构层面挺有教育意义:基础组件必须与业务链路做资源隔离,避免交叉影响。
问题二:cron任务丢失。有个业务方反馈,每天凌晨的数据同步任务偶发没有执行。我查看了数据库,记录显示状态为success,但时间戳比预期晚了90分钟。进一步排查发现,问题是这个cron任务被执行器消费时,执行线程因为依赖的外部接口超时,卡住超过超时阈值,然后被当作running超时回滚为pending。但与此同时,另一轮扫描已经把该任务又捞出来执行了一次,而第一轮的超时线程其实又恢复回来了——两条线程并发执行,最后各自写回success,看似成功,实际其中一次已经晚了90分钟。这个问题的根因是执行器里没有加基于任务级别的互斥锁。修复方案:在executeWithRetry最前面加一层任务级分布式锁(我用Redis SETNX实现,过期时间等于超时时间),保证同一时刻同一任务只会被一个线程执行。
问题三:重试风暴。一次下游核心服务升级,多个任务组同时大面积失败。因为重试逻辑是集中式的,结果指数退避算法没有做均匀抖动,所有失败任务在同一步退避时间点同时重试,反而把下游服务再次压垮。这让我想起断路器模式和抖动重试的经典法则:重试时要加入随机抖动,不要让所有请求在同一时刻打向恢复中的服务。最终的修复方案是在计算延迟时引入一个随机因子:delay = initialDelay * 2^retryCount + Random(0, 1000),有效地把重试请求打散,同时额外加上了一层业务组级熔断,组内错误率达到阈值时直接暂停这个组任务90秒。
5.3 性能排查的黄金套路
如果你也用上了类似架构,我建议排查性能问题一定要抓核心链路的三段耗时:
- 投递耗时:任务从提交到进入数据库或队列的耗时。
- 调度等待耗时:任务进入readyQueue到被线程池取走的时间差。
- 执行耗时:实际业务逻辑本身耗时。
监控埋点时,我会在任务实体里记录三个时间戳submit_time、dispatch_time、execute_time,然后通过Prometheus上报。最高效的排查方式永远是把调度器自身的指标采集全,而不是靠日志捞关键字。线上看板一般只关注一个核心数字:任务从投递到完成的P90延迟。只要这个指标稳定,调优就是锦上添花,而不会变成痛苦的排障。
6. 经验总结与扩展建议
6.1 我踩过的坑,请你们避开
把上面所有经验汇总成一句话:调度器不是一个"任务触发工具",它本质是一个有状态、有生命周期、需要治理的子系统。很多人栽跟头,都是只把它当做一个"到点调用一下"的工具在用,结果状态一致性和重复执行问题全在线上捅娄子。
具体建议:
- 永远不要在业务代码里直接new Thread去执行后台任务,无论是定时还是延迟,你需要的是一次统一的治理接入点。
- 任务执行必须显式设置超时。没有超时的任务就是一颗定时炸弹,它的线程池占用量和资源指数会随着慢任务堆积而成倍放大。
- 为执行器增加全链路traceId。任务在队列里流转时,只有依赖唯一的traceId才能把执行记录和时间线串起来,不然排查问题会比登天还难。
- 对任务执行做告警和看板,比做任务本身更重要。调度系统的可观测性直接决定了线上事故恢复速度。
6.2 后续演进的三个方向
这个"ax"目前已经能稳定支撑现有业务量,但如果后续业务持续增长,我会优先考虑三个方向:
第一个方向是分布式调度协调。现在单机部署最多同时承载几十个Worker,再往上就需要考虑分片和负载均衡。可以利用数据库行锁和任务分片机制把任务按ID取模分散到多个节点,但必须解决节点宕机后的任务迁移,否则又会出现单点压力。
第二个方向是引入内存队列多级反馈。目前的架构是数据库扫描为主,内存队列为辅。如果延迟毫秒级的任务越来越多,可以考虑将热门任务直接常驻内存,定期异步快照数据库,这样能让常见路径完全脱离数据库,将近零延迟。
第三个方向是任务编排和工作流。现在的任务模型还是单体的"触发一个Handler",但真实业务往往有先后依赖关系,比如先拉数据、再清洗、再入库、再推送。后续可以扩展为DAG类型的任务图,定义节点间依赖,由调度器负责按拓扑序分发和执行。这个方向前路较长,但也更能体现一个调度器的长期价值。
6.3 写在最后的一些心得
做"ax"这个自研调度器的过程,我最大的感触是:基础组件设计的好不好,不在一开始的代码有多花哨,而在于它是否在最关键的边界条件上做了足够防御。我踩过重复执行、连接池爆裂、重试风暴的坑,最后发现解决方案都不是很炫酷的技术,更多是耐心地把边界画清楚、把数据状态捋顺。
往后如果再让我选一次,即使有现成的分布式调度框架,在小规模业务场景下,我还是会坚持自研一个足够克制、足够清晰的调度器。因为在这个过程中,我对任务状态流转、线程池治理、事务边界这些底层能力的理解,比用任何黑盒框架都要深入得多。这份理解,才是"ax"最值钱的部分。