高并发流程引擎踩坑记:一次从任务丢失到死锁排查的完整实战

VibeCoding·九月创作之星挑战赛 10w+人浏览 1.2k人参与

在这里插入图片描述

背景

背景是在公司业务中开发一款需要高并发支持的流程引擎时,我遇到了一次典型的多线程“血案”:任务悄无声息地丢失、提交的任务迟迟不处理,最终排查出竟然是死锁的锅。

下面分享这次完整的排查与解决过程,希望能给同样在并发世界中摸爬滚打的你一些启发。

技术环境

整体框架基于 SpringBoot,但问题与 SpringBoot 毫无关系,核心是我纯 Java 编写的流程引擎以及配套的智能体逻辑。多线程部分采用自研的资源池工具,没有引入任何第三方线程池库。

为了保证线程安全,最初我选择在方法上直接加 synchronized 关键字。

出现的问题

随着并发量增加开始出现问题:

① 先是开始丢失处理任务,任务莫名其妙的就消失了。

② 提交的任务也不处理了。

③ 控制台看不到任何异常堆栈。

下面是排查过程。先透露一下最终排查出来的问题,是因为多线程使用不当,造成了死锁问题。

什么是死锁

我用一张图通俗的描述一下啥叫死锁,张三拿着红色外屋的钥匙进入了外屋,但需要蓝色里屋钥匙才能进入蓝屋,而蓝色里屋的钥匙在李四那里,而李四又在等着张三手里红色外屋的钥匙才能进去给张三送蓝色里屋的钥匙;于是乎就尬在这了,我动不了,你也动不了。

死锁示例

排查过程

确定大致问题

通过打印日志,确定问题跟线程有关,但不确定是业务问题,还是技术引擎有关。

Debug

先是以为业务逻辑有问题,在debug过程中排除掉业务逻辑部分后,开始排查线程技术部分,下面是当前流程引擎线程工具方法。

public void scheduleTask(String taskId, long delay, Runnable task) {
    cancelTask(taskId); // 如果任务已存在,则取消它
    // 包装任务,捕获异常
    Runnable safeTask = () -> {
        try {
            task.run();
        } catch (ProcessEngineBusinessException exception) {
            log.error("异步任务 - {} - 执行异常", taskId, exception);
            // 停止流程并推送通知
            RunnerControl.PROJECT_LIST_RUNNER_MAP
                    .get(exception.getFlowIdAndRunner())
                    .getTimeManager()
                    .stopFlowIfNotStopped(FlowEndType.EXCEPTION_END.setType(exception.getMessage()));
        } catch (Throwable e) {
            log.error("异步任务 - {} - 执行异常", taskId, e);
        }
    };
    ScheduledFuture<?> scheduledFuture = scheduler.schedule(safeTask, delay, TimeUnit.MILLISECONDS);
    if (delay > 0) {
        tasks.put(taskId, scheduledFuture);
    }
}

将断电打在task.run();行时,发现断点无法到这里,说明异步任务在此之前就已经丢失了,并且通过断点发现scheduler线程池对象目前是饱和状态,线程池中的核心线程都在工作中,所以初步确定是线程池处于满载状态后,任务进入了等待队列中,而随着队列中堆积任务越来越多,最后导致队列也满了,但核心线程就是不释放。

不释放就只有一个原因了,死锁了;那就好办了,找到死锁原因就解决了。

寻找死锁

使用JDK自带的jconsole工具,就可以一键找到死锁位置,并且能给我解释的明明白白为啥会死锁。

① 在JDK的bin目录下找到jconsole.exe文件,双击启动。

jconsole

② 找到我们的本地服务进程,连接。

新建连接

③ 找到我们命名的线程池名称,可以看到该线程池中有很多线程,随意点几个就可以看到当前状态是BLOCKED,表示这个线程正在尝试获取一个同步锁(synchronized),但该锁当前被别的线程持有,因此挂起。

查看线程

点击下面的检测死锁按钮,可以看到更多信息。

④ 死锁分析,点击检测死锁后界面后展示了与当前线程竞争锁对象的有哪些线程,一看好家伙,在这套娃呢,你抢我的锁,我抢他的锁,他又抢你的锁,正好形成了一个回路。

死锁检测

解决死锁

解读死锁信息

通过jconsole工具给出的信息,简要信息如下,更多信息就不列了:

ActivityNode.start(ActivityNode.java:138)
  - 已锁定 ActivityNode@6944ba35
ActivityNode.dispatch(ActivityNode.java:110)
  - 已锁定 ActivityNode@6944ba35

简单理解就是ActivityNode.startdispatch 方法里都有synchronized 关键字(或进入了某个同步块),并且锁对象是同一个 **ActivityNode@6944ba35**

换成人话就是线程38持有锁 A (6944ba35),试图获取锁 B (44b39f9d)。线程36持有锁 B,同时需要锁 A。完美复原上面讲到的死锁原理。

解决死锁问题

知道问题在哪了,直接把startdispatch方法的synchronized 关键字先去掉,经过理解业务之后,是因为不同任务在同一个智能体中处理不同任务造成的。

所以给每一个任务创建一个锁对象,在start方法中进行手动加锁,既保证了线程安全,又保障了并发。下面是核心代码。

private final ConcurrentHashMap<String, ReentrantLock> projectLocks = new ConcurrentHashMap<>();

public void start(String projectId, long dispatchTime, long startTime, ProjectListCallbackFunction callback) {
    ReentrantLock lock = lockFor(projectId);
    lock.lock();
    try {
      // 业务代码
    } finally {
        lock.unlock();
    }
}

public ReentrantLock lockFor(String projectId) {
    return projectLocks.computeIfAbsent(projectId, k -> new ReentrantLock());
}

总结

这次排查的最大收获是:

① 粗粒度锁易引发死锁,尤其在高并发场景;

② 合理拆分锁、按业务维度精细化加锁;

③ JDK 自带的 jconsole 是个神器。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

二饭

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值