智能BI实战(3)---异步化改造

🤵‍♂️ 个人主页:@rain雨雨编程

😄微信公众号:rain雨雨编程

✍🏻作者简介:持续分享机器学习,爬虫,数据分析
🐋 希望大家多多支持,我们一起进步!
如果文章对你有帮助的话,
欢迎评论 💬点赞👍🏻 收藏 📂加关注+

智能 BI 项目教程

一、项目概述

在传统的数据分析平台中,如果我们想要分析近一年网站的用户增长趋势,通常需要手动导入数据、选择要分析的字段和图表,并由专业的数据分析师完成分析,最后得出结论。

然而,本次设计的项目与传统平台有所不同。在这个项目中,用户只需输入想要分析的目标,并上传原始数据,系统将利用 AI 自动生成可视化图表和学习的分析结论。这样,即使是对数据分析一窍不通的人也能轻松使用该系统。

二、系统问题分析

💡 大家在生成图表的过程中有没有发现什么问题?

2.1 系统问题分析讲解

第一个问题:

如果有同学之前做过同步变异步方面的优化,应该能看出来这个图表生成的时间有点长。

因为我们背后用的 AI 能力是需要一定时间来完成处理的。

第二个问题:

当系统面临大量用户请求时,如果处理能力有限,例如服务器的内存、CPU、网络带宽等资源有限,这可能导致用户处在一个长时间的等待状态。特别是在许多用户同时提交请求的情况下,服务器可能需要较长的时间来处理。

此外,如果我们后端的 AI 处理能力有限,也有可能引发问题。比如,为了确保平台的安全性,我们可能会限制用户的访问频率,即每秒或每几秒用户只能访问一次或几次。一旦用户过多地提交请求,就会增大 AI 处理的服务器的压力,导致 AI 服务器处理不了这么多请求。在这种情况下,其他用户只能等待,而在前端界面也只能显示持续等待的状态。长时间等待后,用户可能会收到服务器繁忙的错误信息。这不仅影响了用户的体验,也对服务器和我们使用的第三方服务带来压力。

我们还需要考虑服务器如 Tomcat 的线程数限制。在极端情况下,比如每十秒只能处理一个请求,但却有 200 个用户在一秒钟内同时提交请求,这就会导致大量用户请求在服务器上积压,数据也无法及时插入到数据库中。如果用户长时间等待最终仍得到请求失败的结果,这种情况下也会对服务器造成压力。

第三个问题:

当我们调用第三方服务,比如我们的 AI 处理能力是有限的,如每三秒只能处理一个请求。在这种情况下,大量用户同时请求可能导致 AI 过载,甚至拒绝我们的请求。

假设我们正在使用的星火平台,这是一个提供 AI 回答功能的服务。在我们的开发的智能 BI 中,如果有 100 个用户同时访问,就需要 100 次调用星火 AI。然而,AI可能无法在一秒钟内服务 100 个用户。这种情况下,AI 服务会认为我们在攻击它,或者超过了它的处理能力,可能会对我们施加限制。这构成了一个潜在的风险。

如果遇到这些问题该怎么办?还有其他问题吗?

解决方式是将这类问题归类,然后采用今天的主题——异步化的思路来解决。我们需要思考在哪些场景下可能会遇到服务处理能力有限或处理时间长的问题。

另一个导致处理时间过长的因素是返回的数据量过大,这可能导致数据传输时间延长,从而增加了返回时间。例如,当你调用一个第三方服务时,可能需要 10 秒、20 秒甚至半小时才能返回结果。在处理繁重任务的时候,我们应该考虑采用异步处理,避免让用户长时间等待。因此,本期的主题就是如何实现异步化,以有效解决这个问题。

2.2 系统问题分析总结

问题场景:面临服务处理能力有限,或者接口处理(或返回)时长较长时,就应该考虑采用异步化。

具体来看,我们可能会遇到以下问题:

  1. 用户等待时间过长:这是因为需要等待 AI 生成结果。

  2. 业务服务器可能面临大量请求处理,导致系统资源紧张,严重时甚至可能导致服务器宕机或无法处理新的请求。

  3. 调用的第三方服务(AI 能力)的处理能力有限。比如每 3 秒只能处理 1 个请求,就会导致 AI 处理不过来;严重时,AI 可能会对我们的后台系统拒绝服务。

综上所述,面对这些问题,我们应当考虑异步化的解决方案。

三、异步化

3.1 介绍

同步: 一件事情做完,再做另外一件事情(烧水后才能处理工作)。

异步: 在处理一件事情的同时,可以处理另一件事情。当第一件事完成时,会收到一个通知告诉你这件事已经完成,这样就可以进行后续的处理(烧水时,可以同时处理其他工作。水壶上的蜂鸣器会在水烧好时发出声音,就知道水已经烧好了,可以进行下一步操作)。

通常,如果想将同步变为异步,必须知道何时任务已经完成。因此,需要一个通知机制。

3.2 异步业务流程分析

在我们的系统中,异步的流程是怎样的呢?用户在点击提交后就不需要在当前界面等待,他们可以直接回到主界面,或者继续填写下一个需要生成或分析的数据。提交完成后,他们回到主页,在主页上就可以看到图表的生成状态。

如果图表已经生成好,那么我们的系统可以在界面的右上角添加一个消息通知功能,用户可以在那里看到相关信息,大致就是这样的一个流程。

标准异步化业务流程
标准异步化业务流程讲解

在用户需要进行长时间的操作时,点击提交后不需要在界面空等。而是先保存至数据库。以往我们会等图表完全生成后再保存,但现在,任务一提交,我们就立即存储,避免让用户在界面上等待。

接着,我们需要将用户的操作或任务添加到任务队列中,让程序或线程执行。想象一下,将用户的操作加入任务队列,这个队列就像个备忘录。比如我是公司唯一的员工,正在进行一个项目,当有用户请求修复 bug 时。我无法立即处理,但可以记下这个修复 bug 的任务,待完成项目后再处理。这个队列就像我的备忘录。

