第2章 批次处理系统架构:核心架构模式与组件

做批处理系统,说白了就是跟“大量数据”和“长时间任务”打交道。我入行那会儿,第一个批处理项目是个日结报表,数据量不到百万级,一台机器跑个脚本就搞定了。后来遇到亿级用户的活动结算,才发现事情没那么简单。

本章咱们聊聊批次处理系统的常见架构模式、核心组件,以及任务的生命周期。理解这些,你排查问题时才能快速定位到“是调度器卡了?还是执行器挂了?还是数据层出了岔子?”

常见批次处理架构模式

我这些年接触过的批处理系统,基本逃不出下面几种模式。每种模式都有自己的适用场景和坑。

1. 单机脚本模式

最简单,也最脆弱。就是一台机器,定时跑个脚本。比如crontab + shell脚本。

  • 优点:开发快,部署简单
  • 缺点:单点故障,没法横向扩展,数据量大时扛不住

避坑指南:我曾经见过一个报表系统,用单机脚本跑了两年。突然某天数据量翻倍,脚本跑了12小时还没跑完,直接把报表时间窗口给堵死了。这类模式只适合数据量稳定、非核心的场景。

2. Master-Worker(主从)模式

这是企业里最常见的一种模式。Master负责调度和分发任务,Worker负责实际干活。可以横向加Worker来提升吞吐量。

# 简化的Master任务分发逻辑
def distribute_task(tasks, workers):
    for task in tasks:
        worker = select_available_worker(workers)
        worker.execute(task)
        log_task_status(task.id, worker.id)

我这里要提一句:Master一旦挂了,整个集群就瘫痪。所以Master必须做高可用,比如用ZooKeeper或者etcd做选主。

3. 事件驱动模式

任务不是被定时触发的,而是被某个“事件”触发的。比如用户下单后,触发优惠券发放、库存扣减等一系列批处理操作。

这种模式很适合微服务架构,但排查问题时会比较头疼——因为调用链会拉得很长。

我遇到过:有一次线上用户反馈优惠券没到账。查了半天,发现是某个事件在消息队列里消费失败了,但失败处理逻辑写得不好,直接丢了。后来加了失败重试和死信队列才解决。

4. 工作流引擎模式

任务之间有依赖关系,需要按DAG(有向无环图)执行。比如先做数据清洗,再做特征工程,最后做模型训练。这一步完成了才能走下一步。

# 工作流定义示例(伪代码)
workflow = {
    "name": "daily_report_flow",
    "nodes": [
        {"id": "extract", "depends_on": [], "command": "extract_data.sh"},
        {"id": "transform", "depends_on": ["extract"], "command": "transform_data.sh"},
        {"id": "load", "depends_on": ["transform"], "command": "load_to_db.sh"}
    ]
}

常用的工具有Airflow、Temporal,或者企业自研的调度系统。

核心组件

不管哪种架构模式,核心组件都离不开这几样。我把它们整理了一下:

组件 职责 影响排查的问题
调度器 决定任务什么时候跑,分配给哪个执行器 调度延迟、任务堆积、时区混乱
执行器 真正执行任务的进程或容器 内存泄漏、OOM、进程僵死
存储层 存任务状态、元数据、中间结果 数据倾斜、连接池耗尽、死锁
监控与告警 采集指标、触发报警 指标丢失、误告、漏告

我个人习惯:排查问题时,先去看监控。如果监控数据没有,再去查调度器日志。如果调度器日志正常,就去查执行器的输出。这就像破案的第一步——确定“案发时间”和“作案工具”。

核心原则:任何一次批处理任务失败,都应该留下两个东西——失败详情日志和可重试的入口。否则排查起来就像大海捞针。

批次任务的生命周期

一个批次任务从生到死,大致有以下几个状态。我画成了一张SVG图,方便你理解:

批次任务生命周期 创建 等待 运行 完成 失败 重试 暂停 说明:任务从“创建”开始,进入“等待”队列;调度器分配资源后转为“运行”;正常结束则“完成”。过程中可能因异常进入“失败”或管理员手动“暂停”。失败后可配置自动“重试”重新排入等待队列。

每个状态背后的含义,我跟你说说实际场景:

  • 创建:任务被提交到系统,拿到一个任务ID。这个时候数据还没进来,只是个空壳。
  • 等待:任务处在调度队列里,等资源。这里经常出现的问题就是“优先级饿死”——低优先级任务永远排不上。
  • 运行:执行器正在干活。我遇到过最诡异的情况是“运行了一天的任务”,查日志发现中间有一个死循环。
  • 完成:正常结束。但“完成”不代表“结果正确”,你需要额外做数据校验。
  • 失败:任务异常终止。这个时候不要慌张,先看错误类型——是代码逻辑抛异常?还是OOM?还是外部依赖挂了?
  • 暂停:人工介入,暂时挂起任务。这个状态在排查问题时很实用——你可以先暂停所有任务,避免问题扩大。
  • 重试:系统自动或手动触发重新执行。注意:重试要有上限,不然遇到不可恢复的错误,它会一直重试,浪费资源。

排查小技巧:如果任务一直处于“等待”状态,先检查调度器的资源池。我曾经遇到过调度器连接数据库的线程池满了,导致新任务永远进不来,看起来是“等待”,其实是“被阻塞”。

注意:不要忽略“暂停”状态的历史记录!有些坑是你上次手动停掉的,但忘记恢复,结果整个批次被卡住了一周。我建议每次暂停都写一个备注,说明原因和预计恢复时间。

状态机与持久化

最后说一个老生常谈的问题:任务状态一定要持久化到数据库或者外部存储里。不能只存在内存里,因为一旦进程重启,你就失去了所有任务的状态,那排查起来就是一场噩梦。

我见过一个团队,任务状态存在一个ConcurrentHashMap里。服务一重启,所有任务的状态都丢了。更惨的是,他们还没法判断哪些任务跑完了,哪些没跑完,最后只能全部重跑一遍,白白浪费了一天算力。

-- 推荐的任务状态表设计(MySQL示例)
CREATE TABLE batch_task (
    task_id      VARCHAR(64) PRIMARY KEY,
    status       ENUM('CREATED', 'WAITING', 'RUNNING', 'COMPLETED', 'FAILED', 'PAUSED', 'RETRYING') NOT NULL,
    retry_count  INT DEFAULT 0,
    max_retry    INT DEFAULT 3,
    error_msg    TEXT,
    create_time  DATETIME,
    update_time  DATETIME,
    INDEX idx_status (status),
    INDEX idx_create_time (create_time)
);

嗯,这里要注意:状态更新的SQL一定要加乐观锁或者条件判断,防止并发更新把状态改乱了。比如一个任务在“运行”中,一个线程想把它变成“完成”,另一个线程想把它变成“失败”,不加控制就会出问题。

好了,批次系统的骨架已经搭起来了。下一层我们要深入看看——当任务真的出问题时,怎么一步一步地查?这就是后面几章要聊的内容。