後端思維之數據庫性能優化方案

作者:陳珙

原文:https://www.cnblogs.com/skychen1218/p/16059148.html

前言

  毫不誇張的說咱們後端工程師,無論在哪家公司,呆在哪個團隊,做哪個系統,遇到的第一個讓人頭疼的問題絕對是數據庫性能問題。如果我們有一套成熟的方法論,能讓大家快速、準確的去選擇出合適的優化方案,我相信能夠快速準備解決咱麼日常遇到的 80% 甚至 90% 的性能問題。

  從解決問題的角度出發,我們得先了解到問題的原因;其次我們得有一套思考、判斷問題的流程方式,讓我們合理的站在哪個層面選擇方案;最後從衆多的方案裏面選擇一個適合的方案進行解決問題,找到一個合適的方案的前提是我們自己對各種方案之間的優缺點、場景有足夠的瞭解,沒有一個方案是完全可以通喫通用的,軟件工程沒有銀彈。

  下文的我工作多年以來,曾經使用過的八大方案,結合了平常自己學習收集的一些資料,以系統、全面的方式整理成了這篇博文,也希望能讓一些有需要的同行在工作上、成長上提供一定的幫助。

爲什麼數據庫會慢?

gGnQFO

無論是關係型數據庫還是 NoSQL,任何存儲系統決定於其查詢性能的主要有三種:

而決定於查找時間複雜度主要有兩個因素:

無論是哪種存儲,數據量越少,自然查詢性能就越高,隨着數據量增多,資源的消耗(CPU、磁盤讀寫繁忙)、耗時也會越來越高。

從關係型數據庫角度出發,索引結構基本固定是 B+Tree,時間複雜度是 O(log n),存儲結構是行式存儲。因此咱們對於關係數據庫能優化的一般只有數據量。

而高負載造成原因有高併發請求、複雜查詢等,導致 CPU、磁盤繁忙等,而服務器資源不足則會導致慢查詢等問題。該類型問題一般會選擇集羣、數據冗餘的方式分擔壓力。

應該站在哪個層面思考優化?

從上圖可見,自頂向下的一共有四層,分別是硬件、存儲系統、存儲結構、具體實現。層與層之間是緊密聯繫的,每一層的上層是該層的載體;因此越往頂層越能決定性能的上限,同時優化的成本也相對會比較高,性價比也隨之越低。以最底層的具體實現爲例,那麼索引的優化的成本應該是最小的,可以說加了索引後無論是 CPU 消耗還是響應時間都是立竿見影降低;然而一個簡單的語句,無論如何優化加索引也是有侷限的,當在具體實現這層沒有任何優化空間的時候就得往上一層【存儲結構】思考,思考是否從物理表設計的層面出發優化(如分庫分表、壓縮數據量等),如果是文檔型數據庫得思考下文檔聚合的結果;如果在存儲結構這層優化得沒效果,得繼續往再上一次進行考慮,是否關係型數據庫應該不適合用在現在得業務場景?如果要換存儲,那麼得換怎樣得 NoSQL?

所以咱們優化的思路,出於性價比的優先考慮具體實現,實在沒有優化空間了再往上一層考慮。當然如果公司有錢,直接使用鈔能力,繞過了前面三層,這也是一種便捷的應急處理方式。

該篇文章不討論頂與底的兩個層面的優化,主要從存儲結構、存儲系統中間兩層的角度出發進行探討

八大方案總結

數據庫的優化方案核心本質有三種:減少數據量用空間換性能選擇合適的存儲系統,這也對應了開篇講解的慢的三個原因:數據總量、高負載、查找的時間複雜度

這裏大概解釋下收益類型:短期收益,處理成本低,能緊急應對,久了則會有技術債務;長期收益則跟短期收益相反,短期內處理成本高,但是效果能長久使用,擴展性會更好。

