《Java 高级编程》5.2 阻塞队列:借阅事件流水线

借阅事件的审计写库不该拖慢请求线程。本节用 ArrayBlockingQueue 搭一条有界流水线,用 300 个事件、32 个槽位、2 个消费者的饱和实验展示背压与拒绝,再把优雅停止做成可观测的 pending、drained、abandoned 三个数。

本节目标:把「借阅成功后要做的几件慢事」从请求线程搬走,同时保证过载可见、停机不丢数据。
适用版本:JDK 25(Temurin 25.0.5);只使用 JDK 标准库。

承接的问题

5.1 的监听器是在请求线程里同步 fire 的。审计监听器要往 loan_audit 表插一条记录,积分监听器要更新会员积分,推送监听器要调外部 HTTP。一次借阅因此变成「一次数据库写 + 一次审计写 + 一次积分写 + 一次外部调用」,响应时间从 20ms 涨到 120ms,而且外部服务抖动会直接体现在借阅接口的 P99 上。

自然的想法是异步:请求线程只负责把「发生了什么」放进队列,后台线程慢慢处理。这引入四个新问题:

  • 队列有没有上限?没有上限就是拿内存当缓冲。
  • 队列满了以后请求线程怎么办?阻塞、丢弃、还是拒绝?
  • 后台消费者挂了怎么办?请求线程会不会永远塞不进去?
  • 停机时队列里还有 200 条事件,是丢掉还是等它跑完?

这一节用一条真实形状的流水线回答这四个问题。

队列的四组操作

BlockingQueue 的方法按「队列满/空时怎么办」分成四组,选错一组语义就全变了:

操作队列满时队列空时
add(e) / remove()抛 IllegalStateException / NoSuchElementException同左
offer(e) / poll()立即返回 false / null同左
put(e) / take()一直阻塞一直阻塞
offer(e, t, u) / poll(t, u)等 t 后返回 false等 t 后返回 null

libraryd 的选择是 offer(event, timeout):请求线程最多等 50ms,等不到就承认过载,返回 503 或降级。不要用无超时的 put:消费者一旦卡住(比如审计库连接池耗尽),所有请求线程会一起挂在这条队列上,HTTP 层的线程池很快被吃光,整个服务不可用。

put 适合「生产者本身就是后台线程、可以无限等待」的场景,比如对账任务往流水线里灌数据。请求线程上永远不要用 put。

ArrayBlockingQueue 与 LinkedBlockingQueue

两者都实现了 BlockingQueue,差别在锁的粒度和内存布局:

  • ArrayBlockingQueue 是一个固定长度的数组,一把锁同时管 put 和 take。
  • LinkedBlockingQueue 是链表,两把锁(putLock、takeLock),生产者和消费者可以并行。
  • LinkedBlockingQueue 不传容量时容量是 Integer.MAX_VALUE,等于无界。

用 1 个生产者、1 个消费者传 20 万个整数,队列容量统一给 64:

javac --release 25 -d /tmp/vol3-b/queues/out /tmp/vol3-b/queues/QueueBench.java
java -cp /tmp/vol3-b/queues/out QueueBench 64
java -cp /tmp/vol3-b/queues/out QueueBench 64

本机两次输出:

ArrayBlockingQueue             capacity=64  events=200000 consumed=200000 elapsedMs=52 opsPerMs=3815.1
LinkedBlockingQueue            capacity=64  events=200000 consumed=200000 elapsedMs=48 opsPerMs=4111.9
LinkedBlockingQueue(unbounded) capacity=2147483647 events=200000 consumed=200000 elapsedMs=21 opsPerMs=9503.4

ArrayBlockingQueue             capacity=64  events=200000 consumed=200000 elapsedMs=71 opsPerMs=2802.0
LinkedBlockingQueue            capacity=64  events=200000 consumed=200000 elapsedMs=54 opsPerMs=3673.2
LinkedBlockingQueue(unbounded) capacity=2147483647 events=200000 consumed=200000 elapsedMs=19 opsPerMs=10491.7

三点观察:

  • 有界队列下两者接近,LinkedBlockingQueue 略快(双锁让生产和消费重叠),但差距在 20% 以内,不构成选型理由。
  • 无界版本快 2–4 倍,因为它几乎不让生产者阻塞。这个「快」是假的:生产者把 20 万个元素全塞进链表就返回了,消费速度根本没跟上。真实系统里,这些元素会变成堆内存和延迟。
  • ArrayBlockingQueue 的优势是内存可预测:数组一次分配,不会因为突发流量产生大量节点对象。GC 敏感的场合值得为此放弃一点吞吐。

libraryd 选 ArrayBlockingQueue,因为容量必须显式给出,这个数字可以写进配置、可以做监控、可以在压测里调。

背压:队列满了会发生什么

做一个饱和实验:容量 32,2 个消费者,每个事件处理 5ms,生产者连续投 300 个事件,每次 offer 的超时设为 0(等价于 tryOffer):

new LoanEventPipeline(32, 2, Duration.ofMillis(5));
for (int i = 0; i < 300; i++) {
    if (pipeline.offer(event(i), Duration.ZERO)) {
        accepted++;
    } else {
        rejected++;
    }
}

