可恶的爬虫直接把生产 6 台机器爬挂了!

2021年1月21日 337点热度 0人点赞 0条评论
图片

作者 | 码农二胖  责编 | 张文
头图 | CSDN 下载自视觉中国
图片


图片
前言

正在午睡,突然收到线上疯狂报警的邮件,查看这个邮件发现这个报警的应用最近半个月都没有发布,应该不至于会有报警,但是还是打开邮件通过监控发现是由于某个接口流量暴增,CPU 暴涨。
为了先解决问题只能先暂时扩容机器了,把机器扩容了一倍,问题得到暂时的解决。
最后复盘为什么流量暴增?由于最近新上线了一个商品列表查询接口,主要用来查询商品信息,展示给到用户。业务逻辑也比较简单,直接调用底层一个 soa 接口,然后把数据进行整合过滤,排序推荐啥的,然后吐给前端。
这个接口平时流量都很平稳。线上只部署了 6 台机器,面对这骤增的流量,只能进行疯狂的扩容来解决这个问题。扩容机器后问题得到暂时的解决。后来经过请求分析,原来大批的请求都是无效的,都是爬虫过来爬取信息的。
这个接口当时上线的时候是裸着上的也没有考虑到会有爬虫过来。

图片

解决办法


既然是爬虫那就只能通过反爬来解决了。自己写一套反爬虫系统,根据用户的习惯,请求特征啥的,浏览器 cookie、同一个请求频率、用户 ID、以及用户注册时间等来实现一个反爬系统。
直接接入公司现有的反爬系统,需要按照它提供的文档来提供指定的格式请求日志让它来分析。既然能够直接用现成的,又何必自己重新造轮子呢。
最后决定还是采用接入反爬系统的爬虫组件。爬虫系统提供了两种方案如下:

方案 1:

爬虫系统提供批量获取黑名单 IP 的接口(getBlackIpList)和移除黑名单 IP 接口(removeBlackIp)。业务项目启动的时候,调用 getBlackIpList 接口把所有 IP 黑名单全部存入到本地的一个容器里面(Map、List),中间会有一个定时任务去调用 getBlackIpList 接口全量拉取黑名单(黑名单会实时更新,可能新增,也可能减少)来更新这个容器。
每次来一个请求先经过这个本地的黑名单 IP 池子,检验 IP 是否在这个池子里面。如果在这个池子直接返回爬虫错误码,然后让前端弹出一个复杂的图形验证码,如果用户输入验证码成功(爬虫基本不会去输入验证码),然后把 IP 从本地容器移除,同时发起一个异步请求调用移除黑名单 IP 接口(removeBlackIp),以防下次批量拉取黑单的时候又拉入进来了。然后在发送一个 activemq 消息告诉其他机器这个 IP 是被误杀的黑名单,其他机器接受到了这个消息也就会把自己容器里面这个 IP 移除掉。
其实同步通知其他机器也可以通过把这个 IP 存入 redis 里面,如果在命中容器里面是黑名单的时候,再去 redis 里面判断这个 ip 是否存在 redis 里面,如果存在则说明这个 ip 是被误杀的,应该是正常请求,下次通过定时任务批量拉取黑名单的时候,拉取完之后把这个 redis 里面的数据全部删除,或者让它自然过期。
这种方案:性能较好,基本都是操作本地内存。但是实现有点麻烦,要维护一份 IP 黑名单放在业务系统中。

图片

方案 2:

爬虫系统提供单个判断 IP 是否黑名单接口 checkIpIsBlack(但是接口耗时有点长 5s)和移除黑名单 IP 接口(removeBlackIp)。每一个请求过来都去调用爬虫系统提供的接口(判断 IP 是否在黑名单里面)这里有一个网络请求会有点耗时。
如果爬虫系统返回是黑名单,就返回一个特殊的错误码给到前端,然后前端弹出一个图形验证码,如果输入的验证码正确,则调用爬虫系统提供的移除 IP 黑名单接口,把 IP 移除。
这种方案:对于业务系统使用起来比较简单,直接调用接口就好,没有业务逻辑,但是这个接口耗时是没法忍受的,严重影响用户的体验。
最终综合考虑下来,最后决定采用方案 1。毕竟系统对响应时间是有要求的尽量不要增加不必要的耗时。

