2024年10月

一、介绍
从今天开始,我们又要开始一个新的系列了,这个系列就是《C++之 Opencv 入门到提高》。这个系列是有关如何使用 C++ 语言,通过 Opencv 来实现图像处理、缺陷检测、视频处理、机器学习等功能。OpenCV 我也是新接触的,一步一步的学习,一步一步提高。这个系列是以 C++ 为基础的,所以要想看懂里面的代码,C++ 还是必须要有点基础的。
OpenCV 是什么,我简要的说明一下。OpenCV 是一个基于 BSD 许可(开源)发行的跨平台计算机视觉库,可以运行在Linux、Windows、Android和Mac OS操作系统上。它轻量级而且高效——由一系列 C 函数和少量 C++ 类构成,同时提供了Python、Ruby、MATLAB等语言的接口,实现了图像处理和计算机视觉方面的很多通用算法。
在计算机视觉项目的开发中,OpenCV作为较大众的开源库,拥有了丰富的常用图像处理函数库,采用C/C++语言编写,可以运行在Linux/Windows/Mac等操作系统上,能够快速的实现一些图像处理和识别的任务。OpenCV 是一个好东西,我这里只是做一个简单的介绍,如果大家想了解更多,可以去网上自行脑补吧。它的官网地址:
https://opencv.org/

今天是这个系列的第一篇,话不多说,我们开始认识一下它。

我需要进行说明,以防大家不清楚,具体情况我已经罗列出来。
操作系统:Windows Professional 10(64位)
开发组件:OpenCV – 4.10.0
开发工具:Microsoft Visual Studio Community 2022 (64 位) - Current版本 17.8.3
开发语言:C++(VC16)

二、测试源码
环境配置好了,我们怎么知道是否配置成功呢,如果有代码执行一下,就可以知道了。
代码里有图片路径,执行之前需要替换。

2.1、helloworld.cpp


1 #include <opencv2/opencv.hpp>
2 #include <opencv2/highgui/highgui.hpp>
3 #include <opencv2/imgproc.hpp>
4 
5 using namespacecv;6 
7 intmain()8 {9     Mat dest = imread("D:\\360MoveData\\Users\\Administrator\\Desktop\\TestImage\\guanyu.jpg", -1);10     namedWindow("demoWindow", cv::WINDOW_AUTOSIZE);11     imshow("demoWindow", dest);12 
13     waitKey(0);14 destroyAllWindows();15     return 0;16 }

View Code



三、配置流程
C++ 版本的 Opencv 配置其实不是很难,主要是分为三步,第一步:下载必要的组件,第二步:配置环境变量,第三步:配置 C++ 项目属性。三步完成,接下来就可以测试了。虽然分为3个步骤,但是我是按着顺序一步一步做的,没有分三步,每步在细分。

3.1、OpenCV 组件官网下载
官网地址:
https://opencv.org/releases/
我直接上一幅图,让大家有一个直接的感受。它的最新版本是 4.10.0,当然,在这个页面也罗列出来其他版本,我使用的是最新的。

3.2、因为我的系统是 Windows 版本,所以我点击【Windows】连接,开始现在 Windows 版本的安装程序。

3.3、当 opencv-4.10.0-windows.exe 下载完毕,双击直接安装就可以了。我的安装目录是 E 盘,目录中不要包含中文字符。

下载完成后,我们双击 exe 文件,把文件进行解压,我存放在 E 盘。如图:

点击【Extract】解压文件。

解压完成后,会在 E 盘生成一个 opencv 的目录,进入目录,如图:

进入 E:\opencv\build 目录,是生成的项目,如图:

在 x64 目录下面,有一个目录是:E:\opencv\build\x64\vc16\bin,这个地址就是要增加到环境变量中的。如图:


3.4、开始配置环境变量,右键点击【此电脑】,选择【属性】,进入【设置】窗口。点击窗口右侧的【高级系统设置】,如图:

3.5、点击【高级系统设置】,打开【系统属性】窗口,选择【高级】选项卡,点击下方的【环境变量】按钮。如图:



3.6、点击【环境变量】按钮,打开【环境变量】窗口。如图:



3.7、在【环境变量】窗口的下方【系统变量】部分,找到【Path】配置项,点击【编辑】按钮,打开编辑窗口。如图:



3.8、在【系统变量】窗口内,选择【Path】项,点击【编辑】按钮,打开【编辑环境变量】窗口。如图:



3.9、进入到【编辑环境变量】窗口,先点击【新建】按钮,会在窗口列表底部,出现空白行,将 opencv 的 bin 目录,拷贝在此,点击【确定】按钮完成。


3.10、现在,我们需要配置 Visual Studio 2022 C++ 项目一些设置了。首先,我们使用 VS2022 创建一个空的 C++ 项目。效果如图:


3.11、先选择【OpenCV.TraningSeries-001】项目,点击右键选择【属性】,或者点击快捷键【alter+enter】,打开项目属性窗口,如图:

红色标注的就是我们要配置的内容。主要有两项:VC++ 目录、连接器的输入项。


3.12、接下来,我们先配置【VC++目录】的【包含目录和库目录】。包含目录,配置内容是:E:\opencv\build\include,库目录:E:\opencv\build\x64\vc16\lib。

配置库目录,操作差不多。点击【库目录】右侧,编辑按钮,打开编辑窗口。如图:

编辑库目录。

最后效果,如图:



3.13、配置 VC++ 连接器的【输入】项目。就是要把 库目录:E:\opencv\build\x64\vc16\lib 下的 opencv_world4100d.lib 文件增加到这里。如图:

点击【编辑】按钮,打开编辑窗口。



3.14、到此,我们就全部配置完成了,就可以写代码测试。



四、总结
这是 C++ 使用 OpenCV 的第一篇文章,很简单,主要讲述了为了在 C++ 环境下使用 OpenCV 如何配置环境,我第一次配置还是忙活了半天,还好,最后成功了。初见成效,继续努力。皇天不负有心人,不忘初心,继续努力,做自己喜欢做的,开心就好。

前一篇:《全面解释人工智能LLM模型的真实工作原理(二)》

序言:
前面两节中,我们介绍了大语言模型的设计图和实现了一个能够生成自然语言的神经网络。这正是现代先进人工智能语言模型的雏形。不过,目前市面上的语言模型远比我们设计的这个复杂得多。那么,它们到底复杂在什么地方?本节将为你详细介绍,这些模型是如何通过一些关键技术使得神经网络在特定领域的表现达到甚至超越人类水平的?总结起来就是下面的九个方面。

