後端調教的幾個大方向
肝不好,人生是黑白的;後端效能不好,SRE 是醒著的
我剛開始工作除了日常的功能、問題修正以外,並不確定該從哪邊開始學習效能的調教。因為我只是一個 BE ,不是專門的 DBA/SRE,這邊分享幾個我碰過而且覺得比較好理解的部分。之後有學到更多的話,會陸續更新在這篇。
這裡我就先從小範圍的開始,先不提比較架構上的例如 Load Balancing、Serverless 等。
後端優化
- Caching
- Connection Pool
- ORM Eager Loading
- CDN
DB 操作本身的優化
- 適當的為欄位加 Index
- 可能造成 Query Planner 不看 Index 的因素
- 減少 Selection 中的方法呼叫/資料轉型
- 不同資料型態造成的效能差異
後端優化
(1) Caching
在 Server 端,將經常使用的資料暫存起來。
若你的 API 需要計算一個相對複雜的項目:例如計算量大的資料庫查詢,與其每次請求都去幫用戶重新計算,我們將結果先暫存一小段時間,便可以在犧牲「資料即時性」的情況下,減少對資料庫的請求數量。
如此一來, API 的速度就會大幅上升。推薦的做法是另外架設快取服務。常見的有這兩個:
- Memcached: 以純字串方式暫存資料的快取系統。
- Redis:記憶體內存資料庫,支援各種資料結構。因此可以用例如 Hash 的方式來暫存你想要的資料。
若你想了解更多 Caching 關於這個部分,可以參考之前寫過的一篇:
- Materialized View
如果你有聽過 View 的話,它其實沒辦法提升效能,骨子裡還是進行一般查詢。但這種 Materialized View (實體檢視表)卻是專門用來暫存結果用的物件。如果想了解更多可以參考:
(2) Connection Pool
通常 Server 都會需要連線到資料庫進行 CRUD。若是公司的新服務剛起步,初期架構通常類似下面這樣:將 Server 放在一台機器上,直接連到資料庫。
這個範例可以看到左邊是一台機器,上面跑著我們的 Application Server。大部分的 Server 每當收到一個請求時,都會產生一個執行緒去處理它。
若現在同時有 N 個查詢請求,便會如上圖產生 N 個資料庫連線。
這樣有什麼問題?
服務剛開始,因為流量不大,這樣的架構感覺不太出來會有什麼問題。想像一下,如果你公司的商業模式很成功,流量突然進來:
我的前老闆也是這樣跟我說:「通常第一個爆掉的應該都是 DB。」。這件事又可以分成幾個原因,一般會聽到的情況是「是不是因為 SQL 寫很爛、沒有 index 等等」?
不過有時候我們會忽略的是 — 其實資料庫連線的建立成本很昂貴,每一個連線對資料庫都是一個 forked process。如果多個執行緒都想要建立連線時,資源的耗費是龐大的。
查詢請求的過程中,網路延遲跟資料庫結果運算,其實都會導致連線呈現閒置狀態,這些零碎的時間加起來也是其實很浪費。那我們有沒有辦法重複利用已經建立的連線?答案是:有,就是使用 Connection Pool。
比對一下剛剛原有的架構,有幾個請求就會產生幾個連線,而現在我們就將耗費資源的動作交給 connection pool 處理。我們告訴它說我們只想要固定幾個連線(例如這邊設定是 3 個),這樣資料庫就不用煩惱新的連線,只需要專心計算就好。
比較常聽到的 Connection Pool 工具應該就是 PgBouncer 了,各位有興趣的話可以玩玩看。你可以依照你的需求,裝在 App Server 或是 Database Server 都可以。(除非你的 DB 是由雲端來管理,我們自己是裝在 App Server 這邊)
TL;DR 使用它的好處
提升 DB 效率:使用 connection pool 除了重用連線來節省資源以外,同時也換來 DB 能用更多 CPU 去處理查詢運算。
保護資料庫:之後都只有 Connection Pool 可以直接連線到 DB,降低資料庫掛掉的風險。
輕量: PgBouncer 設定簡單、資源需求很少,裝上去對硬體沒什麼感覺 XD。
(3) ORM Eager loading
後端面試題裡面很常會問什麼是「N+1 問題」,這邊主要是針對我比較熟悉的 Rails ORM 去做討。其他語言也可能有出現這個問題,例如 Python 的 SQLAlchemy。
Rails 的 ActiveRecord 在沒有預先載入關聯時,容易發生這個叫做 N+1 的問題。直接舉例子會比較好懂。
例如我們定義用戶有很多文章,但我們在用迴圈存取文章時,沒有預先載入用戶的資訊,就導致每個迴圈都會去查詢。
Model:
class User < ApplicationRecord
has_many :articles
endclass Article < ApplicationRecord
enum state: { published: 0, archived: 1 }
end
Controller:
class ArticlesController < ApplicationController
def index
@articles = Article.published # 這邊只有 load article 而已
end
end
View:
# View
<ul>
<% @articles.each do |article| %>
<li>
<h2><%= article.title %></h2>
<p><%= article.user&.name %></p> <---每個迴圈這邊都會觸發查詢一次
</li>
<% end %>
</ul>
想要知道更多細節可以來這邊看:
(4) CDN
CDN (Content Delivery Network) 是一種「在使用者附近快取」的服務。
如果你的網站有架 CDN,當使用者在存取你的網站時, CDN 會自動尋找離它最近的 CDN 節點,看看這邊有沒有其他人訪問過同樣的資料(例如同一張圖片、影片等資源),有的話就會從 CDN 馬上回傳給用戶,讓用戶取得資源的時間縮到非常短。如果找不到、節點中的快取過期,才會到你的伺服器請求資源。
我們來看流程圖更好理解,假設你的網站是賣早餐的,如果沒有用 CDN 的話大概會像這樣:
假設回傳時間花了 600ms ,在沒使用 CDN 下每個用戶都會經過這個流程,當用量大的時候除了造成伺服器負擔以外,用戶體驗也不佳,因為他們可能要等超過 600ms ~ 700ms 才可以看到。
那如果我們使用 CDN 呢?
你可能好奇為什麼還是 600ms?這是因為蛋餅的照片第一次被請求,CDN 節點裡面沒東西,因此要去對伺服器請求圖片。當它存到快取以後,這個地區的所有用戶都可以直接從 CDN 拿到蛋餅的照片:
這樣一來不止用戶體驗變好,大家都可以很快看到蛋餅的照片,也可以減少對伺服器的負擔。
就我所知大家比較常用的就是 Cloudflare、AWS CloudFront 等等。下圖是 CloudFront 提供的 CDN 節點們,你可以想像有使用 CDN 的網站,在這些節點附近的用戶,大多時候讀取資源的速度都是很快的。
資料庫的調教
身爲後端工程師,大多的開發都會使用到資料庫。除了剛剛硬體需求以外,最主要的瓶頸是「查詢太慢/運算時間太久」。針對這個情況我們可以透過一些 APM (Application Performance Monitoring) 服務,例如 DataDog、NewRelic 等等,來挑出較慢的 Transaction。
當我們找到一個很慢的 Query,我們可以再透過指令 EXPLAIN ANAYLZE
來檢視它裡面到底在幹嘛:
EXPLAIN ANALYZE SELECT ... FROM records WHERE .... GROUP BY ...;
QUERY PLAN
-------------------------------------------------------------
GroupAggregate (cost=185753.73..191351.57 rows=200 width=25) (actual time=2883.718..2885.135 rows=31 loops=1)
Group Key: t.user_id
CTE time_threshold
-> GroupAggregate (cost=39613.01..65579.99 rows=11908 width=25) (actual time=233.594..541.393 rows=2576 loops=1)
Group Key: user_id
-> Sort (cost=39613.01..39811.84 rows=79532 width=28) (actual time=233.482..254.666 rows=60486 loops=1)
Sort Key: user_id
Sort Method: external merge Disk: 2480kB
-> Seq Scan on records (cost=0.00..31235.40 rows=79532 width=28) ...
Rows Removed by Filter: 9978
大方向是找這個傢伙 — Seq Scan:通常是罪魁禍首,因為 Scan 就是整個表的資料都看過一次來比對條件。為了避免 API 一直使用 Seq Scan,我們可以在常用的欄位加上 Index。
適當的為欄位加 Index
Index(索引)是一種用來加速查詢速度的資料結構,建立的時候在資料庫中會佔用一些空間。
在關聯式資料庫的 Index,大部分都是用一種叫做 Btree 的樹狀結構。我們在資料結構裡,樹狀結構的高度,基本上就代表查詢最多所需次數。而 Btree 最主要的特色就是會自己平衡高度,使高度維持最小,因此非常適合用來查詢。
依照左小右大的規則,例如尋找 17 的路徑是:
- 比 11 大往右邊
- 比 16 大且比 18 小 往中間
- 找到 17。只要花 3 次時間。
Index 切勿過多
當我們為資料表增加 Index,雖然搜尋上變快了,但是寫入的時間會相對變慢 — 因為索引在新增資料時會被重新計算。
這代表所有新增、更新、刪除等操作都會受到一些影響,一般大家都會說「Insert 會變慢」。下圖是一個參考時間圖,可以看到 Index 越多,執行時間越久。
那 Index 幾個算多?
這個沒有標準答案,不過建議是真的需要時點到為止。如果硬要擠一個數字的話,我一位有 10 多年經驗的前同事說過,建議大部分的表都控制在 5 個(含)以內。
資料表分割
Partition 是指依照某個邏輯去分割大型的資料表,將資料區在意義上分成 Hot(經常被使用) / Cold(不常被使用) ,可以讓查詢去過濾更少的列數和 index 來找到資料。
分割的邏輯要依照你使用的資料庫有提供哪一些。例如 PostgresQL 有提供三種方法:
- Range Partitioning:可以按日期範圍或特定範圍進行分割。
- List Partitioning:直接講哪些鍵值要分配到哪個分割區。
- Hash Partitioning:用除數跟餘數規則來分。例如我們透過 id mod 5 來作為分割邏輯,就可以將 id 除以 5 餘 0 的資料分配到第一區,餘 1 的資料分配到第二區等等。
減少 selection 中的方法呼叫/資料轉型
查詢中如果不是必要,盡量減少方法呼叫和轉換。例如我想要將 timestamps 選成日期格式。如果透過 to_char
方法呼叫:
explain analyze
select id, to_char(created_at, 'YYYY/MM/DD')
from room_records limit 3000 Limit (cost=0.00..73.50 rows=3000 width=40) (actual time=0.065..3.949 rows=3000 loops=1)
-> Seq Scan on room_records (cost=0.00..26012.65 rows=1061732 width=40) (actual time=0.063..2.811 rows=3000 loops=1)
Planning time: 0.217 ms
Execution time: 4.565 ms
如果我們不另外呼叫方法,而是直接轉型 ::date
,即使範例很小,也可以看到一些差別。
explain analyze
select id, created_at::date
from room_records limit 3000 Limit (cost=0.00..73.50 rows=3000 width=12) (actual time=0.012..2.347 rows=3000 loops=1)
-> Seq Scan on room_records (cost=0.00..26012.65 rows=1061732 width=12) (actual time=0.011..1.155 rows=3000 loops=1)
Planning time: 0.227 ms
Execution time: 2.986 ms
可能造成 Query Planner 不看 Index 的因素
資料庫在做搜尋的時候,是由一個叫做 Query Planner 的傢伙來安排怎麼搜尋比較快。
我曾經遇過一個資料表,他有個欄位有加上 Index ,而且是允許為空 (null) 的。但當我下:
select * from that_table where is_allowed is not null
結果就慢到 API 爆掉了。
那時候覺得很怪,不是有 Index 嗎?結果發現是我不懂資料庫的 Query Planner。經過各種搜尋研究,大概整理出以下情境它 有可能 不會用 Index 去搜尋:
- 當 Table 很小,它會覺得 Scan 還比較快
- 你只有複合 Index,且當搜尋條件中不包含 完整的複合 Index
- 搜尋所回傳的列數幾乎跟 Table 一樣大
- 拿不完全相同的資料型態搜尋
- 查詢中有 limit (這個看過幾篇文章,不一定影響)
- 使用負面搜尋( where
XXX is not null
、XXX not in ('123', '456')
) - 其實就是你在搞,沒有加 Index
不同資料型態造成的效能差異
例如數值型態分很多種,每一種都可能會因為使用的記憶體量不同而導致明顯的效能差異。這個可以直接看國外大大做的數據:
上面是使用 serial ,下面是使用 bigserial。聽到 big 就知道他用的空間一定比較大,在實際使用上的確也會花比較多時間處理。
小結
感謝你的閱讀。以上舉的這些只是冰山一角,不過我相信對這些大方向有概念以後,應該可以解決掉大部分的效能問題!
以上是我的一些經驗,若與各位理解不同或是有誤的話歡迎指正、鞭打,因為基本上我也全都是自己一個人在踩雷跟 Google XD。