由于程序的处理能力或线程数有限,我们可以先把待处理的任务放入队列中等待。当我们有空的时候,再按顺序执行,而不是直接拒绝。因此,如果任务队列有空位,我们可以接受新任务;如果有空闲的线程或员工,我们可以立即开始这个任务;如果所有线程都在忙碌,那么我们可以把任务放入等待队列。但是,如果所有线程都在忙,且任务队列已满,那我们该怎么办?

有很多策略供我们选择。一种做法是直接拒绝任务。更好的方式是记录下这个任务,待有空时再处理。无论任务提交成功与否,我们都应该将其保存到数据库中以备查阅。这样,当我们在后期检查时,可以看到哪些任务由于程序处理能力不足而未得到处理。即使任务队列已满,我们也可以通过查阅数据库记录,找出提交失败的任务,并在程序空闲时取出这些任务,放入任务队列中执行。

当用户需要执行新任务时,即使任务提交失败,或者消息队列满了,也要将其记录下来。建议将这个任务保存到数据库中记录,或者至少打一个日志。我们不能只把消息放入消息队列当做唯一的流程。要有一些保险措施,如打更多的日志,以应对网络或电力的突发情况。在开始编写程序前,我们应该清楚这些流程,这些都是我们可能需要处理的情况。

在第三步中,我们的程序(线程)会按照任务队列的顺序逐一执行任务,这就像员工按照备忘录一项接一项地完成任务。任务完成后,我们会更新任务状态,将相关任务记录在数据库中标记为已完成。接下来需要考虑的问题是如何让用户知道他们的任务何时完成。在用户提交任务后,我们应该提供一个查询任务状态的地方,而不是让他们无尽地等待。

通过这一系列流程,用户的体验会比直接等待任务完成更好。尤其是在需要进行复杂分析的情况下,用户不太可能在界面上等待那么久。这时,我们可以采取异步执行,让用户先去做其他事情,可以继续提交新任务,也可以实时查看任务状态。这样的体验更好,远优于等待长时间后任务失败。


❗ 注意:并非所有的操作都需要异步化。只有在任务执行时间较长的场景下,才考虑采用异步化方式。因为多线程和异步处理会增加代码的复杂度,并可能带来更多的问题。如果同步方式能够解决问题,那么就无需使用异步。

异步处理是一个复杂的过程。在异步执行中,开发者可能无法清楚地知道程序执行到了哪一步。对于复杂的任务,我们需要在每一个小任务完成时记录下任务的执行状态或进度,这就像我们下载文件时看到的进度条一样。所以,对于大型、复杂的任务,为了提供更好的用户体验,我们应该提供进度条,让用户在查询状态时能看到任务执行到了哪一步。这是任何异步操作的重要环节,也是优化业务流程的方法。

标准异步化流程总结
  1. 当用户要进行耗时很长的操作时,点击提交后,不需要在界面空等,而是应该把这个任务保存到数据库中记录下来

  2. 用户要执行新任务时:

  3. 任务提交成功:

  • 若程序存在空闲线程,可以立即执行此任务

  • 若所有线程均繁忙,任务将入队列等待处理

  1. 任务提交失败:比如所有线程都在忙碌且任务队列满了

  • 选择拒绝此任务,不再执行

  • 通过查阅数据库记录,发现提交失败的任务,并在程序空闲时将这些任务取出执行

  1. 程序(线程)从任务队列中取出任务依次执行,每完成一项任务,就更新任务状态。

  2. 用户可以查询任务的执行状态,或者在任务执行成功或失败时接收通知(例如:发邮件、系统消息提示或短信),从而优化体验

  3. 对于复杂且包含多个环节的任务,在每个小任务完成时,要在程序(数据库中))记录任务的执行状态(进度)。

我们系统的业务流程
系统的业务流程讲解

回归到我们的系统,流程其实可以大大简化。比如,在创建图表的过程中,每次提交可以视为一个任务,我们甚至可以直接省略一个新的任务记录表。具体来说,将创建图表这个过程视作一个任务,并直接在图表的数据库或数据表中添加一个字段来表示图表的生成状态或任务状态,这样就无需再建立新的表了。

要查询图表是否生成完毕,我们可以直接在页面上给出提示,或者展示一个等待标志、给出其他形式的反馈。这就避免了新建一个任务查询页面的需要,我们完全可以直接在这个界面完成任务状态的展示。

  • 首先,用户在点击智能分析页的提交按钮时,系统会立即将图表保存到数据库中作为一个任务。这是立即保存的,而不是等待 AI 生成结果后再保存。这样即使图表生成失败,用户也能明了其状态。

  • 其次,用户可以在图表管理页面查看所有图表的信息和状态,包括已生成的、正在生成的和生成失败的。此处,如果你想添加一些通知功能,或者定时更新功能,如让这个页面每三秒自动更新一次数据,都是可以的。

  • 最后,用户可以修改生成失败的图表信息,或者选择重新生成等操作。这个功能🐟不一定在这个系统中实现,它类似于增删改查操作中的修改功能,很像星火的重新生成功能。

系统的业务流程总结
  1. 用户点击智能分析页的提交按钮时,先把图表立刻保存到数据库中(作为一个任务)。

  2. 用户可以在图表管理页面查看所有图表(已生成的、生成中的、生成失败)的信息和状态。

  3. 用户可以修改生成失败的图表信息,点击重新生成,以尝试再次创建图表。

3.3 架构图改造

让我们回顾一下第一期🐟绘制的架构图。在进行改造之前,项目的架构是这样的,用户直接点击提交表单进行数据分析,然后后端立即调用 AI 服务。待 AI 服务生成图表数据后,再反馈给后端,最后由后端反馈给用户。

这就是我们最初的业务流程:

