程式"三高"解決方案

Juno3550發表於2021-05-27

0. 程式三高

1. 快取

2. 預處理和延後處理

3. 池化

  • 3.1 記憶體池
  • 3.2 執行緒池
  • 3.3 連線池

4. 非同步(回撥)

5. 訊息佇列

  • 5.1 服務解耦
  • 5.2 非同步處理
  • 5.3 流量削峰

6. 批量處理

7. 資料庫

  • 7.1 索引
  • 7.2 讀寫分離
  • 7.3 分庫分表

8. 零拷貝

9. 無鎖化

10. 序列與反序列化

 

 

0. 程式三高

什麼是程式三高?

1)高併發

高併發(High Concurrency)是網際網路分散式系統架構設計中必須考慮的因素之一。當多個程式或執行緒同時(或著說在同一段時間內)訪問同一資源時會產生併發問題,因此需要通過專門的設計來保證系統能夠同時(併發)正確處理多個請求

2)高效能

簡單地說,高效能(High Performance)就是指程式處理速度快、耗能少。與效能相關的一些指標如下:

  • 響應時間:系統對請求做出響應的時間。例如系統處理一個 HTTP 請求需要 200ms,這個 200ms 就是系統的響應時間。
  • 吞吐量:單位時間內處理的請求數量。
  • TPS:每秒響應事務數。
  • 併發使用者數:同時承載能正常使用系統功能的使用者數量。

高併發和高效能是緊密相關的,提高應用的效能,可以提高系統的併發能力。

應用效能優化時,對於計算密集型和 I/O 密集型還是有很大差別,需要分開來考慮。

水平擴充套件(Scale Out):只要增加伺服器數量,就能線性擴充系統效能。通常增加伺服器資源(CPU、記憶體、伺服器數量),大部分時候是可以提高應用的併發能力和效能 (前提是應用能夠支援多工平行計算和多伺服器分散式計算才行)。但水平擴充套件對系統架構設計是有要求的,難點在於:如何在架構各層進行可水平擴充套件的設計。

3)高可用

高可用性(High Availability)通常用來描述一個系統經過專門的設計,從而減少停工時間,保證服務的持續可用

如高可用性叢集就是保證業務連續性的有效解決方案。

“三高”解決方案

本文主要粗淺地介紹了一些系統設計、系統優化的套路和最佳實踐。

其實從快取、訊息佇列到 CAS……很多看起來很牛逼的架構設計其實都來源於作業系統、體系結構。

這些底層的基礎知識看似古老的技術是經過時間洗禮留下來的好東西。現在很多的新技術、框架看似非常厲害,實則不少都是新瓶裝舊酒,每幾年又會被淘汰一批。

 

1. 快取

什麼是快取?

在計算機中,快取是儲存資料的硬體或軟體元件,以便可以更快地滿足將來對該資料的請求。儲存在快取中的資料可能是之前計算結果,也可能是儲存在其他位置的資料副本。       ——維基百科

快取本質來說是用空間換時間的思想,它在計算機世界中無處不在, 比如 CPU 就自帶 L1、L2、L3 Cache,這在一般應用開發中關注較少,但在一些實時系統、大規模計算模擬、影像處理等追求極致效能的領域,就特別注重編寫快取友好的程式碼。

什麼是快取友好?

簡單來說,就是程式碼在訪問資料的時候,儘量使用快取命中率高的方式。

快取為什麼有效?

快取之所以能夠大幅提高系統的效能,關鍵在於資料的訪問具有區域性性,也就是二八定律:「百分之八十的資料訪問是集中在 20% 的資料上」。這部分資料也被叫做熱點資料。

快取一般使用記憶體作為儲存,記憶體讀寫速度快於磁碟,但容量有限,十分寶貴,不可能將所有資料都快取起來。

如果應用訪問資料沒有熱點,不遵循二八定律,即大部分資料訪問並沒有集中在小部分資料上,那麼快取就沒有意義,因為大部分資料還沒有被再次訪問就已經被擠出快取了。每次訪問都會回源到資料庫查詢,那麼反而會降低資料訪問效率。

