第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一定要加乐观锁或者条件判断,防止并发更新把状态改乱了。比如一个任务在“运行”中,一个线程想把它变成“完成”,另一个线程想把它变成“失败”,不加控制就会出问题。
好了,批次系统的骨架已经搭起来了。下一层我们要深入看看——当任务真的出问题时,怎么一步一步地查?这就是后面几章要聊的内容。