判断 ip 是否在一个超大 ip 集中(识别国内 ip)

2021-06-29 19:22:22 +08:00
 a719114136

原文地址: https://www.ikaze.cn/article/65

前几天发帖/t/785801讨论匹配大量 ip 的方法,经过几天的思考,把最终使用的方法分享给大家。

1. 通过 ip 位置数据库

比较有名的服务商有:ipip(付费), maxmind (付费),纯真 (免费)。

但在这个应用场景下,我们并不需要具体的位置信息,类似的方案会浪费不必要的内存因此放弃。

2. 利用 ip 的连续性

后面两个方法有个前提:ip 地址列表中大部分是连续的。

这里我们已有了国内 ip 地址列表(已有开源的库,很好找,另外我用的这个库已经把 ip 合并为了 CIDR 格式的地址)。

我们先通过二进制把 ip 转为可直接比较的数字,再把连续的 ip 变为 (start_ip, end_ip) 这样的集合,就可以利用二分法快速查找了。

import ipcalc

class ChinaIp:
    def __init__(self):
        self.data = []

    def load(self, cidr_file='data/china_ip_list.txt'):
        with open(cidr_file, 'r')as f:
            for s in f.readlines():
                self.add(s.strip())

    def add(self, cidr):
        n = ipcalc.Network(cidr)
        self.data.append((n.host_first().ip, n.host_last().ip))

    def search(self, ip):

        l = 0
        r = len(self.data) - 1
        while l <= r:
            mid = (l + r) // 2
            if self.data[mid][0] <= ip <= self.data[mid][1]:
                return True
            elif self.data[mid][0] > ip:
                r = mid - 1
            elif self.data[mid][1] < ip:
                l = mid + 1
            else:
                return False
        return False

    def __contains__(self, item):
        ip = ipcalc.IP(item).ip
        return self.search(ip)
        
china_ip = ChinaIp()
china_ip.load()
print('223.70.163.83' in china_ip)

3. 利用 CIDR 的特性

CIDR 是形如 x.x.x.x/n 这样的地址,它表示一组网络地址相同的 ip,其中 n 表示前 n 位作为网络地址。

根据 CIDR 的特性,我们可以得到这样的结论:同一 CIDR 下的 ip,其网络地址是相同的。

因此我们可以把所有国内 cidr 地址的网络地址取出,放字典;对于一个 ip,尝试可能的网络地址(即 n ),看其是否在字典中。

import ipcalc

class ChinaIp(object):
    def __init__(self):
        self.data = {}


    def load(self, cidr_files='data/china_ip_list.txt'):
        with open(cidr_files, 'r')as f:
            cidr_list = f.readlines()

        for cidr in cidr_list:
            self.insert(cidr.strip())

    def insert(self, cidr):
        network = ipcalc.Network(cidr)
        self.data[str(network.netmask())]=True

    def search(self, netmask: str) -> bool:

        for i in netmask.split('.'):
            node = node.children.get(i, None)
            if node is None:
                return False
            if node.is_end:
                return True
        return False

    def __contains__(self, ip):
        for i in range(1,33):
            if self.search(str(ipcalc.Network(f'{ip}/{i}').netmask())):
                return True
        return False
        
china_ip = ChinaIp()
china_ip.load()
print('223.70.163.83' in china_ip)

这个算法看起来没啥毛病,但实际测试中速度比第二种慢了很多,耗时的地方在比较时必须循环所有 n,而二分法可以快速的排除不可能的部分。

对于这种情况,有两种优化方法:

1. 随机 n 的列表

class ChinaIp(object):

    ...
    
    def __contains__(self, ip):
        l = list(range(1, 33))
        random.shuffle(l)
        for i in l:
            if self.search(str(ipcalc.Network(f'{ip}/{i}').netmask())):
                return True
        return False

这种方法在测试中,时间减少了一半多。

2. 排除不会出现的 n

class ChinaIp(object):
    def __init__(self):
        ...
        self.mask_list = []
    ...

    def insert(self, cidr):
        network = ipcalc.Network(cidr)
        self.data[str(network.netmask())] = True
        self.mask_set.add(network.mask)

    def __contains__(self, ip):

        for i in self.mask_set:
            netmask = str(ipcalc.Network(f'{ip}/{i}').netmask())
            if netmask in self.data:
                return True
        return False

这样优化后速度和第二种持平,不过实际应用中还需要根据 ip 列表的情况来判断需要用哪种。

