可惡的爬蟲直接把生產6台機器爬掛了!
引言
- 正在午睡,突然收到線上瘋狂報警的郵件,查看這個郵件發現這個報警的應用最近半個月都沒有發布,應該不至於會有報警,但是還是打開郵件通過監控發現是由於某個介面某個介面流量暴增,
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偽程式碼實現 我們上文《看了CopyOnWriteArrayList後自己實現了一個CopyOnWriteHashMap》有提到過對於讀多寫少的執行緒安全的容器我們可以選擇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結合優化。
- 1.項目啟動的時候把所有的
IP
黑名單全部初始化到一個全局的布隆過濾器 - 2.一個請求過來先經過布隆過濾器,判斷是否在布隆過濾器裡面,如果在的話我們再去看看是否在
redis
白名單裡面(誤殺用戶需要進行洗白)我們再去請求反爬系統判斷IP
是否是黑名單介面,如果介面返回是IP
黑名單直接返回錯誤碼給到前端,如果不是直接放行(布隆過濾器有一定的誤判,但是誤判率是非常小的,所以即使被誤判了,最後再去實際請求介面,這樣的話就不會存在真正的誤判真實用戶)。如果不存在布隆器直接放行。 - 3.如果是被誤殺的用戶,用戶進行了
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
來繼續爬你的網站,這樣的話成本可能就會加大了一點。爬蟲雖然好,但是還是不要亂爬,「爬蟲爬的好,牢飯吃到飽」
結束
- 由於自己才疏學淺,難免會有紕漏,假如你發現了錯誤的地方,還望留言給我指出來,我會對其加以修正。
- 如果你覺得文章還不錯,你的轉發、分享、讚賞、點贊、留言就是對我最大的鼓勵。
- 感謝您的閱讀,十分歡迎並感謝您的關注。