2024年7月

你好呀,我是歪歪。

最近在使用线程池的时候踩了一个坑,给你分享一下。

在实际业务场景下,涉及到业务代码和不同的微服务,导致问题有点难以定位,但是最终分析出原因之后,发现可以用一个很简单的例子来演示。

所以歪师傅这次先用 Demo 说问题,再说场景,方便吸收。


Demo

老规矩,还是先上个代码:

这个代码的逻辑非常简单,首先我们搞了一个线程池,然后起一个 for 循环往线程池里面仍了 5 个任务,这是核心逻辑。

对于这几个任务,我们的这个自定义线程池处理起来,不能说得心应手吧,至少也是手拿把掐。

其他的 StopWatch 是为了统计运行时间用的。
至于 CountDownLatch,你可以理解为在业务流程中,需要这五个任务都执行完成之后才能往下走,所以我搞了一个 CountDownLatch。

这个代码运行起来是没有任何问题的,我们在日志中搜索“执行完成”,也能搜到 5 个,这个结果也能证明程序是正常结束的:

同时,可以看到运行时间是 4s。

示意图大概是这样的:

然后歪师傅看着这个代码,发现了一个可以优化的地方:

这个地方从数据库捞出来的数据,它们之间是没有依赖关系的,也就是说它们之间也是可以并行执行的。

所以歪师傅把代码改成了这样:

在异步线程里面去处理这部分从数据库中捞出来的数据,并行处理加快响应速度。

对应到图片,大概就是这个意思:

把程序运行起来之后,日志变成了这样:

我们搜索“执行完成”,也能搜到 5 个对应输出。

而且我们就拿“任务2”来说:

当前线程pool-1-thread-3,---【任务2】开始执行---
当前线程pool-1-thread-3,---【任务2】执行完成---
当前线程pool-1-thread-1,【任务2】开始处理数据=1
当前线程pool-1-thread-2,【任务2】开始处理数据=2

从日志输出来看,任务 2 需要处理的两个数据,确实是在不同的异步线程中处理数据,也实现了我的需求。

但是,程序运行直接就是到了 9.9ms:

这个优化这么牛逼的吗?

从 4s 到了 9.9ms?

稍加分析,你会发现这里面是有问题的。

那么问题就来了,到底是啥问题呢?

你也分析分析大概是啥问题,别老是想着直接找答案啊。

问题就是由于转异步了,所以 for 循环里面的任务中的 countDownLatch 很快就减到 0 了。

于是 await 继续执行,所以很快就输出了程序运行时间。

然而实际上子任务还在继续执行,程序并没有真正完成。

9.9ms 只是任务提交到线程池的时间,每个任务的数据处理时间还没算呢:

从日志输出上也可以看出,在输出了 StopWatch 的日志后,各个任务还在处理数据。

这样时间就显得不够真实。

那么我们应该怎么办呢?

很简单嘛,需要子任务真正执行完成后,父任务的 countDownLatch 才能进行 countDown 的动作。

具体实现上就是给子任务再加一个 countDownLatch 栅栏:

我们希望的运行结果应该是这样的:

当前线程pool-1-thread-3,---【任务2】开始执行---
当前线程pool-1-thread-1,【任务2】开始处理数据=1
当前线程pool-1-thread-2,【任务2】开始处理数据=2
当前线程pool-1-thread-3,---【任务2】执行完成---

即子任务全部完成之后,父任务才能算执行完成,这样统计出来的时间才是准确的。

思路清晰,非常完美,再次运行,观察日志我们会发现:

呃,怎么回事,日志怎么不输出了?

是的,就是不输出了。

不输出了,就是踩到这个坑了。

不论你重启多少次,都是这样:日志不输出了,程序就像是卡着了一样。


坑在哪儿

上面这个 Demo 已经是我基于遇到的生产问题,极力简化后的版本了。

现在,这个坑也已经呈现在你眼前了。

我们一起来分析一波。

首先,我问你:真的在线上遇到这种程序“假死”的问题,你会怎么办?

早几年,歪师傅的习惯是抱着代码慢慢啃,试图从代码中找到端倪。

这样确实是可以,但是通常来说效率不高。

现在我的习惯是直接把现场 dump 下来,分析现场。

比如在这个场景下,我们直观上的感受是“卡住了”,那就 dump 一把线程,管它有枣没枣,打一杆子再说:

通过 Dump 文件,可以发现线程池的线程都在 MainTest 的第 30 行上 parking ,处于等待状态:

那么第 30 行是啥玩意?

这行代码在干啥?

countDownLatchSub.await();

是父任务在等待子任务执行结束,运行 finally 代码,把 countDownLatchSub 的计数 countDown 到 0,才会继续执行:

所以现在的现象就是子任务的 countDownLatchSub 把父任务的拦住了。

换句话说就是父任务被拦住是因为子任务的 finally 代码中的 countDownLatchSub.countDown() 方法没有被执行。

好,那么最关键的问题就来了:为什么没有执行?

你先别往下看,闭上眼睛在你的小脑瓜子里面推演一下,琢磨一下:finally 为什么没有执行?

或者再换个更加接近真实的问题:子任务为什么没有执行?

这个点,非常简单,可以说一点就破。

琢磨明白了,这个坑的原理摸摸清楚了。

...

...

...

琢磨明白了吗?你就刷刷往下看?

没明白我再给你一个信息:需要结合线程池的参数和运行原理来分析。

什么?

你说线程池的运行原理你不清楚?

请你取关好吗,你个假粉丝。

...

...

...

好,不管你“恍然大悟”了没有,歪师傅给你讲一下。

让你知道“一点就破”这四个是怎么回事儿。

首先,我们把目光聚焦在线程池这里:

这个线程池核心线程数是 3,但是我们要提交 5 个任务到线程池去。

父任务哐哐哐,就把核心线程数占满了。

接下来子任务也要往这个线程池提交任务怎么办?

当然是进队列等着了。

一进队列,就完犊子。

到这里,我觉得你应该能想明白问题了。

应该给到我一个恍然大悟的表情,并配上“哦哦哦~”这样的内心 OS。

你想想,父任务这个时候干啥?

是不是等在 countDownLatchSub.await() 这里。

而 countDownLatchSub.await() 什么时候能继续执行?

是不是要所有子任务都执行 finally 后?

那么子任务现在在干啥?

是不是都在线程池里面的队列等着被执行呢?

那线程池队列里面的任务什么时候才执行?

是不是等着有空闲线程的时候?

那现在有没有空闲线程?

没有,所有的线程都去执行父任务去了。

那你想想,父任务这个时候干啥?

是不是等在 countDownLatchSub.await() 这里。

...

父任务在等子任务执行。

子任务在等线程池调度。

线程池在等父任务释放线程。

闭环了,相互等待了,家人们。

这,就是坑。

现在把坑的原理摸清楚了,我在给你说一下真实的线上场景踩到这个坑是怎么样的呢?

上游发起请求到微服务 A 的接口 1,该接口需要调用微服务 B 的接口 2。

但是微服务 B 的接口 2,需要从微服务 A 接口 3 获取数据。

然而在微服务 A 内部,全局使用的是同一个自定义线程池。

更巧的是接口 1 和接口 3 内部都使用了这个自定义线程池做异步并行处理,想着是加快响应速度。

整个情况就变成了这样:

  1. 接口 1 收到请求之后,把请求转到自定义线程池中,然后等接口 2 返回。
  2. 接口 2 调用接口 3,并等待返回。
  3. 接口 3 里面把请求转到了自定义线程池中,被放入了队列。
  4. 线程池的线程都被接口 1 给占住了,没有资源去执行队列里面的接口 3 任务。
  5. 相互等待,一直僵持。

我们的 Demo 还是能比较清晰的看到父子任务之间的关系。

但是在这个微服务的场景下,在无形之间,就形成了不易察觉的父子任务关系。

所以就踩到了这个坑。


怎么避免

找到了坑的原因,解决方案就随之而出了。

父子任务不要共用一个线程池,给子任务也搞一个自定义线程池就可以了:

运行起来看看日志:

首先整体运行时间只需要 2s 了,达到了我想要的效果。

另外,我们观察一个具体的任务:

当前线程pool-1-thread-3,---【任务2】开始执行---
当前线程pool-2-thread-1,【任务2】开始处理数据=1
当前线程pool-2-thread-4,【任务2】开始处理数据=2
当前线程pool-1-thread-3,---【任务2】执行完成---

日志输出符合我们前面分析的,所有子任务执行完成后,父任务才打印执行完成,且子任务在不同的线程中执行。

而使用不同的线程池,换一个高大上的说法就叫做:线程池隔离。

而且在一个项目中,公用一个线程池,也是一个埋坑的逻辑。

至少给你觉得关键的逻辑,单独分配一个线程池吧。

避免出现线程池的线程都在执行非核心逻辑了,反而重要的任务在队列里面排队去了。

这就有点不合理了。

最后,一句话总结这个问题:

如果线程池的任务之间存在父子关系,那么请不要使用同一个线程池。如果使用了同一个线程池,可能会因为子任务进了队列,导致父任务一直等待,出现假死现象。


想起从前

写这篇文章的时候,我想起了之前写过的这篇文章:

《要我说,多线程事务它必须就是个伪命题!》

这篇文章是 2020 年写的,其中就是使用了父子任务+CountDownLatch 的模式,来实现所谓的“多线程事务”。

在文中我还特别强调了:

不能让任何一个任务进入队列里面。一旦进入队列,程序立马就凉。

这句话背后的原理和本文讨论的其实是一样的。

好吧,原来多年前我就知道这个坑了。

只是多年后再次遇到这个坑的时候,我已经不再是那个二十多岁,喜欢深夜怼文的我了。

那一年的荒腔走板,图片中的沙发,当年只是想摆拍一下,当个道具,后来觉得坐着还挺舒服,我们就买回家了。

当年为了装修房子煞费苦心,现在也已经入住了 3 年有余的时间了。

当我回望几年前写的文章,在当时技术部分是最重要的,但是回望的时候这部分已经不重要了。

它已经由一篇技术文章变成了一个生活的锚点,其中的蛛丝马迹,能让我从脑海深处想起之前生活中一些不痛不痒的印迹。

一艘轮船,在靠岸之后要下锚,那个点位就是锚点。

锚点可以让船稳定在海岸边,不被风浪或者潮汐带走。

生活也需要锚点,我似乎找到了我的锚点。

在机器学习中,我们经常会使用余弦函数来计算向量之间的相似性。从推荐系统到自然语言处理,再到计算机视觉,余弦相似性在多种机器学习应用中都有其独特的价值。它不仅限于特定领域,而是几乎可以在任何需要比较向量相似度的场景下使用。
这里边有两个重要的概念:向量、余弦,这是中学阶段学习的内容,考虑到年代久远,很多同学可能对这两个概念比较模糊了,所以本文先从余弦和向量的概念讲起,带大家一步步深入了解。

什么是余弦?

余弦(余弦函数)是三角函数的一种。
在直角三角形中,某个锐角的余弦等于它的邻边与斜边的比值,比如下图中角A的余弦就可以这样计算:cosA=b/c 。
image.png
在非直角三角形中,我们可以使用余弦定理:任意三角形中任一边的平方等于其他两边的平方和减去这两边与夹角余弦乘积的两倍,用公式表达就是:

我们对这个公式做一下变换,余弦就可以表示为:

直角三角形中锐角的余弦计算其实是余弦定理的一个特例,计算过程如下:
(1)首先,我们了解直角三角形中的勾股定理:

(2)将上边公式中

替换为

,最后就能得出直角三角形中锐角的余弦计算方法:

什么是向量?

考虑到有些同学可能对向量这个概念比较模糊了,我这里先做个简单的介绍:
向量最初应用于物理学,本意是有方向的量,比如加速度。后来这个概念被应用到了数学中,在几何中我们可以用一个有方向的线段来表示向量,线段的箭头代表向量的方向,线段的长度代表向量的大小。如下图所示:
image.png
线段R就是一个向量,我们还可以把它分解到a和b两个维度上,A和B称为向量的分量。
进一步,我们把可以把向量表示在平面直角坐标系中:

d89d6c20-107d-4d60-b642-18ece8e67b69.png
这是一个简单的二维向量(只有x、y两个维度),此时我们可以把向量表示为 a=(4,5),其中(x,y)是点P的坐标。
在机器学习中,我们只要获取到某个事物在若干纬度上的特征,就可以创建一个向量。比如对于部分人群,我们可能要关注他们的年龄、收入、工作年份等特征,假设其中一个人的特征是:

  • 年龄:30
  • 收入:60000
  • 工作年份:2014

映射后的向量就是:(30,60000,2014) ,这是一个三维向量。
在机器学习中,我们需要计算的向量通常有很多纬度,这很难通过图形进行描绘,不过高维向量和低维向量的计算方法都是相同的,所以后续将继续使用二维向量来说明相关原理。

余弦相似性有什么用?

余弦相似性是用来衡量两个向量之间相似程度的一种方法。简单来说,它告诉我们两个向量之间的“夹角”有多小,夹角越小,两个向量越相似。那这在实际生活中有什么用呢?让我举个例子来说明。
假设我们在构建一个电影推荐系统。首先,我们可以把每部电影的类型(比如动作、喜剧、科幻等)转换成一个向量。比如,一部电影可能会被表示为[1, 0, 1, 0, 0],其中1代表该电影属于某种类型,而0代表不属于。
接下来,我们需要了解用户A的电影偏好。我们可以根据用户A之前看过的电影和他们的类型,生成一个用户偏好向量。比如,如果用户A喜欢动作和科幻电影,他们的偏好向量可能是[1, 0, 1, 0, 0]。
现在,关键的一步来了:我们使用余弦相似性来计算用户A的偏好向量和所有电影向量之间的相似度。这个相似度值越高,表示用户A越喜欢这部电影。
通过比较这些相似度值的大小,我们可以筛选出最符合用户A偏好的电影列表。这样一来,我们就可以为用户A推荐他们可能喜欢的新电影。
所以,余弦相似性在这个例子中起到了桥梁的作用,帮助我们从大量的电影中找到那些最符合用户偏好的选项。通过这种方式,我们能让用户更容易发现自己感兴趣的内容,提高用户的满意度。

如何计算向量之间的余弦?

我们还是从三角形的余弦定理出发:

,a、b、c分别代表三条边的长度,θ是a和b的夹角。
在这个公式中,我们只要知道三角形三条边的长度就可以计算出余弦值。
为了计算余弦值,我们可以用向量构造一个三角形。
假设在一个二维坐标系中,我们有两个向量 A(4,2) 和 B(2,3),现在只需要把 A 和 B 的终点连接起来,就能构造一个三角形,如下图所示:
image.png
因为 A 和 B 是已知向量,我们已经可以知道它们的长度,现在只需要第三条边的长度,我们就可以计算余弦了。

向量的减法

第3条边的计算需要依据向量减法的三角形法则,A-B 会产生一个新的向量 C:从B的终点出发指向A的终点,也就是三角形的第3条边。
用公式表达就是:C=A-B
那么第3条边的长度我们可以用 A-B 的长度来代替,余弦定理可以重新表达为:

这里 ||X|| 代表向量X的长度。
这样计算余弦,我们就只需要关注两个向量的相关计算逻辑,而不需要真的构造一个三角形。

向量的长度

向量的长度有一个专业化的名词:模长。长度就长度呗,为什么还单独起个名字?
在数学中,模通常指的是一个数的大小或者绝对值,不考虑其正负符号,比如 3和-3的模都是3。向量作为一个既有大小又有方向的概念,在讨论其大小时,我们只关心它的长度,而不考虑其指向,反映了从向量起点到终点的距离,而忽略了方向性。
模长的计算方法:向量中每个分量的平方和的开方值。比如上图中A向量终点的坐标为 (4,2),那么向量A的长度就是:

向量的点积

这里还要再引入一个新的概念:向量的点积,它是一种向量运算,接收两个向量作为输入,并返回一个标量值。如果有两个n维向量 A=[a1,a2,...,an] 和 B=[b1,b2,...,bn],那么它们的点积定义为所有对应元素乘积的和:

向量长度的平方可以通过该向量与自身的点积来表示,也就是说:

这是可以推导出来的,根据上边向量长度的计算方法:

那么:

而向量A与自身的点积结果也是这个结果:

所以:

使用点积计算余弦

我们再使用点积来表示余弦定理:

向量的点积遵循分配律、结合律和交换律:

  • 分配率:a
    (b+c)=a
    b+a*c
  • 结合律:(a+b)+c=a+(b+c)
  • 交换律:a
    b=b
    a

那么上边的公式可以继续表达为:


然后我们可以约去等式两边都有的 A⋅A 和 B⋅B:

进一步约去 -2:

最终,可以得到余弦的新计算公式:

两个向量的余弦就等于它们的点积除以它们的模长乘积。
向量的点积和模长计算方法上边已经介绍过,对上图中的向量A(4,2)和向量B(2,3),它们的余弦值可以计算为:

如此,只需要向量的值,我们就可以得出他们之间的余弦相似性了。

为什么余弦相似性不关心长度

余弦的特性

使用余弦相似性的情况下,长度不重要,方向才重要。我们可以通过下边的图来理解这个问题:
image.png
对于向量A和向量B,计算它们之间夹角的余弦值,我们可以从B向A发射一条垂线,构造一个直角三角形,然后用夹角的临边除以直角三角形的斜边(这是直角三角形中锐角余弦值的计算方法)。
如果我们缩小向量B的长度,临边和斜边的长度也会同步缩小,这对夹角余弦值的计算没有什么影响。
如果你还心存疑虑,我们可以做个计算。
对上图中的向量A(4,2)和向量B(2,3),我们把上文中的余弦值计算结果拿过来:

现在我们把向量B缩小一半,改为(1,1.5),重新计算夹角的余弦值:

结果还是一样的。

业务的选择

业务选择余弦来对比相似性,是因为数据的特征符合余弦相似性这种度量方法。
此类数据通常是一些分类特征,常见的有内容的主题、风格或者类别等。
举个为用户推荐电影的例子。
我们先将电影类型表示为如下向量:

  • 喜剧:[1, 0, 0, 0, 0]
  • 动作:[0, 1, 0, 0, 0]
  • 爱情:[0, 0, 1, 0, 0]
  • 科幻:[0, 0, 0, 1, 0]
  • 恐怖:[0, 0, 0, 0, 1]

然后我们假设这样一个用户,他喜欢的电影类型是喜剧和科幻。
我们可以将用户的偏好表示为一个由喜剧和科幻向量加权平均得到的向量:

  • 用户偏好:[0.5, 0, 0, 0.5, 0]

如果我们想要为这个用户推荐电影,我们就可以计算用户偏好向量与电影类型向量之间的余弦相似性,值越大,越值得推荐。

不适合余弦相似性度量的数据

连续的特征,比如评分或者活跃度之类的,它们表示用户对物品的喜好程度或使用频率。
使用向量比较此类数据时,向量长度就是一个很关键的指标。
如果用户给某个电影打了5星,我们不仅想要给用户推荐类似类型的电影,还想要推荐同样具备高评分的电影,此时仅用余弦相似性就不够了,我们可能还要加入距离的计算。
其它相似性算法超出了本文的范畴,这里就不多说了,有兴趣的同学可以去搜索下。


以上就是本文的主要内容。

关注萤火架构,加速技术提升!

这天,小悦懒洋洋地步入办公楼下的咖啡馆,意外地与一位男子不期而遇。他显然因前一晚的辛勤工作而略显疲惫,却仍选择早到此地,寻找一丝宁静与放松。他叫逸尘,身姿挺拔,衣着简约而不失格调,晨光下更显英俊不凡,吸引了周遭的目光。两人仿佛心有灵犀,不约而同地走向各自的位置。

小悦手中轻握着新出炉的拿铁,眼睛紧紧盯着手机上的工作邮件,心思全然沉浸在工作的海洋中,对前方即将发生的“小插曲”浑然未觉。而逸尘,正欲伸手取桌上的文件,两人的手在不经意间悄然相遇,伴随着一阵轻微的碰撞,小悦手中的拿铁微微倾斜,几滴热烫的咖啡瞬间在逸尘洁白的衬衫上绽放,如同从树上不经意间洒落的晨露,虽美却略显突兀。

“哎呀,真的非常抱歉!”小悦连忙道歉,脸颊上泛起了红晕,手忙脚乱地在包中搜寻纸巾,希望能为这突如其来的尴尬场面做些什么。逸尘则以他特有的绅士风度,轻轻接过纸巾,自行处理起那片不速之客。

“没关系,下次小心些便是。”逸尘的话语中虽带有一丝不易察觉的责备,但更多的是温柔与宽容。他皱眉的瞬间,非但没有减少魅力,反而增添了几分成熟与稳重。

小悦心中五味杂陈,既有对自己疏忽的懊恼,也有对逸尘那不经意间流露出的严厉与温柔交织的复杂情感。她低声细语:“我真的不是故意的。”这句话虽轻如蚊蚋,却清晰地传入了逸尘的耳中,两人的心间仿佛被一股莫名的力量轻轻触碰,营造出一种难以言喻的微妙氛围。


当时,小悦手机上的邮件内容深深吸引了她的注意,邮件中详细列出了一项sql任务:要求根据公司名称和月份进行分组,统计出2024年全年的订单表总数量,并进一步细分出已下单数量(状态1)、送货中数量(状态2)以及已收货数量(状态3)。这一挑战性的任务让小悦不禁陷入了沉思,她迅速地在脑海中构想出了初步的方案1:

SELECTo.company,
EXTRACT(
MONTH FROM o.order_date) AS month,
(
SELECT COUNT(*) FROM orders o2 WHERE o2.company = o.company AND EXTRACT(MONTH FROM o2.order_date) = EXTRACT(MONTH FROM o.order_date) AND EXTRACT(YEAR FROM o2.order_date) = 2024) AStotal_orders,
(
SELECT COUNT(*) FROM orders o2 WHERE o2.company = o.company AND EXTRACT(MONTH FROM o2.order_date) = EXTRACT(MONTH FROM o.order_date) AND o2.order_status = 1 AND EXTRACT(YEAR FROM o2.order_date) = 2024) ASordered_count,
(
SELECT COUNT(*) FROM orders o2 WHERE o2.company = o.company AND EXTRACT(MONTH FROM o2.order_date) = EXTRACT(MONTH FROM o.order_date) AND o2.order_status = 2 AND EXTRACT(YEAR FROM o2.order_date) = 2024) ASdelivering_count,
(
SELECT COUNT(*) FROM orders o2 WHERE o2.company = o.company AND EXTRACT(MONTH FROM o2.order_date) = EXTRACT(MONTH FROM o.order_date) AND o2.order_status = 3 AND EXTRACT(YEAR FROM o2.order_date) = 2024) ASreceived_countFROMorders oWHEREEXTRACT(YEAR FROM o.order_date) = 2024 GROUP BYo.company,
EXTRACT(
MONTH FROMo.order_date)ORDER BYo.company,month;

