程式”三高”解決方案
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)計算耗時大,且實時性不高
比如王者榮耀里的全區排行榜,一般一周更新一次,並且計算的數據量也比較大,所以計算後快取起來,請求排行榜直接從快取中取出,就不用實時計算了。
不適合快取的場景
- 寫多讀少,頻繁更新。
- 對數據一致性要求嚴格: 因為快取會有更新策略,所以很難做到和資料庫實時同步。
- 數據訪問完全隨機: 因為這樣會導致快取的命中率極低。
快取更新的策略
如何更新快取其實已經有總結得非常好的「最佳實踐」,我們按照套路來,大概率不會犯錯。策略主要分為兩類:
- Cache-Aside
- 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 又分為以下三種方式:
- Read Through:這種方式和 Cache-Aside 非常相似,都是在查詢時發生 cache miss 去更新快取,但是區別在於 Cache-Aside 需要調用方手動更新快取,而 Cache-As-SoR 則是由快取內部實現自己負責,對應用層透明。
- Write Through:直寫式,就是在將數據寫入快取的同時,快取也去更新後面的數據源,並且必須等到數據源被更新成功後才可返回。這樣保證了快取和資料庫里的數據一致性。
- 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 執行緒池
執行緒是幹嘛的?執行緒就是我們程式執行的實體。在伺服器開發領域,我們經常會為每個請求分配一個執行緒去處理,但是執行緒的創建銷毀、調度都會帶來額外的開銷,執行緒過多也會導致系統整體性能下降。在這種場景下,我們通常會提前創建若干個執行緒,通過執行緒池來進行管理。當請求到來時,只需從執行緒池選一個執行緒去執行處理任務即可。
執行緒池常常和隊列一起使用來實現任務調度,主執行緒收到請求後將創建對應的任務,然後放到隊列里,執行緒池中的工作執行緒等待隊列里的任務。
執行緒池實現上一般有四個核心組成部分:
- 管理器(Manager): 用於創建並管理執行緒池。
- 工作執行緒(Worker): 執行任務的執行緒。
- 任務介面(Task): 每個具體的任務必須實現任務介面,工作執行緒將調用該介面來完成具體的任務。
- 任務隊列(TaskQueue): 存放還未執行的任務。
3.3 連接池
顧名思義,連接池是創建和管理連接的。
大家最熟悉的莫過於資料庫連接池,這裡我們簡單分析下如果不用資料庫連接池,一次 SQL 查詢請求會經過哪些步驟:
- 和 MySQL server 建立 TCP 連接:三次握手
- MySQL 許可權認證:
- Server 向 Client 發送密鑰
- Client 使用密鑰加密用戶名、密碼等資訊,將加密後的報文發送給 Server
- Server 根據 Client 請求包,驗證是否是合法用戶,然後給 Client 發送認證結果
- Client 發送 SQL 語句
- Server 返回語句執行結果
- MySQL 關閉
- TCP 連接斷開:四次揮手
可以看出不使用連接池的話,為了執行一條 SQL,會花很多時間在安全認證、網路 I/O 上。
如果使用連接池,執行一條 SQL 就省去了建立連接和斷開連接所需的額外開銷。
還能想起哪裡用到了連接池的思想嗎?HTTP 長鏈接也算一個變相的鏈接池,雖然它本質上只有一個連接,但是思想卻和連接池不謀而合,都是為了復用同一個連接發送多個 HTTP 請求,避免建立和斷開連接的開銷。
池化實際上也是預處理和延後處理的一種應用場景,通過池子將各類資源的創建提前和銷毀延後。
4. 非同步(回調)
對於處理耗時的任務,如果採用同步的方式,會增加任務耗時,降低系統並發度。此時可以通過將同步任務變為非同步進行優化。
- 同步:比如我們去 KFC 點餐,遇到排隊的人很多,當點完餐後,大多情況下我們會隔幾分鐘就去問好了沒,反覆去問了好幾次才拿到,在這期間我們也沒法幹活了,這個就叫同步輪循, 這樣效率顯然太低了。
- 非同步:服務員被問煩了,就在點完餐後給我們一個號碼牌,每次準備好了就會在服務台叫號,這樣我們就可以在被叫到的時候再去取餐,中途可以繼續干自己的事。
在很多程式語言中有非同步編程的庫,比如 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 大佬對系統進行重構。
- P9 大佬第一步先對資料庫進行垂直分庫,根據業務關聯性強弱,將它們分到不同的資料庫,比如訂單庫,商家庫、支付庫、用戶庫。
- 第二步是對一些大表進行垂直分表,將一個表按照欄位分成多表,每個表存儲其中一部分欄位。比如商品詳情表可能最初包含了幾十個欄位,但是往往最多訪問的是商品名稱、價格、產地、圖片、介紹等資訊,所以我們將不常訪問的欄位單獨拆成一個表。
由於垂直分庫已經按照業務關聯切分到了最小粒度,但數據量仍然非常大,於是 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 介面即表示該對象可序列化。