批量聚合策略:固定批次大小、动态批次调整、超时触发机制

批量聚合,说白了就是「攒一批再处理」。但怎么攒、攒多少、等多久,这三个问题直接决定了你的吞吐量天花板。我见过不少团队,上来就设个 1000 的批次大小,结果延迟飙到用户骂娘;也有人设 10ms 超时,结果批次永远凑不满,吞吐量还不如单条处理。

这一章,咱们就把三种主流策略掰开揉碎讲清楚。每种策略都有它的适用场景,也有它的坑。我会结合自己踩过的坑,帮你找到最适合你的那套方案。

固定批次大小:最简单,也最容易翻车

固定批次大小的逻辑很直白:攒够 N 条数据,一次性提交。比如 Kafka 的 batch.size,或者数据库的批量 insert 每次 500 条。

核心参数:批次大小(batch size)

适用场景:数据到达速率稳定、对延迟不敏感的系统

我最早做日志采集系统时,就用的固定 1000 条一批。白天流量大,效果不错。到了凌晨,流量骤降,1000 条要攒 30 秒才能凑满。结果日志延迟从秒级变成了分钟级,运维同学半夜打电话把我叫醒……

所以固定批次大小的致命问题在于:低流量时延迟不可控。你想想看,如果业务高峰期和低谷期的流量差 100 倍,固定批次大小要么高峰期来不及攒,要么低谷期等死人。

// 固定批次大小的伪代码示例
public class FixedBatchProcessor {
    private final int batchSize = 500;
    private final List<Record> buffer = new ArrayList<>();
    
    public void add(Record record) {
        buffer.add(record);
        if (buffer.size() >= batchSize) {
            flush(); // 攒够了,提交
        }
    }
    
    private void flush() {
        // 批量写入数据库或发送到下游
        database.batchInsert(buffer);
        buffer.clear();
    }
}

这个代码看起来没问题,对吧?但实际生产环境里,你还要考虑并发安全、内存溢出、失败重试等问题。我后面会讲到这些坑。

动态批次调整:让系统自己「看情况办」

动态批次调整,就是让系统根据当前流量自动调整批次大小。流量大时批次大,流量小时批次小。这样既能保证吞吐量,又能控制延迟。

我个人习惯用滑动窗口统计来做动态调整。统计过去 1 秒内的数据到达速率,然后根据目标延迟反推批次大小。

流量状态 到达速率 推荐批次大小 预期延迟
高峰 10000 条/秒 1000 100ms
正常 1000 条/秒 100 100ms
低谷 100 条/秒 10 100ms

你看,目标延迟控制在 100ms 左右,批次大小随流量自动变化。这样不管白天黑夜,延迟都稳定。

// 动态批次调整的核心逻辑
public class AdaptiveBatchProcessor {
    private final SlidingWindowRateCounter rateCounter;
    private final long targetLatencyMs = 100;
    
    public int calculateBatchSize() {
        double arrivalRate = rateCounter.getRate(); // 每秒到达条数
        // 目标延迟内能处理多少条?
        int batchSize = (int) (arrivalRate * targetLatencyMs / 1000);
        // 设置上下限,防止极端情况
        return Math.max(10, Math.min(2000, batchSize));
    }
}

经验之谈:动态调整一定要加上下限。我曾经没设下限,结果流量为 0 时批次大小算出来是 0,导致数据永远不提交……那叫一个惨。

超时触发机制:给批次一个「最后期限」

超时触发,就是给每个批次设一个最大等待时间。不管攒没攒够,时间到了就提交。这是解决「低流量时延迟过高」的最直接手段。

常见的超时策略有两种:

  • 固定超时:比如每 100ms 检查一次,到点就提交。简单粗暴,但高峰期可能频繁提交小批次,降低吞吐量。
  • 自适应超时:根据当前批次大小动态调整超时时间。批次越大,超时越长;批次越小,超时越短。

我做过一个支付对账系统,要求延迟不超过 500ms。用了固定超时 200ms,配合动态批次大小。效果不错,但有个坑:超时和批次大小是联动的,不能独立调优。

// 带超时机制的批量处理器
public class TimeoutBatchProcessor {
    private final int maxBatchSize = 500;
    private final long timeoutMs = 200;
    private final List<Record> buffer = new ArrayList<>();
    private long batchStartTime;
    
    public void add(Record record) {
        if (buffer.isEmpty()) {
            batchStartTime = System.currentTimeMillis();
        }
        buffer.add(record);
        
        // 两个条件满足其一就提交
        if (buffer.size() >= maxBatchSize || 
            System.currentTimeMillis() - batchStartTime >= timeoutMs) {
            flush();
        }
    }
}

注意:超时机制要小心「惊群效应」。如果所有批次同时超时,下游系统可能瞬间被打满。建议给超时时间加一点随机抖动(jitter),比如 200ms ± 20ms。

三种策略的对比与选择

这三种策略不是互斥的,实际生产中往往是组合使用。我画了一张图,帮你理清它们的关系:

批量聚合策略决策流程 数据到达 是否达到固定批次大小? 是 否 是否达到超时时间? 是 否 继续等待下一条数据 提交批次 说明: 固定批次大小优先检查,满足则立即提交;否则检查超时条件;两者都不满足则继续等待。 实际生产中,动态批次调整会实时修改「固定批次大小」这个阈值。

从图上你能看到,三种策略是层层递进的关系。我建议的优先级是:固定批次 > 超时 > 动态调整。先保证吞吐量,再控制延迟,最后用动态调整来优化整体表现。

组合策略实战:一个完整的例子

下面是一个我在电商订单处理系统中用过的组合策略。它同时用了固定批次、动态调整和超时三种机制。

public class CombinedBatchStrategy {
    // 配置参数
    private final int minBatchSize = 10;
    private final int maxBatchSize = 2000;
    private final long baseTimeoutMs = 100;
    private final long maxTimeoutMs = 500;
    
    // 运行时状态
    private final RateTracker rateTracker = new RateTracker();
    private final List<Order> buffer = new ArrayList<>();
    private long batchStartTime;
    
    public void addOrder(Order order) {
        if (buffer.isEmpty()) {
            batchStartTime = System.currentTimeMillis();
        }
        buffer.add(order);
        
        // 1. 动态计算当前最优批次大小
        int optimalBatchSize = calculateOptimalBatchSize();
        
        // 2. 动态计算超时时间(批次越小,超时越短)
        long dynamicTimeout = calculateDynamicTimeout(optimalBatchSize);
        
        // 3. 检查是否满足提交条件
        if (buffer.size() >= optimalBatchSize || 
            System.currentTimeMillis() - batchStartTime >= dynamicTimeout) {
            flush();
        }
    }
    
    private int calculateOptimalBatchSize() {
        double rate = rateTracker.getArrivalRate();
        // 目标延迟 200ms
        int size = (int) (rate * 0.2);
        return Math.max(minBatchSize, Math.min(maxBatchSize, size));
    }
    
    private long calculateDynamicTimeout(int batchSize) {
        // 批次越小,超时越短,避免低流量时延迟过高
        double ratio = (double) batchSize / maxBatchSize;
        return (long) (baseTimeoutMs + (maxTimeoutMs - baseTimeoutMs) * ratio);
    }
}

关键点:动态超时的计算公式里,我用了线性映射。批次大小从 10 到 2000,超时时间从 100ms 到 500ms。这样小批次不会等太久,大批次也能给足时间攒数据。

避坑指南

这些坑我都是真金白银换来的教训,你一定要注意:

  • 批次大小不是越大越好。我曾经把批次设到 5000,结果一次 OOM 把整个服务搞挂了。批次大小要考虑内存和下游处理能力。
  • 超时时间不要设太短。设 10ms 超时,基本等于没批处理。建议至少 50ms 起步。
  • 动态调整要平滑。不要每次到达都重新计算,建议每秒或每 10 个批次调整一次。否则系统会「抖动」。
  • 别忘了监控。我习惯把批次大小、超时次数、平均延迟都打到监控系统里。调优全靠数据说话。

我曾经踩过的坑:有一次动态批次调整的算法写错了,导致高峰期批次大小反而变小。结果吞吐量直接腰斩,数据库连接池被打满。后来我加了「批次大小变化率限制」,每次调整不超过 20%。

批量聚合策略,说白了就是在吞吐量和延迟之间找平衡。固定批次保吞吐,超时机制控延迟,动态调整做优化。三者配合好了,你的系统就能像老司机一样,不管路况怎么变,都能稳稳地跑在目标车速上。