整合营销服务商

电脑端+手机端+微信端=数据同步管理

免费咨询热线:

基于Redis实现范围查询的IP库缓存设计方案

源于公众号Java艺术 ,

作者wujiuye


我先说下结果。我现在还不敢放线上去测,这是本地测的数据,我4g内存的电脑本地开redis,一次都没写完过全部数据,都是写一半后不是redis挂就是测试程序挂。可以肯定的是总记录数是以千万为单位的。O(log(n千万/range))的时间复杂度,本地测的结果我并不满意,7ms的时间,太久了。这个数量级的数据,就算内存缓存也很花时间,因为并不是简单的key-value,涉及到范围查找。






使用Sorted Set实现范围查找



最近系统需要更新IP库,IP库存储的是IP所属的国家和城市信息,广告主投放广告会有区域限制,所以需要根据点击广告的终端ip,获取位置信息,并判断是否满足广告投放区域的要求。


最头疼的是,IP信息库是按区间存储的,拿到一个ip得要知道它所属的范围才能知道它对应哪条记录。它的表结构是这样的:

Ip_from,ip_to,ccountry_code,country,regin,city

Ip起始段,Ip结束段,国家编码,国家,区域(比如中国的省),城市


而Ip_from和ip_to是一个数值,将ip通过公式转化后的数值。

a.b.c.d


A*(256*256*256)+b*(256*256)+c*(256)+d


为了效率,程序中的转换可以写为


a*(1<<24)+b*(1<<16)+c*(1<<8)+d


在此之前我都没注意以前是怎么实现查找的,以前是用内存缓存实现,但以前的数据比较少,而查找的方式用的递归,先不说递归查找算法的缺陷。而新的ip库数据量很大,本地debug直接OOM,没有足够的内存撑起这么大的ip库。


如果说,一个csv文件的大小是1g多,那么读取到jvm中,就需要4g甚至更高的内存,因为一个对象占的内存是比一行逗号隔开的字符串耗更大的内存。要实现查找算法,创建对应的数据结构,这些也会占用很大的内存。


综上所述,我们考虑用redis来缓存,当然,也可以用mongodb,如果是用mongodb缓存,那就简单了。既然要用Redis,那么就不得不面对,Redis如何实现范围查询,还要支持高并发。这算是一道难题了。


插入一段内容,关于如果使用Sorted Set实现范围查找,就是sql中的大于等于and小于等于。(下面是我参考的一篇博客,我觉得他的实现有些鸡肋,完全可以用一条:

zadd myset 1015 1011-1015-v1 替代两条记录)。

服务端对于客户端不同的版本区间会做些不同的配置,那么客户端一个版本过来怎么快速的定位是属于哪个版本区间呢?可以利用Sorted Sets的zrangebyscore命令。

zadd myset 1011 v1_start

zadd myset 1015 v1_end


zadd myset 1018 v2_start

zadd myset 1023 v2_end


如上我们像myset里插入了4条数据,代表的意思是版本区间v1是从1011-1015版本,版本区间v2是从1018-1023版本。


https://www.cnblogs.com/zhanjindong/p/3549994.html


那么我现在如何判断1014版本属于哪个区间呢,使用zrangebyscore如下操作:

zrangebyscore myset 1014 +inf LIMIT 0 1

1)v1_end


返回v1_end说明1014属于版本区间1,上面的这个命令的意思是在myset中查找第一个score值大于等于1014的member,如果我们查找一个不在区间内的版本,比如1016:

zrangebyscore myset 1014 +inf LIMIT 0 1

1)v2_start


https://www.cnblogs.com/zhanjindong/p/3549994.html


首先,我想到的是利用Redis的有序集合Sorted Set,存储每条记录的ip区间,或者叫ip范围。ip_to列作为有序集合的score。如:

zadd ip-country-city-locations-range 3756871679 3756871424~3756871679


这样就可以查询一个ip对应的score落在哪个区间,找出符合条件的第一个区间。如:


(1)将ip转为number,假设得到的值为:3756871650
(2)使用zrangebyscore命令获取所在区间
zrangebyscore ip-country-city-locations-range 3756871650 +inf 0 1

因为3756871650比3756871424~3756871679区间的end值3756871679小于等于,所以匹配到这条记录。但拿到这条记录后并不能说明3756871650在这个区间内,所以还要比较


3756871424<=3756871650<=3756871679


因为会存在这种情况,该区间与前一个区间并不是连续的,比如。

(1)3756870911=>3756870656~3756870911
(2)3756871679=>3756871424~3756871679


明显,这两个区间之间存在断层。但可以肯定的是,如果不落在区间(2)中,也不会落在区间(1)。所以不满足就可以直接返回null了。


Ip库用hash类型存储,field取ip_from或者ip_from&ip_to,value当然就是存完整的一行记录了。最后就可以根据拿到的区间信息获取到对应记录的field,使用hget命令就能获取到最终的一行ip信息记录。

