承接的问题
通知扇出是一条条来的,但落库、计费、写审计日志这类动作一条条做太贵。于是有了「攒一批再处理」:攒够 50 条或者等满 100 ms 就提交一次。问题也随之而来——生产者比消费者快时,攒的批次放在哪?答案只能是有界队列,而有界队列一旦满,就必须做个选择:阻塞生产者、拒绝任务,或者让生产者自己干。
这一节把批量窗口、drainTo、有界队列与 CallerRunsPolicy 串起来,回答「背压到底是什么形状」。数据全部来自本机 JDK 25 实跑。
批量窗口需要两个触发条件
只按数量触发,低流量时最后几条会一直躺在队列里;只按时间触发,高峰期又会退化成一条一批。两个都要:
static final int MAX_BATCH = 5;
static final long WINDOW_NANOS = TimeUnit.MILLISECONDS.toNanos(50);
static void consume(ArrayBlockingQueue<Long> queue, List<Integer> sizes) throws InterruptedException {
List<Long> batch = new ArrayList<>(MAX_BATCH);
while (true) { // 真实实现由关闭标志控制
Long first = queue.poll(200, TimeUnit.MILLISECONDS); // 空闲时最多等 200 ms
if (first == null) {
continue; // 真实实现里这是空闲超时,不是退出
}
batch.add(first);
long windowEnd = System.nanoTime() + WINDOW_NANOS; // 窗口从第一条到达开始算
while (batch.size() < MAX_BATCH) {
int n = queue.drainTo(batch, MAX_BATCH - batch.size());
if (n > 0) {
continue; // 一次持锁搬空能搬的
}
long left = windowEnd - System.nanoTime();
if (left <= 0) {
break; // 时间阈值到了
}
Long next = queue.poll(left, TimeUnit.NANOSECONDS);
if (next == null) {
break;
}
batch.add(next);
}
sizes.add(batch.size());
flush(batch); // 一次批量写入
batch.clear();
}
}
static void flush(List<Long> batch) throws InterruptedException {
Thread.sleep(40); // 冒充一次批量落库
}
这段循环有三个设计点。
第一,窗口从第一条元素到达开始计时,不是在循环开头计时。否则前一批处理完、队列空着的 200 ms 也会被算进窗口,下一批的第一条刚到就被时间阈值催走,批量完全失效。
第二,先 drainTo 再 poll。drainTo 是一次加锁、循环搬运,ArrayBlockingQueue 的实现里把 count 次取元素放在同一个 lock 内完成,比循环调用 poll() 少 n-1 次加锁解锁。队列里有货时它一次就能填满批次,只有队列暂时空了才需要 poll(left) 去等剩余窗口。
第三,drainTo 顺手唤醒了被阻塞的生产者。JDK 25 的 drainTo 在归还元素之后,会对 notFull 条件按取走数量逐个 signal(只在有等待者时)。所以消费者搬走一批,阻塞在 put/offer(timeout) 上的生产者立刻就能继续。这也是「批量消费天然缓解背压」的来源:一次搬走 5 个,等于一次腾出 5 个槽位。
drainTo 也有边界:它不阻塞,队列空就返回 0;它把元素 add 到目标集合,如果目标集合抛异常,已搬走的元素不会放回队列,只是把 count 和 takeIndex 修正到已搬走的位置——也就是已经交给你的就是你的,不会再出现在队列里。
有界队列才是背压,offer 的返回值是信号
「背压」常被误解成「限速」。限速是主动控制发送速率(令牌桶),背压是下游处理不过来时,让上游的提交动作本身变慢或失败。前者需要一个计时器,后者只需要一个有界队列加一次阻塞或拒绝。
for (int i = 1; i <= TOTAL; i++) {
long t0 = System.nanoTime();
boolean ok = queue.offer(i, 20, TimeUnit.MILLISECONDS); // 满则最多等 20 ms
long cost = System.nanoTime() - t0;
if (!ok) {
rejected.incrementAndGet(); // 拒绝也是一条明确信号
} else if (cost > TimeUnit.MILLISECONDS.toNanos(1)) {
waited.incrementAndGet(); // 阻塞过,说明队列曾满
}
}
本机真实输出(capacity=8、maxBatch=5、window=50 ms、消费者每批处理 40 ms;单次运行,load average 113,耗时不作基准):
=== 批量聚合:capacity=8, maxBatch=5, window=50 ms ===
[ 5.2 ms] flush batch#1 size=5 trigger=SIZE first=1 last=5
[ 27.5 ms] producer item 14 REJECTED, queue=8
[ 48.9 ms] producer item 15 REJECTED, queue=8
[ 75.5 ms] producer item 16 REJECTED, queue=8
[ 77.1 ms] flush batch#2 size=5 trigger=SIZE first=6 last=10
[ 77.1 ms] producer item 17 blocked 1.4 ms, queue=4
[ 99.9 ms] producer item 22 REJECTED, queue=8
[ 127.6 ms] flush batch#3 size=5 trigger=SIZE first=11 last=18
[ 127.6 ms] producer item 23 blocked 27.4 ms, queue=4
[ 152.5 ms] producer item 28 REJECTED, queue=8
[ 172.6 ms] flush batch#4 size=5 trigger=SIZE first=19 last=24
[ 172.6 ms] producer item 29 blocked 19.8 ms, queue=4
[ 198.2 ms] producer item 34 REJECTED, queue=8
[ 213.7 ms] flush batch#5 size=5 trigger=SIZE first=25 last=30
[ 213.7 ms] producer item 35 blocked 15.2 ms, queue=4
[ 235.7 ms] producer item 40 REJECTED, queue=8
[ 264.1 ms] flush batch#6 size=5 trigger=SIZE first=31 last=36
[ 359.2 ms] flush batch#7 size=3 trigger=TIME first=37 last=39
offered=40 rejected=7 blockedEvents=4 batches=7 sizes=[5, 5, 5, 5, 5, 5, 3]
几条能对上的账:
offered=40,rejected=7,sizes总和5*6+3=33,40 = 33 + 7。被拒的元素不会出现在任何一批里。- 每次
REJECTED时queue=8,说明确实撞到了容量上限;排空后queue=4,说明消费者一次drainTo搬走了 4 个。 - 最后一批是
size=3, trigger=TIME:生产结束、队列空了,时间阈值把半批催了出来。没有时间阈值,这 3 条会一直等到下次流量到来。 - 批次里出现
first=11 last=18 size=5,中间跳过的 14、15、16 正是被拒的那几个,last是最后一条入队的元素而不是first+size-1。
背压的形状在这个输出里非常直白:生产者被阻塞 15–27 ms,或者直接拿到 false。队列长度是唯一的缓冲区,满了之后压力必然回到生产者身上,线程池和消费者都无法凭空变快。
CallerRunsPolicy 是一种天然背压
线程池的拒绝策略里,CallerRunsPolicy 最不像「拒绝」:它把任务还给提交者自己跑。
// JDK 25 源码摘录,已删减
public static class CallerRunsPolicy implements RejectedExecutionHandler {
public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
if (!e.isShutdown()) {
r.run();
}
}
}
这段代码的效果是:池满时,提交线程被这个任务占用,提交循环自然变慢;等它跑完,池里通常也有空位了。不需要任何限速器,背压由「谁提交谁执行」自动完成。
本机实测(pool=2、queue=2、10 个任务、每个睡 30 ms):
=== CallerRunsPolicy:pool=2, queue=2 ===
[ 4.8 ms] task 5 runs on main
[ 4.9 ms] task 1 runs on pool-26
[ 4.9 ms] task 2 runs on pool-27
[ 35.5 ms] task 3 runs on pool-26
[ 36.6 ms] task 4 runs on pool-27
[ 43.3 ms] task 8 runs on main
[ 67.4 ms] task 6 runs on pool-27
[ 67.6 ms] task 7 runs on pool-26
[ 98.5 ms] task 9 runs on pool-27
[ 106.0 ms] task 10 runs on pool-26
producer submit loop finished at 147.1 ms, terminated=true, callerRuns=2
任务 5 和任务 8 在 main 上执行,callerRuns=2。提交循环到 147.1 ms 才结束——10 个任务、2 个线程、每个 30 ms,理论下界 150 ms,生产者自己干了两个,所以没有超出多少。如果不带背压(无界队列),提交循环会在几毫秒内结束,然后 10 个任务在堆里排队,内存和延迟一起涨。
CallerRunsPolicy 的代价同样明确:
| 场景 | 后果 |
|---|---|
| 提交者是处理用户请求的线程 | 用户请求被迫执行一个后台任务,P99 直接抬升 |
| 提交者持有数据库连接或锁 | 任务执行时间被拉长,可能死锁或把连接池耗干 |
| 提交者是事件循环线程(单线程) | 整个事件循环停摆,所有连接都卡住 |
池已 shutdown | 任务被静默丢弃,没有日志、没有异常 |
所以它适合「提交者本来就是后台线程、且任务可以随便推迟」的场景,例如批处理任务的生产者。面向用户的请求线程,宁可显式拒绝并返回 503,也不要在这里偷偷跑一个 30 ms 的任务。
批量失败之后的重新投递边界
一批 50 条,第 37 条写失败了,怎么办?三个选项,边界完全不同:
| 策略 | 重复风险 | 适用条件 |
|---|---|---|
| 整批重投 | 前 36 条可能写两次 | 下游幂等(有唯一键/去重表) |
| 逐条重投 | 无重复,但失败那条会反复失败 | 批次内条目互相独立 |
| 二分重投 | 只重投失败的一半 | 需要定位坏条目,且下游幂等 |
二分是最常用的折中:失败时把批次劈成两半分别重试,直到单条仍失败就丢进死信队列。它把重投次数从 n 降到 log n,同时能精确定位毒条目:
void flushWithSplit(List<Item> batch, int depth) {
try {
repository.saveAll(batch); // 一次事务写一批
return;
} catch (RuntimeException e) {
if (batch.size() == 1 || depth >= 6) {
deadLetter.addAll(batch); // 单条仍失败:隔离,不再重试
return;
}
int mid = batch.size() / 2;
flushWithSplit(batch.subList(0, mid), depth + 1);
flushWithSplit(batch.subList(mid, batch.size()), depth + 1);
}
}
边界在于**「失败」的粒度必须和事务边界一致**。如果 saveAll 内部逐条自动提交,前一半成功、后一半失败,二分重投就会把成功的那一半再写一遍。要么整批一个事务,要么每条带幂等键。这是批量处理里最容易在故障时才暴露的问题。
另外,subList 是视图不是拷贝,重投过程中原 List 不能被清空或修改;flush 之后立刻 batch.clear() 是安全的,因为递归已经返回。
在 libraryd-conc 里落地
库存盘点(stocktake)是天然适合批量的场景:盘点机把每个 ISBN 的实盘数推上来,服务端按批更新 Book.stock,再写一条审计。批量窗口设为 50 条 / 100 ms,队列容量 256,满了就拒绝并让盘点机重传:
package com.plume.conc.core;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicLong;
public final class StocktakeBatcher implements AutoCloseable {
private final ArrayBlockingQueue<StocktakeEvent> queue =
new ArrayBlockingQueue<>(256);
private final StocktakeSink sink;
private final List<StocktakeEvent> deadLetter = new CopyOnWriteArrayList<>();
private final AtomicLong rejected = new AtomicLong();
private final Thread worker;
public StocktakeBatcher(StocktakeSink sink) {
this.sink = sink;
this.worker = Thread.ofPlatform().name("stocktake-batcher").start(this::loop);
}
public boolean submit(StocktakeEvent event) {
if (!queue.offer(event)) { // 有界队列满:立刻拒绝,由盘点机重传
rejected.incrementAndGet();
return false;
}
return true;
}
private void loop() {
List<StocktakeEvent> batch = new ArrayList<>(50);
try {
while (!Thread.currentThread().isInterrupted()) {
StocktakeEvent first = queue.poll(100, TimeUnit.MILLISECONDS);
if (first == null) {
continue;
}
batch.add(first);
queue.drainTo(batch, 49);
long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(100);
while (batch.size() < 50) {
long left = deadline - System.nanoTime();
if (left <= 0) {
break;
}
StocktakeEvent next = queue.poll(left, TimeUnit.NANOSECONDS);
if (next == null) {
break;
}
batch.add(next);
}
flushWithSplit(batch, 0);
batch.clear();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
private void flushWithSplit(List<StocktakeEvent> batch, int depth) {
try {
sink.saveAll(batch); // 一个事务写一批并记审计
} catch (RuntimeException e) {
if (batch.size() == 1 || depth >= 6) {
deadLetter.addAll(batch);
return;
}
int mid = batch.size() / 2;
flushWithSplit(batch.subList(0, mid), depth + 1);
flushWithSplit(batch.subList(mid, batch.size()), depth + 1);
}
}
public long rejected() {
return rejected.get();
}
public List<StocktakeEvent> deadLetter() {
return List.copyOf(deadLetter);
}
@Override
public void close() {
worker.interrupt();
}
public interface StocktakeSink {
void saveAll(List<StocktakeEvent> batch);
}
}
submit 用非阻塞的 offer 而不是 put:盘点机是外部设备,让它拿到 false 后重传,比让 HTTP 线程卡在队列上更好。如果调用方是本进程的后台线程,换成 offer(event, 50, MILLISECONDS) 让背压以延迟的形式体现,也合理。两种都是背压,区别只是压力表现成「拒绝」还是「变慢」。
这个设计的代价
批量把延迟换成了吞吐。 窗口 100 ms 意味着单条数据最多等 100 ms 才被处理。对盘点这种离线业务无所谓,对「用户点了借阅要立刻看到库存变化」就不合适。
队列长度决定了能吸收多少突发。 容量 256 在 10 倍突发下能撑一会儿,在 100 倍突发下只是把 OOM 推迟几秒。队列满之后的拒绝率才是真正要监控的指标,队列长度不是。
二分重投依赖幂等。 前面已经说过:没有幂等键或单事务,重投就是重复写。死信队列也不是垃圾桶,必须有告警和人工处理入口,否则坏数据只是被藏起来了。
什么时候不要用
不要用批量聚合去掩盖一个本来就慢的下游。批量能降低每次调用的固定开销,但如果单条处理本身就要 200 ms,攒 50 条只会让第 50 条等 10 秒。
不要在要求严格顺序的流上用多线程批量消费。批量提交本身会打乱跨批次的顺序,需要顺序就得单消费者加单分区,那时批量只是减少了事务次数。
不要把 CallerRunsPolicy 用在 Web 请求线程池的拒绝策略上。它会让 Tomcat 的工作线程去执行后台任务,表现为「偶发的接口超时」,而且极难从线程栈上看出来。
不要在没有背压信号的地方用无界队列。LinkedBlockingQueue 不传容量就是 Integer.MAX_VALUE,生产速度超过消费速度时,它的表现是内存缓慢上涨直到 OOM,而不是拒绝——这比拒绝危险得多。
小结
批量窗口要同时有数量阈值和时间阈值,窗口从第一条到达开始算;drainTo 一次持锁搬走整批,并顺带唤醒阻塞的生产者。背压的本质是有界队列加阻塞或拒绝,CallerRunsPolicy 把「谁提交谁执行」变成了天然的减速器,但只适合后台线程。批量失败后的重投必须和事务边界、幂等键一起设计,否则省下的调用次数会以重复数据的形式还回来。
阅读导航:上一节 14.1 超时、重试与熔断的并发实现 | 下一节 14.3 并发限流与信号量池
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。