你好呀,我是歪歪。
前两天在一个技术群里看到有人抛出一张图片,提出了这样的一个问题:
请教一下,线程池可以做到根据任务的类型,来指定特定线程执行吗?
了解了一下背景,是批量任务触发,从订单表中查询出“处理中”状态的订单,订单可能属于不同的通道,所以需要调用不同通道的接口。
现在的方案是把订单查出来之后,往线程池里面扔,在异步任务里面判断当前订单是属于哪个通道,就调用哪个通道的查询接口:
这是常规做法,看起来没有毛病。
但是现在提问的这个哥们遇到了一个问题:有一个通道的查询接口特别慢,会占着线程池里面的线程资源,影响了其他两个通道的订单查询。
举个极端的例子,比如你的线程池核心线程数就三个。
假设一共有 5 笔数据,前 3 笔是通道 A 的,后面两笔分别是通道 B 和通道 C 的。
结果现在通道 A 出问题了:
直接把你的核心线程都占满了,剩下的两笔对应 B 和 C 通道的数据就在队列里面排着队,等着。
你说这个合不合理?
非常不合理,对不对?
但是这个问题确实也是很常规,常规到它甚至没有资格作为一个场景面试题出现在面试环节中。
问题在于不同的通道在共用同一个线程池,从而导致的相互影响。所以解决思路主要就是怎么把资源隔离开来。
一般来说,大家能想到的第一个解决方案就是用 MQ 嘛:
利用不同的队列,天然就把不同通道的订单给区分开了,在监听侧各自处理各自通道的数据,这样就达到了资源隔离的效果。
这个方案应该是很常规了,但是这个常规方案立马就被毙了。
因为:
需要注意的是,他这里说的“系统内部”是指同一个微服务,也就是不允许一个微服务使用 MQ 来做“自产自销”。
我个人认为是“自产自销”没有任何问题的,在这个场景下我完全可以借助它的特性帮我做数据分隔、异步处理数据,而且代码简单,逻辑清晰。
但是既然是公司规定,可能有一些因地制宜的考虑,我们也不好去做过多的批判。
反正就是 MQ 可以解决这个问题,但是老板并不采取这个方案。
没关系,小脑壳一转,大多数同学就能立马就掏出了另外一个解决方案。
你前面出问题的原因不是因为不同的通道在共用同一个线程池吗?
那很简单,每个通道各自搞一个线程池。然后和 MQ 的方案类似,根据不同的通道扔到对应的线程池中去,自己玩自己的:
这样即使某个通道出问题了,由于在线程池层面做了线程资源隔离,所以也不影响另外的通道进行数据处理。
这个就是线程池隔离的方案。
其实关于这个方案,我当时还想到了另外一种原理一致,实现形式不一样,但是最终被认为是比较 low 的一个回答。
因为他抛出的这个图片,我第一眼理解错了,我以为是按照通道分组,然后用单线程一个个的去调用查询接口,避免并发调用:
所以我提到了一个叫做 KeyAffinityExecutor 的魔改线程池:
这个线程池,它有一个比较厉害的特性,可以确保投递进来的任务按某个维度划分出任务,然后按照任务提交的顺序依次执行。这个线程池可以通过并行处理(多个线程)来提高吞吐量、又能保证一定范围内的任务按照严格的先后顺序来运行。
对比到当前的这个问题中。
可以按照通道维度进行任务划分,然后把任务往线程池扔的时候,就会被分配到不同的线程中去。
关于这个线程池,我之前写了这篇文章,有兴趣的可以去了解一下,不赘述了:《看到一个魔改线程池,面试素材加一!》
本质上还是线程池隔离的思路,只不过一个是分多个不同的业务线程池,线程池和业务绑定。一个是一个大线程池里面包了多个线程池,线程池可以通过分配规则的方式指定。
同一个思路的不同实现方案而已。
但是为什么我说我提出的这个魔改线程池的方案 low 呢?
因为人家只是需要分组的特性,而不需要“按照任务提交的顺序依次执行”的特性。
反而会出现如果一个通道的订单多,只有一个线程来处理,导致性能不够,任务堆积的情况。
但是,话说回来,你也可以魔改一下这个魔改线程池,把里面的小线程池的核心线程数搞多点,就行了。
总之,都是线程池隔离的思路。
好了,这个方案我又讲完了,谁赞成,谁反对?
看着没有任何问题,但是实际情况是:
卧槽,50 多个?
确实,如果是只有三个通道,或者多说点,五个通道嘛,我觉得用上面这个方案做线程池维度的隔离,都是可以接受的。
但实际情况是 50 多个通道,一想起项目里面有 50 多个线程池在跑,这个就有点难受了。
好了,现在 MQ 和线程池隔离的方案都被否决了,接下来的思路是什么?
没有思路没有关系,我们再来读读题:批量任务触发,从订单表中查询出“处理中”状态的订单,订单可能属于不同的通道,所以需要调用不同通道的接口。但是某个通道慢,导致影响了其他通道订单的查询。
问怎么办?
某个通道慢,该怎么办?
有的通道慢,有的通道快,我该怎么办?
等等...
前面我们按照通道维度分线程池被否了的原因是通道太多了。
但是其实针对响应快的通道,我们完全不需要做线程池隔离,他们完全可以使用同一个线程池嘛,反正都是唰唰唰的就查回来了。
所以,我们只需要搞两个线程池,一个处理通道响应快的,比如把接口调用的超时时间设置为 1s。另外一个处理通道响应慢的,超时时间直接拉满到 30s,自己慢慢玩去:
至于怎么去判断通道到底是快是慢呢?
这里又可以大致分为三个不同的方案了。
第一个方案就是已知某几个通道是慢的,那就代码里面写硬编码都行。虽然不优雅,但是这确实也是一个在实际生产中常常被提及的一个快速解决问题的方案。
第二个方案就是配置化,可以做个配置表,来配置通道的快慢标识。程序里面根据当前订单的通道,来表里面获取当前通道的快慢标识,从而把订单扔到不同的线程池中去。
在这个方案中,用配置表代替了硬编码,但是还是需要人工基于线下沟通或者数据监控的方式去调整通道的快慢标识。
你知道的,线上程序这玩意,一旦涉及到人工介入,就遭老罪了,很不爽。
所以这个方案,有一点优雅,但是不多。
第三个方案就是配置化加自动化这一套组合拳。
配置化还是指前面提到的配置表。
但是这个表中通道的快慢标识,就不需要人工来介入了,完全由程序自己收集信息,进行判断。
比如,我们可以假设一开始的时候所有的通道都能快速响应。但是突然某个通道开始“扯拐”,响应时长出现波动,1s 内没有响应成功,那么这个任务就会超时,就可以把这个任务扔到慢通道线程池中去处理,同时对该通道的失败次数进行记录。
当某个时间段失败次数超过某个阈值之后,则在配置表中标识该通道为慢通道。
这样当下一个属于该通道的订单过来时,就会直接被扔到慢通道线程池中去。
这样,就由程序完成了通道由“快标识”到“慢标识”的处理。
那么当这个通道的问题解决之后,它又变成一个快通道时,怎么去修改它在配置表中的标识呢?
很简单,同样的逻辑,在慢通道线程池处理的过程中,记录某个时间段某个通道的平均响应时长,如果低于指定阈值,比如 1s,则在配置表中重新标识该通道为快通道。
整个过程,不管标识怎么变化,都是基于程序自动的数据统计来的,完全不需要人工介入。
甚至你还可以加一个逻辑:当配置表中的通道都是快通道时,两个线程池都可以用起来,实现资源利用的最大化。
优雅,非常优雅。
至于怎么去统计线程池中的任务“某个时间段失败次数”和“某个时间段某个通道的平均响应时长”这样的统计信息,在线程池里面,专门留了这两个方法给你去在任务执行之前和之后搞事情,完全可以基于这两个方法做一些统计工作:
java.util.concurrent.ThreadPoolExecutor#runWorker
就目前提出的方案来说,把通道分为快慢通道,然后划分为线程池是最满足提问者的需求的。
最后应该就拿着这个方案去汇报了。
汇报题目我都帮忙想好了:
《基于通道关键指标收集分析的全自适应、高敏感度、资源利用最大化的调度方案汇报》
剩下的,就看你怎么去吹了。
除去前面的方案外,其实我还想到一个“比较奇葩”的解决方案。
因为他的业务场景是定时任务嘛,所以我想起了之前写过的这篇文章:《又被夺命连环问了!从一道关于定时任务的面试题说起。》
既然能区分出来通道的快慢,那么在定时任务启动之后,我们就可以把“快慢标识”传递到服务器中去,服务器就能把订单分为快慢两大类,然后一台机器处理通道慢的订单数据,一台处理快的:
这样我就能从服务器这个物理层面就把数据区分开了。
所以只要能标识开区分数据,那么理论上不仅可以在代码中区分,也可以往上抽离一层,通过服务器维度区分。
但是好处是什么呢?
呃...
看起来确实没什么好处,只是这个方案比较奇葩,一般没人想到,我就是顺便提一嘴,主要是显摆一下。
不显摆一下,装装逼,总感觉不得劲。
基于提问者的这个问题,歪师傅也想起了两个类似的场景。
一个是我参与开发过的一个对客发送短信的消息系统,简化一下整个流程大概是这样的:
上面这个图片会出现什么问题呢?
就是消息堆积。
当某个业务系统调用短信发送接口,批量发送消息的时候,比如发送营销活动时,大量的消息就在队列里面堆着,慢慢消费。
其实堆积也没有关系,毕竟营销活动的实时性要求不是那么高,不要求立马发送到客户手机上去。
但是,如果在消息堆积起来之后,突然有用户申请了验证码短信呢?
需要把前面堆积的消费完成后,才会发送验证码短信,这个已经来不及了,甚至验证码已经过期很久了你才发过去。
客户肯定会骂娘,因为获取不到验证码,他就不能进行后续业务。
如果大量客户因为收不到验证码不能进行后续业务,引起群体性的客诉,甚至用户恐慌,这个对于企业来说是一个非常严重的事件。
怎么办呢?
解决方案非常简单,再搞一个“高速”队列出来:
验证码消息直接扔到“高速”队列中去,这个队列专门用来处理验证码、动账通知这一类时效性要求极高的消息,从业务场景上分析,也不会出现消息堆积。
不是特别复杂的方案,大道至简,问题得到了解决。
类比到前面说的“快慢”线程池,其实是一样的思想,都是从资源上进行隔离。
只不过我说的这个场景更加简单,不需要去收集信息进行动态判断。业务流程上天然的就能区分出来,哪些消息实时性比较高,应该走“高速”队列;哪些消息慢慢 发没关系,可以应该走“常规”队列。
而这个所谓的“高速”和“常规”,只是开发人员给一个普通队列赋予的一个属性而已,站在 MQ 的角度,这两个队列没有任何区别。
另外一个场景是我想起了之前写过的这篇文章:《我试图给你分享一种自适应的负载均衡。》
我们还是先看看前面出现的这个图:
图中的线程池,不管是快的还是慢的,本质上他们处理的请求都是一样的,即拿着订单去对应的通道查询订单结果。
那我们是不是可以把这两个线程池抽象一下,理解为部署了同一个服务的两个不同的服务器,一个服务器的性能好,一个服务器的性能差。
现在有一个请求过来了,理论上这两个服务器都能处理这个请求,所以我们通过某个逻辑选一个服务器出来,把请求发过去。
这个“某个逻辑”不就是我们常说的负载均衡算法吗?
负载均衡算法的算法有很多:
其中这几个都是需要统计服务端的相关数据,基于数据进行分析,最终觉得把当前请求发个哪个服务器:
这个逻辑,和我们前面提到的这句话,其实是一脉相承的,都是信息收集、指标分析、阈值设定:
去统计线程池中的任务“某个时间段失败次数”和“某个时间段某个通道的平均响应时长”这样的统计信息
你想想我们最开始的问题是“一个通道慢了,影响了其他通道的数据,怎么办?”
现在我带着你扯到了“负载均衡策略”。
这两个场景不能说八竿子打不着吧,但是它们确实在一定程度上有相似性,转好几个弯之后,也能联系到一起。
你要是再发散一点,你甚至能想到 Serverless 的弹性场景,通过收集 CPU、Mem 指标、QPS、RT、TCP 连接数等指标,进行综合判断,弹性扩容,也无需人工介入,手动扩容。
所以,朋友,这个事情告诉我们一个什么道理?
向上抽象问题的能力,把看看似不一样的场景抽离成类似的问题模型的能力很重要。
还有,“一个通道慢需要进行资源隔离”这个问题的关键不在于“一个通道”上,虽然可以在通道层面做隔离,但是这样并没有抓住问题的关键。问题的关键在于“通道慢”,所以可以在“快慢”的维度上做隔离,这才是问题的关键。
关键问题,就是要找到问题的关键。
这也是我在这一次群聊的讨论中学习到的东西。
好啦,本文的技术部分就到这里了。
下面这个环节叫做[荒腔走板],技术文章后面我偶尔会记录、分享点生活相关的事情,和技术毫无关系。我知道看起来很突兀,但是我喜欢,因为这是一个普通博主的生活气息。
我们家之前有一个花市,距离只有 3 km,基本上每个月我们都会骑自行车过去逛一圈,采购点鲜切花。由于花市规模比较大,所以整体上物美价廉,量大管够。
自从花市从去年年初的时候统一迁移到稍远一点的地方后,因为交通不便,我们就从来没有去过了,家里也很久没有买鲜花了。
2024 年的第一个周末,终于把车提了。从去年 10 月到现在,也算是等得望穿秋水了。
所以,提车后的第一天,就带着 Max 同学,也就是我老婆,以练车的名义往花市跑了一趟。
新的花市开在高架桥下,一字排开,长长的一条街,分为了好几个区。我们直奔鲜切花区,逛了一小时,花了 50 元钱,买了一束鲜花,一把腊梅,两个花瓶。
还有熟悉的物美价廉,还是熟悉的量大管够。
就是过去的路上有一段小路,虽然我已经是拿了驾照有十年的“老司机”了,但是确实没怎么开过车,手上汗水都开出来了。Max 同学坐在副驾,神情严肃,目光如炬,从头到尾也就一句话:注意注意注意,慢点慢点慢点,刹车刹车刹车...