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 三种模型的融合实践

实际项目中,很少只用一种模型。我常用的套路是:生产者-消费者做整体架构,消费者内部用并行处理,每个并行任务内部又是串行。

举个例子,一个数据清洗管道:

  1. 生产者:从 Kafka 拉取原始数据,塞入有界队列。
  2. 消费者(并行):启动 N 个消费者线程,每个线程从队列取一批数据。
  3. 串行处理:每个消费者线程内部,对这批数据逐条清洗、校验、转换。

这样既利用了队列的削峰能力,又发挥了并行的计算优势,还保持了单条处理的简单性。

核心原则:用生产者-消费者解耦流程,用并行提升吞吐,用串行保证单条处理的正确性。三者不是互斥的,而是互补的。

3.5 本章知识体系

下面这张图,把三种模型的关系和选择路径梳理清楚了。你可以把它当作决策参考。

单机批处理模型选择路径 批处理任务 数据量 < 1000 ? 串行处理 是 I/O 密集 ? 否 并行处理 否 生产者-消费者 是 实际项目中,三种模型常组合使用:生产者-消费者做架构,消费者内部并行,并行任务内串行 选择依据:数据量、I/O 占比、是否需要削峰

嗯,这张图基本概括了本章的核心。你想想看,下次接到批处理需求,是不是可以先问自己三个问题:数据量多大?I/O 多不多?需不需要应对突发流量?答案清楚了,模型自然就选出来了。