跳到主要內容

Web Day 6 資料庫入門:SQLModel 與 SQLite

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

這個版本做了三件事:

  1. 用 on_event("startup") 在 FastAPI 啟動時建立資料表(Day 9 會改成 Alembic)。
  2. get_session 用 yield 提供 session,FastAPI 自動管理生命週期。
  3. 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

留言

這個網誌中的熱門文章

Day 2 變數與資料型別

Day 2 變數與資料型別 引言 寫程式的過程中,變數與資料型別是處理資料的基礎。變數是存放資料的容器,資料型別則決定這筆資料有哪些特性、可以進行哪些操作。學會定義變數、認識各種資料型別,是學好 Python 的關鍵一步。 這篇文章會帶你了解 Python 中變數的觀念、如何定義變數,以及常見的資料型別,包括整數、浮點數、字串、布林值,還有串列、元組、字典與集合等容器型別。我們也會介紹變數的命名規則與撰寫風格建議,以及如何用 type() 檢查資料型別。 什麼是變數?如何在 Python 中定義變數 變數是在程式執行時用來存放資料的名稱。透過定義變數,我們可以給一筆資料一個名字,並在程式的其他地方用這個名字取用該筆資料。在 Python 中,變數不需要事先宣告型別,因為 Python 是動態型別語言,變數的型別由指定給它的值決定。 定義變數的基本語法 在 Python 中定義變數非常簡單,只要用賦值符號 = 把值指定給變數即可。例如: x = 5 # 定義變數 x,並把整數 5 賦值給它 name = "Alice" # 定義變數 name,並把字串 "Alice" 賦值給它 在這裡,x 是一個變數,被賦予整數 5;name 是另一個變數,被賦予字串 "Alice"。 變數的更新與覆寫 變數的值可以修改,也就是說,我們可以在程式的不同地方給同一個變數新的值。例如: x = 10 # x 最初被賦予 10 x = 15 # x 的值現在被更新為 15 這樣就能依照需求,在程式執行過程中靈活調整變數的值。 Python 的動態型別系統 Python 和某些靜態型別語言不同,定義變數時不需要宣告型別。賦值時,Python 會根據值自動判斷變數的型別。例如: x = 5 # x 是整數 x = 3.14 # x 變成浮點數 x = "Hi" # x 變成字串 同一個變數在程式執行過程中可以存放不同型別的值,這是 Python 的彈性之一。 常見資料型別 在 Python 中,資料型別決定我們可以對變數進行哪些操作...

Day 1 Python 簡介與環境設定

Day 1 Python 簡介與環境設定 引言 在現在的科技環境裡,程式設計已經是一項重要技能。無論你是對資料科學有興趣、想成為開發者,或是想踏入人工智慧(AI)領域,學會寫程式都能明顯提升你的競爭力。在眾多程式語言中,Python 因為語法簡單、功能強大、應用範圍廣泛,成為許多人進入程式世界的第一選擇。這篇文章會帶你認識 Python 的背景與優勢,並一步步教你在不同系統上安裝與設定 Python 開發環境,最後寫出第一支 Python 程式。 為什麼選擇 Python? Python 是一種高階程式語言,由 Guido van Rossum 在 1991 年發布。Python 的設計哲學強調程式碼的可讀性,並用縮排來定義程式區塊,這點和許多使用大括號的語言不同。簡潔的語法讓它成為初學者的理想選擇;就算是經驗豐富的開發者,也能用它完成複雜的專案。 Python 的優勢如下: 簡單易學 :Python 的語法清楚、結構簡潔,初學者很快就能上手。和其他語言相比,學習曲線相對平緩,不需要先弄懂一堆複雜觀念,就能開始寫程式。 應用範圍廣泛 :從資料科學、網頁開發、人工智慧、機器學習、自動化測試到網路爬蟲,Python 都有大量開源函式庫與工具支援,而且在這些領域都扮演關鍵角色。 豐富的函式庫與框架 :Python 的函式庫生態系非常龐大。做資料分析有 NumPy、Pandas;開發網站有 Django、Flask;做深度學習有 TensorFlow、PyTorch。各種需求幾乎都能找到對應的套件,讓開發更有效率。 跨平台支援 :Python 支援 Windows、macOS、Linux 等作業系統,程式通常不需要太多修改就能跨平台執行,讓開發與部署更有彈性。 活躍的社群 :Python 擁有龐大的開發者社群。學習或開發上遇到問題,幾乎都能在社群與論壇(例如 Stack Overflow)找到答案,對初學者來說是很強的後盾,也能減少卡關時的挫折感。 Python 的應用領域 Python 的流行與強大功能,讓許多領域都開始大量使用它。以下是幾個常見的應用方向: 資料科學 :隨著大數據與人工智慧興起,資料科學大量使用 Python。NumPy、Pandas 與 Matplotlib 等工具能處理和分析龐...

Python 從入門到 PyTorch 深度學習:開啟 AI 世界的大門

Python 從入門到 PyTorch 深度學習:開啟 AI 世界的大門 隨著人工智慧(AI)與深度學習(Deep Learning)快速發展,越來越多人對這些技術產生興趣。不論你是想踏入 AI 領域的初學者,還是已經有程式基礎的開發者,學好 Python 與深度學習框架(例如 PyTorch),都能為你打開更多可能。 為什麼選擇 Python? Python 已經是資料科學與人工智慧領域的首選語言。它的語法簡潔、容易上手,而且擁有龐大的生態系與大量開源函式庫。無論是資料處理、資料視覺化,還是建立機器學習與深度學習模型,Python 都能勝任。對想進入 AI 或資料科學領域的人來說,它幾乎是必備工具。 PyTorch 是什麼? PyTorch 是由 Meta(原 Facebook)AI 研究團隊開發的開源深度學習框架,以易用、靈活和動態計算圖著稱,是許多 AI 研究人員與開發者的首選。相較於其他框架,PyTorch 的寫法更貼近原生 Python,對初學者相對友善。無論是簡單的實驗,還是複雜的深度學習模型,PyTorch 都能提供強大的支援。 這個系列能帶給你什麼? 這個系列會從 Python 的基礎開始,帶你一步一步學習,最後能自己用 PyTorch 建立深度學習模型。即使你完全沒有寫過程式,也能跟著文章的節奏累積技能,理解 AI 與深度學習的核心觀念。 本系列涵蓋的主題 Python 基礎:從變數、條件判斷到函式與模組。 資料處理工具:用 NumPy 與 Pandas 有效率地操作資料。 資料視覺化:用 Matplotlib 與 Seaborn 把資料畫成圖表。 深度學習的數學基礎:線性代數、微積分與機率。 PyTorch 入門:理解張量、模型建構與 GPU 加速。 基礎深度學習模型:CNN 與 RNN 的實作應用。 深度學習專案實戰:從資料前處理到模型部署的端到端流程。 誰適合這個系列? 程式初學者 :如果你對 AI 充滿好奇,卻還沒寫過程式,系列的第一部分會帶你快速上手 Python,並幫助你理解深度學習的基本觀念。 資料科學愛好者 :如果你已經熟悉一些資料處理方法,進階部分會教你如何用 PyTorch 建構深度學習模型。 開發者與研究人員 :想更深入了...