快取分類

1)本地快取

使用程式內成員變數或者靜態變數,適合簡單的場景,不需要考慮快取一致性、過期時間、清空策略等問題。

可以直接使用語言標準庫內的容器來做儲存。

2)分散式快取

當快取的資料量增大以後,單機不足以承載快取服務時,就要考慮對快取服務做水平擴充套件,引入快取叢集。

將資料分片後分散儲存在不同機器中,如何決定每個資料分片存放在哪臺機器呢?一般是採用一致性 Hash 演算法,它能夠保證在快取叢集動態調整,在不斷增加或者減少機器時,客戶端訪問時依然能夠根據 key 訪問到資料。

常用的元件有 Memcache、 Redis Cluster 等,也可以在高效能記憶體儲存 Redis 的基礎上,提供分散式儲存的解決方案。

適合快取的場景

1)讀多寫少

比如電商裡的商品詳情頁面,訪問頻率很高,但是一般寫入只在店家上架商品和修改資訊的時候發生。如果把熱點商品的資訊快取起來,這將攔截掉很多對資料庫的訪問,提高系統整體的吞吐量。

因為一般資料庫的 QPS 由於有「ACID」約束、並且資料是持久化在硬碟的,所以比 Redis 這類基於記憶體的 NoSQL 儲存低不少,這常常是一個系統的瓶頸,如果我們把大部分的查詢都在 Redis 快取中命中了,那麼系統整體的 QPS 也就上去了。

2)計算耗時大,且實時性不高

比如王者榮耀裡的全區排行榜,一般一週更新一次,並且計算的資料量也比較大,所以計算後快取起來,請求排行榜直接從快取中取出,就不用實時計算了。

不適合快取的場景

  1. 寫多讀少,頻繁更新。
  2. 對資料一致性要求嚴格: 因為快取會有更新策略,所以很難做到和資料庫實時同步。
  3. 資料訪問完全隨機: 因為這樣會導致快取的命中率極低。

快取更新的策略

如何更新快取其實已經有總結得非常好的「最佳實踐」,我們按照套路來,大概率不會犯錯。策略主要分為兩類:

  1. Cache-Aside
  2. Cache-As-SoR:SoR(System Of Record,記錄系統)表示資料來源,一般就是指資料庫。

1)Cache-Aside

這應該是最容易想到的模式了,獲取資料時先從快取讀,如果 cache hit(快取命中)則直接返回,若沒命中就從資料來源獲取,然後更新快取。

寫資料的時候則先更新資料來源,然後設定快取失效,那麼下一次獲取資料的時候必然 cache miss,然後觸發回源。

可以看出這種方式對於快取的使用者是不透明的,需要使用者手動維護快取。

2)Cache-As-SoR 

從字面上來看,就是把 Cache 當作 SoR,也就是資料來源,所以一切讀寫操作都是針對 Cache 的,由 Cache 內部自己維護和資料來源的一致性。這樣對於使用者來說就和直接操作 SoR 沒有區別了,完全感知不到 Cache 的存在。

CPU 內部的 L1、L2、L3 Cache 就是這種方式,作為資料的使用方(應用程式),是完全感知不到在記憶體和我們之間還存在幾層的 Cache,但是我們之前又提到編寫 “快取友好”的程式碼。這種策略不是透明的嗎?這是不是衝突呢?

其實不然,快取友好是指我們通過學習瞭解快取內部實現、更新策略之後,通過調整資料訪問順序提高快取的命中率。

