首頁>技術>

在網際網路公司,經常面臨一個“三高”問題:

高併發高效能高可用

這篇文章將總結一下後臺伺服器開發中有哪些常用的解決“三高”問題的方法和思想。

希望這些知識,能夠給你一絲啟發和幫助,助力你收割 各大公司 Offer~

先上本文思維導圖:

如何解決三高

一、快取

什麼是快取?看看維基百科怎麼說:

“In computing, a cache is a hardware or software component that stores data so that future requests for that data can be served faster; the data stored in a cache might be the result of an earlier computation or a copy of data stored elsewhere.”

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

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

什麼是快取友好?簡單來說,就是程式碼在訪問資料的時候,儘量使用快取命中率高的方式。這個後面可以單獨寫一篇 CPU 快取系統以及如何編寫快取友好程式碼的文章。

1.1 快取為什麼有效?

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

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

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

1.2 快取分類

本地快取:使用程序內成員變數或者靜態變數,適合簡單的場景,不需要考慮快取一致性、過期時間、清空策略等問題。可以直接使用語言標準庫內的容器來做儲存。例如:

分散式快取:當快取的資料量增大以後,單機不足以承載快取服務時,就要考慮對快取服務做水平擴充套件,引入快取叢集。將資料分片後分散儲存在不同機器中,如何決定每個資料分片存放在哪臺機器呢?一般是採用一致性 Hash 演算法,它能夠保證在快取叢集動態調整,不斷增加或者減少機器後,客戶端訪問時依然能夠根據 key 訪問到資料。一致性 Hash 演算法也是值得用一篇文章來講的,如果暫時還不懂的話可以去搜一下。常用的元件有 Memcache、 Redis Cluster 等,第二個是在高效能記憶體儲存 Redis 的基礎上,提供分散式儲存的解決方案。

1.3 快取使用指南

1.適合快取的場景:

讀多寫少:比如電商裡的商品詳情頁面,訪問頻率很高,但是一般寫入只在店家上架商品和修改資訊的時候發生。如果把熱點商品的資訊快取起來,這將攔截掉很多對資料庫的訪問,提高系統整體的吞吐量。因為一般資料庫的 QPS 由於有「ACID」約束、並且資料是持久化在硬碟的,所以比 Redis 這類基於記憶體的 NoSQL 儲存低不少。常常是一個系統的瓶頸,如果我們把大部分的查詢都在 Redis 快取中命中了,那麼系統整體的 QPS 也就上去了。計算耗時大,且實時性不高:比如王者榮耀裡的全區排行榜,一般一週更新一次,並且計算的資料量也比較大,所以計算後快取起來,請求排行榜直接從快取中取出,就不用實時計算了。

2.不適合快取的場景:

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

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

主要分為兩類 Cache-Aside 和 Cache-As-SoR。 SoR 即「System Of Record,記錄系統」,表示資料來源,一般就是指資料庫。

1、Cache-Aside:

Cache-Aside架構圖

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

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

直接看虛擬碼:

Cache-Aside 程式碼示範

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

2、Cache-As-SoR:

Cache-As-SoR架構圖

從字面上來看,就是把 Cache 當作 SoR,也就是資料來源,所以一切讀寫操作都是針對 Cache 的,由 Cache 內部自己維護和資料來源的一致性。

這樣對於使用者來說就和直接操作 SoR 沒有區別了,完全感知不到 Cache 的存在。

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

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

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

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

【文章福利】需要C/C++ Linux伺服器架構師學習資料加群812855908(資料包括C/C++,Linux,golang技術,Nginx,ZeroMQ,MySQL,Redis,fastdfs,MongoDB,ZK,流媒體,CDN,P2P,K8S,Docker,TCP/IP,協程,DPDK,ffmpeg等)

二、預處理和延後處理

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

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 減少了很多不必要的資料複製。

三、池化

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

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

3.1 記憶體池

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

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

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

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

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

空閒連結串列

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

比如 Google 的 「tcmalloc」 和 Facebook 的 「jemalloc」。

限於篇幅我們不在這裡詳細講解它們的實現原理,如果感興趣可以搜來看看,也推薦去看看被譽為神書的 CSAPP(《深入理解計算機系統》)第 10 章,那裡也講到了動態記憶體分配演算法。

3.2 執行緒池

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

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

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

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

執行緒池模型

3.3 連線池

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

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

1、和 MySQL server 建立 TCP 連線:

三次握手

2、MySQL 許可權認證:

Server 向 Client 傳送 金鑰Client 使用金鑰加密使用者名稱、密碼等資訊,將加密後的報文傳送給 ServerServer 根據 Client 請求包,驗證是否是合法使用者,然後給 Client 傳送認證結果

3、Client 傳送 SQL 語句4、Server 返回語句執行結果5、MySQL 關閉6、TCP 連線斷開

四次揮手

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

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

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

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

四、同步變非同步

對於處理耗時的任務,如果採用同步的方式,那麼會增加任務耗時,降低系統併發度。

可以透過將同步任務變為非同步進行最佳化。

舉個例子,比如我們去 KFC 點餐,遇到排隊的人很多,當點完餐後,大多情況下我們會隔幾分鐘就去問好了沒,反覆去問了好幾次才拿到,在這期間我們也沒法幹活了,這時候我們是這樣的:

同步寫法

這個就叫同步輪訓, 這樣效率顯然太低了。

服務員被問煩了,就在點完餐後給我們一個號碼牌,每次準備好了就會在服務檯叫號,這樣我們就可以在被叫到的時候再去取餐,中途可以繼續幹自己的事。

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

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

五、訊息佇列

訊息佇列示意圖

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

5.1 服務解耦

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

直接呼叫

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

釋出訂閱模式

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

5.2 非同步處理

有些業務涉及到的處理流程非常多,但是很多步驟並不要求實時性。那麼我們就可以透過訊息佇列非同步處理。比如淘寶下單,一般包括了風控、鎖庫存、生成訂單、簡訊/郵件通知等步驟。但是核心的就風控和鎖庫存, 只要風控和扣減庫存成功,那麼就可以返回結果通知使用者成功下單了。後續的生成訂單,簡訊通知都可以透過訊息佇列傳送給下游服務非同步處理。大大提高了系統響應速度。

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

5.3 流量削峰

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

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

起到了平均流量的作用。

5.4 總結

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

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

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

降低了資料一致性,從強一致性變為最終一致性有訊息丟失的風險,比如宕機,需要有容災機制六、批次處理

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

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

比如我們後臺 RPC 框架,經常有更新資料的需求,而有的資料更新的介面往往只接受一項,這個時候我們往往會最佳化下更新介面,

使其能夠接受批次更新的請求,這樣可以將批次的資料一次性發送,大大縮短網路 RPC 呼叫耗時。

七、資料庫

我們常把後臺開發調侃為「CRUD」,資料庫在整個應用開發過程中的重要性不言而喻。

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

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

7.1 索引

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

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

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

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

“根據 Google 工程師 Jeff Dean 的統計,訪問記憶體資料耗時大概在 100 ns,訪問磁碟則是 10,000,000 ns。”

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

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

其實關於索引想寫的也還有很多,但還是受限於篇幅,以後再單獨寫。

先把我認為索引必知必會的知識列出來,大家可以查漏補缺:

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

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 大佬對系統進行重構。

P9 大佬第一步先對資料庫進行垂直分庫,根據業務關聯性強弱,將它們分到不同的資料庫, 比如訂單庫,商家庫、支付庫、使用者庫。第二步是對一些大表進行垂直分表,將一個表按照欄位分成多表,每個表儲存其中一部分欄位。 比如商品詳情表可能最初包含了幾十個欄位,但是往往最多訪問的是商品名稱、價格、產地、圖片、介紹等資訊,所以我們將不常訪問的欄位單獨拆成一個表。由於垂直分庫已經按照業務關聯切分到了最小粒度,資料量任然非常大,P9 大佬開始水平分庫,比如可以把訂單庫分為訂單1庫、訂單2庫、訂單3庫… 那麼如何決定某個訂單放在哪個訂單庫呢?可以考慮對主鍵透過雜湊演算法計算放在哪個庫。分完庫,單表資料量任然很大,查詢起來非常慢,P9 大佬決定按日或者按月將訂單分表,叫做日表、月表。分庫分表同時會帶來一些問題,比如平時單庫單表使用的主鍵自增特性將作廢,因為某個分割槽庫表生成的主鍵無法保證全域性唯一,這就需要引入全域性 UUID 服務了。

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

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

