集团内部数据资产团队维护了IPv6相关的地域数据,同时也提供了SDK查询特定IPv6的地域信息(参考语雀文档全球IP图谱SDK使用说明)。我在mac机器上进行了相关的随机查询测试(总次数1000万次),mmap的内存模式平均耗时为3us左右(1000万次查询总耗时30.2s),其性能和他们之前做的性能测试报告基本一致。对于大部分查询场景,这个性能基本能够满足需求。但对于SLS SQL的场景,单次查询需要处理10亿(甚至100亿)级别条IPv6的地域解析,当前的性能还是不够的。因此,需要在数据资产团队已有SDK的基础上,探索针对SQL使用场景下的优化。
当前已有设计
数据保存格式
在优化说明之前,需要熟悉下IPv6地址的结构(参考IPv6 address)。IPv6有128位(或16个字节)组成,其中前64位基本是用来识别子网的。换句话说,可以用IPv6的前64位来确定对应IPv6的地域。因此,IPv6的地域查询,可以等价于下面一张巨大的查询表:
IPv6前8个字节 |
地域信息 |
0:0:0:0 |
region_info_1 |
0:0:0:1 |
region_info_2 |
....... |
...... |
ffff:ffff:ffff:ffff |
region_info_N |
不难发现,按照上面的方式保存IPv6和地域的映射关系,是不切实际的,因为这需要2^64个映射项。实际上,对于地址连续的IPv6地址,其地域信息通常是相同的。因此,可以采用范围条目的形式来映射地域信息,具体如下表所示。
IPv6前8个字节 |
地域信息 |
[0:0:0:0, 1fff:ffff:ffff:ffff] |
region_info_1 |
[2000:0:0:0, 2000:ffff:ffff:ffff] |
region_info_2 |
....... |
...... |
[ff00:0:0:0, ffff:ffff:ffff:ffff] |
region_info_N |
采用范围条目的映射方式后,可以显著减少映射项的数量(当前为120万个左右)。数据资产团队将IPv6的地域信息存在单个文件中,该文件由4部分组成:
文件部分 |
说明 |
|
元数据 |
元数据定义了下面3个部分的数据位置以及地域信息具体包含的字段名称 |
|
范围条目映射索引 |
为了避免将全量的范围条目映射加载到内存中,这里会每128条范围条目映射抽取第一条出来作为索引,该索引可以常驻内存中,加快IPv6地址的过滤和查找。 |
|
全量范围条目映射 |
这部分包含了全量的范围条目到地域信息的文件地址映射,相对来说数据量较大。 |
|
地域具体信息 |
这部分包含了地域的具体数据。 |
文件的具体结构,如下图所示。
文件加载流程
首先会读取文件前16个字节的MD5摘要,并校验文件数据是否受损。其次,读取元数据部分的内容。对于范围条目索引,总是加载到JVM堆中,以加快IPv6范围条目的过滤。而对于全量范围条目和地域信息数据,可以选择加载到JVM堆中、通过mmap映射到堆外或者从磁盘读取。对于我们SQL的场景,采用mmap映射的方式较为合适,兼顾性能的同时,不占用JVM堆的空间。
地域查询流程
IPv6地域的查询流程如下所示,其中范围条目过滤采用二分查找算法。因此,不难理解,地域查询的复杂度是O(logN)的,其中N是数据文件中IPv6范围条目的总数。
可优化点挖掘
直接阅读代码
潜在问题
通过对源码的分析发现,范围条目过滤的实现有比较大的改进空间。当前实现存在以下两个问题:
-
匹配范围条目时,总是按照字节逐次对比(两个8字节对比,平均需要比较4次,才能区分大小)
int compareByteBuffer(byte[] ipv6PrefixBytes, ByteBuffer buffer) {
for(byte kb : ipv6PrefixBytes) {
byte bb = buffer.get();
int re = GravityBytesUtil.compareUnByte(kb, bb);
if(re != 0) {
return re;
}
}
return 0;
}
-
范围条目过滤过程中,产生大量的临时对象,这主要来自两个方面:
-
ByteBuffer的产生。范围条目索引在JVM对中是以byte[]形式保存的,每次提取条目内容时,都要转成ByteBuffer。
-
private ByteBuffer wrapCell(int index) {
int position = index * cellSize;
return ByteBuffer.wrap(indexArr, position, cellSize);
}
-
临时过滤结果的产生。每次查询都会产生IndexResult和ItemResult,但它们是可以避免的,因为真正有的信息其实就是地域信息在文件的位置(即address)。
优化方案
知道上面的问题后,改进其实也比较简单。
-
总是采用long进行8字节范围对比。在加载范围条目索引时,避免采用byte[]进行保存,而应该采用两个long[]数组以及一个int[]数组来分别保存范围条目的start、end以及地域信息的位置。在过滤IPv6的地址时,其前8个字节也转为long,这样比较的时候只需要比较一次long即可。同时,这样改造还解决了产生大量ByteBuffer的问题。 但这里有个问题,IPv6的前8个字节转成java long后,可能是负数,而IPv6的前8个字节组成的long值应该解释为unsigned。因此,IPv6的前8个字节转成long后,不能直接用比较运算符进行比较。不过,java有个Long.compareUnsigned专门解决这种情况的比较问题。
public static int compareIpv6Range(long keyValue, long prefixStart, long prefixEnd) {
if (Long.compareUnsigned(keyValue, prefixStart) < 0) {
return -1;
}
if (Long.compareUnsigned(keyValue, prefixEnd) > 0) {
return 1;
}
return 0;
}
-
范围条目查询直接使用int来指示结果。对于indexCker.queryIndex,直接命中索引中的范围条目,则返回地域地址;否则,返回-index-1,其中index为全量范围条目要进一步进行过滤的起始位置。对于,itemCker.searchValue的返回结果,也是类似处理。
优化效果
经过上面的优化后,每次地域查询的时间降为了2.1us,性能提升了30%左右。这个提升应该主要是采用long比较代替字节逐个带来的,可以看到,上面的优化还是非常有效果的。
通过jvisualvm热点分析
潜在问题
通过CPU采样发现,有很大一部分时间花在了IPv6字符串的解析上。通过上面的查询流程知道,查询时会通过正则去判断IPv6格式是否合法(即GravityBytesUtil.isIPv6),然后再提取其前8个字节(即GravityBytesUtil.parseIPv6_V64)。通过代码分析发现,整个解析流程会遍历IPv6字符串好几遍,尤其是正则匹配较为消耗CPU。
优化方案
理论上,通过一遍扫面即可完成判断IPv6是否合法以及提取出它的前8个字节,而无需借助于正则。通过查找资料发现,libc库中有个非常高效函数将IPv6字符串解析为byte数组,只需要扫面一遍即可(具体参考)。根据已有的C实现,我自己用java实现类似的逻辑。就IPv6解析效率来说,提升了4-5倍。
优化效果
在范围比较优化基础上,引入IPv6解析优化后,每次地域查询的时间从2.1us降为了1.5us,性能进一步提升了30%左右。和数据资产团队最初的实现相比,性能至此提升100%。
结合SLS SQL场景
潜在问题
目前地区信息包含了20多个字段的内容,但在文件中却是作为一个字符串整体存放的,各个字段的内容通过$字符进行分割。每个地域信息的存储结构具体如下所示,其中第一部分为地域信息的长度(2个字节),第二部分是地域信息的具体内容。
因此,从文件中读取地域信息后,需要先split字符串,然后再组装为地域信息结构体。这样操作存在以下问题:
-
读放大。对于SQL查询场景,每个地域相关的function通常只需要其中一个字段的信息。
-
字符串切分消耗CPU(即下面splitNoRegex函数)以及产生大量的临时字符串对象
优化方案
为了避免读放大和不必要的字符串切割,需要将每个字段的内容单独存储,优化后的单个地域信息存储结果如下所示。需要说明的是,每个字段的end offset,是相对于第一个字段内容的开始位置的。之所以采用end offset来记录字段内容的位置,为了便于快速获取某个字段内容的起始位置及其大小。
对于SLS SQL的场景,目前只有用到地域信息中的9个字段,通过统计发现,9个字段的内容总长度不会超过255。因此,每个字段的end offset采用1个字节即可。给定地域信息的地址,获取某个字段的内容,其代码如下所示,其中pointerBytes表示的是存储单个字段end offset所需要的字节数,fieldDataOffset表示的是第一个字段内容的偏移量。
public byte[] queryFieldInfo(int address, int fieldId) {
final int fieldStartFp, fieldBytes;
if (fieldId == 0) {
mappedBuffer.position(address);
fieldStartFp = 0;
fieldBytes = readFieldPointer();
} else {
mappedBuffer.position(address + (fieldId - 1) * pointerBytes);
fieldStartFp = readFieldPointer();
fieldBytes = readFieldPointer() - fieldStartFp;
}
if (fieldBytes <= 0) {
return null;
}
mappedBuffer.position(address + fieldDataOffset + fieldStartFp);
byte[] data = new byte[fieldBytes];
mappedBuffer.get(data);
return data;
}
优化效果
在范围比较和IPv6解析优化基础上,引入地域信息结构存储优化后,每次地域查询的时间从1.5us降为了0.3us。而和数据资产团队最初的实现相比,性能至此提升了10倍(即从3us降为了0.3us)。
JVM堆优化前后对比
下图是优化前执行随机压测查询时堆变化情况,可以明显看到堆的使用波动非常大,最高达到116M,Young GC后又会下降回30M。很明显,这种现象是因为执行过程中有大量的临时对象产生导致的。
优化前
下图是优化后执行随机压测查询时堆变化情况,可以看到堆的使用最大不超过35M,使用量的波动明显比优化前小很多,这证实了上述优化对临时变量产生的治理是非常有效的。
优化后
总结
本分主要对数据资产团队的IPv6地域查询设计和实现进行了分析,在他们已有的基础上进行了一定的优化,主要包括减少临时变量的产生、改进IPv6前8个字节对比实现、采用遍历一次的IPv6解析算法以及分离存储地域信息的各字段。整体优化效果非常明显,性能提升了一个数量级(单次查询从3us降为了0.3us),对于SLS SQL海量数据处理来说,这里的优化还是非常有意义的。
对于java程序而言,虽然JVM会自动回收不再使用的对象,但为了更好的性能,需要尽可能避免临时对象的生成(申请内存、初始化、对象销毁都有overhead)。另外,正则表达式使用起来虽然方便,但对于性能要求高的场景,可能并不适合。尤为重要的是,数据处理场景减少数据读写的放大,对性能的提升是显著的。