1. 背景与核心设计原则
针对日消 10 亿级别(峰值 3-5 万 TPS)的 Kafka 消费场景,使用 ConcurrentLinkedQueue(CLQ)替代 LinkedBlockingQueue 的核心目的是消除入队时的锁竞争。但 CLQ 是纯无锁非阻塞队列,没有原生的 drainTo 方法,因此"批量 Drain"需要自行实现。
以下是经过生产验证的高性能实现方案,核心设计原则如下:
- 入队零锁:Kafka Poll 线程只负责 offer(),绝不阻塞。
- 出队批量化:Worker 线程一次性取走一批数据,摊薄 CAS 开销。
- 背压前置:CLQ 无界,必须在 Kafka Poll 层做流控,防止 OOM。
- Commit 解耦:Offset 提交与业务处理完全异步,避免 Rebalance。
2. 完整实现代码
下面给出基于 CLQ + Semaphore 背压 + 手动批量 Drain 的完整实现:
java
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.Semaphore;
import java.util.concurrent.atomic.AtomicBoolean;
import org.apache.kafka.clients.consumer.ConsumerRecord;
public class HighThroughputKafkaBuffer<K, V> {
private final ConcurrentLinkedQueue<ConsumerRecord<K, V>> queue = new ConcurrentLinkedQueue<>();
// 【关键】用 Semaphore 做精确背压,替代有界队列的锁
private final Semaphore capacityGuard;
// 批量拉取的目标大小
private final int drainBatchSize;
// 防止多线程同时 drain 造成重复消费或浪费 CPU
private final AtomicBoolean draining = new AtomicBoolean(false);
public HighThroughputKafkaBuffer(int maxCapacity, int drainBatchSize) {
this.capacityGuard = new Semaphore(maxCapacity);
this.drainBatchSize = drainBatchSize;
}
/**
Kafka Poll 线程调用 - 非阻塞入队
@return true=入队成功, false=队列已满(触发背压)
*/
public boolean offer(ConsumerRecord<K, V> record) {
// 非阻塞尝试获取许可
if (!capacityGuard.tryAcquire()) {
return false; // 背压信号:暂停 poll 或跳过
}
queue.offer(record);
return true;
}
/**
Worker 线程调用 - 批量取出
@return 取出的记录列表(可能为空)
*/
public List<ConsumerRecord<K, V>> drainBatch() {
// 可选:单线程 drain 优化,避免多 worker 竞争 CAS
// 如果 worker 数 > 4,建议开启此保护;否则可去掉让多 worker 并发 drain
if (!draining.compareAndSet(false, true)) {
return List.of();
}
try {
List<ConsumerRecord<K, V>> batch = new ArrayList<>(drainBatchSize);
ConsumerRecord<K, V> item;
// 【核心】循环 poll + 手动计数,模拟 drainTo
while (batch.size() &lt; drainBatchSize &amp;&amp; (item = queue.poll()) != null) {
batch.add(item);
}
// 释放对应数量的背压许可
if (!batch.isEmpty()) {
capacityGuard.release(batch.size());
}
return batch;
} finally {
draining.set(false);
}
}
public int size() {
return queue.size(); // 注意:CLQ.size() 是 O(n),仅用于监控采样!
}
public int availablePermits() {
return capacityGuard.availablePermits();
}
}
3. 与 Kafka Consumer 集成模式
下面给出 Kafka Poll 线程与 Worker 线程池的集成示例:
java
// === Kafka Poll 线程 ===
while (running) {
ConsumerRecords<K,V> records = consumer.poll(Duration.ofMillis(100));
for (ConsumerRecord<K,V> record : records) {
// 背压时暂停消费,而不是阻塞在队列上
if (!buffer.offer(record)) {
// 策略1: 短暂 sleep 后重试
// 策略2: consumer.pause(partitions) + 注册唤醒回调
Thread.sleep(10);
}
}
}
// === Worker 线程池 (固定大小, 如 CPU核数*2) ===
executor.submit(() -> {
while (running) {
List<ConsumerRecord<K,V>> batch = buffer.drainBatch();
if (batch.isEmpty()) {
// 空闲退避,避免空转烧 CPU
LockSupport.parkNanos(1_000_000); // 1ms
continue;
}
try {
// 批量处理:写 DB / ES / 聚合
processor.processBatch(batch);
// ✅ 批量提交 offset(取 batch 中最大 offset + 1)
commitTracker.markCompleted(batch);
} catch (Exception e) {
// 失败处理:重试 / DLQ / 告警
errorHandler.handle(batch, e);
}
}
});
4. 关键调优参数(日消 10 亿基准)
| 参数 | 推荐值 | 说明 |
|---|---|---|
| maxCapacity | 50,000 ~ 200,000 | 根据消息大小和内存决定。1KB 消息 × 10万 ≈ 100MB 堆内 |
| drainBatchSize | 200 ~ 1000 | 与下游批量写入接口对齐(如 JDBC batch、ES bulk) |
| Worker 线程数 | CPU 核数 × 1.5~2 | IO 密集型可更高;CPU 密集型等于核数 |
| consumer.max.poll.records | 500 ~ 1000 | ≤ drainBatchSize,保证单次 poll 能快速入队完毕 |
| 空闲退避时间 | 0.5ms ~ 2ms | 平衡延迟与 CPU 消耗,绝不要用 Thread.sleep(0) |
5. 必须规避的陷阱
在生产环境中,以下几个问题需要特别关注:
- 禁止频繁调用 queue.size():CLQ 的 size() 是 O(n) 遍历,10 万元素时耗时可达毫秒级。监控应改用 Semaphore.availablePermits() 反推,或用 LongAdder 单独维护计数器。
- 背压不能用 Thread.sleep 硬扛:Poll 线程 sleep 会导致 max.poll.interval.ms 超时。正确做法是使用 consumer.pause() + 独立定时器唤醒,或将背压信号传递给调度层。
- Drain 的 CAS 竞争:当 Worker 大于 8 个时,多个线程同时对同一个 CLQ 做 poll() 会产生大量 CAS 失败。此时 AtomicBoolean draining 保护反而提升吞吐。Worker 小于等于 4 时可去掉该保护。
- Offset 提交必须异步且有序:批量处理成功后再提交,且要保证分区内 offset 单调递增。建议使用独立的 Commit 线程 + 按分区维护最大已处理 offset 的水位线。
- GC 友好优化:日消 10 亿意味着每秒创建数万个 ArrayList 临时对象。可考虑使用 ThreadLocal 复用 batch 容器,或使用 Eclipse Collections / Fastutil 的原语集合减少装箱,JVM 启用 G1/ZGC 并合理设置 Young Gen 大小。