现在让我们看一下经过改造后的流程:

假设有很多用户都要提交图表,首先,我们的系统后端会立即将这些数据保存到数据库中。接着,我们将生成图表的任务放入队列。这样,我们就可以向用户返回信息了,告诉他们,你的任务已经提交,稍候就可以查看结果。

在后台,任务被放入队列后,我们的程序中的线程会逐一从队列中取出消息,然后将这些任务交给 AI 服务去执行。这样一来,AI 服务的运行就能够得到保证,确保其安全和稳定。

为什么要这么做呢?因为我们将消息放入了队列,逐一取出,而不是将所有用户的请求一股脑的发给 AI 服务。所以,消息队列在这里发挥了什么作用呢?它起到了消峰的作用。这个消息队列你可以理解为任务队列。任务队列可以将突然增大的流量以平稳的方式,如管道一样,逐一传给下游处理系统,或者说是我们的第三方服务。

第三方服务生成好图表之后,我们的任务处理模块就可以去更新数据库中的图表信息。比如,之前的状态是正在生成,更新后的状态就变成了已生成。

3.4 问题

问题讲解

虽然现在大家看完了整个流程,但对于从未接触过异步化开发的同学,可能会有两个疑问。首先,任务队列应该如何设置最大容量,毕竟我们的能力是有限的,不能无限制地接受任务。就像我们公司接外部工作一样,我们也有满载的瓶颈。 回归到我们的系统中,我们应该如何限制任务队列的最大容量?应该设置多少个任务为最大值?另一个关键的实现点是,我们的程序如何从任务队列中逐一取出任务进行执行?更进一步的话,我们应该如何实现这个任务队列的流程?这两个问题值得我们深思。

首先,我们应该如何设置任务队列的最大容量。其次,我们应该如何实现一个程序,使其能自动从任务队列中取任务执行,并确保它在取任务时,最多只能同时取出多少个任务,最多只能执行多少个任务。例如,假设用户提交了八个任务,但我的程序最多只能同时执行四个任务。我需要等待第四个任务执行完毕后才能取出下一个任务,这又该如何实现呢?

实际上,这可能听起来或者说让你自己实现起来可能比较麻烦,需要用到数据结构中的队列知识,可能还要结合一些编程思想去实现。有些人可能会提出使用阻塞队列,这是一种实现方式,或者说适当增加人手,这也是一种实现方式。 另一种实现方式是分配四个线程,然后这四个线程执行完一个任务之后,才能再执行下一个任务。也就是说,线程需要取出任务来执行。

问题总结
  1. 任务队列的最大容量应该设置为多少?

  2. 程序怎么从任务队列中取出任务去执行?这个任务队列的流程怎么实现?怎么保证程序最多同时执行多少个任务?

四、线程池

本期要讨论的实现方式就是使用线程池。有了线程池,上述的问题就能迎刃而解了。可能有部分同学用过线程池,但线程池里面其实有很多细节需要掌握。

4.1 线程池的讲解

首先,我们需要实现的流程必须有一个工作者,也就是程序的线程,比如我们称之为线程小李。此外,我们还需要一个任务队列,这个队列中有待处理的任务。设想一个情景,用户提交了一个任务,比如智能分析,任务1便加入队列。线程小李现在可以从任务队列中取出任务1进行执行,他关注的就是这个任务队列,有任务则取出,但每次只能执行一个,因为每个线程的工作能力是有限的,不可能同时处理多个任务。

那么问题来了,又来了一个任务2,但线程小李正在处理任务1,没有办法处理任务2。此时,是不是可以考虑再派一个线程小鱼来帮忙?假如再有任务3,但我们的员工已经没有了,只有小李和小鱼,此时又该怎么办?我们可以把新来的任务放入任务队列中,等待小李或者小鱼完成当前任务后,再去取任务3。

但是,如果任务队列满了怎么办?此时,我们就没办法再接收新任务了。这时,为了系统的稳定性,我们可以选择把新任务记录到数据库中,但不再加入任务队列,等有空闲的线程或者有新的线程加入时,再把这些任务加入队列。

然而,这个方案还不够完美。在某些情况下,如果任务队列满了,不一定非要拒绝新任务。我们还可以有其他策略,比如再开一个队列,甚至再开一个公司。同时,我们还需要考虑,当小李正在处理任务1 的时候,任务2 来了,我们是否有必要立刻调用线程小鱼?

例如,如果我们有四个任务,如果小李处理任务的速度非常快,可能一秒钟就能完成一个任务,而小鱼处理任务的速度较慢,需要一个小时才能完成一个任务。我们是不是就让小李依次执行这四个任务就可以了。因此,如何分配任务,如何分配线程,这都需要我们制定出适合的策略。所以,我们需要线程池来管理这些线程,任务队列就像是一个备忘录,任务就是我们需要完成的工作。

为什么需要线程池?

因为线程管理比较复杂。比如,突然来了很多任务,我们需要临时调用更多的线程来处理,但这些任务可能只是暂时的,正常情况下可能每天只有一个任务。当任务处理完毕后,这些临时的线程就没有工作可做了,那我们怎么办呢?我们应该把他们看作临时工,当任务处理完毕后,他们就可以离开,释放系统资源,等到下一次有大量任务需要处理的时候,再临时招募他们。

4.2 线程池的总结

为什么需要线程池?

  1. 线程的管理比较复杂(比如什么时候新增线程、什么时候减少空闲线程)

  2. 任务存取比较复杂(什么时候接受任务、什么时候拒绝任务、怎么保证大家不抢到同一个任务)

线程池的作用: 帮助你轻松管理线程、协调任务的执行过程。

扩充:可以向线程池表达你的需求,比如最多只允许四个人同时执行任务。线程池就能自动为你进行管理。在任务紧急时,它会帮你将任务放入队列。而在任务不紧急或者还有线程空闲时,它会直接将任务交给空闲的线程,而不是放入队列。

