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