现有一文本文件,文件内容每行记录由3个字段组成,字段间以’\t’分隔,每行以’\n’分隔,3个字段分别为”身份证号码 手机号码 姓名“。现需要提供一个服务,业务可通过服务根据身份证号码或者手机号码精确查询相关记录。那么如何设计,使得查询响应延迟低、吞吐高和资源消耗低。
一、数据分析
1. 数据总量
“10亿条由身份证号码、手机号码和姓名组成的记录”,如果这些记录以文本格式存储,那么
- 身份证号码为定长记录,需要18个字节表示
- 手机号码为定长记录,需要11个字节表示
- 姓名为变长记录,假设平均一个姓名需要3个汉字来表示,并且使用UTF8编码。那么则姓名平均需要9个字节表示
- 每行记录包含三个’\t’和一个’\n’,需要4个字节表示
总共则需要$(18 + 11 + 9 + 4 ) × 1000000000$字节来进行存储,即整个记录文件大小大约有39.2GB。而这些记录如果全部装载入内存,则至少需要$(18 + 11 + 9 ) × 1000000000$字节,约35.4GB。如果使用Java实现,还需要考虑到额外的存储占用。
2. 身份证号码
这里的身份证号码统一指中华人员共和国公民身份证上给出的公民身份号码。
该号码格式:
六位地址码+八位出生日期码(yyyyMMdd)+三位顺序码+一位校验码
码号 | 值域 | 总数 | 说明 |
---|---|---|---|
地址码 | N/A | <32000 | 六位行政区划代码(不包括港澳台)。 理论总数不超过310000个,实际上不超过32000个 |
出生日期码 | [19000101, $\infty$] | 365×年数 | 理论上无限个,但实际(截止2019年)已有的不超过43800 |
顺序码 | [000, 999] | 1000 | |
校验码 | [0, 10] | 11 | 实际应用中,10使用X 进行表示 |
截止今年(2019),身份证号码的理论值域空间大小不超过1401600000000,但实际不会超过1500000000。
其中,校验码可以由前面17位数字通过算法计算得到(具体算法参见附录)。因此,实际存储时可以去掉。这样记录中一个身份证就可以使用长整型来表示,那么单个身份证号码存储就可以节省10个字节。这样,记录中的身份证号码存储至多需要7.5GB。
3. 手机号码
这里的手机号码统一指中华人民共和国大陆地区手机所用号码
该号码格式:
三位网络识别码+四位地区码+四位用户号码
码号 | 值域 | 总数 | 说明 |
---|---|---|---|
网络识别码 | N/A | <60 | 主要包括电信、移动和联通三大运营商 |
地区码 | [0000, 9999] | 10000 | 可用于识别手机号码归属地 |
用户号码 | [0000, 9999] | 10000 | 随机分配 |
手机号码的理论值域空间大小不超过6000000000。可以看出手机号码的值域空间比身份证号码的要大,换句话说,记录中身份证号码重复的可能性要比手机号码更高。
由于手机号码由纯数字组成,这样记录中的一个手机号码就可以使用长整型来表示,那么单个手机号码存储就可以节省7个字节。这样,记录中的手机号码存储至多需要7.5GB。
4. 姓名
我国是个多民族国家,虽然姓名组成比较复杂,但姓名的重复率还是很高的。比如常见的“王伟”,“张伟”等。特别地,在<身份证号码,手机号码,姓名>
三元组中,可能会存在一个身份证号码对应多个手机号码的情况,使得姓名的重复率进一步上升。而姓名的值域空间大小则不会超过当前中国人口大小(<15亿)。
这里我们可以将数据中的姓名取出并进行去重处理,同时可以使用一个整型来对去重后的姓名进行编码。这样记录中一个姓名可以使用编码后的一个整型值表示,那么单个姓名存储就可以固定为4个字节。这样,记录中的姓名存储(包括编码和姓名本身)至多需要(假设记录中姓名去重后最多有一亿个)1.2GB。
当然,也可以进一步地将姓名拆分为单个汉字去重后编码,但这样编码后,复杂度上升,单个姓名长度不再固定,每个汉字编码后占用2个字节,这样算下来实际节省空间也没有提高多少。
二、存储设计
通过数据分析后,我们发现如果采用数据全部加载到内存的方式,对整个资源要求是比较高的。因此,自然会想到将“一部分”数据放在内存,“另一部分”数据放在磁盘上。但关键在于哪部分数据在内存,哪部分在磁盘,并且需要解决以下问题:
- 内存中的数据如何组织
- 如何从较慢的磁盘上快速地读取数据
- 数据如何缓存和淘汰
该章节我们先来主要回答前两个问题,第三个问题在下一个章节中描述。
1. 磁盘文件格式
为了能够在磁盘文件中能够更快的读取内存中不存在的记录,设计时应:
- 磁盘文件尽可能紧凑(体积尽可能小),这样每次I/O读取到尽可能多的记录
- 能够快速定位记录在文件中的位置(偏移量)
因此,为了能够满足这些要求,磁盘文件格式设计为二进制格式。
身份证号码可索引文件
用于通过身份证号码检索记录的场景。
总体结构
通过一级和二级索引,能够快速定位记录在文件中的位置。
元数据结构
从左到右依次是:
Len
表示文件所采用的编码字符串长度,比如”utf8“长度为4Charset
表示文件所采用的具体编码,比如”utf8“idx
表示索引字段下标,即构建该索引文件时读取的文本文件中索引字段所在下标Range
表示如何对索引字段进行划分,用于构建二级索引。其中第一个表示一级索引中key如何得到及其所占用字节数,第二个表示二级索引中key如何得到及其所占用字节数,第三个表示内容中每条记录前缀如何得到及其所占用字节数Start
表示取值时在索引字段中的起始位置(包含)End
表示取值时在索引字段中的结束位置(包含)Size
表示该Range表示的值存储时所占用字节数
FieldNum
表示内容中每条记录由多少个字段组成FieldSize
表示内容中每条记录所占用字节数Index
表示记录中第几个字段Size
表示记录中相应字段所占用字节数
Length
表示整个元数据(不包含Length所占用的4个字节)的长度
元数据的作用在于其中的信息可用于指导程序正确地解析二进制文件。
一级索引结构
从左到右依次是:
Num
表示一级索引中的索引条目数量,最大支持32767个Range1
表示通过元数据中第一个Range描述由索引字段生成的值,每个值是唯一的Offset
表示符合Range1值的二级索引在文件中的位置(偏移量)Length
表示整个一级索引(不包含Length所占用的4个字节)的长度
一级索引的作用在于能够根据索引字段的部分信息先缩小需要读取的数据范围。
二级索引结构
从左到右依次是:
Num
表示二级索引中的索引条目数量,最大支持32767个Range2
表示元数据中第二个Range描述由索引字段生成的值,每个值是唯一的Num
表示符合Range2值的记录数,最大支持16777215个Offset
表示Range2值的第一条记录在文件中的位置(偏移量),最大支持1TB大小文件Length
表示整个二级索引(不包含Length所占用的4个字节)的长度
二级索引的作用在于能够根据索引字段的部分信息进一步缩小需要读取的数据范围,并定位到符合要求的记录可能的位置。
内容结构
从左到右依次是:
Range3
表示元数据中第三个Range描述由索引字段生成的值,每个值是唯一的Phone Num
表示一个手机号码Name Seq
表示姓名编号
每个二级索引下的记录按照Range3值排序,这样可以方便进行最后的快速检索。
索引文件构建
索引构建时需要对原始数据做一定的处理。
- 提取出原始数据中姓名字段,并生成姓名索引文件$Name_{index}$和姓名编码文件$Name_{code}$
- 使用姓名编码文件$Name_{code}$中的编码替换原始数据中的姓名,生成文件$SF_1$
- 对文件$SF_1$中的身份证号码字段进行切分,按照一级索引、二级索引和内容前缀的顺序并使用”,”重新拼接,生成文件$SF_2$
- 对文件$SF2$中的记录按照重新组合后的身份证号码进行排序,生成文件$SF_3$
- 对文件$SF3$进行处理,生成最终的身份证号码可索引文件$Index_{sfz}$
假设对10亿条数据进行索引构建时,参数设置如下:
参数 | 设置 |
---|---|
charset | utf8 |
Range1 | start=0;end=5;size=4 |
Range2 | start=6;end=9;size=2 |
Range3 | start=10;end=16;size=4 |
FieldSize1 | index=0;size=4 |
FieldSize2 | index=1;size=8 |
FieldSize3 | index=2;size=4 |
那么,整个索引文件$Index_{sfz}$大小不会超过15GB:
- 元数据大小:36 byte
- 一级索引大小至多:(6 + 12 * 32000) byte
- 二级索引大小至多:(6 + 10 * 100) * 32000 byte
- 内容大小:16 * 1000000000 byte
手机号码可索引文件
用于通过手机号码检索记录的场景。
总体结构
和身份证号码可索引文件的总体结构相同。
元数据结构
和身份证号码可索引文件的元数据结构相同。
一级索引结构
和身份证号码可索引文件的一级索引结构相同。
二级索引结构
和身份证号码可索引文件的二级索引结构相同。
内容结构
从左到右依次是:
Range3
表示元数据中第三个Range描述由索引字段生成的值,每个值是唯一的SFZ
表示一个身份证号码(其中校验位已去除)Name Seq
表示姓名编号
每个二级索引下的记录按照Range3值排序。
索引文件构建
索引构建时需要对原始数据做一定的处理。
- 使用身份证索引构建过程中生成的姓名编码文件$Name_{code}$中的编码替换原始数据中的姓名,生成文件$MF_1$
- 对文件$MF_1$中的手机号码字段进行切分,按照一级索引、二级索引和内容前缀的顺序并使用”,”重新拼接,生成文件$MF_2$
- 对文件$MF2$中记录按照重新组合后的手机号码进行排序,生成文件$MF_3$
- 对文件$MF3$进行处理,生成最终的手机号码可索引文件$Index_{mob}$
假设对10亿条数据进行索引构建时,参数设置如下:
参数 | 设置 |
---|---|
charset | utf8 |
Range1 | start=0;end=2;size=2 |
Range2 | start=3;end=6;size=2 |
Range3 | start=7;end=10;size=2 |
FieldSize1 | index=0;size=2 |
FieldSize2 | index=1;size=8 |
FieldSize3 | index=2;size=4 |
那么,整个索引文件$Index_{mob}$大小不超过13GB:
- 元数据大小:36 byte
- 一级索引大小至多:(6 + 10 * 60) byte
- 二级索引大小至多:(6 + 10 * 10000) * 60 byte
- 内容大小:14 * 1000000000 byte
姓名字典文件
用于姓名编号到姓名的转换。该文件结构比较简单。
总体结构
索引结构
从左到右依次是:
Len1
表示第一个姓名长度Len2
表示第二个姓名长度Len3
表示第三个姓名长度,以此类推Length
表示整个二级索引(不包含Length所占用的4个字节)的长度
可通过长度累加计算生成对应的姓名在文件中的位置。
内容结构
其中内容的存储顺序和索引的存储顺序依次对应。
索引文件构建
在身份证号码可索引文件中已经生成文件$Name_{index}$ 。此索引文件的大小为取决于姓名去重后数量。
- 索引大小:姓名去重后数量 * 1 byte
- 内容大小:3 * 姓名去重后总长度 byte
2. 内存中的索引结构
使用时,将身份证号码可索引文件和手机号码可索引文件中的索引部分全部加载到内存,姓名字典文件全部加载到内存中。
身份证号码索引
索引构建
加载所有的一二级索引到内存中,构建散列表:
1 | Map<Integer, Map<Integer, Long>> index; |
如果构建一二级索引时,使用身份证号码中的地址码作为一级索引,出生年份作为二级索引,那么整个索引个数不超过3840000个。
索引使用
可以通过如下步骤使用索引:
- 使用Range1的start和end获取待查询身份证号码对应的一级索引值$Value_1$
- 通过索引值$Value_1$,查询索引Index,得到二级索引$Index_{2}$。如果无法找到,则不存在该身份证号码,进入第九步
- 使用Range2的start和end获取待查询身份证号码对应的二级索引值$Value_2$
- 通过索引值$Value_2$,查询二级索引$Index_{2}$,得到记录在索引文件中的位置${Offset}$和对应的记录条数$Num$。如果无法找到,则不存在该身份证号码,进入第九步
- 使用Range3的start和end获取待查询身份证号码对应的记录前缀$Prefix$
- 通过位置${Offset}$读取索引文件中的记录,并比对记录中的前缀是否等于$Prefix$
- 如果相等,则继续读取下一条记录,直到找到的记录前缀不等于$Prefix$ ,返回获得的记录;如果不相等,继续读取下一条记录,直到找到的记录前缀等于$Prefix$ 或者$Num$为零(即无剩余记录)
- 如果获得记录数不为空,则构造查询结果。将查询使用的身份证号码反填到结果中,并使用姓名编号通过姓名字典获取对应的姓名。得到最终查询结果,并放回
- 结束本次查询
手机号码索引
索引构建
加载所有的一二级索引到内存中,构建散列表:
1 | Map<Integer, Map<Integer, Long>> index; |
如果构建一二级索引时,使用手机号码中的用户号码作为一级索引,网络识别码作为二级索引,那么整个索引个数不超过600000个。
索引使用
可以通过如下步骤使用索引:
- 使用Range1的start和end获取待查询手机号码对应的一级索引值$Value_1$
- 通过索引值$Value_1$,查询索引Index,得到二级索引$Index_{2}$。如果无法找到,则不存在该手机号码,进入第九步
- 使用Range2的start和end获取待查询手机号码对应的二级索引值$Value_2$
- 通过索引值$Value_2$,查询二级索引$Index_{2}$,得到记录在索引文件中的位置${Offset}$和对应的记录条数$Num$。如果无法找到,则不存在该手机号码,进入第九步
- 使用Range3的start和end获取待查询手机号码对应的记录前缀$Prefix$
- 通过位置${Offset}$读取索引文件中的记录,并比对记录中的前缀是否等于$Prefix$
- 如果相等,则继续读取下一条记录,直到找到的记录前缀不等于$Prefix$ ,返回获得的记录;如果不相等,继续读取下一条记录,直到找到的记录前缀等于$Prefix$ 或者$Num$为零(即无剩余记录)
- 如果获得记录数不为空,则构造查询结果。将查询使用的手机号码反填到结果中,计算并回填身份证号码因构建时去除的校验位,使用姓名编号通过姓名字典获取对应的姓名。得到最终查询结果,并返回
- 结束本次查询
姓名字典
索引结构
加载整个字典文件到内存中,并按照索引中的顺序给每个姓名依次赋予编号。这个索引可以使用以下数组表示:
1 | String[] index = new String[姓名个数]; |
这个索引占用内存大小和姓名个数有关。
索引使用
通过身份证号码索引或者手机号码索引获取得到的记录中取得姓名编号$Seq$,直接通过index[Seq]
得到相应的姓名。
3. 索引的选择问题
索引的选择对性能的影响主要在于数据的分布情况。如果一二级索引没有很好的”区分度“或者说一致性分布不佳,则会导致部分索引下的记录数据量过多,进而导致查询有大概率落入这些区域,造成更多的磁盘I/O和搜索时间。
同时,整个索引(包括一级索引和二级索引)的数量也要控制,否则会造成索引加载后,内存占用过高或者不能将索引全部加载到内存,导致索引效率下降。
身份证号码索引选择
从身份证号码的组成来看,顺序码和出生月日的随机性和均匀性较好。因此,这里选择出生月日(身份证10位到14位)作为一级索引,顺序码(身份证15位到17位)作为二级索引。使用两千万真实身份证号码进行测试:
统计项 | 统计值 |
---|---|
一级索引数量 | 3650 |
二级索引数量 | 2988063 |
记录总数 | 26888239 |
记录最大数量 | 421 |
记录最小数量 | 1 |
记录平均数量 | 9 |
记录数量标准差 | 13.2 |
整体散列效果还是可以的。这种索引理论上总共有3650000个。
现在有10亿条记录,每个二级索引下平均记录数为274条左右。每条记录大小为16byte,那么平均每个二级索引下的记录大小4KB左右,而每次I/O读取的一个磁盘页大小一般为4KB,这样每次平均只需要一次I/O就可以完成数据的搜索。
手机号码索引选择
从手机号码的组成来看,用户号码的随机性和均匀性较好。因此,这里选择用户号码(手机号码8位到11位)作为一级索引,网络识别码加一位网络识别码(手机号码1位到4位)作为二级索引。使用两千万真实手机号码进行测试:
统计项 | 统计值 |
---|---|
一级索引数量 | 10000 |
二级索引数量 | 2787015 |
记录总数 | 26888239 |
记录最大数量 | 4625 |
记录最小数量 | 1 |
记录平均数量 | 9.7 |
记录数量标准差 | 16.76 |
整体散列效果还是可以的。这种索引理论上不超过6000000个。
现在有10亿条记录,每个二级索引下平均记录数为200条左右。每条记录大小为14byte,那么平均每个二级索引下的记录大小3KB左右,而每次I/O读取的一个磁盘页大小一般为4KB,这样每次平均只需要一次I/O就可以完成数据的搜索。
三、关于缓存
虽然每次I/O很小,但随着访问量增大,I/O变得频繁。因此,可以使用缓存,减少磁盘I/O,提升性能。
1. “免费的午餐”
我们知道计算机在速度上是“阶级”分明的。CPU运行速度最快,内存存取速度跟不上,于是有了L1、L2甚至L3缓存。同样磁盘速度相对于内存而言,又是一个量级的差距,因此操作系统往往会充分利用系统中空闲的内存来作为磁盘读取和写入数据的缓存,来提高I/O性能。在Linux系统中,这部分工作通过PageCache和BufferCache来实现(2.4以后,统一使用PageCache实现),比如我们在系统中执行free -m
命令时:
1 | total used free shared buffers cached |
其中的cached
即读写文件时的缓存,包括页缓存和可回收Slab缓存,而buffers
则为读写磁盘时的缓存(一般不大)。这里我们重点关注cached
,可以从/proc/meminfo
中得到根据详细的信息:
1 | cat /proc/meminfo | grep -E "Cached|SReclaimable" |
下面我们看一下,这一机制对性能的影响。
首先,先清空系统缓存。
1 | sudo echo 3 > /proc/sys/vm/drop_caches |
查看系统缓存情况。
1 | cat /proc/meminfo | grep -E "Cached|SReclaimable" |
读取索引文件(记录总数三千万不到),构建索引后,查看系统缓存情况。
1 | cat /proc/meminfo | grep -E "Cached|SReclaimable" |
可以看到页缓存明显增加,新增了大约166MB。
使用100000条数据模拟查询,查看系统缓存情况。
1 | cat /proc/meminfo | grep -E "Cached|SReclaimable" |
可以看出页缓存进一步增加,又新增了大约366MB。而此时,查询性能情况。
1 | 第一次查询 |
后续使用相同的数据重复两次查询,得到的性能情况。
1 | 第二次查询 |
可以看出,页缓存对性能的提升很明显。
这部分内存由操作系统自动管理的(比如通过LRU策略进行缓存页淘汰)。当系统内存不足时,如果页缓存是“干净”的,则直接使用;否则,就需要先将“脏页”回写到磁盘后,才能使用,此时会产生磁盘I/O。这里,由于我们只是做记录查询,并不涉及到文件写入,因此索引文件占用的缓存,不会产生后续的磁盘I/O(或者flush操作),对系统影响较小。
获取磁盘页大小
磁盘最小可访问单位称为sector(一般大小为512 bytes),而操作系统则以block为单位来实现块设备(比如磁盘)的I/O操作。对磁盘而言,block大小必须是sector的整数倍。
我们可以通过blockdev
命令获取当前磁盘的block大小,比如我使用的这台机器的block大小为:
1 | sudo blockdev --getbsz /dev/sda1 |
当我们以block大小的整数倍进行磁盘数据读取时,往往会达到比较好的I/O性能。
2. 应用缓存
这里不进行任何应用层面的缓存,先看看测试结果,避免过早优化。
四、性能测试
1. 测试环境
- OS: CentOS Linux release 7.3
- CPU: 16 cores
- Memory: 32 GB
- JVM: Java HotSpot(TM) 64-Bit Server VM 1.8.0_181
- VM Page Size: 4096 bytes
- Block Size: 4096 bytes
- JVM Parameters: -Xms4g -Xmx4g -XX:MetaspaceSize=64m -XX:MaxMetaspaceSize=64m -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/root/logs/index.hprof
2. 测试数据
原始数据
按照身份证号码和手机号码组成规则,随机生成记录。其中姓名随机从25000000个姓名中选取
- 数据总量:1000000000
- 文本文件大小:39 GB
索引文件
身份证号码可索引文件
统计项 | 统计值 |
---|---|
文件大小 | 15 GB |
一级索引数量 | 3650 |
二级索引数量 | 3650000 |
记录总数 | 1000000000 |
记录最大数量 | 360 |
记录最小数量 | 184 |
记录平均数量 | 274 |
记录数量标准差 | 16.55 |
手机号码可索引文件
统计项 | 统计值 |
---|---|
文件大小 | 14 GB |
一级索引数量 | 10000 |
二级索引数量 | 4500000 |
记录总数 | 1000000000 |
记录最大数量 | 305 |
记录最小数量 | 152 |
记录平均数量 | 222 |
记录数量标准差 | 14.90 |
姓名索引文件
统计项 | 统计值 |
---|---|
文件大小 | 241 MB |
索引个数 | 25000000 |
记录总数 | 25000000 |
3. 测试用例
进行下面每项测试之前,均会清空系统缓存。
数据重复查询
使用多份相同数据(每份100000条)查询,其中查询数据全部均在索引中。这种情况下,会有效利用系统缓存,后续查询性能较好(不会产生读磁盘的I/O),系统缓存占比稳定。
不同数据查询
使用多份不同数据(每份100000条)查询,其中查询数据全部均在索引中。这种情况下,系统缓存作用有限(后续查询每次都会产生读磁盘的I/O),每次查询性能基本一致,系统缓存占比上升。
缺失数据查询
使用多份不同数据(每份100000条)查询,其中查询数据中存在部分(50%)不在索引中。这种情况下,有些查询不会产生I/O,有些则会需要遍历数据产生更多的I/O。
混合查询
使用多份随机抽取数据(每份100000条)进行查询,这些数据可能重复或者不在索引中。这种情况下,性能表现更接近实际使用情况。
4. 测试结果
以身份证号码查询为例。
运行时间和缓存使用情况
数据重复查询结果
1 | 第一次查询 |
可以看出系统缓存对性能具有很大的提升。此时,系统缓存情况:
1 | cat /proc/meminfo | grep -E "Cached|SReclaimable" |
不同数据查询结果
1 | 第一次查询 |
可以看出不同数据查询时,系统缓存的作用就比较有限了,同时JVM的gc对查询响应造成了一定影响。此时,系统缓存情况(明显升高):
1 | cat /proc/meminfo | grep -E "Cached|SReclaimable" |
缺失数据查询结果
1 | 第一次查询 |
可以看出存在缺失时,系统性能并未得到提高,因为部分数据需要遍历完二级索引下所有内容,才能够确认是否存在。此时,系统缓存情况(由于存在需要遍历的情况,导致缓存略微升高):
1 | cat /proc/meminfo | grep -E "Cached|SReclaimable" |
I/O使用情况
这里使用pidstat
工具查看。
数据重复查询结果
1 | 第一次查询 |
可以看出系统缓存对I/O的影响。
不同数据查询结果
1 | 第一次查询 |
可以看到整个查询过程中,产生了大量的读磁盘操作,此时系统缓存作用已失效。
缺失数据查询结果
1 | 第一次查询时 |
可以看出相比非缺失数据查询,其产生的磁盘I/O,并未有出现明显的差异。说明缺失的情况下,并不一定减少磁盘的I/O。
CPU使用情况
以不同数据查询为例, 这里使用pidstat
命令查看。
1 | 05:19:54 PM PID %usr %system %guest %CPU CPU Command |
整个查询过程中,系统负载水平较为稳定。计算能力并未成为系统的性能瓶颈。
内存使用情况
系统内存
以不同数据查询为例, 这里使用pidstat
命令查看。
1 | 05:22:53 PM PID minflt/s majflt/s VSZ RSS %MEM Command |
可以看出这个过程中的内存使用量较为平稳,没有出现大幅度地波动。
JVM情况
以不同数据查询为例。整个查询(三次)过程中,共产生四次Minor GC。
1 | [GC (Allocation Failure) [PSYoungGen: 1048576K->960K(640512K)] 2599747K->1552139K(3437056K), 0.0870845 secs] [Times: user=0.04 sys=0.44, real=0.08 secs] |
这些Minor GC会对查询的响应造成一定的延迟。
堆使用情况
1 | Heap Usage: |
由于整个程序的特点(启动时加载全部的索引和姓名),老生代(大约1.5 GB)是整个程序占用内存最大部分,其中包含了25000000个姓名和全部的一二级索引。这里我们每次在内存中构建完索引后,可以主动调用一下System.gc()
,清空构建过程中产生的大量可回收对象。避免后续查询时频繁地触发GC(特别是Full GC)。
混合查询结果
身份证号码查询
共300000条记录,分三次查询。其中大约四分之一数据重复,一半数据不在索引中。
1 | 第一次查询 |
可以看到这种情况下系统缓存对性能还是具有一定的提升作用。第二次查询,由于发生两次Minor GC导致最大响应时间增高。
手机号码查询
共300000条记录,分三次查询。其中大约六分之一数据重复,一半数据不在索引中。
1 | 第一次查询 |
可以看到这种情况下系统缓存对性能还是具有一定的提升作用。第二三次查询,由于各发生两次Minor GC导致最大响应时间增高。
五、改进
1. 性能问题在哪里
从测试结果来看,目前的性能已接近万级qps。但我们也发现,在缺失数据查询场景中,无论是执行时间还是磁盘I/O都没有明显变化,那是否是由于部分缺失数据需要遍历完二级索引下所有内容引起的呢?同时,也可以发现无论是否存在,每次都需要遍历二级索引进行线性搜索,能否避免呢或者降低搜索时间复杂度?
2. 问题验证
缺失数据遍历数据问题
将缺失数据查询所用的数据,拆分为两组。一组完全在索引中(100000条),一组完全不在索引中(100000条)。进行两次独立测试,每次测试前情况系统缓存。这里以身份证号码查询为例:
完全在索引中
1 | 运行时间 |
完全不在索引中
1 | 运行时间 |
验证结论
可以看到缺失数据确实造成了更多磁盘I/O和更长的运行时间。其实,从身份证号码索引选择上也能够说明这点,出生月日和顺序码虽然能够得到较好的散列,但对身份证号码的唯一性识别能力较弱。
内容遍历问题
构造两份数据(每份100000条),一份数据分布在二级索引下内容中处于靠前部分,另一份数据分布在二级索引下内容中处于靠后部分。这样靠前部分数据需要的遍历时间更少,而靠后部分数据则需要更多时间。
靠前部分
1 | 运行时间 |
靠后部分
1 | 运行时间 |
验证结论
记录位置的不同确实会造成运行时间变长(增加了2.5s),但由于每次磁盘I/O的单位是4KB,而一个二级索引下全部内容大小平均不超过4KB,因此在磁盘I/O上两者差别不大。
3. 改进方案
Bloom Filter
主要解决数据不在索引中引起的数据遍历问题,降低磁盘I/O和搜索时间。
设计
构造一个BloomFilter内容文件。内存中构建索引时,同时根据文件中的内容反序列化出BloomFilter。在读取磁盘上的数据之前增加BloomFilter过滤步骤。即每次查询通过索引获取访问位置后,首先通过BloomFilter验证记录是否可能存在。通过验证后,再通过访问位置读取磁盘数据,进行后续搜索。此处BloomFilter使用Guava实现。
空间代价评估
这里构造BloomFilter时设置放入全部十亿条身份证号码或者手机号码,并且设置期望的false positive(即不存在但被误判为存在)概率不高于1%。
此时整个BloomFilter序列化后,文件大小为1.2 GB。加载入内存需要消耗时间,且内存使用量增高。
时间代价评估
设查询时身份证号码或者手机号码不在索引中的概率为$P_{miss}$ ,每次通过BloomFilter过滤执行时间为$T_f$,而过滤误判的概率为$P_{error}$ 。查询记录不在索引中时,读取磁盘进行搜索的平均时间为$T_{s}$ms。则在系统在使用BloomFilter前后的代价分别为:
$$
\begin{cases} Lost(before) = P_{miss} \times T_s \\ Lost(after) = T_f + P_{miss} \times P_{error} \times T_s \end{cases}
$$
即当以下不等式成立时,BloomFilter的使用能够提高系统性能。
$$
Lost(before) - Lost(after) = P_{miss} \times T_s \times (1 - P_{error}) > T_f
$$
考虑十亿条记录中每个身份证号码或者手机号码都是唯一的极端情况下,且业务查询每个身份证号码或者手机号码是随机的。那么:
- 身份证号码的$P_{miss} \ge 0.29$
- 手机号码的$P_{miss} \ge 0.78$
这里我们设置误判概率$P_{error} \le 0.01$,则
$$
T_f < 0.29 \times T_s \ge 0.06 \quad ms
$$
此时,BloomFilter的使用能够提高系统性能。
这里使用100000条(身份证号码)混合查询数据(其中50067条数据存在于索引中)进行测试,结果如下:
1 | passed = 50549 |
其中有482条误判。执行时间满足要求,即使用BloomFilter可以提高系统性能。
内容前缀缓存
设计
缓存每个二级索引中开始(最小)、中间和结束(最大)的内容前缀。查询时,通过比较待查询的内容前缀是否小于开始前缀或者大于结束内容前缀,来快速判断待查询的内容前缀是否存在。如果待查询的内容前缀存在,则比较其与中间前缀的大小。如果大于中间前缀,说明位于内容后半段,则直接读取后半段内容进行搜索;否则读取前半段内容进行搜索。这样减少了内容前缀位于后半段时的搜索时间(平均降低50%)。
缓存采用热启动的方式,即在读取一二级索引时,生成缓存。为了降低启动加载时间,构建索引文件时,在二级索引中增加以下相关内容:
MinKey
表示该二级索引下最小内容前缀(即第一条)MidKey
表示该二级索引下中间内容前缀MaxKey
表示该二级索引下最大内容前缀(可能是最后一条,如果最大前缀只有一条的话)MidDelta
表示中间内容前缀和第一条之间相差几条记录,可用于生成中间内容前缀所在位置MaxDelta
表示最大内容前缀和第一条之间相差几条记录,可用于生成最大内容前缀所在位置
空间代价评估
索引类型 | 空间代价 |
---|---|
身份证号码索引 | 二级索引个数 * (内容前缀长度 * 3 + 4) = 56 MB |
手机号码索引 | 二级索引个数 * (内容前缀长度 * 3 + 4) = 43 MB |
时间代价评估
设查询时身份证号码或者手机号码不在索引中的概率为$P_{miss}$ ,每次通过缓存判断时间为$T_j$,而通过缓存判断依然不再记录中的概率为$P_{error}$ 。查询记录不在索引中时,读取磁盘进行搜索的平均时间为$T_{s}$ms。查询记录在索引中时,未使用缓存时,读取磁盘进行搜索的平均时间为$T_m$ ms。而使用缓存判断后,读取磁盘进行搜索的平均时间为$\alpha T_m$ ms。则在系统在使用缓存前后的代价分别为:
$$
\begin{cases} Lost(before) = P_{miss} \times T_s + (1-P_{miss}) \times T_m \\ Lost(after) = T_j + (1 - P_{miss}) \times \alpha T_m + P_{error} \times T_s\end{cases}
$$
即当以下不等式成立时,缓存的使用能够提高系统性能。
$$
Lost(before) - Lost(after) = T_m (1 - P_{miss}) (1 - \alpha) > T_j - T_s(P_{error} - P_{miss})
$$
并且存在以下约束条件 :
$$
\begin{cases} T_j << T_s \\ 0 \le P_{miss} \le 1 \\ 0 \lt \alpha \le 1 \end{cases}
$$
因此,性能一定能够得到提升,但提升多少,还是主要看使用缓存后,$P_{error}$和读取磁盘进行搜索的平均时间情况。
4. 改进效果
这里以身份证号码查询为例
使用Bloom Filter后的测试结果
使用Bloom Filter后,程序使用内存量相比之前多出了1GB左右。典型的时间换空间。
完全在索引中数据查询
1 | 运行时间 |
与之前相比,无明显差别。
完全不在索引中数据查询
1 | 运行时间 |
与之前相比,无论是运行时间、磁盘I/O还是系统缓存都有了很大的提升。
混合查询
使用混合查询用例中的测试数据(100000条)
1 | 运行时间 |
与之前相比,运行时间有了显著的提升。
验证效果
BloomFilter的使用确实能够明显地提升查询性能(不论是查询时间,还是磁盘I/O)。
使用内容前缀缓存后的测试结果
靠前部分数据查询
1 | 运行时间 |
与之前相比,无明显差别。
靠后部分数据查询
1 | 运行时间 |
与之前相比,性能有较为明显地提升。
混合查询
使用混合查询用例中的测试数据(100000条)
1 | 运行时间 |
与之前相比,性能提升不明显。
验证效果
使用内容前缀缓存后,确实能够对靠后部分数据的查询性能有一定提升。但总体上,提升效果不明显。
双管齐下
混合查询
使用混合查询用例中的测试数据(100000条)
1 | 运行时间 |
性能得到进一步地提升。此时系统性能已达到万级qps,查询响应也在可接受范围内。
JVM的堆内存使用情况
索引构建后的,JVM的堆内存使用情况如下:
1 | Heap Configuration: |
整个程序占用内存最大的是老生代(大约2.7 GB),其中包含了25000000个姓名、全部的一二级索引和Bloom过滤器。与之前相比,改进后内存使用量新增了1.2 GB。如果查询场景中,数据命中率很高,可以关闭Bloom过滤器减少内存使用,毕竟此时的收益较低。
5. 关于JVM启动参数配置
启动参数设置时,主要考虑两点:
- 老生代存储空间压力大: 需要能够存储索引、姓名字典和Bloom过滤器。
- 垃圾回收对查询响应的影响: 由于使用场景存在响应延迟的要求,我们需要尽量减少GC的stop-the-world事件对响应时间的影响。
因此,最终JVM启动参数设置如下:
1 | 基本设置和GC日志(可用于后期性能分析) |
六、后记
整个过程中涉及到了不少知识点,通过整个设计实现过程,将这些知识点进行了串联和实践,有了更多地理解,也发现之前一些想当然的东西。痛并快乐着吧:P。
算是抛砖引玉吧,如果你有更好地设计或者改进,欢迎交流。
附录
1. 身份证校验码计算
第一步身份证号码(除第18位校验码外)每一位(从1开始)按照以下方法计算相应的权重:
$$
W_i = 2^{18 - i} \mod 11
$$
第二步使用计算得到权重进行身份证号码(除第18位校验码外)加权求和:
$$
S = \sum_{i=1}^{17} a_i . W_i
$$
第三步求余得到校验码$a_{18}$,如果等于10,则使用X
表示:
$$
a_{18} = (12 - (S \mod 11))\mod 11
$$
参考代码
1 | char[] ch = new char[18]; |