跳轉到

3.3 資料庫與 SQLx:關聯模型與非同步存取#

關聯模型的理論地位#

Codd 的關聯模型(1970)至今仍是資料儲存的主宰,因為它提供了宣告式查詢 + 代數最佳化:

  • 資料是關係(集合的子集),查詢是關係代數的表達式
  • 查詢最佳化器可以重寫代數式(等價變換)而不改語意
  • ACID 交易提供一致性保證(Häarder–Reuter, 1983)

Web 後端 90% 的資料需求(結構化、關聯查詢、交易)都適合關聯模型。PostgreSQL 是本書選擇:型別豐富(JSONB、UUID、陣列)、支援 RETURNING、擁有成熟的 Rust 驅動。

SQLx:編譯期 SQL 驗證#

Rust 生態的 ORM 選擇:

方案 思路 取捨
Diesel DSL 式 ORM 型別極強,學習曲線陡
SeaORM 類 ActiveRecord 靈活但抽象層厚
SQLx 純 SQL + 編譯期檢查 SQL 就是你寫的字串

SQLx 的哲學:不要藏在 ORM 後面,直接寫 SQL,但讓編譯器檢查它:

let user = sqlx::query_as!(
    User,
    r#"SELECT id, name, email, created_at FROM users WHERE id = $1"#,
    id
)
.fetch_optional(pool)
.await?
.ok_or(AppError::NotFound)?;

cargo check 時,SQLx 連上資料庫(或在 CI 用 cargo sqlx prepare 快取),真的執行該查詢的型別推導:欄位名錯、型別錯,編譯就失敗。

\[ \text{SQL 錯誤發現時點}: \text{production} \Rightarrow \text{編譯期} \]

這是全書反覆出現的主題第三次出現:把錯誤往時間軸左端移。

非同步資料庫存取的併發模型#

連線池是 Web 後端效能的第一槓桿:

\[ \text{吞吐量} \approx \frac{\text{連線數}}{\text{平均查詢時間}} \]
let pool = PgPoolOptions::new()
    .max_connections(20)       // 上限:避免壓垮 DB
    .acquire_timeout(Duration::from_secs(3))
    .connect(&database_url).await?;

關鍵觀念:PostgreSQL 每個連線是一個行程,因此連線數上限應與 DB 伺服器的資源匹配(經驗法則:核心數 × 2–4)。超過上限的請求在池上非同步等待(而不是開新連線)—— 這就是 Tower 式背壓在資料層的體現。

交易與一致性#

let mut tx = pool.begin().await?;

sqlx::query!("UPDATE accounts SET balance = balance - $1 WHERE id = $2", amount, from)
    .execute(&mut *tx).await?;
sqlx::query!("UPDATE accounts SET balance = balance + $1 WHERE id = $2", amount, to)
    .execute(&mut *tx).await?;

tx.commit().await?;   // 或者:出錯時 tx drop 自動 rollback(RAII!)

注意 tx 的型別獨佔了交易 —— &mut *tx 的借用規則保證同一交易不會被並行使用,這在其他語言需要紀律的地方,Rust 用型別強制。

隔離層級的取捨(SQL 標準):

層級 髒讀 不可重複讀 幻讀
Read Committed(PG 預設) 無 可能 可能
Repeatable Read 無 無 可能
Serializable 無 無 無
\[ \text{一致性} \uparrow \iff \text{併發度} \downarrow \]

實務:預設層級即可,只有涉及「先讀後寫同一批資料」的業務(如庫存扣減)才需要升級或用樂觀鎖。

遷移(Migration):Schema 的版本控制#

sqlx migrate add create_users
-- migrations/20240101000000_create_users.sql
CREATE TABLE users (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    email TEXT NOT NULL UNIQUE,
    name TEXT NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_users_email ON users(email);

啟動時 sqlx::migrate!().run(&pool).await?; 自動套用未執行的遷移。Schema 演進如同程式碼演進,必須可版本化、可重放 —— 這是 DB DevOps 的基石。

習題#

  1. 解釋 SQLx 編譯期檢查如何工作,以及 CI 中離線模式(prepare)的必要性。
  2. 為什麼連線池上限不該設成 1000?畫出「連線數 vs 吞吐量」的關係曲線。
  3. 設計「庫存扣減」的交易:說明用 Serializable 或 SELECT ... FOR UPDATE 各自的取捨。