2024年9月

人生有那么一首诗,往往当你拥有他的时候,你没有读懂他,可是当你读懂他的时候,你却失去了他,这首诗就是青春。

“一寸光阴一寸金,寸金难买寸光阴”,学生时代的作文中,已经被我们用烂了的词汇,时至今日,终于才深刻理解这句话的重要意义。光阴的确是无价的,一旦错过却无法追回,一寸光阴又何止一寸金呢。古人说过“三十而立,四十不惑,五十知天命”,几千年得出的经验,还真当是诚不我欺呀。

二十多岁刚出学校,忙着立足于工作,心高气傲。那时一心想学习更多的技能,更好的工作,获得更高的薪水,看到更多的风景。直到十多年之后的三十岁才逐渐认识到,世界上的工作千万种,然而没有一种是可以让人富有的,我们从小被灌输的那句勤劳致富,实际上是一句谎话。那些美好的童话故事,在实际生活中根本就不会发生。

三十岁之后的我们大多数按部就班成家立业,家庭的琐事会消耗一部分精力,当然也同样会带来慰藉。

三十五岁之后我们终于认识到,身体是革命的本钱,原来不是一句空话。

在即将到来的四十岁人生当口,确如古人所说,到了不惑的年纪。其实不惑是一直都存在的,只不过到了这样的年龄,才逐渐开始思考起来。

刚出学校,我们心气很高,海阔凭鱼跃,天高任鸟飞,每个人对未来都有美好的期许。然而经过这么多年社会的磨砺与摔打,逐渐磨平了棱角,可以说是某种程度上的认命,或者是看清了现实。其实我不认可这种说法,认命这个词语在我看来有消极的成份,我觉得另外一个词语或许更好,那便是成熟。世界上只有一种英雄主义,那就是看清生活的真相之后,依然热爱生活。我希望每个人都做自己生活的英雄。

其实我一直对年龄并没有特别敏感的感觉,一直觉得来日方长。就像单位的同事从九零后变成九五后,再到后来的零零后,我并没有意识到年龄会有什么问题。

直到一个周末的午后,我梦到了小时候的老家,那是一个夏天,老家后面的河里涨大水,淹没了河堤的一角,我与几个高中的同伴跳过被河水淹没的地方,河堤对岸的小卖部盖了新楼,我们感到惊奇。我站在河堤上,端详新修的小卖部,看到它背后黄昏的晚霞,像是一副巨大的绚烂水彩画,好美,我急忙拿出手机拍照。然后我们跳过淹水的地方,回了岸边,看到有几个人坐在岸边钓鱼。稻田夹着宽阔的土路,与远方的天青色交汇在一起。突然一阵寒意袭来,我惊醒,发现只是一场梦。赶紧翻看手机相册,却并没有找到那张晚霞,怅然若有所失。我突然意识到,逝去的时间永远也追不回来了。

其实关于年龄的增长是早就有预示的,只不过常常被我们忽略罢了。不知从什么时候开始,我们会被生活中的细微处所感动,喜欢每一次的久别重逢,喜欢彼此带着笑意的“好久不见”,喜欢带着一身风霜却温暖如初的拥抱。这大概也是年龄增长的暗示吧。

这些年极少看娱乐节目的我,会追《向往的生活》,我喜欢那种老朋友一起吃饭闲聊的生活氛围。那时候常常想拥有一方小的院落,在略显燥热的傍晚,约上三五知己好友,于繁星簇拥的月光下,畅聊过往,让欢歌笑语恣意挥洒,弥漫在酒香与烟雾里。这样的人间烟火气大概是最平凡的幸福吧。所以你看看,二十岁的我大概是不会想这些的。

有人说生活像是围城,外面的人像想进来,里面的人想出去。就好像萨冈说过:“所以漂泊的人都渴望平静、童年和杜鹃花,正如所以平静的人都幻想乐队、伏特加和醉生梦死”。这也许是为什么生活于城市钢筋混凝土中,有时候却向往乡村的荒野小径。而生活在乡村的烟火里,却向往城市的灯红酒绿。

我曾经对这个问题有过思考,这并非是我们少无适俗韵,性本爱丘山。只是在疲惫的城市中也需要一些宁静的生活作为调剂。归根结底还是大多数人,无法做到独善其身罢了。人是社会属性的动物,所以难免会被周围所影响,小隐隐于野,中隐隐于市,大隐隐于朝,对于大多数人来说,是无法在城市生活中,保持一颗隐的心境的。

这两天我第一次注意到到办公楼下的草坪由绿色变成了黄色,这让我想起湖北的秋冬应该也快要来临了吧,在广东已经很久没有家乡秋冬的感觉了。

我有一种独特的放松方式,蹲在某一个角落,观察窗外世界的变化,比如从炙热的中午到闲暇的下午,从黄昏到日落,看外面的人来车往,好像是在看别人的生活,一切井然有序,却与我无关,而我,只是一个旁观者。

我们是俗人,无法做到像颜斶那样返璞归真,晚食以当肉,安步以当车,无罪以当贵,清静贞正以自虞。当我有一次读到史铁生,他说:“我四肢健全时,常抱怨周围环境糟糕;瘫痪后,怀念当初可以行走奔跑的日子;几年后长了褥疮,又怀念起前两年安稳坐在轮椅上的时光;后来得了尿毒症,又开始怀念当初长褥疮的时候。人生无所谓幸与不幸,只是两种不同境遇的比较罢了”。我突然有一点开窍似的恍然大悟,其实我们最应该珍惜的恰好是眼前的时光,经过那些焦虑与不安的日子,才明白,与生活和解,活在当下,却是如此弥足珍贵。

四十岁,是一个人生节点,蹉跎半生,有消极也有积极,有过笑也有过泪,与以往的日子相比,也许相同,也许不同。相同的是我们仍然有一颗不甘的心,不同的是我们逐渐学会了处之泰然,活在当下。如果还要再说点什么,当十年之后,已经五十岁的我们再来回首过往,一定会想,如果时间能够倒流,回到十年前的四十岁那该是多么美好啊,然而我们在当下的四十岁此时此刻却浑然不知,一如十年前三十岁的我们一样,当时只道是寻常罢了。

自言自语,无病呻吟,以一篇短文来作为结尾吧:

最怕问初衷,幻梦成空。
年少立志三千里,踌躇百步无寸功。
懵懂半生,庸碌尘世中。
转眼高堂皆白发,儿女蹒跚学堂中
碎银几两催人老,心仍少,皱纹上眉中。
浮生醉酒回梦里,青葱人依旧
只叹当时,太匆匆。

Less-Attention Vision Transformer
利用了在多头自注意力(
MHSA
)块中计算的依赖关系,通过重复使用先前
MSA
块的注意力来绕过注意力计算,还额外增加了一个简单的保持对角性的损失函数,旨在促进注意力矩阵在表示标记之间关系方面的预期行为。该架构你能有效地捕捉了跨标记的关联,超越了基线的性能,同时在参数数量和每秒浮点运算操作(
FLOPs
)方面保持了计算效率。

来源:晓飞的算法工程笔记 公众号

论文: You Only Need Less Attention at Each Stage in Vision Transformers

Introduction


近年来,计算机视觉经历了快速的增长和发展,主要得益于深度学习的进步以及大规模数据集的可获得性。在杰出的深度学习技术中,卷积神经网络(
Convolutional Neural Networks
,
CNNs
)被证明特别有效,在包括图像分类、目标检测和语义分割等广泛应用中展现了卓越的性能。

受到
Transformer
在自然语言处理领域巨大成功的启发,
ViT

Vision Transformers
)将每幅图像划分为一组标记。这些标记随后被编码以生成一个注意力矩阵,作为自注意力机制的基础组成部分。自注意力机制的计算复杂度随着标记数量的增加而呈平方增长,且随着图像分辨率的提高,计算负担变得更加沉重。一些研究人员尝试通过动态选择或标记修剪来减少标记冗余,以减轻注意力计算的计算负担。这些方法在性能上已证明与标准
ViT
相当。然而,涉及标记减少和修剪的方法需要对标记选择模块进行细致设计,可能导致关键标记的意外丢失。在本研究中,作者探索了不同的方向,并重新思考自注意力的机制。发现在注意力饱和问题中,随着
ViTs
层数的逐渐加深,注意力矩阵往往保持大部分不变,重复前面层中观察到的权重分配。考虑到这些因素,作者提出以下问题:

在网络的每个阶段,从开始到结束,是否真的有必要始终一致地应用自注意力机制?

在本文中,作者提出通过引入少注意力
ViT