靜態數據意思是,相對改動頻率比較低的,也無需過多聯表的,where 過濾比較少。動態數據與之相反,更新頻率高,通過動態條件篩選過濾。

減少數據量

減少數據量類型共有四種方案:數據序列化存儲、數據歸檔、中間表生成、分庫分表

就如上面所說的,無論是哪種存儲,數據量越少,自然查詢性能就越高,隨着數據量增多,資源的消耗(CPU、磁盤讀寫繁忙)、耗時也會越來越高。目前市面上的 NoSQL 基本上都支持分片存儲,所以其天然分佈式寫的能力從數據量上能得到非常的解決方案。而關係型數據庫,查找算法與存儲結構是可以優化的空間比較少,因此咱們一般思考出發點只有從如何減少數據量的這個角度進行選擇優化,因此本類型的優化方案主要針對關係型數據庫進行處理。

數據歸檔

4wnaFK

注意點:別一次性遷移數量過多,建議低頻率多次限量遷移。像 MySQL 由於刪除數據後是不會釋放空間的,可以執行命令 OPTIMIZE TABLE 釋放存儲空間,但是會鎖表,如果存儲空間還滿足,可以不執行。建議優先考慮該方案,主要通過數據庫作業把非熱點數據遷移到歷史表,如果需要查歷史數據,可新增業務入口路由到對應的歷史表(庫)。

中間表(結果表)

D4XA2V

中間表(結果表)其實就是利用調度任務把複雜查詢的結果跑出來存儲到一張額外的物理表,因爲這張物理表存放的是通過跑批匯總後的數據,因此可以理解成根據原有的業務進行了高度的數據壓縮。以報表爲例,如果一個月的源數據有數十萬,我們通過調度任務以月的維度生成,那麼等於把原有的數據壓縮了幾十萬分之一;接下來的季報和年報可以根據月報 * N 來進行統計,以這種方式處理的數據,就算三年、五年甚至十年數據量都可以在接受範圍之內,而且可以精確計算得到。

那麼數據的壓縮比率是否越低越好?下面有一段口訣:

數據序列化存儲

ENa846

在數據庫以序列化存儲的方式,對於一些不需要結構化存儲的業務來說是一種很好減少數據量的方式,特別是對於一些 M*N 的數據量的業務場景,如果以 M 作爲主表優化,那麼就可以把數據量維持最多是 M 的量級。另外像訂單的地址信息,這種業務一般是不需要根據裏面的字段檢索出來,也比較適合。

這種方案我認爲屬於一種臨時性的優化方案,無論是從序列化後丟失了部份字段的查詢能力,還是這方案的可優化性都是有限的。

分庫分表

分庫分表作爲數據庫優化的一種非常經典的優化方案,特別是在以前 NoSQL 還不是很成熟的年代,這個方案就如救命草一般的存在。

如今也有不少同行也會選擇這種優化方式,但是從我角度來看,分庫分表是一種優化成本很大的方案。這裏我有幾個建議:

  1. 分庫分表是實在沒有辦法的辦法,應放到最後選擇。

  2. 優先選擇 NoSQL 代替,因爲 NoSQL 誕生基本上爲了擴展性與高性能。

  3. 究竟分庫還是分表?量大則分表,併發高則分庫

  4. 不考慮擴容,一部做到位。因爲技術更新太快了,每 3-5 年一大變。

拆分方式

WvpbhI

只要涉及到這個拆,那麼無論是微服務也好,分庫分表也好,拆分的方式主要分兩種:垂直拆分、水平拆分

垂直拆分更多是從業務角度進行拆分,主要是爲了**降低業務耦合度;**此外以 SQL Server 爲例,一頁是 8KB 存儲,如果在一張表裏字段越多,一行數據自然佔的空間就越大,那麼一頁數據所存儲的行數就自然越少,那麼每次查詢所需要 IO 則越高因此性能自然也越慢;因此反之,減少字段也能很好提高性能。之前我聽說某些同行的表有 80 個字段,幾百萬的數據就開始慢了。

