一种高效的ip名单的存储与查询方法(基于openresty框架)
业务需求
在应用防火墙的开发中,防cc攻击是一个重要且复杂的模块。如果说防waf攻击是依靠对请求报文中字符串特征的识别来决定是否拦截,那么防cc攻击就是对访问频率的计算来决定是否拦截。防cc攻击逻辑中一般会有ip黑名单或白名单的检查,访问的来源ip在黑名单就直接拦截,在白名单就直接放行。
在实际业务中有这样得一种需求场景:很多访问行为来自于大互联网公司的数据中心,我在实际项目中发现此类ip(段)有4000多个,并且全部是ipv4,它们应该在防cc攻击的白名单中。以此为例,以下探究如何存储和查询这个ip白名单。由于我平时工作内容就是应用防火墙,它基于openresty框架,业务开发用的是lua语言。下面所讲的ip名单的存储与查询的方法都是基于使用lua+openresty。
方法探究
lua table存储与lua查询
如何存储
lua是一种脚本语言,用c编写,它有一种数据类型叫做table,我们可以把它当成数组和字典进行任意的赋值与取值操作。table的底层实现有具体文章可参考:lua之table(我写的),此处简而言之:table底层实现为数组部分和哈希表部分,lua虚拟机会在使用过程中动态调整两者容量并重新安排所有元素的位置,两者容量任何时刻都是2的次幂,且各自真实放置的元素数量介于(容量/2,容量]之间。
用table存储ip名单分为2种情况:
- 如果是精确的ip,则键为该ip(字符串),值为true(布尔值)
- 如果是ip段,则第一层键为对应的掩码(整数),值为表;第二层键为ip段右移(32-掩码)位得到的整数,值为true(布尔值)
假如有一个个ip为1.1.1.1、两个ip段为3.3.3.3/24和4.4.4.0/24 ,则存储这些ip(段)的table是:
{"1.1.1.1":true,24:{197379:true, //代表3.3.3.3/24263172:true //代表4.4.4.0/24}
}
如何查询
当请求到来的时候,我们拿到来源ip。按照以下步骤进行查询:
- 询以该ip字符串为键的值是否为true,是则结束查询;否则下一步
- 查询以该ip字符串为键的值是否为true,是则结束查询;否则下一步
- 将该ip转换为4字节的无符号整数
- 遍历实际业务中每个掩码,找到该掩码为第一层键的表,有则继续;否则下一个掩码
- 将整数格式的ip右移(32-掩码)位得到一个整数,查询以该整数为键的值是否为true,有则结束查询;否则上一步
空间与时间复杂度
空间复杂度
实际业务中,共有4000多个ip(段),根据掩码不同,数量分布为:1154个精确ip, 剩下的都是ip段,共17种掩码(从15到31)。掩码15有1个段,掩码16有4个段,掩码17有2个段,掩码18有8个段,掩码19有21个段,掩码20有14个段,掩码21有28个段,掩码22有50个段,掩码23有142个段,掩码24有668个段,掩码25有161个段,掩码26有216个段,掩码27有267个段,掩码28有271个段,掩码29有311个段,掩码30有315个段,掩码31有396个段。
它们形成table后,经过实际的计算和打印证实,共占据约264K的内存。每个数组元素占16个字节,每个哈希表元素占40个字节,为什么哈希表元素占这么多字节?因为它不仅要存值也要存键。还有最主要的,因为哈希表和数组的容量都是2的次幂,必然造成浪费:1154个精确ip,就需要分配容量为2048的哈希表;17正好比32/2大一点,所以17个掩码表都存在容量为32的数组里。这两个不争气的,都是卡着比一个幂稍微多一点导致容量分配上升了一个幂。不仅如此,每个掩码表自身也是哈希表,这么算也有不少空间浪费。这样的内存占用分析都是来自于table底层实现。
时间复杂度
为了方便起见,不对所有掩码都举例,只简单举例:对在名单中的精确ip、掩码16、掩码24、掩码31以及名单外的ip各自进行10000万次的查询。以下是时间分布:
ip类型 | 查询时间(s) |
---|---|
名单中的精确ip | 1.01 |
名单中的掩码16 | 1.50 |
名单中的掩码24 | 2.17 |
名单中的掩码31 | 2.93 |
名单外的ip | 3.12 |
该结果和查询逻辑可以对得上,因为查询都是从精确ip先查,然后再按照掩码从小到大查,直到最后查不到。
mmdb存储与ffi查询、lua c查询
如何存储
mmdb是一种特殊格式的文件,它的结构有具体文章可参考:mmdb文件结构解析(我写的),简单说,它就是把ip(段)按照从高比特位到低比特位的顺序组织成二叉树,0往左1往右。每个节点有2个成员,第一个成员代表如果接下来一位是0那么应该走到哪个节点;第二个成员代表如何接下来一位是1那么应该走到哪个节点。如果走着走着发现,当前所在节点对应的成员值大于等于节点总数量,就说明查询到结果了:成员值等于节点总数量,那么该ip(段)在文件中;成员值大于节点总数量,那么该ip(段)不在文件中。
如何查询
由于mmdb文件的打开和查询也有独立的项目,用c语言写成,因此我们需要在lua中调用外部c库。调用外部c库有2种方式:一种是mmdb源代码独立打包成动态库,然后在openresty中用ffi调用;另外一种是将mmd源代码和lua c接口一起打包成动态库,然后在openresty中直接调用。下面对这两种调用的查询方式进行阐述。
ffi查询
ffi是一种专在openresty环境中使用的调用c库的方法。本例中,我写了一个lua模块封装了ffi,并以纯lua的方式对业务暴露打开和查询mmdb文件的接口:
local ffi = require("ffi")--先加载ffi
local c_libmmdb = ffi.load("mmdb")--加载mmdb库,本例中,我把实现了打开和查询mmdb文件的mmdb源代码打包成动态库,取名叫做libmmdb.so
ffi.cdef[[--把要用到的mmdb源代码中的数据类型和函数原型进行集中的声明typedef struct MMDB_ipv4_start_node_s {uint16_t netmask;uint32_t node_value;} MMDB_ipv4_start_node_s;typedef struct MMDB_description_s {const char *language;const char *description;} MMDB_description_s;typedef struct MMDB_metadata_s {uint32_t node_count;uint16_t record_size;uint16_t ip_version;const char *database_type;struct {size_t count;const char **names;} languages;uint16_t binary_format_major_version;uint16_t binary_format_minor_version;uint64_t build_epoch;struct {size_t count;MMDB_description_s **descriptions;} description;} MMDB_metadata_s;typedef struct MMDB_s {uint32_t flags;const char *filename;ssize_t file_size;const uint8_t *file_content;const uint8_t *data_section;uint32_t data_section_size;const uint8_t *metadata_section;uint32_t metadata_section_size;uint16_t full_record_byte_size;uint16_t depth;MMDB_ipv4_start_node_s ipv4_start_node;MMDB_metadata_s metadata;} MMDB_s; //打开一个mmdb文件后,文件结构的解析结构会放在这个结构体中typedef struct MMDB_entry_s {const struct MMDB_s *mmdb;uint32_t offset;} MMDB_entry_s;typedef struct MMDB_lookup_result_s {bool found_entry; //代表被查询的ip在不在文件中MMDB_entry_s entry;uint16_t netmask;} MMDB_lookup_result_s; //每次查询一个ip,都会返回一个这样的结构体给调用者int MMDB_open(const char *const filename, uint32_t flags, MMDB_s *const mmdb);MMDB_lookup_result_s MMDB_lookup_string(MMDB_s *const mmdb, const char *const ipstr, int *const gai_error,int *const mmdb_error);const char *MMDB_strerror(int error_code);const char *gai_strerror(int errcode);
]]
local _M = {}local function mmdb_strerror(rc)return ffi.string(c_libmmdb.MMDB_strerror(rc))
endlocal function gai_strerror(rc)return ffi.string(ffi.C.gai_strerror(rc))
endlocal gai_error = ffi.new('int[1]')
local mmdb_error = ffi.new('int[1]')function _M.open(file_name) //打开mmdblocal obj = ffi.new('MMDB_s')local status = c_libmmdb.MMDB_open(file_name, 1, obj) if status ~= 0 thenngx.log(ngx.ERR, "-----MMDB_open error: ", mmdb_strerror(status))return endreturn obj
endfunction _M.lookup(obj, ip) //查询iplocal result = c_libmmdb.MMDB_lookup_string(obj, ip, gai_error, mmdb_error)if mmdb_error[0] ~= 0 thenngx.log(ngx.ERR, "-----MMDB_lookup_string mmdb_error error: ", mmdb_strerror(mmdb_error[0]))return endif gai_error[0] ~= 0 thenngx.log(ngx.ERR, "-----MMDB_lookup_string gai_error error: ", gai_strerror(gai_error[0]))returnendreturn result.found_entry
endreturn _M
lua c查询
无论是在纯lua环境中还是openresty环境中,始终都有一种lua直接调用c库的方式,这是lua语法的一部分。调用方式非常简单,不像ffi那样需要使用很多ffi接口和声明许多c库里的东西,但是调用的简单却是由c库编写的复杂为代价的。为什么c库编写复杂?因为我们需要写一个lua c接口文件并把它和mmdb相关源代码一起打包成动态库,lua c接口文件中要实现lua和c的交互:
#include<stdio.h>
#include<lua.h>
#include<lauxlib.h>
#include <arpa/inet.h>
#include <lobject.h>
#include <lstate.h>
#include <stdlib.h>
#include <string.h>
#include <maxminddb.h>
#include <netdb.h>static int open(lua_State* L){ //打开mmdbconst char *filename = luaL_checkstring(L, 1);MMDB_s *mmdb = lua_newuserdata(L, sizeof(MMDB_s));int r = MMDB_open(filename, 1, mmdb);if (r != 0) {lua_pushstring(L, "MMDB_open err");return 1;}return 1;
}static int lookup(lua_State *L) //查询
{MMDB_s *mmdb = lua_touserdata(L, 1);const char *addr = luaL_checkstring(L, 2);int gaierr, mmerr;MMDB_lookup_result_s res = MMDB_lookup_string(mmdb, addr, &gaierr, &mmerr);if (gaierr != 0 || mmerr != 0) {lua_pushstring(L, "MMDB_lookup_string err");return 1;}lua_pushboolean(L, res.found_entry);return 1;
}static luaL_Reg mylibs[] = {{"open", open},{"lookup", lookup},{NULL, NULL}
};int luaopen_libluacmmdb(lua_State* L){const char* libname = "libluacmmdb";luaL_register(L, libname, mylibs);return 1;
}
在lua中调用该c库就非常简单了:
local libluacmmdb = require("libluacmmdb") //加载mmdb库,本例中,我把实现了打开和查询mmdb文件的mmdb源代码和lua c接口文件打包成动态库,取名叫做libluacmmdb.so
local mmdb_obj = libluacmmdb.open("/root/white_ips.mmdb") //打开mmdb文件
libluammdb.lookup(mddb_obj, "1.1.1.1") //查询
空间与时间复杂度
空间复杂度
用mmdb文件存储40000多个ip(段),perl语言有专门的生成mmdb文件的包,因此我用perl来生成mmdb文件:
use MaxMind::DB::Writer::Tree;my %types = (flag => 'uint16'
);my $tree = MaxMind::DB::Writer::Tree->new(ip_version => 4,record_size => 24,database_type => 'dummy',languages => ['en'],description => { en => 'dummy' },map_key_type_callback => sub { $types{ $_[0] } },
);open my $lines, '<', $ARGV[0]; while (my $line = <$lines>) { $tree->insert_network($line,{flag => 1});
}open my $fh, '>:raw', $ARGV[1];
由于mmdb文件中有二叉搜索区和数据区,在二叉树搜索区查询到一个ip后,可在数据区继续get到该ip对应的属性,如地理位置等。本文只想用它来查询一个ip在不在里面,所以对应属性就直接设置成1了。生成mmdb文件后,我写了一个main.c来调用libmmdb.so,打印出来我们的mmdb文件为124K。
时间复杂度
ffi查询
ip类型 | 查询时间(s) |
---|---|
名单中的精确ip | 8.95 |
名单中的掩码16 | 7.94 |
名单中的掩码24 | 8.44 |
名单中的掩码31 | 8.90 |
名单外的ip | 7.83 |
该结果和查询逻辑可以对得上,查询的位数越长,耗时越长。
lua c查询
ip类型 | 查询时间(s) |
---|---|
名单中的精确ip | 3.38 |
名单中的掩码16 | 2.63 |
名单中的掩码24 | 2.88 |
名单中的掩码31 | 3.21 |
名单外的ip | 2.48 |
该结果和查询逻辑可以对得上,查询的位数越长,耗时越长。
分析与结论
在存储4000个ipv4类型的ip(段)的时候,lua-table需要用264K的空间,而mmdb文件只需要124K的空间,我所参与开发的产品就是用lua-table存储的,如果改用mmdb文件存储,可以节省53%的空间!考虑到lua-table是每个进程一份,而mmdb文件打开后是通过mmap方法实现进程共享,所以实际生产环境中的空间节约会更多,甚至达到97%空间(服务为16进程的情况下)。
实际业务中的ip黑白名单的数量是有限的,考虑到海量的生产环境空间,lua-table的小额浪费可以接受。所以空间的对比分析结果并不支持mmdb能够有资格取代lua-table,因为除了存储还有查询,万一mmdb它查的慢呢?
所以重点在于查询速度上。
查询1000万次在名单中的ip,使用lua-table的lua、使用mmdb的ffi、使用mmdb的lua c,三者的平均时间分别是1.90s、8.56s、3.02s。对比发现,查询一个在名单中的ip,lua-table有不错的速度优势。至于为什么有优势,我想部分原因是:若某ip在名单中,则一定是中途发现的,它不像使用mmdb,只有查询到最后才能发现它在名单中。
但是在实际业务中,在名单中的ip是有限的,不在名单中的ip是无限的,以有限比无限,殆矣!
那么我们就要比较一下查询1000万次不在名单中的ip,三者的表现如何?它们分别是3.12s、7.85s、2.48s。可以发现使用mmdb的lua c的查询速度比使用lua table的lua有了优势,节省21%的时间,苍蝇不大也是肉呀。
所以,我们发现在openresty中用mmdb存储+用luac查询,是最佳的ip名单存储与查询组合。至少这个方法比我参与的产品中的实现方法要节省53%的空间和21%的时间!
todo1:ffi的查询速度非常慢,对比很扎眼,难道ffi就是拿时间换了开发效率吗,这个需要继续探究原因
todo2:本文以真实业务为例,存储的都是ipv4的地址,如果存储ipv6地址,是否情况会一样?也需要继续探究
一种高效的ip名单的存储与查询方法(基于openresty框架)相关推荐
- 海量日志存储和查询方法及系统
摘要 本发明提供一种海量日志存储和查询方法及系统,其中的方法包括将分片后的日志按照主从关系存储为文件结构,其中,分片后的日志按照主从关系存储在主文件和从文件内,主文件包括日志的聚合数量.分片开始时间和 ...
- 一种高效的Polar码冻结比特编译码方法
注:此为论文读书笔记 英文论文原名为:<Efficient Method for Frozen Bits Encoding and Decoding of PolarCode> Abstr ...
- 一种高效的q+1准均匀量化(quasi-uniform quantization)方法及MATLAB实现
一种高效的q+1准均匀量化(quasi-uniform quantization)方法及MATLAB实现 简介 算法描述 均匀量化 准均匀量化 MATLAB代码 效果 参考文献 简介 在将算法部署到硬 ...
- 一种高效自然光供电的6LoWPAN无线传感节点
一种高效自然光供电的6LoWPAN无线传感节点 蒙仕格 广东技术师范学院 摘要:本文设计了一种高效的自然光能量采集自供电6loWPAN无线传感器节点,硬件部分主要采用了德州仪器的能量采集芯片BQ255 ...
- 图解Reformer:一种高效的Transformer
作者:Alireza Dirafzoon 编译:ronghuaiyang 导读 在单GPU上就可以运行的Transformer模型,而且几乎不损失精度,了解一下? 如果你一直在开发机器学习算法用 ...
- 3种方式限制ip访问Oracle数据库
墨墨导读:本文来自墨天轮读者投稿,分享了3种限制某个ip或某个ip段访问Oracle数据库的方式,希望对大家有帮助. 一.概述 本文将给大家介绍如何限制某个ip或某个ip段才能访问Oracle数据库 ...
- 10种相亲交友源码客户端存储,值得一看
数据持久 数据持久指将内存中的数据模型转化为存储模型,和将存储模型转化为内存中的数据模型这一过程的统称.在普通情况下,相亲交友源码存储的数据会一直保留,直到我们删除相关内容:或者是这些数据保存到浏览器 ...
- 无锁环形队列的几种高效实现
1.环形队列是什么 队列是一种常用的数据结构,这种结构保证了数据是按照"先进先出"的原则进行操作的,即最先进去的元素也是最先出来的元素.环形队列是一种特殊的队列结构,保证了元素也是 ...
- soapui工具_Java 开发者不容错过的 12 种高效工具
摘要:Java 程序员常常都会想办法如何更快地编写 Java 代码,让编程变得更加轻松.目前,市面上涌现出越来越多的高效编程工具.所以,以下总结了一系列工具列表,其中包含了大多数开发人员已经使用.正在 ...
最新文章
- mysql出现unblock with 'mysqladmin flush-hosts'
- 架构师必备技能指南:SaaS(软件即服务)架构设计
- 【数学和算法】初识卡尔曼滤波器(四)
- C#操作Excel文件(转)
- python的requests模块功能_《Python数据可视化编程实战》—— 1.7 安装requests模块-阿里云开发者社区...
- php mysql 表关联,mysql的多表关联_MySQL
- 网络html代码是什么问题,html代码问题
- 中国剧本推理市场洞察2021
- 12.1、Libgdx的图像之持续性和非持续性渲染
- 研发部门之间利益之争何时休?如何休?
- 西瓜书+实战+吴恩达机器学习(四)监督学习之线性回归 Linear Regression
- Arm 架构下的中断
- 毕业之际,个人学习感言和收获
- C#基础知识之读取xlsx文件Excel2007
- 3D人脸重建算法汇总
- [js高手之路]设计模式系列课程-委托模式实战微博发布功能
- IMDB-WIKI人脸属性数据集解析,dob matlab序列号转为出生日期
- 自动驾驶汽车传感器——摄像头
- 计算机无法访问,您可能没有权限使用网络资源.请与这台服务器的管理员联系的解决办
- linux系统上安装微信(Ubuntu/Debian 微信安装)
热门文章
- 关于爬虫平台的架构设计实现和框架的选型(二)--scrapy的内部实现以及实时爬虫的实现
- python使用循环嵌套金字塔_流程控制主while,for,python画金字塔,画9*9乘法表
- 和硅谷AI专家一起走进美团,探索美团外卖背后的AI大脑
- Photoshop学习(二十六):颜色模式
- 【转】从零开始React服务器渲染
- python教程80--两个Excel表做对比,找出表的值有哪些差异
- 搜索引擎营销(SEM)优势
- Oracle知识点整理
- C实现 题目 1209: 密码截获
- C++API免费下载