服务器开发设计之算法宝典
作者:lynhlzou,騰訊 IEG 后臺開發工程師
孫子云:“上兵伐謀,其次伐交,其次伐兵,其下攻城”,最上乘行軍打仗的方式是運用謀略,下乘的方式才是與敵人進行慘烈的廝殺。同樣的,在程序設計中,解決問題的辦法有很多種,陷入到與邏輯進行貼身肉搏的境況實屬下下之策,而能運用優秀合理的算法才是”伐謀”的上上之策。
算法的思想精髓是值得深入研究和細細品味的,本寶典總結了服務器開發設計過程中涉及到的一些常用算法,試圖盡量以簡潔的文字和圖表來解釋和說明其中的思想原理,希望能給大家帶來一些思考和啟示。
思維導圖
1. 調度算法
在服務器邏輯開發設計中,調度算法隨處可見,資源的調度,請求的分配,負載均衡的策略等等都與調度算法相關。調度算法沒有好壞之分,最適合業務場景的才是最好的。
1.1. 輪詢
輪詢是非常簡單且常用的一種調度算法,輪詢即將請求依次分配到各個服務節點,從第一個節點開始,依次將請求分配到最后一個節點,而后重新開始下一輪循環。最終所有的請求會均攤分配在每個節點上,假設每個請求的消耗是一樣的,那么輪詢調度是最平衡的調度(負載均衡)算法。
1.2. 加權輪詢
有些時候服務節點的性能配置各不相同,處理能力不一樣,針對這種的情況,可以根據節點處理能力的強弱配置不同的的權重值,采用加權輪詢的方式進行調度。
加權輪詢可以描述為:
調度節點記錄所有服務節點的當前權重值,初始化為配置對應值。
當有請求需要調度時,每次分配選擇當前權重最高的節點,同時被選擇的節點權重值減一。
若所有節點權重值都為零,則重置為初始化時配置的權重值。
最終所有請求會按照各節點的權重值成比例的分配到服務節點上。假設有三個服務節點{a,b,c},它們的權重配置分別為{2,3,4},那么請求的分配次序將是{c,b,c,a,b,c,a,b,c},如下所示:
| 1 | {2,3,4} | c | {2,3,3} |
| 2 | {2,3,3} | b | {2,2,3} |
| 3 | {2,2,3} | c | {2,2,2} |
| 4 | {2,2,2} | a | {1,2,2} |
| 5 | {1,2,2} | b | {1,1,2} |
| 6 | {1,1,2} | c | {1,1,1} |
| 7 | {1,1,1} | a | {0,1,1} |
| 8 | {0,1,1} | b | {0,0,1} |
| 9 | {0,0,1} | c | {0,0,0} |
1.3. 平滑權重輪詢
加權輪詢算法比較容易造成某個服務節點短時間內被集中調用,導致瞬時壓力過大,權重高的節點會先被選中直至達到權重次數才會選擇下一個節點,請求連續的分配在同一個節點上的情況,例如假設三個服務節點{a,b,c},權重配置分別是{5,1,1},那么加權輪詢調度請求的分配次序將是{a,a,a,a,a,b,c},很明顯節點 a 有連續的多個請求被分配。
為了應對這種問題,平滑權重輪詢實現了基于權重的平滑輪詢算法。所謂平滑,就是在一段時間內,不僅服務節點被選擇次數的分布和它們的權重一致,而且調度算法還能比較均勻的選擇節點,不會在一段時間之內集中只選擇某一個權重較高的服務節點。
平滑權重輪詢算法可以描述為:
調度節點記錄所有服務節點的當前權重值,初始化為配置對應值。
當有請求需要調度時,每次會先把各節點的當前權重值加上自己的配置權重值,然后選擇分配當前權重值最高的節點,同時被選擇的節點權重值減去所有節點的原始權重值總和。
若所有節點權重值都為零,則重置為初始化時配置的權重值。
同樣假設三個服務節點{a,b,c},權重分別是{5,1,1},那么平滑權重輪詢每一輪的分配過程如下表所示:
最終請求分配的次序將是{ a, a, b, a, c, a, a},相對于普通權重輪詢算法會更平滑一些。1.4. 隨機
隨機即每次將請求隨機地分配到服務節點上,隨機的優點是完全無狀態的調度,調度節點不需要記錄過往請求分配情況的數據。理論上請求量足夠大的情況下,隨機算法會趨近于完全平衡的負載均衡調度算法。
1.5. 加權隨機
類似于加權輪詢,加權隨機支持根據服務節點處理能力的大小配置不同的的權重值,當有請求需要調度時,每次根據節點的權重值做一次加權隨機分配,服務節點權重越大,隨機到的概率就越大。最終所有請求分配到各服務節點的數量與節點配置的權重值成正比關系。
1.6. 最小負載
實際應用中,各個請求很有可能是異構的,不同的請求對服務器的消耗各不相同,無論是使用輪詢還是隨機的方式,都可能無法準確的做到完全的負載均衡。最小負載算法是根據各服務節點當前的真實負載能力進行請求分配的,當前負載最小的節點會被優先選擇。
最小負載算法可以描述為:
服務節點定時向調度節點上報各自的負載情況,調度節點更新并記錄所有服務節點的當前負載值。
當有請求需要調度時,每次分配選擇當前負載最小(負載盈余最大)的服務節點。
負載情況可以統計節點正在處理的請求量,服務器的 CPU 及內存使用率,過往請求的響應延遲情況等數據,綜合這些數據以合理的計算公式進行負載打分。
1.7. 兩次隨機選擇策略
最小負載算法可以在請求異構情況下做到更好的均衡性。然而一般情況下服務節點的負載數據都是定時同步到調度節點,存在一定的滯后性,而使用滯后的負載數據進行調度會導致產生“群居”行為,在這種行為中,請求將批量地發送到當前某個低負載的節點,而當下一次同步更新負載數據時,該節點又有可能處于較高位置,然后不會被分配任何請求。再下一次又變成低負載節點被分配了更多的請求,一直處于這種很忙和很閑的循環狀態,不利于服務器的穩定。
為應對這種情況,兩次隨機選擇策略算法做了一些改進,該算法可以描述為:
服務節點定時向調度節點上報各自的負載情況,調度節點更新并記錄所有服務節點的當前負載值。
從所有可用節點列表中做兩次隨機選擇操作,得到兩個節點。
比較這兩個節點負載情況,選擇負載更低的節點作為被調度的節點。
兩次隨機選擇策略結合了隨機和最小負載這兩種算法的優點,使用負載信息來選擇節點的同時,避免了可能的“群居”行為。
1.8. 一致性哈希
為了保序和充分利用緩存,我們通常希望相同請求 key 的請求總是會被分配到同一個服務節點上,以保持請求的一致性,既有了一致性哈希的調度方式。
關于一致性哈希算法,筆者曾在 km 發表過專門的文章《一致性哈希方案在分布式系統中應用對比》,詳細介紹和對比了它們的優缺點以及對比數據,有興趣的同學可以前往閱讀。
1.8.1. 劃段
最簡單的一致性哈希方案就是劃段,即事先規劃好資源段,根據請求的 key 值映射找到所屬段,比如通過配置的方式,配置 id 為[1-10000]的請求映射到服務節點 1,配置 id 為[10001-20000]的請求映射到節點 2 等等,但這種方式存在很大的應用局限性,對于平衡性和穩定性也都不太理想,實際業務應用中基本不會采用。
1.8.2. 割環法
割環法的實現有很多種,原理都類似。割環法將 N 臺服務節點地址哈希成 N 組整型值,該組整型即為該服務節點的所有虛擬節點,將所有虛擬節點打散在一個環上。
請求分配過程中,對于給定的對象 key 也哈希映射成整型值,在環上搜索大于該值的第一個虛擬節點,虛擬節點對應的實際節點即為該對象需要映射到的服務節點。
如下圖所示,對象 K1 映射到了節點 2,對象 K2 映射到節點 3。
割環法實現復雜度略高,時間復雜度為 O(log(vn)),(其中,n 是服務節點個數,v 是每個節點擁有的虛擬節點數),它具有很好的單調性,而平衡性和穩定性主要取決于虛擬節點的個數和虛擬節點生成規則,例如 ketama hash 割環法采用的是通過服務節點 ip 和端口組成的字符串的 MD5 值,來生成 160 組虛擬節點。
1.8.3. 二次取模
取模哈希映射是一種簡單的一致性哈希方式,但是簡單的一次性取模哈希單調性很差,對于故障容災非常不好,一旦某臺服務節點不可用,會導致大部分的請求被重新分配到新的節點,造成緩存的大面積遷移,因此有了二次取模的一致性哈希方式。
二次取模算法即調度節點維護兩張服務節點表:松散表(所有節點表)和緊實表(可用節點表)。請求分配過程中,先對松散表取模運算,若結果節點可用,則直接選取;若結果節點已不可用,再對緊實表做第二次取模運算,得到最終節點。如下圖示:
二次取模算法實現簡單,時間復雜度為 O(1),具有較好的單調性,能很好的處理縮容和節點故障的情況。平衡性和穩定性也比較好,主要取決于對象 key 的分布是否足夠散列(若不夠散列,也可以加一層散列函數將 key 打散)。
1.8.4. 最高隨機權重
最高隨機權重算法是以請求 key 和節點標識為參數進行一輪散列運算(如 MurmurHash 算法),得出所有節點的權重值進行對比,最終取最大權重值對應的節點為目標映射節點。可以描述為如下公式:
散列運算也可以認為是一種保持一致性的偽隨機的方式,類似于前面講到的普通隨機的調度方式,通過隨機比較每個對象的隨機值進行選擇。
這種方式需要 O(n)的時間復雜度,但換來的是非常好的單調性和平衡性,在節點數量變化時,只有當對象的最大權重值落在變化的節點上時才受影響,也就是說只會影響變化的節點上的對象的重新映射,因此無論擴容,縮容和節點故障都能以最小的代價轉移對象,在節點數較少而對于單調性要求非常高的場景可以采用這種方式。
1.8.5. Jump consistent hash
jump consistent hash 通過一種非常簡單的跳躍算法對給定的對象 key 算出該對象被映射的服務節點,算法如下:
int?JumpConsistentHash(unsigned?long?long?key,?int?num_buckets) {long?long??b?=?-1,?j?=?0;while?(j?<?num_buckets)?{b?=?j;key?=?key?*?2862933555777941757ULL?+?1;j?=?(b?+?1)?*?(double(1LL?<<?31)?/?double((key?>>?33)?+?1));}return?b; }這個算法乍看難以理解,它其實是下面這個算法的一個變種,只是將隨機函數通過線性同余的方式改造而來的。
int?ch(int?key,?int?num_buckets)?{random.seed(key)?;int?b?=?-1;?//??bucket?number?before?the?previous?jumpint?j?=?0;?//?bucket?number?before?the?current?jumpwhile(j?<?num_buckets){b?=?j;double?r?=?random.next();?//??0<r<1.0j?=?floor(?(b+1)?/?r);}return?b; }它也是一種偽隨機的方式,通過隨機保證了平衡性,而這里隨機函數用到的種子是各個請求的 key 值,因此保證了一致性。它與最高隨機權重的差別是這里的隨機不需要對所有節點都進行一次隨機,而是通過隨機值跳躍了部分節點的比較。
jump consistent hash 實現簡單,零內存消耗,時間復雜度為 O(log(n))。具有很高的平衡性,在單調性方面,擴容和縮容表現較好,但對于中間節點故障,理想情況下需要將故障節點與最后一個節點調換,需要將故障節點和最后的節點共兩個節點的對象進行轉移。###1.8.6. 小結
一致性哈希方式還有很多種類,通常結合不同的散列函實現。也有些或為了更簡單的使用,或為了更好的單調性,或為了更好的平衡性等而對以上這些方式進行的改造等,如二次 Jump consistent hash 等方式。另外也有結合最小負載方式等的變種,如有限負載一致性哈希會根據當前負載情況對所有節點限制一個最大負載,在一致性哈希中對 hash 進行映射時跳過已達到最大負載限制的節點,實際應用過程中可根據業務情況自行做更好的調整和結合。
2. 不放回隨機抽樣算法
不放回隨機抽樣即從 n 個數據中抽取 m 個不重復的數據。關于不放回隨機抽樣算法,筆者曾在 km 發表過專門的文章詳細演繹和實現了各種隨機抽樣算法的原理和過程,以及它們的優缺點和適用范圍,有興趣的同學可以前往閱讀。
2.1. Knuth 洗牌抽樣
不放回隨機抽樣可以當成是一次洗牌算法的過程,利用洗牌算法來對序列進行隨機排列,然后選取前 m 個序列作為抽樣結果。
Knuth 洗牌算法是在 Fisher-Yates 洗牌算法中改進而來的,通過位置交換的方式代替了刪除操作,將每個被刪除的數字交換為最后一個未刪除的數字(或最前一個未刪除的數字)。
Knuth 洗牌算法可以描述為:
生成數字 1 到 n 的隨機排列(數組索引從 1 開始)
for i from 1 to n-1 do
j ← 隨機一個整數值 i ≤ j < n
交換 a[j] 和 a[i]
運用 Knuth 洗牌算法進行的隨機抽樣的方式稱為 Knuth 洗牌隨機抽樣算法,由于隨機抽樣只需要抽取 m 個序列,因此洗牌流程只需洗到前 m 個數據即可。
2.2. 占位洗牌隨機抽樣
Knuth 洗牌算法是一種 in-place 的洗牌,即在原有的數組直接洗牌,盡管保留了原數組的所有元素,但它還是破壞了元素之間的前后順序,有些時候我們希望原數組僅是可讀的(如全局配置表),不會因為一次抽樣遭到破壞,以滿足可以對同一原始數組多次抽樣的需求,如若使用 Knuth 抽樣算法,必須對原數組先做一次拷貝操作,但這顯然不是最好的做法,更好的辦法在 Knuth 洗牌算法的基礎上,不對原數組進行交換操作,而是通過一個額外的 map 來記錄元素間的交換關系,我們稱為占位洗牌算法。
占位洗牌算法過程演示如下:
最終,洗牌的結果為 3,5,2,4,1。
運用占位洗牌算法實現的隨機抽樣的方式稱為占位洗牌隨機抽樣,同樣的,我們依然可以只抽取到前 m 個數據即可。這種算法對原數組不做任何修改,代價是增加不大于 的臨時空間。
2.3. 選擇抽樣技術抽樣
洗牌算法是對一個已經預初始化好的數據列表進行洗牌,需要在內存中全量緩存數據列表,如果數據總量 n 很大,并且單條記錄的數據也很大,那么在內存中緩存所有數據記錄的做法會顯得非常的笨拙。而選擇選擇抽樣技術算法,它不需要預先全量緩存數據列表,從而可以支持流式處理。
選擇抽樣技術算法可以描述為:
生成 1 到 n 之間的隨機數 U
如果 U≥m,則跳轉到步驟 4
把這個記錄選為樣本,m 減 1,n 減 1。如果 m>0,則跳轉到步驟 1,否則取樣完成,算法終止
跳過這個記錄,不選為樣本,n 減 1,跳轉到步驟 1
選擇抽樣技術算法過程演示如下:
最終,抽樣的結果為 2,5。
可以證明,選擇選擇抽樣技術算法對于每個數被選取的概率都是 。
選擇抽樣技術算法雖然不需要將數據流全量緩存到內存中,但他仍然需要預先準確的知道數據量的總大小即 n 值。它的優點是能保持輸出順序與輸入順序不變,且單個元素是否被抽中可以提前知道。
2.4. 蓄水池抽樣
很多時候我們仍然不知道數據總量 n,上述的選擇抽樣技術算法就需要掃描數據兩次,第一次先統計 n 值,第二次再進行抽樣,這在流處理場景中仍然有很大的局限性。
Alan G. Waterman 給出了一種叫蓄水池抽樣(Reservoir Sampling)的算法,可以在無需提前知道數據總量 n 的情況下仍然支持流處理場景。
蓄水池抽樣算法可以描述為:
數據游標 i←0,將 i≤m 的數據一次放入蓄水池,并置 pool[i] ←i
生成 1 到 i 之間的隨機數 j
如果 j>m,則跳轉到步驟 5
把這個記錄選為樣本,刪除原先蓄水池中 pool[j]數據,并置 pool[j] ←i
游標 i 自增 1,若 i<n,跳轉到步驟 2,否則取樣完成,算法終止,最后蓄水池中的數據即為總樣本
蓄水池抽樣算法過程演示如下:
最終,抽樣的結果為 1,5。
可以證明,每個數據被選中且留在蓄水池中的概率為 。
2.5. 隨機分值排序抽樣
洗牌算法也可以認為就是將數據按隨機的方式做一個排序,從 n 個元素集合中隨機抽取 m 個元素的問題就相當于是隨機排序之后取前 m 排名的元素,基于這個原理,我們可以設計一種通過隨機分值排序的方式來解決隨機抽樣問題。
隨機分值排序算法可以描述為:
系統維護一張容量為 m 的排行榜單
對于每個元素都給他們隨機一個(0,1] 區間的分值,并根據隨機分值插入排行榜
所有數據處理完成,最終排名前 m 的元素即為抽樣結果
盡管隨機分值排序抽樣算法相比于蓄水池抽樣算法并沒有什么好處,反而需要增加額外的排序消耗,但接下來的帶權重隨機抽樣將利用到它的算法思想。
2.6. 樸素的帶權重抽樣
很多需求場景數據元素都需要帶有權重,每個元素被抽取的概率是由元素本身的權重決定的,諸如全服消費抽獎類活動,需要以玩家在一定時間段內的總消費額度為權重進行抽獎,消費越高,最后中獎的機會就越大,這就涉及到了帶權重的抽樣算法。
樸素的帶權重隨機算法也稱為輪盤賭選擇法,將數據放置在一個假想的輪盤上,元素個體的權重越高,在輪盤上占據的空間就越多,因此就更有可能被選中。
假設上面輪盤一到四等獎和幸運獎的權重值分別為 5,10,15,30,40,所有元素權重之和為 100,我們可以從[1, 100] 中隨機得到一個值,假設為 45,而后從第一個元素開始,不斷累加它們的權重,直到有一個元素的累加權重包含 45,則選取該元素。如下所示:
由于權重 45 處于四等獎的累加權重值當中,因此最后抽樣結果為四等獎。
若要不放回的選取 m 個元素,則需要先選取一個,并將該元素從集合中踢除,再反復按同樣的方法抽取其余元素。
這種抽樣算法的復雜度是 ,并且將元素從集合中刪除破壞了原數據的可讀屬性,更重要的是這個算法需要多次遍歷數據,不適合在流處理的場景中應用。
2.7. 帶權重的 A-Res 算法蓄水池抽樣
樸素的帶權重抽樣算法需要內存足夠容納所有數據,破壞了原數據的可讀屬性,時間復雜度高等缺點,而經典的蓄水池算法高效的實現了流處理場景的大數據不放回隨機抽樣,但對于帶權重的情況,就不能適用了。
A-Res(Algorithm A With a Reservoir) 是蓄水池抽樣算法的帶權重版本,算法主體思想與經典蓄水池算法一樣都是維護含有 m 個元素的結果集,對每個新元素嘗試去替換結果集中的元素。同時它巧妙的利用了隨機分值排序算法抽樣的思想,在對數據做隨機分值的時候結合數據的權重大小生成排名分數,以滿足分值與權重之間的正相關性,而這個 A-Res 算法生成隨機分值的公式就是:
其中 為第 i 個數據的權重值, 是從(0,1]之間的一個隨機值。
A-Res 算法可以描述為:
對于前 m 個數, 計算特值 ,直接放入蓄水池中
對于從 m+1,m+2,...,n 的第 i 個數,通過公式 計算特征值 ,如若特征值超過蓄水池中最小值,則替換最小值
該算法的時間復雜度為 ,且可以完美的運用在流式處理場景中。
2.8. 帶權重的 A-ExpJ 算法蓄水池抽樣
A-Res 需要對每個元素產生一個隨機數,而生成高質量的隨機數有可能會有較大的性能開銷,《Weighted random sampling with a reservoir》論文中給出了一種更為優化的指數跳躍的算法 A-ExpJ 抽樣(Algorithm A with exponential jumps),它能將隨機數的生成量從 減少到 ,原理類似于通過一次額外的隨機來跳過一段元素的特征值 的計算。
A-ExpJ 算法蓄水池抽樣可以描述為:
對于前 m 個數, 計算特征值 ,其中 為第 i 個數據的權重值, 是從(0,1]之間的一個隨機值,直接放入蓄水池中
對于從 m+1,m+2,...,n 的第 i 個數,執行以下步驟
計算閾值 , ,其中 r 為(0,1]之間的一個隨機值, 為蓄水池中的最小特征值
跳過部分元素并累加這些元素權重值 ,直到第 i 個元素滿足
計算當前元素特征值 ,其中 為(,1]之間的一個隨機值,, 為蓄水池中的最小特征值, 為當前元素權重值
使用當前元素替換蓄水池中最小特征值的元素
更新閾值 ,
有點不好理解,show you the code:
function?aexpj_weight_sampling(data_array,?weight_array,?n,?m)local?result,?rank?=?{},?{}for?i=1,?m?dolocal?rand_score?=?math.random()?^?(1?/?weight_array[i])local?idx?=?binary_search(rank,?rand_score)table.insert(rank,?idx,?{score?=?rand_score,?data?=?data_array[i]})endlocal?weight_sum,?xw?=?0,?math.log(math.random())?/?math.log(rank[m].score)for?i=m+1,?n?doweight_sum?=?weight_sum?+?weight_array[i]if?weight_sum?>=?xw?thenlocal?tw?=?rank[m].score?^?weight_array[i]local?rand_score?=?(math.random()*(1-tw)?+?tw)?^?(1?/?weight_array[i])local?idx?=?binary_search(rank,?rand_score)table.insert(rank,?idx,?{score?=?rand_score,?data?=?data_array[i]})table.remove(rank)weight_sum?=?0xw?=?math.log(math.random())?/?math.log(rank[m].score)endendfor?i=1,?m?doresult[i]?=?rank[i].dataendreturn?result end3. 排序算法
3.1. 基礎排序
基礎排序是建立在對元素排序碼進行比較的基礎上進行的排序算法。
3.1.1. 冒泡排序
冒泡排序是一種簡單直觀的排序算法。它每輪對每一對相鄰元素進行比較,如果相鄰元素順序不符合規則,則交換他們的順序,每輪將有一個最小(大)的元素浮上來。當所有輪結束之后,就是一個有序的序列。
過程演示如下:
3.1.2. 插入排序
插入排序通過構建有序序列,初始將第一個元素看做是一個有序序列,后面所有元素看作未排序序列,從頭到尾依次掃描未排序序列,對于未排序數據,在已排序序列中從后向前掃描,找到相應位置并插入。
過程演示如下:
3.1.3. 選擇排序
選擇排序首先在未排序序列中找到最小(大)元素,存放到已排序序列的起始位置。再從剩余未排序元素中繼續尋找最小(大)元素,然后放到已排序序列的末尾。直到所有元素處理完畢。
過程演示如下:
插入排序是每輪會處理好第一個未排序序列的位置,而選擇排序是每輪固定好一個已排序序列的位置。冒泡排序也是每輪固定好一個已排序序列位置,它與選擇排序之間的不同是選擇排序直接選一個最小(大)的元素出來,而冒泡排序通過依次相鄰交換的方式選擇出最小(大)元素。
3.1.4. 快速排序
快速排序使用分治法策略來把一串序列分為兩個子串序列。快速排序是一種分而治之的思想在排序算法上的典型應用。本質上來看,快速排序應該算是在冒泡排序基礎上的遞歸分治法。
快速排序從數列中挑出一個元素,稱為"基準",所有元素比基準值小的擺放在基準前面,比基準值大的擺在基準的后面。一輪之后該基準就處于數列的中間位置。并遞歸地把小于基準值元素的子數列和大于基準值元素的子數列進行排序。
過程演示如下:
3.1.5. 歸并排序
歸并排序是建立在歸并操作上的一種有效的排序算法,也是采用分治法的一個非常典型的應用。歸并排序首先將序列二分成最小單元,而后通過歸并的方式將兩兩已經有序的序列合并成一個有序序列,直到最后合并為一個最終有序序列。
過程演示如下:
3.1.6. 堆排序
堆排序(Heapsort)是利用堆這種數據結構所設計的一種排序算法。堆是一個近似完全二叉樹的結構,并同時滿足堆的性質:子結點的鍵值或索引總是小于(或者大于)它的父節點。
堆排序首先創建一個堆,每輪將堆頂元素彈出,而后進行堆調整,保持堆的特性。所有被彈出的元素序列即是最終排序序列。
過程演示如下:
3.1.7. 希爾排序
希爾排序,也稱遞減增量排序算法,是插入排序的一種更高效的改進版本,但希爾排序是非穩定排序算法。
插入排序在對幾乎已經排好序的數據操作時,效率高,即可以達到線性排序的效率。但插入排序一般來說是低效的,因為插入排序每次只能將數據移動一位。
希爾排序通過將比較的全部元素分為幾個區域來提升插入排序的性能。這樣可以讓一個元素可以一次性地朝最終位置前進一大步。然后算法再取越來越小的步長進行排序,算法的最后一步就是普通的插入排序,但是到了這步,需排序的數據幾乎是已排好的了(此時插入排序較快)。
過程演示如下:
3.2. 分配排序
基礎排序是建立在對元素排序碼進行比較的基礎上,而分配排序是采用“分配”與“收集”的辦法。
3.2.1. 計數排序
計數排序的核心在于將輸入的數據值轉化為鍵存儲在額外開辟的數組空間中。作為一種線性時間復雜度的排序,計數排序要求輸入的數據必須是有確定范圍的整數。
計數排序的特征:當輸入的元素是 n 個 0 到 k 之間的整數時,它的運行時間是 。計數排序不是比較排序,排序的速度快于任何比較排序算法。
由于用來計數的數組的長度取決于待排序數組中數據的范圍(等于待排序數組的最大值與最小值的差加上 1),這使得計數排序對于數據范圍很大的數組,需要大量時間和空間。
過程演示如下:
3.2.2. 桶排序
桶排序是計數排序的升級版,它利用了函數的映射關系,桶排序高效與否的關鍵就在于這個映射函數的確定。比如我們可以將排序數據進行除 10 運算,運算結果中具有相同的商值放入相同的桶中,即每十個數會放入相同的桶中。
過程演示如下:
為了使桶排序更加高效,我們需要做到這兩點:
在額外空間充足的情況下,盡量增大桶的數量
使用的映射函數能夠將輸入的所有數據均勻的分配到所有桶中
計數排序本質上是一種特殊的桶排序,當桶的個數取最大值(max-min+1)的時候,桶排序就變成了計數排序。
3.2.3. 基數排序
基數排序的原理是將整數按位數切割成不同的數字,然后對每個位數分別比較。基數排序首先按最低有效位數字進行排序,將相同值放入同一個桶中,并按最低位值順序疊放,然后再按次低有效位排序,重復這個過程直到所有位都進行了排序,最終即是一個有序序列。
過程演示如下:
基數排序也是一種桶排序。桶排序是按值區間劃分桶,基數排序是按數位來劃分,基數排序可以看做是多輪桶排序,每個數位上都進行一輪桶排序。
3.3. 多路歸并排序
多路歸并排序算法是將多個已經有序的列表進行歸并排序,合成為一組有序的列表的排序過程。
k 路歸并排序可以描述為:
初始時取出 k 路有序列表中首個元素放入比較池。
從比較池中取最小(大)的元素加入到結果列表,同時將該元素所在有序列表的下一個元素放入比較池(若有)。
重新復進行步驟 2,直到所有隊列的所有元素都已取出。
每次在比較池中取最小(大)的元素時,需要進行一次 k 個數據的比較操作,當 k 值較大時,會嚴重影響多路歸并的效率,為提高效率,可以使用“敗者樹”來實現這樣的比較過程。
敗者樹是完全二叉樹,敗者樹相對的是勝者樹,勝者樹每個非終端結點(除葉子結點之外的其它結點)中的值都表示的是左右孩子相比較后的勝者。
如下圖所示是一棵勝者樹:
而敗者樹雙親結點表示的是左右孩子比較之后的失敗者,但在上一層的比較過程中,仍然是拿前一次的勝者去比較。
如下圖所示是一顆敗者樹:
葉子節點的值是:{7,4,8,2,3,5,6,1},7 與 4 比較,7 是敗者,4 是勝者,因此他們的雙親節點是 7,同樣 8 與 2 比較,8 是敗者,表示在他們雙親節點上,而 7 與 8 的雙親節點需要用他們的勝者去比較,即用 4 與 2 比較,4 是敗者,因此 7 與 8 的雙親節點記錄的是 4,依此類推。
假設 k=8,敗者樹歸并排序的過程演示如下所示:
首先構建起敗者數,最后的勝者是 1,第二次將 1 彈出,取 1 所在的第 8 列的第二個數 15 放入 1 所在的葉子節點位置,并進行敗者樹調整,此時只需調整原 1 所在分支的祖先節點,最后勝者為 2,后續過程依此類推。最后每輪的最終勝者序列即是最后的歸并有序序列。
勝者樹和敗者樹的本質是利用空間換時間的做法,通過輔助節點記錄兩兩節點的比較結果來達到新插入節點后的比較和調整性能。
筆者曾經基于 lua 語言利用敗者樹實現多路歸并排序算法,有興趣可以前往閱讀。
3.4. 跳躍表排序
跳躍表(Skip Lists)是一種有序的數據結構,它通過在每個節點中隨機的建立上層輔助查找節點,從而達到快速訪問節點的目的(與敗者樹的多路歸并排序有異曲同工之妙)。
跳躍列表按層建造,底層是一個普通的有序鏈表,包含所有元素。每個更高層都充當下面列表的“快速通道”,第 i 層中的元素按某個固定的概率 p(通常為 1/2 或 1/4)隨機出現在第 i+1 層中。每個元素平均出現在 1/(1-p)個列表中,而最高層的元素在 個列表中出現。
如下是四層跳躍表結構的示意:
在查找目標元素時,從頂層列表、頭元素起步,沿著每層鏈表搜索,直至找到一個大于或等于目標的元素,或者到達當前層列表末尾。如果該元素等于目標元素,則表明該元素已被找到;如果該元素大于目標元素或已到達鏈表末尾,則退回到當前層的上一個元素,然后轉入下一層進行搜索。依次類推,最終找到該元素或在最底層底仍未找到(不存在)。
當 p 值越大,快速通道就越稀疏,占用空間越小,但查找速度越慢,反之,則占用空間大查找速度快,通過選擇不同 p 值,就可以在查找代價和存儲代價之間獲取平衡。
由于跳躍表使用的是鏈表,加上增加了近似于以二分方式的輔助節點,因此查詢,插入和刪除的性能都很理想。在大部分情況下,跳躍表的效率可以和平衡樹相媲美,它是一種隨機化的平衡方案,在實現上比平衡樹要更為簡單,因而得到了廣泛的應用,如 redis 的 zset,leveldb,我司的 apollo 排行榜等都使用了跳躍表排序方案。
3.5. 百分比近似排序
在流處理場景中,針對大容量的排序榜單,全量存儲和排序需要消耗的空間及時間都很高,不太現實。實際應用中,對于長尾數據的排序,一般也只需要顯示百分比近似排名,通過犧牲一定的精確度來換取高性能和高實時性。
3.5.1. HdrHistogram 算法
HdrHistogram 使用的是直方圖統計算法,直方圖算法類似于桶排序,原理就是創建一個直方圖,以一定的區間間隔記錄每個區間上的數據總量,預測排名時只需統計當前值所在區間及之前區間的所有數量之和與總數據量之間的比率。
區間分割方式可以采用線性分割和指數分割方式:
線性分割,數據以固定長度進行分割,假設數據范圍是[1-1000000],以每 100 的間隔劃分為 1 個區間,總共需要劃分 10000 個區間桶。
指數分割,基于指數倍的間隔長度進行分割,假設數據范圍是[1-1000000],以 2 的冪次方的區間[, ]進行劃分,總共只需要劃分 20 個區間桶。
HdrHistogram 為了兼顧內存和估算的準確度,同時采用了線性分割和指數分割的方式,相當于兩層的直方圖算法,第一層使用指數分割方式,可以粗略的估算數據的排名范圍位置,第二層使用線性分割方式,更加精確的估算出數據的排名位置。線性區間劃分越小結果越精確,但需要的內存越多,可以根據業務精確度需求控制線性區間的大小。
直方圖算法需要預先知道數據的最大值,超過最大值的數據將存不進來。HdrHistogram 提供了一個自動擴容的功能,以解決數據超過預估值的問題,但是這個自動擴容方式存在一個很高的拷貝成本。
3.5.2. CKMS 算法
HdrHistogram 是一種靜態分桶的算法,當數據序列是均勻分布的情況下,有比較好的預測效果,然而實際應用中數據有可能并不均勻,很有可能集中在某幾個區間上,CKMS 采用的是動態分桶的方式,在數據處理過程中不斷調整桶的區間間隔和數量。
CKMS 同時引入一個可配置的錯誤率的概念,在抉擇是否開辟新桶時,根據用戶設置的錯誤率進行計算判定。判定公式為:區間間隔=錯誤率* 數據總量。
下圖是一個桶合并的例子:
如上所示,假設錯誤率設置為 0.1,當數據總量大于 10 個時,通過判定公式計算出區間間隔為 1,因此將會對區間間隔小于等于 1 的相鄰桶進行合并。
CKMS 算法不需要預知數據的范圍,用戶可以根據數據的性質設置合適的錯誤率,以控制桶的空間占用和精確度之間的平衡關系。
3.5.3. TDigest 算法
Tdigest 算法的思想是近似算法常用的素描法(Sketch),用一部分數據來刻畫整體數據集的特征,就像我們日常的素描畫一樣,雖然和實物有差距,但是卻看著和實物很像,能夠展現實物的特征。它本質上也是一種動態分桶的方式。
TDigest 算法估計具體的百分位數時,都是根據百分位數對應的兩個質心去線性插值計算的,和精準百分位數的計算方式一樣。首先我們根據百分位 q 和所有質心的總權重計算出索引值;其次找出和對應索引相鄰的兩個質心;最終可以根據兩個質心的均值和權重用插值的方法計算出對應的百分位數。(實際的計算方法就是加權平均)。
由此我們可以知道,百分位數 q 的計算誤差要越小,其對應的兩個質心的均值應該越接近。TDigest 算法的關鍵就是如何控制質心的數量,質心的數量越多,顯然估計的精度就會越高,但是需要的內存就會越多,計算效率也越低;但是質心數量越少,估計的精度就很低,所以就需要一個權衡。
一種 TDigest 構建算法 buffer-and-merge 可以描述為:
將新加入的數據點加入臨時數組中,當臨時數組滿了或者需要計算分位數時,將臨時數組中的數據點和已經存在的質心一起排序。(其中數據點和質心的表達方式是完全一樣的:平均值和權重,每個數據點的平均值就是其本身,權重默認是 1)。
遍歷所有的數據點和質心,滿足合并條件的數據點和質心就進行合并,如果超出權重上限,則創建新的質心數,否則修改當前質心數的平均值和權重。
假設我們有 200 個質心,那么我們就可以將 0 到 1 拆分 200 等份,則每個質心就對應 0.5 個百分位。假如現在有 10000 個數據點,即總權重是 10000,我們按照大小對 10000 個點排序后,就可以確定每個質心的權重(相當于質心代表的數據點的個數)應該在 10000/200 = 500 左右,所以說當每個質心的權重小于 500 時,我們就可以將當前數據點加入當前的質心,否則就新建一個質心。
實際應用中,我們可能更加關心 90%,95%,99%等極端的百分位數,所以 TDigest 算法特意優化了 q=0 和 q=1 附近的百分位精度,通過專門的映射函數 K 保證了 q=0 和 q=1 附近的質心權重較小,數量較多。
另外一種 TDigest 構建算法是 AVL 樹的聚類算法,與 buffer-and-merge 算法相比,它通過使用 AVL 二叉平衡樹的方式來搜索數據點最靠近的質心數,找到最靠近的質心數后,將二者進行合并。
4. 限流與過載保護
復雜的業務場景中,經常容易遇到瞬時請求量的突增,很有可能會導致服務器占用過多資源,發生了大量的重試和資源競爭,導致響應速度降低、超時、乃至宕機,甚至引發雪崩造成整個系統不可用的情況。
為應對這種情況,通常需要系統具備可靠的限流和過載保護的能力,對于超出系統承載能力的部分的請求作出快速拒絕、丟棄處理,以保證本服務或下游服務系統的穩定。
4.1. 計數器
計數器算法是限流算法里最簡單也是最容易實現的一種算法。計數器算法可以針對某個用戶的請求,或某類接口請求,或全局總請求量進行限制。
比如我們設定針對單個玩家的登錄協議,每 3 秒才能請求一次,服務器可以在玩家數據上記錄玩家上一次的登錄時間,通過與本次登錄時間進行對比,判斷是否已經超過了 3 秒鐘來決定本次請求是否需要繼續處理。
又如針對某類協議,假設我們設定服務器同一秒內總登錄協議請求次數不超過 100 條,我們可以設置一個計數器,每當一個登錄請求過來的時候,計數器加 1,如果計數器值大于 100 且當前請求與第一個請求間隔時間還在 1 秒內,那么就判定為達到請求上限,拒絕服務,如果該請求與第一個請求間隔已經超過 1 秒鐘,則重置計數器的值為 0,并重新計數。
計數器算法存在瞬時流量的臨界問題,即在時間窗口切換時,前一個窗口和后一個窗口的請求量都集中在時間窗口切換的前后,在最壞的情況下,可能會產生兩倍于閾值流量的請求。
為此也可以使用多個不同間隔的計數器相結合的方式進行限頻,如可以限制登錄請求 1 秒內不超過 100 的同時 1 分鐘內不超過 1000 次。
4.2. 漏桶
漏桶算法原理很簡單,假設有一個水桶,所有水(請求)都會先丟進漏桶中,漏桶則以固定的速率出水(處理請求),當請求量速率過大,水桶中的水則會溢出(請求被丟棄)。漏桶算法能保證系統整體按固定的速率處理請求。
如下圖所示:
4.3. 令牌桶
對于很多應用場景來說,除了要求能夠限制請求的固定處理速率外,還要求允許某種程度的突發請求量,這時候漏桶算法可能就不合適了。
令牌桶算法的原理是系統會以一個恒定的速度往桶里放入令牌,而如果請求需要被處理,則需要先從桶里獲取一個令牌,當桶里沒有令牌可取時,則拒絕服務。
令牌桶算法大概描述如下:
所有的請求在處理之前都需要拿到一個可用的令牌才會被處理。
根據限流大小,設置按照一定的速率往桶里添加令牌。
桶設置最大的放置令牌限制,當桶滿時、新添加的令牌就被丟棄。
請求達到后首先要獲取令牌桶中的令牌,拿著令牌才可以進行其他的業務邏輯,處理完業務邏輯之后,將令牌直接刪除。
如下圖所示:
4.4. 滑動窗口
計數器,漏桶和令牌桶算法是在上游節點做的限流,通過配置系統參數做限制,不依賴于下游服務的反饋數據,對于異構的請求不太適用,且需要預估下游節點的處理能力。
滑動窗口限頻類似于 TCP 的滑動窗口協議,設置一個窗口大小,這個大小即當前最大在處理中的請求量,同時記錄滑動窗口的左右端點,每次發送一個請求時滑動窗口右端點往前移一格,每次收到請求處理完畢響應后窗口左端點往前移一格,當右端點與左端點的差值超過最大窗口大小時,等待發送或拒絕服務。
如下圖所示:
4.5. SRE 自適應限流
滑動窗口是以固定的窗口大小限制請求,而 Google 的 SRE 自適應限流相當于是一個動態的窗口,它根據過往請求的成功率動態調整向后端發送請求的速率,當成功率越高請求被拒絕的概率就越小;反之,當成功率越低請求被拒絕的概率就相應越大。
SRE 自適應限流算法需要在應用層記錄過去兩分鐘內的兩個數據信息:
requests:請求總量,應用層嘗試的請求數
accepts:成功被后端處理的請求數
請求被拒絕的概率 p 的計算公式如下:
其中 K 為倍率因子,由用戶設置(比如 2),從算法公式可以看出:
在正常情況下 requests 等于 accepts,新請求被決絕的概率 p 為 0,即所有請求正常通過
當后端出現異常情況時,accepts 的數量會逐漸小于 requests,應用層可以繼續發送請求直到 requests 等于 ,一旦超過這個值,自適應限流啟動,新請求就會以概率 p 被拒絕。
當后端逐漸恢復時,accepts 逐漸增加,概率 p 會增大,更多請求會被放過,當 accepts 恢復到使得 大于等于 requests 時,概率 p 等于 0,限流結束。
我們可以針對不同場景中處理更多請求帶來的風險成本與拒絕更多請求帶來的服務損失成本之間進行權衡,調整 K 值大小:
降低 K 值會使自適應限流算法更加激進(拒絕更多請求,服務損失成本升高,風險成本降低)。
增加 K 值會使自適應限流算法不再那么激進(放過更多請求,服務損失成本降低,風險成本升高)。
如對于某些處理該請求的成本與拒絕該請求的成本的接近場景,系統高負荷運轉造成很多請求處理超時,實際已無意義,然而卻還是一樣會消耗系統資源的情況下,可以調小 K 值。
4.6. 熔斷
熔斷算法原理是系統統計并定時檢查過往請求的失敗(超時)比率,當失敗(超時)率達到一定閾值之后,熔斷器開啟,并休眠一段時間,當休眠期結束后,熔斷器關閉,重新往后端節點發送請求,并重新統計失敗率。如此周而復始。
如下圖所示:
4.7. Hystrix 半開熔斷器
Hystrix 中的半開熔斷器相對于簡單熔斷增加了一種半開狀態,Hystrix 在運行過程中會向每個請求對應的節點報告成功、失敗、超時和拒絕的狀態,熔斷器維護計算統計的數據,根據這些統計的信息來確定熔斷器是否打開。如果打開,后續的請求都會被截斷。然后會隔一段時間,嘗試半開狀態,即放入一部分請求過去,相當于對服務進行一次健康檢查,如果服務恢復,熔斷器關閉,隨后完全恢復調用,如果失敗,則重新打開熔斷器,繼續進入熔斷等待狀態。
如下圖所示:
5. 序列化與編碼
數據結構序列化是指將數據結構或對象狀態轉換成可取用格式(例如存成文件,存于緩沖,或經由網絡傳輸),以留待后續在相同或另一臺計算機環境中,能恢復原先狀態的過程。經過依照序列化格式重新獲取字節的結果時,可以利用它來產生與原始對象相同語義的副本。
5.1. 標記語言
標記語言是一種將文本(Text)以及文本相關的其他信息結合起來,展現出關于文檔結構和數據處理細節的計算機文字編碼。
5.1.1. 超文本標記語言(HTML)
HTML 是一種用于創建網頁的標準標記語言。HTML 是一種基礎技術,常與 CSS、JavaScript 一起被眾多網站用于設計網頁、網頁應用程序以及移動應用程序的用戶界面。網頁瀏覽器可以讀取 HTML 文件,并將其渲染成可視化網頁。HTML 描述了一個網站的結構語義隨著線索的呈現,使之成為一種標記語言而非編程語言。
5.1.2. 可擴展標記語言(XML)
XML 是一種標記語言,設計用來傳送及攜帶數據信息。每個 XML 文檔都由 XML 聲明開始,在前面的代碼中的第一行就是 XML 聲明。這一行代碼會告訴解析器或瀏覽器這個文件應該按照 XML 規則進行解析。
XML 文檔的字符分為標記(Markup)與內容(content)兩類。標記通常以<開頭,以>結尾;或者以字符&開頭,以;結尾。不是標記的字符就是內容。一個 tag 屬于標記結構,以<開頭,以>結尾。
元素是文檔邏輯組成,或者在 start-tag 與匹配的 end-tag 之間,或者僅作為一個 empty-element tag。
屬性是一種標記結構,在 start-tag 或 empty-element tag 內部的“名字-值對”。例如:<img src="madonna.jpg" alt="Madonna" />每個元素中,一個屬性最多出現一次,一個屬性只能有一個值。
5.1.3. Markdown
Markdown 是一種輕量級標記語言,創始人為約翰·格魯伯。它允許人們使用易讀易寫的純文本格式編寫文檔,然后轉換成有效的 XHTML(或者 HTML)文檔。這種語言吸收了很多在電子郵件中已有的純文本標記的特性。
由于 Markdown 的輕量化、易讀易寫特性,并且對于圖片,圖表、數學式都有支持,目前許多網站都廣泛使用 Markdown 來撰寫幫助文檔或是用于論壇上發表消息。如 GitHub、Reddit、Diaspora、Stack Exchange、OpenStreetMap 、SourceForge、簡書等,甚至還能被用來撰寫電子書。當然還有咱們的 KM 平臺,很強大。
Markdown 語法格式如下:
5.1.4. JSON
JSON 是以數據線性化為目標的輕量級標記語言,相比于 XML,JSON 更加簡潔、輕量和具有更好的可讀性。
JSON 的基本數據類型和編碼規則:
數值:十進制數,不能有前導 0,可以為負數,可以有小數部分。還可以用 e 或者 E 表示指數部分。不能包含非數,如 NaN。不區分整數與浮點數。
字符串:以雙引號""括起來的零個或多個 Unicode 碼位。支持反斜杠開始的轉義字符序列。
布爾值:表示為 true 或者 false。
數組:有序的零個或者多個值。每個值可以為任意類型。序列表使用方括號[,]括起來。元素之間用逗號,分割。形如:[value, value]
對象:若干無序的“鍵-值對”(key-value pairs),其中鍵只能是字符串。建議但不強制要求對象中的鍵是獨一無二的。對象以花括號{開始,并以}結束。鍵-值對之間使用逗號分隔。鍵與值之間用冒號:分割。
空值:值寫為 null
5.2. TLV 二進制序列化
很多高效得數據序列化方式都是采用類 TLV(Tag+Length+Value)的方式對數據進行序列化和反序列化,每塊數據以 Tag 開始,Tag 即數據標簽,標識接下來的數據類型是什么,Length 即長度,標識接下來的數據總長,Value 即數據的實際內容,結合 Tag 和 Length 的大小即可獲取當前這塊數據內容。
5.2.1. Protocol Buffers
Protocol Buffers(簡稱:ProtoBuf)是一種開源跨平臺的序列化數據結構的協議,它是一種靈活,高效,自動化的結構數據序列化方法,相比 XML 和 JSON 更小、更快、更為簡單。
Protocol Buffers 包含一個接口描述語言.proto 文件,描述需要定義的一些數據結構,通過程序工具根據這些描述產生.cc 和.h 文件代碼,這些代碼將用來生成或解析代表這些數據結構的字節流。
Protocol Buffers 編碼后的消息都是 Key-Value 形式,Key 的值由 field_number(字段標號)和 wire_type(編碼類型)組合而成,規則為:key = field_number << 3 | wire type。
field_number 部分指示了當前是哪個數據成員,通過它將 cc 和 h 文件中的數據成員與當前的 key-value 對應起來。
wire type 為字段編碼類型,有以下幾類:
Protocol Buffers 編碼特征:
整型數據采用 varint 編碼(見 5.4.1 節),以節省序列化后數據大小。
對于有符號整型,先進行 zigzag 編碼(見 5.4.2 節)調整再進行 varint 數據編碼,以減小負整數序列化后數據大小。
string、嵌套結構以及 packed repeated fields 的編碼類型是 Length-delimited,它們的編碼方式統一為 tag+length+value。
5.2.2. TDR
TDR 是騰訊互娛研發部自研跨平臺多語言數據表示組件,主要用于數據的序列化反序列化以及數據的存儲。TDR 通過 XML 文件來定義接口和結構的描述,通過程序工具根據這些描述產生.tdr 和.h 文件代碼,用于序列化和反序列化這些數據結構。
TDR1.0 的版本是通過版本剪裁方式來序列化反序列化,需要事先維護好字段版本號,序列化反序列化時通過剪裁版本號來完成兼容的方式,只支持單向的高版本兼容低版本數據。
TDR2.0 整體上與 Protocol Buffers 相似,TDR2.0 支持消息協議的前后雙向兼容,整型數據同樣支持 varint 編碼和 zigzag 調整的方式,在對 TLV 中 Length 部分進行處理時,采用定長編解碼方式,以浪費序列化空間的代價來獲取更高性能,避免了類似 Protocol Buffers 中不必要的內存拷貝(或者是預先計算大小)的過程。
Protocol Buffers 和 TDR 都有接口描述語言,這使得它們的序列化更高效,數據序列化后也更加緊湊。
5.2.3. Luna 序列化
luna 庫是開源的基于 C++17 的 lua/C++綁定庫,它同時也實現了針對 lua 數據結構的序列化和反序列化功能,用于 lua 結構數據的傳輸和存儲。
Lua 語言中需要傳輸和存儲的數據類型主要有:nil,boolean,number,string,table。因此在序列化過程中,luna 將類型定義為以下九種類型。
序列化方式如下:
整體上也是類似于 Protocol Buffers 和 TDR 的 TLV 編碼方式,同時針對 lua 類型結構的特性做了一些效率上的優化。
主要特性如下:
Boolean 類型區分為 bool_true 和 bool_false,只需在增加 2 種 type 值就可以解決。
整型 integer 同樣采用 varint 壓縮編碼方式,無需額外字節記錄長度。
有符號整型,同樣是先進行 zigzag 調整再進行 varint 數據編碼。
字符串類型分為 string 和 string_idx,編碼過程中會緩存已經出現過的字符串,對于后續重復出現的字符串記錄為 string_idx 類型,value 值記錄該字符串第一次出現的序號,節約字符串占用的空間。
對于小于 246(255 減去類型數量 9)的小正整型數,直接當成不同類型處理,加上數值 9 之后記錄在 type 中,節約空間。
Table 為嵌套結構,用 table_head 和 table_tail 兩種類型表示開始和結束。key 和 value 分別進行嵌套編碼。
5.2.4. Skynet 序列化
Skynet 是一個應用廣泛的為在線游戲服務器打造的輕量級框架。但實際上它也不僅僅使用在游戲服務器領域。skynet 的核心是由 C 語言編寫,但大多數 skynet 服務使用 lua 編寫,因此它也實現了針對 lua 數據結構的序列化和反序列化功能。
序列化方式如下:
主要特性如下:
Type 類型通過低 3 位和高 5 位來區分主類型和子類型。
Boolean 單獨主類型,子類型字段用 1 和 0 區分 true 和 false。
整型使用 number 主類型,子類型分為 number_zero(0 值),number_byte(小于 8 位的正整數),number_word(小于 16 位的正整數),number_dword(小于 32 位的正負整數),number_qword(其他整數),number_real(浮點數)。
字符串類型分為短字符串 short_string 和長字符串 long_string,小于 32 字節長度的字符串記錄為 short_string 主類型,低 5 位的子類型記錄長度。long_string 又分為 2 字節(長度小于 0x1000)和 4 字節(長度大于等于 0x1000)長字符串,分別用 2 字節 length 和 4 字節 length 記錄長度。
Table 類型會區分 array 部分和 hash 部分,先將 array 部分序列化,array 部分又分為小 array 和大 array,小 array(0-30 個元素)直接用 type 的低 5 位的子類型記錄大小,大 array 的子類型固定為 31,大小通過 number 類型編碼。Hash 部分需要將 key 和 value 分別進行嵌套編碼。
Table 的結束沒有像 luna 一樣加了專門的 table_tail 標識,而是通過 nil 類型標識。
5.3. 壓縮編碼
壓縮算法從對數據的完整性角度分有損壓縮和無損壓縮。
有損壓縮算法通過移除在保真前提下需要的必要數據之外的其小細節,從而使文件變小。在有損壓縮里,因部分有效數據的移除,恢復原文件是不可能的。有損壓縮主要用來存儲圖像和音頻文件,通過移除數據達到比較高的壓縮率。
無損壓縮,也能使文件變小,但對應的解壓縮功能可以精確的恢復原文件,不丟失任何數據。無損數據壓縮被廣泛的應用于計算機領域,數據的傳輸和存儲系統中均使用無損壓縮算法。
接下來我們主要是介紹幾種無損壓縮編碼算法。
5.3.1. 熵編碼法
一種主要類型的熵編碼方式是對輸入的每一個符號,創建并分配一個唯一的前綴碼,然后,通過將每個固定長度的輸入符號替換成相應的可變長度前綴無關(prefix-free)輸出碼字替換,從而達到壓縮數據的目的。每個碼字的長度近似與概率的負對數成比例。因此,最常見的符號使用最短的碼。
霍夫曼編碼和算術編碼是兩種最常見的熵編碼技術。如果預先已知數據流的近似熵特性(尤其是對于信號壓縮),可以使用簡單的靜態碼。
5.3.2. 游程編碼
又稱行程長度編碼或變動長度編碼法,是一種與資料性質無關的無損數據壓縮技術,基于“使用變動長度的碼來取代連續重復出現的原始資料”來實現壓縮。舉例來說,一組資料串"AAAABBBCCDEEEE",由 4 個 A、3 個 B、2 個 C、1 個 D、4 個 E 組成,經過變動長度編碼法可將資料壓縮為 4A3B2C1D4E(由 14 個單位轉成 10 個單位)。
5.3.3. MTF 變換
MTF(Move-To-Front)是一種數據編碼方式,作為一個額外的步驟,用于提高數據壓縮技術效果。MTF 主要使用的是數據“空間局部性”,也就是最近出現過的字符很可能在接下來的文本附近再次出現。
過程可以描述為:
首先維護一個文本字符集大小的棧表,“recently used symbols”(最近訪問過的字符),其中每個不同的字符在其中占一個位置,位置從 0 開始編號。
掃描需要重新編碼的文本數據,對于每個掃描到的字符,使用該字符在“recently used symbols”中的 index 替換,并將該字符提到“recently used symbols”的棧頂的位置(index 為 0 的位置)。重復上一步驟,直到文本掃描結束。
5.3.4. 塊排序壓縮
當一個字符串用該算法轉換時,算法只改變這個字符串中字符的順序而并不改變其字符。如果原字符串有幾個出現多次的子串,那么轉換過的字符串上就會有一些連續重復的字符,這對壓縮是很有用的。
塊排序變換(Burrows-Wheeler Transform)算法能使得基于處理字符串中連續重復字符的技術(如 MTF 變換和游程編碼)的編碼更容易被壓縮。
塊排序變換算法將輸入字符串的所有循環字符串按照字典序排序,并以排序后字符串形成的矩陣的最后一列為其輸出。
5.3.5. 字典編碼法
由 Abraham Lempel 和 Jacob Ziv 獨創性的使用字典編碼器的 LZ77/78 算法及其 LZ 系列變種應用廣泛。
LZ77 算法通過使用編碼器或者解碼器中已經出現過的相應匹配數據信息替換當前數據從而實現壓縮功能。這個匹配信息使用稱為長度-距離對的一對數據進行編碼,它等同于“每個給定長度個字符都等于后面特定距離字符位置上的未壓縮數據流。”編碼器和解碼器都必須保存一定數量的緩存數據。保存這些數據的結構叫作滑動窗口,因為這樣所以 LZ77 有時也稱作滑動窗口壓縮。編碼器需要保存這個數據查找匹配數據,解碼器保存這個數據解析編碼器所指代的匹配數據。
LZ77 算法針對過去的數據進行處理,而 LZ78 算法卻是針對后來的數據進行處理。LZ78 通過對輸入緩存數據進行預先掃描與它維護的字典中的數據進行匹配來實現這個功能,在找到字典中不能匹配的數據之前它掃描進所有的數據,這時它將輸出數據在字典中的位置、匹配的長度以及找不到匹配的數據,并且將結果數據添加到字典中。
5.3.6. 霍夫曼(Huffman)編碼
霍夫曼編碼把文件中一定位長的值看作是符號,比如把 8 位長的 256 種值,也就是字節的 256 種值看作是符號。根據這些符號在文件中出現的頻率,對這些符號重新編碼。對于出現次數非常多的,用較少的位來表示,對于出現次數非常少的,用較多的位來表示。這樣一來,文件的一些部分位數變少了,一些部分位數變多了,由于變小的部分比變大的部分多,所以整個文件的大小還是會減小,所以文件得到了壓縮。
要進行霍夫曼編碼,首先要把整個文件讀一遍,在讀的過程中,統計每個符號(我們把字節的 256 種值看作是 256 種符號)的出現次數。然后根據符號的出現次數,建立霍夫曼樹,通過霍夫曼樹得到每個符號的新的編碼。對于文件中出現次數較多的符號,它的霍夫曼編碼的位數比較少。對于文件中出現次數較少的符號,它的霍夫曼編碼的位數比較多。然后把文件中的每個字節替換成他們新的編碼。
5.3.7. 其他壓縮編碼
deflate 是同時使用了 LZ77 算法與霍夫曼編碼的一個無損數據壓縮算法。
gzip 壓縮算法的基礎是 deflate。
bzip2 使用 Burrows-Wheeler transform 將重復出現的字符序列轉換成同樣字母的字符串,然后用 move-to-front 變換進行處理,最后使用霍夫曼編碼進行壓縮。
LZ4 著重于壓縮和解壓縮速度,它屬于面向字節的 LZ77 壓縮方案家族。
Snappy(以前稱 Zippy)是 Google 基于 LZ77 的思路用 C++語言編寫的快速數據壓縮與解壓程序庫,并在 2011 年開源,它的目標并非最大壓縮率或與其他壓縮程序庫的兼容性,而是非常高的速度和合理的壓縮率。
轉自網絡的壓縮率和性能對比:
| bzip2 | 35984 | 8677 | 11591 | 2362 | 29.5 |
| gzip | 35984 | 8804 | 2179 | 389 | 26.5 |
| deflate | 35984 | 9704 | 680 | 344 | 20.5 |
| lzo | 35984 | 13069 | 581 | 230 | 22 |
| lz4 | 35984 | 16355 | 327 | 147 | 12.6 |
| snappy | 35984 | 13602 | 424 | 88 | 11 |
5.4. 其他編碼
5.4.1. Varint
前文所提到的 Varint 整型壓縮編碼方式,它使用一個或多個字節序列化整數的方法,把整數編碼為變長字節。
Varint 編碼將每個字節的低 7bit 位用于表示數據,最高 bit 位表示后面是否還有字節,其中 1 表示還有后續字節,0 表示當前是最后一個字節。當整型數值很小時,只需要極少數的字節進行編碼,如數值 9,它的編碼就是 00001001,只需一個字節。
如上圖所示,假設要編碼的數據 123456,二進制為:11110001001000000,按 7bit 劃分后,每 7bit 添加高 1 位的是否有后續字節標識,編碼為 110000001100010000000111,占用 3 個字節。
對于 32 位整型數據經過 Varint 編碼后需要 1~5 個字節,小的數字使用 1 個字節,大的數字使用 5 個字節。64 位整型數據編碼后占用 1~10 個字節。在實際場景中小數字的使用率遠遠多于大數字,因此通過 Varint 編碼對于大部分場景都可以起到很好的壓縮效果。
5.4.2. ZigZag
zigzag 編碼的出現是為了解決 varint 對負數編碼效率低的問題。對于有符號整型,如果數值為負數,二進制就會非常大,例如-1 的 16 進制:0xffff ffff ffff ffff,對應的二進制位全部是 1,使用 varint 編碼需要 10 個字節,非常不劃算。
zigzag 編碼的原理是將有符號整數映射為無符號整數,使得負數的二進制數值也能用較少的 bit 位表示。它通過移位來實現映射。
由于補碼的符號位在最高位,對于負數,符號位為 1,這導致 varint 壓縮編碼無法壓縮,需要最大變長字節來存儲,因此首先將數據位整體循環左移 1 位,最低位空出留給符號位使用,另外,對于實際使用中,絕對值小的負數應用場景比絕對值大的負數應用場景大的多,但絕對值小的負數的前導 1 更多(如-1,全是 1),因此對于負整數,再把數據位按取反規則操作,將前導 1 置換為 0,以達到可以通過 varint 編碼能有效壓縮的目的。
最終經過 zigzag 編碼后絕對值小的正負整數都能編碼為絕對值相對小的正整數,編碼效果如下:
5.4.3. Base 系列
有的字符在一些環境中是不能顯示或使用的,比如&, =等字符在 URL 被保留為特殊作用的字符,比如一些二進制碼如果轉成對應的字符的話,會有很多不可見字符和控制符(如換行、回車之類),這時就需要對數據進行編碼。Base 系列的就是用來將字節編碼為 ASCII 中的可見字符的,以便能進行網絡傳輸和打印等。
Base 系列編碼的原理是將字節流按固定步長切片,然后通過映射表為每個切片找一個對應的、可見的 ASCII 字符,最終重組為新的可見字符流。
Base16 也稱 hex,它使用 16 個可見字符來表示二進制字符串,1 個字符使用 2 個可見字符來表示,因此編碼后數據大小將翻倍。
Base32 使用 32 個可見字符來表示二進制字符串,5 個字符使用 8 個可見字符表示,最后如果不足 8 個字符,將用“=”來補充,編碼后數據大小變成原來的 8/5。
Base64 使用 64 個可見字符來表示二進制字符串, 3 個字符使用 4 個可見字符來表示,編碼后數據大小變成原來的 4/3。
Base64 索引表如下:
筆者曾經用 lua 實現過Base64 算法,有興趣可以前往閱讀。
5.4.4. 百分號編碼
百分號編碼又稱 URL 編碼(URL encoding),是特定上下文的統一資源定位符(URL)的編碼機制,實際上也適用于統一資源標志符(URI)的編碼。
百分號編碼同樣也是為了使 URL 具有可傳輸性,可顯示性以及應對二進制數據的完整性而進行的一種編碼規則。
百分號編碼規則為把字符的 ASCII 的值表示為兩個 16 進制的數字,然后在其前面放置轉義字符百分號“%”。
URI 所允許的字符分作保留與未保留。保留字符是那些具有特殊含義的字符,例如:斜線字符用于 URL(或 URI)不同部分的分界符;未保留字符沒有這些特殊含義。
以下是 RFC3986 中對保留字符和未保留字符的定義:
百分號編碼可描述為:
未保留字符不需要編碼
如果一個保留字符需要出現在 URI 一個路徑成分的內部, 則需要進行百分號編碼
除了保留字符和未保留字符(包括百分號字符本身)的其它字符必須用百分號編碼
二進制數據表示為 8 位組的序列,然后對每個 8 位組進行百分號編碼
6. 加密與校驗
6.1. CRC
CRC 循環冗余校驗(Cyclic redundancy check)是一種根據網絡數據包或電腦文件等數據產生簡短固定位數校驗碼的一種散列函數,主要用來檢測或校驗數據傳輸或者保存后可能出現的錯誤。生成的數字在傳輸或者存儲之前計算出來并且附加到數據后面,然后接收方進行檢驗確定數據是否發生變化。它是一類重要的線性分組碼,編碼和解碼方法簡單,檢錯和糾錯能力強,在通信領域廣泛地用于實現差錯控制。
CRC 是兩個字節數據流采用二進制除法(沒有進位,使用 XOR 來代替減法)相除所得到的余數。其中被除數是需要計算校驗和的信息數據流的二進制表示;除數是一個長度為(n+1)的預定義(短)的二進制數,通常用多項式的系數來表示。在做除法之前,要在信息數據之后先加上 n 個 0。CRC 是基于有限域 GF(2)(即除以 2 的同余)的多項式環。簡單的來說,就是所有系數都為 0 或 1(又叫做二進制)的多項式系數的集合,并且集合對于所有的代數操作都是封閉的。
6.2. 奇偶校驗
奇偶校驗(Parity Check)是一種校驗代碼傳輸正確性的方法。根據被傳輸的一組二進制代碼的數位中“1”的個數是奇數或偶數來進行校驗。采用奇數的稱為奇校驗,反之,稱為偶校驗。通常專門設置一個奇偶校驗位,用它使這組代碼中“1”的個數為奇數或偶數。若用奇校驗,則當接收端收到這組代碼時,校驗“1”的個數是否為奇數,從而確定傳輸代碼的正確性。
以偶校驗位來說,如果一組給定數據位中 1 的個數是奇數,補一個 bit 為 1,使得總的 1 的個數是偶數。例:0000001, 補一個 bit 為 1 即 00000011。
以奇校驗位來說,如果給定一組數據位中 1 的個數是奇數,補一個 bit 為 0,使得總的 1 的個數是奇數。例:0000001, 補一個 bit 為 0 即 00000010。
偶校驗實際上是循環冗余校驗的一個特例,通過多項式 x + 1 得到 1 位 CRC。
6.3. MD 系列
MD 系列算法(Message-Digest Algorithm)用于生成信息摘要特征碼,具有不可逆性和高度的離散性,可以看成是一種特殊的散列函數(見 8.1 節),一般認為可以唯一地代表原信息的特征,通常用于密碼的加密存儲,數字簽名,文件完整性驗證等。
MD4 是麻省理工學院教授 Ronald Rivest 于 1990 年設計的一種信息摘要算法,它是一種用來測試信息完整性的密碼散列函數的實現,其摘要長度為 128 位。它是基于 32 位操作數的位操作來實現的。這個算法影響了后來的算法如 MD5、SHA 家族和 RIPEMD 等
MD5 消息摘要算法是一種被廣泛使用的密碼散列函數,可以產生出一個 128 位(16 個字符)的散列值,用于確保信息傳輸完整一致。MD5 由美國密碼學家羅納德·李維斯特(Ronald Linn Rivest)設計,于 1992 年公開,用以取代 MD4 算法。MD5 是輸入不定長度信息,輸出固定長度 128-bits 的算法。經過程序流程,生成四個 32 位數據,最后聯合起來成為一個 128-bits 散列。基本方式為,求余、取余、調整長度、與鏈接變量進行循環運算,得出結果。
MD6 消息摘要算法使用默克爾樹形式的結構,允許對很長的輸入并行進行大量散列計算。該算法的 Block size 為 512 bytes(MD5 的 Block Size 是 512 bits), Chaining value 長度為 1024 bits, 算法增加了并行 機制,適合于多核 CPU。相較于 MD5,其安全性大大改進,加密結構更為完善,但其有證明的版本速度太慢,而效率高的版本并不能給出類似的證明。
6.4. SHA 系列
SHA(Secure Hash Algorithm)是一個密碼散列函數家族,是 FIPS 所認證的安全散列算法。
SHA1 是由 NISTNSA 設計為同 DSA 一起使用的,它對長度小于 264 的輸入,產生長度為 160bit 的散列值,因此抗窮舉性更好。SHA-1 設計時基于和 MD4 相同原理,并且模仿了該算法。SHA-1 的安全性在 2010 年以后已經不被大多數的加密場景所接受。2017 年荷蘭密碼學研究小組 CWI 和 Google 正式宣布攻破了 SHA-1。
SHA-2 由美國國家安全局研發,由美國國家標準與技術研究院(NIST)在 2001 年發布。屬于 SHA 算法之一,是 SHA-1 的后繼者。包括 SHA-224、SHA-256、SHA-384、SHA-512、SHA-512/224、SHA-512/256。SHA-256 和 SHA-512 是很新的雜湊函數,前者以定義一個 word 為 32 位元,后者則定義一個 word 為 64 位元。它們分別使用了不同的偏移量,或用不同的常數,然而,實際上二者結構是相同的,只在循環執行的次數上有所差異。SHA-224 以及 SHA-384 則是前述二種雜湊函數的截短版,利用不同的初始值做計算。
SHA-3 第三代安全散列算法之前名為 Keccak 算法,Keccak 使用海綿函數,此函數會將資料與初始的內部狀態做 XOR 運算,這是無可避免可置換的(inevitably permuted)。在最大的版本,算法使用的內存狀態是使用一個 5×5 的二維數組,資料類型是 64 位的字節,總計 1600 比特。縮版的算法使用比較小的,以 2 為冪次的字節大小 w 為 1 比特,總計使用 25 比特。除了使用較小的版本來研究加密分析攻擊,比較適中的大小(例如從 w=4 使用 100 比特,到 w=32 使用 800 比特)則提供了比較實際且輕量的替代方案。
6.5. 對稱密鑰算法
對稱密鑰算法(Symmetric-key algorithm)又稱為對稱加密、私鑰加密、共享密鑰加密,是密碼學中的一類加密算法。這類算法在加密和解密時使用相同的密鑰,或是使用兩個可以簡單地相互推算的密鑰。事實上,這組密鑰成為在兩個或多個成員間的共同秘密,以便維持專屬的通信聯系。與公開密鑰加密相比,要求雙方獲取相同的密鑰是對稱密鑰加密的主要缺點之一。
常見的對稱加密算法有 AES、ChaCha20、3DES、Salsa20、DES、Blowfish、IDEA、RC5、RC6、Camellia 等。
對稱加密的速度比公鑰加密快很多,在很多場合都需要對稱加密。
6.6. 非對稱加密算法
非對稱式密碼學(Asymmetric cryptography)也稱公開密鑰密碼學(Public-key cryptography),是密碼學的另一類加密算法,它需要兩個密鑰,一個是公開密鑰,另一個是私有密鑰。公鑰用作加密,私鑰則用作解密。使用公鑰把明文加密后所得的密文,只能用相對應的私鑰才能解密并得到原本的明文,最初用來加密的公鑰不能用作解密。由于加密和解密需要兩個不同的密鑰,故被稱為非對稱加密,不同于加密和解密都使用同一個密鑰的對稱加密。
公鑰可以公開,可任意向外發布,私鑰不可以公開,必須由用戶自行嚴格秘密保管,絕不透過任何途徑向任何人提供,也不會透露給被信任的要通信的另一方。
常用的非對稱加密算法是 RSA 算法。
6.7. 哈希鏈
哈希鏈是一種由單個密鑰或密碼生成多個一次性密鑰或密碼的一種方法。哈希鏈是將密碼學中的哈希函數 循環地用于一個字符串。(即將所得哈希值再次傳遞給哈希函數得至其哈希值)。
例:,是一個長度為 4 哈希鏈,記為:。
相比較而言,一個提供身份驗證的服務器儲存哈希字符串,比儲存純文本密碼,更能防止密碼在傳輸或儲存時被泄露。舉例來說,一個服務器一開始存儲了一個由用戶提供的哈希值 。進行身份驗證時,用戶提供給服務器 。服務器計算 即 ,并與已儲存的哈希值 進行比較。然后服務器將存儲 以用來對用戶進行下次驗證。
竊聽者即使嗅探到 送交服務器,也無法將 用來認證,因為現在服務器驗證算法傳入的參數是 。由于安全的哈希函數有一種單向的加密屬性,對于想要算出前一次哈希值的竊聽者來說它的值是不可逆的。在本例中,用戶在整個哈希鏈用完前可以驗證 1000 次之多。每次哈希值是不同的,不能被攻擊者再次使用。
7. 緩存淘汰策略
服務器常用緩存提升數據訪問性能,但由于緩存容量有限,當緩存容量到達上限,就需要淘汰部分緩存數據挪出空間,這樣新數據才可以添加進來。好的緩存應該是在有限的內存空間內盡量保持最熱門的數據在緩存中,以提高緩存的命中率,因此如何淘汰數據有必要進行一番考究。緩存淘汰有多種策略,可以根據不同的業務場景選擇不同淘汰的策略。
7.1. FIFO
FIFO(First In First Out)是一種先進先出的數據緩存器,先進先出隊列很好理解,當訪問的數據節點不在緩存中時,從后端拉取節點數據并插入在隊列頭,如果隊列已滿,則淘汰最先插入隊列的數據。
假設緩存隊列長度為 6,過程演示如下:
7.2. LRU
LRU(Least recently used)是最近最少使用緩存淘汰算法,可它根據數據的歷史訪問記錄來進行淘汰數據,其核心思想認為最近使用的數據是熱門數據,下一次很大概率將會再次被使用。而最近很少被使用的數據,很大概率下一次不再用到。因此當緩存容量的滿時候,優先淘汰最近很少使用的數據。因此它與 FIFO 的區別是在訪問數據節點時,會將被訪問的數據移到頭結點。
假設緩存隊列長度為 6,過程演示如下:
LRU 算法有個缺陷在于對于偶發的訪問操作,比如說批量查詢某些數據,可能使緩存中熱門數據被這些偶發使用的數據替代,造成緩存污染,導致緩存命中率下降。
7.3. LFU
LFU 是最不經常使用淘汰算法,其核心思想認為如果數據過去被訪問多次,那么將來被訪問的頻率也更高。LRU 的淘汰規則是基于訪問時間,而 LFU 是基于訪問次數。LFU 緩存算法使用一個計數器來記錄數據被訪問的次數,最低訪問數的條目首先被移除。
假設緩存隊列長度為 4,過程演示如下:
LFU 能夠避免偶發性的操作導致緩存命中率下降的問題,但它也有缺陷,比如對于一開始有高訪問率而之后長時間沒有被訪問的數據,它會一直占用緩存空間,因此一旦數據訪問模式改變,LFU 可能需要長時間來適用新的訪問模式,即 LFU 存在歷史數據影響將來數據的"緩存污染"問題。另外對于對于交替出現的數據,緩存命中不高。
7.4. LRU-K
無論是 LRU 還是 LFU 都有各自的缺陷,LRU-K 算法更像是結合了 LRU 基于訪問時間和 LFU 基于訪問次數的思想,它將 LRU 最近使用過 1 次的判斷標準擴展為最近使用過 K 次,以提高緩存隊列淘汰置換的門檻。LRU-K 算法需要維護兩個隊列:訪問列表和緩存列表。LRU 可以認為是 LRU-K 中 K 等于 1 的特化版。
LRU-K 算法實現可以描述為:
數據第一次被訪問,加入到訪問列表,訪問列表按照一定規則(如 FIFO,LRU)淘汰。
當訪問列表中的數據訪問次數達到 K 次后,將數據從訪問列表刪除,并將數據添加到緩存列表頭節點,如果數據已經在緩存列表中,則移動到頭結點。
若緩存列表數據量超過上限,淘汰緩存列表中排在末尾的數據,即淘汰倒數第 K 次訪問離現在最久的數據。
假設訪問列表長度和緩存列表長度都為 4,K=2,過程演示如下:
LRU-K 具有 LRU 的優點,同時能夠降低緩存數據被污染的程度,實際應用可根據業務場景選擇不同的 K 值,K 值越大,緩存列表中數據置換的門檻越高。
7.5. Two queues
Two queues 算法可以看做是 LRU-K 算法中 K=2,同時訪問列表使用 FIFO 淘汰算法的一個特例。如下圖所示:
7.6. LIRS
LIRS(Low Inter-reference Recency Set)算法將緩存分為兩部分區域:熱數據區與冷數據區。LIRS 算法利用冷數據區做了一層隔離,目的是即使在有偶發性的訪問操作時,保護熱數據區的數據不會被頻繁地被置換,以提高緩存的命中。
LIRS 繼承了 LRU 根據時間局部性對冷熱數據進行預測的思想,并在此之上 LIRS 引入了兩個衡量數據塊的指標:
IRR(Inter-Reference Recency):表示數據最近兩次訪問之間訪問其它數據的非重復個數
R (Recency):表示數據最近一次訪問到當前時間內訪問其它數據的非重復個數,也就是 LRU 的維護的數據。
如下圖,從左往右經過以下 8 次訪問后,A 節點此時的 IRR 值為 3,R 值為 1。
IRR 可以由 R 值計算而來,具體公式為:IRR=上一時刻的 R-當前時刻的 R,如上圖當前時刻訪問的節點是 F,那么當前時刻 F 的 R 值為 0,而上一個 F 節點的 R 值為 2,因此 F 節點的 IRR 值為 2。
LIRS 動態維護兩個集合:
LIR(low IRR block set):具有較小 IRR 的數據塊集合,可以將這部分數據塊理解為熱數據,因為 IRR 低說明訪問的頻次高。
HIR(high IRR block set):具有較高 IRR 的數據塊集合,可以將這部分數據塊理解為冷數據。
LIR 集合所有數據都在緩存中,而 HIR 集合中有部分數據不在緩存中,但記錄了它們的歷史信息并標記為未駐留在緩存中,稱這部分數據塊為 nonresident-HIR,另外一部分駐留在緩存中的數據塊稱為 resident-HIR。
LIR 集合在緩存中,所以訪問 LIR 集合的數據是百分百會命中緩存的。而 HIR 集合分為 resident-HIR 和 nonresident-HIR 兩部分,所以會遇到未命中情況。當發生緩存未命中需要置換緩存塊時,會選擇優先淘汰置換 resident-HIR。如果 HIR 集合中數據的 IRR 經過更新比 LIR 集合中的小,那么 LIR 集合數據塊就會被 HIR 集合中 IRR 小的數據塊擠出并轉換為 HIR。
LIRS 通過限制 LIR 集合的長度和 resident-HIR 集合長度來限制整體大小,假設設定 LIR 長度為 2,resident-HIR 長度為 1 的 LIRS 算法過程演示如下:
所有最近訪問的數據都放置在稱為 LIRS 堆棧的 FIFO 隊列中(圖中的堆棧 S),所有常駐的 resident-HIR 數據放置在另一個 FIFO 隊列中(圖中的堆棧 Q)。
當棧 S 中的一個 LIR 數據被訪問時,被訪問的數據會被移動到堆棧 S 的頂部,并且堆棧底部的任何 HIR 數據都被刪除,因為這些 HIR 數據的 IRR 值不再有可能超過任何 LIR 數據了。例如,圖(b)是在圖(a)上訪問數據 B 之后生成的。
當棧 S 中的一個 resident-HIR 數據被訪問時,它變成一個 LIR 數據,相應地,當前在棧 S 最底部的 LIR 數據變成一個 HIR 數據并移動到棧 Q 的頂部。例如,圖(c)是在圖(a)上訪問數據 E 之后生成的。
當棧 S 中的一個 nonresident-HIR 數據被訪問時,它變成一個 LIR 數據,此時將選擇位于棧 Q 底部的 resident-HIR 數據作為替換的犧牲品,降級為 nonresident-HIR,而棧 S 最底部的 LIR 數據變成一個 HIR 數據并移動到棧 Q 的頂部。例如,圖(d)是在圖(a)上訪問數據 D 之后生成的。
當訪問一個不在棧 S 中的數據時,它會成為一個 resident-HIR 數據放入棧 Q 的頂部,同樣的棧 Q 底部的 resident-HIR 數據會降級為 nonresident-HIR。例如,圖(e)是在圖(a)上訪問數據 C 之后生成的。
解釋一下當棧 S 中的一個 HIR 數據被訪問時,它為什么一定會變成一個 LIR 數據:這個數據被訪問時,需要更新 IRR 值(即為當前的 R 值),使用這個新的 IRR 與 LIR 集合數據中最大的 R 值進行比較(即棧 S 最底部的 LIR 數據),新的 IRR 一定會比棧 S 最底部的 LIR 數據的 IRR 小(因為棧 S 最底部的數據一定是 LIR 數據,步驟 2 已經保證了),所以它一定會變成一個 LIR 數據。
7.7. MySQL InnoDB LRU
MySQL InnoDB 中的 LRU 淘汰算法采用了類似的 LIRS 的分級思想,它的置換數據方式更加簡單,通過判斷冷數據在緩存中存在的時間是否足夠長(即還沒有被 LRU 淘汰)來實現。數據首先進入冷數據區,如果數據在較短的時間內被訪問兩次或者以上,則成為熱點數據進入熱數據區,冷數據和熱數據部分區域內部各自還是采用 LRU 替換算法。
MySQL InnoDB LRU 算法流程可以描述為:
訪問數據如果位于熱數據區,與 LRU 算法一樣,移動到熱數據區的頭結點。
訪問數據如果位于冷數據區,若該數據已在緩存中超過指定時間,比如說 1s,則移動到熱數據區的頭結點;若該數據存在時間小于指定的時間,則位置保持不變。
訪問數據如果不在熱數據區也不在冷數據區,插入冷數據區的頭結點,若冷數據緩存已滿,淘汰尾結點的數據。
8. 基數集與基數統計
基數集即不重復元素的集合,基數統計即統計基數集中元素的個數。比如說 18 號晚微信視頻號西城男孩直播夜的累積觀看人數統計就是一個基數統計的應用場景。
8.1. 哈希表
哈希表是根據關鍵碼(Key)而直接進行訪問的數據結構,它把關鍵碼映射到一個有限的地址區間上存放在哈希表中,這個映射函數叫做散列函數。哈希表的設計最關鍵的是使用合理的散列函數和沖突解決算法。
好的散列函數應該在輸入域中較少出現散列沖突,數據元素能被更快地插入和查找。常見的散列函數算法有:直接尋址法,數字分析法,平方取中法,折疊法,隨機數法,除留余數法等。
然而即使再好的散列函數,也不能百分百保證沒有沖突,因此必須要有沖突的應對方法,常見的沖突解決算法有:
開放定址法:從發生沖突的那個單元起,按照一定的次序,從哈希表中找到一個空閑的單元。然后把發生沖突的元素存入到該單元的一種方法。開放定址法需要的表長度要大于等于所需要存放的元素。而查詢一個對象時,則需要從對應的位置開始向后找,直到找到或找到空位。根據探查步長決策規則不同,開放定址法中一般有:線行探查法(步長固定為 1,依次探查)、平方探查法(步長為探查次數的平方值)、雙散列函數探查法(步長由另一個散列函數計算決定)。
拉鏈法:在每個沖突處構建鏈表,將所有沖突值鏈入鏈表(稱為沖突鏈表),如同拉鏈一般一個元素扣一個元素。
再哈希法:就是同時構造多個不同的哈希函數,當前面的哈希函數發生沖突時,再用下一個哈希函數進行計算,直到沖突不再產生。
建立公共溢出區:哈希表分為公共表和溢出表,當溢出發生時,將所有溢出數據統一放到溢出區。
使用哈希表統計基數值即將所有元素存儲在一個哈希表中,利用哈希表對元素進行去重,并統計元素的個數,這種方法可以精確的計算出不重復元素的數量。
但使用哈希表進行基數統計,需要存儲實際的元素數據,在數據量較少時還算可行,但是當數據量達到百萬、千萬甚至上億時,使用哈希表統計會占用大量的內存,同時它的查找過濾成本也很高。如 18 號晚微信視頻號西城男孩直播夜有 2000 萬多的用戶觀看,假設記錄用戶的 id 大小需要 8 字節,那么使用哈希表結構至少需要 152.6M 內存,而為了降低哈希沖突率,提高查找性能,實際需要開辟更大的內存空間。
8.2. 位圖(Bitmap)
位圖就是用每一比特位來存放真和假狀態的一種數據結構,使用位圖進行基數統計不需要去存儲實際元素信息,只需要用相應位置的 1bit 來標識某個元素是否出現過,這樣能夠極大地節省內存。
如下圖所示,假設要存儲的數據范圍是 0-15,我們只需要使用 2 個字節組建一個擁有 16bit 位的比特數組,所有 bit 位的值初始化為 0,需要存儲某個值時只需要將相應位置的的 bit 位設置為 1,如下圖存儲了{2,5,6,9,11,14}六個數據。
假設觀看西城男孩直播的微信 id 值域是[0-2000 萬],采用位圖統計觀看人數所需要的內存就只需 2.38M 了。
位圖統計方式內存占用確實大大減少了,但位圖占用的內存和元素的值域有關,因為我們需要把值域映射到這個連續的大比特數組上。實際上觀看西城男孩直播的微信 id 不可能是連續的 2000 萬個 id 值,而應該按微信的注冊量級開辟長度,可能至少需要 20 億的 bit 位(238M 內存)。
8.3. 布隆過濾器
位圖的方式有個很大的局限性就是要求值域范圍有限,比如我們統計觀看西城男孩直播的微信 id 總計 2000 萬個,但實際卻需要按照微信 id 范圍上限 20 億來開辟空間,假如有一個完美散列函數,能正好將觀看了直播的這 2000 萬個微信 id 映射成[0-2000 萬]的不重復散列值,而其余沒有觀看直播的 19.8 億微信 id 都被映射為超過 2000 萬的散列值,那事情就好辦了,但事實是我們無法提前知道哪 2000 萬的微信號會觀看直播,因此這樣的散列函數是不可能存在的。
但這個思想是對的,布隆過濾器就是類似這樣的思想,它能將 20 億的 id 值映射到更小數值范圍內,然后使用位圖來記錄元素是否存在,因為值域范圍被壓縮了,必然會存在大面積的沖突,為了降低沖突導致的統計錯誤率,它通過 K 個不同的散列函數將元素映射成一個位圖中的 K 個 bit 位,并把它們都置為 1,只有當某個元素對應的這 K 個 bit 位同時為 1,才認為這個元素已經存在過。
假設 K=3,3 個哈希函數將數據映射到 0-15 的位圖中存儲,過程演示如下:
類似百分比近似排序,布隆過濾器也是犧牲一定的精確度來換取高性能的做法。它仍然存在一定的錯誤率,不能保證完全準確,比如上圖示例中,假設接下來要插入數據 123,它通過 3 個哈希函數分別被映射為:{2,3,6},此時會誤判為 123 已經存在了,將過濾掉該數據的統計。
但實際上只要 K 值和位圖數組空間設置合理,就能保證錯誤率在一定范圍,這對于大數據量的基數統計,完全能接受這樣的統計誤差。
8.4. 布谷鳥過濾器
布谷鳥過濾器是另外一種通過犧牲一定的精確度來換取高性能的做法,也是非常之巧妙。在解釋布谷鳥過濾器之前我們先來看下布谷鳥哈希算法。
布谷鳥哈希算法是 8.1 節中講到的解決哈希沖突的另一種算法,它的思想來源于布谷鳥“鳩占鵲巢”的生活習性。布谷鳥哈希算法會有兩個散列函數將元素映射到哈希表的兩個不同位置。如果兩個位置中有一個位置為空,那么就可以將元素直接放進去。但是如果這兩個位置都滿了,它就隨機踢走一個,然后自己霸占了這個位置。
被踢走的那個元素會去查看它的另外一個散列值的位置是否是空位,如果是空位就占領它,如果不是空位,那就把受害者的角色轉移出去,擠走對方,讓對方再去找安身之處,如此循環直到某個元素找到空位為止。布谷鳥哈希算法有個缺點是當空間本身很擁擠時,出現“鳩占鵲巢”的現象會很頻繁,插入效率很低,一種改良的優化方案是讓每個散列值對應的位置上可以放置多個元素。
8.1 節講到,哈希表可以用來做基數統計,因此布谷鳥哈希表當然也可以用來基數統計,而布谷鳥過濾器基于布谷鳥哈希算法來實現基數統計,布谷鳥哈希算法需要存儲數據的整個元素信息,而布谷鳥過濾器為了減少內存,將存儲的元素信息映射為一個簡單的指紋信息,例如微信的用戶 id 大小需要 8 字節,我們可以將它映射為 1 個字節甚至幾個 bit 的指紋信息來進行存儲。
由于只存儲了指紋信息,因此谷鳥過濾器的兩個散列函數的選擇比較特殊,當一個位置上的元素被擠走之后,它需要通過指紋信息計算出另一個對偶位置(布谷鳥哈希存儲的是元素的完整信息,必然能找到另一個散列值位置),因此它采用異或的方式達到目的,公式如下:
h1(x)?=?hash(x) h2(x)?=?h1(x)?⊕?hash(x的指紋)位置 h2 可以通過位置 h1 和 h1 中存儲的指紋信息計算出來,同樣的位置 h1 也可以通過 h2 和指紋信息計算出來。
布谷鳥過濾器實現了哈希表過濾和基數統計的能力,同時存儲元素信息改為存儲更輕量指紋信息節約了內存,但它損失了一些精確度,比如會出現兩個元素的散列位置相同,指紋也正好相同的情況,那么插入檢查會認為它們是相等的,只會統計一次。但同樣這個誤差率是可以接受的。
8.5. HyperLogLog
說到基數統計,就不得不提 Redis 里面的 HyperLogLog 算法了,前文所說的哈希表,位圖,布隆過濾器和布谷鳥過濾器都是基于記錄元素的信息并通過過濾(或近似過濾)相同元素的思想來進行基數統計的。
而 HyperLogLog 算法的思想不太一樣,它的基礎是觀察到可以通過計算集合中每個數字的二進制表示中的前導零的最大數目來估計均勻分布的隨機數的多重集的基數。如果觀察到的前導零的最大數目是 n,則集合中不同元素的數量的估計是 。
怎么理解呢?其實就是運用了數學概率論的理論,以拋硬幣的伯努利試驗為例,假設一直嘗試拋硬幣,直到它出現正面為止,同時記錄第一次出現正面時共嘗試的拋擲次數 k,作為一次完整的伯努利試驗。那么對于 n 次伯努利試驗,假設其中最大的那次拋擲次數為 。結合極大似然估算的方法,n 和 中存在估算關聯關系即:。
對應于基數統計的場景,HyperLogLog 算法通過散列函數,將數據轉為二進制比特串,從低位往高位看,第一次出現 1 的時候認為是拋硬幣的正面,因此比特串中前導零的數目即是拋硬幣的拋擲次數。因此可以根據存入數據中,轉化后的二進制串集中最大的首次出現 1 的位置 來估算存入了多少不同的數據。
這種估算方式存在一定的偶然性,比如當某次拋硬幣特別不幸時,拋出了很大的值,數據會偏差的厲害,為了降低這種極端偶然性帶來的誤差影響,在 HyperLogLog 算法中,會將集合分成多個子集(分桶計算),分別計算這些子集中的數字中的前導零的最大數量,最后使用調和平均數的計算方式將所有子集的這些估計值計算為全集的基數。例如 redis 會分為 16384 個子集進行分桶求平均統計。
9. 其他常用算法
9.1. 時間輪定時器
定時器的實現方式有很多,比如用有序鏈表或堆都可以實現,但是他們或插入或運行或刪除的性能不太好。時間輪定時器是一種插入,運行和刪除都比較理想的定時器。
時間輪定時器將按照到期時間分桶放入緩存隊列中,系統只需按照每個桶到期順序依次執行到期的時間桶節點中的所有定時任務。
如下圖所示:
而針對定時任務時間跨度大,且精度要求較高的場景,使用單層時間輪消耗的內存可能比較大,因此還可以進一步優化為采用層級時間輪來實現,層級時間輪就類似我們的時鐘,秒針轉一圈后,分針才進一格的原理,當內層時間輪運轉完一輪后,外層時間輪進一格,接下來運行下一格的內層時間輪。
9.2. 紅包分配
算法很簡潔,該算法沒有預先隨機分配好紅包金額列表,而是在每個用戶點擊搶紅包時隨機生成金額,該算法只需傳入當前剩余的總金額和剩余需要派發的總人數,算法的基本原理是以剩余單個紅包的平均金額的 2 倍為上限,隨機本次分配的金額。
這個算法的公平性在于每個領紅包的人能領取到的金額是從 0 到剩余平均金額的 2 倍之間的隨機值,所以期望就是剩余平均金額,假設 100 元發給 5 個人,那么第一個人領取到的期望是 20 元,第 2 個人領取到的期望是(100-20)/ 4 = 20 元,通過歸納法可以證明每個人領取到的期望都是 20 元。但是由于每個人領取到的金額隨機范圍是不一樣的,如第一個人能領取到的范圍是 0 到 40 元,而最后一個人能領取到的范圍是 0 到 100 元,因此方差跟領取的順序是有關系。這也告訴我們搶微信紅包想穩的人可以先搶,想博的人可以后搶。
這種分配算法的好處是無狀態化,不需要在創建紅包時預先分配并存儲金額列表,在某些場景可能會對性能帶來好處。
9.3. 有緣再續
優秀的算法還有很多,有緣再續。
10. 總結
筆者曾經寫過一篇《服務器開發設計詞匯寶典》,講述了一些后臺程序架構和系統方面的設計知識,如果把架構設計比做程序員的內功修煉的話,那么算法就是戰斗中的招式,選擇合適的算法能讓你的代碼化繁為簡,或高效或優雅,見招拆招,起到四兩撥千斤的效果的同時震撼心靈。
很多算法的思想都有一些共通性,他們用到的基礎思想都有相似之處,如隨機,分治遞歸,多策略相結合,一次不行再來一次等思想。比如隨機,這個在服務器開發設計中隨處可見的策略,yyds,隨機帶來的是一種個體偶然性與總體必然性的結合,在 jump consistent hash 算法,跳表排序算法、帶權重的 A-ExpJ 算法蓄水池抽樣等算法中都使用了隨機跳躍的思想,實現了在無需統計狀態數據的情況下,利用隨機的整體均衡性來保證算法正確性的同時極大的簡化了算法和提高了效率。
學習和研究優秀的算法目的是希望能在實際應用場景中做到靈活運用,取長補短,甚至能結合不同算法思想啟發創造出適合各種問題場景的算法策略。
由于個人水平有限,文中難免有所紕漏,歡迎指正。
參考文章
《The Power of Two Random Choices》
《The Art of Computer Programming (vol. 2_ Seminumerical Algorithms) (3rd ed.)》
《Fisher–Yates shuffle》wikipedia
《Random Sampling with a Reservoir》JEFFREY SCOTT VITTER
《Weighted Random Sampling》(Efraimidis, Spirakis)
《Weighted random sampling with a reservoir》(Efraimidis, Spirakis)
《Reservoir sampling》wikipedia
《十大經典排序算法》菜鳥教程
《多路平衡歸并排序算法》
《TDigest 算法原理》
《ElasticSearch 如何使用 TDigest 算法計算億級數據的百分位數?》
《分位數算法總結》
《Handling Overload》
《zigzag 算法詳解》
《數據序列化格式》wikipedia
《markdown》百度百科
《擁抱 protobuf,迎接 TDR 2.0 時代》 km 文章
《Compress》https://gitee.com/yu120/compress
《無損壓縮算法》wikipedia
《LZ77 與 LZ78》 wikipedia
《公開密鑰加密》wikipedia
《哈希鏈》wikipedia
《常用緩存淘汰算法 LFU/LRU/ARC/FIFO/MRU》
《LIRS caching algorithm》wikipedia
《緩存淘汰算法 LIRS 原理與實現》
《MySQL 5.7 Reference Manual》
《布隆過濾器過時了,未來屬于布谷鳥過濾器?》
《HyperLogLog 算法的原理講解以及 Redis 是如何應用它的》
《關于基數統計》
《算法導論(第 2 版)》
其他網絡資料
最近好文:
微信圖片翻譯技術優化之路
C++ 智能指針最佳實踐&源碼分析
Golang 并發編程指南
總結
以上是生活随笔為你收集整理的服务器开发设计之算法宝典的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: C++ 智能指针最佳实践源码分析
- 下一篇: 2021 腾讯技术十大热门文章