【從一台 Server 到分散式架構】第 04 篇:資料庫變慢了——索引與查詢優化

前面第 02 篇談到,當小明他們的課程平台上線人數變多時,首頁開始變慢、偶爾還出現 504 超時錯誤。經過排查,他們發現:瓶頸其實在資料庫

很多時候,當系統負荷變大,最先出狀況的往往不是後端的 CPU,而是資料庫的查詢(Query)。特別是像線上課程平台這種「讀多寫少」(Read-Heavy)的系統——大多數人都是來「看」課程列表、「查」熱門排行、「刷」影片資訊,真正在「寫入」(下單、註冊)的比例相對低很多。

當發現資料庫撐不住時,我們的直覺反應通常是向外尋找資源,例如:「是不是該花錢把資料庫所在的伺服器升級成更高規格(加 CPU、加記憶體)?」;或者,如果你有稍微聽過一些系統架構的名詞,可能會立刻想到:「那我們來做讀寫分離,或是架設 Redis 快取來幫資料庫擋流量吧!」。

這些解法都沒錯,但在砸錢買更高規的機器,或是馬上引進新的架構元件(這會大幅增加系統與維運的複雜度)之前,我們其實漏掉了一個最根本、也最省錢的步驟。

第一步最該做的其實是向內看:把現有的資料庫體質搞好

很多時候,資料庫之所以 CPU 飆高,不是因為流量真的大到單機撐不住,而是因為我們讓資料庫做了太多「沒有效率的白工」。這一篇,我們就來談談:在不花錢加機器的前提下,如何透過「索引(Index)」與「查詢優化」,先把資料庫的負擔降下來。


為什麼資料庫會變慢?——帳本越翻越厚

想像資料庫是一本厚厚的帳本。一開始,平台上只有幾十筆訂單跟課程,不管你要找什麼,從第一頁翻到最後一頁(這叫作全表掃描,Full Table Scan)也花不了多少時間。

但當課程變多、用戶變多,每一次有人點開首頁,後端都要去這本厚厚的帳本裡尋找「所有上架的課程,還要按觀看人數排序」。如果帳本沒有分類、也沒有目錄,資料庫就只能每一頁、每一頁去翻。這時候硬碟 I/O 會飆高、CPU 滿載,回應時間從原本的幾毫秒變成好幾秒。

後端等不到資料庫的回傳,等久了就超時,最後使用者就看到了 504 錯誤。

所以,資料庫變慢,最常見的原因之一就是:在沒有目錄的情況下,要它從海量資料中找東西。


建立目錄:什麼是索引(Index)?

索引(Index),最直白的理解就是帳本前面的「目錄」。

如果你要在字典裡找「資料庫」這個詞,你不會從第一頁開始看,你會去部首目錄或注音符號表裡找,瞬間就能翻到那一頁。幫資料庫的欄位加上索引,就是告訴資料庫:「請幫我為這個欄位建一個目錄」。

比如說,課程平台最常被用到的查詢之一是: SELECT * FROM courses WHERE category_id = 5 AND status = 'published';

如果沒做索引,資料庫要一筆一筆檢查整張表:「這門課是不是分類 5?是不是上架狀態?」 如果在 category_idstatus 上建立了索引,資料庫就能像查字典一樣,直接跳到符合條件的那幾行資料。原本要掃描幾萬筆資料耗費好幾秒的查詢,瞬間變成只需掃描幾十筆、耗時幾毫秒。

索引的底層原理:為什麼是 B-Tree 與 B+ Tree?

你可能會好奇,這個「目錄」在資料庫底層到底是怎麼運作的?如果沒有目錄,資料庫找尋一筆資料就像是線性搜尋(Linear Search):一百萬筆資料就要最壞情況下掃過一百萬行,時間複雜度是 O(n),這對需要做大量磁碟 I/O 的資料庫來說太吃力了。

如果我們把這個目錄設計成一棵二元搜尋樹(Binary Search Tree),每次比較都能讓範圍減半,搜尋一百萬筆資料最多只要找 20 次(O(log n)),聽起來很棒對吧?但在實務上,關聯式資料庫(如 MySQL、PostgreSQL)並不是用二元搜尋樹,而是使用 B-TreeB+ Tree。為什麼?

關鍵在於**「減少讀取磁碟的次數」**:

  1. 二元搜尋樹太高了:二元樹每個節點只有兩個分岔。一百萬筆資料會有 20 層高,代表最壞情況下你可能需要讀取 20 次不同的磁碟區塊。
  2. B-Tree(矮胖的樹):B-Tree 允許一個節點裡存入多個鍵值(例如 100 個)。這使得一棵存了一百萬筆資料的 B-Tree 只有大約 3 層高!比較數字大小在記憶體中極快,但讀取磁碟 I/O 極慢。3 層代表只要讀 3 次磁碟,比起 20 次,速度提升非常可觀。
  3. B+ Tree(更適合範圍搜尋):進階版的 B+ Tree 進一步改良,把所有的「資料(或指標)」都放在最底層的葉節點,上面的內部節點只負責當「路標」。這樣帶來兩個好處:第一,內部節點能塞入更多的路標,樹變得更矮;第二,最底層的葉節點彼此之間是用鍊表(Linked List)串連起來的,這對於「找出價格在 1000 到 2000 之間的課程」這種**範圍搜尋(Range Query)**特別有效率,不需要在樹的上層來回跑。

