本文内容由阿里云实名注册用户自发贡献,版权归原作者所有,阿里云开发者社区不拥有其著作权,亦不承担相应法律责任。具体规则请查看《
集团内部数据资产团队维护了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范围条目的总数。
可优化点挖掘
直接阅读代码
潜在问题
通过对源码的分析发现,范围条目过滤的实现有比较大的改进空间。当前实现存在以下两个问题:
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;
}
private ByteBuffer wrapCell(int index) {
int position = index * cellSize;
return ByteBuffer.wrap(indexArr, position, cellSize);
}
优化方案
知道上面的问题后,改进其实也比较简单。
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;
}
优化效果
经过上面的优化后,每次地域查询的时间降为了2.1us,性能提升了30%左右
。这个提升应该主要是采用long比较代替字节逐个带来的,可以看到,上面的优化还是非常有效果的。
通过
jvisualvm热点分析
潜在问题
通过CPU采样发现,有很大一部分时间花在了IPv6字符串的解析上。通过上面的查询流程知道,查询时会通过正则去判断IPv6格式是否合法(即GravityBytesUtil.isIPv6),然后再提取其前8个字节(即GravityBytesUtil.parseIPv6_V64)。通过代码分析发现,整个解析流程会遍历IPv6字符串好几遍,尤其是正则匹配较为消耗CPU。
优化方案
理论上,通过一遍扫面即可完成判断IPv6是否合法以及提取出它的前8个字节,而无需借助于正则。通过查找资料发现,libc库中有个非常高效函数将IPv6字符串解析为byte数组,只需要扫面一遍即可(具体参考
inet_pton6
)。根据已有的C实现,我自己用java实现类似的逻辑。就IPv6解析效率来说,提升了4-5倍。
优化效果
在范围比较优化基础上,引入IPv6解析优化后,每次地域查询的时间从2.1us降为了1.5us,性能进一步提升了30%左右。和数据资产团队最初的实现相比,性能至此提升100%。
结合SLS SQL场景
潜在问题
目前地区信息包含了20多个字段的内容,但在文件中却是作为一个字符串整体存放的,各个字段的内容通过$字符进行分割。每个地域信息的存储结构具体如下所示,其中第一部分为地域信息的长度(2个字节),第二部分是地域信息的具体内容。
因此,从文件中读取地域信息后,需要先split字符串,然后再组装为地域信息结构体。这样操作存在以下问题:
优化方案
为了避免读放大和不必要的字符串切割,需要将每个字段的内容单独存储,优化后的单个地域信息存储结果如下所示。
需要说明的是,每个字段的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)。另外,正则表达式使用起来虽然方便,但对于性能要求高的场景,可能并不适合。尤为重要的是,数据处理场景减少数据读写的放大,对性能的提升是显著的。
[《如何获得IP地址对应的地理信息库, 实现智能DNS解析? 就近路由提升全球化部署业务的访问性能》](../202211/20221124_09.md)
上一篇信息提到了如何获取IP地址段的地理信息库, 本篇信息将使用PolarDB for PostgreSQL来加速根据来源IP快速找到对应的IP地址段, 将用到PolarDB for PostgreSQL的SPGiST索引和inet数据类型.
相比于把IP地址段存储为2个int8字段作between and的匹配, SPGiST索引和inet数据类型至少可以提升20倍性能.
支持。PolarDB-X不仅支持直接购买VPC类型的PolarDB-X实例,同时也支持由Classic(经典网络类型)切换到VPC。
PolarDB-X切换到VPC后,原本Classic类型的ECS将无法访问PolarDB-X,只允许VPC环境的ECS访问PolarDB-X。因此,在PolarDB-X切换VPC过程中需要将应用停机并切换ECS的类型。