水平拆分更多是從技術角度進行拆分,拆分後每張表的結構是一模一樣的,簡而言之就是把原有一張表的數據,通過技術手段進行分片到多張表存儲,從根本上解決了數據量的問題。

路由方式

ZkoGMv

進行水平拆分後,根據分區鍵(sharding key)原來應該在同一張表的數據拆解寫到不同的物理表裏,那麼查詢也得根據分區鍵進行定位到對應的物理表從而把數據給查詢出來。

路由方式一般有三種區間範圍、Hash、分片映射表,每種路由方式都有自己的優點和缺點,可以根據對應的業務場景進行選擇。

區間範圍根據某個元素的區間的進行拆分,以時間爲例子,假如有個業務我們希望以月爲單位拆分那麼表就會拆分像 table_2022-04,這種對於文檔型、ElasticSearch 這類型的 NoSQL 也適用,無論是定位查詢,還是日後清理維護都是非常的方便的。那麼缺點也明顯,會因爲業務獨特性導致數據不平均,甚至不同區間範圍之間的數據量差異很大。

Hash 也是一種常用的路由方式,根據 Hash 算法取模以數據量均勻分別存儲在物理表裏,缺點是對於帶分區鍵的查詢依賴特別強,如果不帶分區鍵就無法定位到具體的物理表導致相關所有表都查詢一次,而且在分庫的情況下對於 Join、聚合計算、分頁等一些 RDBMS 的特性功能還無法使用。

一般分區鍵就一個,假如有時候業務場景得用不是分區鍵的字段進行查詢,那麼難道就必須得全部掃描一遍?其實可以使用分片映射表的方式,簡單來說就是額外有一張表記錄額外字段與分區鍵的映射關係。舉個例子,有張訂單表,原本是以 UserID 作爲分區鍵拆分的,現在希望用 OrderID 進行查詢,那麼得有額外得一張物理表記錄了 OrderID 與 UserID 的映射關係。因此得先查詢一次映射表拿到分區鍵,再根據分區鍵的值路由到對應的物理表查詢出來。可能有些朋友會問,那這映射表是否多一個映射關係就多一張表,還是多個映射關係在同一張表。我優先建議單獨處理,如果說映射表字段過多,那跟不進行水平拆分時的狀態其實就是一致的,這又跑回去的老問題。

用空間換性能

該類型的兩個方案都是用來應對高負載的場景,方案有以下兩種:分佈式緩存、一主多從。

與其說這個方案叫用空間換性能,我認爲用空間換資源更加貼切一些。因此兩個方案的本質主要通數據冗餘、集羣等方式分擔負載壓力。

對於關係型數據庫而言,因爲他的 ACID 特性讓它天生不支持寫的分佈式存儲,但是它依然天然的支持分佈式讀

分佈式緩存

G5Tvn4

緩存層級可以分好幾種:客戶端緩存API 服務本地緩存分佈式緩存,咱們這次只聊分佈式緩存。一般我們選擇分佈式緩存系統都會優先選擇 NoSQL 的鍵值型數據庫,例如 Memcached、Redis,如今 Redis 的數據結構多樣性,高性能,易擴展性也逐漸佔據了分佈式緩存的主導地位。

緩存策略也主要有很多種:Cache-AsideRead/Wirte-ThroughWrite-Back,咱們用得比較多的方式主要 **Cache-Aside,**具體流程可看下圖:

我相信大家對分佈式緩存相對都比較熟悉了,但是我在這裏還是有幾個注意點希望提醒一下大家:

避免濫用緩存