所以,當你輸入 CREATE INDEX 時,資料庫其實就是在背後默默幫你蓋了一棵矮矮胖胖、能把磁碟 I/O 降到最低的 B+ Tree。

索引不是加越多越好

既然索引這麼好用,那每個欄位都加不就好了?不行。任何技術都有取捨(Trade-off):

  1. 佔用額外空間:目錄也是要寫在紙上的。索引在底層是一棵樹(通常是 B-Tree 家族),它會吃掉額外的磁碟甚至記憶體空間。
  2. 拖慢寫入速度:這是最關鍵的代價。每次新增一門課、修改訂單狀態,你不只要改原來的帳本,還要同步更新目錄。如果一本帳本有二十個目錄,寫入或修改一筆資料就會變得超慢。

所以,索引只能加在**「常被拿來當作搜尋條件(WHERE)、排序(ORDER BY)或關聯(JOIN)」**的欄位上。這也是為什麼前面特別強調「讀多寫少」的系統:因為讀取的頻次遠大於寫入,花一點寫入的代價去換取極大的查詢效能,是非常划算的交易。


慢查詢分析與語句優化

就算有了目錄,如果你查的方式不對,資料庫還是會很累。小明他們打開了資料庫的慢查詢日誌(Slow Query Log),這份日誌會記錄所有執行時間超過門檻(例如 1 秒)的指令。他們從日誌中找出了幾個常見的雷區,並開始優化:

1. 撈了太多不需要的資料(SELECT *

例如,首頁只要顯示課程名稱和封面圖,程式裡卻偷懶寫了: SELECT * FROM courses; 這會把整張表的內容,包含長達幾萬字的「課程詳細介紹」全都撈出來,送到後端。這不僅無謂地消耗了 DB 的 I/O,又佔用了網路頻寬與後端的記憶體。 優化方向:要什麼查什麼,改成 SELECT id, title, cover_image FROM courses;

2. 避免 N+1 查詢問題

這是使用 ORM(如 Prisma、Sequelize、Hibernate)時超級容易踩的坑。 假設小明要在畫面上顯示 10 門課程,以及每門課的講師名字: 後端先查一次課程:SELECT * FROM courses LIMIT 10; (這跑了 1 次查詢) 然後針對每一門課,跑去查對應的講師:SELECT * FROM users WHERE id = ?; (這跑了 10 次) 總共查了 1+10 = 11 次資料庫!如果是一百門課,就是 101 次。 優化方向:用關聯(JOIN)一次查出來,或是利用 IN 語句把 ID 收集起來一次查完,像是 SELECT * FROM users WHERE id IN (1, 2, 3...);。把大量網路連線來回的時間省下來。

3. 減少不必要的複雜 JOIN

有時候資料庫變慢,是因為寫了一句堪比長篇小說的 SQL,把四五張大表全部 JOIN 在一起查。資料庫在做複雜關聯時,記憶體跟 CPU 會瞬間被榨乾。 優化方向:不需要在 DB 層做到完美的 JOIN,有時可以拆成兩三次簡單的查詢,然後在後端用程式稍微組合一下;或者適度容忍一些「反正規化(Denormalization)」,把常用的資料(例如講師名字)直接冗餘寫在課程表裡,拿空間換取查詢時間。


先做好體質優化,再談後續擴充

小明他們藉著看慢查詢 log,補上了幾個關鍵欄位的索引,把首頁的 N+1 查詢修掉,並且把 SELECT * 改成了只取需要的欄位。

這一輪優化下來,奇蹟出現了:原本滿載的資料庫 CPU 掉回了安全的範圍,首頁從動輒幾秒載入變回了秒開,也不再吐 504 錯誤了。他們在沒有增加任何機器成本的前提下,把 DB 的負擔降了下來。

這就是系統設計裡很重要的一步:擴充架構之前,先確認現有的查詢已經優化到了極限。 好的索引與乾淨的 Query,能為系統爭取到非常大的喘息空間。


小結與預告

當資料庫變慢時,不要急著擴充機器。先檢查是不是帳本越翻越厚、卻沒做目錄。**索引(Index)**能大幅加快查詢,但會拖慢寫入,因此特別適合「讀多寫少」的情境。同時,透過慢查詢分析,避免 SELECT *、解決 N+1 查詢、減少無謂的 JOIN,都能顯著降低 DB 的負擔,為後面的架構升級打好底子。

不過,隨著流量繼續成長,單一一台資料庫終究會有實體極限。而且有些熱門資料(像是首頁的精選課程、講師資訊),它根本就「不太常變」,每一秒卻有幾千個人跑去問資料庫,這對 DB 來說太浪費資源了。

既然這些資料常被問又不太變,那我們能不能把它們寫在「小黑板」上,讓大家不用去翻帳本就能看到? 這就是我們下一篇要談的:常被問的菜單寫在小黑板——Redis 與快取(Cache)。我們下一篇見。