针对IP地址信息的数据结构
1. 需求
需求来源:指标平台
由于公司的人员变动,最近接手了elfin这个基础数据应用,交接后发现IP地理位置数据的更新逻辑一直存在缺陷,该缺陷造成指标平台和大数据平台所用的数据中存在大量的重复数据和老旧数据:本应为580W条的IP地理位置数据实际在数据库中存储了约830W条。这些老旧数据很可能对大数据分析和指标分析的准确性造成影响。
鉴于大数据平台和指标平台的调用链路复杂,具体影响无法估计,决定尽快修复该缺陷并重写数据,以确保数据的准确性。IP地理位置数据中以IP段为索引,由于IP端存在增加、删除、拆分、合并等多种更新情况,十分难以判断。在数据提供方IPIP的建议下我和产品经理决定将IP地理位置数据改为全量更新。
在与指标平台沟通时,了解到全量更新IP数据会极大的影响指标平台的性能。为保证数据的准确性和指标平台的性能,经过多次沟通确立了IP地理位置信息批量查询的需求:
elfin对外提供ip地址数据的批量查询功能:单次请求200条IP数据,集群 2000TPS 时 p99 达到 10ms 以下。
2. 失败的尝试
新增dubbo接口,提供批量ip数据的查询功能,做压测!!压测结果很差,TPS和rt远远无法达到预期。
image.png- 优化尝试
- 开多线程:4核4线程,提升了约 30%的性能。
- 优化查询算法:从遍历部分数据改成二分查找,性能提升不明显。
- 反思失败
经历过几次失败的尝试之后,我意识到常规的优化方式很可能难以显著提高查询性能,需要深入分析数据和算法,找出性能瓶颈。
现有的datax格式数据是一个黑盒,IPIP方面也不愿意透露具体的数据编码方式,只是大致知道其查询方法是通过二进制按位位移的方式有选择的遍历数据,难以进一步优化其查询方法。
3. 具体实现
- 解决思路
我决定彻底更换数据结构和算法,抛弃IPIP提供的datax数据,采取“空间换时间”的方式,使用未经压缩的包含全量IP地理位置信息的txtx格式数据。
原始数据的特点
- IPv4.txtx包含了所有的的IPv4地址,从0.0.0.0到255.255.255.255都有
- 该数据按照IP段的段起始顺序排列
- 所有数据的IP段无缝衔接,即前一个IP段的段结束+1=当前IP段的段起始
- 每条数据为 15字节(IP段起始)+15字节(IP段结束)+281(地理位置数据)
- 数据的主要索引为 IP段的段起始,次要的唯一数据为 IP段的段结束。
- 批量查询请求的特点
由于查询时入参为无规律的IP地址列表,批量IP地址查询可以转化为多次单条IP地址查询,针对单条IP地址的查询做优化。
由于IP数据是范围匹配不是精确匹配,无法使用诸如HashMap的结构,为保障查询效率使用TreeMap,key为IP段的段起始,value存储全量的IP地理位置信息,核心的查询方法为:find floor entry。
- 优化数据结构
在初步构建完Demo后我遇到了一个问题:由于IPv4.txtx数据本身有近600M而且在不断增加,在未来1-2年内可能增加到1G,将所有数据全放到一个TreeMap中会导致该TreeMap极大,每日数据更新时会造成多次长时间的FGC,极为影响系统性能。
我在一次内部周会上我提出了这个问题,希望能得到一些解决思路。孙奇根据需求和数据特点提出了一个良好的解决思路:拆分IP段。
解决思路:
参考IP子网划分和路由的规则,将IP的前两字节拆分出来作为索引,指向一颗较小的 TreeMap。在更新过程中逐步替换就可以避免FGC。同时极大的降低了TreeMap的深度,提升了查询效率。
数据结构:
二维数组+TreeMap。 其中,二维数组为 256x256 ,分别对应IPv4的前两字节,这样会有65536个TreeMap。每个TreeMap的key为无符号长整型的IP段起始,value存储IP地理位置信息。
private TreeMap<Long, IpRangeEntity>[][] treeMaps;
查询逻辑:
根据IP的前两段找到对应的TreeMap,而后使用 TreeMap 自身的 findfloor 方法来找到对应的IP段和IP地理位置信息。
public IpRangeEntity getValue(int ipFirst, int ipSecond, Long k) {
TreeMap<Long, IpRangeEntity> tmp = treeMaps[ipFirst][ipSecond];
Map.Entry<Long, IpRangeEntity> result = tmp == null ? null : tmp.floorEntry(k);
return result == null ? null : result.getValue();
}
数据插入逻辑:
由于IPv4数据是按照IP顺序存储的,我决定逐行读取数据、解析、并存入TreeMap。当IP段起始的前两字节产生变化时,说明此时的IP不属于当前的TreeMap,需要将当前TreeMap存入二维数组,构造一个新的TreeMap,并将此段数据存入新TreeMap。
插入分为两种情况:IP段不跨TreeMap和IP段跨TreeMap。
* 如果一个IP段全部从属于同一个TreeMap,只需要获取此段IP的段起始作为key,将数据存入TreeMap即可。
if (first == getIpPart(ipEnd, 0) && second == getIpPart(ipEnd, 1)) {
currentMap.put(key, ipRangeEntity);
}
如果一个IP段同时从属于两个或多个TreeMap,需要将此IP段拆分成多个子段,分别插入到不同的TreeMap中。拆分时只修改IP段的段起始和段结尾,子段包含的地理位置信息与拆分前相同。
else {
// 65536 = 256^2 16777216 = 256^3
long lastEnd = Utils.ip2Long(ipRangeEntity.getIpEnd());
long nextStart = Utils.ip2Long(ipRangeEntity.getIpStart());
long nextEnd = Math.min(lastEnd, nextStart + 65535);
IpRangeEntity restIpRange = new IpRangeEntity(ipRangeEntity);
restIpRange.setIpStart(Utils.long2Ip(nextStart));
restIpRange.setIpEnd(Utils.long2Ip(nextEnd));
currentMap.put(nextStart, restIpRange);
while (nextEnd <= lastEnd) {
switchTreeMap();
nextStart = nextEnd + 1;
nextEnd = Math.min(lastEnd, nextStart + 65535);
if (nextStart >= nextEnd) break;
first = (int) (nextStart / (16777216)); // 取ip第一个字段
second = (int) (nextStart / (65536)) % 256; // 取ip第二个字段
currentMap = new TreeMap<>();
restIpRange = new IpRangeEntity(ipRangeEntity);
restIpRange.setIpStart(Utils.long2Ip(nextStart));
restIpRange.setIpEnd(Utils.long2Ip(nextEnd));
currentMap.put(nextStart, restIpRange);
}
}
- 优化更新逻辑
系统发布后发现,线上虚拟机在更新时CPU使用过高,可能导致查询请求的rt增高,甚至数秒内服务不可用。为解决这一问题,需要引入分布式锁,将集群中的各个机器的更新时间错开,并且在更新时调整该机的负载均衡权重,使得该机流量降低。
4. 总结
- 数据结构的优缺点
- 优点:
- 10用户并发的情况下,批量查询200条IP的地理位置信息的耗时可以控制在7ms以内,相比原有数据结构的162ms,查询速度提升20多倍。
- 缺点:
- 原有系统平时常驻内存仅需要不到2G内存,现有系统平时常驻内存需要约4G内存,更新时需要约6G内存。
- 后续改进方案
针对内存占用量过大的问题,可以从以下两个方向去解决:
- 将每条IP地址段数据的地理位置信息进行压缩,查询时再解压。为节约查询时的解压的时间,可以考虑 LZ4 和 LZ4hc。
- 将重复的地理位置信息进行统计,构成一个独立的地理位置信息表。每条IP地址段中存储指向该表的一个索引。
- 经验总结
- 遇到问题时多问问同事的意见,不同的人往往可以从不同角度去看待问题,有时候就能找到更好的解决方案。
- 用户提出的需求不一定合理,甚至不一定是必须达到的,可以通过反复的沟通对需求进行调整。