基于PHP+Redis 的ip行为分析及封禁

这个任务的需求来源是因为现在网站的一些接口,比如登录、注册、粉丝、关注等相关功能接口以及部分其他接口存在被一些黑客大量访问、攻击、刷接口以达到某些非法目的情况。对于这一状况,我们需要按照一定的接口频率上限规则,对非法访问接口的 ip 进行行为分析,初步阶段只进行 ip 访问接口频率的判断,并且数据库中存有相关接口要求的 单ip/min 的访问量上限。

那么这件事情就挺好解决了,在数据库中存取某些行为对应的单ip每分钟的访问量上限,接口调度的时候通过请求,将客户端ip和端口限制的行为ID传过来,接着继续执行自身的业务, ip 记录接口保存 ip 和行为 ID 组合在一起的键值对在缓存中值 inc 一次, ip 行为分析工具没分钟自动检索缓存中超过上限 ip ,对其进行封禁即可。

但是海哥告诉我, Redis 中的数据类型有很多种,它不同的数据类型存储、查询等操作,都会有不同的时间复杂度,我们做这个ip行为分析工具,面对的是高并发的状况,不仅要将这个功能做出来,还要考虑到代码的性能以及可用性。于是我就去查阅了一些与Redis命令相关的资料,再进行一些设计上的比较,得到了两种较为合理实现方案。  

  1. Sorted Set 进行存储,每个 [时间:行为] 作为一个存储 KeyIP 作为 Sorted Set 中的 Valueip 每次访问就对该存储Key进行分数增加,利用 Sorted Set 的分数 score ,取区间分数内的 Value ,也就是 ip 集合,对该集合进行封禁。

  2. 利用 Hash 进行存储,每个 [时间:行为] 作为 Hash 的一个存储 KeyIP 作为 Hashfield ,每个 field 的值为 ip -> [时间:行为] 的访问次数。通过 HINCRBY 增加 field 的值并取回当前值,判断是否超过行为的上限,如果超过上限,则将 ip 添加到 [时间:行为] willBan (一个名为 willBanSet 集合) 中,分析机每分钟进行缓存分析,使用 SMEMBERSwillBan Set 中的元素取出直接封禁即可,我们这个项目还要获取ip在当前分钟内的访问量,所以我们通过 HMGETHashwillBan Set 对应的 VALUE 取出。

这两个方法都可以,但是时间复杂度上有一些差别,一个是程序上的时间复杂度,一个是缓存中的时间复杂度。  

  1. Sorted Set 进行存储,虽然可以直接得到分数值score,代表着IP在某一分钟内的访问量,但是Sorted Set 的 ZINCRBY 时间复杂度是 O( log(N) )的,并且, ZINCRBY的操作是非常频繁的,在高并发的情况下,Sorted Set 被进行 ZINCRBY 的次数是非常高的,相比之下行为分析机器所占用的缓存资源几乎可以忽略不计。假设当前每分钟的访问量是 v ,那么这个方案,每一次访问在缓存中的时间复杂度近似于  O( log(v) ),在程序中的复杂度近似于 O(1)。

  2. Hash进行存储,再判断 HINCRBY 的返回值是否大于行为上限,如果大于再将IP记录在 SET 中。在缓存中,HINCRBY的时间复杂度是 O(1) ,SADD的时间复杂度是 O(N),由于只插入一个ip,所以时间复杂度是 O(1) 。在程序中的时间复杂度为 存储的时间复杂度+判断的时间复杂度+if(需要SADD) 的时间复杂度,也就是 3 * O(1),几乎可以忽略不计,这个方案中占用等级到N的,的是每分钟的ip行为分析机,通过SMEMBER得到成员 O(1) ,再HMGET得到被封禁的ip值,时间复杂度是( O(N) ),N是被即将要被封禁的数量,而被封禁的ip数量N,相对于全局来说,真的太小了(毕竟这个世界上还是好人多?)。

而目前来说,针对目前高并发,并且接口请求及其频繁的情况下,采用方案2,是比较好的。   当然, 这个是在PHP上实现的,还有一些其他的方式,比如 nginx + lua + redis ,在服务器(Nginx)端就进行封禁。比如 Nginx + Lua + OpenResty 的高可用性应用开发,可以直接在Nginx端就进行处理,这样速度很快,而且由于Nginx的轻量性,可以很好的处理高并发的情况,在本篇,就不展开来说了。

[注] 1:fastcgi_finish_request(); //可以直接终止请求避免请求方端等待时间过长,但依旧可以继续处理请求,但是仅限 phpfast-cgi 运行模式。

[注] 2:inc 是一个统称,代表将缓存中的值增加或减少一个数值,具体的实现方式有所不同,在 Redis 中有 INCRBY HINCRBY ZINCRBY 等,分别针对不同的存储结构。

附加:

  • 如果在程序运行过程中,想删除 key,但是key是hash、zset等数据结构,存储的数据量很大,建议通过设置key的过期时间为1,否则通过 $redis->删除,需要等待redis进行删除操作完成后才能得到相应结果,这样程序的执行时间就会变长。而设置了key的过期时间,可以让redis自动删除,虽然过期时,Redis进行keyd的删除时间也长,但并不会让程序进入等待。