【任务调度:数据库锁 + 线程池实战】4、架构实战:用线程池 + SKIP LOCKED 构建高可用分布式调度引擎

🚀 架构实战:用线程池 + SKIP LOCKED 构建高可用分布式调度引擎

从零到一打造支持水平扩展、故障自愈的分布式任务调度框架

在前三篇文章中,我们深入探讨了 SELECT FOR UPDATE SKIP LOCKED 的原理、数据库差异以及锁机制。理论终需落地,今天我们将进入实战环节:如何基于线程池和 SKIP LOCKED 构建一个生产可用的分布式调度引擎

本文会涵盖整体架构设计、核心调度器实现、任务执行器扩展、故障恢复机制等关键模块。所有代码均以 Java + Spring Boot 为例,并提供完整的 mermaid 流程图,帮助你轻松理解整个系统的运作方式。


一、整体架构设计

1.1 系统架构图

一个高可用的分布式调度系统通常由多个无状态的调度节点组成,它们共享同一个数据库,通过 SKIP LOCKED 竞争任务。每个节点内部包含调度线程池、任务执行线程池以及监控模块。
节点内部
调度集群
调度节点1
调度节点2
调度节点N
数据库
轮询线程

定时拉取
动态线程池
任务执行器1
任务执行器2
任务执行器3
心跳线程
监控线程
死任务检测

(独立模块或定时任务)

1.2 数据库任务表设计

任务表是整个系统的核心,设计的好坏直接影响调度效率和可靠性。以下是一个经过实战检验的表结构(以 MySQL 为例):