4.3 线程池的实现

在大厂面试时,有可能会让你自行实现线程池。这时,你可以借助之前提到的几种场景,例如何时增加线程,何时减少线程,来逐步解答这个问题。

实际上这是一项繁琐的任务。比如,有一种情况,如果大家执行速度都很快,可能会抢到同一个任务,如何协调各线程不去抢相同的任务就成了问题,这就涉及到线程的协调问题。

在 Linux 中,有一种称为任务窃取的概念。比如,如果小鱼的工作效率非常高,而小李的工作效率较低,我们可以将原本分配给小李的任务,转交给小鱼去做。这就是任务窃取的概念。这个问题非常复杂,涉及到的内容很深。任务是否需要锁,取决于你使用的数据结构类型,例如你是否使用了阻塞队列等来实现任务队列,这些策略都相当复杂。

然后我们来看看如何在程序中实现线程池。事实上,大多数程序都会有一个基本的线程池实现。

  • Spring 中,我们可以利用 ThreadPoolTaskExecutor 配合 @Async 注解来实现线程池(不太建议)。 ps.虽然 Spring 框架提供了线程池的实现,但并不特别推荐使用。因为 Spring 毕竟是一个框架,它进行了一定程度的封装,可能隐藏了一些细节。更推荐大家直接使用 Java 并发包中的线程池,这也是🐟之前公司常用的做法。请注意,这并不是绝对不使用 Spring 的线程池,只是🐟个人的建议,对其使用有一定的保留意见。

  • Java 中,可以使用JUC并发编程包中的 ThreadPoolExecutor,来实现非常灵活地自定义线程池。 ps.建议学完 SpringBoot 并能够实现一个项目,以及学完 Redis 之后,再系统学习 Java 并发编程(JUC)。这样可以避免过早的压力和困扰,在具备一定实践基础的情况下,更好地理解并发编程的概念和应用。

回到后端,在config目录下创建ThreadPoolExecutorConfig.java(线程池配置类)。

生成一个ThreadPoolExecutorConfig的实例。

@Configuration
public class ThreadPoolExecutorConfig {
    @Bean
    public ThreadPoolExecutor threadPoolExecutor() {
        ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor();
        return threadPoolExecutor;
    }
}

把光标放到下图所在位置,然后按[Ctrl+P]显示当前方法的参数,看一下线程池的参数。

按下快捷键后,显示一个提示框,列出了此方法需要的所有参数类型和名称; 线程池的参数有这么多,我们来分别解释一下。

4.4 线程池的参数