hget ip-country-city-locations 3756871424

这并不难实现,但是,耗时却非常严重,可以看下官方文档介绍的Sorted Set的zrangebyscore的时间复杂度。IP库保守估计百万条记录,如果就这样上线,百分百又是服务雪崩。


改进思路:区间+Sorted Set


由于Sorted Set有序集合的查询时间复杂度是O(log(n)+m),其中n是总记录数,m是此次查询获取的记录数,在limit 0 1的情况下是O(log(n)),所以一个有序集合的元素个数越多,它的查询时间耗时越长。对于一般的应用场景,如排行榜,都是只有极少数的几百条记录。而如果用于ip库的区间查询实现,记录上百万条,而且还是用于高并发场景,不把服务搞垮才怪了。


既然我们要用Sorted Set,但又不能让集合的元素过大,那么我们可以分n/m个区间存储啊。假设有一百万条记录,每个Sorted Set存储1000条,那就用1000个Sorted Set集合来存储。hash的查询时间复杂度是接近O(1)的,增加1000个key在分槽位的分布式集群下根本不算什么。


按照上面的思路改进后,获取一个ip的国家城市信息就变成如下步骤:

1、根据ip计算出一个number值

比如:3756871650

2、根据区间大小(这一步的区间指的是每个Sorted Set的最大大小),计算出该number所在的集合的key


比如:ip-country-city-locations-range-375

3、根据这个key,去Sorted Set查询number所属的区间。

比如:zrangebyscore ip-country-city-locations-range 3756871650 +inf 0 1


5、拿到区间信息后,从区间信息获取ip范围位置信息的 field(hash类型存储)

比如查询结果区间信息为:3756871424~3756871679拿到field就是:3756871424


6、根据key和field拿到目标记录。

hget ip-country-city-locations 3756871424


编码实现


我将实现封装成一个组件,目的是对外提供更简单的使用方式,封装复杂的实现逻辑,同时,内部的改动对使用者无感知。通过SPI+分层设计,利用静态代理模式等,使得组件具有极强的扩展性,如果某天想改成使用mongodb或者内存缓存,只需要实现几个接口就可以了。



下面是README.MD的内容


关于数据源表的初始化

使用需要配置update启动参数:

-Dip.database.table.update=true

如:

java -Xss256k-jar -Dip.database.table.update=true xxx-1.0.0.jar

true: 首次启动就会从指定的url文件读取解析记录,插入数据表 false: 表示已经确认表存在记录了,不需要再更新。(也不会去解析文件) 默认:false

解析记录与插入表是异步的,后台开启一个线程执行。耗时根据文件大小决定,我测的是86s

配置使用表

使用了java的SPI

需要指定使用哪个文件解析器,也就对应使用哪种类型的表

配置redis操作实现类

使用了java的SPI

如果解析配置使用了

com.chestnut.ip.database.parser.RedisIP2LocationFileParser

则需要自己实现RedisOperation,并在

com.chestnut.ip.database.suppor.IP2LocationRedisOperation

文件中配置redis操作的实现类

缓存的key

如果使用redis存储数据,则key固定为

ip-country-city-locations // 存储真实记录ip-country-city-locations-range-* // 存储范围与真实记录的key的映射

其中ip-country-city-locations-range-后面的代表的分区索引

们查ip的时候都是利用ip138查询的,不过那个有时候是不准确的,还不如自己引用淘宝的ip库来查询,这样准确度还高一些。不多说了,介绍一下:

淘宝IP地址库,淘宝公布了他们的IP库http://ip.taobao.com/,还有REST API接口,不过每个用户的访问频率需小于10qps,访问方 式:http://ip.taobao.com/service/getIpInfo.PHP?ip=[ip地址字串],返回内容以json格式的。具有IP查询,IP统计等功能。各大运营商拥有的IP数等信息。接下来介绍一下获取ip的实例:

/**

* 通过淘宝IP接口获取IP地理位置

* @param string $ip

* @return: string

**/

function getCity($ip)

{

$url="http://ip.taobao.com/service/getIpInfo.php?ip=".$ip;

$ipinfo=json_decode(file_get_contents($url));

if($ipinfo->code=='1'){

return false;

}

$city = $ipinfo->data->region.$ipinfo->data->city;

return $city;

}

header("Content-type:text/html;charset=utf-8");

// 这样调用,显示山东省临沂市

var_dump(getCity("112.234.69.189"));

?>

调用的时候吧固定的ip替换成你想查询的ip就可以了。

近在使用爬虫爬取数据时,经常会返回403代码,大致意思是该IP访问过于频繁,被限制访问。限制IP访问网站最常用的反爬手段了,其实破解也很容易,就是在爬取网站是使用代理即可,这个IP被限制了,就使用其他的IP。对于高大上的公司来说,他们基本都使用收费的代理,基本不会有什么问题,比较稳定。像我这样的矮矬穷,肯定是用不起收费的代理。一般都是使用国内免费的代理,网上也有很多提供免费的代理。

