前言
毫不誇張的說我們們後端工程師,無論在哪家公司,呆在哪個團隊,做哪個系統,遇到的第一個讓人頭疼的問題絕對是資料庫效能問題。如果我們有一套成熟的方法論,能讓大家快速、準確的去選擇出合適的優化方案,我相信能夠快速準備解決我們麼日常遇到的80%甚至90%的效能問題。
從解決問題的角度出發,我們得先了解到問題的原因;其次我們得有一套思考、判斷問題的流程方式,讓我們合理的站在哪個層面選擇方案;最後從眾多的方案裡面選擇一個適合的方案進行解決問題,找到一個合適的方案的前提是我們自己對各種方案之間的優缺點、場景有足夠的瞭解,沒有一個方案是完全可以通吃通用的,軟體工程沒有銀彈。
下文的我工作多年以來,曾經使用過的八大方案,結合了平常自己學習收集的一些資料,以系統、全面的方式整理成了這篇博文,也希望能讓一些有需要的同行在工作上、成長上提供一定的幫助。
為什麼資料庫會慢?
慢的本質 |
|
查詢的時間複雜度 |
查詢演算法 |
儲存資料結構 |
|
資料總量 |
資料拆分 |
高負載 |
CPU、磁碟繁忙 |
無論是關係型資料庫還是NoSQL,任何儲存系統決定於其查詢效能的主要有三種:
- 查詢的時間複雜度
- 資料總量
- 高負載
而決定於查詢時間複雜度主要有兩個因素:
- 查詢演算法
- 儲存資料結構
無論是哪種儲存,資料量越少,自然查詢效能就越高,隨著資料量增多,資源的消耗(CPU、磁碟讀寫繁忙)、耗時也會越來越高。
從關係型資料庫角度出發,索引結構基本固定是B+Tree,時間複雜度是O(log n),儲存結構是行式儲存。因此我們們對於關聯式資料庫能優化的一般只有資料量。
而高負載造成原因有高併發請求、複雜查詢等,導致CPU、磁碟繁忙等,而伺服器資源不足則會導致慢查詢等問題。該型別問題一般會選擇叢集、資料冗餘的方式分擔壓力。
應該站在哪個層面思考優化?
從上圖可見,自頂向下的一共有四層,分別是硬體、儲存系統、儲存結構、具體實現。層與層之間是緊密聯絡的,每一層的上層是該層的載體;因此越往頂層越能決定效能的上限,同時優化的成本也相對會比較高,價效比也隨之越低。以最底層的具體實現為例,那麼索引的優化的成本應該是最小的,可以說加了索引後無論是CPU消耗還是響應時間都是立竿見影降低;然而一個簡單的語句,無論如何優化加索引也是有侷限的,當在具體實現這層沒有任何優化空間的時候就得往上一層【儲存結構】思考,思考是否從物理表設計的層面出發優化(如分庫分表、壓縮資料量等),如果是文件型資料庫得思考下文件聚合的結果;如果在儲存結構這層優化得沒效果,得繼續往再上一次進行考慮,是否關係型資料庫應該不適合用在現在得業務場景?如果要換儲存,那麼得換怎樣得NoSQL?
所以我們們優化的思路,出於價效比的優先考慮具體實現,實在沒有優化空間了再往上一層考慮。當然如果公司有錢,直接使用鈔能力,繞過了前面三層,這也是一種便捷的應急處理方式。
該篇文章不討論頂與底的兩個層面的優化,主要從儲存結構、儲存系統中間兩層的角度出發進行探討。
八大方案總結
方案總覽 |
||||
方案型別 |
方案描述 |
資料型別 |
收益型別 |
應對場景 |
減少資料量 |
資料序列化儲存 |
靜態資料 |
短期收益 |
大資料量 |
資料歸檔 |
動態資料 |
中期收益 |
大資料量 |
|
中間表生成 |
靜態資料 |
長期收益 |
大資料量、高負載 |
|
分庫分表 |
動態資料 |
長期收益 |
大資料量、高負載 |
|
用空間換效能 |
分散式快取 |
靜態資料 |
短期收益 |
高負載 |
一主多從 |
動態資料 |
中期收益 |
高負載 |
|
選擇合適的儲存系統 |
CQRS |
動態資料 |
長期收益 |
大資料量、高負載 |
更換儲存系統 |
動態資料 |
長期收益 |
大資料量、高負載 |
資料庫的優化方案核心本質有三種:減少資料量、用空間換效能、選擇合適的儲存系統,這也對應了開篇講解的慢的三個原因:資料總量、高負載、查詢的時間複雜度。
這裡大概解釋下收益型別:短期收益,處理成本低,能緊急應對,久了則會有技術債務;長期收益則跟短期收益相反,短期內處理成本高,但是效果能長久使用,擴充套件性會更好。
靜態資料意思是,相對改動頻率比較低的,也無需過多聯表的,where過濾比較少。動態資料與之相反,更新頻率高,通過動態條件篩選過濾。
減少資料量
減少資料量型別共有四種方案:資料序列化儲存、資料歸檔、中間表生成、分庫分表。
就如上面所說的,無論是哪種儲存,資料量越少,自然查詢效能就越高,隨著資料量增多,資源的消耗(CPU、磁碟讀寫繁忙)、耗時也會越來越高。目前市面上的NoSQL基本上都支援分片儲存,所以其天然分散式寫的能力從資料量上能得到非常的解決方案。而關係型資料庫,查詢演算法與儲存結構是可以優化的空間比較少,因此我們們一般思考出發點只有從如何減少資料量的這個角度進行選擇優化,因此本型別的優化方案主要針對關係型資料庫進行處理。
資料歸檔
資料歸檔 |
|||
做法 |
場景 |
優點 |
缺點 |
利用資料庫作業,定時把歷史資料移到歷史表或者庫 |
區域性的熱點資料 |
結構無需改動,少侵入性 |
熱點資料過多仍會導致效能問題 |
注意點:別一次性遷移數量過多,建議低頻率多次限量遷移。像MySQL由於刪除資料後是不會釋放空間的,可以執行命令OPTIMIZE TABLE釋放儲存空間,但是會鎖表,如果儲存空間還滿足,可以不執行。
建議優先考慮該方案,主要通過資料庫作業把非熱點資料遷移到歷史表,如果需要查歷史資料,可新增業務入口路由到對應的歷史表(庫)。
在資料庫以序列化儲存的方式,對於一些不需要結構化儲存的業務來說是一種很好減少資料量的方式,特別是對於一些M*N的資料量的業務場景,如果以M作為主表優化,那麼就可以把資料量維持最多是M的量級。另外像訂單的地址資訊,這種業務一般是不需要根據裡面的欄位檢索出來,也比較適合。
這種方案我認為屬於一種臨時性的優化方案,無論是從序列化後丟失了部份欄位的查詢能力,還是這方案的可優化性都是有限的。
中間表(結果表)
中間表(結果表) |
|||
做法 |
場景 |
優點 |
缺點 |
通過排程任務定時,把某個業務以多個維度進行聚合分組 |
報表型、排行榜等靜態資料 |
壓縮比率大 |
需要開發人員針對場景業務進行開發 |
- 欄位越多,粒度越細,靈活性越高,可以以中間表進行不同業務聯表處理。
- 欄位越少,粒度越粗,靈活性越低,一般作為結果表查詢出來。
資料序列化儲存
資料序列化儲存 |
|||
做法 |
場景 |
優點 |
缺點 |
把一對多的資料,通過序列化字串儲存 |
不需要要求所有欄位作為結構化儲存 |
壓縮比率高 |
序列化的欄位無法聯表 |
分庫分表
分庫分表作為資料庫優化的一種非常經典的優化方案,特別是在以前NoSQL還不是很成熟的年代,這個方案就如救命草一般的存在。
如今也有不少同行也會選擇這種優化方式,但是從我角度來看,分庫分表是一種優化成本很大的方案。這裡我有幾個建議:
- 分庫分表是實在沒有辦法的辦法,應放到最後選擇。
- 優先選擇NoSQL代替,因為NoSQL誕生基本上為了擴充套件性與高效能。
- 究竟分庫還是分表?量大則分表,併發高則分庫
- 不考慮擴容,一部做到位。因為技術更新太快了,每3-5年一大變。
拆分方式
分庫分表-拆分方式 |
||
拆分方式 |
角度 |
優點 |
垂直拆分 |
按照業務拆分 |
降低業務耦合度 |
減少欄位,物理頁所擁有的行數則變多 |
||
水平拆分 |
從物理層面分片 |
從根本上減少資料量 |
只要涉及到這個拆,那麼無論是微服務也好,分庫分表也好,拆分的方式主要分兩種:垂直拆分、水平拆分。
垂直拆分更多是從業務角度進行拆分,主要是為了降低業務耦合度;此外以SQL Server為例,一頁是8KB儲存,如果在一張表裡欄位越多,一行資料自然佔的空間就越大,那麼一頁資料所儲存的行數就自然越少,那麼每次查詢所需要IO則越高因此效能自然也越慢;因此反之,減少欄位也能很好提高效能。之前我聽說某些同行的表有80個欄位,幾百萬的資料就開始慢了。
水平拆分更多是從技術角度進行拆分,拆分後每張表的結構是一模一樣的,簡而言之就是把原有一張表的資料,通過技術手段進行分片到多張表儲存,從根本上解決了資料量的問題。
路由方式
路由方式 |
||
演算法 |
優點 |
缺點 |
區間範圍 |
查詢定位比較容易 |
容易造成資料不平均(熱點資料) |
容易忘記建立新表 |
||
Hash |
分片均勻 |
必須帶分割槽鍵,不帶分割槽鍵則會所有表都掃描一遍 |
無法使用關係型資料庫的特性(Join和聚合計算) |
||
分片對映表 |
補充方案 |
二次查詢 |
進行水平拆分後,根據分割槽鍵(sharding key)原來應該在同一張表的資料拆解寫到不同的物理表裡,那麼查詢也得根據分割槽鍵進行定位到對應的物理表從而把資料給查詢出來。
路由方式一般有三種區間範圍、Hash、分片對映表,每種路由方式都有自己的優點和缺點,可以根據對應的業務場景進行選擇。
區間範圍根據某個元素的區間的進行拆分,以時間為例子,假如有個業務我們希望以月為單位拆分那麼表就會拆分像 table_2022-04,這種對於文件型、ElasticSearch這型別的NoSQL也適用,無論是定位查詢,還是日後清理維護都是非常的方便的。那麼缺點也明顯,會因為業務獨特性導致資料不平均,甚至不同區間範圍之間的資料量差異很大。
Hash也是一種常用的路由方式,根據Hash演算法取模以資料量均勻分別儲存在物理表裡,缺點是對於帶分割槽鍵的查詢依賴特別強,如果不帶分割槽鍵就無法定位到具體的物理表導致相關所有表都查詢一次,而且對於Join和聚合計算等一些RDBMS的特性功能還無法使用。
一般分割槽鍵就一個,假如有時候業務場景得用不是分割槽鍵的欄位進行查詢,那麼難道就必須得全部掃描一遍?其實可以使用分片對映表的方式,簡單來說就是額外有一張表記錄額外欄位與分割槽鍵的對映關係。舉個例子,有張訂單表,原本是以UserID作為分割槽鍵拆分的,現在希望用OrderID進行查詢,那麼得有額外得一張物理表記錄了OrderID與UserID的對映關係。因此得先查詢一次對映表拿到分割槽鍵,再根據分割槽鍵的值路由到對應的物理表查詢出來。可能有些朋友會問,那這對映表是否多一個對映關係就多一張表,還是多個對映關係在同一張表。我優先建議單獨處理,如果說對映表欄位過多,那跟不進行水平拆分時的狀態其實就是一致的,這又跑回去的老問題。
用空間換效能
該型別的兩個方案都是用來應對高負載的場景,方案有以下兩種:分散式快取、一主多從。
與其說這個方案叫用空間換效能,我認為用空間換資源更加貼切一些。因此兩個方案的本質主要通資料冗餘、叢集等方式分擔負載壓力。
對於關係型資料庫而言,因為他的ACID特性讓它天生不支援寫的分散式儲存,但是它依然天然的支援分散式讀。
分散式快取
分散式快取 |
||
做法 |
場景 |
缺點 |
Cache Aside |
應對高併發讀 |
動態條件比較多的業務場景,快取命中低 |
偽靜態資料(業務配置、低時效的資料) |
實時性要求高的資料場景,處理起來比較花功夫 |
快取層級可以分好幾種:客戶端快取、API服務本地快取和分散式快取,我們們這次只聊分散式快取。一般我們選擇分散式快取系統都會優先選擇NoSQL的鍵值型資料庫,例如Memcached、Redis,如今Redis的資料結構多樣性,高效能,易擴充套件性也逐漸佔據了分散式快取的主導地位。
快取策略也主要有很多種:Cache-Aside、Read/Wirte-Through、Write-Back,我們們用得比較多的方式主要Cache-Aside,具體流程可看下圖:
我相信大家對分散式快取相對都比較熟悉了,但是我在這裡還是有幾個注意點希望提醒一下大家:
避免濫用快取
快取應該是按需使用,從28法則來看,80%的效能問題由主要的20%的功能引起。濫用快取的後果會導致維護成本增大,而且有一些資料一致性的問題也不好定位。特別像一些動態條件的查詢或者分頁,key的組裝是多樣化的,量大又不好用keys指令去處理,當然我們可以用額外的一個key把記錄資料的key以集合方式儲存,刪除時候做兩次查詢,先查Key的集合,然後再遍歷Key集合把對應的內容刪除。這一頓操作下來無疑是非常廢功夫的,誰弄誰知道。
避免快取擊穿
當快取沒有資料,就得跑去資料庫查詢出來,這就是快取穿透。假如某個時間臨界點資料是空的例如周排行榜,穿透過去的無論查詢多少次資料庫仍然是空,而且該查詢消耗CPU相對比較高,併發一進來因為缺少了快取層的對高併發的應對,這個時候就會因為併發導致資料庫資源消耗過高,這就是快取擊穿。資料庫資源消耗過高就會導致其他查詢超時等問題。
該問題的解決方案也簡單,對於查詢到資料庫的空結果也快取起來,但是給一個相對快過期的時間。有些同行可能又會問,這樣不就會造成了資料不一致了麼?一般有資料同步的方案像分散式快取、後續會說的一主多從、CQRS,只要存在資料同步這幾個字,那就意味著會存在資料一致性的問題,因此如果使用上述方案,對應的業務場景應允許容忍一定的資料不一致。
不是所有慢查詢都適用
一般來說,慢的查詢都意味著比較吃資源的(CPU、磁碟I/O)。舉個例子,假如某個查詢功能需要3秒時間,序列查詢的時候並沒什麼問題,我們繼續假設這功能每秒大概QPS為100,那麼在第一次查詢結果返回之前,接下來的所有查詢都應該穿透到資料庫,也就意味著這幾秒時間有300個請求到資料庫,如果這個時候資料庫CPU達到了100%,那麼接下來的所有查詢都會超時,也就是無法有第一個查詢結果快取起來,從而還是形成了快取擊穿。
一主多從
一主多從 |
||
場景 |
優點 |
缺點 |
分擔資料庫讀壓力 |
應急調整方便,單以運維直接解決。 |
高硬體成本 |
還沒找到更好的降低資料庫負載的臨時方案 |
擴充套件性有限 |
常用的分擔資料庫壓力還有一種常用做法,就是讀寫分離、一主多從。我們們都是知道關係型資料庫天生是不具備分散式分片儲存的,也就是不支援分散式寫,但是它天然的支援分散式讀。一主多從是部署多臺從庫只讀例項,通過冗餘主庫的資料來分擔讀請求的壓力,路由演算法可有程式碼實現或者中介軟體解決,具體可以根據團隊的運維能力與程式碼元件支援視情況選擇。
一主多從在還沒找到根治方案前是一個非常好的應急解決方案,特別是在現在雲服務的年代,擴充套件從庫是一件非常方便的事情,而且一般情況只需要運維或者DBA解決就行,無需開發人員接入。當然這方案也有缺點,因為資料無法分片,所以主從的資料量完全冗餘過去,也會導致高的硬體成本。從庫也有其上限,從庫過多了會主庫的多執行緒同步資料的壓力。
選擇合適的儲存系統
NoSQL主要以下五種型別:鍵值型、文件型、列型、圖型、搜素引擎,不同的儲存系統直接決定了查詢演算法、儲存資料結構,也應對了需要解決的不同的業務場景。NoSQL的出現也解決了關係型資料庫之前面臨的難題(效能、高併發、擴充套件性等)。
例如,ElasticSearch的查詢演算法是倒排索引,可以用來代替關係型資料庫的低效能、高消耗的Like搜尋(全表掃描)。而Redis的Hash結構決定了時間複雜度為O(1),還有它的記憶體儲存,結合分片叢集儲存方式以至於可以支撐數十萬QPS。
因此本型別的方案主要有兩種:CQRS、替換(選擇)儲存,這兩種方案的最終本質基本是一樣的主要使用合適儲存來彌補關係型資料庫的缺點,只不過切換過渡的方式會有點不一樣。
CQRS
CQS(命令查詢分離)指同一個物件中作為查詢或者命令的方法,每個方法或者返回的狀態,要麼改變狀態,但不能兩者兼備
CQRS |
||
場景 |
優點 |
缺點 |
需要保留關係型資料庫的使用,又要使用NoSQL的高效能與可擴充套件性 |
原應用改動範圍比較小,相容舊業務,只需要替換讀的底層。 |
高硬體成本 |
允許非實時的資料場景 |
即保留了關係型資料庫的ACID特性,又使用NoSQL的可擴充套件性與高效能 |
資料同步 |
講解CQRS前得了解CQS,有些小夥伴看了估計還沒不是很清晰,我這裡用通俗的話解釋:某個物件的資料訪問的方法裡,要麼只是查詢,要麼只是寫入(更新)。而CQRS(命令查詢職責分離)基於CQS的基礎上,用物理資料庫來寫入(更新),而用另外的儲存系統來查詢資料。因此我們在某些業務場景進行儲存架構設計時,可以通過關係型資料庫的ACID特性進行資料的更新與寫入,用NoSQL的高效能與擴充套件性進行資料的查詢處理,這樣的好處就是關係型資料庫和NoSQL的優點都可以兼得,同時對於某些業務不適於一刀切的替換儲存的也可以有一個平滑的過渡。
從程式碼實現角度來看,不同的儲存系統只是呼叫對應的介面API,因此CQRS的難點主要在於如何進行資料同步。
資料同步方式
CQRS實現方式 |
||||
方式 |
實時性 |
方案型別 |
優點 |
缺點 |
推 |
高 |
CDC(變更資料捕獲) |
無業務侵入,解決多業務入口 |
額外中介軟體 |
領域事件 |
可讀性高 |
需要在框架程式碼層面處理 |
||
拉 |
低 |
排程任務定時同步 |
同CDC |
物理刪除無法識別,只能全量 |
一般討論到資料同步的方式主要是分推和拉:
推指的是由資料變更端通過直接或者間接的方式把資料變更的記錄傳送到接收端,從而進行資料的一致性處理,這種主動的方式優點是實時性高。
拉指的是接收端定時的輪詢資料庫檢查是否有資料需要進行同步,這種被動的方式從實現角度來看比推簡單,因為推是需要資料變更端支援變更日誌的推送的。
而推的方式又分兩種:CDC(變更資料捕獲)和領域事件。對於一些舊的專案來說,某些業務的資料入口非常多,無法完整清晰的梳理清楚,這個時候CDC就是一種非常好的方式,只要從最底層資料庫層面把變更記錄取到就可。
對於已經服務化的專案來說領域事件是一種比較舒服的方式,因為CDC是需要資料庫額外開啟功能或者部署額外的中介軟體,而領域事件則不需要,從程式碼可讀性來看會更高,也比較開發人員的維護思維模式。
替換(選擇)儲存系統
因為從本質來看該模式與CQRS的核心本質是一樣的,主要是要對NoSQL的優缺點有一個全面認識,這樣才能在對應業務場景選擇與判斷出一個合適的儲存系統。這裡我像大家介紹一本書馬丁.福勒《NoSQL精粹》,這本書我重複看了好幾遍,也很好全面介紹各種NoSQL優缺點和使用場景。
當然替換儲存的時候,我這裡也有個建議:加入一箇中間版本,該版本做好資料同步與業務開關,資料同步要保證全量與增加的處理,隨時可以重來,業務開關主要是為了後續版本的更新做的一個臨時型的功能,主要避免後續版本更新不順利或者因為版本更新時導致的資料不一致的情況出現。在跑了一段時間後,驗證了兩個不同的儲存系統資料是一致的後,接下來就可以把資料訪問層的底層呼叫替換了。如此一來就可以平滑的更新切換。
結束
本文到這裡就把八大方案介紹完了,在這裡再次提醒一句,每個方案都有屬於它的應對場景,我們們只能根據業務場景選擇對應的解決方案,沒有通吃,沒有銀彈。
這八個方案裡,大部分都存在資料同步的情況,只要存在資料同步,無論是一主多從、分散式快取、CQRS都好,都會有資料一致性的問題導致,因此這些方案更多適合一些只讀的業務場景。當然有些寫後既查的場景,可以通過過渡頁或者廣告頁通過使用者點選關閉切換頁面的方式來緩解資料不一致性的情況。
通過這篇文章我相信大家對資料庫設計優化有了一個全面的認識,如果有更加的建議可以在下方評論反饋給給我。