欢迎来到第十二期!
在第一期的基础教程中,我们用一个简单的字典来存学生成绩;在第七期,我们用 SQLite 给 FastAPI 项目搭了个持久化后端;在第十期,FinanceFlow 项目的数据库虽然能跑,但你仔细一看——全是手动拼 SQL,数据库切换还得改一堆代码……
**这就像盖房子只打了地基,还没砌墙呢。**
今天这一期,我们要完成 Python 后端开发中最核心的基础设施之一——**数据库层的工程化**。
学完这一期,你将能够:
- 用 **SQLAlchemy ORM** 替代手写 SQL,让 Python 对象直接映射到数据库表
- 用 **Alembic** 管理数据库版本迁移,告别"删库重建"的野蛮操作
- 用 **PostgreSQL** 替代 SQLite,让你的应用能扛住真实流量
- 掌握 **多表关联、外键约束、索引优化** 等数据库进阶技能
- 把 FinanceFlow 的数据层从玩具升级为生产级架构
>**前置知识**:需要熟悉本系列前 11 期的内容,特别是 Episode 07(FastAPI)和 Episode 10(FinanceFlow 项目)。
---
## 12.1 为什么需要 ORM?
### 12.1.1 手写 SQL 的痛苦
先看一段典型的「不写 ORM」的代码。假设我们要做一个用户注册功能:
```python
# ❌ 糟糕的做法:到处散落着字符串拼接的 SQL
import sqlite3
defcreate_user(username, email, password_hash):
conn = sqlite3.connect("app.db")
cursor = conn.cursor()
cursor.execute(
f"INSERT INTO users (username, email, password_hash) "
f"VALUES ('{username}', '{email}', '{password_hash}')"
)
conn.commit()
conn.close()
defget_user_by_username(username):
conn = sqlite3.connect("app.db")
cursor = conn.cursor()
# ⚠️ SQL 注入攻击风险!用户名里带单引号就挂了
cursor.execute(f"SELECT * FROM users WHERE username = '{username}'")
user = cursor.fetchone()
conn.close()
return user
```
这种写法有几个致命问题:
1.**SQL 注入**:上面的 `f-string` 拼接是经典漏洞。`username = "admin' OR '1'='1"` 就能直接绕过登录。
2.**代码混乱**:SQL 散落在各个函数里,改一个表结构要找到所有引用它的手动查询。
3.**无法复用连接**:每个函数都新建连接,没有连接池概念。
4.**数据库锁定**:SQLite 是文件锁机制,并发写入会直接报错 "database is locked"。
### 12.1.2 ORM 的思路
ORM(Object-Relational Mapping,对象关系映射)的核心思想很简单:**用操作 Python 对象的方式来操作数据库,而不是写 SQL 语句。**
```python
# ✅ 使用 SQLAlchemy ORM
from app import db, User
# 创建用户——注意,这不是 INSERT 语句,而是创建了一个 Python 对象
new_user = User(username="alice", email="alice@example.com", password_hash="hashed_secret")
db.session.add(new_user) # 标记"准备插入"
db.session.commit() # 提交事务,实际执行 INSERT
# 查询用户——不是 SELECT,而是 filter_by
user = db.session.query(User).filter_by(username="alice").first()
print(user.email) # alice@example.com
```
你看不到任何 SQL 字符串,但底层 SQLAlchemy 会自动帮你翻译成 `INSERT` / `SELECT` / `UPDATE` / `DELETE`。
ORM 的三大核心价值:
| 价值 | 说明 |
|------|------|
| **安全性** | 参数化查询自动防止 SQL 注入 |
| **可维护性** | 表结构定义集中管理,改一处全项目生效 |
| **可移植性** | 从 SQLite 切到 PostgreSQL 只需改一行配置 |
---
## 12.2 SQLAlchemy 基础
### 12.2.1 安装与环境准备
```bash
pipinstallsqlalchemy"psycopg2-binary"alembic
```
| 包名 | 用途 |
|------|------|
| `sqlalchemy` | ORM 核心框架 |
| `psycopg2-binary` | PostgreSQL 驱动(开发环境用 binary 版) |
| `alembic` | 数据库迁移工具 |
### 12.2.2 初始化和配置
先创建数据库连接配置模块:
```python
# app/database.py
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, DeclarativeBase
from sqlalchemy.pool import StaticPool
import os
# 数据库 URL,优先读环境变量,默认用 SQLite
DATABASE_URL = os.getenv(
"DATABASE_URL",
"sqlite:///./app.db"# 开发环境默认 SQLite
)
# 创建引擎
# echo=True 会在控制台打印执行的 SQL(开发时很有用)
engine = create_engine(
DATABASE_URL,
echo=False, # 生产环境关闭 SQL 日志
connect_args={"check_same_thread": False} if"sqlite"inDATABASE_URLelse {},
)
# 会话工厂——每次请求创建一个新会话
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
# Base 类——所有模型都要继承它
classBase(DeclarativeBase):
pass
# 依赖注入:给 FastAPI 用的获取会话函数
defget_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
```
关键点:
-**`create_engine`**:创建数据库引擎,它是 ORM 与数据库之间的桥梁。
-**`sessionmaker`**:会话工厂,类比于"数据库连接的管理器"。
-**`DeclarativeBase`**:声明式基类,所有模型通过继承它来声明自己的表结构。
-**`connect_args`**:SQLite 的特有参数,解决多线程下 "database is locked" 的问题。
### 12.2.3 定义第一个模型
```python
# app/models/user.py
from datetime import datetime
from sqlalchemy import String, Integer, DateTime, Boolean
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
classUser(Base):
__tablename__ = "users"# 数据库中的表名
# ===== 字段定义 =====
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
username: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True)
email: Mapped[str] = mapped_column(String(120), unique=True, nullable=False)
password_hash: Mapped[str] = mapped_column(String(256), nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# ===== 关系定义 =====
# 一个用户有多个交易记录(一对多)
transactions = relationship("Transaction", back_populates="owner", cascade="all, delete-orphan")
def__repr__(self):
returnf"<User {self.username}>"
```
逐行拆解:
-**`Mapped[T]`**:类型提示语法,告诉 SQLAlchemy 这个字段是什么类型。
-**`mapped_column`**:定义列的属性(主键、唯一、非空、索引等)。
-**`index=True`**:在 username 字段上创建索引,加速 `WHERE username = ?` 查询。
-**`relationship`**:定义模型之间的关系。`cascade="all, delete-orphan"` 表示删除用户时自动删除其所有交易记录。
-**`onupdate=datetime.utcnow`**:字段值更新时自动修改时间戳。
### 12.2.4 多表关系建模
以 FinanceFlow 为例,我们有用户、交易、分类三个实体:
```python
# app/models/transaction.py
from sqlalchemy import String, Float, DateTime, ForeignKey, Enum as SAEnum
from sqlalchemy.orm import Mapped, mapped_column, relationship
import enum
import uuid
from app.database import Base
classTransactionType(str, enum.Enum):
INCOME = "income"
EXPENSE = "expense"
classTransaction(Base):
__tablename__ = "transactions"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
amount: Mapped[float] = mapped_column(Float, nullable=False)
type: Mapped[TransactionType] = mapped_column(SAEnum(TransactionType), nullable=False)
description: Mapped[str] = mapped_column(String(256), nullable=True)
date: Mapped[str] = mapped_column(String(10), nullable=False, default=lambda: datetime.utcnow().strftime("%Y-%m-%d"))
# 外键——关联到 users 表
owner_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False, index=True)
category_id: Mapped[int] = mapped_column(Integer, ForeignKey("categories.id"), nullable=True)
# 关系
owner = relationship("User", back_populates="transactions")
category = relationship("Category", back_populates="transactions")
def__repr__(self):
returnf"<Transaction {self.type.value} ${self.amount}>"
# app/models/category.py
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
classCategory(Base):
__tablename__ = "categories"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(50), nullable=False)
type: Mapped[TransactionType] = mapped_column(SAEnum(TransactionType), nullable=False)
icon: Mapped[str] = mapped_column(String(10), default="📁")
# 关系
transactions = relationship("Transaction", back_populates="category")
def__repr__(self):
returnf"<Category {self.icon}{self.name}>"
```
关系总览:
```
User (1) ───< Transaction (>1) ──> Category (1)
一个用户可以有多条交易
一条交易属于一个用户和一个分类
一个分类可以包含多条交易
```
这就是典型的 **1:N(一对多)** 关系。
### 12.2.5 创建表与基础 CRUD
```python
# app/crud/users.py
from sqlalchemy import select
from app.database import SessionLocal, Base, engine
from app.models.user import User
# 第一步:创建所有表(相当于 MySQL 的 CREATE TABLE ...)
Base.metadata.create_all(bind=engine)
```
```python
# app/crud/users.py —— CRUD 操作
from sqlalchemy import select, func
from datetime import datetime
from app.database import SessionLocal
from app.models.user import User
defcreate_user(username: str, email: str, password_hash: str) -> User:
"""创建新用户"""
db = SessionLocal()
try:
user = User(
username=username,
email=email,
password_hash=password_hash
)
db.add(user)
db.commit()
db.refresh(user) # 刷新:获取数据库中生成的 id、created_at 等字段
return user
exceptExceptionas e:
db.rollback() # 出错回滚
raise e
finally:
db.close()
defget_user_by_id(user_id: int) -> User | None:
"""通过 ID 查询用户"""
db = SessionLocal()
try:
return db.query(User).filter(User.id == user_id).first()
finally:
db.close()
defget_user_by_username(username: str) -> User | None:
"""通过用户名查询"""
db = SessionLocal()
try:
return db.query(User).filter(User.username == username).first()
finally:
db.close()
deflist_users(skip: int = 0, limit: int = 100) -> list[User]:
"""分页列出用户"""
db = SessionLocal()
try:
return db.query(User).offset(skip).limit(limit).all()
finally:
db.close()
defupdate_user_email(user_id: int, new_email: str) -> User | None:
"""更新用户邮箱"""
db = SessionLocal()
try:
user = db.query(User).filter(User.id == user_id).first()
ifnot user:
returnNone
user.email = new_email
user.updated_at = datetime.utcnow()
db.commit()
db.refresh(user)
return user
finally:
db.close()
defdelete_user(user_id: int) -> bool:
"""删除用户"""
db = SessionLocal()
try:
user = db.query(User).filter(User.id == user_id).first()
ifnot user:
returnFalse
db.delete(user)
db.commit()
returnTrue
finally:
db.close()
```
```python
# 测试一下
from app.crud.users import create_user, get_user_by_username
# 创建
alice = create_user("alice", "alice@example.com", "hashed_pwd_123")
print(alice) # <User alice>
print(alice.id) # 1
print(alice.created_at) # 2026-07-09 06:00:00
# 查询
user = get_user_by_username("alice")
print(user.email) # alice@example.com
```
对应的 SQL:
| ORM 操作 | 生成的 SQL |
|----------|-----------|
| `create_user()` | `INSERT INTO users (username, email, password_hash, ...) VALUES (?, ?, ?, ...)` |
| `get_user_by_id(1)` | `SELECT * FROM users WHERE id = ? LIMIT ?` |
| `list_users(skip=0, limit=100)` | `SELECT * FROM users OFFSET 0 LIMIT 100` |
| `update_user_email(1, "new@email")` | `UPDATE users SET email=?, updated_at=? WHERE id=?` |
| `delete_user(1)` | `DELETE FROM users WHERE id=?` |
你不需要写任何 SQL,SQLAlchemy 自动生成安全参数化的查询。
---
## 12.3 SQLAlchemy 高级用法
### 12.3.1 复杂查询
```python
from sqlalchemy import select, func, and_, or_
from app.models.user import User
from app.models.transaction import Transaction, TransactionType
from app.models.category import Category
db = SessionLocal()
# 查询 1:找出所有消费超过 1000 元的用户
stmt = (
select(User)
.join(Transaction, User.id == Transaction.owner_id) # JOIN
.where(Transaction.type == TransactionType.EXPENSE) # WHERE
.group_by(User.id) # GROUP BY
.having(func.sum(Transaction.amount) > 1000) # HAVING
)
high_spenders = db.execute(stmt).scalars().all()
# 查询 2:用户列表 + 交易统计(子查询)
subquery = (
select(
Transaction.owner_id.label("user_id"),
func.count(Transaction.id).label("tx_count"),
func.sum(Transaction.amount).label("total_amount")
)
.group_by(Transaction.owner_id)
.subquery()
)
stmt = (
select(User.username, User.email, subquery.c.tx_count, subquery.c.total_amount)
.outerjoin(subquery, User.id == subquery.c.user_id) # LEFT OUTER JOIN
)
results = db.execute(stmt).all()
for row in results:
print(f"{row.username}: {row.tx_count} 笔交易,合计 ¥{row.total_amount or0:.2f}")
# 查询 3:模糊搜索 + 排序 + 分页
keyword = "%花呗%"
stmt = (
select(Transaction)
.where(Transaction.description.like(keyword))
.order_by(Transaction.date.desc()) # 按日期倒序
.limit(20)
.offset(0)
)
search_results = db.execute(stmt).scalars().all()
```
### 12.3.2 事务处理
```python
from app.models.user import User
from app.models.transaction import Transaction
db = SessionLocal()
try:
# 转账操作:扣 A 的钱,加 B 的钱,要么全部成功,要么全部失败
sender = db.query(User).filter(User.username == "alice").first()
receiver = db.query(User).filter(User.username == "bob").first()
# 创建转账记录
transfer = Transaction(
amount=100.0,
type=TransactionType.EXPENSE,
description="转账给 bob"
)
sender.transactions.append(transfer)
income = Transaction(
amount=100.0,
type=TransactionType.INCOME,
description="收到 alice 的转账"
)
receiver.transactions.append(income)
# 提交事务——如果中间任何一步出错,全部回滚
db.commit()
print("转账成功!")
exceptExceptionas e:
db.rollback() # 回滚到转账前的状态
print(f"转账失败:{e}")
finally:
db.close()
```
### 12.3.3 连接 PostgreSQL
```python
# 将 DATABASE_URL 改为 PostgreSQL:
import os
os.environ["DATABASE_URL"] = "postgresql+psycopg2://user:password@localhost:5432/financeflow"
# 或者在 docker-compose.yml 中传递环境变量:
# environment:
# - DATABASE_URL=postgresql+psycopg2://postgres:mysecretpassword@db:5432/financeflow
```
只需要改一行配置,从 SQLite 切换到 PostgreSQL,**所有 ORM 代码无需改动**。这就是 ORM 的可移植性优势。
---
## 12.4 Alembic:数据库版本管理
### 12.4.1 为什么需要迁移?
想象这个场景:
```
你上线了 v1.0 版本,数据库表如下:
CREATE TABLE users (
id INTEGER PRIMARY KEY,
username VARCHAR(50),
email VARCHAR(120)
);
后来,你想给用户加上"头像 URL"字段:
ALTER TABLE users ADD COLUMN avatar_url TEXT;
```
手动执行 `ALTER TABLE` 没问题,但当你换了台新服务器、或者有同事拉了最新代码时——**他们本地并没有这张 `avatar_url` 列**。
如果你下次又加了"最后登录时间",再手动执行 `ALTER`,第三次手动执行 `ALTER`……最终你的团队会有不同的人在不同的数据库上执行不同的 `ALTER` 语句——混乱不可避免。
**这就是 Alembic 解决的问题:像 Git 管代码一样,用版本控制的方式管数据库表结构。**
### 12.4.2 初始化 Alembic
```bash
# 在项目根目录执行
alembicinitalembic
# 项目结构变成这样:
financeflow/
├──app/
│├──database.py# SQLAlchemy 配置
│└──models/# 所有模型
├──alembic/
│├──env.py# Alembic 运行配置
│└──versions/# 迁移脚本存放处
│└──.gitkeep
├──alembic.ini# Alembic 全局配置
└──requirements.txt
```
编辑 `alembic.ini`:
```ini
# 找到 sqlalchemy.url 这行,改为你的数据库 URL
sqlalchemy.url = postgresql+psycopg2://postgres:mysecretpassword@localhost:5432/financeflow
```
编辑 `alembic/env.py`:
```python
# alembic/env.py
import sys
from pathlib import Path
from logging.config import fileConfig
from sqlalchemy import engine_from_config, pool
from alembic import context
# 确保能导入 app 模块
sys.path.insert(0, str(Path(__file__).parent.parent))
config = context.config
if config.config_file_name isnotNone:
fileConfig(config.config_file_name)
# 关键:导入所有模型,让 Alembic 能检测到表结构
from app.database import Base
from app.models.user import User
from app.models.transaction import Transaction
from app.models.category import Category
target_metadata = Base.metadata
defrun_migrations_offline():
url = config.get_main_option("sqlalchemy.url")
context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
with context.begin_transaction():
context.run_migrations()
defrun_migrations_online():
connectable = engine_from_config(
config.get_section(config.config_ini_section),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
```
### 12.4.3 自动生成迁移脚本
```bash
# 第一步:检查当前模型有哪些变更
alembiccheck
# 第二步:自动生成迁移脚本
alembicrevision--autogenerate-m"初始迁移,创建所有表"
# 你会看到 alembic/versions/ 目录下多了一个文件:
# 0001_initial_migration.py
# 内容示例:
"""初始迁移,创建所有表
Revision ID: abc123
Revises:
Create Date: 2026-07-09 10:00:00.000000
"""
fromalembicimportop
importsqlalchemyassa
defupgrade():
op.create_table('users',
sa.Column('id',sa.Integer(),primary_key=True,autoincrement=True),
sa.Column('username',sa.String(length=50),nullable=False),
sa.Column('email',sa.String(length=120),nullable=False),
sa.Column('password_hash',sa.String(length=256),nullable=False),
sa.Column('is_active',sa.Boolean(),server_default=sa.text('true')),
sa.Column('created_at',sa.DateTime(),server_default=sa.func.now()),
sa.UniqueConstraint('username'),
sa.UniqueConstraint('email'),
)
# ... 其他表的创建语句
defdowngrade():
op.drop_table('users')
# ... 其他表的删除语句(反向操作)
```
### 12.4.4 执行迁移
```bash
# 执行最新的迁移
alembicupgradehead
# 查看当前版本
alembiccurrent
# 查看迁移历史
alembichistory--verbose
# 回滚到上一个版本
alembicdowngrade-1
# 回滚到指定版本
alembicdowngradeabc123
# 回滚到初始状态(删库级别,慎用!)
alembicdowngradebase
```
### 12.4.5 实际工作流
```
日常工作流示意:
第 1 天:创建 models → 生成迁移 → 执行迁移 → 上线 v1.0 ✅
第 5 天:给用户表加 "phone" 字段 → 重新生成迁移 → 执行迁移 → 上线 v1.1 ✅
第 10 天:新同事拉代码 → 运行 "alembic upgrade head" → 自动拿到最新版表结构 ✅
```
---
## 12.5 PostgreSQL 实战
### 12.5.1 为什么选 PostgreSQL?
| 特性 | SQLite | PostgreSQL |
|------|--------|------------|
| 并发写入 | 差(文件锁) | 优秀(MVCC) |
| 数据量 | 适合小项目 | TB 级没压力 |
| 高级类型 | JSON、数组 | ✅ JSONB、数组、几何、GIS |
| 全文检索 | 无 | ✅ tsvector |
| 权限控制 | 粗粒度 | ✅ 行级安全(RLS) |
| 适用场景 | 开发/小型个人项目 | 生产环境 |
对于个人项目和原型验证,SQLite 完全够用。但一旦你要面对**多用户同时写入**、**日活上千**、**数据量过万**,PostgreSQL 是最佳选择。
### 12.5.2 本地启动 PostgreSQL
```bash
# 方式 1:用 Docker 启动(推荐)
dockerrun-d\
--name pg-financeflow \
-ePOSTGRES_PASSWORD=mysecretpassword\
-e POSTGRES_DB=financeflow \
-p5432:5432\
postgres:16-alpine
# 方式 2:macOS Homebrew
brewinstallpostgresql
brewservicesstartpostgresql
# 方式 3:Windows - 下载官方安装包
# https://www.postgresql.org/download/windows/
```
连接测试:
```bash
# 命令行连接
psql-Upostgres-dfinanceflow
# 输出:
# psql (16.0)
# Type "help" for help.
#
# financeflow=# SELECT version();
# PostgreSQL 16.x on ...
# financeflow=# \q
```
### 12.5.3 集成到 FastAPI
```python
# app/main.py
from fastapi import FastAPI, Depends, HTTPException
from sqlalchemy.orm import Session
from pydantic import BaseModel, EmailStr
from app.database import engine, Base, get_db
from app.models.user import User
from app.models.transaction import Transaction, TransactionType
from app.models.category import Category
from app.crud.users import create_user, get_user_by_username
# 创建表(开发环境)
Base.metadata.create_all(bind=engine)
app = FastAPI(title="FinanceFlow API", version="2.0.0")
# ===== Pydantic 模型(用于 API 请求/响应验证)=====
classUserCreate(BaseModel):
username: str
email: EmailStr
password: str# 实际应在服务端哈希后存入 password_hash
classTransactionCreate(BaseModel):
amount: float
type: TransactionType
description: str | None = None
category_id: int | None = None
@app.post("/api/users", response_model=UserCreate)
defregister_user(user_data: UserCreate, db: Session = Depends(get_db)):
"""注册新用户"""
existing = get_user_by_username(user_data.username)
if existing:
raise HTTPException(status_code=409, detail="用户名已存在")
# 密码哈希(生产环境用 passlib + bcrypt)
from passlib.context import CryptContext
pwd_ctx = CryptContext(schemes=["bcrypt"], deprecated="auto")
hashed = pwd_ctx.hash(user_data.password)
user = create_user(user_data.username, user_data.email, hashed)
return user
@app.get("/api/users/{user_id}/stats")
defget_user_stats(user_id: int, db: Session = Depends(get_db)):
"""获取用户的财务统计"""
from sqlalchemy import func
user = db.query(User).filter(User.id == user_id).first()
ifnot user:
raise HTTPException(status_code=404, detail="用户不存在")
stats = db.query(
func.sum(Transaction.amount).label("total_expense"),
func.count(Transaction.id).label("tx_count")
).filter(
Transaction.owner_id == user_id,
Transaction.type == TransactionType.EXPENSE
).first()
return {
"user_id": user_id,
"username": user.username,
"total_expenses": float(stats.total_expense or0),
"transaction_count": stats.tx_count or0,
}
```
```bash
# 启动服务
uvicornapp.main:app--reload--host0.0.0.0--port8000
# 测试注册
curl-XPOSThttp://localhost:8000/api/users\
-H "Content-Type: application/json" \
-d'{"username": "alice", "email": "alice@example.com", "password": "secure123"}'
# 返回:
# {"username": "alice", "email": "alice@example.com"}
```
---
## 12.6 实操练习
### 练习 1:从零搭建一个简单的图书管理系统
**题目**:设计 `Book` 和 `Author` 两个模型,实现多对多关系(一个作者可以写多本书,一本书也可以有多个合著者)。
**任务**:
1. 定义 `Book`、`Author` 表和中间关联表(`book_authors`)
2. 编写 `add_author_to_book(book_id, author_id)` 和 `get_books_by_author(author_id)` 函数
3. 编写 Alembic 迁移脚本并执行
<details>
<summary>参考答案</summary>
```python
from sqlalchemy import Table, Column, Integer, String, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
# 多对多中间表(不需要继承 Base,只是纯关联)
book_authors = Table(
"book_authors",
Base.metadata,
Column("book_id", Integer, ForeignKey("books.id"), primary_key=True),
Column("author_id", Integer, ForeignKey("authors.id"), primary_key=True),
)
classAuthor(Base):
__tablename__ = "authors"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(100), nullable=False)
books: Mapped[list["Book"]] = relationship("Book", secondary=book_authors, back_populates="authors")
classBook(Base):
__tablename__ = "books"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
title: Mapped[str] = mapped_column(String(200), nullable=False)
isbn: Mapped[str] = mapped_column(String(13), unique=True)
authors: Mapped[list["Author"]] = relationship("Author", secondary=book_authors, back_populates="books")
```
```python
# CRUD 操作
from sqlalchemy import select
defadd_author_to_book(db: Session, book_id: int, author_id: int):
book = db.query(Book).filter(Book.id == book_id).first()
author = db.query(Author).filter(Author.id == author_id).first()
if book and author:
book.authors.append(author)
db.commit()
defget_books_by_author(db: Session, author_id: int) -> list[Book]:
author = db.query(Author).filter(Author.id == author_id).first()
ifnot author:
return []
return author.books
```
```bash
# 生成并执行迁移
alembicrevision--autogenerate-m"创建书籍和作者表"
alembicupgradehead
```
</details>
---
### 练习 2:性能优化——给慢查询加索引
**题目**:FinanceFlow 的 `/api/transactions` 接口在数据量大时变得很慢。分析原因并优化。
```python
# 原始查询(慢)
@app.get("/api/transactions")
deflist_transactions(
user_id: int,
start_date: str,
end_date: str,
db: Session = Depends(get_db)
):
return db.query(Transaction).filter(
Transaction.owner_id == user_id,
Transaction.date >= start_date,
Transaction.date <= end_date
).order_by(Transaction.date.desc()).all()
```
**任务**:
1. 指出为什么这个查询在万级数据时会慢
2. 在模型中添加复合索引
3. 用 `EXPLAIN ANALYZE` 验证优化效果
<details>
<summary>参考答案</summary>
```python
# 解决方案 1:在模型中给常用查询字段加索引
classTransaction(Base):
__tablename__ = "transactions"
# 已有 owner_id 索引
owner_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), index=True)
# 新增:给 date 也加索引(用于范围查询)
date: Mapped[str] = mapped_column(String(10), index=True)
# 更优方案:加复合索引(需要原生 SQL)
# 可以用 event listener 或手动写 migration:
```
```python
# 在 Alembic 迁移脚本中手动添加复合索引:
defupgrade():
op.create_index(
"ix_transaction_owner_date",
"transactions",
["owner_id", "date"],
unique=False,
)
defdowngrade():
op.drop_index("ix_transaction_owner_date", table_name="transactions")
```
```bash
# 验证:在 psql 中执行 EXPLAIN ANALYZE
EXPLAINANALYZESELECT*FROMtransactions
WHEREowner_id=1ANDdate >='2026-01-01'ANDdate <='2026-12-31';
--优化前:SeqScan(全表扫描),耗时~50ms(1万条数据)
--优化后:IndexScan(索引扫描),耗时~1ms
```
</details>
---
### 练习 3:写一个复杂的聚合查询
**题目**:为每个用户生成分月的支出报告。
**任务**:
1. 用 SQLAlchemy 写出按月分组 + 按类别分组的支出汇总
2. 返回格式如:
```json
{
"month": "2026-06",
"categories": [
{"name": "餐饮", "amount": 1500.00},
{"name": "交通", "amount": 300.00},
{"name": "购物", "amount": 800.00}
],
"total": 2600.00
}
```
<details>
<summary>参考答案</summary>
```python
from sqlalchemy import extract, func
from datetime import datetime
defget_monthly_report(db: Session, user_id: int, year: int, month: int):
# 筛选当月数据
monthly_txns = (
db.query(Transaction, Category.name.label("cat_name"))
.outerjoin(Category, Transaction.category_id == Category.id)
.join(User, Transaction.owner_id == User.id)
.filter(
User.id == user_id,
extract("year", Transaction.date) == year,
extract("month", Transaction.date) == month,
Transaction.type == TransactionType.EXPENSE,
)
.all()
)
# 按类别聚合
cat_totals = {}
total = 0.0
for txn, cat_name in monthly_txns:
key = cat_name or"未分类"
cat_totals[key] = cat_totals.get(key, 0.0) + txn.amount
total += txn.amount
return {
"month": f"{year}-{month:02d}",
"categories": [
{"name": k, "amount": round(v, 2)}
for k, v insorted(cat_totals.items(), key=lambdax: -x[1])
],
"total": round(total, 2),
}
```
</details>
---
### 练习 4:从 SQLite 迁移到 PostgreSQL
**题目**:你有一个用 SQLite 开发的 FinanceFlow 项目,现在要迁移到 PostgreSQL。
**任务**:
1. 列出完整迁移步骤
2. 如果迁移过程中发现 Alembic 迁移丢失了,该怎么办?
<details>
<summary>参考答案</summary>
**完整迁移步骤**:
```bash
# 1. 启动 PostgreSQL 容器
dockerrun-d--namepg-test-ePOSTGRES_PASSWORD=test-ePOSTGRES_DB=ff-p5433:5432postgres:16-alpine
# 2. 导出 SQLite 数据
sqlite3app.db".dump" > backup.sql
# 3. 导入到 PostgreSQL(或用 python 脚本转换)
psql-Upostgres-dff-fbackup.sql
# 注意:SQLite dump 可能需要简单修改语法(如 AUTOINCREMENT → SERIAL)
# 4. 改 DATABASE_URL
exportDATABASE_URL="postgresql+psycopg2://postgres:test@localhost:5433/ff"
# 5. 测试所有 API 端点
# 6. 如果没有 Alembic 迁移记录:
alembicstamphead
# 用 "stamp" 告诉 Alembic "当前数据库已经是最新的了",不用重新生成迁移
```
**如果迁移记录丢失的应急方案**:
```bash
# 方案 A:用 stamp 对齐版本(推荐)
alembicstamphead
# 方案 B:从头生成迁移(会丢失后续迁移)
alembicrevision--autogenerate-m"重新初始化"
alembicupgradehead
```
</details>
---
### 练习 5:设计一个博客系统的数据库模型
**题目**:设计一个简易博客系统的数据模型,包括:
-`User`(用户):id, username, email, bio, created_at
-`Post`(文章):id, title, content, slug(URL 友好标识), author_id, created_at, updated_at
-`Comment`(评论):id, content, post_id, author_id, created_at
-`Tag`(标签):id, name
-`post_tags`(多对多关联表)
**任务**:
1. 用 SQLAlchemy 声明所有模型
2. 设置适当的 `back_populates` / `backref`
3. 给常用查询字段加索引
4. 在 Article.slug 上实现唯一约束,并创建一个自动生成 slug 的方法
<details>
<summary>参考答案</summary>
```python
import re
from sqlalchemy import String, Text, DateTime, ForeignKey, Integer
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
from datetime import datetime
post_tags = Table(
"post_tags",
Base.metadata,
Column("post_id", Integer, ForeignKey("posts.id"), primary_key=True),
Column("tag_id", Integer, ForeignKey("tags.id"), primary_key=True),
)
classUser(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
username: Mapped[str] = mapped_column(String(50), unique=True, index=True)
email: Mapped[str] = mapped_column(String(120), unique=True)
bio: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
posts = relationship("Post", back_populates="author", cascade="all, delete-orphan")
comments = relationship("Comment", back_populates="author")
classPost(Base):
__tablename__ = "posts"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
title: Mapped[str] = mapped_column(String(200), nullable=False)
slug: Mapped[str] = mapped_column(String(200), unique=True, index=True)
content: Mapped[str] = mapped_column(Text, nullable=False)
author_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"))
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
author = relationship("User", back_populates="posts")
comments = relationship("Comment", back_populates="post", cascade="all, delete-orphan")
tags = relationship("Tag", secondary=post_tags, back_populates="posts")
@staticmethod
defgenerate_slug(title: str) -> str:
"""从标题生成 URL 友好的 slug"""
slug = re.sub(r'[^\w\s-]', '', title.lower().strip())
slug = re.sub(r'[\s]+', '-', slug)
return slug
classComment(Base):
__tablename__ = "comments"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
content: Mapped[str] = mapped_column(Text, nullable=False)
post_id: Mapped[int] = mapped_column(Integer, ForeignKey("posts.id"), index=True)
author_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"))
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
post = relationship("Post", back_populates="comments")
author = relationship("User", back_populates="comments")
classTag(Base):
__tablename__ = "tags"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(50), unique=True, index=True)
posts = relationship("Post", secondary=post_tags, back_populates="tags")
```
</details>
---
## 12.7 知识点小结
| 知识点 | 在实际项目中的价值 |
|--------|-------------------|
| SQLAlchemy ORM | 用 Python 对象操作数据库,告别手写 SQL 的隐患 |
| 多表关系(1:N, N:M) | 构建真实业务数据模型的能力 |
| 参数化查询 | 自动防止 SQL 注入 |
| Alembic 迁移 | 像 Git 一样管理数据库版本,团队协作标配 |
| PostgreSQL | 从 SQLite 过渡到生产级数据库的第一选择 |
| 索引优化 | 让慢查询变快的最简手段 |
---
## 12.8 小结与展望
恭喜!你已经掌握了 **Python 数据库开发的完整工程链路**——从 ORM 建模到版本迁移,从本地 SQLite 到生产 PostgreSQL。
这一期补上了你作为一个 Python 开发者最重要的拼图之一。现在你的能力栈是这样的:
```
学习阶段 你得到了什么能力
─────────────────────────────────────────────────
Episode 01-04 写好 Python 代码的基础
Episode 05 抓取外部数据的能力
Episode 06 分析和理解数据的能力
Episode 07 构建后端服务的能力
Episode 08 接入 AI 智能的能力
Episode 09 打造用户界面的能力
Episode 10 整合所有能力的综合能力
Episode 11 将项目部署到世界任何地方的能力
Episode 12 ⭐ 构建健壮数据层的能力——这是从"玩具项目"到"正经产品"的分水岭
```
有了这一章,你的项目不再是"跑在我机器上是好的",而是**在任何环境下都有统一、可追踪的数据结构**。
---
## 12.9 下期预告
接下来的方向将继续深入后端工程化:
-**Episode 13:Celery 异步任务** —— 让耗时操作(邮件发送、文件处理、AI 分析)后台跑,不阻塞用户请求。
-**Episode 14:认证与安全** —— JWT 鉴权、OAuth2、RBAC 权限模型、中间件防护。
-**Episode 15:API 测试与文档** —— pytest + httpx,让每个端点都有自动化测试。
-**Episode 16:缓存策略** —— Redis 缓存、缓存失效模式、分布式缓存入门。
-**Episode 17:GraphQL vs REST** —— 下一代 API 查询语言的实践对比。
无论哪条路,扎实的数据库功底都会让你在后续学习中如鱼得水。下期满载而归!🗄️⚡✨