数据库数据迁移方案

数据库数据迁移方案

更换数据库这个事儿,是一个非常大的技术挑战,因为我们需要保证整个迁移过程中,既不能长时间停服,也不能丢数据。

实际上,无论是新版本的程序,还是新的数据库,即使我们做了严格的验证测试,做了高可用方案,刚刚上线的系统,它的稳定性总是没有那么好的,需要一个磨合的过程,才能逐步达到一个稳定的状态,这是一个客观规律。这个过程中一旦出现故障,如果不能及时恢复,造成的损失往往是我们承担不起的。

所以我们在设计迁移方案的时候,一定要做到,每一步都是可逆的。要保证,每执行一个步骤后,一旦出现问题,能快速地回滚到上一个步骤。这是很多同学在设计这种升级类技术方案的时候,容易忽略的问题。

第一步-复制旧库数据

首先要做的就是,把旧库的数据复制到新库中。对于大规模数据可以使用自增字段(自增主键/创建时间)作为复制的区间,在业务低谷期分批复制数据到新库中。

第二步-同步数据

因为旧库还在服务线上业务,所以不断会有数据写入旧库,我们不仅要往新库复制数据,还要保证新旧两个库的数据是实时同步的。所以,我们需要用一个同步程序来实现新旧两个数据库实时同步。

我们可以使用 Binlog 实时同步数据。如果源库不是 MySQL 的话,就麻烦一点儿,但也可以参考复制状态机理论来实现。这一步不需要回滚,原因是,只增加了一个新库和一个同步程序,对系统的旧库和程序都没有任何改变。即使新上线的同步程序影响到了旧库,只要停掉同步程序就可以了。

第三步-双写

然后,我们需要改造一下业务,业务逻辑部分不需要变,DAO 层需要做如下改造:

  1. 支持双写新旧两个库,并且预留热切换开关,能通过开关控制三种写状态:只写旧库、只写新库和同步双写。
  2. 支持读新旧两个库,同样预留热切换开关,控制读旧库还是新库。

然后上线新版的业务服务,这个时候业务服务仍然是只读写旧库,不读写新库。让这个新版的服务需要稳定运行至少一到二周的时间,期间除了验证新版服务的稳定性以外,还要验证新旧两个库中的数据是否是一致的。这个过程中,如果新版服务有问题,可以立即下线新版服务,回滚到旧版本的服务。

稳定一段时间之后,就可以开启服务的双写开关了。开启双写开关的同时,需要停掉同步程序。这里面有一个问题需要注意一下,就是这个双写的业务逻辑,一定是先写旧库,再写新库,并且以写旧库的结果为准。旧库写成功,新库写失败,返回写成功,但这个时候要记录日志,后续我们会用到这个日志来验证新库是否还有问题。旧库写失败,直接返回失败,就不写新库了。这么做的原因是,不能让新库影响到现有业务的可用性和数据准确性。上面这个过程如果出现问题,可以关闭双写,回滚到只读写旧库的状态。

第四步-对比补偿

切换到双写之后,新库与旧库的数据可能会存在不一致的情况,原因有两个:一是停止同步程序和开启双写,这两个过程很难做到无缝衔接,二是双写的策略也不保证新旧库强一致,这时候我们需要上线一个对比和补偿的程序,这个程序对比旧库最近的数据变更,然后检查新库中的数据是否一致,如果不一致,还要进行补偿。

开启双写后,还需要至少稳定运行至少几周的时间,并且期间我们要不断地检查,确保不能有旧库写成功,新库写失败的情况出现。对比程序也没有发现新旧两个库的数据有不一致的情况,这个时候,我们就可以认为,新旧两个库的数据是一直保持同步的。

第五步-流量切换

接下来就可以用类似灰度发布的方式,把读请求一点儿一点儿地切到新库上。同样,期间如果出问题的话,可以再切回旧库。全部读请求都切换到新库上之后,这个时候其实读写请求就已经都切换到新库上了,实际的切换已经完成了,但还有后续的收尾步骤。

第六步-下线历史逻辑

再稳定一段时间之后,就可以停掉对比程序,把服务的写状态改为只写新库。到这里,旧库就可以下线了。注意,整个迁移过程中,只有这个步骤是不可逆的。但是,这步的主要操作就是摘掉已经不再使用的旧库,对于在用的新库并没有什么改变,实际出问题的可能性已经非常小了。

到这里,我们就完成了在线更换数据库的全部流程。双写版本的服务也就完成了它的历史使命,可以在下一次升级服务版本的时候,下线双写功能。

如何实现对比和补偿程序?

在上面的整个切换过程中,如何实现这个对比和补偿程序,是整个这个切换设计方案中的一个难点。这个对比和补偿程序的难度在于,我们要对比的是两个都在随时变换的数据库中的数据。这种情况下,我们没有类似复制状态机这样理论上严谨实际操作还很简单的方法,来实现对比和补偿。但还是可以根据业务数据的实际情况,来针对性地实现对比和补偿,经过一段时间,把新旧两个数据库的差异,逐渐收敛到一致。

像订单这类时效性强的数据,是比较好对比和补偿的。因为订单一旦完成之后,就几乎不会再变了,那我们的对比和补偿程序,就可以依据订单完成时间,每次只对比这个时间窗口内完成的订单。补偿的逻辑也很简单,发现不一致的情况后,直接用旧库的订单数据覆盖新库的订单数据就可以了。

这样,切换双写期间,少量不一致的订单数据,等到订单完成之后,会被补偿程序修正。后续只要不是双写的时候,新库频繁写入失败,就可以保证两个库的数据完全一致。

