【從一台 Server 到分散式架構】第 06 篇:共用一本帳本太慢——讀寫分離與複製
上一篇,小明他們把首頁的「熱門課程」與「講師資訊」這些一秒鐘被問幾千次、卻不太常變動的資料,寫到了 Redis 這塊「小黑板」上。
這塊小黑板成功擋下了 90% 以上的查詢流量,讓資料庫的 CPU 掉了下來,伺服器再次恢復了平靜。
然而,隨著平台營運得越來越好,問題不再只出現在「逛網頁」的人身上,「買課」的人也變多了。
雖然看課程介紹的人可以從小黑板拿資料,但只要是**「結帳下單」、「註冊帳號」、「講師修改課程內容」**這些會真正改變資料狀態的動作,就絕對不能只寫在黑板上,必須得規規矩矩地翻開最底層的那本大帳本(資料庫)寫進去。
當一秒鐘有一千個人同時要買課、同時要在帳本上加上一筆新訂單時,大家就只能排隊。因為帳本只有一本,不可能同時讓一千個人在同一頁上寫字。
這時候,單一一台資料庫的「寫入極限」就成了系統新的瓶頸。
主從複製(Master-Slave Replication)與讀寫分離
如果帳本只有一本,大家都要搶著看、又搶著寫,那一定會打結。 小明想到了一個辦法:「我們去影印幾本『只能看、不能寫』的副本出來吧!」
在系統設計裡,這個做法對應到資料庫的主從複製(Master-Slave Replication,或稱 Primary-Replica),並且基於這個架構來實現讀寫分離(Read-Write Splitting)。
1. 什麼是主從複製?
我們把原本唯一的那台資料庫,升格為**「主節點(Primary / Master)」。 然後,我們再開一台(或多台)新的資料庫,稱之為「讀取節點(Replica / Slave)」**。
主節點的功能是:負責接收所有的「寫入」動作(INSERT、UPDATE、DELETE)。 讀取節點的功能是:不接受任何使用者的寫入,它唯一的任務就是在背後默默地、不斷地把主節點剛寫進去的資料給「抄」過來。所以讀取節點的資料,基本上會跟主節點一模一樣(雖然可能會慢個幾毫秒)。
2. 什麼是讀寫分離?
為了讓大家更清楚這個架構長什麼樣子,你可以看看下面這張圖:
有了正本跟副本之後,後端的程式碼也要跟著改。 當前端傳來一個**「我要下單」的請求,後端程式會把這筆寫入指令打到主節點**。 當前端傳來一個**「我要看我的歷史訂單」的請求,後端程式就會把這筆查詢指令打到讀取節點**。
這就是讀寫分離。 原本所有的壓力全都壓在同一台機器上;現在,所有「讀」的壓力都被分攤到讀取節點去了,主節點終於可以全心全意地處理「寫入」就好。如果以後「讀」的量又變大了,我們就再多印幾本副本(這就是第 14 篇會談到的多節點擴展)。
3. 實務上要怎麼做?
實務上要做到「寫入打主節點、讀取打讀取節點」,我們絕對不會在每一支 API 裡面手刻 if (isSelect) { query(replica) }。業界常見的做法主要分為兩大流派:一、應用程式層(Application Level)與二、中介代理層(Middleware / Proxy Level)。
流派一:應用程式層(ORM / Database Driver)
這是最省硬體成本、也是多數團隊的第一步。我們直接利用後端框架的 ORM 或資料庫驅動程式來設定兩個連線字串。當程式呼叫寫入操作時,ORM 會自動連到主節點;呼叫讀取操作時,ORM 會自動連到讀取節點。
以 Python + FastAPI + SQLAlchemy 為例: 在 FastAPI 中使用 SQLAlchemy 時,你可以設定兩個 Engine:一個綁定主庫,一個綁定從庫。
from fastapi import FastAPI, Depends
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, Session
app = FastAPI()
# 1. 準備兩條連線字串
PRIMARY_URL = "postgresql://user:pass@primary.db.com/course_db"
REPLICA_URL = "postgresql://user:pass@replica.db.com/course_db"
# 2. 建立兩個 Engine
engine_primary = create_engine(PRIMARY_URL)
engine_replica = create_engine(REPLICA_URL)
# 3. 建立 SessionMaker (不綁定單一 engine, 等需要時再餵進去)
SessionLocal = sessionmaker(autocommit=False, autoflush=False)
# 4. 在 FastAPI 的 Dependency 中決定這支 API 要用哪個 DB
def get_read_db():
db = SessionLocal(bind=engine_replica)
try:
yield db
finally:
db.close()
def get_write_db():
db = SessionLocal(bind=engine_primary)
try:
yield db
finally:
db.close()
# 5. API 實作:讀取課程列表(依賴 get_read_db)
@app.get("/api/courses")
def get_courses(db: Session = Depends(get_read_db)):
return db.query(Course).all()
# 6. API 實作:下單買課(依賴 get_write_db)
@app.post("/api/orders")
def create_order(order: OrderCreate, db: Session = Depends(get_write_db)):
new_order = Order(course_id=order.course_id)
db.add(new_order)
db.commit()
return {"status": "success"}優點非常明顯:架構單純,不用多架設任何伺服器。缺點是如果後端有很多不同語言的微服務,每個團隊都要自己寫好這段切換邏輯。
流派二:中介代理層(Database Proxy)
當系統大到一定程度,或是希望「後端程式碼完全不要改,連框架都不用動」時,就會在後端伺服器跟資料庫中間,多架設一層代理伺服器(Proxy)。
這時候,所有後端程式的連線 IP,統一改成指向這台 Proxy。對於後端來說,它以為全世界只有這「一台」資料庫。但這台 Proxy 內部寫滿了路由規則:
- 它攔截了後端發送過來的所有 SQL 語法。
- 如果解析出這句開頭是
SELECT,它就自動幫你轉發給後面的讀取節點。 - 如果解析出開頭是
INSERT,UPDATE,DELETE,它就轉發給主節點。
業界常用的工具有:
- ProxySQL、MaxScale(MySQL 生態系最愛用)。
- PgBouncer (雖然主要做連線池,但也常搭配其他 Router 使用)。
- AWS RDS Proxy(雲端全託管的懶人解法)。
優點是對程式碼 100% 透明(Zero Code Change)。缺點是系統架構變複雜了,多了一次網路傳輸的延遲,且 Proxy 本身如果掛掉,整個資料庫連線就全斷了(所以 Proxy 也必須做到高可用叢集)。
為什麼說這最適合「讀多寫少」的系統?
你可能會問,為什麼不乾脆開兩台「主庫」,讓大家兩邊都可以寫? 如果兩邊都可以寫,A 在第一本帳本加了訂單,B 在第二本帳本扣了庫存,兩本帳本之間的同步與衝突處理會複雜到讓人頭皮發麻(這牽涉到分散式寫入與一致性問題,我們後面的篇章再來痛)。
而小明他們的課程平台,和大多數的內容網站(像 YouTube、Dcard、新聞網)一樣,有著一個極為鮮明的特徵:讀多寫少(Read-Heavy)。
以 YouTube 為例,一部爆紅的影片可能有 100 萬次觀看(讀取),但它的標題與資訊大概只會被上傳者修改個 2、3 次(寫入)。 課程平台也是一樣:幾千個人在看課程、瀏覽歷史訂單(讀取),但同一時間真正在刷卡結帳、或是講師在上傳新影片的比例相對低很多(寫入)。
既然「讀取」佔了流量的 90% 以上,那我們只要透過「複製多個只能讀的從庫」,就能完美地分攤掉這 90% 的壓力。對於「讀多寫少」的系統來說,這幾乎是擴充資料庫架構的標準起手式。
讀寫分離的副作用:複製延遲(Replication Lag)
這世界上沒有免費的午餐。當你決定讓資料從主庫「抄」到從庫時,就一定會面對時間差的問題。如果主庫剛寫完,從庫還沒來得及抄過去,這時候有客人去查從庫,會發生什麼事?
情境:用戶剛買完課,重新整理卻發現「沒買到」? 有一位很心急的學生剛刷卡買了一堂課,寫入請求打到了主節點,主節點說:「好的,訂單成立!」。 網頁隨即自動重新整理,去抓取這位學生的「已購買課程列表」。但這個查詢請求被打到了讀取節點。 由於網路傳輸或資料庫太忙,讀取節點還差 500 毫秒才把剛剛那筆訂單抄過來。於是讀取節點回傳:「你還沒買任何課程喔!」
這位學生嚇壞了,以為自己被坑了錢,氣得立刻打擊客服大罵。小明他們接到客訴一查才發現,這就是著名的**複製延遲(Replication Lag)**所造成的資料不一致。
怎麼解?
實務上有幾種常見的妥協做法:
-
強制熱點資料讀主節點: 如果是「剛結帳完的使用者,去查他自己的訂單」,這種極度要求強一致性的場景,我們可以寫點邏輯:「只要是涉及到資金、剛改寫過狀態的查詢,接下來的 5 秒內,強制改去查主節點」。而其他不那麼急迫的查詢(例如首頁看所有課程清單),就繼續查讀取節點。
-
前端巧妙的 UI 掩飾(Optimistic UI): 當知道後端已經寫入成功,前端不需要馬上重新去讀取節點要資料,而是用 JavaScript 直接在畫面上假裝「這堂課已經出現在列表裡了」。等使用者下次登入或過幾分鐘再重整時,讀取節點早就同步完畢了。這在許多大型應用的點讚、發留言功能中非常常見。
小結與預告
當所有的「寫入」動作讓單一資料庫排隊大塞車時,我們透過引入主從複製與讀寫分離的架構,讓主節點專注處理寫入,讀取節點(們)來分攤龐大的查詢壓力。雖然這套作法極度適合「讀多寫少」的情境,但隨之而來的是後端程式必須自己決定「誰要打主節點、誰要打讀取節點」,以及必須面對「主從抄寫之間的時間差(複製延遲)」。
到目前為止,小明他們的系統:
- 用 Nginx + 多台後端 解決了運算的瓶頸(水平擴充)。
- 用 Redis 解決了熱門資料查詢的瓶頸(快取)。
- 用 主從複製 分攤了資料庫整體的讀寫壓力(讀寫分離)。
系統又大了一圈,效能也越來越好。 但小明發現,有時候使用者在註冊完帳號後,網頁會轉好幾秒鐘的手指才顯示「註冊成功」。 仔細一看,原來是因為系統在使用者註冊時,會「同步」去呼叫第三方服務寄送一封「歡迎信」。要是郵件伺服器稍微慢一點,整個網頁就跟著卡住了!
為什麼要把寄發歡迎信這種「不影響網頁顯示」的事情,跟使用者的請求綁死在一起? 既然廚房接了訂單,能不能先把訂單接下來,晚點再出菜?這就是我們下一篇要談的:發信拖慢了整個網站——非同步與背景任務。我們下一篇見。