Less-Attention Vision Transformer
)来修改标准
ViT
的基本架构。框架由原始注意力(
Vanilla Attention
,
VA
)层和少注意力(
Less Attention
,
LA
)层组成,以捕捉长范围的关系。在每个阶段,专门计算传统的自注意力,并将注意力分数存储在几个初始的原始注意力(
VA
)层中。在后续的层中,通过利用先前计算的注意力矩阵高效地生成注意力分数,从而减轻与自注意力机制相关的平方计算开销。此外,在跨阶段的降采样过程中,在注意力层内集成了残差连接,允许保留在早期阶段学习到的重要语义信息,同时通过替代路径传输全局上下文信息。最后,作者仔细设计了一种新颖的损失函数,从而在变换过程中保持注意力矩阵的对角性。这些关键组件使作者提出的
ViT
模型能够减少计算复杂性和注意力饱和,从而实现显著的性能提升,同时降低每秒浮点运算次数(
FLOPs
)和显著的吞吐量。

为验证作者提出的方法的有效性,在各种基准数据集上进行了全面的实验,将模型的性能与现有最先进的
ViT
变种(以及最近的高效
ViT
)进行了比较。实验结果表明,作者的方法在解决注意力饱和并在视觉识别任务中取得优越性能方面非常有效。

论文的主要贡献总结如下:

  1. 提出了一种新颖的
    ViT
    架构,通过重新参数化前面层计算的注意力矩阵生成注意力分数,这种方法同时解决了注意力饱和和相关的计算负担。

  2. 此外,提出了一种新颖的损失函数,旨在在注意力重新参数化的过程中保持注意力矩阵的对角性。作者认为这一点对维护注意力的语义完整性至关重要,确保注意力矩阵准确反映输入标记之间的相对重要性。

  3. 论文的架构在包括分类、检测和分割在内的多个视觉任务中,始终表现优异,同时在计算复杂度和内存消耗方面具有类似甚至更低的特点,胜过几种最先进的
    ViTs

Methodology


Vision Transformer


\(\mathbf{x} \in \mathbb{R}^{H \times W \times C}\)
表示一个输入图像,其中
\(H \times W\)
表示空间分辨率,
\(C\)
表示通道数。首先通过将图像划分为 $N = \frac{HW}{p^{2}} $ 个块来对图像进行分块,其中每个块
\(P_i \in \mathbb{R}^{p \times p \times C}\left(i \in \{1, \ldots, N\} \right)\)
的大小为
\(p \times p\)
像素和
\(C\)
通道。块大小
\(p\)
是一个超参数,用于确定标记的粒度。块嵌入可以通过使用步幅和卷积核大小均等于块大小的卷积操作提取。然后,每个块通过不重叠的卷积投影到嵌入空间
\(\boldsymbol{Z} \in \mathbb{R}^{N\times{D}}\)
,其中
\(D\)
表示每个块的维度。

  • Multi-Head Self-Attention

首先提供一个关于处理块嵌入的经典自注意力机制的简要概述,该机制在多头自注意力块(
MHSAs
)的框架内工作。在第
\(l\)

MHSA
块中,输入
\(\boldsymbol{Z}_{l-1}, l \in \{1,\cdots, L\}\)
被投影为三个可学习的嵌入
\(\{\mathbf{Q,K,V}\} \in \mathbb{R}^{N \times D}\)
。多头注意力旨在从不同的视角捕捉注意力;为简单起见,选择
\(H\)
个头,每个头都是一个维度为
\(N \times \frac{D}{H}\)
的矩阵。第
\(h\)
个头的注意力矩阵
\(\mathbf{A}_h\)
可以通过以下方式计算:

\[\begin{align}
\mathbf{A}_h =
\mathrm{Softmax} \left(\frac{\mathbf{Q}_h \mathbf{K}_h^\mathsf{T}}{\sqrt{d}} \right) \in \mathbb{R}^{N \times N}.
\label{eq:attn}
\end{align}
\]

\(\mathbf{A}_h, \mathbf{Q}_h\)

\(\mathbf{K}_h\)
分别是第
\(h\)
个头的注意力矩阵、查询和键。还将值
\(\mathbf{V}\)
分割成
\(H\)
个头。为了避免由于概率分布的锐性导致的梯度消失,将
\(\mathbf{Q}_h\)

\(\mathbf{K}_h\)
的内积除以
\(\sqrt{d}\)
(
\(d = D/H\)
)。注意力矩阵被拼接为:

\[\begin{equation}
\begin{split}
\mathbf{A} &= \textrm{Concat}(\mathbf{A}_1, \cdots, \mathbf{A}_h, \cdots,\mathbf{A}_H); \\
\mathbf{V} &= \textrm{Concat}(\mathbf{V}_1, \cdots, \mathbf{V}_h, \cdots,\mathbf{V}_H).
\end{split}
\label{eq:concat}
\end{equation}
\]

在空间分割的标记之间计算的注意力,可能会引导模型关注视觉数据中最有价值的标记。随后,将加权线性聚合应用于相应的值
\(\mathbf{V}\)

\[\begin{align}
\boldsymbol{Z}^{\textrm{MHSA}} = \mathbf{AV} \in \mathbb{R}^{N \times D}.
\label{eq:val-feats}
\end{align}
\]

  • Downsampling Operation

受到
CNN
中层次架构成功的启发,一些研究将层次结构引入到
ViTs
中。这些工作将
Transformer
块划分为
\(M\)
个阶段,并在每个
Transformer
阶段之前应用下采样操作,从而减少序列长度。在论文的研究中,作者采用了一个卷积层进行下采样操作,卷积核的大小和步幅都设置为
\(2\)
。该方法允许在每个阶段灵活调整特征图的尺度,从而建立一个与人类视觉系统的组织相一致的
Transformer
层次结构。

The Less-Attention Framework

整体框架如图
1
所示。在每个阶段,分两步提取特征表示。在最初的几个
Vanilla Attention
(
VA
) 层中,进行标准的多头自注意力(
MHSA
)操作,以捕捉整体的长距离依赖关系。随后,通过对存储的注意力分数应用线性变换,模拟注意力矩阵,以减少平方计算并解决接下来的低注意力(
LA
)层中的注意力饱和问题。在这里,将第
\(m\)
个阶段的初始
\(l\)
-th VA 层的
\(\textrm{Softmax}\)
函数之前的注意力分数表示为
\(\mathbf{A}^{\text{VA},l}_m\)
,它是通过以下标准程序计算的:

\[\begin{equation}
\mathbf{A}^{\text{VA},l}_m = \frac{\mathbf{Q}^l_m(\mathbf{K}^l_m)^\mathsf{T}}{\sqrt{d}}, ~~ l \leq L^{\text{VA}}_m.
\label{eq:init}
\end{equation}
\]

这里,
\(\mathbf{Q}_m^l\)

\(\mathbf{K}_m^l\)
分别表示来自第
\(m\)
个阶段第
\(l\)
层的查询和键,遵循来自前一阶段的下采样。而
\(L^{\text{VA}}_m\)
用于表示
VA
层的数量。在最初的原始注意力阶段之后,丢弃传统的平方
MHSA
,并对
\(\mathbf{A}^\textrm{VA}_m\)
应用变换,以减少注意力计算的数量。这个过程包括进行两次线性变换,中间夹一个矩阵转置操作。为了说明,对于该阶段的第
\(l\)
层(
\(l > L^{\text{VA}}_m\)
,即
LA
层)的注意力矩阵:

\[\begin{equation}
\begin{aligned}
&\mathbf{A}^{l}_m = \Psi(\Theta(\mathbf{A}^{l-1}_m)^\mathsf{T})^\mathsf{T}, ~~ L^{\text{VA}}_m<l \leq L_m,\\
&\mathbf{Z}^{\text{LA},l} = \textrm{Softmax}(\mathbf{A}^l_m)\mathbf{V}^l.
\end{aligned}
\end{equation}
\]

在这个上下文中,
\(\Psi\)

\(\Theta\)
表示维度为
\(\mathbb{R}^{N\times{N}}\)
的线性变换层。这里,
\(L_m\)

\(L_m^{\text{VA}}\)
分别表示第
\(m\)
个阶段的层数和
VA
层的数量。在这两个线性层之间插入转置操作的目的是保持矩阵的相似性行为。这个步骤是必需的,因为单层中的线性变换是逐行进行的,这可能导致对角特性丧失。

Residual-based Attention Downsampling

当计算在分层
ViT

ViTs
)中跨阶段进行时,通常会对特征图进行下采样操作。虽然该技术减少了标记数量,但可能会导致重要上下文信息的丧失。因此,论文认为来自前一阶段学习的注意力亲和度对于当前阶段在捕捉更复杂的全局关系方面可能是有利的。受到
ResNet
的启发,后者引入了快捷连接以减轻特征饱和问题,作者在架构的下采样注意力计算中采用了类似的概念。通过引入一个短路连接,可以将固有的偏差引入当前的多头自注意力(
MHSA
)块。这使得前一阶段的注意力矩阵能够有效引导当前阶段的注意力计算,从而保留重要的上下文信息。

然而,直接将短路连接应用于注意力矩阵可能在这种情况下面临挑战,主要是由于当前阶段和前一阶段之间注意力维度的不同。为此,作者设计了一个注意力残差(
AR
)模块,该模块由深度卷积(
DWConv
)和一个
\(\textrm{Conv}_{1\times1}\)
层构成,用以在保持语义信息的同时对前一阶段的注意力图进行下采样。将前一阶段(第
\(m-1\)
阶段)的最后一个注意力矩阵(在
\(L_{m-1}\)
层)表示为
\(\textbf{A}_{m-1}^{\text{last}}\)
,将当前阶段(第
\(m\)
阶段)的下采样初始注意力矩阵表示为
\(\textbf{A}_m^\text{init}\)

\(\textbf{A}_{m-1}^{\text{last}}\)
的维度为
\(\mathbb{R}^{B\times{H}\times{N_{m-1}}\times{N_{m-1}}}\)

\(N_{m-1}\)
表示第
\(m-1\)
阶段的标记数量)。将多头维度
\(H\)
视为常规图像空间中的通道维度,因此通过
\(\textrm{DWConv}\)
操作符(
\(\textrm{stride}=2,\ \textrm{kernel size}=2\)
),可以在注意力下采样过程中捕获标记之间的空间依赖关系。经过
\(\textrm{DWConv}\)
变换后的输出矩阵适合当前阶段的注意力矩阵的尺寸,即
\(\mathbb{R}^{B\times{H}\times{N_m}\times{N_m}} (N_m = \frac{N_{m-1}}{2})\)
。在对注意力矩阵进行深度卷积后,再执行
\(\text{Conv}_{1\times1}\)
,以便在不同头之间交换信息。

论文的注意力下采样过程如图
2
所示,从
\(\textbf{A}_{m-1}^\text{last}\)

\(\textbf{A}_{m}^\text{init}\)
的变换可以表示为:

\[\begin{align}
\textbf{A}^\textrm{init}_m &= \textrm{Conv}_{1\times1}\left(\textrm{Norm}(\textrm{DWConv}(\textbf{A}^\textrm{last}_{m-1}))\right), \label{eq:residual}
\\
\mathbf{A}^{\text{VA}}_m &\gets \mathbf{A}^{\text{VA}}_m + \textrm{LS}(\textbf{A}^\textrm{init}_m) \label{eq:plus},
\end{align}
\]

其中
\(\textrm{LS}\)
是在
CaiT
中引入的层缩放操作符,用以缓解注意力饱和现象。
\(\mathbf{A}^{\text{VA}}_m\)
是第
\(m\)
阶段第一层的注意力得分,它是通过将标准多头自注意力(
MHSA
)与公式
4
和由公式
6
计算的残差相加得出的。

论文的注意力下采样模块受两个基本设计原则的指导。首先,利用
\(\text{DWConv}\)
在下采样过程中捕获空间局部关系,从而实现对注意力关系的高效压缩。其次,采用
\(\textrm{Conv}_{1\times1}\)
操作在不同头之间交换注意力信息。这一设计至关重要,因为它促进了注意力从前一阶段有效传播到后续阶段。引入残差注意力机制只需进行少量调整,通常只需在现有的
ViT
主干中添加几行代码。值得强调的是,这项技术可以无缝应用于各种版本的
Transformer
架构。唯一的前提是存储来自上一层的注意力得分,并相应地建立到该层的跳跃连接。通过综合的消融研究,该模块的重要性将得到进一步阐明。

Diagonality Preserving Loss

作者通过融入注意力变换算子,精心设计了
Transformer
模块,旨在减轻计算成本和注意力饱和的问题。然而,仍然存在一个紧迫的挑战——确保变换后的注意力保留跨
Token
之间的关系。众所周知,对注意力矩阵应用变换可能会妨碍其捕捉相似性的能力,这在很大程度上是因为线性变换以行的方式处理注意力矩阵。因此,作者设计了一种替代方法,以确保变换后的注意力矩阵保留传达
Token
之间关联所需的基本属性。一个常规的注意力矩阵应该具备以下两个属性,即对角性和对称性:

\[\begin{equation}
\begin{aligned}[b]
\mathbf{A}_{ij} &= \mathbf{A}_{ji}, \\
\mathbf{A}_{ii} &> \mathbf{A}_{ij}, \forall j \neq i.
\end{aligned}
\label{eq:property}
\end{equation}
\]

因此,设计了第
\(l\)
层的对角性保持损失,以保持这两个基本属性如下所示:

\[\begin{equation}
\begin{split}
{\mathcal{L}_{\textrm{DP},l}} &= \sum_{i=1}^N\sum_{j=1}^N\left|\mathbf{A}_{ij} -\mathbf{A}_{ji}\right| \\
&+ \sum_{i=1}^N((N-1)\mathbf{A}_{ii}-\sum_{j\neq i}\mathbf{A}_{j}).
\end{split}
\end{equation}
\]

在这里,
\(\mathcal{L}_\textrm{DP}\)
是对角性保持损失,旨在维护公式
8
中注意力矩阵的属性。在所有变换层上将对角性保持损失与普通的交叉熵 (
CE
) 损失相结合,因此训练中的总损失可以表示为:

\[\begin{equation}
\begin{aligned}[b]
\mathcal{L}_\textrm{total} &= \mathcal{L}_\textrm{CE} + \sum_{m=1}^M\sum_{l=1}^{L_m}\mathcal{L}_{\textrm{DP},l}, \\
\mathcal{L}_\textrm{CE} &= \textrm{cross-entropy}(Z_\texttt{Cls}, y),
\end{aligned}
\end{equation}
\]

其中,
\(Z_\texttt{Cls}\)
是最后一层表示中的分类标记。

Complexity Analysis

论文的架构由四个阶段组成,每个阶段包含
\(L_m\)
层。下采样层应用于每个连续阶段之间。因此,传统自注意力的计算复杂度为
\(\mathcal{O}(N_m^2{D})\)
,而相关的
K-Q-V
转换则带来了
\(\mathcal{O}(3N_mD^2)\)
的复杂度。相比之下,论文的方法在变换层内利用了
\(N_m\times N_m\)
的线性变换,从而避免了计算内积的需要。因此,变换层中注意力机制的计算复杂度降至
\(\mathcal{O}(N_m^2)\)
,实现了
\(D\)
的减少因子。此外,由于论文的方法在
Less-Attention
中只计算查询嵌入,因此
K-Q-V
转换复杂度也减少了
3
倍。

在连续阶段之间的下采样层中,以下采样率
2
为例,注意力下采样层中
DWConv
的计算复杂度可以计算为
\(\textrm{Complexity} = 2 \times 2 \times \frac{N_m}{2} \times \frac{N_m}{2} \times D = \mathcal{O}(N_m^2D)\)
。同样,注意力残差模块中
\(\textrm{Conv}_{1\times1}\)
操作的复杂度也是
\(\mathcal{O}(N_m^2D)\)
。然而,重要的是,注意力下采样在每个阶段仅发生一次。因此,对比
Less-Attention
方法所实现的复杂度减少,这些操作引入的额外复杂度可以忽略不计。

Experiments




如果本文对你有帮助,麻烦点个赞或在看呗~
更多内容请关注 微信公众号【晓飞的算法工程笔记】

work-life balance.

原创文章,欢迎转载,转载请注明出处,谢谢。


0. 前言

函数是 Go 的一级公民,本文从汇编角度出发看看我们常用的一些函数在干什么。

1. 函数

1.1 main 函数

在 main 函数中计算两数之和如下:

package main

func main() {
	x, y := 1, 2
	z := x + y
	print(z)
}

使用
dlv
调试函数(不了解
dlv
的请看
Go plan9 汇编: 打通应用到底层的任督二脉
):

# dlv debug
Type 'help' for list of commands.
(dlv) b main.main
Breakpoint 1 set at 0x45feca for main.main() ./ex4.go:3
(dlv) c
> main.main() ./ex4.go:3 (hits goroutine(1):1 total:1) (PC: 0x45feca)
     1: package main
     2:
=>   3: func main() {
     4:         x, y := 1, 2
     5:         z := x + y
     6:         print(z)
     7: }

disass
查看对应的汇编指令:

(dlv) 
TEXT main.main(SB) /root/go/src/foundation/ex4/ex4.go
        ex4.go:3        0x45fec0        493b6610                cmp rsp, qword ptr [r14+0x10]
        ex4.go:3        0x45fec4        763d                    jbe 0x45ff03
        ex4.go:3        0x45fec6        55                      push rbp
        ex4.go:3        0x45fec7        4889e5                  mov rbp, rsp
=>      ex4.go:3        0x45feca*       4883ec20                sub rsp, 0x20
        ex4.go:4        0x45fece        48c744241801000000      mov qword ptr [rsp+0x18], 0x1
        ex4.go:4        0x45fed7        48c744241002000000      mov qword ptr [rsp+0x10], 0x2
        ex4.go:5        0x45fee0        48c744240803000000      mov qword ptr [rsp+0x8], 0x3
        ex4.go:6        0x45fee9        e8d249fdff              call $runtime.printlock
        ex4.go:6        0x45feee        488b442408              mov rax, qword ptr [rsp+0x8]
        ex4.go:6        0x45fef3        e86850fdff              call $runtime.printint
        ex4.go:6        0x45fef8        e8234afdff              call $runtime.printunlock
        ex4.go:7        0x45fefd        4883c420                add rsp, 0x20
        ex4.go:7        0x45ff01        5d                      pop rbp
        ex4.go:7        0x45ff02        c3                      ret
        ex4.go:3        0x45ff03        e8d8cdffff              call $runtime.morestack_noctxt
        ex4.go:3        0x45ff08        ebb6                    jmp $main.main
(dlv) regs
    Rsp = 0x000000c00003e758

相信看过
Go plan9 汇编: 打通应用到底层的任督二脉
的同学对上述汇编指令已经有一定了解的。

这里进入 main 函数,执行到
sub rsp, 0x20
指令,该指令为 main 函数开辟 0x20 字节的内存空间。继续往下执行,分别将
0x1

0x2

0x3
放到
[rsp+0x18]

[rsp+0x10]

[rsp+0x8]
处(从汇编指令好像没看到
z := x + y
的加法,合理怀疑是编译器做了优化)。

继续,
mov rax, qword ptr [rsp+0x8]

[rsp+0x8]
地址的值
0x3
放到
rax
寄存器中。然后,调用
call $runtime.printint
打印
rax
的值。实现输出两数之后。后续的指令我们就跳过了,不在赘述。

1.2 函数调用

在 main 函数中实现两数之和,我们没办法看到函数调用的过程。
接下来,定义 sum 函数实现两数之和,在 main 函数中调用 sum。重点看函数在调用时做了什么。

示例如下:

package main

func main() {
	a, b := 1, 2
	println(sum(a, b))
}

func sum(x, y int) int {
	z := x + y
	return z
}

使用
dlv
调试函数:

# dlv debug
Type 'help' for list of commands.
(dlv) b main.main
Breakpoint 1 set at 0x45feca for main.main() ./ex6.go:3
(dlv) c
> main.main() ./ex6.go:3 (hits goroutine(1):1 total:1) (PC: 0x45feca)
     1: package main
     2:
=>   3: func main() {
     4:         a, b := 1, 2
     5:         println(sum(a, b))
     6: }
     7:
     8: func sum(x, y int) int {
(dlv) disass
Sending output to pager...
TEXT main.main(SB) /root/go/src/foundation/ex6/ex6.go
        ex6.go:3        0x45fec0        493b6610                cmp rsp, qword ptr [r14+0x10]
        ex6.go:3        0x45fec4        764f                    jbe 0x45ff15
        ex6.go:3        0x45fec6        55                      push rbp
        ex6.go:3        0x45fec7        4889e5                  mov rbp, rsp
=>      ex6.go:3        0x45feca*       4883ec28                sub rsp, 0x28
        ex6.go:4        0x45fece        48c744241801000000      mov qword ptr [rsp+0x18], 0x1
        ex6.go:4        0x45fed7        48c744241002000000      mov qword ptr [rsp+0x10], 0x2
        ex6.go:5        0x45fee0        b801000000              mov eax, 0x1
        ex6.go:5        0x45fee5        bb02000000              mov ebx, 0x2
        ex6.go:5        0x45feea        e831000000              call $main.sum
        ex6.go:5        0x45feef        4889442420              mov qword ptr [rsp+0x20], rax
        ex6.go:5        0x45fef4        e8c749fdff              call $runtime.printlock
        ex6.go:5        0x45fef9        488b442420              mov rax, qword ptr [rsp+0x20]

regs
查看寄存器状态:

(dlv) regs
    Rip = 0x000000000045feca
    Rsp = 0x000000c00003e758
    Rbp = 0x000000c00003e758
    ...

继续往下分析指令的执行过程:

  1. sub rsp, 0x28
    :
    rsp
    的内存地址减
    0x28
    ,意味着 main 函数开辟
    0x28
    字节的栈空间。
  2. mov qword ptr [rsp+0x18], 0x1

    mov qword ptr [rsp+0x10], 0x2
    :将
    0x1

    0x2
    分别放到内存地址
    [rsp+0x18]

    [rsp+0x10]
    中。
  3. mov eax, 0x1

    mov ebx, 0x2
    :将
    0x1

    0x2
    分别放到寄存器
    eax

    ebx
    中。

跳转到
0x45feea
指令:

(dlv) b *0x45feea
Breakpoint 2 set at 0x45feea for main.main() ./ex6.go:5
(dlv) c
> main.main() ./ex6.go:5 (hits goroutine(1):1 total:1) (PC: 0x45feea)
     1: package main
     2:
     3: func main() {
     4:         a, b := 1, 2
=>   5:         println(sum(a, b))
     6: }
     7:
     8: func sum(x, y int) int {
     9:         z := x + y
    10:         return z
(dlv) disass
Sending output to pager...
TEXT main.main(SB) /root/go/src/foundation/ex6/ex6.go
        ex6.go:3        0x45fec0        493b6610                cmp rsp, qword ptr [r14+0x10]
        ex6.go:3        0x45fec4        764f                    jbe 0x45ff15
        ex6.go:3        0x45fec6        55                      push rbp
        ex6.go:3        0x45fec7        4889e5                  mov rbp, rsp
        ex6.go:3        0x45feca*       4883ec28                sub rsp, 0x28
        ex6.go:4        0x45fece        48c744241801000000      mov qword ptr [rsp+0x18], 0x1
        ex6.go:4        0x45fed7        48c744241002000000      mov qword ptr [rsp+0x10], 0x2
        ex6.go:5        0x45fee0        b801000000              mov eax, 0x1
        ex6.go:5        0x45fee5        bb02000000              mov ebx, 0x2
=>      ex6.go:5        0x45feea*       e831000000              call $main.sum
        ex6.go:5        0x45feef        4889442420              mov qword ptr [rsp+0x20], rax
        ex6.go:5        0x45fef4        e8c749fdff              call $runtime.printlock
        ex6.go:5        0x45fef9        488b442420              mov rax, qword ptr [rsp+0x20]
        ex6.go:5        0x45fefe        6690                    data16 nop

在执行
call $main.sum
前,让我们先看下内存分布:

image

(绿色部分表示 main 函数栈)

继续执行
call $main.sum
:

(dlv) si
> main.sum() ./ex6.go:8 (PC: 0x45ff20)
TEXT main.sum(SB) /root/go/src/foundation/ex6/ex6.go
=>      ex6.go:8        0x45ff20        55                      push rbp
        ex6.go:8        0x45ff21        4889e5                  mov rbp, rsp
        ex6.go:8        0x45ff24        4883ec10                sub rsp, 0x10
        ex6.go:8        0x45ff28        4889442420              mov qword ptr [rsp+0x20], rax
        ex6.go:8        0x45ff2d        48895c2428              mov qword ptr [rsp+0x28], rbx
        ex6.go:8        0x45ff32        48c7042400000000        mov qword ptr [rsp], 0x0
        ex6.go:9        0x45ff3a        4801d8                  add rax, rbx
        ex6.go:9        0x45ff3d        4889442408              mov qword ptr [rsp+0x8], rax
        ex6.go:10       0x45ff42        48890424                mov qword ptr [rsp], rax
        ex6.go:10       0x45ff46*       4883c410                add rsp, 0x10
        ex6.go:10       0x45ff4a        5d                      pop rbp
        ex6.go:10       0x45ff4b        c3                      ret
(dlv) regs
    Rip = 0x000000000045ff20
    Rsp = 0x000000c00003e728
    Rbp = 0x000000c00003e758

可以看到,Rsp 寄存器往下减 8 个字节,压栈开辟 8 个字节空间。继续往下分析指令:

  1. push rbp
    :将
    rbp
    寄存器的值压栈,rbp 中存储的是地址
    0x000000c00003e758
    。由于进行了压栈操作,这里的
    Rsp
    会往下减 8 个字节。
  2. mov rbp, rsp
    :将当前 rsp 的值给
    rbp

    rbp

    sum
    函数栈的栈底。
  3. sub rsp, 0x10

    rsp
    往下减
    0X10
    个字节,开辟16 个字节的空间,做为
    sum
    的函数栈,此时
    rsp
    的地址为
    0x000000c00003e710
    ,表示函数栈的栈顶。

执行到这里,我们画出内存分布图如下:

image

继续往下分析:

  1. mov qword ptr [rsp+0x20], rax

    mov qword ptr [rsp+0x28], rbx
    :分别将
    rax
    寄存器的值 1 放到
    [rsp+0x20]:0x000000c00003e730

    rbx
    寄存器的值 2 放到
    [rsp+0x28]:0x000000c00003e738
  2. mov qword ptr [rsp], 0x0
    :将 0 放到
    [rsp]
    中。
  3. add rax, rbx
    :将 rax 和 rbx 的值相加,结果放到 rax 中,相加后 rax 中的值为 3。
  4. mov qword ptr [rsp+0x8], rax
    :将 3 放到
    [rsp+0x8]
    中。
  5. mov qword ptr [rsp], rax
    :将 3 放到
    [rsp]
    中。

根据上述分析,画出内存分布图如下:

image

可以看出,传给 sum 的形参 x 和 y 实际是在 main 函数栈分配的。

继续往下执行:

  1. add rsp, 0x10

    rsp
    寄存器加
    0x10
    回收
    sum
    栈空间。
  2. pop rbp
    :将存储在
    0x000000c00003e720
    的值
    0x000000c00003e758
    移到
    rbp
    中。
  3. ret

    sum
    函数返回。

在执行
ret
指令前最后看下寄存器的状态:

(dlv) regs
    Rip = 0x000000000045ff4b
    Rsp = 0x000000c00003e728
    Rbp = 0x000000c00003e758

我们知道
Rip
寄存器存储的是运行指令所在的内存地址,那么问题就来了,当函数返回时,要执行调用函数的下一条指令:

TEXT main.sum(SB) /root/go/src/foundation/ex6/ex6.go
        ex6.go:5        0x45feea*       e831000000              call $main.sum
        ex6.go:5        0x45feef        4889442420              mov qword ptr [rsp+0x20], rax

这里我们需要
main.sum
返回后执行的下一条指令是
mov qword ptr [rsp+0x20], rax
。可是
Rip
指令怎么获得指令所在的地址
0x45feef
呢?

答案在
call $main.sum
这里,这条指令会将下一条指令压栈,在
sum
函数调用
ret
返回时,将之前压栈的指令移到
Rip
寄存器中。这个压栈的内存地址是
0x000000c00003e728
,查看其中的内容:

(dlv) print *(*int)(uintptr(0x000000c00003e728))
4587247

4587247
的十六进制就是
0x45feef

执行
ret

(dlv) si
> main.main() ./ex6.go:5 (PC: 0x45feef)
        ex6.go:4        0x45fece        48c744241801000000      mov qword ptr [rsp+0x18], 0x1
        ex6.go:4        0x45fed7        48c744241002000000      mov qword ptr [rsp+0x10], 0x2
        ex6.go:5        0x45fee0        b801000000              mov eax, 0x1
        ex6.go:5        0x45fee5        bb02000000              mov ebx, 0x2
        ex6.go:5        0x45feea*       e831000000              call $main.sum
=>      ex6.go:5        0x45feef        4889442420              mov qword ptr [rsp+0x20], rax
        ex6.go:5        0x45fef4        e8c749fdff              call $runtime.printlock
        ex6.go:5        0x45fef9        488b442420              mov rax, qword ptr [rsp+0x20]
        ex6.go:5        0x45fefe        6690                    data16 nop
        ex6.go:5        0x45ff00        e85b50fdff              call $runtime.printint
        ex6.go:5        0x45ff05        e8f64bfdff              call $runtime.printnl
(dlv) regs
    Rip = 0x000000000045feef
    Rsp = 0x000000c00003e730
    Rbp = 0x000000c00003e758

可以看到
Rip
指向了下一条指令的位置。

继续往下执行:

  1. mov qword ptr [rsp+0x20], rax
    :将 3 放到
    [rsp+0x20]
    中,
    [rsp+0x20]
    就是存放
    sum
    函数返回值的内存地址。
  2. call $runtime.printint
    :调用
    runtime.printint
    打印返回值 3。

分析完上述调用函数的过程我们可以画出函数栈调用的完整内存分布如下:

image

2. 小结

本文从汇编角度看函数调用的过程,力图做到对函数调用有个比较通透的了解。


前言

在笔者 Java 后端开发的项目经历中,MySQL 和 MongoDB 都有使用过作为后端的数据库来对业务数据进行持久化,两者没有孰优孰劣之分,都可以在合适的场景下发挥出它们的优势。

今天要分享的是一个项目重构过程中如何将数据库选型由原来的 MongoDB 改为 MySQL 的思考,涉及到业务当前的痛点、选型分析、解决的核心思路,最后会给出简单的 demo。

本篇文章侧重在于两者在表设计思维上的转换,而业务数据迁移同步的方案,下一篇文章将给出。


一、痛点所在

该项目是一个【PC端管理后台】+【移动端h5页面】为主业务框架的系统,原来的预期是:在后台配置好活动所需的参数,h5 既可以放在 app 客户端打开,也可以作为url 链接的形式直接在浏览器打开。项目一期的时候,业务方认为这样的运营活动会带来不少的流量和用户。但是到后来业务重心有所调整,引流的方式发生变化,最终导致了项目的一个重构。

主要的原因有以下几点:

  1. 总体的数据量没有预想的那么大

    活动参与人数前期预估为30w+,经历过2个线上活动后的实际总参与人数为5w+,客户端注册用户数为3w+,占全部参与人数的65%左右,远不及预期规模;

  2. 核心接口的并发也没有预想的高

    h5 端的大约 5-8 个的核心接口在实际线上活动进行的最高 QPS 只达到 200-300 左右,CPU 与 内存占用率也未达到设置的告警线(60%);

  3. MySQL 在硬件资源成本上性价比更高

    以阿里云的 RDS for MySQL 与 云数据库 MongoDB 做对比,都是集群部署 + 8核16GB + 100GB 存储 + 1年时长的规格下,前者会比后者便宜7w+RMB;

  4. MySQL 的动态数据源切换方案更成熟

    当时后端的项目已经被全部要求接入多租户改造,市面上开源的、成熟的动态数据源切换方案并不多,而完全专门支持 MongoDB 的是少之又少。

综合以上几点原因,完全放弃该项目是没必要的,但也需要适应当前业务的变化和成本控制,预计花费30人/天,即 2 个后端开发在 2-3 周内完成对该系统的重构,接口和前端页面基本无需调整。


二、选型分析

这里就正式进入技术部分了,首要对比的是两者各自的特点以及适用的场景,这对于把握整个项目的走向是至为关键的。

2.1特点对比

表2-1
对比项 MySQL MongoDB
数据模型 关系型数据库,采用表格(table)的形式存储数据,每一行是一条记录 非关系型(NoSQL)、文档型数据库,数据以文档(document)的非结构化形式存储
查询方式 使用标准的 SQL 进行查询,提供了丰富的查询条件、连接(join)、排序、分页等功能 使用基于 JSON 结构特点的的查询语句,支持大量数据的聚合、统计、分析
事务支持 支持 ACID 事务,确保在多条操作组成的事务中数据的一致性和可靠性。特别是在InnoDB引擎中,提供了完整的事务支持 4.0 版本开始引入了多文档事务支持,可以保证在一定范围内的读写操作具备ACID特性。但对于需要严格事务特性的复杂业务场景不及 MySQL 成熟
数据处理 在处理复杂查询和高并发写入时,需要依赖索引来优化性能,或者通过分区、分片等手段进行水平扩展 在水平扩展和实时数据处理方面优势很大,通过分片(sharding)技术可以轻松应对海量数据存储和高并发读写
空间占用 由于数据结构紧凑,对数据的存储通常更为节省空间,特别是对于简单数据结构和关系清晰的数据集 由于文档存储的灵活性和包含元数据等因素,通常占用空间较大
项目集成 已经有成熟的第三方 ORM 框架支持,如:Mybatis、Mybatis Plus、io.mybatis、tk.mybatis等 目前集成在 Spring Boot 项目里的增删改查都是基于 MongoRepository 和 MongoTemplate 来实现的

2.2场景对比

  • MySQL
    1. Web 应用程序:如常见的 xx 管理后台、xx 管理系统,电商 web 网站,包括一些移动端 h5 的页面等;
    2. 企业级应用:如常见的客户关系管理系统(CRM)、人力资源管理系统(HRM)和供应链管理系统(SCM)等,MySQL 提供了强大的事务支持;
    3. 嵌入式开发:需要轻量级数据库的软件、硬件和设备,MySQL 可以作为一个嵌入式数据库引擎集成到各种应用程序中,提高应用程序的可移植性;
    4. 云计算和大数据:MySQL 在云数据库服务中被广泛使用,支持云原生应用程序和分布式数据处理框架,如 Hadoop 和 Spark 等。
  • MongoDB
    1. 处理实时数据:非常适合处理移动互联网应用常见的大部分场景,如用户活动、社交互动、在线购物等;
    2. 内容管理系统(CMS):用于处理文章、稿件、评论、图片、视频等富媒体内容的存储和增删改查,支持全文搜索和实时更新;
    3. 数据聚合仓库:存储原始或半处理的业务数据,利用聚合框架进行实时数据聚合、统计分析和数据可视化;
    4. 游戏数据管理:存储玩家账户信息、游戏进度、成就、虚拟物品、社交关系等,快速计算和更新游戏排行榜数据,支持实时查询等。


三、核心思路

我们知道,在 MongoDB 中,一条数据的记录(文档)格式是 json 的 格式,即强调 key-value 的关系。

表2-2

对于一个 MongoDB 的文档来说,里面可以包含很多这个集合的属性,就像一篇文章里面有很多章节一样。

以下面这个图2-1为例子,activity 是一个完整的集合,里面包含了很多属性,id、name、status等基本属性,还有 button 和 share 等额外属性,这些属性共同构成了这个集合。

但这样的结构在 MySQL 里是不能实现的,理由很简单,MySQL 强调关系,1:1 和 1:N 是十分常见的关系。
可以看到,下面将基本属性放在 activity 作为主表,而其它额外属性分别放在了 button 表和 share 表里,同时将主表的主键 id 作为了关联表的 ac_id 外键。

图2-1

那要怎么替换才能实现呢?MongoDB 改成 MySQL 的核心在于:原有的集合关系以及嵌套关系,需要拆表成1 : N 的范式关系,用主键-外键的方式做关联查询,同时避免 join 连接查询。


四、demo 示例

下面首先分别给出实际的表设计与实体映射,包括 MongoDB 和 MySQL 的,然后再通过简单的查询代码来体现两者的区别。

4.1实体映射

4.1.1MongoDB 实体
@EqualsAndHashCode(callSuper = true)
@Data
public class Activity extends BaseEntity {
    @Id
    private String id;
    private String name;
    private ActivityStatusEnum status;
    private ReviewStatusEnum review;
    private ActivityTypeEnum type;
    private ActivityButton button;
    private ActivityShare share;
}
4.1.2MySQL 实体
@Data
public class Activity extends BaseEntity {
    @Id
    private Integer id;
    private String name;
    private Integer status;
    private Integer review;
    private Integer type;
}
@Data
public class ActivityButton extends BaseEntity {
    @Id
    private Integer id;
    private Integer acId;
    private String signUp;
    private Integer status;
    private String desc;
}
@Data
public class ActivityShare extends BaseEntity {
    @Id
    private String id;
    private Integer acId;
    private String title;
    private String iconUrl;
}

4.2查询代码

下面就根据主键 id 和状态这两个条件进行活动详情的查询。

4.2.1MongoDB 查询
    /**
     * @apiNote 通过主键id和活动状态查询活动
     * @param id 主键id
     * @return 实体
     */
    @Override
    public Avtivity getDetailById(String id) {
        return this.repository.findById(id)
                .filter(val -> ActivityStatusEnum.ON.equals(val.getStatus()))
                .orElseThrow(() -> new RuntimeException("该活动不存在!"));
    }
4.2.2MySQL 查询
    @Resource
    private ActivityShareService activityShareService;
    @Resource
    private ActivityButtonService activityButtonService;
    @Override
    public ActivityVO detail(Integer id) {
        ExampleWrapper<Activity, Serializable> wrapper = this.wrapper();
        wrapper.eq(Activity::getid, id)
                .eq(Activity::getStatus(), DataStatusEnum.NORMAL.getCode());
        Activity activity = Optional.ofNullable(this.findOne(wrapper.example()))
            .orElseThrow(() -> new RuntimeException("该活动不存在!"));
        ActivityVO vo = new ActivityVO();
        vo.setName(Optional.ofNullable(activity.getName()).orElse(StringUtils.EMPTY));
        //查两个关联表
        vo.setShare(this.activityShareService.getShare(activity.getId()));
        vo.setButton(this.activityButtonService.getButton(activity.getId()));
        return vo;
    }


五、文章小结

使用 MySQL 替换 MongoDB 的小结如下:

  1. 做技术选型时要充分考虑对比两者的特点以及应用场景,选择最合适的
  2. 如非必要,那么还是继续沿用原来的设计;一旦选择重构,那么就要考虑成本
  3. 原有的集合关系以及嵌套关系,需要拆表成1 : N 的范式关系,用主键-外键的方式做关联

最后,如有不足和错误,还请大家指正。或者你有其它想说的,也欢迎大家在评论区交流!

开心一刻

清明节那天,看到一小孩在路边烧纸
时不时地偷偷往火堆里扔几张考试卷子
边烧边念叨:爷爷呀,你岁数大了,在那边多做做题吧,对脑子好,要是有不懂的地方,就把我老师带走,让他教您!

开心一刻

前提说明

假设
MySQL 5.7.36
的库
qsl_datax

mysql5

有表
qsl_datax_source
和 数据

CREATE TABLE `qsl_datax_source`  (
  `id` bigint(20) NOT NULL COMMENT '自增主键',
  `username` varchar(255) NOT NULL COMMENT '姓名',
  `password` varchar(255) NOT NULL COMMENT '密码',
  `birth_day` date NOT NULL COMMENT '出生日期',
  `remark` text,
  PRIMARY KEY (`id`)
) ENGINE = InnoDB ;
INSERT INTO `qsl_datax_source` VALUES (1, '张三', 'z123456', '1991-01-01', '张三');
INSERT INTO `qsl_datax_source` VALUES (2, '李四', 'l123456', '1992-01-01', '李四');
INSERT INTO `qsl_datax_source` VALUES (3, '王五', 'w123456', '1993-01-01', '王五');
INSERT INTO `qsl_datax_source` VALUES (4, '麻子', 'm123456', '1994-01-01', '麻子');

需要将表中数据同步到
MySQL 8.0.30

mysql8

sql_db
库的
qsl_datax_source
表中,并且只用
JDBC
的方式,该如何实现?你们可能觉得非常简单,直接引入
mysql-connector-j
依赖

<dependency>
    <groupId>com.mysql</groupId>
    <artifactId>mysql-connector-j</artifactId>
    <version>8.0.33</version>
</dependency>

然后直接写同步代码

public static void main(String[] args) throws Exception {
    String url5 = "jdbc:mysql://192.168.2.118:3307/qsl_datax?useUnicode=true&useSSL=false&characterEncoding=utf8&serverTimezone=Asia/Shanghai";
    String url8 = "jdbc:mysql://192.168.2.118:3311/sql_db?useUnicode=true&useSSL=false&characterEncoding=utf8&serverTimezone=Asia/Shanghai";
    Properties pro = new Properties();
    pro.put("user", "root");
    pro.put("password", "123456");
    // 加载驱动类
    Class.forName("com.mysql.cj.jdbc.Driver");
    // 建立连接
    Connection conn5 = DriverManager.getConnection(url5, pro);
    // 查数据
    Statement statement = conn5.createStatement();
    ResultSet resultSet = statement.executeQuery("SELECT * FROM qsl_datax_source");
    StringBuilder insertSql = new StringBuilder("INSERT INTO qsl_datax_source(id,username,password,birth_day,remark) VALUES ");
    while (resultSet.next()) {
        // 拼接sql
        insertSql.append("(")
                .append(resultSet.getLong("id")).append(",")
                .append("'").append(resultSet.getString("username")).append("',")
                .append("'").append(resultSet.getString("password")).append("',")
                .append("'").append(resultSet.getString("birth_day")).append("',")
                .append("'").append(resultSet.getString("remark")).append("'")
                .append("),");
    }
    // 因为mysql5和mysql8的账密是一样的,所以用的同一个 pro
    Connection conn8 = DriverManager.getConnection(url8, pro);
    Statement stmt = conn8.createStatement();
    int count = stmt.executeUpdate(insertSql.substring(0, insertSql.length() - 1));
    System.out.println("新插入记录数:" + count);

    resultSet.close();
    statement.close();
    stmt.close();
    conn5.close();
    conn8.close();
}

执行后输出

新插入记录数:4


MySQL 8.0.30
的库
sql_db
查看表
qsl_datax_source
的数据

同驱动同步成功

同步完成,这不是有手就行吗?

行不行

一般来说,高版本的驱动会兼容低版本的数据库,但也不绝对,或者说兼容不全;MySQL版本、驱动版本、JDK版本对应关系如下

mysql版本驱动版本jdk版本对应关系

mysql-connector-j 8.0.33 驱动兼容 MySQL 5.7.36,所以上面的同步没问题,但如果 MySQL 版本很低(比如:5.1.x),例如从
MySQL 5.1.8
同步到
MySQL 8.0.30
,如上同步代码还能同步成功吗(我就不去试了,你们也别去试了,因为引申目的已经达到了),所以保险做法是

mysql-connector-j 8.0.33 操作 MySQL 8.0.30

mysql-connector-java 5.1.49 操作 MySQL 5.7.36

mysql-connector-java 5.0.x 操作 MySQL 5.0.x

所以问题就来了

如何用 mysql-connector-java 5.1.49 从 MySQL 5.7.36 查数据后,用 mysql-connector-j 8.0.33 将数据插入 MySQL 8.0.30

多驱动操作

你们肯定也觉得简单,继续引入
mysql-connector-java 5.1.49
依赖

<dependency>
    <groupId>com.mysql</groupId>
    <artifactId>mysql-connector-j</artifactId>
    <version>8.0.33</version>
</dependency>
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <version>5.1.49</version>
</dependency>

然后调整代码

public static void main(String[] args) throws Exception {
    String url5 = "jdbc:mysql://192.168.2.118:3307/qsl_datax?useUnicode=true&useSSL=false&characterEncoding=utf8&serverTimezone=Asia/Shanghai";
    String url8 = "jdbc:mysql://192.168.2.118:3311/sql_db?useUnicode=true&useSSL=false&characterEncoding=utf8&serverTimezone=Asia/Shanghai";
    Properties pro = new Properties();
    pro.put("user", "root");
    pro.put("password", "123456");
    // 加载驱动类
    Class.forName("com.mysql.jdbc.Driver");
    // 建立连接
    Connection conn5 = DriverManager.getConnection(url5, pro);
    // 查数据
    Statement statement = conn5.createStatement();
    ResultSet resultSet = statement.executeQuery("SELECT * FROM qsl_datax_source");
    StringBuilder insertSql = new StringBuilder("INSERT INTO qsl_datax_source(id,username,password,birth_day,remark) VALUES ");
    while (resultSet.next()) {
        // 拼接sql
        insertSql.append("(")
                .append(resultSet.getLong("id")).append(",")
                .append("'").append(resultSet.getString("username")).append("',")
                .append("'").append(resultSet.getString("password")).append("',")
                .append("'").append(resultSet.getString("birth_day")).append("',")
                .append("'").append(resultSet.getString("remark")).append("'")
                .append("),");
    }
    Class.forName("com.mysql.cj.jdbc.Driver");
    // 因为mysql5和mysql8的账密是一样的,所以用的同一个 pro
    Connection conn8 = DriverManager.getConnection(url8, pro);
    Statement stmt = conn8.createStatement();
    int count = stmt.executeUpdate(insertSql.substring(0, insertSql.length() - 1));
    System.out.println("新插入记录数:" + count);

    resultSet.close();
    statement.close();
    stmt.close();
    conn5.close();
    conn8.close();
}

和之前代码对比下

多驱动使用前后代码比较

调整甚微;执行后输出结果如下

Loading class `com.mysql.jdbc.Driver'. This is deprecated. The new driver class is `com.mysql.cj.jdbc.Driver'. The driver is automatically registered via the SPI and manual loading of the driver class is generally unnecessary.
新插入记录数:4

如果只从结果来看,确实同步成功了,但第一行的
警告
值得得我们琢磨下

类 com.mysql.jdbc.Driver 加载中。这个类已经被弃用。新的驱动类是 com.mysql.cj.jdbc.Driver,这个驱动通过 SPI 机制已经自动注册了,不需要手动加载

从中我们会产生 2 个疑问

  1. com.mysql.jdbc.Driver 不应该是
    mysql-connector-java 5.1.49
    的吗,怎么会被弃用
  2. SPI 机制是什么,
    com.mysql.cj.jdbc.Driver
    什么时候加载的

我们先来看问题 2,关于 SPI 机制可查看

记一次 JDK SPI 配置不生效的问题 → 这么简单都不会,还是回家养猪吧

DriverManager
有静态代码块

static {
    loadInitialDrivers();
    println("JDBC DriverManager initialized");
}

loadInitialDrivers()
中有这样一段代码

loadInitialDrivers

自动加载了驱动,而驱动类中往往有类似如下代码

驱动类注册驱动实例

将驱动实例注册给
DriverManager
,所以不需要再去手动加载驱动类了

从 JDBC 4.0 开始,JDBC 驱动支持自动加载功能,不再需要调用 Class.forName 来加载驱动

我们回到问题 1,同步的告警信息

Loading class `com.mysql.jdbc.Driver'. This is deprecated. The new driver class is `com.mysql.cj.jdbc.Driver'. The driver is automatically registered via the SPI and manual loading of the driver class is generally unnecessary.

肯定是
mysql-connector-j 8.0.33
告警出来的,因为
mysql-connector-java 5.1.49
没有类

com.mysql.cj.jdbc.Driver

对不对?全局搜索下

This is deprecated. The new driver class is `com.mysql.cj.jdbc.Driver'

同步告警信息出处

点进去,我们会发现
mysql-connector-j 8.0.33
也有类

com.mysql.jdbc.Driver

大家看仔细了,这个 Driver 是没有把自己的实例注册进
java.sql.DriverManager

mysql8驱动com_mysql_jdbc_Driver类

这说明什么,说明是从
mysql-connector-j 8.0.33
加载的类:
com.mysql.jdbc.Driver
,而不是从
mysql-connector-java 5.1.49
加载

我们来捋一捋整个同步流程

  1. 通过 SPI 机制,会加载文件
    META-INF/services/java.sql.Driver
    中配置的类

    mysql-connector-j 8.0.33 的 java.sql.Driver 文件内容


    mysql8驱动java_sql_Driver

    mysql-connector-java 5.1.49 的 java.sql.Driver 文件内容


    mysql5驱动_java_sql_Driver

    类加载器加载
    com.mysql.cj.jdbc.Driver
    的时候,毫无疑问找到的肯定是 mysql-connector-j 8.0.33 jar包中的,而加载
    com.mysql.jdbc.Driver
    的时候,类加载器找到的却是 mysql-connector-j 8.0.33 jar包中的,而非 mysql-connector-java 5.1.49 jar包中的,所以告警了

  2. 代码中手动调用
    Class.forName("com.mysql.jdbc.Driver");
    进行类加载,根据
    双亲委派模型
    ,已经加载过的类不会再加载,所以相当于没做任何操作

    前面的告警信息不是这里触发出来的!!!不信的话可以注释掉该行代码执行下,你们会发现仍有同样的告警信息

  3. 从 MySQL5 查数据,用的驱动实际是
    com.mysql.cj.jdbc.Driver


    连接mysql5的实际驱动

    因为 DriverManager 中合适的驱动只有这一个

  4. 代码中手动调用
    Class.forName("com.mysql.cj.jdbc.Driver");
    进行类加载,根据
    双亲委派模型
    ,已经加载过的类不会再加载,所以相当于没做任何操作

  5. 从 MySQL8 查数据,用的驱动毫无疑问也只能是
    com.mysql.cj.jdbc.Driver

所以整个同步,用的都是 mysql-connector-j 8.0.33 下的驱动,mysql-connector-java 5.1.49 压根就没用到,是不是在你们的意料之外?

小孩 震惊

所以如何实现我们最初的想法?

如何用 mysql-connector-java 5.1.49 从 MySQL 5.7.36 查数据后,用 mysql-connector-j 8.0.33 将数据插入 MySQL 8.0.30

maven-shade-plugin

甲方扔给两个存在包名与类名均相同的Jar包,要在工程中同时使用怎么办?
中谈到了好些解决办法,但 maven-shade-plugin 相对而言是最优解,其具体使用可参考

maven 插件之 maven-shade-plugin,解决同包同名 class 共存问题的神器

那如何应该到当前案例中来了,其实很简单,只需要用到 maven-shade-plugin 的
重定位 class
功能即可,请看我表演

  1. 对 mysql-connector-j 8.0.33 进行 class 重定位

    新建一个工程
    mysql-jdbc
    ,没有任何代码和配置文件


    mysql-jdbc8

    只有一个
    pom.xml

    <?xml version="1.0" encoding="UTF-8"?>
    <project xmlns="http://maven.apache.org/POM/4.0.0"
             xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
             xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
        <modelVersion>4.0.0</modelVersion>
    
        <groupId>com.qsl</groupId>
        <artifactId>mysql-jdbc8</artifactId>
        <version>8.0.33</version>
    
        <properties>
            <maven.compiler.source>8</maven.compiler.source>
            <maven.compiler.target>8</maven.compiler.target>
            <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        </properties>
    
        <dependencies>
            <dependency>
                <groupId>com.mysql</groupId>
                <artifactId>mysql-connector-j</artifactId>
                <version>8.0.33</version>
            </dependency>
        </dependencies>
    
        <build>
            <plugins>
                <plugin>
                    <groupId>org.apache.maven.plugins</groupId>
                    <artifactId>maven-shade-plugin</artifactId>
                    <version>3.6.0</version>
                    <executions>
                        <execution>
                            <!-- 和 package 阶段绑定 -->
                            <phase>package</phase>
                            <goals>
                                <goal>shade</goal>
                            </goals>
                            <configuration>
                                <relocations>
                                    <relocation>
                                        <pattern>com.mysql.jdbc</pattern>
                                        <shadedPattern>com.mysql.jdbc8</shadedPattern>
                                    </relocation>
                                </relocations>
                                <filters>
                                    <filter>
                                        <artifact>com.qsl:mysql-jdbc8</artifact>
                                        <excludes>
                                            <exclude>META-INF/*.*</exclude>
                                        </excludes>
                                    </filter>
                                </filters>
                            </configuration>
                        </execution>
                    </executions>
                </plugin>
            </plugins>
        </build>
    </project>
    

    mvn install
    一下,将重新打包后的 jar 部署到本地仓库


    mysql-jdbc8_8_0_30
  2. 调整示例代码的 maven 依赖

    mysql-connector-j 8.0.33 调整成 mysql-jdbc8 8.0.33,mysql-connector-java 5.1.49 原样保留

    <dependencies>
        <dependency>
            <groupId>com.qsl</groupId>
            <artifactId>mysql-jdbc8</artifactId>
            <version>8.0.33</version>
        </dependency>
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>5.1.49</version>
        </dependency>
    </dependencies>
    
  3. 调整同步代码

    去掉手动加载驱动,增加 connection 驱动信息版本输出

    public static void main(String[] args) throws Exception {
        String url5 = "jdbc:mysql://192.168.2.118:3307/qsl_datax?useUnicode=true&useSSL=false&characterEncoding=utf8&serverTimezone=Asia/Shanghai";
        String url8 = "jdbc:mysql://192.168.2.118:3311/sql_db?useUnicode=true&useSSL=false&characterEncoding=utf8&serverTimezone=Asia/Shanghai";
        Properties pro = new Properties();
        pro.put("user", "root");
        pro.put("password", "123456");
        // 建立连接
        Connection conn5 = DriverManager.getConnection(url5, pro);
        // 查数据
        Statement statement = conn5.createStatement();
        System.out.println("conn5 driver version: " + conn5.getMetaData().getDriverVersion());
        ResultSet resultSet = statement.executeQuery("SELECT * FROM qsl_datax_source");
        StringBuilder insertSql = new StringBuilder("INSERT INTO qsl_datax_source(id,username,password,birth_day,remark) VALUES ");
        while (resultSet.next()) {
            // 拼接sql
            insertSql.append("(")
                    .append(resultSet.getLong("id")).append(",")
                    .append("'").append(resultSet.getString("username")).append("',")
                    .append("'").append(resultSet.getString("password")).append("',")
                    .append("'").append(resultSet.getString("birth_day")).append("',")
                    .append("'").append(resultSet.getString("remark")).append("'")
                    .append("),");
        }
        // 因为mysql5和mysql8的账密是一样的,所以用的同一个 pro
        Connection conn8 = DriverManager.getConnection(url8, pro);
        System.out.println("conn8 driver version: " + conn8.getMetaData().getDriverVersion());
        Statement stmt = conn8.createStatement();
        int count = stmt.executeUpdate(insertSql.substring(0, insertSql.length() - 1));
        System.out.println("新插入记录数:" + count);
    
        resultSet.close();
        statement.close();
        stmt.close();
        conn5.close();
        conn8.close();
    }
    

处理就算完成,我们执行一下看结果

maven-shade-plugin改造后执行结果

之前的警告确实没了,但新的问题又来了:为什么驱动用的是同一个,mysql-connector-java 5.1.49 中的驱动为什么没有被用到?

一个bug改一天

mysql-connector-java 5.1.49 中的 com.mysql.jdbc.Driver 肯定是被正常加载了,并且注册到了 DriverManager 中,这点大家认同不?(不认同也没关系,后面会得到证明)那它为什么没有被使用了,我们需要跟一下
DriverManager.getConnection
的源码了;源码跟进去比较简单,我就带你们一步一步跟了,最终回来到如下方法

java.sql.DriverManager#getConnection(java.lang.String, java.util.Properties, java.lang.Class<?>)

这个方法里面有这么一段代码

for(DriverInfo aDriver : registeredDrivers) {
    // If the caller does not have permission to load the driver then
    // skip it.
    if(isDriverAllowed(aDriver.driver, callerCL)) {
        try {
            println("    trying " + aDriver.driver.getClass().getName());
            Connection con = aDriver.driver.connect(url, info);
            if (con != null) {
                // Success!
                println("getConnection returning " + aDriver.driver.getClass().getName());
                return (con);
            }
        } catch (SQLException ex) {
            if (reason == null) {
                reason = ex;
            }
        }

    } else {
        println("    skipping: " + aDriver.getClass().getName());
    }

}

我们打个断点跟一下(com.mysql.cj.jdbc.Driver 排在 com.mysql.jdbc.Driver 前面!!!)

debug_驱动列表

isDriverAllowed
作用是检查一个给定的
Driver
对象是否被允许通过指定的
ClassLoader
加载,我们不需要关注,而我们需要关注的是

Connection con = aDriver.driver.connect(url, info);

跟进去来到
com.mysql.cj.jdbc.NonRegisteringDriver#connect

debug_connect

感兴趣的可以继续跟进
ConnectionUrl.acceptsUrl(url)
,但我觉得没必要了,很明显就是根据正则表达式去匹配 url,看看是否适配,因为 MySQL5 的 url 与 MySQL8 的 URL 格式一致

String url5 = "jdbc:mysql://192.168.2.118:3307/qsl_datax?useUnicode=true&useSSL=false&characterEncoding=utf8&serverTimezone=Asia/Shanghai";
String url8 = "jdbc:mysql://192.168.2.118:3311/sql_db?useUnicode=true&useSSL=false&characterEncoding=utf8&serverTimezone=Asia/Shanghai";

因为 com.mysql.cj.jdbc.Driver 排在 com.mysql.jdbc.Driver 前面,所以用它连接了 MySQL5 和 MySQL8,前面的问题

为什么驱动用的是同一个,mysql-connector-java 5.1.49 中的驱动为什么没有被用到?

是不是就清楚了?你们可能又有疑问了:为什么不是 com.mysql.jdbc.Driver 排在前面?这个跟类加载的顺序有关,超出了本文范围,你们自行去查阅。那还能实现最初的目的吗

用 mysql-connector-java 5.1.49 从 MySQL 5.7.36 查数据后,用 mysql-connector-j 8.0.33 将数据插入 MySQL 8.0.30

肯定是能的,看我调整下代码

public static void main(String[] args) throws Exception {
    String url5 = "jdbc:mysql://192.168.2.118:3307/qsl_datax?useUnicode=true&useSSL=false&characterEncoding=utf8&serverTimezone=Asia/Shanghai";
    String url8 = "jdbc:mysql://192.168.2.118:3311/sql_db?useUnicode=true&useSSL=false&characterEncoding=utf8&serverTimezone=Asia/Shanghai";
    Properties pro = new Properties();
    pro.put("user", "root");
    pro.put("password", "123456");

    // 建立连接
    Driver driver5 = getDriver("com.mysql.jdbc.Driver");
    Connection conn5 = driver5.connect(url5, pro);
    // 查数据
    Statement statement = conn5.createStatement();
    System.out.println("conn5 driver version: " + conn5.getMetaData().getDriverVersion());
    ResultSet resultSet = statement.executeQuery("SELECT * FROM qsl_datax_source");
    StringBuilder insertSql = new StringBuilder("INSERT INTO qsl_datax_source(id,username,password,birth_day,remark) VALUES ");
    while (resultSet.next()) {
        // 拼接sql
        insertSql.append("(")
                .append(resultSet.getLong("id")).append(",")
                .append("'").append(resultSet.getString("username")).append("',")
                .append("'").append(resultSet.getString("password")).append("',")
                .append("'").append(resultSet.getString("birth_day")).append("',")
                .append("'").append(resultSet.getString("remark")).append("'")
                .append("),");
    }
    // 因为mysql5和mysql8的账密是一样的,所以用的同一个 pro
    Driver driver8 = getDriver("com.mysql.cj.jdbc.Driver");
    Connection conn8 = driver8.connect(url8, pro);
    System.out.println("conn8 driver version: " + conn8.getMetaData().getDriverVersion());
    Statement stmt = conn8.createStatement();
    int count = stmt.executeUpdate(insertSql.substring(0, insertSql.length() - 1));
    System.out.println("新插入记录数:" + count);

    resultSet.close();
    statement.close();
    stmt.close();
    conn5.close();
    conn8.close();
}

private static Driver getDriver(String driverClassName) {
    Enumeration<Driver> drivers = DriverManager.getDrivers();
    while (drivers.hasMoreElements()) {
        Driver driver = drivers.nextElement();
        if (driver.getClass().getName().equals(driverClassName)) {
            return driver;
        }
    }
    throw new RuntimeException("未找到驱动:" + driverClassName);
}

执行一下看结果

改造成功_执行结果

此时我就想说一句:还有谁?

还有谁

总结

  1. 示例代码:
    mysql-driver-demo

    不包括 mysql-jdbc8 的代码

  2. 就 MySQL 而言,mysql-connector-j 8 驱动兼容 MySQL 5.5、5.6、5.7,实际工作中是可以用 mysql-connector-j 8 去连 MySQL 5.7的

  3. SQL Server 就存在驱动不兼容的情况

    Microsoft JDBC Driver for SQL Server 支持矩阵


    SQLServer驱动兼容情况
  4. maven-shade-plugin 来实现多版本驱动的共存,简单高效,值得掌握!

    maven 插件之 maven-shade-plugin,解决同包同名 class 共存问题的神器