(关注不迷路,及时收到最新的人工智能资料更新)

到底是什么让大型语言模型如此有效?

最早的模型通过逐字符生成‘Humpty Dumpty sat on a wall’,这与当前最先进的大型语言模型的功能相去甚远,但它却是这些先进模型的核心原理。通过一系列创新和改进,生成式AI从这种简单的形式演变为能够进行类人对话的机器人、AI客服、虚拟员工等,成为解决现实问题的强大工具。那么,当前的先进模型究竟在哪些方面做了改进?让我们逐一解析。

嵌入

还记得我们提到的输入字符方式并非最佳吗?之前我们随意给每个字符分配了一个数字。若是可以找到更合适的数字,或许可以训练出更好的网络。那么如何找到这些更好的数字呢?这里有个聪明的方法:

在前面的模型训练中,我们通过调整权重,观察最终损失是否减小来训练模型,不断调整权重。在每个步骤中,我们会:

• 输入数据

• 计算输出层

• 与期望输出比较并计算平均损失

• 调整权重,重新开始

在这个过程中,输入是固定的,这在RGB和体积作为输入时是合理的。但现在的输入字符a、b、c等的数字是我们随意选定的。如果在每次迭代中,不仅调整权重,还调整输入表示法,看看用不同数字代表“a”能否降低损失呢?这确实可以减少损失,让模型变得更好(这是我们设计的方向)。基本上,不仅对权重应用梯度下降,对输入的数字表示也应用梯度下降,因为它们本身就是随意选择的数字。这被称为“嵌入”。它是一种输入到数字的映射,需要像参数一样进行训练。嵌入训练完成后,还可以在其他模型中复用。请注意,要始终用相同的嵌入表示同一符号/字符/词。

我们讨论的嵌入只有每个字符一个数字。然而,实际上嵌入通常由多个数字组成,因为用单个数字难以表达一个概念的丰富性。回顾我们的叶子和花朵例子,每个物体有四个数(输入层的大小),这些数分别表达了物体的属性,模型可以有效利用这些数去识别物体。若只有一个数字,例如红色通道,模型可能会更难判断。要捕捉人类语言的复杂性,需要不止一个数字。

因此,我们可以用多个数字表示每个字符以捕捉更多丰富性吗?让我们给每个字符分配一组数字,称之为“向量”(有顺序地排列每个数字,如果交换位置会变成不同的向量。比如在叶子和花朵的数据中,交换红色和绿色的数会改变颜色,得到不同的向量)。向量的长度即包含多少个数字。我们会给每个字符分配一个向量。这里有两个问题:

• 如果给每个字符分配向量而非数字,如何将“humpty dumpt”输入到网络?答案很简单。假设我们为每个字符分配了10个数字的向量,那么输入层中12个神经元就变成120个神经元,因为“humpty dumpt”中的每个字符有10个数字。然后我们将神经元并排放好即可。

• 如何找到这些向量?幸运的是,我们刚刚学习了嵌入训练。训练嵌入向量与训练参数相似,只是现在有120个输入而不是12个,目标还是减少损失。取前10个数就是对应“h”的向量,依此类推。

所有嵌入向量的长度必须相同,否则无法一致输入不同字符组合。比如“humpty dumpt”和“umpty dumpty”——两者都输入12个字符,若每个字符的向量长度不同,就无法输入到120长的输入层。我们来看嵌入向量的可视化:

让我们称这组相同长度的向量为矩阵。上图的矩阵称为嵌入矩阵。你告诉它列号,代表字符,然后在矩阵中找到对应列即可获得表示该字符的向量。这种嵌入适用于嵌入任何事物集合,你只需为每个事物提供足够的列数。

子词分词器

到目前为止,我们使用字符作为语言的基本构件,但这种方法有局限性。神经网络的权重必须做大量工作来理解某些字符序列(即单词)以及它们之间的关系。如果我们直接将嵌入分配给单词,并让网络预测下一个单词呢?反正网络也只理解数字,我们可以给“humpty”、“dumpty”、“sat”、“on”等单词分配一个10维向量,然后输入两个单词让它预测下一个。“Token”指的是嵌入的单元,我们的模型之前使用字符作为token,现在提议用整个单词作为token(当然你也可以用整个句子或短语作为token)。

使用单词分词对模型有深远影响。英语中有超过18万个单词,若每个可能输出用一个神经元表示,则输出层需要几十万个神经元,而不是26个左右。随着现代网络中隐藏层大小的增加,这一问题变得不那么棘手。需要注意的是,由于每个单词都是独立处理的,初始嵌入也用随机数表示,所以相似的单词(如“cat”和“cats”)的初始表示毫无关系。可以预期模型会学习到两个单词的相似性,但能否利用这个明显的相似性以简化学习呢?

可以的。今天语言模型中最常用的嵌入方案是将单词分成子词并嵌入。例如,我们将“cats”分成两个token:“cat”和“s”。模型更容易理解其他词后的“s”的含义等。这也减少了token数量(sentencepiece是一种常用的分词器,词汇表大小为数万,而不是英语中的几十万单词)。分词器将输入文本(如“Humpty Dumpt”)拆分为token并返回相应的数字,用于查找该token在嵌入矩阵中的向量。例如,“humpty dumpty”在字符级分词器下会拆成字符数组[‘h’,‘u’,…‘t’],然后返回对应数字[8,21,…20],因为你需要查找嵌入矩阵的第8列以获得‘h’的嵌入向量(嵌入向量是输入模型的,而不是数字8,不同于之前的操作)。矩阵列的排列无关紧要,给‘h’分配任何列都可以,只要每次输入‘h’时查找相同的向量就行。分词器给我们一个随意(但固定)的数字以便查找,而真正需要分词器的是将句子切分成token。

利用嵌入和子词分词,模型可能如下所示:

接下来的几节涉及语言建模中的最新进展,正是它们让LLM如此强大。然而,理解这些之前需要掌握一些基础数学概念。以下是这些概念的总结:

• 矩阵及矩阵乘法

• 数学中函数的基本概念

• 数字的幂次方(例如a³=aaa)

• 样本均值、方差和标准差

附录中有这些概念的总结。

自注意力机制

到目前为止,我们只讨论了一种简单的神经网络结构(称为前馈网络),这种网络包含若干层,每层都与下一层完全连接(即,任何相邻层的两个神经元之间都有一条线),并且它仅连接到下一层(例如,层1和层3之间没有连接线)。然而,你可以想象,其实没有什么可以阻止我们移除或创建其他连接,甚至构建更复杂的结构。让我们来探讨一种特别重要的结构:自注意力机制。

观察人类语言的结构,我们想要预测的下一个词通常取决于前面的所有词。然而,它可能更依赖某些前面的词。例如,如果我们要预测“Damian有一个秘密孩子,是个女孩,他在遗嘱中写到他的所有财产,连同魔法球,都将归____”。此处填的词可以是“她”或“他”,具体取决于句子前面的某个词:女孩/男孩。

好消息是,我们的简单前馈模型可以连接到上下文中的所有词,因此它可以学习重要词的适当权重。但问题在于,通过前馈层连接特定位置的权重是固定的(对每个位置都是如此)。如果重要的词总是在同一个位置,它可以学习到适当的权重,那就没问题了。然而,下一步预测所需的相关词可以出现在系统的任何地方。我们可以将上面的句子进行改写,在猜“她还是他”时,无论出现在句子的哪个地方,男孩/女孩这个词都是非常重要的。所以我们需要让权重不仅依赖于位置,还依赖于该位置的内容。如何实现这一点?

自注意力机制的运作类似于对每个词的嵌入向量进行加权,但不是直接将它们相加,而是为每个词应用一些权重。例如,如果humpty、dumpty、sat的嵌入向量分别是x1、x2、x3,那么它会在相加之前将每个向量乘以一个权重(一个数值)。比如output = 0.5 * x1 + 0.25 * x2 + 0.25 * x3,其中output是自注意力的输出。如果我们将权重写作u1、u2、u3,那么output = u1 * x1 + u2 * x2 + u3 * x3,那么这些权重u1、u2、u3是怎么得到的呢?

理想情况下,我们希望这些权重依赖于我们所加的向量——如前所述,有些词可能比其他词更重要。但对谁更重要呢?对我们即将预测的词更重要。因此,我们还希望这些权重取决于我们即将预测的词。不过,这里有一个问题:在预测之前,我们当然不知道即将预测的词是什么。所以,自注意力机制采用紧接着我们将要预测的词的前一个词,即句子中当前可用的最后一个词(我不确定为什么是这样而不是其他词,不过深度学习中的许多事情都是通过反复试验得出的,我猜这是个有效的选择)。

好了,我们想要这些向量的权重,并且希望每个权重依赖于当前聚合的词和即将预测词的前一个词。基本上,我们想要一个函数u1 = F(x1, x3),其中x1是我们要加权的词,x3是我们已有序列中的最后一个词(假设我们只有3个词)。一种直接的实现方法是为x1定义一个向量(称为k1),为x3定义一个独立的向量(称为q3),然后取它们的点积。这会得到一个数值,且它依赖于x1和x3。那么,这些向量k1和q3是如何得到的?我们可以构建一个简单的单层神经网络,将x1映射为k1(或者将x2映射为k2,x3映射为k3等)。同时构建另一个网络将x3映射为q3,依此类推。用矩阵表示法,我们基本上可以得到权重矩阵Wk和Wq,使得k1 = Wk * x1,q1 = Wq * x1,依此类推。现在我们可以对k1和q3进行点积得到一个标量,所以u1 = F(x1, x3) = Wk * x1 · Wq * x3。

在自注意力机制中,还有一个额外步骤,我们不会直接取嵌入向量的加权和,而是取该嵌入向量的某种“值”的加权和,这个“值”通过另一个小的单层网络获得。这意味着类似于k1和q1,我们还需要一个v1用于词x1,通过矩阵Wv得到,即v1 = Wv * x1。然后聚合这些v1。因此,若我们仅有3个词,并试图预测第四个词,整个过程看起来是这样的:

图中的加号表示向量的简单相加,意味着它们必须长度相同。最后一个未展示的修改是标量u1、u2、u3等不一定加和为1。如果我们需要它们作为权重,应该让它们加和为1。所以这里我们会应用熟悉的技巧,使用softmax函数。

这就是自注意力。还有一种交叉注意力(cross-attention),可以将q3来自最后一个词,但k和v可以来自完全不同的句子。例如,这在翻译任务中很有价值。现在我们已经了解了什么是注意力机制。

我们可以将这个整体封装成一个“自注意力块”。基本上,这个自注意力块接收嵌入向量并输出一个任意用户选择长度的单一向量。这个块有三个参数,Wk、Wq、Wv——它并不需要更复杂。机器学习文献中有许多这样的块,通常在图中用一个标注其名称的方框来表示。类似这样:

在自注意力中你会注意到,词的顺序似乎不那么重要。我们在整个过程中使用相同的W矩阵,所以交换Humpty和Dumpty并不会有实质差别——所有数值的结果都会相同。这意味着,虽然注意力可以识别需要关注的内容,但它不会依赖词的位置。不过我们知道词的位置在英语中很重要,通过给模型一些位置信息可以提高性能。

因此,在使用注意力机制时,我们通常不会直接将嵌入向量输入自注意力块。稍后我们将看到如何在输入注意力块之前,通过“位置编码”将位置信息添加到嵌入向量中。

注意:对于已经了解自注意力的人可能会注意到,我们没有提到任何K和Q矩阵,也没有应用掩码等。这是因为这些是模型常见训练方式的实现细节。数据批量输入,模型同时被训练预测从humpty到dumpty、从humpty dumpty到sat,等等。这是为了提高效率,并不影响理解或模型输出,因此我们选择忽略了训练效率上的优化技巧。

Softmax

我们在最开始简要提到过softmax。这是softmax试图解决的问题:在输出层中,我们有与可能选项数量相同的神经元,并且我们说将选择网络中值最高的神经元作为输出。然后我们会计算损失,方法是求网络提供的值和我们期望的理想值之间的差。但我们理想的值是什么呢?在叶子/花朵的例子中,我们设为0.8。但为什么是0.8?为什么不是5、10或1000万?理论上,越高越好!理想情况下,我们想要无穷大!不过这样会让问题变得不可解——所有的损失都将是无穷大,我们通过调整参数来最小化损失的计划(记得“梯度下降”吗)就失效了。该如何处理呢?

一个简单的方法是限制理想值在某个范围内,比如0到1之间。这样所有损失都会是有限的,但现在又出现了新问题:如果网络输出值超出这个范围怎么办?比如在某个例子中它输出(5,1)表示(叶子, 花朵),而另一个例子输出(0,1)。第一个例子做出了正确的选择,但损失却更高!好吧,我们需要一种方法也将输出层的值转换到(0,1)的范围内,同时保持顺序不变。我们可以使用任何数学上的“函数”来实现这个目标(一种“函数”就是将一个数字映射到另一个数字的规则——输入一个数字,输出另一个数字),一个可行的选择是逻辑函数(如下图所示),它将所有数字映射到(0,1)之间,并保持顺序不变:

现在,输出层中每个神经元都有一个0到1之间的数值,我们可以通过设定正确的神经元为1、其他为0来计算损失,这样就可以比较网络输出与理想值的差异。这样能行,不过能不能更好一点?

回到我们“Humpty Dumpty”的例子,假设我们逐字生成“dumpty”,模型在预测“m”时出错了,输出层中最高的不是“m”而是“u”,但“m”紧随其后。如果我们继续用“duu”来预测下一个字符,模型的信心会很低,因为“humpty duu...”后续可能性不多。然而,“m”是接近的次高值,我们可以也给“m”一个机会,预测接下来的字符,看看结果如何?也许能给出一个更合理的词。

所以,我们这里谈到的不是盲目选择最大值,而是试试几种可能性。怎么做才好呢?我们得给每个可能性一个概率——比如选最高的概率为50%,次高的为25%,依次类推,这样挺好。不过,或许我们还希望概率与模型的预测结果相关联。如果模型对m和u的预测值相当接近(相对其他值),那么50-50的机会可能会是不错的选择。

我们需要一个漂亮的规则,将这些数值转换为概率。softmax就做了这个工作。它是上述逻辑函数的推广,但增加了一些特性。如果你输入10个任意数字,它会返回10个输出,每个在0到1之间,且总和为1,因此我们可以将它们解释为概率。你会发现,softmax在几乎每个语言模型中作为最后一层出现。

残差连接

随着章节的进展,我们逐渐用方框/模块表示网络中的概念。这种表示法在描述“残差连接”这种有用概念时特别有效。让我们看看与自注意力块结合的残差连接:

注意,我们将“输入”和“输出”用方框表示,以简化内容,但它们仍然基本上只是数字或神经元的集合,和上图所示的类似。

这里发生了什么呢?我们基本上是在自注意力块的输出传递到下一个块之前,将它与原始输入相加。首先要注意的是,这要求自注意力块输出的维度必须与输入相同。这并不是问题,因为自注意力块的输出维度是用户确定的。但为什么要这样做呢?我们不会深入所有细节,这里关键是当网络层次加深(输入和输出之间的层数增加)时,训练难度会显著增加。研究表明,残差连接有助于缓解这些训练难题。

层归一化

层归一化是一个相对简单的层,它对传入的数据进行归一化,方式是减去均值,然后除以标准差(如下文稍微多做一些)。例如,如果我们在输入后立即应用层归一化,它将对输入层的所有神经元计算均值和标准差。假设均值为M,标准差为S,那么层归一化会将每个神经元的值替换为(x-M)/S,其中x表示任意神经元的原始值。

那么,这有什么帮助?它基本上稳定了输入向量,有助于训练深层网络。一个问题是,通过归一化输入,我们是否会丢失一些可能对目标有帮助的有用信息?为了解决这个问题,层归一化层有一个“缩放”和一个“偏置”参数。基本上,对每个神经元,你可以乘以一个缩放值,然后加一个偏置。缩放和偏置值是可以训练的参数,允许网络学习到一些可能对预测有价值的变化。由于这是唯一的参数,层归一化块不需要大量参数进行训练。整个过程看起来大概是这样的:

缩放和偏置是可训练参数。可以看到,层归一化是一个相对简单的块,操作主要是逐点进行(在初始均值和标准差计算之后)。让人联想到激活层(如ReLU),唯一不同的是这里我们有一些可训练参数(虽然比其他层要少很多,因为它是简单的逐点操作)。

标准差是一种统计指标,表示值的分布范围,例如,如果所有值都相同,则标准差为零。如果每个值都与均值相距甚远,标准差就会较高。计算一组数字a1, a2, a3…(假设有N个数字)的标准差的公式如下:将每个数字减去均值,然后将每个N个数字的结果平方。将所有这些数字相加,然后除以N,最后对结果开平方根。

Dropout

Dropout是一种简单而有效的方法来防止模型过拟合。过拟合是指当模型在训练数据上效果很好,但对模型未见过的示例泛化能力不佳。帮助避免过拟合的技术称为“正则化技术”,而Dropout就是其中之一。

如果你训练一个模型,它可能会在数据上产生错误,或以某种方式过拟合。如果你训练另一个模型,它可能也会产生错误,但方式不同。如果你训练多个模型并对它们的输出取平均值呢?这通常被称为“集成模型”,因为它通过组合多个模型的输出来进行预测,集成模型通常比任何单个模型表现更好。

在神经网络中,你也可以这么做。可以构建多个(略有不同的)模型,然后组合它们的输出以获得更好的模型。然而,这可能计算开销很大。Dropout是一种不会实际构建集成模型的方法,但它捕捉到了一些集成模型的精髓。

概念很简单,通过在训练期间插入一个dropout层,你可以随机删除一定比例的神经元连接。以我们初始网络为例,在输入和中间层之间插入一个50%的Dropout层,可能看起来像这样:



这迫使网络在大量冗余中进行训练。本质上,你同时训练了许多不同的模型——但它们共享权重。

在进行推断时,我们可以采用类似集成模型的方式。我们可以使用dropout进行多次预测,然后组合结果。然而,这计算开销较大——而且由于模型共享权重——为什么不直接用所有权重进行预测呢(而不是每次只用50%的权重)?这应该能近似集成模型的效果。

不过,有一个问题:用50%权重训练的模型与用全部权重的模型在中间神经元的数值上会有很大不同。我们想要的是更像集成模型的平均效果。如何实现呢?一个简单的方法是将所有权重乘以0.5,因为现在使用的权重数量是原来的两倍。这就是Dropout在推断期间所做的:使用全网络所有权重,并将权重乘以(1-p),其中p是删除的概率。研究表明,这作为一种正则化技术效果相当不错。

多头注意力

这是Transformer架构中的关键模块。我们已经了解了什么是注意力模块。还记得吗?一个注意力模块的输出长度是由用户决定的,即v的长度。多头注意力就是在并行中运行多个注意力头(它们都接受相同的输入),然后将所有的输出简单地串联起来,看起来像这样:

请注意,从v1 -> v1h1的箭头表示线性层——每条箭头上都有一个矩阵来进行转换。我这里没展示出来是为了避免图形过于复杂。

这里的过程是为每个头生成相同的key、query和value。但是我们基本上是在它们之上应用了线性变换(分别对每个k、q、v和每个头单独应用),然后才使用这些k、q、v的值。这个额外层在自注意力中并不存在。

说句题外话,我认为这种创建多头注意力的方法有点奇特。比如,为什么不给每个头创建独立的Wk、Wq、Wv矩阵,而是添加新的一层并共享这些权重?如果你知道原因,告诉我——我还真没弄明白。

位置编码与嵌入

在自注意力部分,我们简要讨论了使用位置编码的动机。那这些是什么呢?虽然图中展示了位置编码,但使用位置嵌入比位置编码更为常见。因此,我们在此讨论常见的“位置嵌入”,但附录中也包含了原始论文中使用的“位置编码”。位置嵌入和其他嵌入没有区别,唯一不同的是这里不是对词汇表中的单词进行嵌入,而是对数字1、2、3等进行嵌入。因此,这种嵌入是一个与词汇嵌入同长度的矩阵,每一列对应一个数字。就是这么简单。

未完待续。下一节将是本篇的最后部分,我们将简单提及一下目前最先进的语言模型——GPT及其架构,并分享一个由OpenAI前工程师完全开源的人工智能模型代码…

本文分享自华为云社区
《【GaussTech技术专栏】GaussDB逻辑解码技术原理》
,作者:GaussDB 数据库。

1.背景

随着国内各大行业数字化改造步伐的加快,异构数据库数据同步的需求场景越来越多。

异构数据库同步,即将不同类型、不同结构的数据库之间的数据进行同步处理,以确保数据在不同数据库之间的一致性。比如,将当前数据库的数据迁移到其他类型的数据库中,或者将当前数据库中的数据实时备份到另一个数据库,从而提升数据的安全性和可靠性。

华为云提供了DRS服务,该服务涵盖了异构数据库之间的迁移、同步、灾备、订阅、录制回放等功能。目前DRS支持超过20种关系型和非关系型数据库作为源端,其中就包括了GaussDB。以GaussDB作为源数据库的DRS数据同步的原理如下图所示。

DRS驱动源端数据库GaussDB实时解析WAL日志,生成逻辑日志,随后DRS服务接收并解析逻辑日志,将其转换为目标数据库的SQL语句,并驱动目标数据库执行SQL语句,该过程被称为逻辑复制。

对于源端数据库来说,核心要解决的问题是如何将WAL日志转换成逻辑日志,该过程叫逻辑解码。本文我们为大家介绍GaussDB逻辑解码的技术原理。

2. GaussDB逻辑解码

WAL日志包含数据库中发生的所有数据变更,包括插入、更新和删除等操作,同时还包含了诸多数据库内部细节和特有实现。

逻辑解码用于将WAL日志解析为易于理解和处理的逻辑日志格式,包括JSON、二进制或者固定的text格式。用户和逻辑复制工具(如DRS)可以根据自身需求来解析和处理这些逻辑日志。

当启用逻辑解码时,GaussDB除了将每个事务的基本操作写入WAL日志,还会将少量的解码辅助信息(例如csn快照,用于解码阶段的可见性判断)记录到WAL日志中,以支持逻辑解码过程。同时还需要创建一个逻辑复制槽。逻辑复制槽的作用是阻止数据库将已落盘的WAL日志删除,并防止解码所需的系统表记录被清理。

如上图所示,逻辑解码主要包括数据来源、读取/加载、解码、重排/发送几个模块。WAL日志和系统表中存储的表的元数据是逻辑解码的内容来源。逻辑解码从WAL日志捕获用户表DML的变更记录,依据其中的物理存储标识(block number和offset等)和提交序列号(csn),加载系统表对应时刻的表的元数据,再将物理变更记录中强耦合的内部信息转换为用户可理解的表内容,生成和数据库实现无关的逻辑变更记录,最后重排和发送逻辑变更记录。

GaussDB逻辑解码有两种方式,分别为串行解码和并行解码。串行解码流程分为读取、解码、发送三个步骤,整个串行解码流程均在同一个线程内完成,其中解码的耗时占据全流程的70%以上。串行解码性能约3-5M/s。而并行解码是通过多线程并行执行的方式,极大压缩了解码过程耗时。因为串行解码效率较低,我们不推荐使用串行解码,而是推荐效率更高的并行解码,而且当前通过华为云DRS服务启动的GaussDB逻辑解码任务均为并行解码,下面我们重点介绍并行解码。

3. 并行解码

并行解码是通过多线程并发执行来提升逻辑解码性能。

如上图所示,并行逻辑解码包含三类线程:

Reader线程

读取WAL日志,抽取业务DML操作以及解码辅助信息对应的record,构建LogicalLogChange内存对象,如果该条DML涉及toast数据,还需要将toast数据拼接到该dml操作的LogicalLogChange对象中,然后按照日志序列号(LSN,WAL日志的唯一标识符,是一个有序值)顺序轮流分发到解码输入队列。

如果解码到了DDL,DDL更新了系统表,Reader线程此时会失效localsyscache,并将失效消息加入Decoder线程输入队列中,广播通知Decoder线程失效本地缓存。后续decoder线程在解码到失效消息时,同样对线程内的localsyscache作失效处理。

Decoder线程

从解码输入队列获取LogicalLogChange对象,根据日志版本内容加载数据表的元信息,将日志中的物理数据转换成表名、列名、列数据等用户易理解的逻辑数据,解码后的内容存放在LogicalLog内存对象中,并将LogicalLog对象加入到解码输出队列。

前面介绍到,Reader线程在将LogicalLogChange对象入队时是按LSN序将其放入解码输入队列,decoder解码完成后也会将LogicalLog按LSN需放入解码输出队列,供后面的Sender线程读取。

Sender线程

按照DML日志生成顺序,从解码输出队列拿取LogicalLog对象,根据每个对象的事务ID构建hash桶,按照每个LogicalLog对象在解码输出队列中的顺序(队列中已按LSN序排序),把同一个事务的LogicalLog对象,归类到对应的hash桶中,再以事务的提交顺序把每个事务的所有LogicalLog对象发送给逻辑日志接收端(例如DRS)。