八、具體技法

8.1 零複製

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

傳送檔案

如果瞭解 Linux IO 的話就知道這個過程包含了核心空間和使用者空間之間的多次複製:

IO示意圖

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

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

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

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

常用的零複製技術:

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

mmap

2、sendfilesendfile是 Linux2.1 版本提供的,資料不經過使用者態,直接從頁快取複製到 socket 快取,同時由於和使用者態完全無關,就減少了一次上下文切換。在 Linux 2.4 版本,對 sendfile 進行了最佳化,直接透過 DMA 將磁碟檔案資料讀取到 socket 快取,真正實現了 ”0” 複製。前面 mmap 和 2.1 版本的 sendfile 實際上只是消除了使用者空間和核心空間之間複製,而頁快取和 socket 快取之間的複製依然存在。

8.2 無鎖化

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

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

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

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

用 C 語言來表達就是:

CAS

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

假設我需要實現這樣一個功能:

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

CAS和鎖示範

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

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

Fetch-And-Add,對變數原子性 + 1Test-And-Set,這是各種鎖演算法的核心,在 AT&T/GNU 彙編語法下,叫 xchg 指令,我會單獨寫一篇如何使用 xchg實現各種鎖。

8.3 序列化與反序列化

先看看維基百科怎麼定義的序列化:

“In computing, serialization (US spelling) or serialisation (UK spelling) is the process of translating a data structure or object state into a format that can be stored (for example, in a file or memory data buffer) or transmitted (for example, across a computer network) and reconstructed later (possibly in a different computer environment). When the resulting series of bits is reread according to the serialization format, it can be used to create a semantically identical clone of the original object. For many complex objects, such as those that make extensive use of references, this process is not straightforward. Serialization of object-oriented objects does not include any of their associated methods with which they were previously linked.”

我相信你大機率沒有看完上面的英文描述,其實我也不愛看英文資料,總覺得很慢,但是計算機領域一手的學習資料都是美帝那邊的,所以沒辦法,必須逼自己去試著讀一些英文的資料。

實際上也沒有那麼難,熟悉常用的幾百個專業名詞,句子都是非常簡單的一些從句。沒看的話,再倒回去看看?

這裡我就不做翻譯了,主要是水平太低,估計做到「信達雅」的信都很難。

扯遠了,還是回到序列化來。

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

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

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

文字型別:

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

二進位制型別

如 Protocol Buffer、Thrift等,這些型別採用二進位制編碼,資料組織得更加緊湊,資訊密度高,佔用空間小,但是帶來的問題就是基本不可讀。還有 Java 、Go 這類語言內建了序列化方式,比如在 Java 裡實現了 Serializable 介面即表示該物件可序列化。

說到這讓我想起了大一寫的的兩個程式,一個是用剛 C 語言寫的公交管理系統,當時需要將公交線路、站點資訊持久化儲存,當時的方案就是每個公交線路寫在一行,用 "|"分割資訊,比如:

5|6:00-22:00|大學城|南山站|北京站123|6:30-23:00|南湖大道|茶山劉|世界

第一列就是線路編號、第二項是發車時間、後面就是途徑的站點。是不是非常原始?實際上這也是一種序列化方式,只是效率很低,也不通用。而且存在一個問題就是如果資訊中包含 “|”怎麼辦?當然是用轉義。

第二個程式是用 Java 寫的網路五子棋,當時需要透過網路傳輸表示棋子位置的物件,查了一圈最後發現只需要實現 Serializable 介面,自己什麼都不用幹,就能自己完成物件的序列化,然後透過網路傳輸後反序列化。當時哪懂得這就叫序列化,只覺得牛逼、神奇!

最後完成了一個可以網路五子棋,拉著隔壁室友一起玩。。。真的是成就感滿滿哈哈哈。

說來在程式設計方面,已經很久沒有這樣的成就感了。

總結

這篇文章主要是粗淺的介紹了一些系統設計、系統最佳化的套路和最佳實踐。

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

24
  • BSA-TRITC(10mg/ml) TRITC-BSA 牛血清白蛋白改性標記羅丹明
  • 18張圖讓你搞懂高併發中的執行緒與執行緒池,看完還不會你來打我