3. 单机批处理模型:串行处理、并行处理、生产者-消费者模式
说到单机批处理,很多人第一反应就是「把任务塞进循环里跑」。嗯,确实能跑,但跑得快不快、稳不稳,就是另一回事了。我见过不少项目,初期数据量小的时候一切正常,等量一上来,直接卡死。说白了,就是没选对处理模型。
这一章,咱们就把单机上最经典的三种批处理模型掰开揉碎。你想想看,一个 CPU,一堆数据,到底怎么安排才能把吞吐量压到极限?
3.1 串行处理:最朴素,也最危险
串行处理就是一条道走到黑。任务一个接一个,前一个不结束,后一个就别想开始。代码写起来最简单,但坑也最多。
核心特征:单线程、顺序执行、无并发开销。
// 串行批处理示例
public void serialBatchProcess(List<Record> records) {
for (Record record : records) {
processOne(record); // 一个一个处理
}
}
这段代码,我刚开始工作时写过无数遍。当时觉得没问题啊,逻辑清晰,跑得也对。直到有一次,单条记录处理时间从 2ms 涨到了 50ms,整个批处理从 2 秒变成了 50 秒。用户直接炸了。
串行的优缺点:
| 维度 | 说明 |
|---|---|
| 优点 | 实现简单、无竞态、调试容易、资源占用低 |
| 缺点 | CPU 利用率低、I/O 等待浪费严重、吞吐量上限低 |
| 适用场景 | 数据量小(千级以下)、单条处理极快、对延迟不敏感 |
我曾经踩过的坑:串行处理里混入了远程 RPC 调用,每次等网络往返 100ms,1000 条数据就是 100 秒。后来改成批量 RPC,直接降到 5 秒。记住:串行模型最怕 I/O 阻塞。
3.2 并行处理:让 CPU 满负荷运转
串行太慢,那就多开几个线程一起干。并行处理的核心思路就是「分而治之」——把大任务拆成小片,扔给多个线程同时处理。
// 并行批处理示例(Java ForkJoinPool)
public void parallelBatchProcess(List<Record> records) {
records.parallelStream().forEach(this::processOne);
}
一行代码,吞吐量可能翻好几倍。但别高兴太早,并行不是银弹。我有个项目,用 parallelStream 处理 10 万条数据,结果比串行还慢。为什么?因为每条处理里有个 synchronized 块,所有线程都在抢锁,反而增加了上下文切换的开销。
并行处理的关键参数:
| 参数 | 建议值 | 说明 |
|---|---|---|
| 线程数 | CPU 核心数 + 1 | 计算密集型场景;I/O 密集型可适当增加 |
| 任务粒度 | 每条任务 1ms~10ms | 太细则线程调度开销占比大;太粗则负载不均 |
| 共享资源 | 尽量无锁或使用 CAS | 锁竞争会严重拖垮并行效率 |
我个人习惯:并行处理前先做一次「任务拆分评估」。如果单条处理时间 < 0.1ms,就别并行,串行反而更快。因为线程创建和上下文切换的成本已经超过了收益。
3.3 生产者-消费者模式:解耦与削峰
串行和并行都是「主动拉取」模型——程序自己控制什么时候处理下一条。但现实场景中,数据往往是源源不断进来的,比如日志采集、消息队列消费。这时候就需要生产者-消费者模式了。
说白了,就是让生产数据的线程和处理数据的线程分开。生产者只管往队列里塞,消费者只管从队列里取。中间用阻塞队列做缓冲。
// 生产者-消费者模式示例
BlockingQueue<Record> queue = new LinkedBlockingQueue<>(1000);
// 生产者线程
public void produce(Record record) {
queue.put(record); // 队列满时自动阻塞
}
// 消费者线程
public void consume() {
while (true) {
Record record = queue.take(); // 队列空时自动等待
processOne(record);
}
}
这个模式我在日志采集系统里用过很多次。生产者是网络接收线程,消费者是批量写入线程。中间用队列缓冲,既防止了网络抖动导致写入压力暴增,又让生产者和消费者可以独立扩缩容。
三种模型的对比:
| 维度 | 串行处理 | 并行处理 | 生产者-消费者 |
|---|---|---|---|
| 数据流向 | 顺序 | 分片 | 队列 |
| 吞吐量 | 低 | 中高 | 高(可调) |
| 实现复杂度 | 低 | 中 | 中高 |
| 背压能力 | 无 | 弱 | 强(队列满自动阻塞) |
| 典型场景 | 小批量计算 | 大数据量计算 | 流式数据、消息处理 |
我曾经犯过的错:生产者-消费者模式里,队列大小设成了无界(new LinkedBlockingQueue<>())。结果消费者挂了,生产者还在疯狂塞数据,内存直接 OOM。后来一律用有界队列,配合拒绝策略,宁可丢数据也不能让进程挂掉。
3.4 三种模型的融合实践
实际项目中,很少只用一种模型。我常用的套路是:生产者-消费者做整体架构,消费者内部用并行处理,每个并行任务内部又是串行。
举个例子,一个数据清洗管道:
- 生产者:从 Kafka 拉取原始数据,塞入有界队列。
- 消费者(并行):启动 N 个消费者线程,每个线程从队列取一批数据。
- 串行处理:每个消费者线程内部,对这批数据逐条清洗、校验、转换。
这样既利用了队列的削峰能力,又发挥了并行的计算优势,还保持了单条处理的简单性。
核心原则:用生产者-消费者解耦流程,用并行提升吞吐,用串行保证单条处理的正确性。三者不是互斥的,而是互补的。
3.5 本章知识体系
下面这张图,把三种模型的关系和选择路径梳理清楚了。你可以把它当作决策参考。
嗯,这张图基本概括了本章的核心。你想想看,下次接到批处理需求,是不是可以先问自己三个问题:数据量多大?I/O 多不多?需不需要应对突发流量?答案清楚了,模型自然就选出来了。