高性能分佈式緩存的設計原理

image

又是一個沒有開工紅包的公司!!!

問題分析

通過以上對話,各位是否能夠猜到所有緩存穿透的原因呢?回答之前我們先來看一下緩存策略的具體代碼

緩存服務器IP=hash(key)%服務器數量

這裏還要多說一句,key的取值可以根據具體業務具體設計。比如,我想要做負載均衡,key可以爲調用方的服務器IP;獲取用戶信息,key可以爲用戶ID;等等。

在服務器數量不變的情況下,以上設計沒有問題。但是要知道,程序員的現實世界是悲慘的,唯一不變的就是業務一直在變。我本無奈,只能靠技術來改變這種狀況。

假如我們現在服務器的數量爲10,當我們請求key爲6的時候,結果是4,現在我們增加一臺服務器,服務器數量變爲11,當再次請求key爲6的服務器的時候,結果爲5.不難發現,不光是key爲6的請求,幾乎大部分的請求結果都發生了變化,這就是我們要解決的問題, 這也是我們設計分佈式緩存等類似場景時候主要需要注意的問題。

我們終極的設計目標是:在服務器數量變動的情況下

  1. 儘量提高緩存的命中率(轉移的數據最少)
  2. 緩存數據儘量平均分配

解決方案

通過以上的分析我們明白了,造成大量緩存失效的根本原因是公式分母的變化,如果我們把分母保持不變,基本上可以減少大量數據被移動

分母不變方案

如果基於公式:緩存服務器IP=hash(key)%服務器數量 我們保持分母不變,基本上可以改善現有情況。我們選擇緩存服務器的策略會變爲:

緩存服務器IP=hash(key)%N (N爲常數)
N的數值選擇,可以根據具體業務選擇一個滿足情況的值。比如:我們可以肯定將來服務器數量不會超過100臺,那N完全可以設定爲100。那帶來的問題呢?

目前的情況可以認爲服務器編號是連續的,任何一個請求都會命中一個服務器,還是以上作爲例子,我們服務器現在無論是10還是增加到11,key爲6的請求總是能獲取到一臺服務器信息,但是現在我們的策略公式分母爲100,如果服務器數量爲11,key爲20的請求結果爲20,編號爲20的服務器是不存在的。

以上就是簡單哈希策略帶來的問題(簡單取餘的哈希策略可以抽象爲連續的數組元素,按照下標來訪問的場景)

爲了解決以上問題,業界早已有解決方案,那就是一致性哈希

一致性哈希算法在1997年由麻省理工學院的Karger等人在解決分佈式Cache中提出的,設計目標是爲了解決因特網中的熱點(Hot spot)問題,初衷和CARP十分類似。一致性哈希修正了CARP使用的簡單哈希算法帶來的問題,使得DHT可以在P2P環境中真正得到應用。

一致性哈希具體的特點,請各位百度,這裏不在詳細介紹。至於解決問題的思路這裏還要強調一下:

  1. 首先求出服務器(節點)的哈希值,並將其配置到環上,此環有2^32個節點。
  2. 採用同樣的方法求出存儲數據的鍵的哈希值,並映射到相同的圓上。
  3. 然後從數據映射到的位置開始順時針查找,將數據保存到找到的第一個服務器上。如果超過2^32仍然找不到服務器,就會保存到第一臺服務器上

當增加新的服務器的時候會發生什麼情況呢?
image
通過上圖我們可以發現發生變化的只有如黃色部分所示。刪除服務器情況類似。

通過以上介紹,一致性哈希正是解決我們目前問題的一種方案。解決方案千萬種,能解決問題即爲好。

優化方案

到目前爲止方案都看似完美,但現實是殘酷的。以上方案雖好,但還存在瑕疵。假如我們有3臺服務器,理想狀態下服務器在哈希環上的分配如下圖:
image
但是現實往往是這樣:
image
這就是所謂的哈希環偏斜。分佈不均勻在某些場景下會依次壓垮服務器,實際生產環境一定要注意這個問題。爲了解決這個問題,虛擬節點應運而生。
image
如上圖,哈希環上不再是實際的服務器信息,而是服務器信息的映射信息,比如:ServerA-1,ServerA-2 都映射到服務器A,在環上是服務器A的一個複製品。這種解決方法是利用數量來達到均勻分佈的目的,隨之需要的內存可能會稍微大一點,算是空間換取設計的一種方案。