_

4296 次点击
所在节点    分享发现
35 条回复
westoy
2021-06-29 19:25:47 +08:00
netaddr 大法好
westoy
2021-06-29 19:26:00 +08:00
我靠, 又是我, 真是有缘.....
a719114136
2021-06-29 20:33:40 +08:00
@westoy 又是你在摸鱼
sujin190
2021-06-29 20:38:49 +08:00
https://gist.github.com/snower/1ecc87c017838cb6a6b30812d9fee6e3

之前写过的一个算法参考下,大多数情况一次判断就能得出结果
aureole999
2021-06-29 22:57:07 +08:00
CIDR 的方法如果建一个 Trie 呢?
如果测试的 ip 不在 china_ip 里的话不管你随不随机 n 那肯定要查找 32 次,Trie 的话只要 ip 段不是极其零碎的话应该比较次数少点?我瞎猜的
RicardoY
2021-06-29 23:16:35 +08:00
为什么不用字典树…
fxrocks
2021-06-30 07:29:09 +08:00
字典不是直接 d[k]查询 v 么或者 d.get(k)?
没必要自己写个 2 分法吧
a719114136
2021-06-30 08:33:03 +08:00
@fxrocks 一共 3 亿个 ip
a719114136
2021-06-30 08:42:40 +08:00
@RicardoY
@aureole999
不适用,比如 39.155.42.8 在 39.128.0.0/10 中,但他们只有开头是相同的,没法建字典树
a719114136
2021-06-30 08:43:45 +08:00
@sujin190 就是第三种方法,但实际测试中这样方法比二分慢很多
mywaiting
2021-06-30 08:51:53 +08:00
模仿一下路由器查找路由表的实现?

感觉这货应该有一个很成熟的算法实现
djj0809
2021-06-30 09:14:46 +08:00
@a719114136 建立二进制的 trie tree 啊,算法最差只需要比较 32 次就可以了
jinliming2
2021-06-30 09:20:12 +08:00
想到一个算法,和上面 3. 利用 CIDR 的特性 应该一样,没测试:
1,将 IP cidr 转换为 unsigned int 整数,比如 39.128.0.0/10 转为 662700032/10
2,根据 cidr 长度分别存到不同的 dict/list 中,比如:{ 10: [662700032, 796917760], 15: [1431699456] }(存 list 就排好序,便于后面去查找)
3,查询的时候 for cidr in cidr_list 遍历每一个 cidr 表,最多 32 个。假如上面这个 10 和 15 。
4,把要查询的 IP 转为 unsigned int 整数,根据 cidr 特性,使用按位与 & 运算,仅保留数字的前 cidr 位。
5,到对应的 dict/list 中查询这个数字。
missdeer
2021-06-30 09:57:57 +08:00
a719114136
2021-06-30 10:00:21 +08:00
@djj0809 比较 32 次比二分慢很多,方法 3 就是用类似的思路。
a719114136
2021-06-30 10:04:37 +08:00
@mywaiting 当时也考虑过,但没搜到。linux 自己倒是用了 ipset,但我看他里面貌似用的是 bitmap,hash 等结构。


另外路由器上的方案一般都要考虑通用性,也不会添加过多的规则。

对于这个场景 ip 是有规律的,因此有更好的方法。
aureole999
2021-06-30 10:17:13 +08:00
@a719114136 不啊,全换成 2 进制就可以啊,像 /10 的话 Trie 里只有 10 层,比较前 10 位就 ok 了。
aureole999
2021-06-30 10:24:39 +08:00
实际上都是大段大段的 ip,Trie 基本不会比较那么多次。但你的第三种方法在测试不在 china_ip 里的 ip 时一定要比较 32 次。
sujin190
2021-06-30 10:25:00 +08:00
@a719114136 #10 并不完全是,你这个第三种,如果这个 ip 不在里边并不能保证一次判断就能得出结果

ip 网段最重要特性就是掩码越短,网络范围越大,所以只要找出所有 ip 的全部可能掩码长度,然后在收集每个掩码长度下每个 ip 网段信息,判断的时候按掩码长度从小到大,如果需要判断的 ip 不在列表里,第一次就能得出结果,存在的也可以再优化到再判断一次就能得出结果
RicardoY
2021-06-30 10:27:17 +08:00
@a719114136 建 01 字典树…

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/786537

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX