程式設計師修仙之路--優雅快速的統計千萬級別uv(留言送書)

架構師修行之路發表於2019-07-01

菜菜,我們們網站現在有多少PV和UV了?

Y總,我們們沒有統計pv和uv的系統,預估大約有一千萬uv吧

寫一個統計uv和pv的系統吧

網上有現成的,直接接入一個不行嗎?

別人的不太放心,畢竟自己寫的,自己擁有主動權。給你兩天時間,系統效能不要太差呀

好吧~~~

定義
PV是page view的縮寫,即頁面瀏覽量,通常是衡量一個網路新聞頻道或網站甚至一條網路新聞的主要指標。網頁瀏覽數是評價網站流量最常用的指標之一,簡稱為PV


UV是unique visitor的簡寫,是指通過網際網路訪問、瀏覽這個網頁的自然人。

通過以上的概念,可以清晰的看出pv是比較好設計的,網站的每一次被訪問,pv都會增加,但是uv就不一定會增加了,uv本質上記錄的是按照某個標準劃分的自然人,這個標準其實我們可以自己去定義,比如:可以定義同一個IP的訪問者為同一個UV,這也是最常見的uv定義之一,另外還有根據cookie定義等等。無論是pv還是uv,都需要一個時間段來加以描述,平時我們所說的pv,uv數量指的都是24小時之內(一個自然日)的資料。


pv相比較uv來說,技術上比較容易一些,今天我們們就來說一說uv的統計,為什麼說uv的統計相對來說比較難呢,因為uv涉及到同一個標準下的自然人的去重,尤其是一個uv千萬級別的網站,設計一個好的uv統計系統也許並非想象的那麼容易。


那我們就來設計一個以一個自然日為時間段的uv統計系統,一個自然人(uv)的定義為同一個來源IP(當然你也可以自定義其他標準),資料量級別假設為每日千萬uv的量級。

注意:今天我們討論的重點是獲取到自然人定義的資訊之後如何設計uv統計系統,並非是如何獲取自然人的定義。uv系統的設計並非想象的那麼簡單,因為uv可能隨著網站的營銷策略會出現瞬間大流量,比如網站舉辦了一個秒殺活動。

基於DB方案

服務端程式設計有一句名言曰:沒有一個表解決不了的功能,如果有那就兩個表三個表。一個uv統計系統確實可以基於資料庫來實現,而且也不復雜,uv統計的記錄表可以類似如下(不要太糾結以下表設計是否合理):

欄位型別描述
IPvarchar(30)客戶端來源ip
DayIDint時間的簡寫,例如 20190629
其他欄位int其他欄位描述


當一個請求到達伺服器,服務端每次需要查詢一次資料庫是否有當前IP和當前時間的訪問記錄,如果有,則說明是同一個uv,如果沒有,則說明是新的uv記錄,插入資料庫。當然以上兩步也可以寫到一個sql語句中:

if exists( select 1 from table where ip='ip' and dayid=dayid )
  Begin
    return 0
  End
else
  Begin
     insert into table .......
  End

所有基於資料庫的解決方案,在資料量大的情況下幾乎都更容易出現瓶頸。面對每天千萬級別的uv統計,基於資料庫的這種方案也許並不是最優的。

優化方案

面對每一個系統的設計,我們都應該沉下心來思考具體的業務。至於uv統計這個業務有幾個特點:

1. 每次請求都需要判斷是否已經存在相同的uv記錄

2. 持久化uv資料不能影響正常的業務

3. uv資料的準確性可以忍受一定程度的誤差

雜湊表

基於資料庫的方案中,在大資料量的情況下,效能的瓶頸引發原因之一就是:判斷是否已經存在相同記錄,所以要優化這個系統,肯定首先是要優化這個步驟。根據菜菜以前的文章,是否可以想到解決這個問題的資料結構,對,就是雜湊表。雜湊表根據key來查詢value的時間複雜度為O(1)常數級別,可以完美的解決查詢相同記錄的操作瓶頸。

也許在uv資料量比較小的時候,雜湊表也許是個不錯的選擇,但是面對千萬級別的uv資料量,雜湊表的雜湊衝突和擴容,以及雜湊表佔用的記憶體也許並不是好的選擇了。假設雜湊表的每個key和value  佔用10位元組,1千萬的uv資料大約佔用 100M,對於現代計算機來說,100M其實不算大,但是有沒有更好的方案呢?

優化雜湊表

基於雜湊表的方案,在千萬級別資料量的情況下,只能算是勉強應付,如果是10億的資料量呢?那有沒有更好的辦法搞定10億級資料量的uv統計呢?這裡拋開持久化資料,因為持久化設計到資料庫的分表分庫等優化策略了,我們們以後再談。有沒有更好的辦法去快速判斷在10億級別的uv中某條記錄是否存在呢?