Cache-As-SoR 又分為以下三種方式:

  1. Read Through:這種方式和 Cache-Aside 非常相似,都是在查詢時發生 cache miss 去更新快取,但是區別在於 Cache-Aside 需要呼叫方手動更新快取,而 Cache-As-SoR 則是由快取內部實現自己負責,對應用層透明。
  2. Write Through:直寫式,就是在將資料寫入快取的同時,快取也去更新後面的資料來源,並且必須等到資料來源被更新成功後才可返回。這樣保證了快取和資料庫裡的資料一致性。
  3. Write Back:回寫式,資料寫入快取即可返回,快取內部會非同步的去更新資料來源,這樣好處是寫操作特別快,因為只需要更新快取。並且快取內部可以合併對相同資料項的多次更新,但是帶來的問題就是資料不一致,可能發生寫丟失。

 

2. 預處理與延後處理

預先延後,這其實是一個事物的兩面,兩者的核心思想都是將本來該在實時鏈路上處理的事情剝離,要麼提前處理、要麼延後處理,以降低實時鏈路的路徑長度, 這樣能有效提高系統效能。

2.1 預處理

案例:

前段時間支付寶聯合杭州市政府發放消費劵,但是要求只有杭州市常駐居民才能領取,那麼需要在搶卷請求進入後臺的時候就判斷一下使用者是否是杭州常駐居民。

而判斷使用者是否是常駐居民這個是另外一個微服務介面,如果直接實時的去呼叫那個介面,短時的高併發很有可能把這個服務也拖掛,最終導致整個系統不可用,並且 RPC 本身也是比較耗時的,所以就考慮在這裡進行優化。

解決思路:

那麼該怎麼做呢?很簡單的一個思路,提前將杭州所有常駐居民的 user_id 存到快取中, 比如可以直接存到 Redis,大概就是千萬量級。這樣,當請求到來的時候我們直接通過快取可以快速判斷是否來自杭州常駐居民,如果不是則直接在這裡返回前端。

這裡通過預先處理減少了實時鏈路上的 RPC 呼叫,既減少了系統的外部依賴,也極大地提高了系統的吞吐量。

預處理在 CPU 和作業系統中也廣泛使用,比如 CPU 基於歷史訪存資訊,將記憶體中的指令和資料預取到 Cache 中,這樣可以大大提高 Cache 命中率。 還比如在 Linux 檔案系統中,預讀演算法會預測即將訪問的 page,然後批量載入比當前讀請求更多的資料快取在 page cache 中,這樣當下次讀請求到來時可以直接從 cache 中返回,大大減少了訪問磁碟的時間。

2.2 延後處理

還是支付寶的案例:

這是支付寶春節集五福活動開獎當晚。大家發現沒有,這類活動中獎獎金一般會顯示 「稍後到賬」,為什麼呢?那當然是到賬這個操作不簡單!

到賬即轉賬,等於 A 賬戶給 B 賬戶轉錢,A 減錢時,B 就必須要同時加上錢。也就是說不能 A 減了錢但 B 沒有加上,這就會導致資金損失。資金安全是支付業務的生命線,這可不行。

這兩個動作必須一起成功或是一起都不成功,不能只成功一半,這是保證資料一致性,保證兩個操作同時成功或者失敗就需要用到事務。

如果去實時的做到賬,那麼大概率資料庫的 TPS(每秒處理的事務數) 會是瓶頸。通過產品提示,將到賬操作延後處理,解決了資料庫 TPS 瓶頸。

延後處理還有一個非常著名的例子,COW(Copy On Write,寫時複製)。如 Linux 建立程式時呼叫 fork,fork 產生的子程式只會建立虛擬地址空間,而不會分配真正的實體記憶體,子程式共享父程式的物理空間,只有當某個程式需要寫入的時候,才會真正分配物理頁,拷貝該物理頁,通過 COW 減少了很多不必要的資料拷貝。

 

3. 池化

後臺開發過程中你一定離不開各種 「池子」: 記憶體池、連線池、執行緒池、物件池……