本机输出(两次运行一致):

produced=300 accepted=34 rejected=266 elapsedMs=1 maxDepth=32
before close pending=26
close: pendingAtClose=26 drainedDuringClose=28 abandoned=0 elapsedMs=83
  • 300 个事件只有 34 个被接受:2 个在消费者手里,32 个在队列里。
  • 266 个被拒绝,占 88.7%。这不是失败,是背压生效:系统明确告诉调用方「现在处理不了」。
  • maxDepth=32 说明队列确实被填满过,配置的上限被用到了。
  • 生产者 1ms 就返回了,没有因为队列满而阻塞 300 次。

如果超时改成 50ms 而不是 0,被拒绝的会少一些,但请求线程会被拖慢。这两个数是一对权衡:

超时拒绝率请求延迟
0ms高不受影响
50ms低最多 +50ms
无超时 put0可能无限

libraryd 对审计事件用 50ms:审计不能丢,但也不能把借阅接口拖垮。对推送事件用 0ms:推送丢一条可以补,接口延迟更重要。同一个流水线,不同事件类型可以有不同的超时,这比全局一个参数更贴近业务。

优雅停止:先排空,再退出

停机时队列里还有事件,直接 interrupt 消费者会丢掉正在处理的那条,直接退出会丢掉整个队列。libraryd 的做法是「停止接收 + 排空 + 超时兜底」:

  1. running = false,消费循环的条件变成 while (running || !queue.isEmpty()),队列里还有就继续取。
  2. 主线程 join 每个消费者,最多等 drainTimeout。
  3. 超时后仍有消费者活着,interrupt,把队列里剩下的记为 abandoned。

上面的输出里,close 时队列有 26 条,排空期间又消费了 28 条(26 条加上 2 个消费者手里正在处理的),abandoned=0,耗时 83ms。这个 83ms 可以写进停机的总预算:HTTP 先停(不再有新事件),流水线再排空,最后关连接池。

三个数要进日志和指标:

  • pending:close 那一刻还排队的数量,反映停机时的压力。
  • drained:排空期间成功处理的数量,正常应等于 pending 加上在途数量。
  • abandoned:超时后被放弃的数量,非 0 就需要告警和人工补偿。

在 libraryd 里落地

事件类型用 record,字段不可变:

package com.plume.library.domain;

import java.time.Instant;

public record LoanEvent(long loanId, String isbn, String memberId,
                        Type type, Instant at) {

    public enum Type { BORROWED, RETURNED, RENEWED }

    public LoanEvent {
        if (loanId < 0) {
            throw new IllegalArgumentException("loanId 不能为负: " + loanId);
        }
        if (type == null || at == null) {
            throw new IllegalArgumentException("type 与 at 不能为 null");
        }
    }
}

流水线本体:

package com.plume.library.persistence;

import com.plume.library.domain.LoanEvent;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.LongAdder;

public final class LoanEventPipeline implements AutoCloseable {

    private static final System.Logger LOG = System.getLogger("libraryd.loan.events");

    public interface LoanEventConsumer {
        void accept(LoanEvent event) throws Exception;
    }

    public record Metrics(long accepted, long rejected, long consumed, long failed,
                          int queued, int maxDepth) { }

    private final BlockingQueue<LoanEvent> queue;
    private final List<Thread> workers = new ArrayList<>();
    private final LongAdder accepted = new LongAdder();
    private final LongAdder rejected = new LongAdder();
    private final LongAdder consumed = new LongAdder();
    private final LongAdder failed = new LongAdder();
    private final AtomicInteger maxDepth = new AtomicInteger();
    private volatile boolean running = true;

    public LoanEventPipeline(int capacity, int workerCount,
                             LoanEventConsumer consumer, String name) {
        if (capacity < 1 || workerCount < 1) {
            throw new IllegalArgumentException("capacity 与 workerCount 必须大于 0");
        }
        this.queue = new ArrayBlockingQueue<>(capacity);
        for (int i = 0; i < workerCount; i++) {
            Thread worker = new Thread(() -> consumeLoop(consumer), name + "-" + i);
            worker.setDaemon(false);
            worker.start();
            workers.add(worker);
        }
    }

    private void consumeLoop(LoanEventConsumer consumer) {
        while (running || !queue.isEmpty()) {
            LoanEvent event;
            try {
                event = queue.poll(100, TimeUnit.MILLISECONDS);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                return;
            }
            if (event == null) {
                continue;
            }
            try {
                consumer.accept(event);
                consumed.increment();
            } catch (Exception e) {
                failed.increment();
                LOG.log(System.Logger.Level.WARNING,
                        "event failed loan=" + event.loanId() + " type=" + event.type(), e);
            }
        }
    }

    public boolean publish(LoanEvent event, Duration timeout) throws InterruptedException {
        boolean ok = queue.offer(event, timeout.toMillis(), TimeUnit.MILLISECONDS);
        if (ok) {
            accepted.increment();
            maxDepth.accumulateAndGet(queue.size(), Math::max);
        } else {
            rejected.increment();
        }
        return ok;
    }

