代理 ip 因为配置简单而且廉价,经常用来作为反反爬虫的手段,但是稳定性一直是其诟病。筛选出优质的代理 ip 并不简单,即使付费购买的代理 ip 源,卖家也不敢保证 100% 可用;另外代理 ip 的生命周期也无法预知,可能上一秒能用,下一秒就扑街了。基于这些原因,会给使用代理 ip 的爬虫程序带来很多不稳定的因素。要排除代理 ip 的影响,通常的做法是建一个代理 ip 池,每次请求前来池子取一个 ip,用完之后归还,保证池子里的 ip 都是可用的。本文接下来就探讨一下,如何使用 Redis 构建代理 ip 池,实现自动更新,自动择优。

整体流程

由上图所示,左侧是形成了整个流程的闭环,从爬虫程序以独占的方式拿到一个代理 ip 到爬取完成归还 ip。这个流程其实是不太严谨的,如果爬虫程序异常中断,就会导致 ip 无法归还,就会导致这个 ip 无法循环利用。但是由于代理 ip 本身的特点,量多而且循环利用的价值并不大,所以这种情况就let it go。

上面也提到 ip 是以独占的方式获取,如果是去爬两个毫不相关的网站,本来一个 ip 就可以,可现在需要两个。为了资源最大化使用,这里引入了频道 ip 池和总代理 ip 池。两个网站就当做两个频道,各自独占,互不相关;总池子就是保存所有的 ip,每个频道都共享。假设只有一个 ip:1.1.1.1 在总池子,爬 A 网站会把它从总池子取到 A 频道的 ip 池,然后 A 爬虫程序从 A 频道 ip 池取出 1.1.1.1 进行使用,这时 1.1.1.1 依然在总池子里,但 A 频道的 ip 池已经不包含 1.1.1.1 了;爬 B 网站也是一样的流程拿到 1.1.1.1,只是从 B 自己的频道池获取。下面就详细说说总池子和频道池子。

总代理 ip 池

总池子的作用就是共享所有可用的 ip,但是仅作为存储 ip 的池子并不能实现自动择优啊,这里的择优通常是希望延迟低速度快的 ip 更容易被筛选出,所以我们希望池子中的 ip 是根据它们的延时升序排列,借助 Redis 的 Sorted Sets 数据结构即可实现,用延时表示 score,ip 表示 member。

使用 ZADD 添加新 ip 或更新 ip 的延迟:

> ZADD proxy_global_ips 200 1.1.1.1:8080 100 2.2.2.2:80 300 3.3.3.3:8888
(integer) 3

使用 ZRANGE 获取 ip,可以指定获取的个数,比如取两个:

> ZRANGE proxy_global_ips 0 1 WITHSCORES
1) "2.2.2.2:80"
2) "100"
3) "1.1.1.1:8080"
4) "200"

频道 ip 池

频道 ip 池的作用是为了最大化使用总池子中的 ip,并且隔离其他频道的 ip 池。由于一个 ip 使用次数过多是有很大的概率被目标网站屏蔽掉,所以这里也需要进行择优,应该优先筛选出使用次数少的 ip,同理也是使用 Sorted Sets,使用次数表示 score,ip 表示 member,这里与总池子明显的不同之处是 key 不是固定的,需要把频道名称组合进去,这样保证频道之间的隔离,如频道 abc 的 key:proxy_channel_abc_ips

由于频道池子中的 ip 是要以独占的方式取出,我们需要一个 ZPOP 的方法,奈何 Redis 本身没有,还好可以通过 Lua 模拟,在一个原子操作下取出 ip,然后删除:

> eval "local el = redis.call('zrange', KEYS[1], 0, 0, 'WITHSCORES'); redis.call('zrem', KEYS[1], el[1]); return el;" 1 proxy_channel_abc_ips

往频道 ip 池添加 ip:

> ZADD proxy_channel_abc_ips INCR 0 1.1.1.1:8080