sql 复制代码
CREATE TABLE `task` (
  `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
  `task_type` VARCHAR(64) NOT NULL COMMENT '任务类型,用于路由到具体的执行器',
  `payload` JSON DEFAULT NULL COMMENT '任务参数,JSON格式,灵活扩展',
  `status` TINYINT NOT NULL DEFAULT '0' COMMENT '状态:0-待执行 1-执行中 2-成功 3-失败 4-待重试',
  `priority` TINYINT NOT NULL DEFAULT '5' COMMENT '优先级 1-10,数值越大优先级越高',
  `execute_time` DATETIME NOT NULL COMMENT '计划执行时间(或下次重试时间)',
  `retry_times` TINYINT NOT NULL DEFAULT '0' COMMENT '已重试次数',
  `max_retries` TINYINT NOT NULL DEFAULT '3' COMMENT '最大重试次数',
  `node_id` VARCHAR(64) DEFAULT NULL COMMENT '当前执行节点ID,用于故障恢复',
  `version` INT NOT NULL DEFAULT '0' COMMENT '乐观锁版本(可选,用于防止并发更新)',
  `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  PRIMARY KEY (`id`),
  KEY `idx_status_exec_time` (`status`, `execute_time`),
  KEY `idx_node_id` (`node_id`),
  KEY `idx_task_type` (`task_type`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='任务表';

关键字段说明

  • task_type:用于区分不同的业务任务,如 EMAILREPORTCLEANUP 等。
  • payload:JSON 格式,存储任务所需的参数,灵活且无需改表。
  • status:状态机流转如下:

创建任务
节点领取
执行完成
失败且未达最大重试次数
重试时间到,再次领取
失败且已达最大重试次数
待执行
执行中
成功
待重试
失败

  • priority:支持优先级调度,结合 ORDER BY 使用。
  • node_id:记录当前执行任务的节点,用于检测死任务(节点宕机后,其他节点可接管)。

索引优化 :最核心的查询是 WHERE status IN (0,4) AND execute_time <= NOW() ORDER BY priority DESC, execute_time ASC LIMIT ?,因此需要建立 (status, execute_time) 复合索引。如果还涉及 priority 排序,可以尝试 (status, priority, execute_time),但要注意索引大小和更新成本。


二、核心调度器实现

2.1 定时拉取 + SKIP LOCKED

每个调度节点都有一个后台轮询线程,定期执行以下操作:

  1. 计算当前可用的执行线程槽位数(动态负载控制)。
  2. 使用 SELECT FOR UPDATE SKIP LOCKED 从数据库拉取待执行任务。
  3. 将拉取到的任务提交给线程池执行。

下面是核心代码(基于 Spring Boot + JdbcTemplate):

java 复制代码
@Component
@Slf4j
public class DistributedTaskScheduler implements InitializingBean, DisposableBean {
    @Autowired
    private JdbcTemplate jdbcTemplate;
    @Autowired
    private TaskExecutorFactory executorFactory;
    
    @Value("${scheduler.node-id}")
    private String nodeId;
    
    @Value("${scheduler.poll-interval:1000}")
    private long pollIntervalMs;
    
    @Value("${scheduler.batch-size:20}")
    private int batchSize;
    
    @Value("${scheduler.thread-pool.core-size:10}")
    private int corePoolSize;
    
    @Value("${scheduler.thread-pool.max-size:50}")
    private int maxPoolSize;
    
    private ScheduledExecutorService pollExecutor;
    private ThreadPoolExecutor taskExecutor;
    private final Set<Long> processingIds = ConcurrentHashMap.newKeySet();
    private final Map<Long, Future<?>> runningTasks = new ConcurrentHashMap<>();
    private volatile boolean running = false;

    @Override
    public void afterPropertiesSet() {
        initialize();
    }

    private void initialize() {
        // 创建动态线程池
        this.taskExecutor = new ThreadPoolExecutor(
                corePoolSize,
                maxPoolSize,
                60L, TimeUnit.SECONDS,
                new LinkedBlockingQueue<>(1000),
                new ThreadFactoryBuilder().setNameFormat("task-worker-%d").build(),
                new ThreadPoolExecutor.CallerRunsPolicy() // 当队列满时,由调用线程执行
        );
        
        this.pollExecutor = Executors.newSingleThreadScheduledExecutor(
                new ThreadFactoryBuilder().setNameFormat("poll-thread").build()
        );
        
        this.running = true;
        
        // 启动轮询任务
        pollExecutor.scheduleWithFixedDelay(this::pollLoop, 0, pollIntervalMs, TimeUnit.MILLISECONDS);
        
        // 启动监控
        startMonitor();
        
        log.info("Task scheduler started on node: {}", nodeId);
    }

    /**
     * 核心轮询逻辑
     */
    private void pollLoop() {
        if (!running) return;
        
        try {
            // 计算当前可用的线程槽位
            int availableSlots = maxPoolSize - taskExecutor.getActiveCount() - processingIds.size();
            if (availableSlots <= 0) {
                log.debug("No available slots, skip polling");
                return;
            }
            
            int fetchSize = Math.min(availableSlots, batchSize);
            List<Task> tasks = fetchTasksWithSkipLocked(fetchSize);
            
            if (!tasks.isEmpty()) {
                log.info("Fetched {} tasks, submitting to thread pool", tasks.size());
                tasks.forEach(this::submitTask);
            }
        } catch (Exception e) {
            log.error("Error in poll loop", e);
        }
    }

    /**
     * 使用 SKIP LOCKED 拉取任务
     */
    @Transactional
    public List<Task> fetchTasksWithSkipLocked(int limit) {
        String sql = """
            SELECT id, task_type, payload, status, execute_time, retry_times, max_retries, priority
            FROM task
            WHERE status IN (0, 4) 
              AND execute_time <= NOW()
            ORDER BY priority DESC, execute_time ASC
            LIMIT ?
            FOR UPDATE SKIP LOCKED
            """;
        
        List<Task> tasks = jdbcTemplate.query(sql, (rs, rowNum) -> {
            Task task = new Task();
            task.setId(rs.getLong("id"));
            task.setTaskType(rs.getString("task_type"));
            task.setPayload(rs.getString("payload"));
            task.setStatus(rs.getInt("status"));
            task.setExecuteTime(rs.getTimestamp("execute_time").toLocalDateTime());
            task.setRetryTimes(rs.getInt("retry_times"));
            task.setMaxRetries(rs.getInt("max_retries"));
            task.setPriority(rs.getInt("priority"));
            return task;
        }, limit);
        
        if (tasks.isEmpty()) {
            return Collections.emptyList();
        }
        
        // 更新任务状态为"执行中",并记录 node_id
        List<Long> ids = tasks.stream().map(Task::getId).collect(Collectors.toList());
        claimTasks(ids, nodeId);
        
        return tasks;
    }

    /**
     * 批量认领任务(更新状态和 node_id)
     */
    private void claimTasks(List<Long> ids, String nodeId) {
        String inClause = ids.stream().map(String::valueOf).collect(Collectors.joining(","));
        String sql = String.format("""
            UPDATE task 
            SET status = 1, node_id = '%s', update_time = NOW()
            WHERE id IN (%s) AND status IN (0,4)
            """, nodeId, inClause);
        jdbcTemplate.update(sql);
    }

    /**
     * 提交任务到线程池执行
     */
    private void submitTask(Task task) {
        // 本地去重:防止因并发导致重复提交
        if (!processingIds.add(task.getId())) {
            log.warn("Task {} already in processing, skip", task.getId());
            return;
        }
        
        Future<?> future = taskExecutor.submit(() -> executeTask(task));
        runningTasks.put(task.getId(), future);
    }
}

2.2 动态线程池管理

上面的代码中,线程池使用了 ThreadPoolExecutor,并通过 availableSlots 动态计算可拉取的任务数量,避免任务堆积在线程池队列中。当线程池活跃线程数接近最大值时,轮询线程会减少拉取量,实现**背压(backpressure)**机制。

线程池配置要点:

  • corePoolSize:核心线程数,根据系统平均负载设置。
  • maxPoolSize:最大线程数,应对突发流量。
  • queue:有界队列,防止内存溢出。
  • 拒绝策略 :这里使用 CallerRunsPolicy,当队列满时由轮询线程执行任务,相当于降级。

2.3 本地去重与任务追踪

为什么需要 processingIdsrunningTasks

  • processingIds:一个线程安全的 Set,用于记录当前正在处理的任务 ID,防止同一任务被多次提交(例如,轮询线程在一次循环中不小心重复拉取到相同任务,虽然 SKIP LOCKED 保证了不同节点不会重复,但同一节点内由于异步提交可能导致重复)。
  • runningTasks:记录每个任务的 Future,用于后续可能的取消操作(如优雅关闭时)。

三、任务执行器工厂模式

3.1 插件化设计

为了支持多种任务类型(如邮件、报表、清理等),我们采用工厂模式 + 策略模式。每个任务类型对应一个 TaskExecutor 实现类,通过 task_type 路由到正确的执行器。

java 复制代码
public interface TaskExecutor {
    /**
     * 返回执行器支持的任务类型
     */
    String getType();

    /**
     * 执行任务
     * @param payload 任务参数(JSON 格式)
     * @return 执行结果(可选)
     * @throws Exception 执行失败时抛出异常
     */
    Object execute(String payload) throws Exception;
}

工厂类负责管理所有执行器:

java 复制代码
@Component
public class TaskExecutorFactory {
    private final Map<String, TaskExecutor> executorMap = new ConcurrentHashMap<>();

    @Autowired
    public TaskExecutorFactory(List<TaskExecutor> executors) {
        executors.forEach(e -> executorMap.put(e.getType(), e));
    }

    public TaskExecutor getExecutor(String type) {
        TaskExecutor executor = executorMap.get(type);
        if (executor == null) {
            throw new IllegalArgumentException("No executor found for type: " + type);
        }
        return executor;
    }

    // 允许动态注册(如热加载)
    public void register(TaskExecutor executor) {
        executorMap.put(executor.getType(), executor);
    }
}

3.2 示例:邮件任务执行器

java 复制代码
@Component
public class EmailTaskExecutor implements TaskExecutor {
    @Autowired
    private EmailService emailService;

    @Override
    public String getType() {
        return "EMAIL";
    }

    @Override
    public Object execute(String payload) throws Exception {
        // 解析 JSON 参数
        JsonNode params = JsonUtils.parse(payload);
        String to = params.get("to").asText();
        String subject = params.get("subject").asText();
        String content = params.get("content").asText();

        emailService.send(to, subject, content);
        return Map.of("sent", true, "to", to);
    }
}

3.3 执行任务的核心方法

executeTask 方法中,我们调用工厂获取执行器,并处理成功/失败逻辑:

java 复制代码
private void executeTask(Task task) {
    long taskId = task.getId();
    String taskType = task.getTaskType();
    int retryTimes = task.getRetryTimes();
    int maxRetries = task.getMaxRetries();

    try {
        TaskExecutor executor = executorFactory.getExecutor(taskType);
        Object result = executor.execute(task.getPayload());
        
        // 执行成功,更新任务状态
        markSuccess(taskId);
        log.info("Task {} executed successfully", taskId);
    } catch (Exception e) {
        log.error("Task {} execution failed: {}", taskId, e.getMessage(), e);
        
        // 判断是否需要重试
        if (retryTimes < maxRetries) {
            // 下次重试时间 = 当前时间 + 指数退避
            LocalDateTime nextRetry = calculateNextRetryTime(retryTimes + 1);
            markForRetry(taskId, retryTimes + 1, nextRetry, e.getMessage());
        } else {
            markFailed(taskId, e.getMessage());
        }
    } finally {
        // 清理本地追踪
        processingIds.remove(taskId);
        runningTasks.remove(taskId);
    }
}

private void markSuccess(Long taskId) {
    jdbcTemplate.update("UPDATE task SET status = 2, node_id = NULL WHERE id = ?", taskId);
}

private void markForRetry(Long taskId, int retryTimes, LocalDateTime nextRetry, String errorMsg) {
    jdbcTemplate.update("""
        UPDATE task 
        SET status = 4, retry_times = ?, execute_time = ?, node_id = NULL, 
            update_time = NOW()
        WHERE id = ?
        """, retryTimes, nextRetry, taskId);
    // 可选:记录错误日志到 task_log 表
}

private void markFailed(Long taskId, String errorMsg) {
    jdbcTemplate.update("UPDATE task SET status = 3, node_id = NULL WHERE id = ?", taskId);
}

/**
 * 指数退避计算下次重试时间:2^n 秒后,加上随机抖动(0~2^n 秒)
 */
private LocalDateTime calculateNextRetryTime(int retryTimes) {
    long baseDelay = (long) Math.pow(2, retryTimes) * 1000; // 毫秒
    long jitter = ThreadLocalRandom.current().nextLong(baseDelay);
    return LocalDateTime.now().plusNanos(TimeUnit.MILLISECONDS.toNanos(baseDelay + jitter));
}

四、故障恢复机制

分布式系统中,节点随时可能宕机。我们必须确保:

  1. 正在执行的任务不会丢失:如果节点在执行任务过程中宕机,该任务应被其他节点重新执行。
  2. 任务不会无限期卡在"执行中"状态:需要死任务检测机制。

4.1 死任务检测

每个节点可以启动一个定时任务,扫描那些处于"执行中"状态但长时间未更新的任务(即节点可能已宕机)。扫描条件通常是:

  • status = 1(执行中)
  • update_time 超过阈值(例如 timeout_seconds + 缓冲时间)
java 复制代码
@Component
public class DeadTaskDetector {
    @Autowired
    private JdbcTemplate jdbcTemplate;

    @Value("${scheduler.dead-task-timeout:300}") // 默认5分钟
    private int timeoutSeconds;

    @Scheduled(fixedDelay = 60000) // 每分钟执行一次
    public void detectAndRecover() {
        int recovered = jdbcTemplate.update("""
            UPDATE task 
            SET status = 0, 
                node_id = NULL,
                update_time = NOW()
            WHERE status = 1 
              AND update_time < DATE_SUB(NOW(), INTERVAL ? SECOND)
            """, timeoutSeconds);
        
        if (recovered > 0) {
            log.warn("Recovered {} dead tasks", recovered);
        }
    }
}

注意

  • 超时时间应大于任务的最大执行时间,避免误判。
  • 如果任务执行时间很长(如数小时),可以设计心跳机制:节点定期更新 update_time,表明自己还活着。这样死任务检测模块就不会误杀正在执行的长任务。

4.2 节点心跳与自动释放

为了防止长任务被误判为死任务,我们可以在每个节点启动一个心跳线程,定期更新自己领取的所有"执行中"任务的 update_time

java 复制代码
@Component
public class HeartbeatManager {
    @Autowired
    private JdbcTemplate jdbcTemplate;

    @Value("${scheduler.node-id}")
    private String nodeId;

    @Scheduled(fixedDelay = 30000) // 每30秒心跳一次
    public void heartbeat() {
        int updated = jdbcTemplate.update("""
            UPDATE task 
            SET update_time = NOW() 
            WHERE node_id = ? AND status = 1
            """, nodeId);
        if (updated > 0) {
            log.debug("Heartbeat updated {} tasks", updated);
        }
    }
}

当节点正常关闭时,我们应该主动释放自己领取的任务,以便其他节点立即接手。

java 复制代码
@Override
public void destroy() {
    running = false;
    
    // 停止轮询
    pollExecutor.shutdown();
    
    // 取消正在执行的任务(可选,根据业务决定)
    runningTasks.forEach((id, future) -> future.cancel(true));
    
    // 释放所有由本节点领取的任务
    int released = jdbcTemplate.update("""
        UPDATE task 
        SET status = 0, node_id = NULL 
        WHERE node_id = ? AND status = 1
        """, nodeId);
    log.info("Released {} tasks on shutdown", released);
    
    // 关闭线程池
    taskExecutor.shutdown();
    try {
        if (!taskExecutor.awaitTermination(60, TimeUnit.SECONDS)) {
            taskExecutor.shutdownNow();
        }
    } catch (InterruptedException e) {
        taskExecutor.shutdownNow();
    }
}

4.3 整体故障恢复流程

死任务检测器 数据库 节点B 节点A 死任务检测器 数据库 节点B 节点A 心跳停止 loop [每30秒] 领取任务T1,更新状态为执行中 节点A突然宕机 扫描执行中超时的任务 发现T1超时(update_time过旧) 更新T1状态为待执行,node_id=NULL 轮询任务,使用SKIP LOCKED 返回T1(因为已被释放) 领取T1,状态置为执行中 执行T1

通过这种方式,系统实现了自动故障转移,保证了任务最终会被执行。


五、总结与扩展

本文详细介绍了如何使用线程池和 SKIP LOCKED 构建一个高可用的分布式调度引擎。我们涉及了:

  • 架构设计:多节点无状态 + 共享数据库
  • 任务表设计:关键字段和索引优化
  • 核心调度器:轮询、SKIP LOCKED、动态线程池
  • 任务执行器:工厂模式支持多种任务类型
  • 故障恢复:死任务检测 + 心跳机制

这个架构已经在多家公司的生产环境中得到验证,能够支撑每日千万级的任务调度。如果你正在设计类似的系统,完全可以基于本文的代码进行改造。

当然,还有很多可以优化的地方:

  • 分库分表:当任务量极大时,可以对任务表进行水平拆分。
  • 优先级队列:可以使用多个队列(如高、中、低优先级)分别拉取。
  • 任务依赖:可以引入 DAG 工作流支持。
  • 监控告警:对接 Prometheus + Grafana,实时监控任务堆积、失败率等。

在下一篇文章中,我们将探讨如何兼容多种数据库(如 SQL Server、Oracle)以及如何进行性能调优,敬请期待!


如果你觉得这篇文章对你有帮助,欢迎点赞、收藏、分享,也欢迎在评论区留言交流你的实战经验!

相关推荐
x-cmd2 小时前
[x-cmd] x ollama - 本地大语言模型运行工具
人工智能·ai·语言模型·自然语言处理·x-cmd
gorgeous(๑>؂<๑)2 小时前
【ICLR26-Oral Paper-字节跳动】推理即表征:重新思考图像质量评估中的视觉强化学习
人工智能·深度学习·神经网络·机器学习·计算机视觉
2501_926978332 小时前
从Prompt的“结构-参数”到多AI的“协作-分工”--底层逻辑的同构分化
大数据·人工智能·机器学习
狮子座明仔2 小时前
MemFly:当智能体的记忆学会了“断舍离“——信息瓶颈驱动的即时记忆优化
人工智能·深度学习·语言模型·自然语言处理
呆萌很2 小时前
各版本ResNet变体通道数解析
人工智能
Max_uuc2 小时前
【架构心法】榨干 USB 带宽:多合一调试工具的“复合设备”架构与端点分配哲学
架构
教男朋友学大模型2 小时前
平衡AI自动化与人工干预
大数据·人工智能·自动化
hzwy232 小时前
【AI智能体】会玩电脑的AI智能体
人工智能
啊阿狸不会拉杆2 小时前
《计算机视觉:模型、学习和推理》第 7 章-复杂数据密度建模
人工智能·python·学习·算法·计算机视觉·t分布·复杂数据密度建模