方案1查询语句使用了多个子查询来计算每个公司和月份的订单数量,虽然可以实现所需的功能,但也存在一些缺点:

  1. 性能问题


    • 每个子查询都需要对
      orders
      表进行独立的扫描,这会导致多次重复的数据库查询,增加了数据库的负担。
    • 对于大型数据集,这种多次扫描和查询的方式会导致性能显著下降。
  2. 可读性和维护性


    • 使用多个子查询使得SQL语句变得复杂,难以阅读和理解。
    • 如果需要修改或调试,需要逐个检查每个子查询,增加了维护的难度。
  3. 重复代码


    • 相同的条件(如公司、月份、年份)在每个子查询中重复出现,导致代码冗余。
    • 如果需要修改这些条件,必须在每个子查询中逐一修改,容易遗漏或出错。
  4. 索引利用


    • 子查询可能无法有效利用索引,尤其是在没有合适的索引情况下,查询性能会进一步下降。


随后,小悦没有放弃,反而更加专注地投入到方案一的优化中。她仔细分析了初步方案的可行性,并考虑到了性能优化和数据处理效率的问题。于是,她提出了优化后的方案2(Oracle/MySql/Mssql):

SELECTcompany,
EXTRACT(
MONTH FROM order_date) AS month,COUNT(*) AStotal_orders,COUNT(CASE WHEN status = 1 THEN 1 END) ASordered_count,COUNT(CASE WHEN status = 2 THEN 1 END) ASdelivering_count,COUNT(CASE WHEN status = 3 THEN 1 END) ASreceived_countFROMordersWHEREEXTRACT(YEAR FROM order_date) = 2024 GROUP BYcompany,
EXTRACT(
MONTH FROMorder_date)ORDER BYcompany,month;

方案2查询语句使用了
COUNT(CASE WHEN ...)
语法,具有以下优点:

  1. 性能优化


    • 通过在一个查询中完成所有计算,避免了多次扫描和查询数据库,从而提高了查询性能。
    • 数据库引擎可以更好地优化查询计划,利用索引和缓存来加速查询。
  2. 简洁性和可读性


    • 使用
      COUNT(CASE WHEN ...)
      语法使得SQL语句更加简洁,减少了冗余代码。
    • 查询逻辑清晰,易于阅读和理解,便于维护和调试。
  3. 减少重复代码


    • 相同的条件(如公司、月份、年份)只需要在
      WHERE
      子句中写一次,避免了在多个子查询中重复书写相同的条件。
    • 如果需要修改查询条件,只需在一个地方进行修改,减少了出错的可能性。
  4. 灵活性


    • COUNT(CASE WHEN ...)
      语法非常灵活,可以轻松地添加或修改条件,以适应不同的查询需求。
    • 可以很容易地扩展到其他状态或条件,而不需要重构整个查询。
  5. 索引利用


    • 这种查询方式可以更好地利用索引,尤其是在有合适的索引情况下,查询性能会得到进一步提升。


小悦意识到虽然方案2的
CASE
语法可以实现需求,但使用
COUNT FILTER
语法在PostgreSQL中更为简洁高效,而且由于国产数据库大多兼容PostgreSQL,这种选择不仅提升了查询性能,还确保了代码在国产数据库环境中的广泛适用性。方案3(PostgreSQL语法):,

SELECTcompany,
EXTRACT(
MONTH FROM order_date) AS month,COUNT(*) AStotal_orders,COUNT(*) FILTER (WHERE status = 1) ASordered_count,COUNT(*) FILTER (WHERE status = 2) ASdelivering_count,COUNT(*) FILTER (WHERE status = 3) ASreceived_countFROMordersWHEREEXTRACT(YEAR FROM order_date) = 2024 GROUP BYcompany,
EXTRACT(
MONTH FROMorder_date)ORDER BYcompany,month;

