intro1:对一段解析17monipdb/ipip.net ip库 函数的 15倍-70倍-300倍 性能优化
intro2: 一个sql count 语句的优化
声明:
- 本文代码均为本人所写或可在网上搜索的到的开源代码,遵循开源协议。
- 文中引用代码不涉及所就职公司代码,即便有也是本人从开源代码获取,或做了混淆处理。
起因:
先看一段某系统中的的IP查询的代码:123456789101112131415161718192021222324252627282930313233343536373839404142public class IpParse {private static int offset;private static int[] index = new int[256];private static ByteBuffer dataBuffer;private static ByteBuffer indexBuffer;...private static ReentrantLock lock = new ReentrantLock();public static Area find(String ip) {int ip_prefix_value = new Integer(ip.substring(0, ip.indexOf(".")));long ip2long_value = ip2long(ip);int start = index[ip_prefix_value];int max_comp_len = offset - 1028;long index_offset = -1;int index_length = -1;byte b = 0;for (start = start * 8 + 1024; start < max_comp_len; start += 8) {if (int2long(indexBuffer.getInt(start)) >= ip2long_value) {index_offset = bytesToLong(b, indexBuffer.get(start + 6), indexBuffer.get(start + 5),indexBuffer.get(start + 4));index_length = 0xFF & indexBuffer.get(start + 7);break;}}byte[] areaBytes;lock.lock();try {dataBuffer.position(offset + (int) index_offset - 1024);areaBytes = new byte[index_length];dataBuffer.get(areaBytes, 0, index_length);} finally {lock.unlock();}return new Area(new String(areaBytes).split("\t", -1));}private static void load() {// 读取 ip 库到 dataBuffer// dataBuffer 读取并设置 indexBuffer...}...}
不知道读者看到这段代码是否有所忆,笔者第一眼看到这段代码时,就想起很久前还在用php解析17monipdb的IP库代码,解析部分代码,尤其是256个数组、ip2long等几个ip处理函数,看起来和网上代码的完全一样,只不过这里是参考了c#实现。
17monipdb曾是一个开放的优质的ip库,后来似乎属ipip.net这家公司所有(非官方信息,网上资料较少,从百度结果/代码/数据格式推测),并另起一收费版ip库。在这之前,更为大家使用的是QQ IP数据库,也叫纯真IP数据库,它最为熟知的是一个叫做 qqwry.dat 的二进制文件。笔者曾在实习的第一家公司时,就是用 C 语言解析 qqwry.dat 的文件,根据IP获取省市信息,至今还依稀记得它分为头信息/数据段/索引段,从索引段读取索引/偏移信息并对数据段建立索引后方可使用,17monipdb/ipip.net也是这个原理,这里看17monip数据格式。
言归正传
当笔者被告知这段代码性能能达到300万/s的查询次时,其实是怀疑的,毕竟有一个大且不可优化的锁lock了查询函数的大部分,事实上性能确实未达到20万/s(下文会附上数据)。
如何优化呢?
先看关键部分,lock.lock(), 这个lock其实是一个ReentrantLock,也就是相当于synchronize,一个完全的悲观锁,虽然java(似乎是1.6以后,对ReentrantLock做了比synchronize好一点的优化),也就是说,这里多线程访问其实是线性的。
为什么这里要lock呢,可以优化吗?
这里用lock是因为涉及的ByteBuffer几个操作不是线程安全的,其实上文 indexBuffer 也同样非 Thread-safe的,只是这里的操作相当于不可变量(immutable),所以不加锁(lock)也未产生并发问题。正是因为有ByteBuffer的非线程安全操作,所以这里不会采用优化锁的思路去优化,因为首先想到的是ByteBuffer替换为byte数组,这样所有offset/偏移都是方法局部变量,无所谓并发了,企图用ByteBuffer这个所谓的零拷贝技术实在是大材小用/滥用/金玉其外,实际上,七牛就有个byte数组的实现,ip17mon-java,正如其自称“IP 17mon java version, 比官方的速度快很多,支持监视文件改动自动加载”,下文会对其做个benchmark/或对其做个指正,并对比下和笔者B Tree实现的哪个更快。
题外话:为什么这里要lock呢,其实可能还有一个考虑,就是实现者可能想实现动态更新IP库信息(包括文件改动全量更新/部分更新)。
如果全量更新,其实可以有其他lockless的方式优化,就是将IP库信息设置为volatile/autorefference的,更新的时候加锁或CAS方式(这也是下文代码采用方式),find函数可以采用经典的Doug Lee在JUC包采用的方法local变量保存引用。
如果部分更新,那么不建议这么做,因为要修改索引段数据,索引段本身是个数组,大部分改动不会只是追加的方式,那样不比全量更新改动更少。
但有没有其他更好的或更高性能的实现呢?
我想做的通用一点,且其格式扩展起来方便,或者性能高。
因为是对查找性能要求高,所以首先想到了 BTree,看起来像数据库,实际上,一些数据库实现就有线程安全的BTree可借鉴,这样动态更新很方便。
我们知道Mysql数据库索引的innodb实现就是BTree,比如2000万(好像是)数据通常会用一个三层BTree即可满足,1-4次io就能定位数据,前文几个解析17monipdb的本质上也是个一层的BTree,一层有256个索引分段定位ip位于哪个“段”(类似Mysql的页),之后就是从该页中进行二分查找,找到该ip所属的条目,该条目携带的信息即是其ip库地址信息。
这里只不过是一层,为了减少内存的花费。
需要先明确的一点,查询时,都会把ip通过移位转化为一个对应的long数值,然后通过long找到对应ip段。
其次,我想做的更通用一点,因为通过第三方渠道获取的IP信息可能只是一行文本数据,不是17monipdb那样的结构化的二进制数据, 且方便其他扩展。
综上,笔者决定采用 B+ Tree和地址数组 的数据结构保存IP库信息(BTree保存IP段->对应地址信息在地址数组下标)。
公司的IP数据也是文本方式提供,而国外就有个免费的基于文本的IP库 Maxmind geoip,它也是logstash官方ip插件在用/ELK等支持的IP库,我们配置logstash ip/地址功能时就是用这个免费IP库。该IP库主要分为两个文件 城市信息和IP段-城市id文件,下载下来后,IP段-城市id文件可能几百MB,加载会耗时,所以需要剔除部分信息。笔者简化后,城市信息/BTree实际占用内存约60MB,而17monipdb在20-30MB左右。
OceanBase/Hsqldb都有java版实现的B+Tree,但改起来麻烦,所以笔者直接用 维基百科上的B+Tree代码。
代码:
解释:
- 这里的find不是标准的BTree find 方法,因为存储的是IP段,所以BTree节点存的是IP界限,查询时对于查找不到返回null的[常见]会再次追溯父节点,返回大于该数值的最小(右侧最左)的节点,即 findGTE方法。
- 简单起见,代码中保证IP段连续/保留IP段这些没加进来,但是必须的,否则可能导致结果不准确。
下面是BenchMark数据
使用 JMH 测试的性能,2-32线程:
target_ipparse表示原来的方法,target_localtor为七牛开源的解析方法,target_maxmindv2为笔者的B Tree方法。硬件为2017年 8G i5 new Mac,JDK信息如下:
JMH类:
针对这个结果几个有趣总结:
- 几轮结果,ipparse/localtor 都是8线程(4核)时性能最好,而 maxmindv2比较稳定。
- 老的ipparse性能其实只有15万/s不到的吞吐量,localtor比maxmindv2稍微差点,但也可达到200万/s的吞吐量
对于1,说明ipparse/localtor在更大如200线程并发时,性能其实会更差,甚至达不到10万/s,虽然对应用可能足够,但是和声称的差距还是蛮大的。
对于1,localtor似乎是无锁的,竟然也会因线程增长而减少,有点意外。另外如果仔细看 Localtor代码,可以发现其文件流/URL流都没有规范的关闭,AutoReload的那个类不够并发安全。
对于2,奇怪的是maxmindv2怎么始终不变?是不是还漏了哪里的明显的性能优化点?
更多
实际上:
- 是的,find(String ip) 这个方法里,convIp2Long2(ip)也耗费许多性能,如果用 find(long ip)替代,还可以有 5倍性能的提升!(一个快速把字符串ip转化为long数字的方法就是使用查表方法,毕竟只额外需一个长度为256的数组)。
见 下文benchmark数据。 - 上述ip 220.255.1.166或者说3707699622,其实是一个B+ Tree首次未命中的情况,如果首次命中性能会怎样?可以再有24倍的性能提升!做到这点其实不难,可以在创建B+ Tree索引时,叶节点加一个指针指向他的下一个兄弟叶节点。
这个是jmh数据,这也是为什么上文采用 220.255.1.166这个IP,因为它可以代表大多数差性能下的查询。123456789# 16 线程 string ip -> long ip : 3707699622Benchmark Mode Cnt Score Error UnitsJMHIP.target_maxmindv2 thrpt 3 2110.476 ± 647.039 ops/msJMHIP.target_maxmindv2long thrpt 3 9010.649 ± 1973.710 ops/ms# 16 线程 string ip -> long ip + 命中 : 16819199Benchmark Mode Cnt Score Error UnitsJMHIP.target_maxmindv2 thrpt 3 2068.251 ± 178.626 ops/msJMHIP.target_maxmindv2long thrpt 3 46669.170 ± 28929.424 ops/ms
总结下来,使用B Tree和优化convIp2Long2可以有70倍性能提升的,这还是在ipparse 表现最好情况下,如果再优化BTree首次命中情况,就会有约300倍性能提升了。
SQL count(id)
几个月前,朋友问的一个问题,是我几年前写的系统,大概是定时刷Mysql数据库的任务,3分钟一次,数据量最近破千万了,加了功能,发现任务耗时超过了3分钟,咨询我优化下。
N年不做数据库相关的我,不再熟练sql优化了,看到代码里sql语句其实还是有种放弃的感觉,但好在看到有几条类似:
select count(*) from xxx
的统计sql,id就是各个表的主键索引,试着把它换为 count一个二级索引,即一个非空且index的列:
select count(*) from xxx where uid > 0
耗时还是能从4秒多提升到0.37秒。勉强挤进2分半了。
因为第一种要全表扫描,但是mysql innodb 的clustered index其实把主键和行数据存放在一起的[这也是为什么列存储兴起],但二级索引(secondary index)不会包含,仅记录主键指向行记录。
对于用count(*)/count(id)统计表记录总数时,利用二级索引进行count会快些。