这里与总池子不同的是多了一个 INCR 选项,这是 Redis 3.0.2 版本后才支持的新特性,即指定在 ZADD 时发生 member 冲突采取的处理方式,INCR 顾名思义是冲突后累加 score 的方式,为什么要用这个选项,看看下面这个流程:

  1. 在频道池子中只有 1.1.1.1,使用次数为 10;总池子也有 1.1.1.1,而且排在第一个
  2. 线程 A 取出 1.1.1.1
  3. 线程 B 从频道池子取 ip,没取到,从总池子补充 ip 到频道池子:ZADD proxy_channel_abc_ips 0 1.1.1.1;取出 1.1.1.1
  4. 线程 A 归还 1.1.1.1:ZADD proxy_channel_abc_ips 11 1.1.1.1
  5. 线程 B 归还 1.1.1.1:ZADD proxy_channel_abc_ips 1 1.1.1.1

第 5 步结束后,ip 1.1.1.1 的计数被错误地重置为 1,而不是我们预期的 12。使用 INCR 选项就可以避免这个尴尬,其实这也只能保证最终计数正确,中途还是会有些非预期的情况,如:

  1. 在频道池子中有 1.1.1.1,使用次数为 10,还有 2.2.2.2,使用次数为 2;总池子也有 1.1.1.1,而且排在第一个
  2. 线程 A 取出 1.1.1.1
  3. 线程 B 取出 2.2.2.2
  4. 线程 C 从频道池子取 ip,没取到,从总池子补充 ip 到频道池子:ZADD proxy_channel_abc_ips 0 1.1.1.1;取出 1.1.1.1
  5. 线程 C 归还 1.1.1.1:ZADD proxy_channel_abc_ips INCR 1 1.1.1.1
  6. 线程 B 归还 2.2.2.2:ZADD proxy_channel_abc_ips INCR 3 2.2.2.2
  7. 线程 D 来池子取 ip,按使用次数少的被分配了 1.1.1.1,这就不是我们期望的,1.1.1.1 实际已经用了 12 次,我们更希望 2.2.2.2 被取出

如果要避免这个问题,一个简单粗暴的办法就是增加频道池子的容量,让 ip 数永远大于并发的线程数。

更新

与 ip 有关的两个属性:延时(爬取页面所花的时间)和使用次数。上面只讲到了根据它们自动择优,这里的就来说下它们是如何更新的。延时和使用次数的更新需要爬虫程序的配合,程序中要记录时间和递增使用次数,在归还 ip 时要将最新值带回给总池子和频道池子。上面频道 ip 池的例子也有提及,每次归还 ip 都要将最新的使用次数带上,其次还要将 ip 的延时更新到总池子里面。如果归还 ip 时出现使用失败的情况,就要将该 ip 从总池子里删除掉,保证该 ip 不会再被使用,至于当前的频道池不用归还就行了。其他频道池不作任何处理,因为 ip 在当前频道不可用,一般都是因为被屏蔽,其他频道依然可以使用,即使确实都不能使用,也会在其他频道归还 ip 时被删除。

这两个属性其实也可以都在 Redis 中更新,在获取 ip 时,使用 Hashs 保存 ip 对应的获取时间和使用次数;在归还时从 Hashs 中取出时间计算出延时,取出使用次数并加 1,再分别更新到总池子和频道池子中。而且这还能避免上面提到的获取 ip 不符合预期的问题。

总结

放在 Redis 中更新的方法也有弊端,延时会包含获取和归还的传输时间,如果爬虫程序获取一个 ip 多次使用,会造成使用次数统计偏少。当然也可以通过在程序中多次调用 Redis 更新 ip 的属性来解决,这样增加了整个流程的复杂性,需要自己权衡。

个人还是倾向在程序中记录,最后更新到 Redis 中。这个方案逻辑确实不够严谨,但是出现问题也不会导致严重后果。程序的健壮性也不是不允许出现 bug,而是出现 bug 有很好的容错性。

© 2017. All Rights Reserved.

Proudly published with Ghost