後端調教的幾個大方向

肝不好,人生是黑白的;後端效能不好,SRE 是醒著的

袁浩 Harry Yuan
15 min readMar 24, 2022

我剛開始工作除了日常的功能、問題修正以外,並不確定該從哪邊開始學習效能的調教。因為我只是一個 BE ,不是專門的 DBA/SRE,這邊分享幾個我碰過而且覺得比較好理解的部分。之後有學到更多的話,會陸續更新在這篇。

這裡我就先從小範圍的開始,先不提比較架構上的例如 Load Balancing、Serverless 等。

後端優化

  1. Caching
  2. Connection Pool
  3. ORM Eager Loading
  4. CDN

DB 操作本身的優化

  1. 適當的為欄位加 Index
  2. 可能造成 Query Planner 不看 Index 的因素
  3. 減少 Selection 中的方法呼叫/資料轉型
  4. 不同資料型態造成的效能差異

後端優化

(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 來管理對 DB 的直接連線

比對一下剛剛原有的架構,有幾個請求就會產生幾個連線,而現在我們就將耗費資源的動作交給 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
end
class 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 拿到蛋餅的照片:

剛剛小明看過以後,在台灣不管是誰都會可以直接從 CDN 取得圖片

這樣一來不止用戶體驗變好,大家都可以很快看到蛋餅的照片,也可以減少對伺服器的負擔

就我所知大家比較常用的就是 Cloudflare、AWS CloudFront 等等。下圖是 CloudFront 提供的 CDN 節點們,你可以想像有使用 CDN 的網站,在這些節點附近的用戶,大多時候讀取資源的速度都是很快的。

轉自 CloudFront

資料庫的調教

身爲後端工程師,大多的開發都會使用到資料庫。除了剛剛硬體需求以外,最主要的瓶頸是「查詢太慢/運算時間太久」。針對這個情況我們可以透過一些 APM (Application Performance Monitoring) 服務,例如 DataDog、NewRelic 等等,來挑出較慢的 Transaction。

NewRelic 例圖。在上面可以看到各種服務組件花了多少時間

當我們找到一個很慢的 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 有提供三種方法:

  1. Range Partitioning:可以按日期範圍或特定範圍進行分割。
  2. List Partitioning:直接講哪些鍵值要分配到哪個分割區。
  3. 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 去搜尋:

  1. 當 Table 很小,它會覺得 Scan 還比較快
  2. 你只有複合 Index,且當搜尋條件中不包含 完整的複合 Index
  3. 搜尋所回傳的列數幾乎跟 Table 一樣大
  4. 拿不完全相同的資料型態搜尋
  5. 查詢中有 limit (這個看過幾篇文章,不一定影響)
  6. 使用負面搜尋( where XXX is not nullXXX not in ('123', '456')
  7. 其實就是你在搞,沒有加 Index

不同資料型態造成的效能差異

例如數值型態分很多種,每一種都可能會因為使用的記憶體量不同而導致明顯的效能差異。這個可以直接看國外大大做的數據:

轉自 Optimization using explain,可以進去看詳細!

上面是使用 serial ,下面是使用 bigserial。聽到 big 就知道他用的空間一定比較大,在實際使用上的確也會花比較多時間處理。

小結

感謝你的閱讀。以上舉的這些只是冰山一角,不過我相信對這些大方向有概念以後,應該可以解決掉大部分的效能問題!

以上是我的一些經驗,若與各位理解不同或是有誤的話歡迎指正、鞭打,因為基本上我也全都是自己一個人在踩雷跟 Google XD。

參考

--

--

袁浩 Harry Yuan
袁浩 Harry Yuan

Written by 袁浩 Harry Yuan

Software Engineer | Ruby on Rails 喜歡學習前後端技術。希望文章白話到阿嬤都看得懂。

No responses yet