方案 1 实现

方案 1 伪代码实现,对于读多写少的线程安全的容器我们可以选择 CopyOnWrite 容器。
static CopyOnWriteArraySet blackIpCopyOnWriteArraySet = null; /** * 初始化 */ @PostConstruct public void init() { // 调用反爬系统接口 拉取批量黑名单 List<String> blackIpList = getBlackIpList(); // 初始化 blackIpCopyOnWriteArraySet = new CopyOnWriteArraySet(blackIpList); }
/** * 判断IP 是否黑名单 * @param ip * @return */ public boolean checkIpIsBlack(String ip) { boolean checkIpIsBlack = blackIpCopyOnWriteArraySet.contains(ip); if (!checkIpIsBlack ) return false; // 不在redis白名单里面 if (!RedisUtils.exist(String.format("whiteIp_%", ip)){ return false; } return true; }
上线后经过一段时间让爬虫系统消费我们的请求日志,经过一定模型特征的训练,效果还是很明显的。
由于大部分都是爬虫很多请求直接就被拦截了,所以线上的机器可以直接缩容掉一部分了又回到了 6 台。
但是好景不长,突然发现 GC 次数频繁告警不断。为了暂时解决问题,赶紧把生产机器进行重启(生产出问题之后,除了重启和回退还有什么解决办法吗),并且保留了一台机器把它拉出集群,重启之后发现过又是一样的还是没啥效果。
通过 dump 线上的一台机器,通过 MemoryAnalyzer 分析发现一个大对象就是我们存放 IP 的大对象,存放了大量的的 IP 数量。这个 IP 存放的黑名单是放在一个全局的静态 CopyOnWriteArraySet,所以每次 gc 它都不会被回收掉。只能临时把线上的机器配置都进行升级,由原来的 8 核 16g 直接变为 16 核 32g,新机器上线后效果很显著。
为啥测试环境没有复现?测试环境本来就没有什么其他请求,都是内网 IP,几个黑名单 IP 还是开发手动构造的。

解决方案

业务系统不再维护 IP 黑名单池子了,由于黑名单来自反爬系统,爬虫黑名单的数量不确定。所以最后决定采取方案 2 和方案 1 结合优化。
项目启动的时候把所有的 IP 黑名单全部初始化到一个全局的布隆过滤器
一个请求过来,先经过布隆过滤器,判断是否在布隆过滤器里面,如果在的话我们再去看看是否在 redis 白名单里面(误杀用户需要进行洗白)我们再去请求反爬系统判断 IP 是否是黑名单接口,如果接口返回是 IP 黑名单直接返回错误码给到前端;如果不是直接放行(布隆过滤器有一定的误判,但是误判率是非常小的,所以即使被误判了,最后再去实际请求接口,这样的话就不会存在真正的误判真实用户)。如果不存在布隆器直接放行。
如果是被误杀的用户,用户进行了 IP 洗白,布隆过滤器的数据是不支持删除(布谷鸟布隆器可以删除(可能误删)),把用户进行正确洗白后的 IP 存入 redis 里面。(或者一个本地全局容器,mq 消息同步其他机器)
下面我们先来了解下什么是布隆过滤器吧。
图片
什么是布隆过滤器

布隆过滤器(英语:Bloom Filter)是 1970 年由布隆提出的。它实际上是一个很长的二进制向量和一系列随机映射函数。布隆过滤器可以用于检索一个元素是否在一个集合中。它的优点是空间效率和查询时间都远远超过一般的算法,缺点是有一定的误识别率和删除困难。
上述出自百度百科。说白了布隆过滤器主要用来判断一个元素是否在一个集合中,它可以使用一个位数组简洁的表示一个数组。它的空间效率和查询时间远远超过一般的算法,不过它存在一定的误判的概率,适用于容忍误判的场景。如果布隆过滤器判断元素存在于一个集合中,那么大概率是存在在集合中,如果它判断元素不存在一个集合中,那么一定不存在于集合中。
实现原理
布隆过滤器的原理是,当一个元素被加入集合时,通过 K 个散列函数将这个元素映射成一个位数组(Bit array)中的 K 个点,把它们置为 1 。检索时,只要看看这些点是不是都是 1 就知道元素是否在集合中;如果这些点有任何一个  0,则被检元素一定不在;如果都是 1,则被检元素很可能在(之所以说“可能”是误差的存在)。
底层是采用一个 bit 数组和几个哈希函数来实现。

图片图片

下面我们以一个 bloom filter 插入"java" 和"PHP"为例,每次插入一个元素都进行了三次 hash 函数。
java 第一次 hash 函数得到下标是 2,所以把数组下标是 2 给置为 1;java 第二次 Hash 函数得到下标是 3,所以把数组下标是 3 给置为 1;java第三次 Hash 函数得到下标是 5,所以把数组下标是 5 给置为1;PHP 第一次 Hash 函数得到下标是 5,所以把数组下标是 5 给置为 1
...
查找的时候,当我们去查找 C++ 的时候发现第三次 hash 位置为 0,所以 C++ 一定是不在不隆过滤器里面。但是我们去查找“java”这个元素三次 hash 出来对应的点都是 1。只能说这个元素是可能存在集合里面。
布隆过滤器添加元素
  • 将要添加的元素给 k 个哈希函数
  • 得到对应于位数组上的 k 个位置
  • 将这 k 个位置设为 1
布隆过滤器查询元素
  • 将要查询的元素给 k 个哈希函数
  • 得到对应于位数组上的 k 个位置
  • 如果 k 个位置有一个为 0,则肯定不在集合中
  • 如果 k 个位置全部为 1,则可能在集合中

使用 BloomFilter

引入 pom
<dependency> <groupId>com.google.guava</groupId> <artifactId>guava</artifactId> <version>23.0</version> </dependency>
public static int count = 1000000; private static BloomFilter<String> bf = BloomFilter.create(Funnels.stringFunnel(Charset.forName("utf-8")), count,0.009); public static void main(String[] args) { int missCount = 0; for (int i = 0; i < count; i++) { bf.put(i+""); } for (int i = count; i < count+1000000; i++) { boolean b = bf.mightContain(i +""); if (b) { missCount++; } } System.out.println(new BigDecimal(missCount).divide(new BigDecimal(count))); }

解决问题

布隆过滤器介绍完了,我们再回到上述的问题,我们把上述问题通过伪代码来实现下;
/** * 初始化 */ @PostConstruct public void init() { // 这个可以通过配置中心来读取 double fpp = 0.001; // 调用反爬系统接口 拉取批量黑名单 List<String> blackIpList = getBlackIpList(); // 初始化 不隆过滤器 blackIpBloomFilter = BloomFilter.create(Funnels.stringFunnel(Charset.forName("utf-8")), blackIpList.size(), fpp); for (String ip: blackIpList) { blackIpBloomFilter.put(ip); } } /** * 判断是否是爬虫 */ public boolean checkIpIsBlack(String ip) { boolean contain = blackIpBloomFilter.mightContain(ip); if (!contain) { return false; } // 不在redis白名单里面 if (!RedisUtils.exist(String.format("whiteIp_%", ip)){ return false; } // 调用反爬系统接口 判断IP是否在黑名单里面 }
图片

总结


上述只是列举了通过 IP 来反爬虫,这种反爬的话只能应对比较低级的爬虫,如果稍微高级一点的爬虫也可以通过代理 IP 来继续爬你的网站,这样的话成本可能就会加大了一点。
爬虫虽然好,但是还是不要乱爬。“爬虫爬的好,牢饭吃到饱”。
图片

程序员如何避免陷入“内卷”、选择什么技术最有前景,中国开发者现状与技术趋势究竟是什么样?快来参与「2020 中国开发者大调查」,更有丰富奖品送不停!

图片

滴滴开源的损失!章文嵩将离职,曾是阿里开源“赶集人”,投身开源 20 年

红帽急了:新年的 RHEL 将有低成本或免费版

不打不相识,苹果偷学微信代码

图片
图片
图片
图片
在看
61870可恶的爬虫直接把生产 6 台机器爬挂了!

这个人很懒,什么都没留下

文章评论