緩存應該是按需使用,從 28 法則來看,80% 的性能問題由主要的 20% 的功能引起。濫用緩存的後果會導致維護成本增大,而且有一些數據一致性的問題也不好定位。特別像一些動態條件的查詢或者分頁,key 的組裝是多樣化的,量大又不好用 keys 指令去處理,當然我們可以用額外的一個 key 把記錄數據的 key 以集合方式存儲,刪除時候做兩次查詢,先查 Key 的集合,然後再遍歷 Key 集合把對應的內容刪除。這一頓操作下來無疑是非常廢功夫的,誰弄誰知道。

避免緩存擊穿

當緩存沒有數據,就得跑去數據庫查詢出來,這就是緩存穿透

假如某個時間臨界點數據是空的例如周排行榜,穿透過去的無論查找多少次數據庫仍然是空,而且該查詢消耗 CPU 相對比較高,併發一進來因爲缺少了緩存層的對高併發的應對,這個時候就會因爲併發導致數據庫資源消耗過高,這就是緩存擊穿。數據庫資源消耗過高就會導致其他查詢超時等問題。

該問題的解決方案也簡單,對於查詢到數據庫的空結果也緩存起來,但是給一個相對快過期的時間。有些同行可能又會問,這樣不就會造成了數據不一致了麼?

一般有數據同步的方案像分佈式緩存、後續會說的一主多從、CQRS,只要存在數據同步這幾個字,那就意味着會存在數據一致性的問題,因此如果使用上述方案,對應的業務場景應允許容忍一定的數據不一致。

不是所有慢查詢都適用

一般來說,慢的查詢都意味着比較喫資源的(CPU、磁盤 I/O)。

舉個例子,假如某個查詢功能需要 3 秒時間,串行查詢的時候並沒什麼問題,我們繼續假設這功能每秒大概 QPS 爲 100,那麼在第一次查詢結果返回之前,接下來的所有查詢都應該穿透到數據庫,也就意味着這幾秒時間有 300 個請求到數據庫,如果這個時候數據庫 CPU 達到了 100%,那麼接下來的所有查詢都會超時,也就是無法有第一個查詢結果緩存起來,從而還是形成了緩存擊穿。

一主多從

9gMfSZ

常用的分擔數據庫壓力還有一種常用做法,就是讀寫分離、一主多從。咱們都是知道關係型數據庫天生是不具備分佈式分片存儲的,也就是不支持分佈式寫,但是它天然的支持分佈式讀。一主多從是部署多臺從庫只讀實例,通過冗餘主庫的數據來分擔讀請求的壓力,路由算法可有代碼實現或者中間件解決,具體可以根據團隊的運維能力與代碼組件支持視情況選擇。

一主多從在還沒找到根治方案前是一個非常好的應急解決方案,特別是在現在雲服務的年代,擴展從庫是一件非常方便的事情,而且一般情況只需要運維或者 DBA 解決就行,無需開發人員接入。當然這方案也有缺點,因爲數據無法分片,所以主從的數據量完全冗餘過去,也會導致高的硬件成本。從庫也有其上限,從庫過多了會主庫的多線程同步數據的壓力。

選擇合適的存儲系統

NoSQL 主要以下五種類型:鍵值型、文檔型、列型、圖型、搜素引擎,不同的存儲系統直接決定了查找算法存儲數據結構,也應對了需要解決的不同的業務場景。

NoSQL 的出現也解決了關係型數據庫之前面臨的難題(性能、高併發、擴展性等)。

例如,ElasticSearch 的查找算法是倒排索引,可以用來代替關係型數據庫的低性能、高消耗的 Like 搜索(全表掃描)。

例如,Redis 的 Hash 結構決定了時間複雜度爲 O(1),還有它的內存存儲,結合分片集羣存儲方式以至於可以支撐數十萬 QPS。

因此本類型的方案主要有兩種:

這兩種方案的最終本質基本是一樣的:主要使用合適存儲來彌補關係型數據庫的缺點,只不過切換過渡的方式會有點不一樣。

CQRS

CQS(命令查詢分離)指同一個對象中作爲查詢或者命令的方法,每個方法或者返回的狀態,要麼改變狀態,但不能兩者兼備

