PS: kafka consumer 掉线且没有自动重连,是工程代码中的并发问题导致的,下面将过程和问题分析具体复盘总结一下
1. 背景
生产环境因 kafka 分区数据量不均匀,导致 kafka 消息积压问题,在现有不能改变 canal 同步 binlog 环境的条件下,需要在消费端增加消费吞吐能力,缓解或解决消息积压问题。
1.1 问题描述
在压测过程中,发现 kafka 的消费者全部掉线(偶现),经过分析发现 kafka 消费者线程全部处于阻塞状态,导致 consumer 长时间未拉取消息,在 reblance 之后消费者全部掉线。问题根因是自定义线程池并发处理逻辑有问题,后面分析过程会详细讲。
1.2 方案设计
order 服务消费 binlog 消息写入到 ES,其中,在消费消息到写 ES 之间增加一层有序线程池,进行并发消费,根据订单号对并发度取模后,分发给指定线程执行,即,相同的订单号一定会被同一个线程消费,以此保证消息的有序性,如下图:

1.3 代码实现
java
// 自定义有序线程池
public class OrderedThreadPoolExecutor {
private final InnerTaskWorker[] taskWorkers;
private final ThreadFactory threadFactory;
public OrderedThreadPoolExecutor(int concurrency, ThreadFactory threadFactory) {
BizAssertUtil.notNull(threadFactory, "【并发有序线程池】ThreadFactory不能为空");
this.taskWorkers = new InnerTaskWorker[concurrency];
this.threadFactory = threadFactory;
this.init(concurrency);
}
private void init(int taskCount) {
this.initFillRunTasks(taskCount);
this.addShutdownHook();
}
/**
* 初始化填充并行任务
* @param taskCount 任务数,即并发度
*/
private void initFillRunTasks(int taskCount) {
for (int i = 0; i < taskCount; i++) {
this.taskWorkers[i] = new InnerTaskWorker(this.threadFactory);
}
}
/**
* 设置jvm退出的钩子,响应中断、释放资源
* 1. 不再接受新任务
* 2. 停止内部线程池
*/
private void addShutdownHook() {
Runtime.getRuntime().addShutdownHook(new Thread(() ->
Arrays.asList(this.taskWorkers).forEach(InnerTaskWorker::stopWorker)));
}
/**
* 根据提供的hashKey,执行有序的任务
* @param hashKey 分区标识
* @param task 任务
*/
public void executeOrdered(int hashKey, Runnable task) {
int i = hashKey % taskWorkers.length;
this.taskWorkers[i].addWorker(task);
}
/**
* 内部封装真正执行任务的线程
*/
private static final class InnerTaskWorker {
private final SynchronousQueue<Runnable> sq;
private final ThreadFactory threadFactory;
private final AtomicBoolean running;
private volatile Thread command;
private InnerTaskWorker(ThreadFactory threadFactory) {
this.sq = new SynchronousQueue<>();
this.threadFactory = threadFactory;
this.running = new AtomicBoolean(true);
this.init();
}
private void init() {
this.startWorker();
}
/**
* 当前是否存在可执行任务的线程
* @return true存在执行任务线程,可直接提交任务;false需要重新启动执行任务
*/
private boolean isRunning() {
return this.running.get();
}
/**
* 启动执行任务
*/
private void startWorker() { // NOSONAR
this.command = threadFactory.newThread(() -> {
while (true) {
if (!this.isRunning()) {
throw new RejectedExecutionException("【并发有序线程池】从同步队列获取任务,响应中断");
}
try {
sq.take().run(); // 代码1
} catch (InterruptedException e) {
// 如果发布服务触发jvm退出,需要响应中断信号
if (!this.isRunning()) {
throw new RejectedExecutionException("【并发有序线程池】jvm退出,响应中断");
}
log.error("【并发有序线程池】从同步队列获取任务,线程异常恢复", e);
}
}
});
this.command.start();
}
/**
* 停止执行任务
*/
private void stopWorker() {
this.running.set(false);
}
/**
* 提交目标任务
* @param task 目标任务
*/
private synchronized void addWorker(Runnable task) {
try {
// 线程非正常退出的,需要重新拉起
boolean commandStopped = (command == null || command.getState() == Thread.State.TERMINATED);
if (commandStopped && this.isRunning()) {
this.startWorker();
}
sq.put(task); // 代码2
} catch (InterruptedException e) {
if (this.isRunning()) {
log.error("【并发有序线程池】将任务放入同步队列异常", e);
return;
}
throw new RejectedExecutionException("【并发有序线程池】容器退出不再接受新任务");
}
}
}
}
2. 排查过程
okay,上面基本已经把背景交代清楚了,现在我们已知:为提升消费吞吐而设计的自定义有序线程池,整体思路没问题,但是代码实现上存在并发问题,接下来我们就重点分析一下到底是什么样的并发问题。
2.1 线程池工作流程分析
- 当线程池启动后会初始化 c 个 InnerTaskWorker,(注:c 是给定的并发度),每个 InnerTaskWorker 内部有一个线程不停的从自己的 SQ 中获取任务并执行,其中为了让业务层感知执行异常,在工作线程中并没有捕捉业务代码产生的任何异常,即 代码1 片段
- 因业务代码抛出的异常会导致当前工作线程结束,所以,在 addWorker 方法中有一个重新创建线程的逻辑,为了保证每一个 InnerTaskWorker 始终有一个工作线程,在不停的从自己的 SQ 中获取任务并执行
- 当 SQ 不为空时(已提交任务但 InnerTaskWorker 还未完成上一个任务),消费线程会 park 在 代码2 位置
2.2 执行正常/异常场景分析
首先尝试复现该问题,多线程并发且工作线程执行异常场景下,果然复现,如下图:
PS:正常场景即业务代码正常执行完成,而异常场景就是业务代码抛出了异常,2.1 部分交代了线程池的工作流程,下面分析下并发场景代码执行的时序问题:
2.3 优化代码解决问题
思路:
- 存在竞态条件的代码逻辑做薄,避免时序问题,所以 addWorker 所以只做一件事,就是将任务加入到队列中,该方法同时可以去掉 synchronized 修饰方法,如下 代码2 位置
- 哪里抛出异常,哪里就负责新建线程,将创建工作线程的逻辑收到 startWorker 中,并在异常处理的位置通过 cas 的方式创建线程,如下 代码1 位置
代码:
java
// 自定义有序线程池
public class OrderedThreadPoolExecutor {
private final InnerTaskWorker[] taskWorkers;
private final ThreadFactory threadFactory;
/**
* 构造包含了给定并发度的顺序执行器
* @param concurrency 并发度
* @param threadFactory 线程工厂
*/
public OrderedThreadPoolExecutor(int concurrency, ThreadFactory threadFactory) {
BizAssertUtil.notNull(threadFactory, "【并发有序线程池】ThreadFactory不能为空");
this.taskWorkers = new InnerTaskWorker[concurrency];
this.threadFactory = threadFactory;
this.init(concurrency);
}
private void init(int taskCount) {
this.initFillRunTasks(taskCount);
this.addShutdownHook();
}
/**
* 初始化填充并行任务
* @param taskCount 任务数,即并发度
*/
private void initFillRunTasks(int taskCount) {
for (int i = 0; i < taskCount; i++) {
this.taskWorkers[i] = new InnerTaskWorker(this.threadFactory);
}
}
/**
* 设置jvm退出的钩子,响应中断、释放资源
* 1. 不再接受新任务
* 2. 停止内部线程池
*/
private void addShutdownHook() {
Runtime.getRuntime().addShutdownHook(new Thread(() ->
Arrays.asList(this.taskWorkers).forEach(InnerTaskWorker::stopWorker)));
}
/**
* 根据提供的hashKey,执行有序的任务
* @param hashKey 分区标识
* @param task 任务
*/
public void executeOrdered(int hashKey, Runnable task) {
int i = hashKey % taskWorkers.length;
this.taskWorkers[i].addWorker(task);
}
/**
* 内部封装真正执行任务的线程
*/
private static final class InnerTaskWorker {
private final SynchronousQueue<Runnable> sq;
private final ThreadFactory threadFactory;
private final AtomicInteger threadState;
private volatile Thread command;
private InnerTaskWorker(ThreadFactory threadFactory) {
this.sq = new SynchronousQueue<>();
this.threadFactory = threadFactory;
this.threadState = new AtomicInteger(InnerThreadState.RUNNING);
this.init();
}
private void init() {
this.startWorker();
}
/**
* 当前是否存在可执行任务的线程
* @return true存在执行任务线程,可直接提交任务;false需要重新启动执行任务
*/
private boolean isRunning() {
return this.threadState.get() == InnerThreadState.RUNNING;
}
/**
* 当前线程是否需要退出
* @return true不可执行任务,退出线程
*/
private boolean isTerminated() {
return this.threadState.get() == InnerThreadState.TERMINATED;
}
/**
* 启动执行任务
*/
private void startWorker() { // NOSONAR
this.command = threadFactory.newThread(() -> {
while (isRunning()) {
try {
sq.take().run();
} catch (InterruptedException e) {
if (isTerminated()) {
throw new RejectedExecutionException("【并发有序线程池】jvm退出,响应中断");
}
log.error("【并发有序线程池】从同步队列获取任务,线程异常恢复", e);
} catch (Throwable t) {
handleWorkerException(); // 代码1
throw t;
}
}
});
this.command.start();
}
/**
* 停止执行任务
*/
private void stopWorker() {
this.threadState.set(InnerThreadState.TERMINATED);
this.command.interrupt();
try {
this.command.join();
} catch (InterruptedException e) {
log.error("【并发有序线程池】停止工作线程失败", e);
}
}
/**
* 提交目标任务
* @param task 目标任务
*/
private void addWorker(Runnable task) {
try {
// 提交任务,只负责将任务放入队列
// 该方法为阻塞方法,即必须同步放入队列后才返回
sq.put(task); // 代码2
} catch (InterruptedException e) {
if (isRunning()) {
log.error("【并发有序线程池】将任务放入同步队列异常", e);
return;
}
throw new RejectedExecutionException("【并发有序线程池】容器退出不再接受新任务");
}
}
/**
* 处理任务异常时,继续有工作线程执行任务
*/
private void handleWorkerException() {
this.threadState.set(InnerThreadState.EXCEPTED);
// 通过cas方式来启动新线程
// 这个判断不是脱裤子放屁,因为threadState.set()与startWorker()并不是原子操作
if (this.threadState.compareAndSet(InnerThreadState.EXCEPTED, InnerThreadState.RUNNING)) {
this.startWorker();
}
}
}
/**
* 内部线程状态码
*/
private static final class InnerThreadState {
/**
* 正常运行
*/
private static final int RUNNING = 1;
/**
* 异常停止
*/
private static final int EXCEPTED = 2;
/**
* 正常结束
*/
private static final int TERMINATED = 3;
}
}
并发测试验证:
java
// 多线程并发测试代码
public static void main(String[] args) {
OrderedThreadPoolExecutor orderedExecutor = new OrderedThreadPoolExecutor(64);
Runnable task = () -> {
if (new Random().nextBoolean()) {
try {
Thread.sleep(20L);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
// 模拟偶尔业务代码抛出异常情况
if (new Random().nextBoolean()) {
throw new RuntimeException("Test exception");
}
}
};
StringBuilder log = new StringBuilder();
for (int i = 0; i < 38400; i++) {
try {
String orderId = "o_" + (100_000_000 + i);
int hash = Math.abs(Hashing.murmur3_32().hashBytes(orderId.getBytes()).asInt());
orderedExecutor.executeOrdered(hash, task);
} catch (Exception ignore) {
// 忽略模拟的业务代码异常
}
}
InnerTaskWorker[] tws = orderedExecutor.taskWorkers;
for (int i = 0; i < tws.length; i++) {
log.append(tws[i].command.getName())
.append(" ==> ")
.append(tws[i].command.getState())
.append("\n");
}
System.out.println(log);
}
JVM 监控:
如下图,活跃线程数与预期一致,说明异常场景下新创建线程是并发安全的
模拟业务异常-活跃线程数-74
模拟业务无异常-活跃线程数-74
3. 结论
- 并发问题在编码阶段是很容易遗漏场景的,所以,在编码结束后应该通过多线程并发模拟可能出现的场景,比如在该案例中,业务代码执行异常场景就应该在多线程环境下自测
- 要结合线程堆栈具体分析问题,该案例中表象问题是 kafka consumer 掉线,但根因是消费者主线程阻塞
- 我们在实际开发中,不仅要考虑 cpu、内存、io 等资源的消耗,也需要考虑并发线程安全问题,通常是一些小问题容易在高并发的场景下被放大
- 压测、监控是发现问题的一大利器,在可评估的合理范围内,尽量多轮压测,也许就会发现新问题