并行解码可按需配置解码并发度,并发度参数取值范围为1-20,最大可配置20个并发解码线程。

GaussDB并行解码能极大地提升解码性能。典型tpcc场景下,解码速率可达到100MB/s。

4. DDL解码

GaussDB逻辑解码支持DDL解码。如果GaussDB开启了逻辑解码,则会在DDL SQL执行阶段对DDL语句的解析树进行解析,解析的结果组装为Json格式的字符串(示例),并新增一种WAL日志类型,用于将该Json字符串写入WAL日志。逻辑解码线程解析到该WAL日志类型时,按照原Json格式输出DDL的解码逻辑日志。DDL语句alter table t1 add column col3 varchar(64)的Json格式解码结果如下图所示。

5. 多版本数据字典与指定位点解码

逻辑解码在解码DML过程中依赖表的元数据信息,因此需要访问系统表(也叫数据字典),这种解码模式也叫online catalog模式。GaussDB将系统表信息缓存在syscache中,当DDL语句更新了系统表时,需要对syscache进行失效处理。执行DDL时会往WAL中写一条失效消息日志,逻辑解码的每个线程都在本线程内保存了一份syscache的副本,即localcache,解码到失效消息日志时,会对localcache做相应的失效处理。

online catalog模式的逻辑解码依赖表的历史元数据信息,因此需要利用逻辑复制槽来保留系统表的旧版本元组。如果想对任意历史日志进行指定位点解码,逻辑解码依赖的系统表旧版本元组需要需要一直保留,这样会严重影响数据库的性能。

因此,GaussDB单独增加了一套逻辑解码专用的系统表,叫做逻辑解码多版本数据字典。逻辑解码多版本数据字典参考内核系统表,表结构与内核系统表结构基本保持一致,保留了解码过程中表的元数据的所有历史版本,并单独实现了清理机制。

新安装的实例会自动创建好逻辑解码多版本数据字典,非新安装的实例,首次初始化逻辑解码多版本数据字典时,需要调用一个系统函数进行初始化,该系统函数将内核系统表的数据同步到逻辑解码多版本数据字典中。当DDL语句执行完,事务提交前,往内核系统表插入元数据的同时往逻辑解码多版本数据字典插入元数据。解码的时候,解码线程根据当前csn快照,从逻辑解码多版本数据字典中将元数据信息读取到localcache缓存中使用。同时逻辑解码实现了多版本数据字典清理的定时任务,通过guc参数,配置保留时间,将超过保留时间的元组删除。而且,通过该guc参数,我们可以保留任意时间的旧元组,从而在不影响数据库性能的基础上实现从指定位点(即WAL的物理位置)进行逻辑解码,位点可以是包括归档WAL日志在内的任意位置。

通过这种单独增加一套逻辑解码多版本数据字典的方式,可以将逻辑解码与内核系统表解耦,内核系统表无需为逻辑解码保留大量的旧版本元组,保障了内核的运行性能。

6. 分布式CN解码

GaussDB支持分布式解码能力。分布式解码又分为直连DN解码与CN解码。直连DN解码即不通过CN,解码工具直接连接到DN节点上进行DN解码,该解码方式和集中式解码原理一致,只能解本DN分片的数据。分布式解码主要是指连CN进行解码,可以完整解码整个集群的增量数据。

事务提交顺序(CommitCSN)代表事务完成的先后顺序。对于有依赖的两个事务,后执行事务的CommitCSN大于先执行事务的CommitCSN。分布式逻辑解码按照事务提交顺序有序返回事务的逻辑日志。

如图所示,DN各自按照事务提交顺序返回局部事务的逻辑日志,CN通过堆排序协调汇总来自各DN的事务逻辑日志,按堆排序的顺序输出逻辑日志。

相比于直连DN解码,具有链路数少、能保证强一致性、易用性强等优点。

7. 总结

GaussDB内核提供的逻辑解码技术,配合DRS服务,可以实现异构数据库之间的逻辑复制。GaussDB逻辑解码不仅支持DML解码,还支持DDL解码,极大提高了逻辑复制的易用性。支持独立的多版本数据字典模式进行解码,并支持指定位点解码与数据找回。通过开启并行解码,可以极大提高WAL日志解码速率,保证数据同步的高性能。

华为开发者空间,汇聚鸿蒙、昇腾、鲲鹏、GaussDB、欧拉等各项根技术的开发资源及工具,致力于为每位开发者提供一台云主机、一套开发工具及云上存储空间,让开发者基于华为根生态创新。

点击链接,免费领取您的专属云主机~

随着远程办公与异地协作越来越频繁,视频会议系统的使用也是越来越普遍。同时,用户对视频会议系统的功能也提出了更高的要求,比如,其中之一就是希望可以将整个视频会议的过程录制下来,以备之后可以查阅观看。

我们可以选择在视频会议系统的服务端或客户端来录制整个视频会议过程,在服务端录制与在客户端录制各有优劣。比如,在服务端录制对服务器配置要求更高,因为同时可能有很多个会议同时录制;而在客户端录制,录制的文件存放在客户端本地电脑上,只能在本地播放,如果其他人需要观看,则需要在录制完成后将该文件再上传到服务器。

无论是在服务端录制,还是在客户端录制,其技术原理是一样的。这里我就
傲瑞视频会议
(OrayMeeting)在服务端(Windows、Linux、信创国产OS、银河麒麟、统信UOS)录制会议过程的技术原理和实现介绍给大家。

如下图就是某会议录制文件使用QQ影音播放的效果:

接下来,我们将具体介绍傲瑞视频会议是怎么实现会议录制的。

一. 录制流程说明

(1)会议信息中心包含了一个字段,用于指示该会议是否开启录制。

(2)当第一个人进入会议时,启动录制。

(3)当到会议结束时间:若会议室没有人,这立即结束录制;若会议室还有人,等最后一个人退出时,结束录制。

(3)任何时候,主持人结束会议,则将同时结束录制。

(4)中途若会议室没有人时,则暂停录制;当再有人进入会议时,则继续录制。

二. 傲瑞会议录制的画面布局

(1)录制画面最上面有一行高为30px标题栏,将实时显示如下信息:会议名称、系统时间、参会人数。

(2)标题栏下面的剩下区域为内容区,用来渲染用户视频/头像,或者是渲染用户分享屏幕的桌面图像。

(3)当参会人数不超过4个人时,采用2x2四宫格;当参会人数多于4个人时,采用3x3九宫格。

