• go语言实现高性能自定义ip管理模块(ip黑名单)


    ip黑名单设计

    对于IPV4而言,理论上有256^4个,也就是约42亿个。我想了好久,也查了挺多资料,但是,确实没有通用现成的解决方案。

    PS:以下方案的讨论,适用于对于IP管理不那么严苛的情况。当然也可以改进为严苛的方案,无非是加锁,加内存。

    非常欢迎有更好方案的大佬指点一下,因为我想了好久,实在没有更好的思路了,比如有大型业务系统的ip访问管理经验的,分享一下心得。

    考虑过的方案

    1. 只管理大陆的ip,对于其余ip直接不给通过
      当初我想过只包含国内的ip,也有渠道获取国内的ip列表,好像也有上亿。上亿的量不算大。可以用数组来存储。麻烦的是做映射和更新。所以放弃
    2. 用一维数组,结合位运算存储
      可以算一下,假设以int64的数组来存储,一个int64可以存储64个ip,256^4/64*8/1024/1024=512MB,也就是只需要这么多内存。这也是网上能查到资料给到的可考虑方案之一。
      但是,这种方案肯呢个做最基本的黑名单记录,某个位上标记为1,就表示它被拉黑了。为0就没被拉黑,但是,如果我需要记录ip访问的次数,封禁的时长这些呢?这个方案做不到。你说再用一个结构体来存储这些需要的信息?那不是得不偿失?
    3. redis来存储?不说内存占用多大,redis 是有网络开销的,性能就不达标了
    4. 对应IPv4 的四段关系,采用四层引用来关系来存储(当前方案)。存储结构如[256]*[256]*[256]*[4]int64

    第4方案优缺点

    存储IP的方法

    例如我要存储ip127.0.0.1,那么,我就会存储成[127]*[0]*[0]*[4]int64,上,最后这个1存在哪里,就是用位运算去处理,这里不再细说

    缺点
    1. 当ip存储量很大时,占用的空间多余直接一维数组的方式,因为指针和空值也是要占用空间的,而且不少
    2. 和第二种方案一样,同样无法记录更多的信息
    优点
    1. 链式索引结构,查找性能和一维数组的形式无明显差异,性能非常好
    2. 当ip量不大时,我不需要像一维数组那样初始化就搞个那么大的数组,这个方案最初只需要初始化[256]个空值,占用空间4096字节(64位机器上)
    3. 公网上很多ip段是不能被使用的,如果我们为这些IP段去做映射,可以节省空间,但是映射的规则非常复杂,增加代码的复杂度,降低可维护性。而采取这种方案,这些不可能出现的IP段也不会占用空间,所以根本无需考虑做映射。这也是比一维数组方案的一大优势。

    改进

    针对上述缺点,可以考虑,对于百万级别的ip,需要多大的业务量才达得到这个量级。我想全国每天能有百万个ip访问的系统,应该都是非常知名的了,所以,完全不用考虑这么多,所以,第一条缺点,不存在,反而证明了第二条优点。

    我最开始想要设计这个ip管理模块是因为想为自己的网站防御ddos攻击,虽然我不太懂ddos攻击,但是看来拉不拉黑名单对ddos没啥用,他是阻塞带宽从而使正常的访问无法进入。

    但是,我已经花了好多时间去向这个方案了。虽然对ddos没用,但是对系统稳定性有用嘛

    如何才能记录更多的信息

    我想要做的不仅仅是黑名单,而是ip管理,也就是可以记录IP是否访问,访问次数,什么时候访问,最近一次访问,是否被封禁,封禁时长,永久封禁,如何解封等

    记录信息,那么就需要有这个一个结构体,搞一堆字段,最后的结构应该是[256]*[256]*[256]*ipManage{}。这样固然好,记录什么信息都可以自由拓展。当然,你需要为他付出更多的内存空间。保存后面保存到数据库的空间。

    所以,我没钱租那么大内存的服务器,所以我选择简化方案。

    最终方案

    最终我综合考虑自己的业务需求,设计了如何结构体:

    type ipVisitS struct {
    	IpList            [256]*[256]*[256]*[256]int8
    	limit             int8  // 设置的每cycleSecond的访问上限
    	cycleSecond       int32 // 每次循环检查的间隔时长,单位秒
    	visitLimitBanTime int8  // 访问超限的封禁时长,单位分钟
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    IpList就是我拿来储存访问的IP信息的数组,我最后采用int8。而不是一个结构体,因为这样可以节省很多空间。

    简化的方案固然能表达的信息就少很多了:

    1. 指针索引为nil[256]int8所在位置上的值为0,表示该ip未记录
    2. [256]int8上记录的值大于0,表示该IP的访问次数。由于int8正数最大127,所以最多记录127次访问
    3. [256]int8上记录的值小于0,表示该IP被禁用时长,单位分钟。由于int8负数最小负128,且-128保留作为永封的标记,所以最多表示的封禁时长是127分钟。
    4. 如上所说-128表达为永封,当然你可以根据实际需求把更多负数赋予特殊含义

    是的,局限也很大,但是是我考虑实际得出的方案,不满足就可以换成int16,甚至是结构体。思路是一样的。
    另外解释下其他属性的含义:

    limit :每个ip在单位时间内(也就是cycleSecond)的访问上限,虽然int8正数最大127,但是未必就一定要让他的上限等于127,支持自定义
    cycleSecond:一个周期的时长,单位是秒,关系到limit 的记录周期,以及后面一个巡检的定时器周期,后面在介绍定时器
    visitLimitBanTime:访问超过limit后的封禁时长,单位是分钟

    代码实现

    ip管理模块实现如下接口:

    type IBlacklist2 interface {
    	/**
    	- @description: 将一个ip 字符串添加到名单里,单线程下添加一千万ip耗时约不到4秒
    	- @param {string} ipStr 形如127.0.0.1
    	- @return {*}
    					0: 输入的ip格式错误
    					(0,128): cycleSecond时间内访问次数
    					(-128,0):封禁时长
    					-128:    永封
    	*/
    	Add(ipStr string) int8
    	
    	/**
    	- @description: 增加封禁时长,如果还未被封禁,则等于封禁时长,如果已被封禁,则增加banTime
    	- @param {string} ipStr 封禁的IP地址
    	- @param {int8} banTime 用负数表示,数值表示增加的时长,单位分钟,-128表示永封
    	- @return {int8} 0: 输入的ip格式错误
    					(0,128): cycleSecond时间内访问次数
    					(-128,0):封禁时长
    					-128:    永封
    	*/
    	AddBanTime(ipStr string, banTime int8) int8 // 给一个ip增加封禁时长
    	
    	/**
    	 * @description: 判断一个ip是否被封禁,如果被封禁,则返回封禁时长,否则返回访问次数
    	 * @param {string} ipStr
    	 * @return {int8} 第一个返回值的含义:0:表示该ip未记录;>0:表示该ip的访问次数;<0:表示该ip的封禁时长;-128:表示该ip永久封禁
    			   {bool} 第二个返回值的含义:false:输入的ip有误,true:输入的ip正确
    	*/
    	IsBan(ipStr string) (int8, bool) // 判断一个ip是否被禁用
    	
    	GetLen() int                     //获取黑名单列表长度
    	GetAll() []string                // 获取黑名单列表,升序排序
    	GetSizeOf() uintptr              // 获取黑名单列表占用的内存空间
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35

    额外实现两个方法:

    /**
     * @description: 初始化
     * @param {int8} limit 设置的每cycleSecond的访问上限
     * @param {int32} cycleSecond 每次循环检查的间隔时长,单位秒
     * @param {int8} visitLimitBanTime 访问超限的封禁时长,单位分钟
     * @return {*ipVisitS} 对象
     * @return {error} 错误
     */
    func InitIpVisit(limit int8, cycleSecond int32, visitLimitBanTime int8,stopChan chan bool) (*ipVisitS, error) {
    	if !(limit >= 0 && limit <= 127) {
    		return nil, errors.New("limit的取值范围是 [0,127]")
    	} else if !(cycleSecond >= 1 && cycleSecond <= 3600) {
    		return nil, errors.New("cycleSecond的取值范围是 [1,3600]秒")
    	} else if !(visitLimitBanTime >= 1 && visitLimitBanTime <= 127) {
    		return nil, errors.New("visitLimitBanTime的取值范围是 [1,127]分钟")
    	}
    
    	ipVisit := &ipVisitS{
    		limit:             limit,
    		cycleSecond:       cycleSecond,
    		visitLimitBanTime: visitLimitBanTime,
    	}
    	ipVisit.CheckBlackList(stopChan) // 启动定时器
    	return ipVisit, nil
    }
    /**
     * @description: 定时任务,每cycleSecond循环检查一次,当取值范围为[-127,0)时,加1,表示封禁时长减一分钟。当取值范围为(0,127]时,减1,表示访问次数清零。且将不再记录ip段重置为空值,防止一直占用内存
     * @return {*} *time.Ticker的指针
     */
    func (ip *ipVisitS) CheckIPList() *time.Ticker {
    ······省略
    功能就是每个周期巡检IP列表
    1.封禁时长减1
    2.访问次数清零,重新记录。是的,直接清零,这也是该方案的一个缺陷,因为无依据判断访问的时间,索性直接清零
    3.清理历史记录的空间,释放内存
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36

    性能测试

    测试代码:

    	ipStart := time.Now()
    	ipList := test.CreateIp(1000000, 4) // 创建ip 一百万个,4个协程同时进行
    	fmt.Println("创建ip耗时:", time.Since(ipStart))
    	//初始化
    	stopchan:=make(chan bool,1)
    	onlyIp, _ := ipmanage.InitIpVisit(127, 60, 5,stopchan)
    
    	startTime := time.Now()
    	// 单线程运行添加ip
    	for i := 0; i < len(ipList); i++ {
    		onlyIp.Add(ipList[i])
    	}
    	//模拟第二次访问
    	for i := 0; i < 10000; i++ {
    		onlyIp.Add(ipList[i])
    	}
    	// 模拟第三次访问
    	for i := 0; i < 10000; i++ {
    		onlyIp.Add(ipList[i])
    	}
    
    	onlyIp.AddBanTime("0.0.0.1", -5)	// 模拟封禁5分钟
    	onlyIp.AddBanTime("127.0.0.1", -128) // 模拟永封
    
    	fmt.Println("记录ip耗时:", time.Since(startTime))
    	// 等待十次巡检
    	for i := 0; i < 10; i++ {
    		sizeStart := time.Now()
    		fmt.Println("ip队列占用内存(字节):", onlyIp.GetSizeOf())
    		fmt.Println("计算ip队列占用内存耗时:", time.Since(sizeStart))
    		allStart := time.Now()
    		fmt.Println("ip队列长度:", onlyIp.GetLen())
    		fmt.Println("计算ip队列长度耗时:", time.Since(allStart))
    		time.Sleep(time.Minute)
    	}
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35

    创建ip耗时: 65.4136ms
    记录ip耗时: 404.0073ms
    ip队列占用内存(字节): 390746112
    计算ip队列占用内存耗时: 14.0027ms
    ip队列长度: 1000002
    计算ip队列长度耗时: 369.1569ms

    可以看出,单线程应对百万IP,也仅需300多毫秒,我觉很OK了,当然,我没有加锁。追求极致性能。因为我的业务量下, 为了那极低概率会出现的线程不安全问题,加锁简直就是浪费,对于那偶尔的计算出错,无所谓。

    完整代码:https://gitee.com/lsjWeiYi/ip-manage

  • 相关阅读:
    「AIGC」Python实现tokens算法
    Spring MVC总结2 - @ControllerAdvice详解
    Vue+Element 仿飞书表格||甘特图表格 ,搜索、筛选、字段管理、卡片管理功能
    【机器学习】向量化计算 -- 机器学习路上必经路
    基于多传感器数据融合的全自动泊车系统研究与应用(文献综述)
    算法-动态规划-编辑距离
    数据结构之<RBTree >
    《Java并发编程的艺术》总结
    LAXCUS分布式操作系统是什么?
    阿里云99元ECS云服务器老用户也能买,续费同价!
  • 原文地址:https://blog.csdn.net/lsjweiyi/article/details/134539378