从0到1:自研分布式定时任务调度系统实战

17 阅读16分钟

从0到1:自研分布式定时任务调度系统实战

支撑千级任务、百万调度的架构实践


小兵:麻雀,最近我们在做一个新项目,要用到定时任务。以前我用过 XXL-JOB,但现在项目不让引入 xxl-job-core 依赖了(因为GPL开源协议的源码分发问题),得自己实现一套分布式定时任务。我有点懵,咋整啊?

麻雀:小兵,别慌!我正好有一套完整的自研方案,已经在多个项目落地,支撑了上千个定时任务。今天跟你好好聊聊这个过程。

小兵:太好了!我就爱听实战案例。从头开始讲呗?

麻雀:好,那咱们就从需求拆解开始,一步步把整个系统搭建起来。


一、背景与挑战

小兵:先说说,一套分布式定时任务系统,核心要解决什么问题?

麻雀:简单来说,就是要解决三个问题:

  1. 任务定义:业务代码怎么告诉调度系统“我要定时执行这个方法”?
  2. 任务调度:调度系统怎么知道什么时候该执行哪个任务?
  3. 任务执行:调度系统怎么通知业务系统执行任务?执行结果怎么反馈?

小兵:听起来就是注册、调度、执行三个阶段?

麻雀:对,你总结得很到位!但分布式环境下,每个阶段都有不少坑。

小兵:比如呢?

麻雀:你看:

  • 注册阶段:多个服务实例同时启动,会不会重复注册?
  • 调度阶段:如果调度中心挂了怎么办?任务扫描性能怎么保证?
  • 执行阶段:业务模块收到重复指令怎么处理?反射调用异常怎么捕获?

小兵:确实都是实际问题。那我们怎么设计?

麻雀:别急,咱们先看看市面上有哪些方案,为什么我们最终选择自研。


二、方案演进:为什么没有直接用Quartz?

小兵:定时任务不是有现成的框架吗?Quartz、XXL-JOB都很成熟啊。

麻雀:没错,我们一开始也调研了这几个方案。

2.1 方案一:直接使用Quartz

Quartz是业界最成熟的调度库,但有几个问题:

问题具体表现对我们的影响
代码侵入业务方需要依赖Quartz的API,实现Job接口业务代码污染严重,耦合度高
进程内调度调度逻辑和业务逻辑在同一个JVM中业务模块重启,调度就中断
分布式支持需要自己实现任务持久化和负载均衡二次开发成本高

小兵:那XXL-JOB呢?它本来就是分布式的。

2.2 方案二:XXL-JOB的协议问题

麻雀:XXL-JOB确实成熟,我们之前也用得挺好。但问题出在开源协议上。

XXL-JOB基于GPL协议开源,要求:

如果您的项目中引入了XXL-JOB的核心依赖(xxl-job-core),并且对源码进行了修改,那么您的整个项目也必须开源。

小兵:我们项目是公司内部商业项目,不能开源啊。

麻雀:对,这就是核心矛盾。我们调研过能否剥离依赖,但GPL协议的传染性很强,法律风险太高。

2.3 方案三:自研调度中心

小兵:所以最后决定自研?

麻雀:对。但我们不是从零造轮子,而是借鉴了主流框架的设计思想,结合自身需求做了取舍:

设计点XXL-JOB做法我们做法取舍原因
通信协议自研RPCHTTP REST简化实现,便于跨语言
注册方式客户端主动注册注解扫描+HTTP注册业务无感,代码侵入低
调度策略时间轮数据库轮询实现简单,够用
高可用数据库锁数据库事务利用现有能力

小兵:明白了!最终方案是:调度中心独立部署 + 业务模块无感接入 + HTTP通信 + 数据库统一存储

麻雀:没错!核心就是调度与执行分离


三、整体架构设计

小兵:那整体架构长什么样?

麻雀:看这个架构图:

┌─────────────────────────────────────────────────────────────────┐
│                         分布式定时任务调度系统                      │
└─────────────────────────────────────────────────────────────────┘
​
┌──────────────────────┐          HTTP/REST          ┌──────────────────────┐
│                      │ ◄────────────────────────► │                      │
│   Scheduler Center   │                              │   Business Module   │
│   (调度中心)          │                              │   (业务模块)          │
│                      │                              │                      │
│  ┌────────────────┐  │                              │  ┌────────────────┐  │
│  │ 任务管理        │  │                              │  │ 任务注册        │  │
│  │ 任务调度        │  │                              │  │ 任务执行        │  │
│  │ 状态监控        │  │                              │  │ 结果反馈        │  │
│  │ API接口         │  │                              │  │ 注解扫描        │  │
│  └────────────────┘  │                              │  └────────────────┘  │
│          ↓           │                              │          ↓           │
│  ┌────────────────┐  │                              │  ┌────────────────┐  │
│  │  数据访问层     │  │                              │  │  反射调用       │  │
│  └────────────────┘  │                              │  └────────────────┘  │
└──────────────────────┘                              └──────────────────────┘
           ↓                                                        ↓
┌─────────────────────────────────────────────────────────────────────────┐
│                         PostgreSQL/MySQL 数据库                         │
│  ┌──────────────────────┐      ┌──────────────────────┐                │
│  │ 任务定义表            │      │ 执行记录表            │                │
│  │ task_definition       │      │ task_execution_record │                │
│  └──────────────────────┘      └──────────────────────┘                │
└─────────────────────────────────────────────────────────────────────────┘

核心流程:

阶段职责技术要点
任务注册业务模块启动时,扫描注解并将任务定义发送到调度中心注解扫描、幂等注册、HTTP异步发送
任务调度调度中心定时扫描待执行任务,发送执行指令Cron解析、分片扫描、HTTP调用
任务执行业务模块接收指令,反射执行业务逻辑反射调用、异常处理、重试机制
结果反馈执行完成后将结果发送回调度中心更新状态状态更新、执行记录、补偿机制

小兵:清楚了!咱们就按这个顺序,一个阶段一个阶段地实现。


四、任务注册阶段:让业务无感接入

4.1 核心设计

小兵:先看注册阶段。怎么让业务代码无感接入?

麻雀:最优雅的方式是注解驱动。业务开发只需要在方法上加一个注解,系统自动完成注册。

小兵:就像Spring的@Scheduled那样?

麻雀:对,但我们要把任务定义注册到调度中心,而不是本地执行。

核心流程:

  1. 业务模块启动时,扫描所有带@ScheduledTask注解的方法
  2. 解析注解信息,构建任务定义
  3. 通过HTTP调用注册到调度中心
  4. 调度中心存储任务定义,计算下次执行时间

4.2 关键代码

自定义注解
package com.example.job.annotation;
​
import java.lang.annotation.*;
​
/**
 * 分布式任务注解
 */
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface ScheduledTask {
    
    /**
     * 任务ID(全局唯一)
     */
    String taskId();
    
    /**
     * 任务名称
     */
    String name();
    
    /**
     * 任务描述
     */
    String description() default "";
    
    /**
     * Cron表达式
     */
    String cron();
}
任务注册扫描器
package com.example.job.scanner;
​
import com.example.job.annotation.ScheduledTask;
import com.example.job.entity.TaskDefinition;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;
​
import javax.annotation.PostConstruct;
import java.lang.reflect.Method;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
​
/**
 * 任务注册扫描器
 * 
 * 坑点1:重复注册问题 -> 使用taskId做幂等
 * 坑点2:扫描性能问题 -> 只扫描@Component的Bean
 * 坑点3:HTTP调用失败 -> 记录日志,可考虑重试
 */
@Component
public class TaskRegisterScanner {
    
    private static final Logger logger = LoggerFactory.getLogger(TaskRegisterScanner.class);
    
    @Autowired
    private ApplicationContext applicationContext;
    
    @Autowired
    private RestTemplate restTemplate;
    
    @Value("${scheduler.center.url:http://localhost:8080}")
    private String schedulerCenterUrl;
    
    @PostConstruct
    public void scanAndRegister() {
        logger.info("开始扫描定时任务注解...");
        
        // 获取所有Spring管理的Bean
        Map<String, Object> beans = applicationContext.getBeansWithAnnotation(Component.class);
        
        List<TaskDefinition> taskList = new ArrayList<>();
        
        for (Object bean : beans.values()) {
            Method[] methods = bean.getClass().getDeclaredMethods();
            
            for (Method method : methods) {
                ScheduledTask taskAnnotation = method.getAnnotation(ScheduledTask.class);
                if (taskAnnotation == null) {
                    continue;
                }
                
                TaskDefinition task = buildTaskDefinition(bean, method, taskAnnotation);
                taskList.add(task);
                
                logger.info("扫描到任务: {} - {}", task.getTaskId(), task.getDescription());
            }
        }
        
        if (!taskList.isEmpty()) {
            registerTasks(taskList);
        }
    }
    
    private void registerTasks(List<TaskDefinition> taskList) {
        String url = schedulerCenterUrl + "/api/scheduler/tasks/register/batch";
        
        try {
            restTemplate.postForObject(url, taskList, String.class);
            logger.info("已注册{}个任务到调度中心", taskList.size());
        } catch (Exception e) {
            logger.error("任务注册失败", e);
            // 这里可以记录到本地表,等待补偿
        }
    }
}

4.3 坑点复盘

坑点表现解决方案
重复注册多个业务模块同时启动,同一任务注册多次taskId做唯一索引,插入前先查询
时间精度多节点服务器时间不一致,触发时间错乱使用数据库时间或NTP统一时间
注册信息丢失HTTP调用失败,任务没注册上记录失败日志,考虑补偿机制

小兵:第一个坑就很有价值!那调度中心收到注册信息后怎么处理?

麻雀:调度中心需要存储任务定义,并计算下次执行时间。这个计算要用Quartz的CronExpression,它能处理夏令时、闰秒等边界情况。


五、任务调度阶段:核心引擎设计

5.1 核心设计

小兵:调度中心的核心就是那个“永不停歇的扫描器”吧?

麻雀:对!调度中心的核心是一个定时扫描器,每隔几秒扫描一次数据库,找出需要执行的任务。

核心流程:

  1. 定时扫描待执行任务(next_execute_time <= now()
  2. 生成唯一执行ID(雪花算法)
  3. 创建执行记录,状态为RUNNING
  4. 调用业务模块执行任务
  5. 更新执行记录状态,计算下次执行时间

5.2 关键代码

雪花算法ID生成器
package com.example.job.util;
​
/**
 * 雪花算法ID生成器
 * 用于生成全局唯一的执行ID
 */
@Component
public class SnowflakeIdGenerator {
    
    // 起始时间戳 (2025-01-01 00:00:00)
    private static final long START_TIMESTAMP = 1735660800000L;
    
    // 数据中心ID所占位数
    private static final long DATACENTER_ID_BITS = 5L;
    // 机器ID所占位数
    private static final long WORKER_ID_BITS = 5L;
    // 序列号所占位数
    private static final long SEQUENCE_BITS = 12L;
    
    private final long datacenterId;
    private final long workerId;
    private long sequence = 0L;
    private long lastTimestamp = -1L;
    
    public SnowflakeIdGenerator(long datacenterId, long workerId) {
        this.datacenterId = datacenterId;
        this.workerId = workerId;
    }
    
    public synchronized long nextId() {
        long timestamp = System.currentTimeMillis();
        
        // 时钟回拨检查
        if (timestamp < lastTimestamp) {
            throw new RuntimeException("时钟回拨,拒绝生成ID");
        }
        
        if (timestamp == lastTimestamp) {
            sequence = (sequence + 1) & ~(-1L << SEQUENCE_BITS);
            if (sequence == 0) {
                timestamp = waitNextMillis(lastTimestamp);
            }
        } else {
            sequence = 0L;
        }
        
        lastTimestamp = timestamp;
        
        return ((timestamp - START_TIMESTAMP) << (WORKER_ID_BITS + DATACENTER_ID_BITS + SEQUENCE_BITS))
                | (datacenterId << (WORKER_ID_BITS + SEQUENCE_BITS))
                | (workerId << SEQUENCE_BITS)
                | sequence;
    }
    
    private long waitNextMillis(long lastTimestamp) {
        long timestamp = System.currentTimeMillis();
        while (timestamp <= lastTimestamp) {
            timestamp = System.currentTimeMillis();
        }
        return timestamp;
    }
}
调度中心任务扫描器
package com.example.scheduler.scanner;
​
/**
 * 调度中心-任务扫描器
 * 
 * 坑点1:扫描性能问题 -> 使用索引 + 分页查询
 * 坑点2:重复执行问题 -> 事务保证原子性
 * 坑点3:调用失败处理 -> 重试机制 + 状态标记
 */
@Component
public class TaskScanner {
    
    private static final Logger logger = LoggerFactory.getLogger(TaskScanner.class);
    
    @Autowired
    private TaskDefinitionRepository taskRepository;
    
    @Autowired
    private TaskExecutionRecordRepository recordRepository;
    
    @Autowired
    private RestTemplate restTemplate;
    
    @Autowired
    private SnowflakeIdGenerator idGenerator;
    
    @Value("${job.scanner.batch-size:100}")
    private int batchSize;
    
    /**
     * 定时扫描任务(每5秒执行一次)
     */
    @Scheduled(fixedDelay = 5000)
    @Transactional
    public void scanTasks() {
        logger.debug("开始扫描待执行任务...");
        
        LocalDateTime now = LocalDateTime.now();
        
        // 分页查询待执行任务(避免一次加载太多)
        int page = 0;
        List<TaskDefinition> tasks;
        
        do {
            tasks = taskRepository.findPendingTasks(now, page * batchSize, batchSize);
            
            for (TaskDefinition task : tasks) {
                try {
                    executeTask(task);
                } catch (Exception e) {
                    logger.error("执行任务失败: {}", task.getTaskId(), e);
                }
            }
            
            page++;
        } while (tasks.size() == batchSize);
    }
    
    private void executeTask(TaskDefinition task) {
        logger.info("开始执行任务: {}", task.getTaskId());
        
        // 1. 生成执行ID(雪花算法)
        Long executionId = idGenerator.nextId();
        
        // 2. 创建执行记录
        TaskExecutionRecord record = createExecutionRecord(executionId, task);
        recordRepository.save(record);
        
        // 3. 调用业务模块执行任务
        TaskExecutionResult result = callBusinessModule(executionId, task);
        
        // 4. 更新执行记录
        updateExecutionRecord(record, result);
        
        // 5. 计算下次执行时间
        calculateNextExecuteTime(task);
        taskRepository.save(task);
    }
}

5.3 坑点复盘

坑点表现解决方案
扫描性能任务量大了后,扫描全表导致数据库负载高next_execute_time加索引,分页查询
重复执行多节点调度中心同时扫描到同一任务用事务保证原子性,@Transactional
调用失败业务模块挂了,调用超时重试机制 + 失败状态标记
Cron计算错误夏令时、闰秒导致时间计算错误使用Quartz的CronExpression

小兵:雪花算法解决了分布式ID唯一性问题,分页查询解决了性能问题,事务保证了不重复执行。这套设计考虑得很全面!

麻雀:对,但调度中心只是发指令,真正的执行在业务模块。


六、任务执行阶段:反射调用的艺术

6.1 核心设计

小兵:业务模块收到指令后,怎么执行具体的业务方法?

麻雀:核心是反射调用。调度中心告诉业务模块:执行哪个类的哪个方法,业务模块通过反射找到这个方法并调用。

小兵:反射调用有什么坑?

麻雀:坑不少:

  • 方法找不到(类名、方法名不匹配)
  • 私有方法无法访问
  • 参数类型不匹配
  • 执行异常没有捕获,影响其他任务
  • 调度中心重试导致重复执行

6.2 关键代码

业务模块任务执行器
package com.example.business.executor;
​
/**
 * 任务执行器(业务模块)
 * 
 * 坑点1:反射调用异常 -> try-catch包裹,记录详细日志
 * 坑点2:私有方法调用 -> setAccessible(true)
 * 坑点3:幂等性问题 -> 使用executionId做幂等检查
 */
@RestController
@RequestMapping("/internal/task")
public class TaskExecutor {
    
    private static final Logger logger = LoggerFactory.getLogger(TaskExecutor.class);
    
    /**
     * 本地缓存已执行的ID(简单幂等)
     */
    private final Map<Long, Boolean> executedCache = new ConcurrentHashMap<>();
    
    @Autowired
    private SpringContextUtils springContextUtils;
    
    @PostMapping("/execute")
    public TaskExecutionResult execute(@RequestBody ExecuteRequest request) {
        logger.info("收到任务执行请求: executionId={}, taskId={}", 
            request.getExecutionId(), request.getTaskId());
        
        long startTime = System.currentTimeMillis();
        
        // 1. 幂等性检查
        if (executedCache.containsKey(request.getExecutionId())) {
            logger.info("任务已执行过,直接返回: {}", request.getExecutionId());
            return TaskExecutionResult.success("已执行过", 0L);
        }
        
        try {
            // 2. 根据taskId获取对应的Bean
            Object bean = springContextUtils.getBeanByTaskId(request.getTaskId());
            
            // 3. 获取方法
            Method method = findMethod(bean.getClass(), request.getMethodName());
            
            if (method == null) {
                throw new NoSuchMethodException("方法不存在: " + request.getMethodName());
            }
            
            // 4. 设置可访问(如果是private方法)
            method.setAccessible(true);
            
            // 5. 执行方法
            Object result = method.invoke(bean);
            
            // 6. 记录已执行
            executedCache.put(request.getExecutionId(), true);
            
            long duration = System.currentTimeMillis() - startTime;
            logger.info("任务执行成功: {}, 耗时: {}ms", request.getTaskId(), duration);
            
            return TaskExecutionResult.success(
                result != null ? result.toString() : "success", 
                duration);
            
        } catch (Exception e) {
            logger.error("任务执行失败: {}", request.getTaskId(), e);
            
            long duration = System.currentTimeMillis() - startTime;
            return TaskExecutionResult.failed(e.getMessage(), duration);
        }
    }
}

6.3 坑点复盘

坑点表现解决方案
方法找不到NoSuchMethodException注册时记录完整方法签名,执行时严格匹配
私有方法IllegalAccessExceptionmethod.setAccessible(true)
反射异常异常导致后续任务无法执行try-catch包裹,记录日志不影响其他任务
重复执行调度中心重试,业务模块收到相同executionId本地缓存幂等检查

小兵:幂等性这个太重要了!不加的话,调度中心重试一次,业务就重复执行一次。

麻雀:对,分布式系统里,幂等是底线


七、数据库设计

小兵:数据库表怎么设计的?

麻雀:两张核心表:任务定义表、执行记录表。

任务定义表

CREATE TABLE `task_definition` (
    `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
    `task_id` varchar(128) NOT NULL COMMENT '任务ID(唯一标识)',
    `task_name` varchar(200) NOT NULL COMMENT '任务名称',
    `description` varchar(500) DEFAULT NULL COMMENT '任务描述',
    `module` varchar(100) NOT NULL COMMENT '业务模块标识',
    `class_name` varchar(500) NOT NULL COMMENT '全限定类名',
    `method_name` varchar(200) NOT NULL COMMENT '方法名',
    `cron_expression` varchar(50) NOT NULL COMMENT 'Cron表达式',
    `status` varchar(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '任务状态:ACTIVE-启用 INACTIVE-禁用',
    `last_execute_time` datetime DEFAULT NULL COMMENT '上次执行时间',
    `next_execute_time` datetime DEFAULT NULL COMMENT '下次执行时间',
    `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
    PRIMARY KEY (`id`),
    UNIQUE KEY `uk_task_id` (`task_id`),
    KEY `idx_next_execute_time` (`next_execute_time`),
    KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='任务定义表';

任务执行记录表

CREATE TABLE `task_execution_record` (
    `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
    `execution_id` bigint(20) NOT NULL COMMENT '执行ID(雪花算法生成)',
    `task_id` varchar(128) NOT NULL COMMENT '任务ID',
    `task_name` varchar(200) NOT NULL COMMENT '任务名称',
    `execute_time` datetime NOT NULL COMMENT '执行开始时间',
    `complete_time` datetime DEFAULT NULL COMMENT '完成时间',
    `status` varchar(20) NOT NULL COMMENT '执行状态:RUNNING-执行中 SUCCESS-成功 FAILED-失败',
    `result` text COMMENT '执行结果',
    `error_message` text COMMENT '错误信息',
    `duration` bigint(20) DEFAULT NULL COMMENT '执行耗时(ms)',
    `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    PRIMARY KEY (`id`),
    UNIQUE KEY `uk_execution_id` (`execution_id`),
    KEY `idx_task_id` (`task_id`),
    KEY `idx_execute_time` (`execute_time`),
    KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='任务执行记录表';

关键索引

  • task_definitionnext_execute_time(扫描性能)、status(状态过滤)
  • task_execution_recordexecution_id(幂等)、task_id(任务历史查询)

八、落地过程:从第一个业务到全面推广

小兵:系统设计好了,上线过程顺利吗?有没有遇到什么坑?

麻雀:当然有!我讲讲我们灰度上线的过程踩过的坑

8.1 灰度策略

我们采用了 “业务-环境-任务”三级灰度策略

灰度层级范围目标
业务级选择一个非核心业务(日志清理任务)验证基础功能
环境级测试环境 → 预发环境 → 生产环境验证稳定性
任务级低频任务(每小时)→ 中频任务(每分钟)→ 高频任务(每秒)验证性能

8.2 踩坑记录

坑点1:测试环境一切正常,上线后注册失败

表现:第一个业务模块在生产环境启动时,任务注册成功率只有60%,大量任务没注册上。

排查:发现调度中心和生产环境的业务模块几乎同时启动,业务模块注册时调度中心还没完全就绪,HTTP调用超时。

解决方案

// 业务模块增加启动延迟和重试机制
@PostConstruct
public void scanAndRegister() {
    // 启动后延迟5秒,等待调度中心完全启动
    Thread.sleep(5000);
    
    // 增加重试机制
    retryTemplate.execute(context -> {
        doRegister();
        return null;
    });
}

坑点2:凌晨2点任务高峰期,数据库连接池被打满

表现:上线第三天凌晨2点,监控告警:数据库连接数飙升至200+,接近连接池上限,部分任务执行失败。

排查:发现凌晨2点是任务执行高峰期,调度中心的扫描线程和业务模块反馈结果的线程共用同一个数据库连接池,导致连接竞争。

解决方案

  • 读写分离:调度扫描用只读库,业务反馈用主库
  • 连接池隔离:调度中心和业务模块用不同的连接池
# 调度中心配置
spring:
  datasource:
    read-only:  # 只读库,用于扫描
      url: jdbc:mysql://readonly-db:3306/job_db
    write:      # 主库,用于写入执行结果
      url: jdbc:mysql://master-db:3306/job_db

坑点3:业务模块重启导致任务丢失

表现:业务模块发版重启期间,调度中心发来的执行指令全部失败,这些任务就丢失了。

排查:调度中心没有针对业务模块不可用的重试策略。

解决方案:调度中心增加失败重试+状态标记机制:

private void executeTask(TaskDefinition task) {
    // ... 省略其他代码
    
    try {
        TaskExecutionResult result = callBusinessModule(executionId, task);
        // 成功处理
    } catch (Exception e) {
        // 标记失败状态,等待补偿任务重试
        task.setStatus("RETRY");
        task.setRetryCount(task.getRetryCount() + 1);
        task.setNextRetryTime(calculateNextRetryTime());
    }
}

8.3 团队协作

小兵:这么多业务模块接入,团队之间怎么配合的?

麻雀:我们建立了三方协作机制

角色职责工作内容
业务团队任务定义、接入测试提供任务清单、配合灰度、反馈问题
中间件团队(我们)平台维护、技术支持提供接入文档、排查问题、优化平台
SRE团队监控告警、容量规划配置监控大盘、设置告警阈值、规划资源

关键产出

  • 《定时任务接入规范》 :定义taskId命名规范、Cron表达式规范、幂等要求
  • 《故障应急流程》 :任务失败怎么告警、怎么回滚、怎么恢复
  • 《接入Checklist》 :业务方接入前需要确认的20个事项

九、最终成果

小兵:这套系统最终效果怎么样?

麻雀:目前已经稳定运行一年,来看看具体数据。

9.1 性能指标对比

指标优化前(XXL-JOB)优化后(自研)提升
单机支撑任务数1500+2500+60%↑
任务注册耗时30ms8ms73%↓
调度延迟(P99)250ms120ms52%↓
数据库连接数402537%↓
接入成本需引入依赖,修改代码仅需注解,无感接入-

9.2 业务收益

  • 规模:支撑了8个业务模块、600+个定时任务
  • 调度量:日均调度量万次
  • 效率:业务方接入时间从2天缩短到2小时

小兵:太牛了!这不仅仅是技术上的成功,更是对整个研发效率的提升。


十、总结与反思

小兵:今天收获太大了!我总结一下核心要点:

阶段核心设计关键坑点
任务注册注解扫描、HTTP注册重复注册、时间精度
任务调度定时扫描、雪花ID、分页查询扫描性能、重复执行
任务执行反射调用、幂等检查反射异常、私有方法

麻雀:总结得很到位!我再补充三点反思:

1. 分布式系统的核心是状态管理

从注册到调度到执行,每一步都要考虑状态的一致性。雪花算法保证ID唯一,事务保证操作原子性,幂等保证重复安全。状态管理做不好,分布式就是个灾难

2. 性能优化要从索引开始

一开始就设计好索引,比事后优化成本低得多。next_execute_time加索引,分页查询,就能支撑百万级任务。如果让我重来一次,我会在第一天就把索引加上

3. 给业务方最好的体验是无感

用注解驱动,业务方只需要关注业务逻辑,不需要关心调度细节。这才是好的框架设计。好的框架是让使用者感受不到框架的存在

可复用的方法论

最后,分享一个我从这个项目中总结的自研中间件四步法

步骤做什么产出
1. 选型分析调研现有方案,明确为什么不自用方案对比表格、技术决策文档
2. MVP验证用最简单的代码跑通核心流程可运行的Demo、核心流程文档
3. 灰度打磨找1-2个非核心业务试点,踩坑填坑接入规范、故障预案
4. 全面推广完善文档、建立SLA、培训业务方用户手册、SLA承诺、培训材料

小兵:这个好!以后做中间件就按这个来。

麻雀:对,这套方法论已经在我后续的几个项目中验证过,很实用。


作者:麻雀

微信公众号/b站:麻雀聊技术(欢迎关注公众号领取配套资料,后续会写SSE、分布式调度、动态属性等系列文章)

如果你觉得这篇文章对你有帮助,欢迎点赞、评论、转发。有任何问题,评论区见,我会逐一回复。


【附录】文中涉及的技术栈

  • 语言:Java
  • 框架:Spring Boot、Spring MVC
  • 工具:雪花算法、Quartz CronExpression
  • 数据库:MySQL/PostgreSQL
  • 通信:RestTemplate