方案3中的COUNT(*) FILTER (WHERE status = 1)` 这种语法是 SQL:2003 标准引入的一个新特性,称为"过滤聚合"(Filtered Aggregation)。

过滤聚合的出现是为了解决一些常见的 SQL 分析需求,例如:

1. 在统计订单总数的同时,也统计已完成订单的数量。
2. 在统计销售总额的同时,也统计已付款订单的销售额。
3. 在统计某个商品的总销量中,也统计该商品的正常销量和退货销量。

在传统的 SQL 中,解决这类需求通常需要使用多个子查询或者分组之后进行过滤,代码会比较复杂。

过滤聚合的出现,让这类需求的实现变得更加简单和优雅。开发者可以在聚合函数中直接加上 `FILTER (WHERE ...)` 子句,对聚合的数据进行过滤,从而得到所需的统计结果。

比如上面的例子中,`COUNT(*) FILTER (WHERE status = 1)` 就可以直接统计状态为 1 的订单数量,无需再额外添加子查询。

这种语法在 SQL:2003 标准中引入,PostgreSQL首先实现了这个语法。它极大地简化了 SQL 的编写,提高了代码的可读性和可维护性。

Oracle /MySql/MsSql,对于这个 SQL 标准的新特性,并没有直接支持,只能通过case when的形式实现。


示例,在Having中使用过滤聚合语法:

--case语法示例
SELECTcompany,
EXTRACT(
MONTH FROM order_date) AS month,COUNT(*) AStotal_orders,COUNT(CASE WHEN status = 1 THEN 1 END) ASordered_count,COUNT(CASE WHEN status = 2 THEN 1 END) ASdelivering_count,COUNT(CASE WHEN status = 3 THEN 1 END) ASreceived_countFROMordersWHEREEXTRACT(YEAR FROM order_date) = 2024 GROUP BYcompany,
EXTRACT(
MONTH FROMorder_date)Having COUNT(CASE WHEN status = 1 THEN 1 END)>0 ORDER BYcompany,month;--filter语法示例 SELECTcompany,
EXTRACT(
MONTH FROM order_date) AS month,COUNT(*) AStotal_orders,COUNT(*) FILTER (WHERE status = 1) ASordered_count,COUNT(*) FILTER (WHERE status = 2) ASdelivering_count,COUNT(*) FILTER (WHERE status = 3) ASreceived_countFROMordersWHEREEXTRACT(YEAR FROM order_date) = 2024 GROUP BYcompany,
EXTRACT(
MONTH FROMorder_date)Having COUNT(*) FILTER (WHERE status = 1)>0 ORDER BYcompany,month;

前言

最近笔者在实际项目开发中会频繁涉及到服务之间的远程调用、域名的配置和请求的转发等与计算机网络相关的知识。

这些其实在读本科和考研的时候都有学习过理论,但为了更透彻地掌握便于在工作中使用,我还是决定写一篇文章来分享实际开发中是怎么应用的。

下面将从 HTTP 协议的基本概念与简介、完整的请求过程、客户端的请求以及服务端的响应这四部分来展开,同时会使用实际的场景来加以分析,便于大家理解。


一、HTTP协议

1.1基本概念

HTTP 协议(Hyper Text Transfer Protocol)超文本传输协议,即传输文字、图片、音频、视频等超文本数据、是一种用于分布式、协作式和超媒体信息系统的应用层协议。为了更快地处理大量事务,确保协议的可伸缩性,HTTP 协议被设计成了一种无状态协议,不保留之前一切的请求或响应报文的信息。HTTP 协议也是万维网(WWW,World Wide Web)的数据通信的基础。

HTTP 是一个客户端(用户)和服务器端(网站)请求和应答的标准,其定义了定义 Web 客户端如何向 Web 服务器请求 Web 页面,以及服务器如何把 Web 页面响应给客户端。

HTTP 协议中并没有规定必须使用 TCP/IP 或其支持的层。事实上,HTTP 可以在任何互联网协议上,或其他网络上实现。HTTP 假定其下层协议提供可靠的传输。因此,任何能够提供这种保证的协议都可以被其使用,所以其在 TCP/IP 协议族使用 TCP 作为其传输层,而 UDP 是不可靠传输。

用户通过使用各种
工具(如网页浏览器、网络爬虫或者 Jmeter 等)作为客户端
,来发起一个 HTTP 请求到服务器的指定端口(默认为80)。这个客户端被称为用户代理程序(User Agent)。而接受并
响应该 HTTP 请求的服务器上会存储着各种用户需要的资源,比如 HTML 文件和图像,这个被用户请求的服务器被称为源服务器(Origin Server)

1.2工作原理

通常,由 HTTP 客户端发起一个请求,创建一个到服务器指定端口(默认是80端口)的 TCP 连接,HTTP服务器则在那个端口监听客户端的请求。一旦收到请求,服务器会向客户端返回一个状态,比如"HTTP/1.1 200 OK",以及响应请求而返回的内容,如文件、错误消息、或者其它数据等。

以下是 HTTP 协议工作流程的几个关键步骤:

第一步:建立 TCP/IP 连接,客户端与服务器通过 Socket 三次握手进行连接

第二步:客户端向服务端发起 HTTP 请求,如:POST/login.html http/1.1

第三步:客户端发送请求头部、请求内容,最后会发送一空白行,标示客户端请求完毕

第四步:服务器做出应答,表示对于客户端请求的应答,如:HTTP/1.1 200 OK

第五步:服务器向客户端发送响应头部信息,发送一空白行,表示应答头信息发送完毕,随后以 Content-type 要求的数据格式,发送响应正文给客户端

第六步:服务端关闭 TCP 连接,如果服务器或者客户端的 Connection:keep-alive 则表示客户端与服务器端继续保存连接,在下次请求时可以继续使用这次的连接


二、请求过程

下面对 1.2 小节中的几个步骤做更为细致的讲解。

2.1域名解析

浏览器向 DNS 服务器请求解析该 URL 中的域名所对应的 IP 地址,查找过程依次如下:

  1. 浏览器缓存

    首先搜索浏览器自身的 DNS 缓存(缓存的时间比较短,大概只有1分钟,且只能容纳1000条缓存),看自身的缓存中是否是有域名对应且未过期的条目。如果有,则域名解析到此结束。

  2. 操作系统缓存

    如果上一步没有找到对应的条目,浏览器会搜索操作系统自身的 DNS 缓存,如果找到了没有过期的对应条目,则停止搜索,解析到此结束。查看操作系统自身的 DNS 缓存,以 Windows 系统为例,win + R 后输入 cmd 命令提示行,输入
    ipconfig /displaydns
    进行查看。

  3. hosts 文件

    如果上一步没有找到对应条目,浏览器就会尝试读取操作系统本地的文件,以 Windows 系统为例:
    C:\Windows\System32\drivers\etc
    内的 hosts 文件。

  4. DNS 服务器

    如果以上的三步都没有找到对应条目,那么浏览器就会向 DNS 服务器请求进行域名解析。

    更具体地说,浏览器发起一个 DNS 的系统调用,向本地配置的首选 DNS 服务器(一般由运营商提供)发起域名解析请求。域名解析请求是通过 UDP 协议向DNS 的 53 端口发起请求,这个请求是递归的请求。
    也就是说,运营商的 DNS 服务器必须得提供给我们该域名的公网 IP 地址。

2.2TCP 连接

根据 DNS 服务器解析出的 IP 地址和默认端口号,与该服务器进行 TCP 连接中 3 次握手的前两次,来建立连接:

TCP 连接

2.3发送 HTTP 请求

即完成 TCP 的 3 次握手的第三次:

发送 HTTP 请求

2.4服务器应答

客户端发起了请求,服务器一定要有应答吗?要回答这个问题,得知道 HTTP 响应的底层原理是基于 HTTP 协议的通信机制,这个协议决定了:如果客户端发送的请求能准确到达服务器,那么服务器必须会有响应并返回。

在本文的第四章,我会拿一个部署在 Linux 服务器上的、基于Spring Boot 的 Java 程序来分析具体服务器是怎么做出响应的。

服务器应答

2.5响应内容

下面是访问
https://mvnrepository.com/
即 Maven 远程中央仓库时,调用其搜索接口所产生的响应标头内容:

响应内容

2.6关闭连接

最后浏览器会关闭该 TCP 连接,浏览器利用自己内部的工作机制,把请求到的静态资源和 HTML 代码进行渲染,呈现给用户。


三、客户端请求

下面其实是本文的重头戏,会重点讲解具体的 HTTP 请求是怎么构建、发送请求的。

3.1请求Header

一个 HTTP 请求报文由请求行(request line)、请求头部(headers)、请求数据(request body)和空行(blank line)4个部分组成。

其中请求头部(headers)为请求报文添加了一些附加信息,由键值对组成,每行一对,名和值之间使用冒号分隔,如下图是由 PostMan 调用所示:

响应内容
请求 Header

常见的几个请求头释义:

常见请求头

且我们还可以自定义 Header 如:Authorization 是认证信息、Tenant-Code 是发起本次请求的租户编码。

注意:由于 HTTP 协议只规定 POST 提交的数据必须放在消息主体(body)中,并没有规定数据必须使用什么编码方式。服务端通常是根据请求头中的 Content-Type 字段来获知请求中的消息主体是用何种方式编码,再对 body 进行解析。

常用的 Content-Type 编码方式有:

  • application/x-www-form-urlencoded
    数据在发送到服务器之前,会将表单内的数据转换为键值对,比如 username=admin&password=123456,并将所有字符都会进行 URL 转码;
  • multipart/form-data
    数据将被编码为一条消息以标签为单元,用分隔符分开,既可以上传键值对,也可以上传文件,通常用于上传二进制的文件;
  • application/json
    用来告诉服务端消息主体是序列化后的 JSON 字符串,前端无法将表单的 enctype 属性指定为 application/json,通常使用 Ajax 的方式发送这种编码形式的请求。

3.2请求方法

最常用的四种请求方法:GET、POST、PUT、DELETE。

常见 HTTP 请求方法

3.3cookie 和 token

在了解 Session 和 Cookies 之前,我们还需要了解 HTTP 的一个特点,叫作无状态。

HTTP 的无状态是指 HTTP 协议对事务处理是没有记忆能力的,也就是说服务器不知道客户端是什么状态。

这时两个用于保持 HTTP 连接状态的技术就出现了,它们分别是 Session 和 Cookie。

Session 在服务端,也就是网站的服务器,用来保存用户的 Session 信息。

Cookie 在客户端,也可以理解为浏览器端有了 Cookie,浏览器在下次访问网页时会自动附带上它发送给服务器,服务器通过识别 Cookie 并鉴定出是哪个用户,然后再判断用户是否是登录状态,进而返回对应的响应。


四、服务端响应

4.1demo 举例

这里以一个基于 Spring Boot 的 Java 程序来举例,@RequestMapping 是 Spring MVC 框架中的一个注解,它用于指示具体的 Controller 方法如何响应某个特定的请求。它可以用于将请求URL映射到控制器上,并可以指定不同的参数设置。

@RestController
@RequestMapping("/study")
public class StudyController {

    @Resource
    private StudyService studyService;

    /**
     * 新增
     * @param studyDTO
     * @return 是否成功
     */
    @PostMapping("/add")
    public BaseResponse<Boolean> addAwards(@RequestBody StudyDTO studyDTO) {
        return ResultUtils.success(studyService.addStudy(studyDTO));
    }
}

如果将这个应用部署在服务器上,你想访问到,那么需要在浏览器中输入:
https://ip+port/服务名/study/add

我自己本地访问则是:
http://localhost:28089/initial/study/add

4.2返回内容

那么 HTTP 返回的响应报文内容是什么?主要包括以下3个部分:

  • 响应状态行(Status Line):包含HTTP协议版本、响应状态码和状态消息。例如,HTTP/1.1 200 OK 表示 HTTP 协议版本是1.1,响应状态码是 200,状态消息是 OK。这个在下一节会单独拿出来讲。

  • 响应头部(Headers)
    :包含了一系列的键值对,用来描述响应的属性和元数据。常见的响应头包括 Content-Type(指定响应的数据类型)、Content-Length(指定响应体的长度)等。HTTP 协议定义了许多标准的响应头,不同的头部字段有不同的作用。

    以下是一些常见的响应头:


    • Content-Type:指定响应体的数据类型。例如,Content-Type: text/html 表示响应体是 HTML 文档。

    • Content-Length:指定响应体的长度,以字节为单位。例如,Content-Length: 1024 表示响应体的长度是 1024 字节。

    • Location:用于重定向客户端到新的URL。例如,Location:
      http://example.com/new_page
      会将客户端重定向到
      http://example.com/new_page。

    • Set-Cookie:用于设置 Cookie,可以在响应中向客户端发送 Cookie 信息。

    • Cache-Control:控制响应的缓存行为,包括缓存的过期时间、验证方式等。

    • Server:指定响应的服务器信息。例如,Server: Apache/2.4.38 表示响应是由 Apache 服务器版本 2.4.38 生成的。

  • 响应体(Body):
    包含了实际的响应数据,可以是HTML页面、JSON数据、文本等。响应体的格式由Content-Type头部字段指定。

    以下是一些常见的Content-Type值:


    • text/html:HTML 文档。

    • application/json:JSON 数据。

    • text/plain:纯文本。

    • image/jpeg:JPEG图像。

    • application/xml:XML 数据。

4.3返回状态码

以下是一些常见的HTTP响应状态码:

常见 HTTP 响应状态码


五、文章小结

无论是前端还是后端,不论是科班还是非科班,也无论是开发、测试还是产品,了解和掌握 HTTP 请求的一些基本知识都是非常重要的。它是现代互联网中不可或缺的一部分,为我们提供了高效、灵活、可靠的数据传输方式,为 Web 应用程序的开发和使用提供了强有力的支持。

今天的分享就到这里,如有不足和错误,还请大家指正。或者你有其它想说的,也欢迎大家在评论区交流!

参考文档:

https://blog.csdn.net/u010804417/article/details/123638124

https://www.cnblogs.com/engeng/articles/5959335.html

一、写在开头

很久没更新喽,最近build哥一直在忙着工作,忙着写小说,都忘记学习自己的本职了,哈哈,不过现在正式回归!
我们继续学习Java的IO相关内容,之前我们了解到,所谓的IO(Input/Output)就是计算机系统与外部设备之间通信的过程。

二、IO调用过程

接下来我们从应用调用的过程中来分析一下整个IO的执行过程。不过在此之前,我们需要简单的了解一下整个操作系统的空间布局。为了保证操作系统的稳定性和安全性,一个进程的地址空间划分为用户空间(User space) 和 内核空间(Kernel space ) 。

内核空间:
是操作系统内核所使用的空间,用来存储底层内核代码、数据结构以及内核级别的系统调用。内核空间拥有比较高的权限,比如文件管理、进程通信、内存管理等等。
用户空间:
用户级别的应用程序和服务分配的内存区域。它包含了应用程序的代码、数据和运行时堆栈。用户空间与内核空间相对隔离,具有较低的权限级别,不能直接访问内核空间或硬件资源。
image

所以说,我们想要进行 IO 操作,一定是要依赖内核空间的能力。平常开发过程中接触最多的就是磁盘 IO(读写文件) 和 网络 IO(网络请求和响应)。

执行步骤:

  1. 应用程序发起IO请求;
  2. 系统内核接受到系统调用请求;
  3. 内核等待数据准备;
  4. 内核将数据从内核空间拷贝到用户空间;
  5. IO输出给应用程序。

三、IO常用模型

在UNIX系统中,我们所提到的IO模型一般是这四种:
同步阻塞 I/O、同步非阻塞 I/O、I/O 多路复用、信号驱动 I/O 和异步 I/O

不过,在日常使用中,我们常用的多为
BIO(Blocking I/O)
:同步阻塞 IO 模型、
NIO (Non-blocking/New I/O)
:同步非阻塞 IO 模型、
AIO (Asynchronous I/O)
:异步 IO 模型。

3.1 BIO (Blocking I/O)

在传统的IO中,多以这种同步阻塞的IO模型为主,这种模型下,程序发起IO请求后,处理线程处于阻塞状态,直到请求的IO数据从内核空间拷贝到用户空间。如下图可以直观的体现整个流程(图源:沉默王二)。
image

如果发起IO的应用程序并发量不高的情况下,这种模型是没问题的。但很明显,当前的互联网中,很多应用都有高并发IO请求的情况,这时就迫切的需要一款高效的IO模型啦。

3.2 NIO (Non-blocking/New I/O)

这种NIO模型,这个N既可以命名为NEW代表一种新型的IO模型,又可以理解为Non-Blocking,非阻塞之意。

Java NIO 是 Java 1.4 版本引入的,基于通道(Channel)和缓冲区(Buffer)进行操作,采用非阻塞式 IO 操作,允许线程在等待 IO 时执行其他任务。常见的 NIO 类有 ByteBuffer、FileChannel、SocketChannel、ServerSocketChannel 等。(图源:深入拆解Tomcat & Jetty)
image

虽然在应用发起IO请求时,之多多次发起,无须阻塞。但在内核将数据拷贝到用户空间时,还是会阻塞的,为了保证数据的准确性和系统的安全稳定。

3.3 I/O 多路复用模型

在同步非阻塞IO模型下,需要通过不断的轮询去检查请求数据是否已经完成,这个过程是很耗CPU的。因此,便又诞生了I/O多路复用模型。

I/O 多路复用模型
:使用操作系统提供的多路复用功能(如 select、poll、epoll 等),使得单个线程可以同时处理多个 I/O 事件。当某个连接上的数据准备好时,操作系统会通知应用程序。这样,应用程序可以在一个线程中处理多个并发连接,而不需要为每个连接创建一个线程。(图源:沉默王二)
image

3.4 AIO (Asynchronous I/O)

AIO 也就是 NIO 2。Java 7 中引入了 NIO 的改进版 NIO 2,它是异步 IO 模型。异步 IO 是基于事件和回调机制实现的,也就是应用操作之后会直接返回,不会堵塞在那里,当后台处理完成,操作系统会通知相应的线程进行后续的操作。

image

总结

以上BIO、NIO、AIO三种常见的IO模型是Java面试中最常考的,大家一定要记住其各自的特点和作用。

  • 阻塞 I/O:应用程序执行 I/O 操作时,会一直等待数据传输完成,期间无法执行其他任务。
  • 非阻塞 I/O:应用程序执行 I/O 操作时,如果数据未准备好,立即返回错误状态,不等待数据传输完成,可执行其他任务。
  • 异步 I/O:应用程序发起 I/O 操作后,内核负责数据传输过程,完成后通知应用程序。应用程序无需等待数据传输,可执行其他任务。