可惡的爬蟲直接把生產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。只能說這個元素是可能存在集合裡面。

  • 布隆過濾器添加元素
  1. 將要添加的元素給k個哈希函數
  2. 得到對應於位數組上的k個位置
  3. 將這k個位置設為1
  • 布隆過濾器查詢元素
  1. 將要查詢的元素給k個哈希函數
  2. 得到對應於位數組上的k個位置
  3. 如果k個位置有一個為0,則肯定不在集合中
  4. 如果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來繼續爬你的網站,這樣的話成本可能就會加大了一點。爬蟲雖然好,但是還是不要亂爬,「爬蟲爬的好,牢飯吃到飽

結束

  • 由於自己才疏學淺,難免會有紕漏,假如你發現了錯誤的地方,還望留言給我指出來,我會對其加以修正。
  • 如果你覺得文章還不錯,你的轉發、分享、讚賞、點贊、留言就是對我最大的鼓勵。
  • 感謝您的閱讀,十分歡迎並感謝您的關注。
    在這裡插入圖片描述
Tags: