局部性原理在php、mysql、kafka的實際應用

何爲局部性原理?

    工作已經快10年的時間,看了很多各種技術書籍,總結了其中一些共性,其中就有一點,在系統性能方面,尤其是存儲方面,局部性原理都在其中扮演着非常重要的角色,比如PHP5到PHP7的優化、比如MySQL索引、kafka的page cache 的應用等等,都或多或少夾雜着局部性原理在其中的應用。

    那麼,究竟什麼是局部性原理?這裏一般分爲2種:

         時間局部性:如果一個信息項正在被訪問,那麼在近期它很可能還會被再次訪問。

         空間局部性:在最近的將來將用到的信息很可能與正在使用的信息在空間地址上是臨近的。

   總結起來就是一句話:當一個數據被用到時,其附近的數據也通常會馬上被使用。

CPU緩存與局部性原理

    CPU的運算速度實在太快了,現有的內存條和硬盤是跟不上這個速度的,爲了解決CPU運行處理速度與內存讀寫速度不匹配的矛盾,在CPU和內存之前,加入了L1、L2、L3三個級別的緩存,緩存有着更快的速度,但是考慮到CPU芯片的面積和成本,緩存一般都特別小。一二三級緩存的成本依次降低,因此容量依次增大。CPU會先在最快的L1中尋找需要的數據,找不到再去找次快的L2,還找不到再去找L3,L3都沒有那就只能去內存找了。

    CPU各級緩存與內存速度到底差多少?

     我們來簡單地打個比方:如果CPU在L1一級緩存中找到所需要的資料要用的時間爲3個週期左右,那麼在L2二級緩存找到資料的時間就要10個週期左右,L3三級緩存所需時間爲50個週期左右;如果要到內存上去找呢,那就慢多了,可能需要幾百個週期的時間。

     CPU緩存命中率

       CPU需要找的內容,80% 都可以從L1中找到,剩餘的20%可以在L2、L3、以及內存中找到,爲什麼會有如此高的一個概率,就是因爲局部性原理在起作用。

    緩存命中率在PHP7優化的應用

     大家都知道PHP5升級到PHP7有着至少2倍的性能提升,其實在優化中,並沒有用到非常先進的技術,完全是在細節處的把握,這裏我主要講到3點,

    1、PHP7的變量底層結構zval,有着更加的精妙的結構,更少的內存佔用。

    2、數組底層結構HashTable也是更少的內存佔用,而且會爲每個Bucket單獨分配空間,這樣的弊端如下:

  • 內存分配總是低效的,而且每次還額外需要分配8/16個字節,這是內存分配的冗餘。分開分配也意味着這些buckets會分佈在內存空間的不同地址中。
  • Zvals也需要分開分配。上面已經說明這種方式很低效,它也會產生一些額外的頭開銷冗餘(header overhead)。另外這需要在每個bucket中保存一個指向zval結構的指針,由於老的實現過於考慮通用性,所以不止需要一個指針,而是兩個指針。
  • 雙向鏈表中的每個bucket需要4個指針用於鏈表的連接,這會帶來16/32個字節的開銷,遍歷這種鏈表也不利於緩存(cache-unfriendly)操作。

    PHP7的數組結構不僅擁有更加精簡的HashTable結構。而且採用arData數組保存了所有的buckets(也就是數組的元素),另外有一個數組做hash對應數據數組的下標。

    這樣PHP7的HashTable除了有更低的內存,而且擁有更緊湊的內存結構。

    以上更低的內存佔用和更緊湊的內存結構,都是爲了更高的CPU緩存命中率。

   3、除此之外,在執行器層面還有一個非常重要的優化,execute_data、opline採用寄存器變量存儲,執行器的調度函數爲execute_ex(),這個函數負責執行PHP代碼編譯生成的ZendVM指令,在執行期間會頻繁地用到execute_data、opline兩個變量,在PHP5中,這兩個變量是有execute_ex()通過參數傳遞給各指令handler的,在PHP7中不再採用傳參的方式,而是將execute_data、opline通過寄存器來進行存儲,避免傳參導致的頻繁出入棧操作,同時,寄存器相比內存的訪問速度更快,這使得PHP的性能有了5%左右的提升。

    寄存器是離CPU最近的存儲結構,擁有比一級緩存還要快的速度。

局部性原理在MySQL的應用

MySQL索引採用的數據結構是B+Tree,其結構採用如下:

樹的每個節點佔用一個頁的磁盤空間,InnoDB存儲引擎中頁的大小爲16KB,一般表的主鍵類型爲INT(佔用4個字節)或BIGINT(佔用8個字節),指針類型也一般爲4或8個字節,也就是說一個頁(B+Tree中的一個節點)中大概存儲16KB/(8B+8B)=1K個鍵值(因爲是估值,爲方便計算,這裏的K取值爲〖10〗^3)。也就是說一個深度爲3的B+Tree索引可以維護10^3 * 10^3 * 10^3 = 10億 條記錄。

實際情況中每個節點可能不能填充滿,因此在數據庫中,B+Tree的高度一般都在2~4層。mysql的InnoDB存儲引擎在設計時是將根節點常駐內存的,也就是說查找某一鍵值的行記錄時最多隻需要1~3次磁盤I/O操作。

爲什麼使用B+Tree?

紅黑樹等數據結構也可以用來實現索引,但是文件系統及數據庫系統普遍採用B-/+Tree作爲索引結構,這一節將結合計算機組成原理相關知識討論B-/+Tree作爲索引的理論基礎。

    一般來說,索引本身也很大,不可能全部存儲在內存中,因此索引往往以索引文件的形式存儲的磁盤上。這樣的話,索引查找過程中就要產生磁盤I/O消耗,相對於內存存取,I/O存取的消耗要高几個數量級,所以評價一個數據結構作爲索引的優劣最重要的指標就是在查找過程中磁盤I/O操作次數的漸進複雜度。換句話說,索引的結構組織要儘量減少查找過程中磁盤I/O的存取次數。下面先介紹內存和磁盤存取原理,然後再結合這些原理分析B-/+Tree作爲索引的效率。

局部性原理與磁盤預讀

    索引一般以文件形式存儲在磁盤上,索引檢索需要磁盤I/O操作。與主存不同,磁盤I/O存在機械運動耗費,因此磁盤I/O的時間消耗是巨大的。

   因此爲了提高效率,要儘量減少磁盤I/O。爲了達到這個目的,磁盤往往不是嚴格按需讀取,而是每次都會預讀,即使只需要一個字節,磁盤也會從這個位置開始,順序向後讀取一定長度的數據放入內存。這樣做的理論依據就是計算機科學中著名的局部性原理:當一個數據被用到時,其附近的數據也通常會馬上被使用。程序運行期間所需要的數據通常比較集中。
    由於磁盤順序讀取的效率很高(不需要尋道時間,只需很少的旋轉時間),因此對於具有局部性的程序來說,預讀可以提高I/O效率。
    預讀的長度一般爲頁(page)的整倍數。頁是計算機管理存儲器的邏輯塊,硬件及操作系統往往將主存和磁盤存儲區分割爲連續的大小相等的塊,每個存儲塊稱爲一頁(在許多操作系統中,頁得大小通常爲4k),主存和磁盤以頁爲單位交換數據。當程序要讀取的數據不在主存中時,會觸發一個缺頁異常,此時系統會向磁盤發出讀盤信號,磁盤會找到數據的起始位置並向後連續讀取一頁或幾頁載入內存中,然後異常返回,程序繼續運行。

B-/+Tree索引的性能分析

    從使用磁盤I/O次數評價索引結構的優劣性:根據B-Tree的定義,可知檢索一次最多需要訪問h個結點。數據庫系統的設計者巧妙的利用了磁盤預讀原理,將一個結點的大小設爲等於一個頁面,這樣每個結點只需要一次I/O就可以完全載入。爲了達到這個目的,在實際實現B-Tree還需要使用如下技巧:

    每次新建結點時,直接申請一個頁面的空間,這樣可以保證一個結點的大小等於一個頁面,加之計算機存儲分配都是按頁對齊的,就實現了一個node只需一次I/O。

    B-Tree中一次檢索最多需要h-1次I/O(根結點常駐內存),漸進複雜度爲O(h)=O(logdN)。一般實際應用中,出讀d是非常大的數字,通常超過100,因此h非常小。

    綜上所述,用B-Tree作爲索引結構效率是非常高的。

    而紅黑樹結構,h明顯要深得多。由於邏輯上很近的結點(父子結點)物理上可能離得很遠,無法利用局部性原理。所以即使紅黑樹的I/O漸進複雜度也爲O(h),但是查找效率明顯比B-Tree差得多

 

page cache與Kafka   

  關於Kafka的一個靈魂拷問:它爲什麼這麼快?

  有很多文章已經對這個問題給出了回答,但本文只重點研究其中的一個方向,即對page cache的使用。先簡單地認識一下Linux系統中的page cache。 該小節出自kafka:https://www.jianshu.com/p/92f33aa0ff52

  page cache出現的目的是爲了加速數據I/O:寫數據時首先寫到緩存,將寫入的頁標記爲dirty,然後向外部存儲flush,也就是緩存寫機制中的write-back(另一種是write-through,Linux未採用);讀數據時首先讀取緩存,如果未命中,再去外部存儲讀取,並且將讀取來的數據也加入緩存。操作系統總是積極地將所有空閒內存都用作page cache,當內存不夠用時也會用LRU等算法淘汰緩存頁,page cache能夠有效,依然是局部性原理的應用。

   在kafka中如何運營page cache 呢?我們通過下面一張圖來看:

   producer生產消息時,會使用pwrite()系統調用【對應到Java NIO中是FileChannel.write() API】按偏移量寫入數據,並且都會先寫入page cache裏。consumer消費消息時,會使用sendfile()系統調用【對應FileChannel.transferTo() API】,零拷貝地將數據從page cache傳輸到broker的Socket buffer,再通過網絡傳輸。

  圖中沒有畫出來的還有leader與follower之間的同步,這與consumer是同理的:只要follower處在ISR中,就也能夠通過零拷貝機制將數據從leader所在的broker page cache傳輸到follower所在的broker。

   同時,page cache中的數據會隨着內核中flusher線程的調度以及對sync()/fsync()的調用寫回到磁盤,就算進程崩潰,也不用擔心數據丟失。另外,如果consumer要消費的消息不在page cache裏,纔會去磁盤讀取,並且會順便預讀出一些相鄰的塊放入page cache,以方便下一次讀取。

  由此我們可以得出重要的結論:如果Kafka producer的生產速率與consumer的消費速率相差不大,那麼就能幾乎只靠對broker page cache的讀寫完成整個生產-消費過程,磁盤訪問非常少。這個結論俗稱爲“讀寫空中接力”。並且Kafka持久化消息到各個topic的partition文件時,是隻追加的順序寫,充分利用了磁盤順序訪問快的特性,效率高。

 

 

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