Web Day 6 資料庫入門:SQLModel 與 SQLite
執行需求:CPU 可跑。前五天的書本 API 雖然功能齊全,但每次重啟伺服器資料就會消失。這種「腳本級」的後端沒辦法真正上線。今天要進入資料持久化的主題:用 SQLModel 定義資料模型、用 SQLite 做本機儲存、寫出真正的建立、讀取、更新、刪除。學完這篇,你的 API 將從「能跑的腳本」升級成「資料會留下來的系統」。
引言
為什麼要把資料庫放進後端?最直接的理由是「資料要能存活」。但更深層的理由是:後端系統的價值不在於「能回應 HTTP 請求」,而在於「能把使用者的操作累積成有意義的資料,再從資料中產生價值」。一個沒有資料庫的 API 就像是沒有記憶的對話機器人——你問它「我昨天訂了什麼?」它回答「我不知道」。
Python 生態系裡存取資料庫的主流方式有兩條路:一是直接寫 SQL 字串(用 sqlite3、psycopg 等驅動程式),二是用 ORM(Object-Relational Mapping)框架。直接寫 SQL 給你最大的控制權,但每個查詢都要自己組字串、自己處理參數化、自己對應欄位;ORM 則犧牲一點效能換來大幅簡化的程式碼。對一個初學後端的人來說,先學 ORM 再回頭理解 SQL 通常是比較順的路。
SQLModel 是 FastAPI 同一個作者(tiangolo)在 2022 年推出的 ORM,設計目標是「Pydantic 模型 + SQLAlchemy ORM = SQLModel」。它的好處是用同一份 class 既能驗證請求、回應,又能對應資料表,程式碼不會重複。配合 SQLite(Python 內建的輕量資料庫),整個開發環境可以在沒有任何外部服務的情況下運作。Day 31 會把 SQLite 換成 PostgreSQL,但核心概念一致。
為什麼選 SQLModel 而不是直接寫 SQL
用直接寫 SQL 的方式做 CRUD 是這樣的:
# 直接寫 SQL 的範例(用 sqlite3)
import sqlite3
conn = sqlite3.connect("books.db")
cursor = conn.cursor()
# 建立資料表
cursor.execute("""
CREATE TABLE IF NOT EXISTS books (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
author TEXT NOT NULL,
year INTEGER NOT NULL
)
""")
# 新增一筆
cursor.execute(
"INSERT INTO books (title, author, year) VALUES (?, ?, ?)",
("Python 程式設計實戰", "Hao", 2024),
)
conn.commit()
# 查詢所有
cursor.execute("SELECT id, title, author, year FROM books")
rows = cursor.fetchall()
conn.close()
# rows 是 list[tuple],要自己轉成 dict 才能回應 API
這段程式有幾個痛點:第一,欄位名稱寫死在字串裡,改欄位名要搜尋整個檔案;第二,fetchall() 回傳的是 tuple,沒辦法直接當 Pydantic 模型用,要手動轉;第三,SQL injection 防禦要靠「參數化查詢」(? 佔位符),忘了寫就中招。SQLModel 把這幾件事都自動化:欄位名稱是 Python 屬性、查詢結果是 ORM 物件、參數化由 ORM 處理。
SQLModel 與 SQLAlchemy 的關係
SQLModel 是建立在 SQLAlchemy 2.x 之上的「薄包裝」。SQLAlchemy 是 Python 生態系歷史最久、功能最強的 ORM,從 2005 年發展到現在;SQLModel 則是把 SQLAlchemy 2.0 的新風格 API(基於 Python 型別提示)跟 Pydantic 合併起來。所以學 SQLModel 之後,要回頭用 SQLAlchemy 也不會太吃力,反之亦然。
SQLModel 的兩個核心:
- SQLModel class:繼承自 SQLModel 的類別,既是 SQLAlchemy 的 ORM 模型(對應資料表),又是 Pydantic 的資料模型(用於驗證)。
- Session:SQLAlchemy 的工作單元(unit of work),負責管理一連串資料庫操作的交易(transaction)。
一個簡單的資料模型範例:
# 第一個 SQLModel 範例
from sqlmodel import Field, Session, SQLModel, create_engine, select
# ---------- 定義資料表 ----------
class Book(SQLModel, table=True):
# table=True 表示這是一個資料表(不是單純的 Pydantic 模型)
id: int | None = Field(default=None, primary_key=True)
title: str = Field(min_length=1, max_length=200)
author: str = Field(min_length=1, max_length=80)
year: int = Field(ge=0, le=2100)
# ---------- 建立 SQLite 連線 ----------
sqlite_url = "sqlite:///./books.db"
engine = create_engine(sqlite_url, echo=False)
# echo=True 會印出 SQL 語句;開發時打開有助於除錯
# 建立資料表(如果還沒建立)
SQLModel.metadata.create_all(engine)
# ---------- CRUD 操作 ----------
def create_one() -> Book:
with Session(engine) as session:
book = Book(title="Python 程式設計實戰", author="Hao", year=2024)
session.add(book)
session.commit()
session.refresh(book) # 取得自動產生的 id
return book
def list_all() -> list[Book]:
with Session(engine) as session:
statement = select(Book)
return list(session.exec(statement).all())
# 跑一次
created = create_one()
print(f"建立:id={created.id}, title={created.title}")
# 輸出(範例):建立:id=1, title=Python 程式設計實戰
all_books = list_all()
print(f"目前共 {len(all_books)} 本書")
# 輸出:目前共 1 本書
幾個關鍵寫法:Field(primary_key=True) 標示主鍵;id: int | None = Field(default=None) 讓 SQLAlchemy 在新增時自動產生 id;session.refresh(book) 把資料庫回填的欄位(例如自動產生的 id)同步到物件;select(Book) 是 SQLAlchemy 2.0 的新查詢 API,比舊的 query 更直覺。
SQLite 的連線 URL sqlite:///./books.db 中,三個斜線代表「相對路徑」,第四個斜線之後是檔名。第一次執行後會在專案根目錄產生 books.db 檔案,這就是你的資料庫。
用 FastAPI 整合 SQLModel
把 SQLModel 跟 FastAPI 結合的關鍵是「session 的生命週期」:每個請求開一個 session,請求結束時關閉。這正是昨天介紹的 yield 相依性完美適用的場景:
# 把昨天 FakeDB 換成 SQLModel Session
from collections.abc import Generator
from fastapi import Depends
from sqlmodel import Session, SQLModel, create_engine
DATABASE_URL = "sqlite:///./books.db"
engine = create_engine(DATABASE_URL, echo=False)
def create_db_and_tables():
# 啟動時建立資料表(正式部署會用 Alembic 處理)
SQLModel.metadata.create_all(engine)
def get_session() -> Generator[Session, None, None]:
with Session(engine) as session:
yield session
# 在 FastAPI app 啟動時建立資料表
from fastapi import FastAPI
app = FastAPI(title="書本管理 API", version="0.3.0")
@app.on_event("startup")
def on_startup():
create_db_and_tables()
# 之後的端點就可以這樣寫
from typing import Annotated
from fastapi import HTTPException
from sqlmodel import select
from pydantic import BaseModel, ConfigDict
class Book(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
title: str = Field(min_length=1, max_length=200)
author: str = Field(min_length=1, max_length=80)
year: int = Field(ge=0, le=2100)
class BookCreate(BaseModel):
title: str = Field(min_length=1, max_length=200)
author: str = Field(min_length=1, max_length=80)
year: int = Field(ge=0, le=2100)
class BookPublic(BookCreate):
id: int
model_config = ConfigDict(from_attributes=True)
SessionDep = Annotated[Session, Depends(get_session)]
@app.get("/books", response_model=list[BookPublic])
def list_books(session: SessionDep, offset: int = 0, limit: int = 20):
statement = select(Book).offset(offset).limit(limit)
return session.exec(statement).all()
@app.post("/books", response_model=BookPublic, status_code=201)
def create_book(session: SessionDep, payload: BookCreate):
book = Book(**payload.model_dump())
session.add(book)
session.commit()
session.refresh(book)
return book
@app.get("/books/{book_id}", response_model=BookPublic)
def get_book(session: SessionDep, book_id: int):
book = session.get(Book, book_id)
if book is None:
raise HTTPException(status_code=404, detail="book not found")
return book
這個版本做了三件事:
- 用
on_event("startup")在 FastAPI 啟動時建立資料表(Day 9 會改成 Alembic)。 get_session用yield提供 session,FastAPI 自動管理生命週期。BookPublic從Book的屬性建立回應模型,但因為它是獨立的 class,不會包含 SQLModel 的內部屬性,避免洩漏到 API 回應。
完整實作:書本 API 換成 SQLite
把昨天 FakeDB 版本整個換掉,用真實的 SQLite:
# src/book_api/main.py
# 書本管理 API(SQLModel + SQLite)
from collections.abc import Generator
from typing import Annotated
from fastapi import Depends, FastAPI, HTTPException, Query
from pydantic import BaseModel, ConfigDict, Field
from sqlmodel import Field as SQLField, Session, SQLModel, create_engine, select
DATABASE_URL = "sqlite:///./books.db"
engine = create_engine(DATABASE_URL, echo=False)
# ---------- 資料模型 ----------
class Book(SQLModel, table=True):
id: int | None = SQLField(default=None, primary_key=True)
title: str = SQLField(min_length=1, max_length=200, index=True)
author: str = SQLField(min_length=1, max_length=80, index=True)
year: int = SQLField(ge=0, le=2100)
class BookCreate(BaseModel):
title: str = Field(min_length=1, max_length=200)
author: str = Field(min_length=1, max_length=80)
year: int = Field(ge=0, le=2100)
class BookPublic(BookCreate):
id: int
model_config = ConfigDict(from_attributes=True)
# ---------- 資料庫生命週期 ----------
def create_db_and_tables():
SQLModel.metadata.create_all(engine)
def get_session() -> Generator[Session, None, None]:
with Session(engine) as session:
yield session
SessionDep = Annotated[Session, Depends(get_session)]
# ---------- FastAPI app ----------
app = FastAPI(title="書本管理 API(SQLModel + SQLite)", version="0.3.0")
@app.on_event("startup")
def on_startup():
create_db_and_tables()
# ---------- 端點 ----------
@app.get("/books", response_model=list[BookPublic])
def list_books(
session: SessionDep,
offset: Annotated[int, Query(ge=0)] = 0,
limit: Annotated[int, Query(ge=1, le=100)] = 20,
):
statement = select(Book).offset(offset).limit(limit)
return list(session.exec(statement).all())
@app.get("/books/{book_id}", response_model=BookPublic)
def get_book(session: SessionDep, book_id: int):
book = session.get(Book, book_id)
if book is None:
raise HTTPException(status_code=404, detail="book not found")
return book
@app.post("/books", response_model=BookPublic, status_code=201)
def create_book(session: SessionDep, payload: BookCreate):
book = Book(**payload.model_dump())
session.add(book)
session.commit()
session.refresh(book)
return book
@app.delete("/books/{book_id}", status_code=204)
def delete_book(session: SessionDep, book_id: int):
book = session.get(Book, book_id)
if book is None:
raise HTTPException(status_code=404, detail="book not found")
session.delete(book)
session.commit()
return None
# 啟動:uvicorn book_api.main:app --reload --port 8000
這個版本跟昨天的最大差別:所有 CRUD 都透過 session 物件操作資料庫。session.add() + session.commit() 是「新增並儲存」;session.get(Book, book_id) 是「依主鍵讀取」;session.exec(select(...)) 是「執行查詢」;session.delete(book) + session.commit() 是「刪除並儲存」。整個工作流程圍繞著 session 的「add → commit」與「exec → get」兩個迴圈。
index=True 讓 title 與 author 自動建立索引,後續做 WHERE 查詢時效能會好很多。索引的細節會在 Day 31 切到 PostgreSQL 時進一步討論。
用 httpx 測試資料持久化:
# tests/test_persistence.py
import httpx
BASE = "http://127.0.0.1:8000"
def main():
with httpx.Client(base_url=BASE, timeout=5.0) as client:
# 1. 建立新書
r = client.post(
"/books",
json={"title": "資料庫設計", "author": "Hao", "year": 2025},
)
print("POST /books ->", r.status_code, r.json())
# 輸出(範例):201 {'id': 1, 'title': '資料庫設計', 'author': 'Hao', 'year': 2025}
book_id = r.json()["id"]
# 2. 再建立一本同作者
r = client.post(
"/books",
json={"title": "FastAPI 實戰", "author": "Hao", "year": 2025},
)
second_id = r.json()["id"]
# 3. 讀取所有(應該有兩本)
r = client.get("/books")
print("GET /books ->", r.status_code, f"共 {len(r.json())} 本")
# 輸出(範例):GET /books -> 200 共 2 本
# 4. 讀取單筆
r = client.get(f"/books/{book_id}")
print(f"GET /books/{book_id} ->", r.status_code, r.json()["title"])
# 輸出(範例):GET /books/1 -> 200 資料庫設計
# 5. 刪除一本
r = client.delete(f"/books/{second_id}")
print(f"DELETE /books/{second_id} ->", r.status_code)
# 輸出(範例):DELETE /books/2 -> 204
# 6. 再次列出(剩一本)
r = client.get("/books")
print("GET /books ->", r.status_code, f"剩 {len(r.json())} 本")
# 輸出(範例):GET /books -> 200 剩 1 本
main()
# 重要:重啟 uvicorn 後再次 GET /books,資料依然存在(因為已經寫進 books.db)
這個迴圈展示了「資料持久化」的可貴:你在腳本裡刪除、修改、新增的紀錄都會留在 books.db 裡;即使 uvicorn 重啟,下次再啟動時資料還在。這是「腳本」與「系統」最關鍵的差別。
查詢進階:篩選、排序、聚合
SQLModel 提供完整的查詢 API。實務上常見的需求是「依某個欄位過濾」、「依某個欄位排序」、「計算總數」。下面是一個擴充範例:
# 進階查詢範例
from sqlmodel import func, select
@app.get("/books/search", response_model=list[BookPublic])
def search_books(
session: SessionDep,
keyword: Annotated[str, Query(min_length=1, max_length=200)],
year_from: Annotated[int | None, Query(ge=0)] = None,
year_to: Annotated[int | None, Query(ge=0)] = None,
):
statement = select(Book).where(Book.title.contains(keyword))
if year_from is not None:
statement = statement.where(Book.year >= year_from)
if year_to is not None:
statement = statement.where(Book.year <= year_to)
statement = statement.order_by(Book.year.desc())
return list(session.exec(statement).all())
@app.get("/books/stats")
def stats(session: SessionDep):
# 計算總數與平均年份
count_statement = select(func.count(Book.id))
total = session.exec(count_statement).one()
avg_statement = select(func.avg(Book.year))
avg_year = session.exec(avg_statement).one()
return {"total": total, "avg_year": round(avg_year, 1) if avg_year else None}
# 範例呼叫:
# GET /books/search?keyword=Python&year_from=2020
# GET /books/stats
# 輸出(範例):{"total": 3, "avg_year": 2024.7}
Book.title.contains(keyword) 是 SQL 的 LIKE '%keyword%';func.count、func.avg 是 SQLAlchemy 用來包裝 SQL 函式的方式。session.exec(statement).one() 預期回傳恰好一筆;.first() 接受零到多筆;.all() 接受任意筆數。這三個方法對應不同的查詢結果結構。
常見錯誤與踩雷
第一個常見的踩雷是「忘記 session.commit()」。新增或刪除的資料如果沒有 commit,session 結束時會自動 rollback,資料就沒寫進資料庫。這是新手最常見的「為什麼資料沒寫進去」的原因。對照的測試方式是看 SQLite 檔案大小(新增後檔案會變大)或是用另一支腳本查詢。
第二個是「SQLModel 與 Pydantic 模型分不清楚」。當你看到 class Book(SQLModel, table=True),它既是資料表也是 Pydantic 模型;但 class BookCreate(BaseModel) 只是 Pydantic 模型,不是資料表。在端點函式裡,接收請求用 BookCreate,建立資料庫紀錄用 Book,兩者用 model_dump() 互相轉換。混用的話有時候會把內部屬性(例如 SQLAlchemy 的 state)洩漏到 API 回應。
第三個是「SQLite 對並行的限制」。SQLite 在寫入時會鎖住整個資料庫,這意味著高並行的寫入場景效能會很差。對本機開發、單一使用者、小流量 API 沒問題,但一旦進入正式環境(多個 process、分散式部署),就會碰到 lock 問題。Day 31 會把 SQLite 換成 PostgreSQL 解決這個問題。
效能與實務提醒
SQLite 的快取(page cache)寫入時是同步的,這讓「寫入很慢」的感覺特別明顯。對小專案(每天幾百筆寫入)影響不大,但對高頻寫入的應用會成為瓶頸。如果你想最佳化,可以:開啟 PRAGMA journal_mode=WAL(write-ahead logging)讓讀寫可以並行、調整 cache_size 讓更多資料留在記憶體。Day 31 切換到 PostgreSQL 時這些調校都會消失。
索引(index)是另一個常見的調校工具。Field(index=True) 會讓 SQLAlchemy 在建立資料表時加上 B-tree 索引,大幅加速 WHERE 查詢。但索引不是越多越好——每個索引都會拖慢寫入(每次寫入都要更新索引)。原則是「只對常用查詢的欄位加索引」,例如作者、狀態、類別。對純粹顯示用的欄位(例如標題)通常不需要索引(除非你要做全文搜尋)。
SQLModel 的 session.refresh() 會多打一次 SELECT 查詢把資料庫回填到物件,這在小型物件沒問題。如果你一次建立幾萬筆,可以考慮用 RETURNING 子句(SQLAlchemy 2.0 支援)一次拿回 id,省下一輪查詢。本系列規模不大,不會碰到這個瓶頸。
小結
今天我們正式讓資料「活」起來:用 SQLModel 定義資料模型(兼顧資料表與 Pydantic 驗證)、用 SQLite 做本機儲存(檔案型、不需要額外服務)、用 yield 相依性管理 session 的生命週期。我們把昨天的 FakeDB 整個換掉,建立一個能用 uvicorn 啟動、用 httpx 測試、且資料會真正留在 books.db 的書本 API。SQLite + SQLModel 是 FastAPI 後端的標準起點,本系列專案篇會繼續用這套架構(Web Day 35 之後)。
進階主題:交易、隔離等級與 session 模式
今天前面展示了 session 的基本 CRUD 用法,但真實世界的 API 經常需要更精細的控制:跨多個操作的「交易(transaction)」、要指定隔離等級避免髒讀、或是要做唯讀查詢。這裡示範三個進階用法。
# transactions.py
# SQLModel session 的交易與隔離等級
from sqlmodel import Session, select
def transfer_credits(session: Session, from_user: int, to_user: int, amount: int):
"""把 credits 從一位使用者轉到另一位——典型交易場景"""
# 在 SQLAlchemy 2.0 裡,session.begin() 明確開啟交易
with session.begin():
# 扣 from_user
from_row = session.get(User, from_user)
from_row.credits -= amount
# 加 to_user
to_row = session.get(User, to_user)
to_row.credits += amount
# with 區塊結束時自動 commit;中途拋例外則自動 rollback
這段程式碼展示 SQLAlchemy 2.0 的「明確交易」寫法。session.begin() 開啟一個交易,with 區塊結束時自動 commit();如果中途拋出任何例外,會自動 rollback(),保證「要嘛全部成功、要嘛全部失敗」的原子性。這對「轉帳」、「庫存扣減」、「訂單建立」這類多步驟操作非常重要——你絕對不會希望「錢扣了但對方沒收到」這種半完成狀態。
第二個進階主題是「session 的幾種關閉策略」。預設的 get_session() 在 yield 之後用 session.close(),但有些情境你會希望「自動 commit」或「自動 flush」。
# session_modes.py
# session 的不同使用模式
from sqlmodel import Session
def with_autocommit(engine):
"""用 begin() 明確交易;commit 在 with 結束時自動觸發"""
with Session(engine) as session:
with session.begin():
session.add(User(username="alice", email="alice@example.com"))
# with 結束時自動 commit
def with_explicit_commit(engine):
"""不用 begin(),手動控制 commit/rollback"""
session = Session(engine)
try:
session.add(User(username="bob", email="bob@example.com"))
session.commit()
except Exception:
session.rollback()
raise
finally:
session.close()
def read_only_query(engine):
"""唯讀查詢:使用 begin() 但不修改"""
with Session(engine) as session:
with session.begin():
users = session.exec(select(User)).all()
return users
# 沒有 commit 也可以,因為沒改東西
這三個模式各有適用情境:with_autocommit 適合「簡單的單筆寫入」;with_explicit_commit 適合「需要 try/except 細部控制的複雜邏輯」;read_only_query 適合「純查詢、不改資料」的情境(例如匯出報表)。本系列從明天起會用第一種「自動 commit」寫法,最直覺也最少出錯。
第三個進階主題是「session 的 autocommit=False 與 autoflush」。這是 SQLAlchemy 內部的兩個參數,理解它們能幫你除錯一些奇怪的「為什麼我剛剛新增的資料查不到」問題。
# session_internal.py
# 理解 session 的 autoflush 與 expire_on_commit
from sqlmodel import Session
def demo_autoflush(engine):
"""autoflush:查詢前自動 flush 暫存區的變更"""
with Session(engine, autoflush=True) as session:
session.add(User(username="carol", email="carol@example.com"))
# 還沒 commit;資料還在 session 的「identity map」裡
# 下一個 query 會自動觸發 flush,把 SQL 送到資料庫
count = session.exec(select(User)).all()
# count 裡面已經包含 carol,因為 autoflush 把它送出去了
def demo_expire_on_commit(engine):
"""expire_on_commit:commit 後所有屬性都標為 expired"""
with Session(engine, expire_on_commit=True) as session:
user = session.get(User, 1)
session.commit()
# commit 後 user 的屬性都「過期」了
# 下一行存取 user.username 會重新查資料庫
print(user.username) # 觸發 SELECT
autoflush=True 是預設值,這代表「任何查詢執行前都會把還沒送出的 INSERT/UPDATE/DELETE 送到資料庫」。這聽起來很合理,但偶爾會造成意外的行為——例如你新增一筆資料後,想先查「有沒有這筆資料」判斷是不是重複新增,autoflush 會讓你查到「剛剛自己加的」,而不是「資料庫裡實際有的」。遇到這種情境可以暫時設 autoflush=False。
expire_on_commit=True 也是預設值,這代表 commit 後所有物件的屬性都會被「過期」,下次存取時 SQLAlchemy 會重新從資料庫讀取最新值。這保證了「commit 後的物件不會殘留舊資料」,但偶爾會讓你想「我剛剛已經 commit 了,為什麼這行讀 user.username 又打了一次 SELECT?」——這是正常的,因為物件已過期。
理解這些內部機制,能讓你在除錯「為什麼資料庫狀態跟我預期的不一樣」時有更多線索。本系列規模不大,預設值通常就夠用;但對進階應用,這些旋鈕值得知道。
SQLite 的限制與替代方案
SQLite 對開發階段非常友善(零設定、檔案型、Python 內建),但對正式環境有一些明顯的限制。理解這些限制能幫你判斷什麼時候該升級到 PostgreSQL。
# sqlite_limits.py
# 展示 SQLite 的限制與替代方案
import sqlite3
def demo_concurrent_writes():
"""SQLite 的寫入鎖:同時間只有一個寫入可以進行"""
# 建立兩個連線,嘗試同時寫入
conn1 = sqlite3.connect("books.db")
conn2 = sqlite3.connect("books.db")
cur1 = conn1.cursor()
cur1.execute("BEGIN IMMEDIATE") # 取得寫入鎖
# 這時候 conn2 想寫入會被鎖住
# ... 實際應用會卡在這裡 ...
cur1.execute("INSERT INTO books (title, author, year) VALUES (?, ?, ?)",
("書1", "Hao", 2024))
conn1.commit()
cur2 = conn2.cursor()
cur2.execute("INSERT INTO books (title, author, year) VALUES (?, ?, ?)",
("書2", "Wei", 2025))
# conn2 的寫入現在才能執行
conn2.commit()
conn1.close()
conn2.close()
def demo_wal_mode():
"""開啟 WAL 模式,讓讀寫可以並行"""
conn = sqlite3.connect("books.db")
conn.execute("PRAGMA journal_mode=WAL")
# WAL 模式下,讀寫可以並行;只有寫寫會互相阻塞
conn.close()
SQLite 最知名的限制是「單一寫入鎖」:當一個 process 在寫入時,其他 process 的寫入會被阻塞。對小流量(每秒幾次寫入)的應用沒問題,但對高流量(每秒幾百次寫入)會成為瓶頸。WAL 模式 能部分緩解——讓「讀與寫」可以並行,但「寫與寫」仍然互斥。對真正的分散式高流量場景,還是要升級到 PostgreSQL 或 MySQL。
SQLite 的另一個限制是「類型系統較弱」。SQLite 對欄位型別的強制力比 PostgreSQL 鬆很多——例如 TEXT 欄位可以接受數字、INTEGER 可以接受字串(會自動轉型)。這讓開發很方便,但對需要嚴格資料一致性的應用(例如金融、醫療)會成為問題。Day 31 升級到 PostgreSQL 時,這類限制都會消失。
最後,SQLite 不適合「大量並行的網路應用」。如果你打算把後端部署到多個 process(例如 Gunicorn 開 4 個 worker,每個 worker 同時接請求),SQLite 的單一寫入鎖會讓整個系統的吞吐量受限。PostgreSQL 的連線池(connection pool)能讓多個 process 同時寫入,這是 Day 31 升級的最重要理由之一。
結語
今天我們從「記憶體內的字典」走到「真正存進資料庫的紀錄」,資料持久化的問題已經解決了。明天,我們要把這個 CRUD 做得更完整:進階篩選(多條件組合)、排序(多欄位)、分頁(cursor-based)、大量資料的批次操作。學完這些,你就具備寫一個「資料量較大但仍能快速回應」的後端所需的所有工具,後續 Day 8 的關聯設計就是站在這個基礎上。
延伸資源
- SQLModel 官方教學(2025):
https://sqlmodel.tiangolo.com/ - SQLite 官方介紹(2025):
https://www.sqlite.org/docs.html - SQLAlchemy 2.0 新風格(2024):
https://docs.sqlalchemy.org/en/20/orm/queryguide/select.html - Real Python:SQLModel 介紹(2025):
https://realpython.com/sqlmodel-python/ - SQLite WAL 模式(2025):
https://www.sqlite.org/wal.html
留言
張貼留言