05AybK

講解 CQRS 前得了解 CQS,有些小夥伴看了估計還沒不是很清晰,我這裏用通俗的話解釋:某個對象的數據訪問的方法裏,要麼只是查詢,要麼只是寫入(更新)。

CQRS 是一種與領域驅動設計 (DDD) 和事件溯源相關的架構模式。

Greg Young 在 2010 年創造了 CQRS 這個術語,CQRS 的內容基於 Bertrand Meyer 的 CQS 設計模式。

CQRS 的背後是什麼?

CQS (命令查詢分離)設計模式建議將對象的方法映射到兩類:方法要麼改變對象的內部狀態,但不返回任何內容,要麼只返回元數據。這種方法稱爲 Command。或者一個方法返回信息但不改變內部狀態。這種方法稱爲 Query

根據 CQS,一個方法永遠不應該同時存在命令和查詢。

比如典型數據結構棧的操作中,push 函數是一個命令,而 top 是一個查詢,遵守了 CQS。然後,pop 函數違反了 CQS 模式,因爲它修改了堆棧的內部狀態並同時返回信息。

因此,CQS 的核心是在單個對象上分離寫入和讀取。

這尤其重要,例如,當代碼要並行執行時:由於沒有副作用,查詢可以並行化而沒有任何問題,但命令不能。

在 CQS 模式的基礎上,Greg Young 在 2010 年創造了 CQRS(Command Query Responsibility Segregation)架構模式。

CQRS 它也將寫入和讀取分開,但在主要說的是 API 方面。

CQRS 提出了單獨的 API,一個專用於更改應用程序狀態的命令路由,另一個專用於返回有關應用程序狀態信息的查詢路由。

另一個方面,CQRS(命令查詢職責分離)基於 CQS 的基礎上,用物理數據庫來寫入(更新),而用另外的存儲系統來查詢數據。

因此我們在某些業務場景進行存儲架構設計時,可以 NOSQL+SQL 結合:

NOSQL+SQL 結合的好處:

從代碼實現角度來看,不同的存儲系統只是調用對應的接口 API,因此 CQRS 的難點主要在於如何進行數據同步。

數據同步方式

一般討論到數據同步的方式主要是分拉:

而推的方式又分兩種:CDC(變更數據捕獲)和領域事件。

對於一些舊的項目來說,某些業務的數據入口非常多,無法完整清晰的梳理清楚,這個時候 CDC 就是一種非常好的方式,只要從最底層數據庫層面把變更記錄取到就可。

對於已經服務化的項目來說領域事件是一種比較舒服的方式,因爲 CDC 是需要數據庫額外開啓功能或者部署額外的中間件,而領域事件則不需要,從代碼可讀性來看會更高,也比較開發人員的維護思維模式。

替換(選擇)存儲系統

替換(選擇)存儲系統,  從本質來看該模式與 CQRS 的核心本質是一樣的,主要是要對 NoSQL 的優缺點有一個全面認識,這樣才能在對應業務場景選擇與判斷出一個合適的存儲系統。

這裏我像大家介紹一本書馬丁. 福勒《NoSQL 精粹》,這本書我重複看了好幾遍,也很好全面介紹各種 NoSQL 優缺點和使用場景。

當然替換存儲的時候,我這裏也有個建議:加入一箇中間版本,該版本做好數據同步與業務開關,數據同步要保證全量與增量的處理,

數據同步,需要隨時可以重來,業務開關主要是爲了後續版本的更新做的一個臨時型的功能,主要避免後續版本更新不順利或者因爲版本更新時導致的數據不一致的情況出現。

在跑了一段時間後,驗證了兩個不同的存儲系統數據是一致的後,接下來就可以把數據訪問層的底層調用替換了。如此一來就可以平滑的更新切換。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/i1luKgA93IzM8pUSq0OI6w