為了儘量縮小使用的記憶體,我們可以這樣設計,可以預先分配bit型別的陣列,陣列的大小是統計的最大資料量的一個倍數,這個倍數可以自定義調整。現在假設系統的uv最大資料量為1千萬,系統可以預先分配一個長度為5千萬的bit陣列,bit佔用的記憶體最小,只佔用一位。按照一個雜湊衝突比較小的雜湊函式計算每一個資料的雜湊值,並設定bit陣列相應雜湊值位置的值為1。由於雜湊函式都有衝突,有可能不同的資料會出現相同的雜湊值,出現誤判,但是我們可以用多個不同的雜湊函式來計算同一個資料,來產生不同的雜湊值,同時把這多個雜湊值的陣列位置都設定為1,從而大大減少了誤判率,剛才新建的陣列為最大資料量的一個倍數也是為了減小衝突的一種方式(容量越大,衝突越小)。當一個1千萬的uv資料量級,5千萬的bit陣列佔用記憶體才幾十M而已,比雜湊表要小很多,在10億級別下記憶體佔用差距將會更大。


以下為程式碼示例:

class BloomFilter
    {
        BitArray container = null;
      public BloomFilter(int length)
        
{
            container = new BitArray(length);
        }

        public void Set(string key)
        
{
            var h1 = Hash1(key);
            var h2 = Hash2(key);
            var h3 = Hash3(key);
            var h4 = Hash4(key);
            container[h1] = true;
            container[h2] = true;
            container[h3] = true;
            container[h4] = true;

        }
        public bool Get(string key)
        
{
            var h1 = Hash1(key);
            var h2 = Hash2(key);
            var h3 = Hash3(key);
            var h4 = Hash4(key);

            return container[h1] && container[h2] && container[h3] && container[h4];
        }

        //模擬雜湊函式1
         int Hash1(string key)
        
{
            int hash = 5381;
            int i;
            int count;
            char[] bitarray = key.ToCharArray();
            count = bitarray.Length;
            while (count > 0)
            {
                hash += (hash << 5) + (bitarray[bitarray.Length - count]);
                count--;
            }
            return (hash & 0x7FFFFFFF) % container.Length;

        }
         int Hash2(string key)
        
{
            int seed = 131// 31 131 1313 13131 131313 etc..
            int hash = 0;
            int count;
            char[] bitarray = (key+"key2").ToCharArray();
            count = bitarray.Length;
            while (count > 0)
            {
                hash = hash * seed + (bitarray[bitarray.Length - count]);
                count--;
            }

            return (hash & 0x7FFFFFFF)% container.Length;
        }
         int Hash3(string key)
        
{
            int hash = 0;
            int i;
            int count;
            char[] bitarray = (key + "keykey3").ToCharArray();
            count = bitarray.Length;
            for (i = 0; i < count; i++)
            {
                if ((i & 1) == 0)
                {
                    hash ^= ((hash << 7) ^ (bitarray[i]) ^ (hash >> 3));
                }
                else
                {
                    hash ^= (~((hash << 11) ^ (bitarray[i]) ^ (hash >> 5)));
                }
                count--;
            }

            return (hash & 0x7FFFFFFF) % container.Length;

        }
        int Hash4(string key)
        
{
            int hash = 5381;
            int i;
            int count;
            char[] bitarray = (key + "keykeyke4").ToCharArray();
            count = bitarray.Length;
            while (count > 0)
            {
                hash += (hash << 5) + (bitarray[bitarray.Length - count]);
                count--;
            }
            return (hash & 0x7FFFFFFF) % container.Length;
        }
    }

測試程式為:

BloomFilter bf = new BloomFilter(200000000);
            int exsitNumber = 0;
            int noExsitNumber = 0;

            for (int i=0;i < 10000000; i++)
            {
                string key = $"ip_{i}";
                var isExsit= bf.Get(key);
                if (isExsit)
                {
                    exsitNumber += 1;
                }
                else
                {
                    bf.Set(key);
                    noExsitNumber += 1;
                }
            }
            Console.WriteLine($"判斷存在的資料量:{exsitNumber}");
            Console.WriteLine($"判斷不存在的資料量:{noExsitNumber}");

 測試結果:

判斷存在的資料量:7017
判斷不存在的資料量:9992983


佔用記憶體40M,誤判率不到 千分之1,在這個業務場景下在可接受範圍之內。在真正的業務當中,系統並不會在啟動之初就分配這麼大的bit陣列,而是隨著衝突增多慢慢擴容到一定容量的。

非同步優化

當判斷一個資料是否已經存在這個過程解決之後,下一個步驟就是把資料持久化到DB,如果資料量較大或者瞬間資料量較大,可以考慮使用mq或者讀寫IO比較大的NOSql來代替直接插入關係型資料庫。

思路一轉,整個的uv流程其實也都可以非同步化,而且也推薦這麼做。



福利送書

公眾號的本文第5,15,30個留言者將獲得技術書一本(自付郵費),新增菜菜微信領取吧。福利群內會經常送書哦!!


架構師之路,菜菜與君一起成長

長按識別二維碼關注


相關文章