    public Metrics metrics() {
        return new Metrics(accepted.sum(), rejected.sum(), consumed.sum(),
                failed.sum(), queue.size(), maxDepth.get());
    }

    public void close(Duration drainTimeout) {
        running = false;
        int pending = queue.size();
        long before = consumed.sum();
        long deadline = System.nanoTime() + drainTimeout.toNanos();
        for (Thread worker : workers) {
            long remaining = deadline - System.nanoTime();
            if (remaining <= 0) {
                break;
            }
            try {
                worker.join(Math.max(1, remaining / 1_000_000));
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                break;
            }
        }
        int abandoned = queue.size();
        for (Thread worker : workers) {
            if (worker.isAlive()) {
                worker.interrupt();
            }
        }
        LOG.log(System.Logger.Level.INFO,
                "pipeline closed pending=" + pending + " drained=" + (consumed.sum() - before)
                        + " abandoned=" + abandoned);
    }

    @Override
    public void close() {
        close(Duration.ofSeconds(10));
    }
}

几个需要解释的决定:

  • 消费循环条件用 running || !queue.isEmpty()。只写 while (running) 会在 close 时立刻退出,队列里的事件全部丢失。这样写在 close 之后仍会把剩余事件处理完,自然排空。
  • poll(100ms) 而不是 take()。take 会一直阻塞,close 只能靠 interrupt,而中断可能打断正在执行 consumer.accept 的线程。用带超时的 poll,线程每 100ms 检查一次 running,退出更干净。
  • 消费者异常不退出循环。单条事件失败只记 failed 并继续,否则一条脏数据就能让整个流水线停摆。需要重试的事件应该有独立的重试队列或死信表。
  • maxDepth 只在投递成功时更新。它记录的是「队列曾经到过多深」,是容量规划的依据;queued 是当前值,两者不是一回事。

装配到 AppMain,顺序与 9.1 的工作池一致:

LoanEventListener audit = new AuditEventListener(auditRepository);
LoanEventPipeline auditPipeline = new LoanEventPipeline(
        config.auditQueueCapacity(), 2, audit::onEvent, "loan-audit");

LoanService loanService = new LoanService(repositories, transactions, metrics, auditPipeline);

Runtime.getRuntime().addShutdownHook(new Thread(() -> {
    http.stop();
    auditPipeline.close(Duration.ofSeconds(5));
    dataSource.close();
}, "libraryd-shutdown"));

HTTP 先停,保证不再有新事件进来;再排空流水线;最后关连接池——如果先关连接池,排空过程中的审计写会全部失败。

常见错误与边界

错误一:用无界队列「避免拒绝」。 new LinkedBlockingQueue<>() 让生产者永远成功,代价是队列可以涨到 OOM。上面实验里无界版本快了 4 倍,但那是因为它没做事。拒绝不是故障,是系统在保护自己。

错误二:消费者抛异常后线程退出。 如果 consumeLoop 没有 try/catch,一条事件的异常会让这个消费者线程死掉。队列还在接收,消费者却越来越少,最后变成 1 个甚至 0 个,生产者被永久阻塞。至少要有「记失败 + 继续」的兜底。

错误三:close 时先 interrupt 再排空。 中断会让 poll 抛 InterruptedException,正在处理的事件被打断,队列里剩余的全部留下。正确顺序是「置停止标志 → 等排空 → 超时才中断」。

错误四:往队列里放可变对象。 生产者放进去之后又修改了对象的字段,消费者读到的可能是改了一半的状态。LoanEvent 用 record 且字段全 final,就是为了避免这个问题。

边界:队列容量不是并发度。 容量 32 只表示最多积压 32 条,不代表 32 个消费者。消费能力由 workerCount 和单条处理时间决定:2 个消费者、每条 5ms,吞吐上限约 400 条/秒,和容量无关。

边界:多实例部署时队列是进程内的。 每个实例一条队列,A 实例的审计事件不会跑到 B 实例。需要跨实例的顺序或去重,得用数据库或消息队列,不能靠 BlockingQueue。

小结

  • BlockingQueue 的四组操作对应四种满/空语义;请求线程只用 offer(e, timeout),不用无超时的 put。
  • ArrayBlockingQueue 单锁、内存可预测;LinkedBlockingQueue 双锁、1P1C 下略快;无界版本快是因为没做背压。
  • 容量 32、2 消费者、每条 5ms 时,300 个事件接受 34 个、拒绝 266 个,队列被打满,背压可见。
  • 优雅停止是「停止接收 → 排空 → 超时中断」,输出 pending、drained、abandoned 三个数。
  • 消费者要捕获单条异常并继续,否则一条脏数据能让整条流水线停摆。
  • libraryd 用 ArrayBlockingQueue 加 LoanEventPipeline,不同事件类型配不同的 offer 超时。

队列解决了「一件事怎么异步做」,但启动预热、并发上限、分批对齐这些协调问题还需要别的原语。下一节看三个同步器各自适合什么。

阅读导航:上一节:5.1 并发容器:ConcurrentHashMap 与 CopyOnWriteArrayList · 下一节:5.3 同步器:CountDownLatch、Semaphore 与 CyclicBarrier 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发