記憶體、連線、執行緒這些都是資源,建立執行緒、分配記憶體、資料庫連線這些操作都有一個特徵, 那就是建立和銷燬過程都會涉及到很多系統呼叫或者網路 I/O,每次都在請求中去申請建立這些資源,就會增加請求處理耗時。如果我們用一個“容器(池)”把它們儲存起來,下次需要的時候,直接拿出來使用,就可以避免重複建立和銷燬所浪費的時間

3.1 記憶體池

在 C/C++ 中經常會使用 malloc、new 等 API 動態申請記憶體。由於申請的記憶體塊大小不一,如果頻繁的申請、釋放會導致大量的記憶體碎片,並且這些 API 底層依賴系統呼叫,會有額外的開銷。

記憶體池就是在使用記憶體前,先向系統申請一塊空間留做備用,使用者需要內池時則向記憶體池申請,用完後還回來。

記憶體池的思想非常簡單,實現卻不簡單,難點在於以下幾點:

  • 如何快速分配記憶體
  • 降低記憶體碎片率
  • 維護記憶體池所需的額外空間儘量少

如果不考慮效率,我們完全可以將記憶體分為不同大小的塊,然後用連結串列連線起來,分配的時候找到大小最合適的返回,釋放的時候直接新增進連結串列。如:

當然這只是玩具級別的實現,業界有效能非常好的實現了,我們可以直接拿來學習和使用。

比如 Google 的“tcmalloc”和 Facebook 的“jemalloc”,如果感興趣可以搜來看看,也推薦去看看被譽為神書的 CSAPP(《深入理解計算機系統》)第 10 章,那裡也講到了動態記憶體分配演算法。

3.2 執行緒池

執行緒是幹嘛的?執行緒就是我們程式執行的實體。在伺服器開發領域,我們經常會為每個請求分配一個執行緒去處理,但是執行緒的建立銷燬、排程都會帶來額外的開銷,執行緒過多也會導致系統整體效能下降。在這種場景下,我們通常會提前建立若干個執行緒,通過執行緒池來進行管理。當請求到來時,只需從執行緒池選一個執行緒去執行處理任務即可。

執行緒池常常和佇列一起使用來實現任務排程,主執行緒收到請求後將建立對應的任務,然後放到佇列裡,執行緒池中的工作執行緒等待佇列裡的任務。

執行緒池實現上一般有四個核心組成部分:

  1. 管理器(Manager): 用於建立並管理執行緒池。
  2. 工作執行緒(Worker): 執行任務的執行緒。
  3. 任務介面(Task): 每個具體的任務必須實現任務介面,工作執行緒將呼叫該介面來完成具體的任務。
  4. 任務佇列(TaskQueue): 存放還未執行的任務。

3.3 連線池

顧名思義,連線池是建立和管理連線的。

大家最熟悉的莫過於資料庫連線池,這裡我們簡單分析下如果不用資料庫連線池,一次 SQL 查詢請求會經過哪些步驟:

  1. 和 MySQL server 建立 TCP 連線:三次握手
  2. MySQL 許可權認證:
    1. Server 向 Client 傳送金鑰
    2. Client 使用金鑰加密使用者名稱、密碼等資訊,將加密後的報文傳送給 Server
    3. Server 根據 Client 請求包,驗證是否是合法使用者,然後給 Client 傳送認證結果
  3. Client 傳送 SQL 語句
  4. Server 返回語句執行結果
  5. MySQL 關閉
  6. TCP 連線斷開:四次揮手

可以看出不使用連線池的話,為了執行一條 SQL,會花很多時間在安全認證、網路 I/O 上。

如果使用連線池,執行一條 SQL 就省去了建立連線和斷開連線所需的額外開銷。

還能想起哪裡用到了連線池的思想嗎?HTTP 長連結也算一個變相的連結池,雖然它本質上只有一個連線,但是思想卻和連線池不謀而合,都是為了複用同一個連線傳送多個 HTTP 請求,避免建立和斷開連線的開銷。

池化實際上也是預處理和延後處理的一種應用場景,通過池子將各類資源的建立提前和銷燬延後。

 

4. 非同步(回撥)

