一文搞懂後臺高性能服務器設計的常見套路, BAT 高頻面試系列

微信搜索🔍「編程指北」,關注這個寫乾貨的程序員,回覆「資源」,即可獲取後臺開發學習路線和書籍
先贊後看,養成習慣~

前言

金九銀十,又是一年校招季。

經歷過,才深知不易。最近,和作爲校招面試官的同事聊了聊,問他們是如何去考察一個學生的,我簡單歸爲以下幾點:

  1. 聰明、反應快,這點自不必說,聰明意味着學習能力、適應力強,能夠快速勝任工作。
  2. 算法不錯,代碼基本功好,這點其實考察的是算法能力和代碼是否寫得優雅。
  3. 基礎過硬,技術崗面試最核心的還是考察「技術儲備」,包括了語言基本功,操作系統、網絡、體系結構、系統設計。
  4. 語言組織和表達能力,這點很重要,很多同學懂得某個知識點,卻很難用簡潔準確的語言表述出來。

想必有很多同學在刷題、刷面經,不過我想說“面經雖好,不要貪杯哦~”,面經可以刷,看看面試官都是怎麼提問的,但不要寄希望於原題。
因爲面試過程中的問題往往是一環扣一環的,這意味着你需要有足夠的技術深度,將知識由點連接成面,而不是停留在相互孤立的知識點上。

所以還是建議系統性的看書,如果覺得時間不夠,可以關注書裏的重點章節。至於看哪些書?後面也會列一個我的書單和閱讀建議。在【編程指北】後臺回覆【書單】即可獲取

那麼回到技術面試上,除了算法和網絡、操作系統這種基礎之外,還有一類系統設計和優化的問題。這類問題需要你有一個全局的技術視野,以及熟悉一些常用的系統優化方法論,也就是工程上的一些 Best Practice,而不至於自己臨時拍腦袋瞎設計。

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

  • 高併發
  • 高性能
  • 高可用

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

希望這些知識,能夠給你一絲啓發和幫助,助力你收割 各大公司 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 緩存分類

  • 1. 本地緩存:

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

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


  • 2. 分佈式緩存:

    當緩存的數據量增大以後,單機不足以承載緩存服務時,就要考慮對緩存服務做水平擴展,引入緩存集羣。

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

    一致性 Hash 算法也是值得用一篇文章來講的,如果暫時還不懂的話可以去搜一下。

    常用的組件有 MemcacheRedis Cluster 等,第二個是在高性能內存存儲 Redis 的基礎上,提供分佈式存儲的解決方案。

1.3 緩存使用指南

1. 適合緩存的場景:

  • 讀多寫少:

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

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

  • 計算耗時大,且實時性不高:
    比如王者榮耀裏的全區排行榜,一般一週更新一次,並且計算的數據量也比較大,所以計算後緩存起來,請求排行榜直接從緩存中取出,就不用實時計算了。

2. 不適合緩存的場景

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

1.4 緩存更新的策略

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

主要分爲兩類 Cache-AsideCache-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.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): 存放還未執行的任務。

線程池在 C、C++ 中沒有具體的實現,需要應用開發者手動實現上訴幾個部分。

在 Java 中 「ThreadPoolExecutor」 類就是線程池的實現。後續我也會寫文章分析 C++ 如何寫一個簡單的線程池以及 Java 中線程池是如何實現的。

3.3 連接池

顧名思義,連接池是創建和管理連接的。

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

  1. 和 MySQL server 建立 TCP 連接:
    • 三次握手
  2. MySQL 權限認證:
    • Server 向 Client 發送 密鑰
    • Client 使用密鑰加密用戶名、密碼等信息,將加密後的報文發送給 Server
    • Server 根據 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 大佬對系統進行重構。

  1. P9 大佬第一步先對數據庫進行垂直分庫,
    根據業務關聯性強弱,將它們分到不同的數據庫, 比如訂單庫,商家庫、支付庫、用戶庫。

  2. 第二步是對一些大表進行垂直分表,將一個表按照字段分成多表,每個表存儲其中一部分字段。 比如商品詳情表可能最初包含了幾十個字段,但是往往最多訪問的是商品名稱、價格、產地、圖片、介紹等信息,所以我們將不常訪問的字段單獨拆成一個表。

  • 由於垂直分庫已經按照業務關聯切分到了最小粒度,數據量任然非常大,P9 大佬開始水平分庫,比如可以把訂單庫分爲訂單1庫、訂單2庫、訂單3庫...... 那麼如何決定某個訂單放在哪個訂單庫呢?可以考慮對主鍵通過哈希算法計算放在哪個庫。
  • 分完庫,單表數據量任然很大,查詢起來非常慢,P9 大佬決定按日或者按月將訂單分表,叫做日表、月表。

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

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

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

八、具體技法

8.1 零拷貝

高性能的服務器應當避免不必要數據複製,特別是在用戶空間和內核空間之間的數據複製。 比如 HTTP 靜態服務器發送靜態文件的時候,一般我們會這樣寫:

如果瞭解 Linux IO 的話就知道這個過程包含了內核空間和用戶空間之間的多次拷貝:

內核空間和用戶空間之間數據拷貝需要 CPU 親自完成,但是對於這類數據不需要在用戶空間進行處理的程序來說,這樣的兩次拷貝顯然是浪費。什麼叫 「不需要在用戶空間進行處理」?

比如 FTP 或者 HTTP 靜態服務器,它們的作用只是將文件從磁盤發送到網絡,不需要在中途對數據進行編解碼之類的計算操作。

如果能夠直接將數據在內核緩存之間移動,那麼除了減少拷貝次數以外,還能避免內核態和用戶態之間的上下文切換。

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

常用的零拷貝技術:

  1. mmap

    <kbd>mmap</kbd> 通過內存映射,將文件映射到內核緩衝區,同時,用戶空間可以共享內核空間的數據。這樣,在進行網絡傳輸時,就可以減少內核空間到用戶空間的拷貝次數。

  1. sendfile

    <kbd>sendfile</kbd> 是 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 函數實際上是一條原子指令,那麼是如何用的呢?

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

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

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

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

  • Fetch-And-Add,對變量原子性 + 1
  • Test-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 接口,自己什麼都不用幹,就能自己完成對象的序列化,然後通過網絡傳輸後反序列化。當時哪懂得這就叫序列化,只覺得牛逼、神奇!

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

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

總結

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

不知道你發現沒有,從緩存到消息隊列、CAS......,很多看起來很牛逼的架構設計其實都來源於操作系統、體系結構。

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

最後說一句(求關注)

這篇文章寫了挺久的,從寫文章、畫圖,調格式每一步都很花時間。如果覺得對你有幫助的話,可以點個關注或者在看鼓勵下~
文章持續更新,微信搜索「 編程指北 」第一時間獲取,回覆【資料】有我準備的一線 BAT 大廠面試資料和簡歷模板。
期待你的關注~
有任何問題,歡迎留言~

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