public ThreadPoolExecutor(int corePoolSize,
                          int maximumPoolSize,
                          long keepAliveTime,
                          TimeUnit unit,
                          BlockingQueue<Runnable> workQueue,
                          ThreadFactory threadFactory,
                          RejectedExecutionHandler handler) {
AI 能力的瓶颈

我们要做线程池,肯定是为了解决问题。在刚刚提出的问题中,任务队列的最大容量应设置为多少?换句话说,我们允许有多少个线程同时执行我们的 BI 任务?这个问题如何确定呢?我们如何确定线程池参数?是否需要结合业务场景?有位同学给出的答案很到位,根据需求进行测试。线程池参数在初次设定时并不可能一次就能百分之百精准,要结合实际测试情况,系统资源和实际业务场景来调整。

假设回到我们的业务场景。为什么我们需要使用线程?为什么需要异步?是不是因为我们的 AI 服务处理速度很慢,AI 服务的处理能力有限,所以我们需要配合 AI 服务,避免 AI 服务器崩溃。如果 AI 服务的能力强大,我们是否就可以提高并发度,让更多的线程同时去生成 AI。但如果 AI 服务性能较差,只允许你每三秒钟只能访问一次,只允许一个线程同时工作。那么线程池的参数就需要调小,只能让一个线程按顺序执行任务。

我们需要结合实际场景,考虑系统最脆弱的环节,或者找出系统的瓶颈在哪里。比如 AI 生成能力的并发是一秒两次,或者并发仅允许四个线程同时执行。也就是说,你不能同时生成五个任务,需要等待前四个任务完成后才能进行,那么我们线程池中的工作者数量最多只需要四个。如果 AI 服务允许 20 个线程或 20 个任务排队,那么我们的队列长度是不是也有了。

假设最多允许四个线程同时执行,最多允许20个任务排队。我们的系统同时向 AI 提交了八个任务,那么有四个任务会被执行,剩下的四个任务就会进入队列等待。如果你同时提交了 50 个任务,那么有四个任务会被执行,20 个任务排队,剩下的 26 个任务就会被丢弃。因此,我们的线程池参数需要根据这些条件来设定。

线程池参数

接下来给大家讲一下这些线程池参数都是干什么的。

ps.一个非常经典的面试题。让你讲线程有什么参数、你平时怎么去设置线程池参数。

  • 第一个参数 corePoolSize (核心线程数)。这些线程就好比是公司的正式员工,他们在正常情况下都是随时待命处理任务的。如何去设定这个参数呢?比如,如果我们的 AI 服务只允许四个任务同时进行,那么我们的核心线程数应该就被设置为四。

  • 第二个参数 maximumPoolSize (最大线程数)。在极限情况下我们的系统或线程池能有多少个线程在工作。就算任务再多,你最多也只能雇佣这么多的人,因为你需要考虑成本和资源的问题。假设 AI 服务最多只允许四个任务同时执行,那么最大线程数应当设置为四。

  • 第三个参数 keepAliveTime (空闲线程存活时间)。这个参数决定了当任务少的时候,临时雇佣的线程会等待多久才会被剔除。这个参数的设定是为了释放无用的线程资源。你可以理解为,多久之后会“解雇”没有任务做的临时工。

  • 第四个参数 TimeUnit (空闲线程存活时间的单位)。将keepAliveTimeTimeUnit 组合在一起,就能指定一个具体的时间,比如说分钟、秒等等。

  • 第五个参数 workQueue (工作队列),也就是任务队列。这个队列存储所有等待执行的任务。也可以叫它阻塞队列,因为线程需要按顺序从队列中取出任务来执行。这个队列的长度一定要设定,因为无限长度的队列会消耗大量的系统资源。

  • 第六个参数 threadFactory (线程工厂)。它负责控制每个线程的生成,就像一个管理员,负责招聘、管理员工,比如设定员工的名字、工资,或者其他属性。

  • 第七个参数 RejectedExecutionHandler (拒绝策略)。当任务队列已满的时候,我们应该怎么处理新来的任务?是抛出异常,还是使用其他策略?比如说,我们可以设定任务的优先级,会员的任务优先级更高。如果你的公司或者产品中有会员业务,或者有一些重要的业务需要保证不被打扰,你可以考虑定义两个线程池或者两个任务队列,一个用于处理VIP任务,一个用于处理普通任务,保证他们不互相干扰,也就是资源隔离策略。(可以写到简历上的点)

这就是线程池的各个参数的作用,理解了这些,你就可以更好地根据你的业务需求来设定和优化你的线程池了。

线程池参数总结

**怎么确定线程池参数呢? **结合实际情况(实际业务场景和系统资源)来测试调整,不断优化。

回归到我们的业务,要考虑系统最脆弱的环节(系统的瓶颈)在哪里?

现有条件:比如 AI 生成能力的并发是只允许 4 个任务同时去执行,AI 能力允许 20 个任务排队。

  • corePoolSize (核心线程数 => 正式员工数):正常情况下,我们的系统可以同时工作的线程数(随时就绪的状态)

  • maximumPoolSize (最大线程数 => 哪怕任务再多,你也最多招这些人):极限情况下,线程池最多可以拥有多少个线程?

  • keepAliveTime (空闲线程存活时间):非核心线程在没有任务的情况下,过多久要删除(理解为开除临时工),从而释放无用的线程资源。

  • TimeUnit unit (空闲线程存活时间的单位):分钟、秒

  • workQueue (工作队列):用于存放给线程执行的任务,存在一个队列的长度(一定要设置,不要说队列长度无限,因为也会占用资源)

  • threadFactory (线程工厂):控制每个线程的生成、线程的属性(比如线程名)

  • RejectedExecutionHandler (拒绝策略):任务队列满的时候,我们采取什么措施,比如抛异常、不抛异常、自定义策略

**资源隔离策略: **比如重要的任务(VIP 任务)一个队列,普通任务一个队列,保证这两个队列互不干扰。

线程池的工作机制

刚开始,没有任何的线程和任务:

当有新任务进来,发现当前员工数量还未达到设定的正式员工数(corePoolSize = 2),则会直接增聘一名新员工来处理这个任务:

又来一个新任务,发现当前员工数量还未达到设定的正式员工数(corePoolSize = 2),则会再次增聘一名新员工来处理这个任务:

又来了一个新任务,但是正式员工数已经达到上限(当前线程数 = corePoolSize = 2),这个新任务将被放到等待队列中(最大长度 workQueue.size 是 2) ,而不是立即增聘新员工:

又来了一个新任务,但是我们的任务队列已经满了(当前线程数 > corePoolSize = 2,已有任务数 = 最大长度 workQueue.size = 2),我们将增设新线程(最大线程数 maximumPoolSize = 4)来处理任务,而不是选择丢弃这个任务:

当达到七个任务时,由于我们的任务队列已经满了、临时工也招满了(当前线程数 = maximumPoolSize = 4,已有任务数 = 最大长度 workQueue.size = 2),此时我们会采用 RejectedExecutionHandler(拒绝策略)来处理多余的任务:

如果当前线程数超过corePoolSize(正式员工数),并且这些额外的线程没有新的任务可执行,那么在 keepAliveTime 时间达到后,这些额外的线程将会被释放。

设置线程池参数

如何设置线程参数呢?

设置线程池参数讲解
  • 首先,corePoolSize 参数非常关键,它代表了在正常情况下需要的线程数量。你可以根据希望的系统运行状况以及同时执行的任务数设定这个值。

  • 接着是 maximumPoolSize,这个参数的设定应当与我们的下游系统的瓶颈相关联。比如,如果我们的 AI 系统一次只允许两个任务同时执行,那么 maximumPoolSize 就应设为两个,这就是其上限,不宜过大。所以,这个参数值的设定就应当对应于极限情况。 让我们回到我们的业务场景,我们的 AI 系统最多允许 4 个任务同时执行,那么我们应如何设定这些参数呢?对于核心线程数(corePoolSize),你可以设定为四,这是在正常运行情况下的需求。然后,maximumPoolSize 就应设定为极限条件,也就是小于等于 4。

  • 至于 keepAliveTime 空闲存活时间,并不需要过于纠结,这个问题相对较小。你可以设定为几秒钟,虽然这可能稍微有点短。你可能需要根据任务以及人员的变动频率进行设定,但无需过于纠结,通常设定为秒级或分钟级别就可以了。

  • 再看 workQueue 工作队列的长度,建议你结合系统的瓶颈进行设定。在我们的场景中,可以设定为 20。如果下游系统最多允许一小时的任务排队,那么你这边就可以设置 20 个任务排队,而核心线程数则设定为 4。

  • threadFactory 线程工厂这里就不细说了,应根据具体情况设定。至于 RejectedExecutionHandler 拒绝策略,我们可以直接选择丢弃、抛出异常,然后交由数据库处理,或者标记任务状态为已拒绝,表示任务已满,无法再接受新的任务。 大体上,这就是我们设定这些参数的思路。


🪔 小知识:

线程池的设计主要分为 IO 密集型计算密集型。 在面试中,如果面试官问你关于线程池的设置,首先你需要明确,设置的依据应该是具体的业务场景。

  1. 通常,我们可以将任务分为 IO 密集型和CPU密集型,也称为计算密集型。 对于计算密集型的任务,它会大量消耗 CPU 资源进行计算,例如音视频处理、图像处理、程序计算和数学计算等。要最大程度上利用 CPU,避免多个线程间的冲突,一般将核心线程数设置为 CPU 的核数加一。这个“加一”可以理解为预留一个额外的线程,或者说一个备用线程,来处理其他任务。这样做可以充分利用每个 CPU 核心,减少线程间的频繁切换,降低开销。在这种情况下,对 maximumPoolSize 的设定没有严格的规则,一般可以设为核心线程数的两倍或三倍。

  2. 而对于 IO 密集型的任务,它主要消耗的是带宽或内存硬盘的读写资源,对CPU的利用率不高。比如说,查询数据库或等待网络消息传输,可能需要花费几秒钟,而在这期间 CPU 实际上是空闲的。在这种情况下,可以适当增大 corePoolSize 的值,因为 CPU 本来就是空闲的。比如说,如果数据库能同时支持 20 个线程查询,那么 corePoolSize 就可以设置得相对较大,以提高查询效率。虽然有一些经验值,比如 2N+1,不太推崇这种经验值,建议根据 IO 的能力来设定。

总结:

一般情况下,任务分为 IO 密集型和计算密集型两种。 计算密集型: CPU 比如音视频处理、图像处理、数学计算等,一般是设置 corePoolSizeCPU 的核数 + 1(空余线程),可以让每个线程都能利用好 CPU 的每个核,而且线程之间不用频繁切换(减少打架、减少开销) IO 密集型:吃带宽/内存/硬盘的读写资源corePoolSize 可以设置大一点,一般经验值是 2n 左右,但是建议以 IO 的能力为主。

对于导入百万级 Excel 数据到数据库这种情况,我们应该如何归类呢? 其实答案很简单,我们只需要考虑这个过程主要消耗哪种资源。想一下,当你将百万条数据导入到数据库时,会进行大量的计算操作吗?可能有一些,但数量极少。大部分的时间花费在哪里呢?主要是在数据写入数据库的过程,这涉及到网络传输和磁盘 I/O 操作。实际上,数据库操作的本质就是磁盘 I/O。所以,这种情况下,我们可以认为是IO密集型的。

设置线程池参数总结

现有条件:比如AI生成能力的并发是只允许 4 个任务同时去执行,AI 能力允许 20 个任务排队。

  • corePoolSize(核心线程数 => 正式员工数):正常情况下,可以设置为 2 - 4

  • maximumPoolSize:设置为极限情况,设置为 <= 4

  • keepAliveTime(空闲线程存活时间):一般设置为秒级或者分钟级

  • TimeUnit unit(空闲线程存活时间的单位):分钟、秒

  • workQueue(工作队列):结合实际请况去设置,可以设置为 20

  • threadFactory(线程工厂):控制每个线程的生成、线程的属性(比如线程名)

  • RejectedExecutionHandler(拒绝策略):抛异常,标记数据库的任务状态为 “任务满了已拒绝”

5. 线程池的开发

回到后端,继续去设置线程池的参数:

  • corePoolSize (核心线程数) 为 2 — 2 个正式员工;

  • maximumPoolSize (最大线程数) 为 4 — 最多允许 4 个任务同时执行;

  • keepAliveTime (空闲线程存活时间) 为 100 — 多余的临时工会等待 100 后,被开除;

  • TimeUnit (空闲线程存活时间的单位) 为TimeUnit.SECONDS— 与 keepAliveTime 组合使用,指多余临时工会等待 100 秒后,被开除;

  • workQueue`` (工作队列) 为 new ArrayBlockingQueue<>(10000)` — 使用数组阻塞队列,并指定长度为 4,最多等待 4 个任务;

  • threadFactory (线程工厂) 创建一个自己的线程工厂;

  • RejectedExecutionHandler (拒绝策略) 不写,默认拒绝。

@Configuration
public class ThreadPoolExecutorConfig {
    @Bean
    public ThreadPoolExecutor threadPoolExecutor() {
        // 创建一个线程工厂
        ThreadFactory threadFactory = new ThreadFactory() {
            // 初始化线程数为 1
            private int count = 1;

            @Override
            // 每当线程池需要创建新线程时,就会调用newThread方法
            // @NotNull Runnable r 表示方法参数 r 应该永远不为null,
            // 如果这个方法被调用的时候传递了一个null参数,就会报错
            public Thread newThread(@NotNull Runnable r) {
                // 创建一个新的线程
                Thread thread = new Thread(r);
                // 给新线程设置一个名称,名称中包含线程数的当前值
                thread.setName("线程" + count);
                // 线程数递增
                count++;
                // 返回新创建的线程
                return thread;
            }
        };
        // 创建一个新的线程池,线程池核心大小为2,最大线程数为4,
        // 非核心线程空闲时间为100秒,任务队列为阻塞队列,长度为4,使用自定义的线程工厂创建线程
        ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(2, 4, 100, TimeUnit.SECONDS,
                new ArrayBlockingQueue<>(4), threadFactory);
        // 返回创建的线程池
        return threadPoolExecutor;
    }
}

五、前后端异步化改造

5.1 实现工作流程

  1. chart 表新增任务状态字段(比如排队中、执行中、已完成、失败),任务执行信息字段(用于记录任务执行中、或者失败的一些信息)

  2. 用户点击智能分析页的提交按钮时,先把图表立刻保存到数据库中,然后提交任务

  3. 任务:先修改图表任务状态为 “执行中”。等执行成功后,修改为 “已完成”、保存执行结果;执行失败后,状态修改为 “失败”,记录任务失败信息。

  4. 用户可以在图表管理页面查看所有图表(已生成的、生成中的、生成失败)的信息和状态 → (优化点)

  5. 用户可以修改生成失败的图表信息,点击重新生成 → (优化点)

5.2 库表设计

来实现工作流程的第一步,在chart表新增两个字段:

-- 任务状态字段(排队中wait、执行中running、已完成succeed、失败failed)
status       varchar(128) not null default 'wait' comment 'wait,running,succeed,failed',
-- 任务执行信息字段
execMessage  text   null comment '执行信息',

create_table.sql中新增字段,方便后面查看或修改。

现在往chart表去新增字段:点击右侧菜单栏上的Database,选中chart,点击modify

genResult字段后添加字段:点击genResult字段→。

直接用新增字段的 SQL 语句替换下面 SQL Script add 后面的部分。

alter table chart
 add status  varchar(128) not null default 'wait' comment 'wait,running,succeed,failed',
  add execMessage  text   null comment '执行信息';

双击chart表,可以查看到新增的字段。

现在所有图表默认状态都是wait(排队中),全改成succeed(已完成)。

然后去补充实体类的字段,找到Chart

5.3 任务执行逻辑

来实现工作流程的第二步,用户点击智能分析页的提交按钮时,先把图表立刻保存到数据库中,然后提交任务。

先找到 QueueController ,指定只能开发、本地环境生效;

正式上线前要把测试去掉,不要把测试暴露出去。

找到业务流程改造一下,来到ChartControllergenChartByAi接口。

  1. 之前咱们的业务流程是校验 → 限流 → 构造用户输入、调用 AI;

  2. 现在可以把调用 AI 变成提交任务。

  3. 来实现工作流程的第三步,任务:先修改图表任务状态为 “执行中”。

  4. 等执行成功后,修改为 “已完成”、保存执行结果;执行失败后,状态修改为 “失败”,记录任务失败信息。

ps.任务执行逻辑:先修改任务状态为执行中,减少重复执行的风险、同时让用户知道执行状态。

将智能分析拆分为同步和异步操作。由于异步操作没有返回值信息,我们无法解析并返回图表信息,这导致可视化图表和分析结果无法被展示。因此,我们保留之前的智能分析结果;否则,大家在拉取这一期的代码时可能会遇到问题。

/**
 * 智能分析(异步)
 *
 * @param multipartFile
 * @param genChartByAiRequest
 * @param request
 * @return
 */
@PostMapping("/gen/async")
public BaseResponse<BiResponse> genChartByAiAsync(@RequestPart("file") MultipartFile multipartFile,
                                                  GenChartByAiRequest genChartByAiRequest, HttpServletRequest request) {
    String name = genChartByAiRequest.getName();
    String goal = genChartByAiRequest.getGoal();
    String chartType = genChartByAiRequest.getChartType();

    // 校验
    ThrowUtils.throwIf(StringUtils.isBlank(goal), ErrorCode.PARAMS_ERROR, "目标为空");
    ThrowUtils.throwIf(StringUtils.isNotBlank(name) && name.length() > 100, ErrorCode.PARAMS_ERROR, "名称过长");

    // 校验文件
    long size = multipartFile.getSize();
    String originalFilename = multipartFile.getOriginalFilename();

    // 校验文件大小
    final long ONE_MB = 1024 * 1024L;
    ThrowUtils.throwIf(size > ONE_MB, ErrorCode.PARAMS_ERROR, "文件超过 1M");

    // 校验文件大小缀 aaa.png
    String suffix = FileUtil.getSuffix(originalFilename);
    final List<String> validFileSuffixList = Arrays.asList("xlsx", "xls");
    ThrowUtils.throwIf(!validFileSuffixList.contains(suffix), ErrorCode.PARAMS_ERROR, "文件后缀非法");

    User loginUser = userService.getLoginUser(request);

    // 限流判断,每个用户一个限流器
    redisLimiterManager.doRateLimit("genChartByAi_" + loginUser.getId());

    // 指定一个模型id(把id写死,也可以定义成一个常量)
    long biModelId = 1659171950288818178L;
    // 分析需求:
    // 分析网站用户的增长情况
    // 原始数据:
    // 日期,用户数
    // 1号,10
    // 2号,20
    // 3号,30

    // 构造用户输入
    StringBuilder userInput = new StringBuilder();
    userInput.append("分析需求:").append("\n");

    // 拼接分析目标
    String userGoal = goal;
    if (StringUtils.isNotBlank(chartType)) {
        userGoal += ",请使用" + chartType;
    }
    userInput.append(userGoal).append("\n");
    userInput.append("原始数据:").append("\n");
    // 压缩后的数据
    String csvData = ExcelUtils.excelToCsv(multipartFile);
    userInput.append(csvData).append("\n");

    // 先把图表保存到数据库中
    Chart chart = new Chart();
    chart.setName(name);
    chart.setGoal(goal);
    chart.setChartData(csvData);
    chart.setChartType(chartType);
    // 插入数据库时,还没生成结束,把生成结果都去掉
//        chart.setGenChart(genChart);
//        chart.setGenResult(genResult);
    // 设置任务状态为排队中
    chart.setStatus("wait");
    chart.setUserId(loginUser.getId());
    boolean saveResult = chartService.save(chart);
    ThrowUtils.throwIf(!saveResult, ErrorCode.SYSTEM_ERROR, "图表保存失败");

    // 在最终的返回结果前提交一个任务
    // todo 建议处理任务队列满了后,抛异常的情况(因为提交任务报错了,前端会返回异常)
    CompletableFuture.runAsync(() -> {
        // 先修改图表任务状态为 “执行中”。等执行成功后,修改为 “已完成”、保存执行结果;执行失败后,状态修改为 “失败”,记录任务失败信息。(为了防止同一个任务被多次执行)
        Chart updateChart = new Chart();
        updateChart.setId(chart.getId());
        // 把任务状态改为执行中
        updateChart.setStatus("running");
        boolean b = chartService.updateById(updateChart);
        // 如果提交失败(一般情况下,更新失败可能意味着你的数据库出问题了)
        if (!b) {
            handleChartUpdateError(chart.getId(), "更新图表执行中状态失败");
            return;
        }

        // 调用 AI
        String result = aiManager.doChat(biModelId, userInput.toString());
        String[] splits = result.split("【【【【【");
        if (splits.length < 3) {
            handleChartUpdateError(chart.getId(), "AI 生成错误");
            return;
        }
        String genChart = splits[1].trim();
        String genResult = splits[2].trim();
        // 调用AI得到结果之后,再更新一次
        Chart updateChartResult = new Chart();
        updateChartResult.setId(chart.getId());
        updateChartResult.setGenChart(genChart);
        updateChartResult.setGenResult(genResult);
        updateChartResult.setStatus("succeed");
        boolean updateResult = chartService.updateById(updateChartResult);
        if (!updateResult) {
            handleChartUpdateError(chart.getId(), "更新图表成功状态失败");
        }
    },threadPoolExecutor);

    BiResponse biResponse = new BiResponse();
//        biResponse.setGenChart(genChart);
//        biResponse.setGenResult(genResult);
    biResponse.setChartId(chart.getId());
    return ResultUtils.success(biResponse);
}
// 上面的接口很多用到异常,直接定义一个工具类
private void handleChartUpdateError(long chartId, String execMessage) {
    Chart updateChartResult = new Chart();
    updateChartResult.setId(chartId);
    updateChartResult.setStatus("failed");
    updateChartResult.setExecMessage(execMessage);
    boolean updateResult = chartService.updateById(updateChartResult);
    if (!updateResult) {
        log.error("更新图表失败状态失败" + chartId + "," + execMessage);
    }
}

如果为了给面试官看,建议把这两种处理方式都保留,你可以向面试官解释为什么要引入异步流程,异步的优点,以及如何实现异步。只要谈到线程池,有超过 50% 的可能性面试官会问你关于线程池的核心参数。这时你可以把本期中介绍的内容全都讲出来,相信面试官会认为你的思路非常清晰。

  1. 优化思路 肯定有些同学会倾向于同步方式,因为他们可以随时查看结果,即使可能需要等待十几秒或者 20 秒。然而,另一些同学可能会觉得如果需要等待一分钟或者五分钟的话,异步方式可能会更合适。实际上,你也可以选择实时更新,比如每隔几秒刷新一下页面,自动获取新结果。批量异步也是一种可行的方式。

另外,还有一种策略。你可以根据系统当前的负载动态地调整用户查询的处理方式。比如,如果系统当前状态良好,就可以选择同步返回结果。而如果用户提交请求后发现系统非常繁忙,预计需要等待很长时间,那么就可以选择异步处理方式。这种思考方式在实际的企业项目开发中也是很常见的。

除了刚刚提到的一些点,我们还可以使用定时任务来处理失败的图表,添加重试机制。此外,我们也可以更精确地预见AI生成错误,并在后端进行异常处理,如提取正确的字符串。例如,AI 说一些多余的话,我们就需要提取出正确的信息。同时,如果任务没有提交,我们可以使用定时任务将其提取出来。我们还可以为任务增加一个超时时间。如果超时,任务将自动标记为失败,这就是超时控制,这一点非常重要。对于 AI 生成的脏数据,会导致最后出现错误,因此前端也需要进行异常处理,不能仅仅依赖于后端。

刚刚提到了一点,那就是在系统压力大的时候,使用异步,而在系统压力小的时候,使用同步,这就是反向压力的概念。

进一步扩展一下,关于我们的线程池,现在的核心参数不是设定为二嘛。实际上,如果 AI 最多允许四个任务同时执行,我们是否可以提前确认 AI 当前的业务是否繁忙,即我们调用的第三方 API 是否还有多余的资源给我们使用。如果他表示资源已经耗尽,我们为了保证系统的稳定性,是否可以将核心线程数调小一些。反之,如果我们询问 AI 第三方并发现它的状态是空闲,我们是否可以将核心线程数增加,以此来提高系统性能。这种通过下游服务来调整你的业务以及核心线程池参数,进而改变你的系统策略的方式就是反向压力。

例如,你发现当前 AI 服务的任务队列中没有任何人提交任务,那么你是否可以提高使用率。这其实是一个很好的点,如果你能在简历上写到反向压力,将会是一个很大的加分项。反向压力其实是我们在做大数据系统中,特别是在做实时数据流系统时经常会用到的一个术语。我们是不是可以在任务执行成功或失败后,给用户发送消息通知。比如说,在图表页面增加一个刷新或者定时自动刷新的按钮,以保证用户能够获取到图表的最新状态。这就是前端轮询的技术。还有就是在任务执行过程中,我们可以向用户发送消息通知,虽然这可能比较复杂。

六、优化点

  1. guava Retrying 重试机制

  2. 提前考虑到 AI 生成错误的情况,在后端进行异常处理(比如 AI 说了多余的话,提取正确的字符串)

  3. 如果说任务根本没提交到队列中(或者队列满了),是不是可以用定时任务把失败状态的图表放到队列中(补偿)

  4. 建议给任务的执行增加一个超时时间,超时自动标记为失败(超时控制)

  5. 反向压力:https://zhuanlan.zhihu.com/p/404993753,通过调用的服务状态来选择当前系统的策略(比如根据 AI 服务的当前任务队列数来控制咱们系统的核心线程数),从而最大化利用系统资源。

  6. 我的图表页面增加一个刷新、定时自动刷新的按钮,保证获取到图表的最新状态(前端轮询)

  7. 任务执行成功或失败,给用户发送实时消息通知(实时:websocket、server side event

文章持续跟新,可以微信搜一搜公众号  rain雨雨编程 ],第一时间阅读,涉及数据分析,机器学习,Java编程,爬虫,实战项目等。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值