很多人都是从网上爬取一批免费的代理IP,存放在存储媒介中,例如excel文件或者数据库。定时维护代理,保证代理可用。这个做法有个缺点,有些机器上并没有装有excel或者mysql、redis等数据库,这就导致了的代理池无法正常使用。

我之前是做java开发的,经常会把一些常用的数据放在ArrayList中,使用起来非常方便,效率高,因此借鉴之前在java方面的经验,将代理IP爬取下来存放在list列表中中,将list列表当做一个代理池,经常维护这个池里的代理。

我经常爬取免费代理的网站xicidaili

swei360等,这些免费的代理足够我使用了,能够应付大多数的爬虫工作。爬取过程需要用到requests和pyquery库,没有安装的同学自行安装。

首先介绍下爬取xicidaili网站的过程,

要先定义一个方法用于抓取xicidaili网站的,参数有两个,一个是url,另外一个是要爬取代理网页的页数,也就是要爬几页,方法如下:

def get_xicidaili_proxy(url,page):
 for i in range(1,page):
 headers = {
 "User-Agent": "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.36 SE 2.X MetaSr 1.0"}
 response = requests.get(url + str(i), headers=headers)
 html = response.text
 doc = pq(html)
 ip_list = doc('#ip_list')('tr:gt(0)').items()
 for item in ip_list:
 ip = item.find('td:nth-child(2)').text()
 port = item.find('td:nth-child(3)').text()
 http_type = item.find('td:nth-child(6)').text()
 proxy_ip = http_type + "://" + ip + ":" + port
 if http_type == 'HTTP':
 http_proxy_pool.append(proxy_ip)
 elif http_type == 'HTTPS':
 https_proxy_pool.append(proxy_ip)
 # print(proxy_ip)

定义了http_proxy_pool和https_proxy_pool两个list变量,用于存储http类型和https类型的代理。 使用PyQuery根据css伪选择器提取出ip,端口和http类型信息,并按照http:// + ip+port的方式组合成一个字符串,存储在已经定义好的http_proxy_tool和https_proxy_pool变量中。

爬取swei360网站代理的方法就不贴出来了,原理和爬取xicidaili网站是一样的。

一个代理在使用之前要判断是否可用,我们使用request的get请求的返回代码判断代理是否可用,返回200,就说明代理可用,返回其他的代码就表示代理不可用,代码如下:

def detect_proxy(test_url,http_type,proxy):
 headers = {
 "User-Agent": "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.36 SE 2.X MetaSr 1.0"}
 proxy={
 http_type : proxy
 }
 try:
 response = requests.get(test_url,proxies=proxy,headers=headers)
 if response.status_code in [200]:
 print('代理可用',proxy)
 return True
 else:
 print('代理不可用', proxy);
 delete_proxy(http_type,proxy)
 return False
 except(requests.exceptions.ProxyError,RequestException):
 print('代理不可用', proxy)
 delete_proxy(http_type, proxy)
 return False

定义了detect_proxy方法用于检测代理是否可用,有三个参数,分别是测试网址,代理类型(http和https)和代理IP。当requests的请求返回200代码时,就表示该代理可用,返回True,否则就是不可用,返回False。当遇到request异常或者其他的错误也认为代理不可用,返回False。对于不可用的代理,要从代理池中删除。

从代理池中获取代理时,我们使用的是从代理池中随机返回一个代理,这样就避免经常使用一个代理,从而遭到拒绝访问。代码如下:

def get_https_proxy():
 proxy_ip = random.choice(https_proxy_pool)
 return proxy_ip
def get_http_proxy():
 proxy_ip = random.choice(http_proxy_pool)
 return proxy_ip

为了保证代理的可用,当检测到一个代理不可用时,要及时的清理掉。就是从http_proxy_pool和https_proxy_pool列表中删除。

一个简单的爬虫代理池已经搭建好,总结下爬虫代理池搭建的过程:

  • 从免费的代理网站上爬取代理信息,存放在列表中。
  • 提供从代理池中随机获取代理的方法。http类型的网站要使用http类型的代理,https类型的网站要使用https类型的代理,因此分别提供获取http和https类型代理的方法。
  • 提供检测代理是否可用的方法,代理可用返回True,不可用返回False。
  • 提供删除代理的方法。

这个代理池其实相当的简单,有一个弊端就是在检测代理是否可用时,如果返回的不是200代码就认为代理不可用,返回其他代码的情况有很多,例如网络不可用、测试网站不可访问等。比较好的做法是给每个代理设置一个分值,例如10分,如果检测到不可用就减1,当分数为0时,就确定该代理不可用,直接从代理池中移除。检测到代理可用,就将分数设为10分。这种做法给每个检测到不可用代理一个改邪归正的机会,不至于一刀切的抛弃掉。

作者:Summer哥

出处:www.bigdata17.com