比较麻烦的是更一般的情况,比如像商品信息这类数据,随时都有可能会变化。如果说数据上有更新时间,那我们的对比程序可以利用这个更新时间,每次在旧库取一个更新时间窗口内的数据,去新库上找相同主键的数据进行对比,发现数据不一致,还要对比一下更新时间。如果新库数据的更新时间晚于旧库数据,那可能是对比期间数据发生了变化,这种情况暂时不要补偿,放到下个时间窗口去继续对比。另外,时间窗口的结束时间,不要选取当前时间,而是要比当前时间早一点儿,比如 1 分钟前,避免去对比正在写入的数据。如果数据连时间戳也没有,那只能去旧库读取 Binlog,获取数据变化,然后去新库对比和补偿。

有一点需要说明的是,上面这些方法,如果严格推敲,都不是百分之百严谨的,都不能保证在任何情况下,经过对比和补偿后,新库的数据和旧库就是完全一样的。但是,在大多数情况下,这些实践方法还是可以有效地收敛新旧两个库的数据差异,你可以酌情采用。

小结

设计在线切换数据库的技术方案,首先要保证安全性,确保每一个步骤一旦失败,都可以快速回滚。此外,还要确保迁移过程中不丢数据,这主要是依靠实时同步程序和对比补偿程序来实现。

我把这个复杂的切换过程的要点,按照顺序总结成下面这个列表:

  1. 上线同步程序,从旧库中复制数据到新库中,并实时保持同步;
  2. 上线双写服务,只读写旧库;
  3. 开启双写,同时停止同步程序;
  4. 开启对比和补偿程序,确保新旧数据库数据完全一样;
  5. 逐步切量读请求到新库上;
  6. 下线对比补偿程序,关闭双写,读写都切换到新库上;
  7. 下线旧库和服务的双写功能。
Hash冲突解决方式

Hash冲突解决方式

Hash冲突主要有两种解决办法,开放寻址法和链表法。这两种冲突解决办法在实际的软件开发中都非常常用。比如,Java 中 LinkedHashMap 就采用了链表法解决冲突,ThreadLocalMap 是通过线性探测的开放寻址法来解决冲突。那么这两种冲突解决方法各有什么优势和劣势,又各自适用哪些场景吗?

开放寻址法

我们先来看看,开放寻址法的优点有哪些。

开放寻址法不像链表法,需要拉很多链表。散列表中的数据都存储在数组中,可以有效地利用 CPU 缓存加快查询速度。而且,这种方法实现的散列表,序列化起来比较简单。链表法包含指针,序列化起来就没那么容易。你可不要小看序列化,很多场合都会用到的。我们后面就有一节会讲什么是数据结构序列化、如何序列化,以及为什么要序列化。

我们再来看下,开放寻址法有哪些缺点。

上一节我们讲到,用开放寻址法解决冲突的散列表,删除数据的时候比较麻烦,需要特殊标记已经删除掉的数据。而且,在开放寻址法中,所有的数据都存储在一个数组中,比起链表法来说,冲突的代价更高。所以,使用开放寻址法解决冲突的散列表,装载因子的上限不能太大。这也导致这种方法比链表法更浪费内存空间。

所以,我总结一下,当数据量比较小、装载因子小的时候,适合采用开放寻址法。这也是 Java 中的ThreadLocalMap使用开放寻址法解决散列冲突的原因。

链表法

首先,链表法对内存的利用率比开放寻址法要高。因为链表结点可以在需要的时候再创建,并不需要像开放寻址法那样事先申请好。实际上,这一点也是链表优于数组的地方。

链表法比起开放寻址法,对大装载因子的容忍度更高。开放寻址法只能适用装载因子小于 1 的情况。接近 1 时,就可能会有大量的散列冲突,导致大量的探测、再散列等,性能会下降很多。但是对于链表法来说,只要散列函数的值随机均匀,即便装载因子变成 10,也就是链表的长度变长了而已,虽然查找效率有所下降,但是比起顺序查找还是快很多。

链表因为要存储指针,所以对于比较小的对象的存储,是比较消耗内存的,还有可能会让内存的消耗翻倍。而且,因为链表中的结点是零散分布在内存中的,不是连续的,所以对 CPU 缓存是不友好的,这方面对于执行效率也有一定的影响。

当然,如果我们存储的是大对象,也就是说要存储的对象的大小远远大于一个指针的大小(4 个字节或者 8 个字节),那链表中指针的内存消耗在大对象面前就可以忽略了。

实际上,我们对链表法稍加改造,可以实现一个更加高效的散列表。那就是,我们将链表法中的链表改造为其他高效的动态数据结构,比如跳表、红黑树。这样,即便出现散列冲突,极端情况下,所有的数据都散列到同一个桶内,那最终退化成的散列表的查找时间也只不过是 O(logn)。这样也就有效避免了前面讲到的散列碰撞攻击。

所以,我总结一下,基于链表的散列冲突处理方法比较适合存储大对象、大数据量的散列表,而且,比起开放寻址法,它更加灵活,支持更多的优化策略,比如用红黑树代替链表。

使用Visual Studio Code阅读Switch Homebrew程序源码

使用Visual Studio Code阅读Switch Homebrew程序源码

Visual Studio Code是当之无愧的代码利器,我看重的正是它独到的代码提示和Intellisense功能,这能有效帮助我阅读和学习别人编写的源码。而破解后的Switch上可以安装格式为“nro”的自制程序,它们使用C语言编写,为了方便阅读其源代码,我对VS Code进行如下配置。

第一步:搭建编译环境

DevKitPro组织维护着为任天堂系列主机做开发的工具集,这使得下载开发Switch上的程序更为简便。在这里下载包管理工具dkp-pacman,然后安装下列包:

1
2
3
4
# 安装
sudo dkp-pacman -S switch-dev
# 更新
sudo dkp-pacman -Syu

使用Root权限安装,默认会装到/opt/devkitpro。

如果MacOS 版本为 Catalina 会报错:

1
2
3
error: Partition / is mounted read only
error: not enough free disk space
error: failed to commit transaction (not enough free disk space)

因为Catalina 的根分区是只读的,所以可以用下面的命令指定安装位置:

1
sudo dkp-pacman -S switch-dev -r /System/Volumes/Data

第二步:配置Code

在Code中打开Homebrew程序的源码目录,按Ctrl+Alt+P呼出命令面板,找到“C/C++: Edit configurations”,点击打开C/C++扩展的配置文件“c_cpp_properties.json”。

然后将以下路径添加到“includePath”列表中。添加过程注意遵循JSON语法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
{
"configurations": [
{
"name": "switch",
"includePath": [
"${workspaceFolder}/**",
"/opt/devkitpro/devkitA64/aarch64-none-elf/include/**",
"/opt/devkitpro/libnx/include/**",
"/opt/devkitpro/portlibs/switch/include/**"
],
"defines": [],
"macFrameworkPath": [
"/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/System/Library/Frameworks"
],
"compilerPath": "/opt/devkitpro/devkitA64/bin/aarch64-none-elf-gcc",
"cStandard": "c11",
"cppStandard": "c++17",
"intelliSenseMode": "clang-x64"
}
],
"version": 4
}

即刻生效。

第三步:测试

可以遵循以下的方法检验代码提示功能是否生效:

  • 将鼠标移到任意一个对象名上,可以清楚看到它的定义;
  • 按Ctrl键,将鼠标指针指向对象名或头文件名,它们会变成超链接,点进去即可跳转到它们的定义处。
排查docker oomkillded问题

排查docker oomkillded问题

年前docker oomkilled 问题一直在困扰我们项目组,大致现象为java堆Xmx配置了6G,但运行一段时间后常驻内存RSS从5G逐渐增长到8G容器阈值,最后报出om killed 之后重启。因为我们业务对内存需求不是很迫切,所以占用8G内存明显不合理,所以之后有了一场漫长的排查问题之旅。

基础中的基础-JVM内存模型

开始逐步对堆外内存进行排查,首先了解一下JVM内存模型。根据JVM规范,JVM运行时数据区共分为虚拟机栈、堆、方法区、程序计数器、本地方法栈五个部分。
内存模型

  • PC 寄存器,也叫程序计数器。可以看成是当前线程所执行的字节码的行号指示器。不是重点。

  • 虚拟机栈,描述Java方法执行的内存区域,它是线程私有的,栈帧在整个JVM体系中的地位颇高,包括局部变量表、操作栈、动态连接、方法返回地址等。当申请不到空间时,会抛出 OutOfMemoryError。

  • 本地方法栈,和虚拟机栈实现的功能与抛出异常几乎相同。

  • 堆内存。堆内存是 JVM 所有线程共享的部分,在虚拟机启动的时候就已经创建。所有的对象和数组都在堆上进行分配。这部分空间可通过 GC 进行回收。当申请不到空间时会抛出 OutOfMemoryError。

  • Metaspace(元空间)在JDK 1.8开始,方法区实现采用Metaspace代替,这些元数据信息直接使用本地内存来分配。元空间与永久代之间最大的区别在于:元空间不属于JVM使用的内存,而是使用(进程中的)直接内存。当申请不到空间时会抛出 OutOfMemoryError。

直接内存

java 8下是指除了Xmx设置的java堆外,java进程使用的其他内存。主要包括:DirectByteBuffer分配的内存,JNI里分配的内存,线程栈分配占用的系统内存,jvm本身运行过程分配的内存,codeCache,metaspace元数据空间。

JVM监控分析

2.png

可以看到重启前堆内存、栈内存、元空间、直接内存占用空间都没有异常,多数问题通过监控就能定位大致方向,可惜这次监控大法没有生效,怀疑是JVM问题转向JVM原生内存使用方向排查。

使用NMT排查JVM原生内存使用

Native Memory Tracking(NMT)使用

NMT是Java7U40引入的HotSpot新特性,可用于监控JVM原生内存的使用,但比较可惜的是,目前的NMT不能监控到JVM之外或原生库分配的内存。java进程启动时指定开启NMT(有一定的性能损耗),输出级别可以设置为“summary”或“detail”级别。如:

1
2
-XX:NativeMemoryTracking=summary 或者 
-XX:NativeMemoryTracking=detail

开启后,通过jcmd可以访问收集到的数据。