(4)录制时最多渲染9个人的视频或头像(3x3),开启了视频的用户排在最前面,其次是开启了麦克风的用户。

(5)如果用户开启了视频,录制时就渲染其视频图像,否则,就渲染该用户的默认头像。

(6)如果参会人员中有人开启了屏幕分享,这录制画面的内容区将不再渲染用户视频/头像,而是改为渲染被分享屏幕的桌面图像。

三. 程序实现技术要点

(1)获取参会人员的PCM声音数据和RGB图像数据。

使用
OMCS
提供的 MicrophoneConnector 和 DynamicCameraConnector 就可以获取每个参会人员的声音数据以及图像数据。

(2)拼接并渲染要录制的视频图像帧

在Windows可使用GDI+技术、在Linux上则可使用Skia技术来完成录制图像帧的拼接渲染。

if (desktopShare) //如果有人分享桌面,这主体内容区就是桌面图像
{//获取屏幕分享的最新桌面图像帧
    Image image = this.connectorManager.GetCurrentImage(null);
canvas.SetDesktopImage(image);
}
else{for (int i = 0; i < recordMembers.Count; i++)
{
string userID =recordMembers[i];
Image image
= null;if (!meeting.CamClosedMemberList.Contains(userID))
{
//获取参会成员的最新视频图像帧 image = this.connectorManager.GetCurrentImage(userID);
}

DrawInfo renderModel
= this.drawInfoManagers.Get(userID);
renderModel.SetCameraImage(image);
renderModel.SetMicState(
!meeting.MuteMemberList.Contains(userID));
}
//绘制主体内容区 canvas.SetCameraImage(this.drawInfoManagers.GetAll());
}
byte[] bytes = canvas.RenderImage(); //准备好要录制的图像 if (bytes != null)
{
this.videoFileMaker?.AddVideoFrame(bytes); //提交给录制器 }

(3)混音

使用 OMCS 提供的 MicrophoneConnectorMixer 可以将参会人员的声音混成一路。

IConnectorManager connectorManager = newOMCSConnectorManager(globalCache);
connectorManager.AudioMixed
+=ConnectorManager_AudioMixed;private void ConnectorManager_AudioMixed(string userID, byte[] data)
{
if (recording && data != null)
{
this.videoFileMaker?.AddAudioFrame(data); //将混音数据提交给录制器 }
}

(4)定时器

使用定时器来调度声音数据和图像数据。比如:每隔10毫秒就从各个MicrophoneConnector获取一帧语音数据,并将它们混音。每隔40毫秒就从各个 DynamicCameraConnector 获取相应的图像数据,并将它们拼接,并按照前面描述的画面布局进行渲染。

//开启录制线程,定时调用
internal voidStartRecord()
{
recording
= true;
Task.Factory.StartNew(()
=>{
RecordThread();
});
}
private voidRecordThread()
{
int sleepSeconds = 1000 /frameRate;while (true)
{
System.Threading.Thread.Sleep(sleepSeconds);
//record ... }
}

(5)将图像帧和声音帧编码并生成MP4文件。

将混音好的声音数据、拼接好的渲染图像提交给
MFile
,MFile会将它们编码并写入到MP4文件中。

会议结束时,将结束录制,并释放相关的资源。

internal voidFinishRecord()
{
recording
= false;//释放麦克风、摄像头、桌面设备连接器 connectorManager.DisconnectAllConnect();//释放录制器 videoFileMaker?.Close(true);
}

四. 结语

在将录制会议的流程、画面布局、技术要点做了简单介绍后,相信大家对视频会议服务端在程序上是如何实现会议录制功能的,已经初步了解了。

本文只是粗略地介绍了视频会议录制的原理与技术实现,如果你有更具体的实现细节需要了解的,欢迎与我讨论。

前言

最近在我的知识星球中,有个小伙伴问了这样一个问题:百万商品分页查询接口,如何保证接口的性能?

这就需要对该分页查询接口做优化了。

这篇文章从9个方面跟大家一起聊聊分页查询接口优化的一些小技巧,希望对你会有所帮助。

图片



1 增加默认条件

对于分页查询接口,如果没有特殊要求,我们可以在输入参数中,给一些默认值。

这样可以缩小数据范围,避免每次都count所有数据的情况。

对于商品查询,这种业务场景,我们可以默认查询当天上架状态的商品列表。

例如:

select * from product 
where edit_date>='2023-02-20' and edit_date<'2023-02-21' and status=1

如果每天有变更的商品数量不多,通过这两个默认条件,就能过滤掉绝大部分数据,让分页查询接口的性能提升不少。

温馨提醒一下:记得给
时间

状态
字段增加一个
联合索引

2 减少每页大小

分页查询接口通常情况下,需要接收两个参数:
pageNo
(即:页码)和
pageSize
(即:每页大小)。

如果分页查询接口的调用端,没有传pageNo默认值是1,如果没有传pageSize也可以给一个默认值10或者20。

不太建议pageSize传入过大的值,会直接影响接口性能。

在前端有个下拉控件,可以选择每页的大小,选择范围是:10、20、50、100。

前端默认选择的每页大小为
10

不过在实际业务场景中,要根据产品需求而且,这里只是一个参考值。

3 减少join表的数量

有时候,我们的分页查询接口的查询结果,需要join多张表才能查出数据。

比如在查询商品信息时,需要根据商品名称、单位、品牌、分类等信息查询数据。

这时候写一条sql可以查出想要的数据,比如下面这样的:

select 
  p.id,
  p.product_name,
  u.unit_name,
  b.brand_name,
  c.category_name
from product p
inner join unit u on p.unit_id = u.id
inner join brand b on p.brand_id = b.id
inner join category c on p.category_id = c.id
where p.name='测试商品' 
limit 0,20;

使用product表去
join
了unit、brand和category这三张表。

其实product表中有unit_id、brand_id和category_id三个字段。

我们可以先查出这三个字段,获取分页的数据缩小范围,之后再通过主键id集合去查询额外的数据。

我们可以把sql改成这样:

select 
  p.id,
  p.product_id,
  u.unit_id,
  b.brand_id,
  c.category_id
from product
where name='测试商品'
limit 0,20;

这个例子中,分页查询之后,我们获取到的商品列表其实只要20条数据。

再根据20条数据中的id集合,获取其他的名称,例如:

select id,name 
from unit
where id in (1,2,3);

然后在程序中填充其他名称。

伪代码如下:

List<Product> productList = productMapper.search(searchEntity);
List<Long> unitIdList = productList.stream().map(Product::getUnitId).distinct().collect(Collectors.toList());
List<Unit> unitList = UnitMapper.queryUnitByIdList(unitIdList);
for(Product product: productList) {
   Optional<Unit> optional = unitList.stream().filter(x->x.getId().equals(product.getId())).findAny();
   if(optional.isPersent()) {
      product.setUnitName(optional.get().getName());
   } 
}

这样就能有效的减少join表的数量,可以一定的程度上优化查询接口的性能。

4 优化索引

分页查询接口性能出现了问题,最直接最快速的优化办法是:
优化索引

因为优化索引不需要修改代码,只需回归测试一下就行,改动成本是最小的。

我们需要使用
explain
关键字,查询一下生产环境分页查询接口的
执行计划

看看有没有创建索引,创建的索引是否合理,或者索引失效了没。

索引不是创建越多越好,也不是创建越少越好,我们需要根据实际情况,到生产环境测试一下sql的耗时情况,然后决定如何创建或优化索引。

建议优先创建
联合索引

如果你对explain关键字的用法比较感兴趣,可以看看我的这篇文章《
explain | 索引优化的这把绝世好剑,你真的会用吗?
》。

如果你对索引失效的问题比较感兴趣,可以看看我的这篇文章《
聊聊索引失效的10种场景,太坑了
》。

5 用straight_join

有时候我们的业务场景很复杂,有很多查询sql,需要创建多个索引。

在分页查询接口中根据不同的输入参数,最终的查询sql语句,MySQL根据一定的抽样算法,却选择了不同的索引。

不知道你有没有遇到过,某个查询接口,原本性能是没问题的,但一旦输入某些参数,接口响应时间就非常长。

这时候如果你此时用
explain
关键字,查看该查询sql执行计划,会发现现在走的索引,跟之前不一样,并且驱动表也不一样。

之前一直都是用表a驱动表b,走的索引c。

此时用的表b驱动表a,走的索引d。

为了解决Mysql选错索引的问题,最常见的手段是使用
force_index
关键字,在代码中指定走的索引名称。

但如果在代码中硬编码了,后面一旦索引名称修改了,或者索引被删除了,程序可能会直接报错。

这时该怎么办呢?

答:我们可以使用
straight_join
代替
inner join

straight_join会告诉Mysql用左边的表驱动右边的表,能改表优化器对于联表查询的执行顺序。

之前的查询sql如下:

select p.id from product p
inner join warehouse w on p.id=w.product_id;
...

我们用它将之前的查询sql进行优化:

select p.id from product p
straight_join warehouse w on p.id=w.product_id;
...

6 数据归档

随着时间的推移,我们的系统用户越来越多,产生的数据也越来越多。

单表已经到达了几千万。

这时候分页查询接口性能急剧下降,我们不能不做分表处理了。

做简单的分表策略是将历史数据归档,比如:在
主表
中只保留最近三个月的数据,三个月前的数据,保证到
历史表
中。

我们的分页查询接口,默认从主表中查询数据,可以将数据范围缩小很多。

如果有特殊的需求,再从历史表中查询数据,最近三个月的数据,是用户关注度最高的数据。

7 使用count(*)

在分页查询接口中,需要在sql中使用
count
关键字查询
总记录数

目前count有下面几种用法:

  • count(1)
  • count(id)
  • count(普通索引列)
  • count(未加索引列)

那么它们有什么区别呢?

  • count(*) :它会获取所有行的数据,不做任何处理,行数加1。
  • count(1):它会获取所有行的数据,每行固定值1,也是行数加1。
  • count(id):id代表主键,它需要从所有行的数据中解析出id字段,其中id肯定都不为NULL,行数加1。
  • count(普通索引列):它需要从所有行的数据中解析出普通索引列,然后判断是否为NULL,如果不是NULL,则行数+1。
  • count(未加索引列):它会全表扫描获取所有数据,解析中未加索引列,然后判断是否为NULL,如果不是NULL,则行数+1。

由此,最后count的性能从高到低是:

count(*) ≈ count(1) > count(id) > count(普通索引列) > count(未加索引列)

所以,其实
count(*)
是最快的。

我们在使用count统计总记录数时,一定要记得使用count(*)。

8 从ClickHouse查询

有些时候,join的表实在太多,没法去掉多余的join,该怎么办呢?

答:可以将数据保存到
ClickHouse

ClickHouse是基于
列存储
的数据库,不支持事务,查询性能非常高,号称查询十几亿的数据,能够秒级返回。

为了避免对业务代码的嵌入性,可以使用
Canal
监听
Mysql

binlog
日志。当product表有数据新增时,需要同时查询出单位、品牌和分类的数据,生成一个新的结果集,保存到ClickHouse当中。

查询数据时,从ClickHouse当中查询,这样使用count(*)的查询效率能够提升N倍。

需要特别提醒一下:使用ClickHouse时,新增数据不要太频繁,尽量批量插入数据。

其实如果查询条件非常多,使用ClickHouse也不是特别合适,这时候可以改成
ElasticSearch
,不过它跟Mysql一样,存在
深分页
问题。

9 数据库读写分离

有时候,分页查询接口性能差,是因为用户并发量上来了。

在系统的初期,还没有多少用户量,读数据请求和写数据请求,都是访问的同一个数据库,该方式实现起来简单、成本低。

刚开始分页查询接口性能没啥问题。

但随着用户量的增长,用户的读数据请求和写数据请求都明显增多。

我们都知道数据库连接有限,一般是配置的空闲连接数是100-1000之间。如果多余1000的请求,就只能等待,就可能会出现接口超时的情况。

因此,我们有必要做数据库的
读写分离
。写数据请求访问
主库
,读数据请求访问
从库
,从库的数据通过binlog从主库同步过来。

根据不同的用户量,可以做一主一从,一主两从,或一主多从。

数据库读写分离之后,能够提升查询接口的性能。

如果你对性能优化比较感兴趣,可以看看《
性能优化35讲
》,里面有更多干货内容。

最后说一句(求关注,别白嫖我)

如果这篇文章对您有所帮助,或者有所启发的话,帮忙扫描下发二维码关注一下,您的支持是我坚持写作最大的动力。
求一键三连:点赞、转发、在看。
关注公众号:【苏三说技术】,在公众号中回复:进大厂,可以免费获取我最近整理的10万字的面试宝典,好多小伙伴靠这个宝典拿到了多家大厂的offer。