擴展閱讀

  • 既然是哈希就會有哈希衝突,那多個服務器節點的哈希值相同該怎麼辦呢?我們可以採用散列表尋址的方案:從當前位置順時針開始查找空位置,直到找到一個空位置。如果未找到,菜菜認爲你的哈希環是不是該擴容了,或者你的分母參數是不是太小了呢。
  • 在實際的業務中,增加服務器或者減少服務器的操作要比查找服務器少的多,所以我們存儲哈希環的數據結構的查找速度一定要快,具體說來本質是:自哈希環的某個值起,能快速查找第一個不爲空的元素。
  • 如果你度娘過你就會發現,網上很多介紹虛擬哈希環節點個數爲2^32(2的32次方),千篇一律。難道除了這個個數就不可以嗎?在菜菜看來,這個數目完全必要這麼大,只要符合我們的業務需求,滿足業務數據即可。
  • 一致性哈希用到的哈希函數,不止要保證比較高的性能,還要保持哈希值的儘量平均分佈,這也是一個工業級哈希函數的要求,一下代碼實例的哈希函數其實不是最佳的,有興趣的同學可以優化一下。
  • 有些語言自帶的GetHashCode()方法應用於一致性哈希是有問題的,例如c#。程序重啓之後同一個字符串的哈希值是變動的。所有需要一個更加穩定的字符串轉int的哈希算法。

一致性哈希解決的本質問題是:相同的key通過相同的哈希函數,能正確路由到相同的目標。像我們平時用的數據庫分表策略,分庫策略,負載均衡,數據分片等都可以用一致性哈希來解決。

理論結合實際纔是真諦(NetCore代碼)

以下代碼經過少許修改可直接應用於中小項目生產環境

 //真實節點的信息
    public abstract class NodeInfo
    {
        public abstract string NodeName { get; }
    }

測試程序所用節點信息:

    class Server : NodeInfo
        {
            public string IP { get; set; }
            public override string NodeName
            {
                get => IP;
            }
        }

以下爲一致性哈希核心代碼:

 /// <summary>
    /// 1.採用虛擬節點方式  2.節點總數可以自定義  3.每個物理節點的虛擬節點數可以自定義
    /// </summary>
    public class ConsistentHash
    {
        //哈希環的虛擬節點信息
        public class VirtualNode
        {
            public string VirtualNodeName { get; set; }
            public NodeInfo Node { get; set; }
        }

        //添加元素 刪除元素時候的鎖,來保證線程安全,或者採用讀寫鎖也可以
        private readonly object objLock = new object();

        //虛擬環節點的總數量,默認爲100
        int ringNodeCount;
        //每個物理節點對應的虛擬節點數量
        int virtualNodeNumber;
        //哈希環,這裏用數組來存儲
        public VirtualNode[] nodes = null;
        public ConsistentHash(int _ringNodeCount = 100, int _virtualNodeNumber = 3)
        {
            if (_ringNodeCount <= 0 || _virtualNodeNumber <= 0)
            {
                throw new Exception("_ringNodeCount和_virtualNodeNumber 必須大於0");
            }
            this.ringNodeCount = _ringNodeCount;
            this.virtualNodeNumber = _virtualNodeNumber;
            nodes = new VirtualNode[_ringNodeCount];
        }
        //根據一致性哈希key 獲取node信息,查找操作請業務方自行處理超時問題,因爲多線程環境下,環的node可能全被清除
        public NodeInfo GetNode(string key)
        {
            var ringStartIndex = Math.Abs(GetKeyHashCode(key) % ringNodeCount);
            var vNode = FindNodeFromIndex(ringStartIndex);
            return vNode == null ? null : vNode.Node;
        }
        //虛擬環添加一個物理節點
        public void AddNode(NodeInfo newNode)
        {
            var nodeName = newNode.NodeName;
            int virtualNodeIndex = 0;
            lock (objLock)
            {
                //把物理節點轉化爲虛擬節點
                while (virtualNodeIndex < virtualNodeNumber)
                {
                    var vNodeName = $"{nodeName}#{virtualNodeIndex}";
                    var findStartIndex = Math.Abs(GetKeyHashCode(vNodeName) % ringNodeCount);
                    var emptyIndex = FindEmptyNodeFromIndex(findStartIndex);
                    if (emptyIndex < 0)
                    {
                        // 已經超出設置的最大節點數
                        break;
                    }
                    nodes[emptyIndex] = new VirtualNode() { VirtualNodeName = vNodeName, Node = newNode };
                    virtualNodeIndex++;
                   
                }
            }
        }
        //刪除一個虛擬節點
        public void RemoveNode(NodeInfo node)
        {
            var nodeName = node.NodeName;
            int virtualNodeIndex = 0;
            List<string> lstRemoveNodeName = new List<string>();
            while (virtualNodeIndex < virtualNodeNumber)
            {
                lstRemoveNodeName.Add($"{nodeName}#{virtualNodeIndex}");
                virtualNodeIndex++;
            }
            //從索引爲0的位置循環一遍,把所有的虛擬節點都刪除
            int startFindIndex = 0;
            lock (objLock)
            {
                while (startFindIndex < nodes.Length)
                {
                    if (nodes[startFindIndex] != null && lstRemoveNodeName.Contains(nodes[startFindIndex].VirtualNodeName))
                    {
                        nodes[startFindIndex] = null;
                    }
                    startFindIndex++;
                }
            }

        }


        //哈希環獲取哈希值的方法,因爲系統自帶的gethashcode,重啓服務就變了
        protected virtual int GetKeyHashCode(string key)
        {
            var sh = new SHA1Managed();
            byte[] data = sh.ComputeHash(Encoding.Unicode.GetBytes(key));
            return BitConverter.ToInt32(data, 0);

        }

        #region 私有方法
        //從虛擬環的某個位置查找第一個node
        private VirtualNode FindNodeFromIndex(int startIndex)
        {
            if (nodes == null || nodes.Length <= 0)
            {
                return null;
            }
            VirtualNode node = null;
            while (node == null)
            {
                startIndex = GetNextIndex(startIndex);
                node = nodes[startIndex];
            }
            return node;
        }
        //從虛擬環的某個位置開始查找空位置
        private int FindEmptyNodeFromIndex(int startIndex)
        {

            while (true)
            {
                if (nodes[startIndex] == null)
                {
                    return startIndex;
                }
                var nextIndex = GetNextIndex(startIndex);
                //如果索引回到原地,說明找了一圈,虛擬環節點已經滿了,不會添加
                if (nextIndex == startIndex)
                {
                    return -1;
                }
                startIndex = nextIndex;
            }
        }
        //獲取一個位置的下一個位置索引
        private int GetNextIndex(int preIndex)
        {
            int nextIndex = 0;
            //如果查找的位置到了環的末尾,則從0位置開始查找
            if (preIndex != nodes.Length - 1)
            {
                nextIndex = preIndex + 1;
            }
            return nextIndex;
        }
        #endregion
    }

測試生成的節點

            ConsistentHash h = new ConsistentHash(200, 5);
            h.AddNode(new Server() { IP = "192.168.1.1" });
            h.AddNode(new Server() { IP = "192.168.1.2" });
            h.AddNode(new Server() { IP = "192.168.1.3" });
            h.AddNode(new Server() { IP = "192.168.1.4" });
            h.AddNode(new Server() { IP = "192.168.1.5" });

            for (int i = 0; i < h.nodes.Length; i++)
            {
                if (h.nodes[i] != null)
                {
                    Console.WriteLine($"{i}===={h.nodes[i].VirtualNodeName}");
                }
            }

輸出結果(還算比較均勻):

2====192.168.1.3#4
10====192.168.1.1#0
15====192.168.1.3#3
24====192.168.1.2#2
29====192.168.1.3#2
33====192.168.1.4#4
64====192.168.1.5#1
73====192.168.1.4#3
75====192.168.1.2#0
77====192.168.1.1#3
85====192.168.1.1#4
88====192.168.1.5#4
117====192.168.1.4#1
118====192.168.1.2#4
137====192.168.1.1#1
152====192.168.1.2#1
157====192.168.1.5#2
158====192.168.1.2#3
159====192.168.1.3#0
162====192.168.1.5#0
165====192.168.1.1#2
166====192.168.1.3#1
177====192.168.1.5#3
185====192.168.1.4#0
196====192.168.1.4#2

測試一下性能

            Stopwatch w = new Stopwatch();
            w.Start();
            for (int i = 0; i < 100000; i++)
            {
                var aaa = h.GetNode("test1");
            }
            w.Stop();
            Console.WriteLine(w.ElapsedMilliseconds);

輸出結果(調用10萬次耗時657毫秒):

657

寫在最後

以上代碼實有優化空間

  1. 哈希函數
  2. 很多for循環的臨時變量
    有興趣優化的同學可以留言哦!!

添加關注,查看更精美版本,收穫更多精彩

image

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章