1
jcmd <pid> VM.native_memory [summary | detail | baseline | summary.diff | detail.diff 

如:jcmd 1 VM.native_memory,输出如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
Native Memory Tracking:

Total: reserved=12259645KB(保留内存), committed=11036265KB (提交内存)
堆内存使用情况,保留内存和提交内存和Xms、Xmx一致,都是8G。
- Java Heap (reserved=8388608KB, committed=8388608KB)
(mmap: reserved=8388608KB, committed=8388608KB)
用于存储类元数据信息使用到的原生内存,总共12045个类,整体实际使用了79M内存。
- Class (reserved=1119963KB, committed=79751KB)
(classes #12045)
(malloc=1755KB #29277)
(mmap: reserved=1118208KB, committed=77996KB)
总共2064个线程,提交内存是2.1G左右,一个线程1M,和设置Xss1m相符。
- Thread (reserved=2130294KB, committed=2130294KB)
(thread #2064)
(stack: reserved=2120764KB, committed=2120764KB)
(malloc=6824KB #10341)
(arena=2706KB #4127)
JIT的代码缓存,12045个类JIT编译后代码缓存整体使用79M内存。
- Code (reserved=263071KB, committed=79903KB)
(malloc=13471KB #15191)
(mmap: reserved=249600KB, committed=66432KB)
GC相关使用到的一些堆外内存,比如GC算法的处理锁会使用一些堆外空间。118M左右。
- GC (reserved=118432KB, committed=118432KB)
(malloc=93848KB #453)
(mmap: reserved=24584KB, committed=24584KB)
JAVA编译器自身操作使用到的一些堆外内存,很少。
- Compiler (reserved=975KB, committed=975KB)
(malloc=844KB #1074)
(arena=131KB #3)
Internal:memory used by the command line parser, JVMTI, properties等。
- Internal (reserved=117158KB, committed=117158KB)
(malloc=117126KB #44857)
(mmap: reserved=32KB, committed=32KB)
Symbol:保留字符串(Interned String)的引用与符号表引用放在这里,17M左右
- Symbol (reserved=17133KB, committed=17133KB)
(malloc=13354KB #145640)
(arena=3780KB #1)
NMT本身占用的堆外内存,4M左右
- Native Memory Tracking (reserved=4402KB, committed=4402KB)
(malloc=396KB #5287)
(tracking overhead=4006KB)
不知道啥,用的很少。
- Arena Chunk (reserved=272KB, committed=272KB)
(malloc=272KB)
其他未分类的堆外内存占用,100M左右。
- Unknown (reserved=99336KB, committed=99336KB)
(mmap: reserved=99336KB, committed=99336KB)
  • 保留内存(reserved):reserved memory 是指JVM 通过mmaped PROT_NONE 申请的虚拟地址空间,在页表中已经存在了记录(entries),保证了其他进程不会被占用,且保证了逻辑地址的连续性,能简化指针运算。
  • 提交内存(commited):committed memory 是JVM向操做系统实际分配的内存(malloc/mmap),mmaped PROT_READ | PROT_WRITE,仍然会page faults,但是跟 reserved 不同,完全内核处理像什么也没发生一样。

这里需要注意的是:由于malloc/mmap的lazy allocation and paging机制,即使是commited的内存,也不一定会真正分配物理内存。

malloc/mmap is lazy unless told otherwise. Pages are only backed by physical memory once they’re accessed.

Tips:由于内存是一直在缓慢增长,因此在使用NMT跟踪堆外内存时,一个比较好的办法是,先建立一个内存使用基线,一段时间后再用当时数据和基线进行差别比较,这样比较容易定位问题。

1
jcmd 1 VM.native_memory baseline

同时pmap看一下物理内存的分配,RSS占用了10G。

1
pmap -x 1 | sort -n -k3

3.jpg

运行一段时间后,做一下summary级别的diff,看下内存变化,同时再次pmap看下RSS增长情况。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
jcmd 1 VM.native_memory summary.diff
Native Memory Tracking:

Total: reserved=13089769KB +112323KB, committed=11877285KB +117915KB

- Java Heap (reserved=8388608KB, committed=8388608KB)
(mmap: reserved=8388608KB, committed=8388608KB)

- Class (reserved=1126527KB +2161KB, committed=85771KB +2033KB)
(classes #12682 +154)
(malloc=2175KB +113KB #37289 +2205)
(mmap: reserved=1124352KB +2048KB, committed=83596KB +1920KB)

- Thread (reserved=2861485KB +94989KB, committed=2861485KB +94989KB)
(thread #2772 +92)
(stack: reserved=2848588KB +94576KB, committed=2848588KB +94576KB)
(malloc=9169KB +305KB #13881 +460)
(arena=3728KB +108 #5543 +184)

- Code (reserved=265858KB +1146KB, committed=94130KB +6866KB)
(malloc=16258KB +1146KB #18187 +1146)
(mmap: reserved=249600KB, committed=77872KB +5720KB)

- GC (reserved=118433KB +1KB, committed=118433KB +1KB)
(malloc=93849KB +1KB #487 +24)
(mmap: reserved=24584KB, committed=24584KB)

- Compiler (reserved=1956KB +253KB, committed=1956KB +253KB)
(malloc=1826KB +253KB #2098 +271)
(arena=131KB #3)

- Internal (reserved=203932KB +13143KB, committed=203932KB +13143KB)
(malloc=203900KB +13143KB #62342 +3942)
(mmap: reserved=32KB, committed=32KB)

- Symbol (reserved=17820KB +108KB, committed=17820KB +108KB)
(malloc=13977KB +76KB #152204 +257)
(arena=3844KB +32 #1)

- Native Memory Tracking (reserved=5519KB +517KB, committed=5519KB +517KB)
(malloc=797KB +325KB #9992 +3789)
(tracking overhead=4722KB +192KB)

- Arena Chunk (reserved=294KB +5KB, committed=294KB +5KB)
(malloc=294KB +5KB)

- Unknown (reserved=99336KB, committed=99336KB)
(mmap: reserved=99336KB, committed=99336KB

4.jpg

发现这段时间pmap看到的RSS增长了3G多,但NMT观察到的内存增长了不到120M,还有大概2G多常驻内存不知去向,因此也基本排除了由于JVM自身管理的堆外内存的嫌疑。

gdb分析内存块内容

上面提到使用pmap来查看进程的内存映射,pmap命令实际是读取了/proc/pid/maps和/porc/pid/smaps文件来输出。发现一个细节,pmap取出的内存映射发现很多64M大小的内存块。这种内存块逐渐变多且占用的RSS常驻内存也逐渐增长到reserved保留内存大小,内存增长的2G多基本上也是由于这些64M的内存块导致的,因此看一下这些内存块里具体内容。

pmap -x 1看一下实际内存分配情况:
5.jpg

找一块内存块进行dump:

1
gdb --batch --pid 1 -ex "dump memory a.dump 0x7fd488000000 0x7fd488000000+56124000"

简单分析一下内容,发现绝大部分是乱码的二进制内容,看不出什么问题。
strings a.dump | less
或者: hexdump -C a.dump | less
或者: view a.dump

没啥思路的时候,随便搜了一下发现貌似很多人碰到这种64M内存块的问题,了解到glibc的内存分配策略在高版本有较大调整:

从glibc 2.11(为应用系统在多核心CPU和多Sockets环境中高伸缩性提供了一个动态内存分配的特性增强)版本开始引入了per thread arena内存池,Native Heap区被打散为sub-pools ,这部分内存池叫做Arena内存池。也就是说,以前只有一个main arena,目前是一个main arena(还是位于Native Heap区) + 多个per thread arena,多个线程之间不再共用一个arena内存区域了,保证每个线程都有一个堆,这样避免内存分配时需要额外的锁来降低性能。main arena主要通过brk/sbrk系统调用去管理,per thread arena主要通过mmap系统调用去分配和管理。

一个32位的应用程序进程,最大可创建 2 CPU总核数个arena内存池(MALLOC_ARENA_MAX),每个arena内存池大小为1MB,一个64位的应用程序进程,最大可创建 8 CPU总核数个arena内存池(MALLOC_ARENA_MAX),每个arena内存池大小为64MB

ptmalloc2内存分配和释放

当某一线程需要调用 malloc()分配内存空间时, 该线程先查看线程私有变量中是否已经存在一个分配区,如果存在, 尝试对该分配区加锁,如果加锁成功,使用该分配区分配内存,如果失败, 该线程搜索循环链表试图获得一个没有加锁的分配区。如果所有的分配区都已经加锁,那么 malloc()会开辟一个新的分配区,把该分配区加入到全局分配区循环链表并加锁,然后使用该分配区进行分配内存操作。在释放操作中,线程同样试图获得待释放内存块所在分配区的锁,如果该分配区正在被别的线程使用,则需要等待直到其他线程释放该分配区的互斥锁之后才可以进行释放操作。用户 free 掉的内存并不是都会马上归还给系统,ptmalloc2 会统一管理 heap 和 mmap 映射区域中的空闲的chunk,当用户进行下一次分配请求时, ptmalloc2 会首先试图在空闲的chunk 中挑选一块给用户,这样就避免了频繁的系统调用,降低了内存分配的开销。

ptmalloc2的内存收缩机制

业务层调用free方法释放内存时,ptmalloc2先判断 top chunk 的大小是否大于 mmap 收缩阈值(默认为 128KB),如果是的话,对于主分配区,则会试图归还 top chunk 中的一部分给操作系统。但是最先分配的 128KB 空间是不会归还的,ptmalloc 会一直管理这部分内存,用于响应用户的分配 请求;如果为非主分配区,会进行 sub-heap 收缩,将 top chunk 的一部分返回给操 作系统,如果 top chunk 为整个 sub-heap,会把整个 sub-heap 还回给操作系统。做 完这一步之后,释放结束,从 free() 函数退出。可以看出,收缩堆的条件是当前 free 的 chunk 大小加上前后能合并 chunk 的大小大于 64k,并且要 top chunk 的大 小要达到 mmap 收缩阈值,才有可能收缩堆。

ptmalloc2的mmap分配阈值动态调整

M_MMAP_THRESHOLD 用于设置 mmap 分配阈值,默认值为 128KB,ptmalloc 默认开启 动态调整 mmap 分配阈值和 mmap 收缩阈值。当用户需要分配的内存大于 mmap 分配阈值,ptmalloc 的 malloc()函数其实相当于 mmap() 的简单封装,free 函数相当于 munmap()的简单封装。相当于直接通过系统调用分配内存, 回收的内存就直接返回给操作系统了。因为这些大块内存不能被 ptmalloc 缓存管理,不能重用,所以 ptmalloc 也只有在万不得已的情况下才使用该方式分配内存。

如何优化解决
三种方案:

**第一种:*控制分配区的总数上限。默认64位系统分配区数为:cpu核数8,如当前环境40核系统分配区数为320个,每个64M上限的话最多可达20G,限制上限后,后续不够的申请会直接走mmap分配和munmap回收,不会进入ptmalloc2的buffer池。
所以第一种方案调整一下分配池上限个数到4:

1
export MALLOC_ARENA_MAX=4

**第二种:**之前降到ptmalloc2默认会动态调整mmap分配阈值,因此对于较大的内存请求也会进入ptmalloc2的内存buffer池里,这里可以去掉ptmalloc的动态调整功能。可以设置 M_TRIM_THRESHOLD,M_MMAP_THRESHOLD,M_TOP_PAD 和 M_MMAP_MAX 中的任意一个。这里可以固定分配阈值为128K,这样超过128K的内存分配请求都不会进入ptmalloc的buffer池而是直接走mmap分配和munmap回收(性能上会有损耗):

1
2
3
4
export MALLOC_MMAP_THRESHOLD_=131072
export MALLOC_TRIM_THRESHOLD_=131072
export MALLOC_TOP_PAD_=131072
export MALLOC_MMAP_MAX_=65536

**第三种:**使用tcmalloc来替代默认的ptmalloc2。google的tcmalloc提供更优的内存分配效率,性能更好,ThreadCache会阶段性的回收内存到CentralCache里。 解决了ptmalloc2中arena之间不能迁移导致内存浪费的问题。

总结收获

  • 定位问题,一定要了解问题的领域范围,在这次排查中,定位OOM问题领域顺序就是 jvm内存 -> jvm内部内存 -> 进程内存。

  • 操作系统知识不能丢,扎实的基础知识可以节省非常多百度的时间和推理问题的时间

  • 知识领域是相同的,比如这次的ptmalloc内存分配基本原理和metaspace内存分配、netty的内存分配原理非常相似

  • 当时排查问题时因为已经定位到是内存分配问题,所以没有留下问题排查中间过程的相关数据。最近偶然看到一篇博客的记录和我的经历极为相似,于是我参考博客和自己的排查经验整合了这篇排查问题记录。结果和过程都很重要,只有结果,没有过程容易招致他人的不理解,能被人理解也是一门学问~

IM系统基础-IM系统结构

IM系统基础-IM系统结构

从一个 IM 系统开发者的角度看,聊天系统大概由这几大部分组成:客户端、接入服务、业务处理服务、存储服务和外部接口服务。

im1.png

**客户端。**客户端一般是用户用于收发消息的终端设备,内置的客户端程序和服务端进行网络通信,用来承载用户的互动请求和消息接收功能。

**接入服务。**接入服务可以认为是服务端的门户,为客户端提供消息收发的出入口。发送的消息先由客户端通过网络给到接入服务,然后再由接入服务递交到业务层进行处理。

接入服务主要有四块功能:

  • 连接保持

    当服务端有消息需要推送给客户端时,也是将经过业务层处理的消息先递交给接入层,再由接入层通过网络发送到客户端。

  • 协议解析

    在很多基于私有通信协议的 IM 系统实现中,接入服务还提供协议的编解码工作,编解码实际主要是为了节省网络流量,系统会针对传输的内容进行紧凑的编码(比如 Protobuf),为了让业务处理时不需要关心这些业务无关的编解码工作,一般由接入层来处理。

  • Session 维护

    session 的作用是标识“哪个用户在哪个 TCP 连接”,用于后续的消息推送能够知道,如何找到接收人对应的连接来发送。

  • 消息推送

    接入服务还负责最终消息的推送执行,也就是通过网络连接把最终的消息从服务器传输送达到用户的设备上。

**业务处理服务。**业务处理服务是真正的消息业务逻辑处理层,比如消息的存储、未读数变更、更新最近联系人等,这些内容都是业务处理的范畴。

我们可以想象得到,业务处理服务是整个 IM 系统的中枢大脑,负责各种复杂业务逻辑的处理。

就好比你的信到达分拨中心后,分拨中心可能需要给接收人发条短信告知一下,或者分拨中心发现接收人告知过要拒绝接收这个发送者的任何信件,因此会在这里直接把信件退回给发信人。

**存储服务。**这个比较好理解,账号信息、关系链,以及消息本身,都需要进行持久化存储。

另外一般还会有一些用户消息相关的设置,也会进行服务端存储,比如:用户可以设置不接收某些人的消息。我们可以把它理解成辖区内所有人的通信地址簿,以及储存信件的仓库。

**外部接口服务。**由于手机操作系统的限制,以及资源优化的考虑,大部分 App 在进程关闭,或者长时间后台运行时,App 和 IM 服务端的连接会被手机操作系统断开。这样当有新的消息产生时,就没法通过 IM 服务再触达用户,因而会影响用户体验。

为了让用户在 App 未打开时,或者在后台运行时,也能接收到新消息,我们会将消息给到第三方外部接口服务,来通过手机操作系统自身的公共连接服务来进行操作系统级的“消息推送”,通过这种方式下发的消息一般会在手机的“通知栏”对用户进行提醒和展示。

这种最常用的第三方系统推送服务有苹果手机自带的 APNs(Apple Push Notification service)服务、安卓手机内置的谷歌公司的 GCM(Google Cloud Messaging)服务等。

但 GCM 服务在国内无法使用,为此很多国内手机厂商在各自手机系统中,也提供类似的公共系统推送服务,如小米、华为、OPPO、vivo 等手机厂商都有相应的 SDK 提供支持。

消息队列基础补充
消息队列基础概念

消息队列基础概念

消息队列提供的功能

  • 异步处理
  • 流量控制
  • 消息解耦

队列和主题的区别

最初的消息队列,就是一个严格意义上的队列。在计算机领域,“队列(Queue)”是一种数据结构,有完整而严格的定义。在维基百科中,队列的定义是这样的:

队列是先进先出(FIFO, First-In-First-Out)的线性表(Linear List)。在具体应用中通常用链表或者数组来实现。队列只允许在后端(称为 rear)进行插入操作,在前端(称为 front)进行删除操作。

**早期的消息队列,就是按照“队列”的数据结构来设计的。**我们一起看下这个图,生产者(Producer)发消息就是入队操作,消费者(Consumer)收消息就是出队也就是删除操作,服务端存放消息的容器自然就称为“队列”。

这就是最初的一种消息模型:队列模型。
queue.jpeg

如果需要将一份消息数据分发给多个消费者,要求每个消费者都能收到全量的消息,例如,对于一份订单数据,风控系统、分析系统、支付系统等都需要接收消息。这个时候,单个队列就满足不了需求,一个可行的解决方式是,为每个消费者创建一个单独的队列,让生产者发送多份。

为了解决这个问题,演化出了另外一种消息模型:“发布 - 订阅模型(Publish-Subscribe Pattern)”。

queue2.jpeg

在发布 - 订阅模型中,消息的发送方称为发布者(Publisher),消息的接收方称为订阅者(Subscriber),服务端存放消息的容器称为主题(Topic)。发布者将消息发送到主题中,订阅者在接收消息之前需要先“订阅主题”。“订阅”在这里既是一个动作,同时还可以认为是主题在消费时的一个逻辑副本,每份订阅中,订阅者都可以接收到主题的所有消息。

保障消息队列不丢失

其实,现在主流的消息队列产品都提供了非常完善的消息可靠性保证机制,完全可以做到在消息传递过程中,即使发生网络中断或者硬件故障,也能确保消息的可靠传递,不丢消息。

绝大部分丢消息的原因都是由于开发者不熟悉消息队列,没有正确使用和配置消息队列导致的。虽然不同的消息队列提供的 API 不一样,相关的配置项也不同,但是在保证消息可靠传递这块儿,它们的实现原理是一样的。

检测消息丢失的方法
  • 分布式链路跟踪系统
  • 根据生产者标示+分区号+递增消息号判断
  • 生产者保存需要核对是否丢失的数据,消费者消费完之后需要与生产者核对数据

像 Kafka 和 RocketMQ 这样的消息队列,它是不保证在 Topic 上的严格顺序的,只能保证分区上的消息是有序的,所以我们在发消息的时候必须要指定分区,并且,在每个分区单独检测消息序号的连续性。

如果你的系统中 Producer 是多实例的,由于并不好协调多个 Producer 之间的发送顺序,所以也需要每个 Producer 分别生成各自的消息序号,并且需要附加上 Producer 的标识,在 Consumer 端按照每个 Producer 分别来检测序号的连续性。

Consumer 实例的数量最好和分区数量一致,做到 Consumer 和分区一一对应,这样会比较方便地在 Consumer 内检测消息序号的连续性。

如何确保消息不丢失

一条消息从生产到消费完成这个过程,可以划分三个阶段:

queue1.jpeg
  1. 生产阶段

    在生产阶段,消息队列通过最常用的请求确认机制,来保证消息的可靠传递:当你的代码调用发消息方法时,消息队列的客户端会把消息发送到 Broker,Broker 收到消息后,会给客户端返回一个确认响应,表明消息已经收到了。客户端收到响应后,完成了一次正常消息的发送。

    只要 Producer 收到了 Broker 的确认响应,就可以保证消息在生产阶段不会丢失。有些消息队列在长时间没收到发送确认响应后,会自动重试,如果重试再失败,就会以返回值或者异常的方式告知用户。

    你在编写发送消息代码时,需要注意,正确处理返回值或者捕获异常,就可以保证这个阶段的消息不会丢失。

  2. 存储阶段

    在存储阶段正常情况下,只要 Broker 在正常运行,就不会出现丢失消息的问题,但是如果 Broker 出现了故障,比如进程死掉了或者服务器宕机了,还是可能会丢失消息的。

    如果对消息的可靠性要求非常高,可以通过配置 Broker 参数来避免因为宕机丢消息。

    对于单个节点的 Broker,需要配置 Broker 参数,在收到消息后,将消息写入磁盘后再给 Producer 返回确认响应,这样即使发生宕机,由于消息已经被写入磁盘,就不会丢失消息,恢复后还可以继续消费。例如,在 RocketMQ 中,需要将刷盘方式 flushDiskType 配置为 SYNC_FLUSH 同步刷盘。

    如果是 Broker 是由多个节点组成的集群,需要将 Broker 集群配置成:至少将消息发送到 2 个以上的节点,再给客户端回复发送确认响应。这样当某个 Broker 宕机时,其他的 Broker 可以替代宕机的 Broker,也不会发生消息丢失。

  3. 消费阶段

    消费阶段采用和生产阶段类似的确认机制来保证消息的可靠传递,客户端从 Broker 拉取消息后,执行用户的消费业务逻辑,成功后,才会给 Broker 发送消费确认响应。如果 Broker 没有收到消费确认响应,下次拉消息的时候还会返回同一条消息,确保消息不会在网络传输过程中丢失,也不会因为客户端在执行消费逻辑中出错导致丢失。

    你在编写消费代码时需要注意的是,不要在收到消息后就立即发送消费确认,而是应该在执行完所有消费业务逻辑之后,再发送消费确认。

对于kafka的相关使用可以参考之前的一篇文章【消息队列(一)-如何解决消息丢失】

解决消息重复问题

消息重复的情况必然存在

在 MQTT 协议中,给出了三种传递消息时能够提供的服务质量标准,这三种服务质量从低到高依次是:

  • At most once: 至多一次。消息在传递时,最多会被送达一次。换一个说法就是,没什么消息可靠性保证,允许丢消息。一般都是一些对消息可靠性要求不太高的监控场景使用,比如每分钟上报一次机房温度数据,可以接受数据少量丢失。
  • At least once: 至少一次。消息在传递时,至少会被送达一次。也就是说,不允许丢消息,但是允许有少量重复消息出现。
  • Exactly once:恰好一次。消息在传递时,只会被送达一次,不允许丢失也不允许重复,这个是最高的等级。

这个服务质量标准不仅适用于 MQTT,对所有的消息队列都是适用的。我们现在常用的绝大部分消息队列提供的服务质量都是 At least once,包括 RocketMQ、RabbitMQ 和 Kafka 都是这样。也就是说,消息队列很难保证消息不重复。

利用幂等性解决重复消息问题
  1. 利用数据库的唯一约束实现幂等

    不光是可以使用关系型数据库,只要是支持类似“INSERT IF NOT EXIST”语义的存储类系统都可以用于实现幂等,比如,你可以用 Redis 的 SETNX 命令来替代数据库中的唯一约束,来实现幂等消费。

  2. 为更新的数据设置前置条件

    另外一种实现幂等的思路是,给数据变更设置一个前置条件,如果满足条件就更新数据,否则拒绝更新数据,在更新数据的时候,同时变更前置条件中需要判断的数据。这样,重复执行这个操作时,由于第一次更新数据的时候已经变更了前置条件中需要判断的数据,不满足前置条件,则不会重复执行更新数据操作。
    但是,如果我们要更新的数据不是数值,或者我们要做一个比较复杂的更新操作怎么办?用什么作为前置判断条件呢?更加通用的方法是,给你的数据增加一个版本号属性,每次更数据前,比较当前数据的版本号是否和消息中的版本号一致,如果不一致就拒绝更新数据,更新数据的同时将版本号 +1,一样可以实现幂等更新。

  3. 记录并检查操作
    如果上面提到的两种实现幂等方法都不能适用于你的场景,还有一种通用性最强,适用范围最广的实现幂等性方法:记录并检查操作,也称为“Token 机制或者 GUID(全局唯一 ID)机制”,实现的思路特别简单:在执行数据更新操作之前,先检查一下是否执行过这个更新操作。

具体的实现方法是,在发送消息时,给每条消息指定一个全局唯一的 ID,消费时,先根据这个 ID 检查这条消息是否有被消费过,如果没有消费过,才更新数据,然后将消费状态置为已消费。

原理和实现是不是很简单?其实一点儿都不简单,在分布式系统中,这个方法其实是非常难实现的,在“检查消费状态,然后更新数据并且设置消费状态”中,三个操作必须作为一组操作保证原子性,才能真正实现幂等,否则就会出现 Bug。

对于这个问题,当然我们可以用事务来实现,也可以用锁来实现,但是在分布式系统中,无论是分布式事务还是分布式锁都是比较难解决问题。

对于kafka的相关使用可以参考之前的一篇文章【消息队列(二)-消息幂等】

处理消息积压

优化消息收发性能,预防消息积压的方法有两种,增加批量或者是增加并发,在发送端这两种方法都可以使用,在消费端需要注意的是,增加并发需要同步扩容分区数量,否则是起不到效果的。

对于系统发生消息积压的情况,需要先解决积压,再分析原因,毕竟保证系统的可用性是首先要解决的问题。快速解决积压的方法就是通过水平扩容增加 Consumer 的实例数量。

[片段] 方法参数收集

[片段] 方法参数收集

以前的代码,用于收集当前方法的所有参数,放在map中方便调取

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
import com.google.common.collect.ImmutableMap;
import lombok.Data;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.After;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.stereotype.Component;

import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.stream.IntStream;

@Aspect
@Component
public class ArgumentsCollector {

private static final ThreadLocal<Map<String, Object>> ARGUMENTS = ThreadLocal.withInitial(ImmutableMap::of);

static Map<String, Object> getArgs() {
return ARGUMENTS.get();
}

private Object[] args(Object[] args, int exceptLength) {
if (exceptLength == args.length) {
return args;
}

return Arrays.copyOf(args, exceptLength);
}

@Pointcut("@annotation(CollectArguments)")
void collectArgumentsAnnotationPointCut() {
}

@Before("collectArgumentsAnnotationPointCut()")
public void doAccessCheck(JoinPoint joinPoint) {
final String[] parameterNames = ((MethodSignature) joinPoint.getSignature()).getParameterNames();
final Object[] args = args(joinPoint.getArgs(), parameterNames.length);

ARGUMENTS.set(Collections.unmodifiableMap((IntStream.range(0, parameterNames.length)
.mapToObj(idx -> Tuple2.of(parameterNames[idx], args[idx]))
.collect(HashMap::new, (m, t) -> m.put(t.getT1(), t.getT2()), HashMap::putAll))));
}

@After("collectArgumentsAnnotationPointCut()")
public void remove() {
ARGUMENTS.remove();
}

@Data
private static class Tuple2<T1, T2> {

private T1 t1;
private T2 t2;

Tuple2(T1 t1, T2 t2) {
this.t1 = t1;
this.t2 = t2;
}

public static <T1, T2> Tuple2<T1, T2> of(T1 t1, T2 t2) {
return new Tuple2<>(t1, t2);
}
}
}

附送一段代码,用于将方法中收集的参数转换成Bean

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import org.springframework.beans.BeanUtils;
import org.springframework.beans.MutablePropertyValues;
import org.springframework.validation.DataBinder;

public class BinderUtil {

BinderUtil() {
}

@SuppressWarnings("unchecked")
public static <T> T getTarget(Class<T> beanClazz) {
final DataBinder binder = new DataBinder(BeanUtils.instantiate(beanClazz));
binder.bind(new MutablePropertyValues(ArgumentsCollector.getArgs()));
return (T) binder.getTarget();
}
}

使用实例:

1
2
3
4
5
6
7
8
9
10
@Override
@CollectArguments
public List<PsJobSequenceVO> findJobSequence(
String jobSeqGroupId,
String jobSeqId,
Integer state,
Date endDate
) {
return jobSequenceHandler.findJobSequence(BinderUtil.getTarget(PsJobSequenceFindRO.class)).getData();
}
高并发系统-数据库关键点梳理
奇怪的知识又增加了- BLNJ导致索引有序性失效

奇怪的知识又增加了- BLNJ导致索引有序性失效

先来看表结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
CREATE TABLE a (
`id`bigint AUTO_INCREMENT ,
`a` int,
`b` int,
PRIMARY KEY (`id`),
KEY `idx_a_b` (`a`,`b`)
);

CREATE TABLE b (
`id`bigint AUTO_INCREMENT ,
`b` int,
`c` int,
PRIMARY KEY (`id`)
)

看一下join语句,因为b上没有索引,所以mysql用的BLNJ:

1
2
3
4
explain select * from a 
join b using(b)
where a = 1
order by a, b;
id select_type table partitions type possible_keys key key_len ref rows filtered extra
1 SIMPLE a null ref idx_a_b idx_a_b 4 const 5206 100.00 Using temporary; Using filesort
1 SIMPLE b null ALL null null null Null 1000 100.00 Using where; Using join buffer (Block Nested Loop)

如果b表有索引的话:

1
2
3
4
5
6
7
CREATE TABLE b (
`id`bigint AUTO_INCREMENT ,
`b` int,
`c` int,
PRIMARY KEY (`id`),
KEY `idx_b` (`b`)
)
id select_type table partitions type possible_keys key key_len ref rows filtered extra
1 SIMPLE a null ref idx_a_b idx_a_b 8 Const 5206 100.00 Using index condition
1 SIMPLE b null Ref idx_b Idx_b 4 b.b 50 100.00 null

可以发现a表idx_a_b有序性没有利用上,至于原因,先看一下BNLJ执行的流程图:

BNLJ.jpeg

执行过程为:

  1. 扫描表 t1,顺序读取数据行放入 join_buffer 中,直到 join_buffer 满了,继续第 2 步;
  2. 扫描表 t2,把 t2 中的每一行取出来,跟 join_buffer 中的数据做对比,满足 join 条件的,作为结果集的一部分返回;
  3. 清空 join_buffer;
  4. 继续扫描表 t1,顺序读取之后数据放入 join_buffer 中,继续执行第 2 步,直到所有数据读取完毕。

其中隐含的问题在于第二步:即使t1表的数据是有序读取到join_buffer中的,由于是先扫描t2表再关联join_buffer数据,导致join_buffer中的有序性失效。

如果表b有索引idx_b,那么使用BKA算法第二步的关联顺序与BNLJ相反,是先扫描join_buffer后通过索引关联t2,则可以利用join_buffer中的有序数据。