對於處理耗時的任務,如果採用同步的方式,會增加任務耗時,降低系統併發度。此時可以通過將同步任務變為非同步進行優化。

  1. 同步:比如我們去 KFC 點餐,遇到排隊的人很多,當點完餐後,大多情況下我們會隔幾分鐘就去問好了沒,反覆去問了好幾次才拿到,在這期間我們也沒法幹活了,這個就叫同步輪循, 這樣效率顯然太低了。
  2. 非同步:服務員被問煩了,就在點完餐後給我們一個號碼牌,每次準備好了就會在服務檯叫號,這樣我們就可以在被叫到的時候再去取餐,中途可以繼續幹自己的事。

在很多程式語言中有非同步程式設計的庫,比如 C++ 的 std::future、Python 的 asyncio 等,但是非同步程式設計往往需要回撥函式(Callback function),如果回撥函式的層級太深,這就是回撥地獄(Callback hell)。回撥地獄如何優化又是一個龐大的話題……

這個例子相當於函式呼叫的非同步化,還有的情況是處理流程非同步化,這個會在接下來訊息佇列中講到。

 

5. 訊息佇列

這是一個非常簡化的訊息佇列模型,上游生產者將訊息通過佇列傳送給下游消費者。在這之間,訊息佇列可以發揮很多作用,比如:

5.1 服務解耦

有些服務被其它很多服務依賴,比如一個論壇網站,當使用者成功釋出一條帖子後,系統會有一系列的流程要做,有積分服務計算積分、推送服務向釋出者的粉絲推送一條訊息等,對於這類需求,常見的實現方式是直接呼叫:

但是如果此時需要新增一個資料分析的服務,那麼又得改動釋出服務,這違背了依賴倒置原則,即上層服務不應該依賴下層服務,那麼怎麼辦呢? 

引入訊息佇列作為中間層,當帖子釋出完成後,傳送一個事件到訊息佇列裡,而關心帖子釋出成功這件事的下游服務就可以訂閱這個事件,這樣即使後續繼續增加新的下游服務,只需要訂閱該事件即可,完全不用改動釋出服務,完成系統解耦。

5.2 非同步處理

有些業務涉及到的處理流程非常多,但是很多步驟並不要求實時性,那麼我們就可以通過訊息佇列非同步處理

比如淘寶下單,一般包括了風控、鎖庫存、生成訂單、簡訊/郵件通知等步驟,但是核心的就風控和鎖庫存,只要風控和扣減庫存成功,那麼就可以返回結果通知使用者成功下單了。後續的生成訂單,簡訊通知都可以通過訊息佇列傳送給下游服務非同步處理,這樣可以大大提高系統響應速度。

這就是處理流程非同步化。

5.3 流量削峰

一般像秒殺、抽獎、搶卷這種活動都會伴隨短時間內海量的請求, 一般都超過後端的處理能力,那麼我們就可以在接入層將請求放到訊息佇列裡,後端根據自己的處理能力不斷從佇列裡取出請求進行業務處理,起到平均流量的作用

就像長江汛期,上游短時間大量的洪水匯聚直奔下游,但是通過三峽大壩將這些水快取起來,然後勻速的向下遊釋放,起到了很好的削峰作用。

5.4 總結

訊息佇列的核心思想就是把同步的操作變成非同步處理,而非同步處理會帶來相應的好處,比如:

  • 服務解耦。
  • 提高系統的併發度,將非核心操作非同步處理,這樣不會阻塞主流程。

但是軟體開發沒有銀彈,所有的方案選擇都是一種 trade-off(權衡、取捨)。 同樣,非同步處理也不全是好處,也會導致一些問題:

  • 降低了資料一致性,從強一致性變為最終一致性。
  • 有訊息丟失的風險,比如當機,需要有容災機制。

 

6. 批量處理

在涉及到網路連線、I/O 等情況時,將操作批量進行處理能夠有效提高系統的傳輸速率和吞吐量

在前後端通訊中,通過合併一些頻繁請求的小資源可以獲得更快的載入速度。

比如我們後臺 RPC 框架,經常有更新資料的需求,而有的資料更新的介面往往只接受一項,這個時候我們往往會優化下更新介面,使其能夠接受批量更新的請求,這樣可以將批量的資料一次性傳送,大大縮短網路 RPC 呼叫耗時。

 

7. 資料庫

我們常把後臺開發調侃為“CRUD”(增刪改查),可見資料庫在整個應用開發過程中的重要性不言而喻。

而且很多時候系統的瓶頸也往往處在資料庫這裡,慢的原因也有很多,比如沒用索引、沒用對索引、讀寫鎖衝突等等。

那麼如何使用資料才能又快又好呢?下面這幾點需要重點關注:

7.1 索引

索引可能是我們平時在使用資料庫過程中接觸得最多的優化方式。索引好比圖書館裡的書籍索引號,想象一下,如果我讓你去一個沒有書籍索引號的圖書館找《人生》這本書,你是什麼樣的感受?當然是懷疑人生,同理,你應該可以理解當你查詢資料卻不用索引的時候,資料庫該有多崩潰了吧。

資料庫表的索引就像圖書館裡的書籍索引號一樣,可以提高我們檢索資料的效率。索引能提高查詢效率,可是你有沒有想過為什麼呢?這是因為索引一般而言是一個排序列表,排序意味著可以基於二分思想進行查詢,將查詢時間複雜度做到 O(logn),從而快速地支援等值查詢和範圍查詢。

二叉搜尋樹的查詢效率無疑是最高的,因為平均來說每次比較都能縮小一半的搜尋範圍,但是一般在資料庫索引的實現上卻會選擇 B 樹或 B+ 樹而不用二叉搜尋樹,為什麼呢?

這就涉及到資料庫的儲存介質了,資料庫的資料和索引都是存放在磁碟,並且是 InnoDB 引擎是以頁為基本單位管理磁碟的,一頁一般為 16 KB。AVL 或紅黑樹的搜尋效率雖然非常高,但是同樣的資料項,它也會比 B、B+ 樹(高度)更高,高就意味著平均來說會訪問更多的節點,即磁碟 I/O 次數!

所以表面上來看我們使用 B、B+ 樹沒有二叉查詢樹效率高,但是實際上由於 B、B+ 樹降低了樹高,減少了磁碟 I/O 次數,反而大大提升了速度。

這也告訴我們,沒有絕對的快和慢,系統分析要抓主要矛盾,先分析出決定系統瓶頸的到底是什麼,然後才是針對瓶頸的優化。

下面是索引必知必會的知識,大家可以查漏補缺:

  • 主鍵索引和普通索引,以及它們之間的區別
  • 最左字首匹配原則
  • 索引下推
  • 覆蓋索引、聯合索引

7.2 讀寫分離

一般業務剛上線的時候,直接使用單機資料庫就夠了,但是隨著使用者量上來之後,系統就面臨著大量的寫操作和讀操作,單機資料庫處理能力有限,容易成為系統瓶頸。

由於存在讀寫鎖衝突,並且很多大型網際網路業務往往讀多寫少,讀操作會首先成為資料庫瓶頸,我們希望消除讀寫鎖衝突從而提升資料庫整體的讀寫能力

那麼就需要採用讀寫分離的資料庫叢集方式,如一主多從,主庫會同步資料到從庫,寫操作都到主庫,讀操作都去從庫

讀寫分離之後就避免了讀寫鎖爭用,這裡解釋一下,什麼叫讀寫鎖爭用:

MySQL 中有兩種鎖:

  • 排它鎖(X 鎖): 事務 T 對資料 A 加上 X 鎖時,只允許事務 T 讀取和修改資料 A。
  • 共享鎖(S 鎖): 事務 T 對資料 A 加上 S 鎖時,其他事務只能再對資料 A 加 S 鎖,而不能加 X 鎖,直到 T 釋放 A 上的 S 鎖。

讀寫分離解決問題的同時也會帶來新問題,比如主庫和從庫資料不一致。

MySQL 的主從同步依賴於 binlog,binlog(二進位制日誌)是 MySQL Server 層維護的一種二進位制日誌,是獨立於具體的儲存引擎。它主要儲存對資料庫更新(insert、delete、update)的 SQL 語句,由於記錄了完整的 SQL 更新資訊,所以 binlog 是可以用來資料恢復和主從同步複製的。

從庫從主庫拉取 binlog 然後依次執行其中的 SQL 即可達到複製主庫的目的,由於從庫拉取 binlog 存在網路延遲等,所以主從資料同步存在延遲問題

那麼這裡就要看業務是否允許短時間內的資料不一致,如果不能容忍,那麼可以通過如果讀從庫沒獲取到資料就去主庫讀一次來解決

7.3 分庫分表

如果使用者越來越多,寫請求暴漲,對於上面的單 Master 節點肯定扛不住,那麼該怎麼辦呢?多加幾個 Master?不行,這樣會帶來更多的資料不一致的問題,且增加系統的複雜度。那該怎麼辦?就只能對庫表進行拆分了。

常見的拆分型別有垂直拆分和水平拆分。

以拼夕夕電商系統為例,一般有訂單表、使用者表、支付表、商品表、商家表等,最初這些表都在一個資料庫裡。後來隨著砍一刀帶來的海量使用者,拼夕夕後臺扛不住了!於是緊急從阿狸粑粑那裡挖來了幾個 P8、P9 大佬對系統進行重構。

  1. P9 大佬第一步先對資料庫進行垂直分庫,根據業務關聯性強弱,將它們分到不同的資料庫,比如訂單庫,商家庫、支付庫、使用者庫。
  2. 第二步是對一些大表進行垂直分表,將一個表按照欄位分成多表,每個表儲存其中一部分欄位。比如商品詳情表可能最初包含了幾十個欄位,但是往往最多訪問的是商品名稱、價格、產地、圖片、介紹等資訊,所以我們將不常訪問的欄位單獨拆成一個表。

由於垂直分庫已經按照業務關聯切分到了最小粒度,但資料量仍然非常大,於是 P9 大佬開始水平分庫,比如可以把訂單庫分為訂單 1 庫、訂單 2 庫、訂單 3 庫……那麼如何決定某個訂單放在哪個訂單庫呢?可以考慮對主鍵通過雜湊演算法計算放在哪個庫。

分完庫,單表資料量任然很大,查詢起來非常慢,P9 大佬決定按日或者按月將訂單分表,叫做日表、月表。

分庫分表同時會帶來一些問題,比如平時單庫單表使用的主鍵自增特性將作廢,因為某個分割槽庫表生成的主鍵無法保證全域性唯一,這就需要引入全域性 UUID 服務了。

經過一番大刀闊斧的重構,拼夕夕恢復了往日的活力,大家又可以愉快的在上面互相砍一刀了。

(分庫分表會引入很多問題,並沒有一一介紹,這裡只是為了講解什麼是分庫分表。)

 

8. 零拷貝

高效能的伺服器應當避免不必要資料複製,特別是在使用者空間和核心空間之間的資料複製。 比如 HTTP 靜態伺服器傳送靜態檔案的時候,一般我們會這樣寫:

如果瞭解 Linux I/O 的話就知道這個過程包含了核心空間和使用者空間之間的多次拷貝:

 

核心空間和使用者空間之間資料拷貝需要 CPU 親自完成,但是對於這類資料不需要在使用者空間進行處理的程式來說,這樣的兩次拷貝顯然是浪費。什麼叫“不需要在使用者空間進行處理”?

比如 FTP 或者 HTTP 靜態伺服器,它們的作用只是將檔案從磁碟傳送到網路,不需要在中途對資料進行編解碼之類的計算操作。

如果能夠直接將資料在核心快取之間移動,那麼除了減少拷貝次數以外,還能避免核心態和使用者態之間的上下文切換。

而這正是零拷貝(Zero copy)乾的事,主要就是利用各種零拷貝技術,減少不必要的資料拷貝,將 CPU 從資料拷貝這樣簡單的任務解脫出來,讓 CPU 專注於別的任務。

常用的零拷貝技術

1)mmap

mmap 通過記憶體對映,將檔案對映到核心緩衝區,同時,使用者空間可以共享核心空間的資料。這樣,在進行網路傳輸時,就可以減少核心空間到使用者空間的拷貝次數。

2)sendfile

sendfile 是 Linux 2.1 版本提供的,資料不經過使用者態,直接從頁快取拷貝到 socket 快取,同時由於和使用者態完全無關,就減少了一次上下文切換。

在 Linux 2.4 版本,對 sendfile 進行了優化,直接通過 DMA 將磁碟檔案資料讀取到 socket 快取,真正實現了“0”拷貝。前面 mmap 和 2.1 版本的 sendfile 實際上只是消除了使用者空間和核心空間之間拷貝,而頁快取和 socket 快取之間的拷貝依然存在。

 

9. 無鎖化

在多執行緒環境下,為了避免競態條件(race condition),我們通常會採用加鎖來進行併發控制。鎖的代價也是比較高的,鎖會導致上下文切換,甚至被掛起直到鎖被釋放。

基於硬體提供的原子操作“CAS(Compare And Swap)”實現了一些高效能無鎖的資料結構,比如無鎖佇列,可以在保證併發安全的情況下,提供更高的效能。

首先需要理解什麼是 CAS,CAS 有三個運算元,記憶體裡當前值 M、預期值 E、修改的新值 N,CAS 的語義就是:

如果當前值等於預期值,則將記憶體修改為新值,否則不做任何操作

用 C 語言來表達就是:

注意,上面的 CAS 函式實際上是一條原子指令,那麼該如何使用呢?

假設我需要實現這樣一個功能:對一個全域性變數 global 在兩個不同執行緒分別對它加 100 次,這裡多執行緒訪問一個全域性變數存在 race condition,所以我們需要採用執行緒同步操作,下面分別用鎖和 CAS 的方法來實現這個功能。

CAS 和鎖示範:

通過使用原子操作大大降低了鎖衝突的可能性,提高了程式的效能。

除了 CAS,還有一些硬體原子指令:

  • Fetch-And-Add:對變數原子性 + 1。
  • Test-And-Set:這是各種鎖演算法的核心,在 AT&T/GNU 彙編語法下,叫 xchg 指令。

 

10.序列化與反序列化

所有的程式設計一定是圍繞資料展開的,而資料呈現形式往往是結構化的,比如結構體(Struct)、類(Class)。 但是當我們通過網路、磁碟等傳輸、儲存資料的時候卻要求是二進位制流。 比如 TCP 連線,它提供給上層應用的是面向連線的可靠位元組流服務。那麼如何將這些結構體和類轉化為可儲存和可傳輸的位元組流呢?這就是序列化要乾的事情,反之,從位元組流如何恢復為結構化的資料就是反序列化。

序列化解決了物件持久化和跨網路資料交換的問題。

序列化一般按照序列化後的結果是否可讀,而分為以下兩類:

1)文字型別

如 JSON、XML,這些型別可讀性非常好,語義是自解釋的。也常常用在前後端資料互動上,如介面除錯時可讀性高,非常方便。但是缺點就是資訊密度低,序列化後佔用空間大

2)二進位制型別

如 Protocol Buffer、Thrift 等,這些型別採用二進位制編碼,資料組織得更加緊湊,資訊密度高,佔用空間小,但是帶來的問題就是基本不可讀。

像 Java、Python 便內建了序列化方法,比如在 Java 裡實現了 Serializable 介面即表示該物件可序列化。

 

相關文章