前端转 AI 100 天 Day 23:FastAPI + 数据库深度整合——事务、迁移、连接池

0 阅读18分钟

前端转 AI 第 23 天:FastAPI + 数据库深度整合 - 事务、迁移、连接池

昨日回顾

Day 22 给 API 加了中间件、异常处理和认证,从"能跑"升级到"能上线"。

但数据库这块还是"糊"的:

  • 每个请求都手动建连接?还是共享一个?
  • 事务边界在哪?出了问题怎么回滚?
  • 加了字段怎么改表?删库重来?
  • 高并发时会怎样?

Day 23 目标:把数据库从"能用"到"用对"——请求级 Session、事务边界、Alembic 迁移、连接池一次讲透。


一、请求级 Session:为什么不能全局共享

1. 先看一个"能跑但危险"的写法

Day 21 里的 deps.py 是这样:

@lru_cache
def get_storage() -> SessionStorageORM:
    return SessionStorageORM(get_db())

这里 lru_cache 让 storage 全局唯一,所有请求共享。

问题来了——

SQLAlchemy 的 Session 不是线程安全的。 多个请求并发时:

# 请求 A: 修改 user.name
user.name = "张三"

# 请求 B: 也修改 user.name
user.name = "李四"

# 请求 A: commit → 提交了什么?

结果是不可预测的——SQLAlchemy 会抱怨"对象不属于当前 Session",或者提交错数据。

2. 正确姿势:每个请求一个 Session

FastAPI 官方推荐的模式:

# deps.py
from sqlalchemy.orm import Session
from ..db_orm import DatabaseORM


# 引擎是全局的(线程安全,管理连接池)
@lru_cache
def get_engine():
    return create_engine(...)


def get_db_session():
    """每个请求一个独立 Session"""
    SessionLocal = sessionmaker(bind=get_engine())
    session = SessionLocal()
    try:
        yield session
        session.commit()
    except Exception:
        session.rollback()
        raise
    finally:
        session.close()

用法:

@app.get("/users")
def list_users(session: Session = Depends(get_db_session)):
    return session.query(User).all()

每个请求:

  1. 拿一个独立的 Session
  2. 用完自动 commit
  3. 出异常自动 rollback
  4. 无论如何都 close

这解决了 3 个问题:

  • 线程安全:每个请求自己的 Session,不共享
  • 事务边界清晰:请求开始 = 事务开始,请求结束 = 事务提交
  • 资源可控:连接在请求结束后归还连接池

3. 为什么用 yield 而不是 return

核心:yield 允许"请求前"和"请求后"都执行代码。

调用 get_db_session()
    ↓
创建 session
    ↓
yield session  ← 交给接口用
    ↓
接口处理完,或抛异常
    ↓
回到 get_db_session
    ↓
commit / rollback / close

用 return 就没这个机会——Session 拿到手后就没机会做提交了。

4. 用 SQLAlchemy 2.0 的写法

SQLAlchemy 2.0 加了 async_sessionmaker,更现代:

from sqlalchemy.ext.asyncio import (
    create_async_engine,
    async_sessionmaker,
    AsyncSession,
)


# 异步引擎
engine = create_async_engine(
    "sqlite+aiosqlite:///data/app.db",
    echo=False,
)

# 异步 Session 工厂
AsyncSessionLocal = async_sessionmaker(
    bind=engine,
    expire_on_commit=False,
)


async def get_async_session():
    async with AsyncSessionLocal() as session:
        try:
            yield session
            await session.commit()
        except Exception:
            await session.rollback()
            raise

用法:

@app.get("/users")
async def list_users(session: AsyncSession = Depends(get_async_session)):
    result = await session.execute(select(User))
    return result.scalars().all()

异步 SQLAlchemy 需要异步驱动:SQLite 用 aiosqlite,PostgreSQL 用 asyncpg,MySQL 用 aiomysql。

pip install aiosqlite

为什么用异步数据库?

  • FastAPI 是异步框架,全链路异步才不会阻塞事件循环
  • 高并发时性能更好(不占用线程池)

代价:

  • 代码都是 await:不习惯会有点烦
  • 一些同步 ORM 特性用不了
  • 调试稍麻烦

建议:

  • 新手/学习:先用同步 Session,简单
  • 生产/高并发:上异步

今天讲同步,Day 30 上生产时再切异步。


二、事务边界:什么时候提交

1. 单个写操作:让 FastAPI 自动管

Day 22 的 get_db_session 已经在"请求结束"自动 commit 了:

@app.post("/users")
def create_user(user: UserIn, session: Session = Depends(get_db_session)):
    session.add(User(**user.dict()))
    return {"ok": True}
    # 出函数时自动 commit

最简单,也最常用。 一个请求 = 一个事务。

2. 多个写操作:手动控制原子性

场景: 创建用户 + 创建默认笔记,必须同时成功或同时失败。

@app.post("/users")
def create_user(user: UserIn, session: Session = Depends(get_db_session)):
    try:
        # 1. 创建用户
        new_user = User(name=user.name)
        session.add(new_user)
        session.flush()   # 拿到 new_user.id(不 commit)

        # 2. 创建默认笔记
        note = Note(user_id=new_user.id, title="欢迎")
        session.add(note)

        # 一起提交(get_db_session 的 commit)
        return {"id": new_user.id}

    except Exception:
        # 出问题就回滚(get_db_session 也会回滚,这里显式一下)
        session.rollback()
        raise

关键 API:

方法作用
session.add(obj)加入 Session(未执行 SQL)
session.flush()执行 SQL,但不提交(拿到 ID 等)
session.commit()提交事务
session.rollback()回滚事务

flush 和 commit 的区别:

  • flush:把 SQL 发到数据库,但在事务里,可以回滚
  • commit:结束事务,不可回滚

flush 的常见用途:拿到自增 ID。

3. 手动管理事务:session.begin()

如果需要精细控制:

@app.post("/transfer")
def transfer(from_id: int, to_id: int, amount: float,
             session: Session = Depends(get_db_session)):

    async with session.begin():   # 显式事务
        # 扣钱
        from_acc = session.get(Account, from_id)
        from_acc.balance -= amount

        # 加钱
        to_acc = session.get(Account, to_id)
        to_acc.balance += amount

        # 出了 with 自动 commit,异常自动 rollback
    return {"ok": True}

async with session.begin() 内的所有操作是原子的。

4. 嵌套事务:savepoint

如果"部分失败不影响整体":

with session.begin():
    session.add(order)

    try:
        with session.begin_nested():   # savepoint
            session.add(payment)
            do_something_risky()
    except Exception:
        # 只回滚 payment,order 保留
        pass

    session.add(log)

begin_nested 创建 savepoint——只回滚这一小段,外层事务不受影响。

JS 类比: 类似 Promise 的 try/catch 局部捕获,外层继续。

5. 事务边界三条原则

记住这三条,比死记 API 有用:

  1. "一个请求一个事务":默认由 get_db_session 管理
  2. "业务逻辑完整执行完再提交":中间报错就 rollback
  3. "不同请求不能共享 Session":Depends 保证这点

三、Alembic:数据库迁移

问题: 加了个字段,怎么改已上线的表?

天真做法:

# 删库重来
Base.metadata.drop_all(engine)
Base.metadata.create_all(engine)

生产环境上这等于"清空所有用户数据"。 😱

专业做法:Alembic——SQLAlchemy 官方的数据库迁移工具。

1. 安装与初始化

pip install alembic
alembic init alembic

生成目录:

alembic/
├── env.py              # 配置文件(需要改)
├── script.py.mako      # 迁移脚本模板
└── versions/           # 迁移脚本存放目录
alembic.ini             # 主配置

2. 配置 alembic.ini

# 数据库 URL(也可以放到 env.py 里动态取)
sqlalchemy.url = sqlite:///data/assistant.db

生产环境不要把真实密码写这里,放到 env.py 里从环境变量读:

# alembic/env.py
import os
config.set_main_option("sqlalchemy.url", os.getenv("DATABASE_URL"))

3. 配置 env.py:告诉 Alembic 你的模型

# alembic/env.py
import sys
from pathlib import Path

# 把 src 加到路径
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))

from ai_assistant.orm_models import Base

# 关键:告诉 Alembic 你的模型基类
target_metadata = Base.metadata

target_metadata 是核心配置——Alembic 靠它比对"模型定义"和"数据库实际"。

4. 工作流

核心流程:

1. 修改 Python 模型(加字段)
2. alembic revision --autogenerate -m "描述"
3. Alembic 生成迁移脚本
4. 人工检查脚本(重要!)
5. alembic upgrade head 应用迁移

5. 举个例子

Step 1:加一个字段

# orm_models.py
class User(Base):
    __tablename__ = "users"

    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str]
    email: Mapped[str] = mapped_column(String(100), default="")   # ← 新增
    created_at: Mapped[datetime] = mapped_column(default=datetime.now)

Step 2:生成迁移

alembic revision --autogenerate -m "add email to users"

输出:

INFO  [alembic.runtime.migration] Context impl SQLiteImpl.
INFO  [alembic.autogenerate.compare] Detected added column 'users.email'
Generating /path/alembic/versions/abc123_add_email_to_users.py ...  done

Step 3:检查生成的脚本

# alembic/versions/abc123_add_email_to_users.py
def upgrade() -> None:
    """应用这次迁移"""
    op.add_column(
        'users',
        sa.Column('email', sa.String(length=100), nullable=False, server_default=''),
    )


def downgrade() -> None:
    """回滚这次迁移"""
    op.drop_column('users', 'email')

一定要人工检查! Alembic 的自动检测有时不准:

  • 删表不会自动生成(防止误操作)
  • 重命名字段会当成"删+加",数据丢了
  • 默认值处理方式要确认

Step 4:应用

alembic upgrade head

输出:

INFO  [alembic.runtime.migration] Running upgrade -> abc123, add email to users

数据库的表结构改了,且没有丢失数据**!

6. 常用命令速查

# 生成迁移(自动比对)
alembic revision --autogenerate -m "描述"

# 生成空迁移(手动写)
alembic revision -m "描述"

# 应用所有未应用的迁移
alembic upgrade head

# 应用一条
alembic upgrade +1

# 回滚一条
alembic downgrade -1

# 回滚到某个版本
alembic downgrade abc123

# 查看当前版本
alembic current

# 查看历史
alembic history --verbose

# 显示某次迁移的 SQL(不实际执行)
alembic upgrade head --sql

7. 生产环境的 Alembic 使用

关键差异:生产环境"先备份,再迁移"。

# 1. 备份数据库
sqlite3 data/assistant.db ".backup data/assistant.db.bak"

# 2. 切换到新版本
alembic upgrade head

# 3. 验证
alembic current

# 4. 出问题了,立即回滚
alembic downgrade -1

重要原则:

  • 迁移脚本一旦提交到 git,就不要再改(别人可能已经 apply 了)
  • downgrade 一定要写好,回滚是保命技能
  • 每次只迁移一步,验证再继续

8. 前端视角:Alembic 就是数据库的 Git

概念GitAlembic
初始仓库git initalembic init
提交git commitalembic revision
提交历史git logalembic history
应用变更git mergealembic upgrade
回滚git revertalembic downgrade
当前状态git statusalembic current

Prisma 类比:

PrismaAlembic
prisma migrate devalembic revision --autogenerate
prisma migrate deployalembic upgrade head
prisma migrate resetalembic downgrade base + upgrade head
schema.prismamodels.py

思路完全一样,只是名字不同。


四、连接池:高并发的关键

先理解"连接池是什么"。

1. 没有连接池会怎样

# 每个请求都新建连接
@app.get("/users")
def list_users():
    conn = sqlite3.connect("data/app.db")   # 建立连接(慢)
    ...
    conn.close()                            # 关闭(慢)

问题:

  • 建立 TCP 连接很慢(毫秒级)
  • 高并发时数据库连接数暴涨(PostgreSQL 默认 100 个,不够用)
  • 频繁建立/关闭消耗资源

解决:连接池——预先建一批连接,需要时借,用完还。

2. SQLAlchemy 的连接池

默认就带连接池:

from sqlalchemy import create_engine

engine = create_engine(
    "postgresql://...",
    pool_size=10,        # 池里保持 10 个连接
    max_overflow=20,     # 高峰期最多再开 20 个
    pool_timeout=30,     # 拿不到连接等 30 秒
    pool_recycle=3600,   # 连接 1 小时后回收(避免被数据库断开)
    pool_pre_ping=True,  # 用前 ping 一下,避免用死连接
)

参数解释:

参数作用建议值
pool_size池内常驻连接数5-20
max_overflow临时扩展的连接数10-30
pool_timeout拿不到连接时的等待秒数30
pool_recycle连接多久回收一次3600(MySQL 8 小时超时要小于)
pool_pre_ping用前 ping 一下True(保命配置)

3. SQLite 的特殊处理

SQLite 的"连接池"意义不大——它是文件数据库,不通过网络连接。

# SQLite 常用配置
engine = create_engine(
    "sqlite:///data/app.db",
    connect_args={"check_same_thread": False},   # 多线程共享
    poolclass=StaticPool,                        # 单连接池
)

但要注意——SQLite 的并发能力很弱:

  • 写操作是串行的:同一时间只能一个进程写
  • 不适合高并发:QPS 上千就崩
  • 生产环境要换 PostgreSQL

什么时候用 SQLite?

  • 本地开发:轻量、方便
  • 单机小应用:个人工具、桌面应用
  • 原型验证:快速试错

什么时候换 PostgreSQL?

  • 多用户并发:真实生产环境
  • 大数据量:百万级以上
  • 复杂查询:JOIN / 聚合多

4. 生产环境切 PostgreSQL

SQLAlchemy 的好处:换数据库改一行。

# SQLite
engine = create_engine("sqlite:///data/app.db")

# PostgreSQL
engine = create_engine("postgresql://user:pass@localhost:5432/dbname")

其他代码全不动。

安装驱动:

pip install psycopg2-binary      # 同步
pip install asyncpg              # 异步

环境变量管理:

# config.py
import os
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///data/app.db")

# 生产环境设置
# export DATABASE_URL=postgresql://user:pass@host:5432/db

开发用 SQLite,生产用 PostgreSQL,配置文件切换。

5. 连接池大小怎么定

经验公式:

连接数 ≈ CPU 核数 × 2 + 磁盘数

经验值:

场景pool_size
单机开发5
中小型生产10-20
大型服务20-50

关键原则:

  • 不要设太大:数据库也有连接上限,撑爆了更难查
  • 监控"池满"日志:经常出现说明要调大
  • 不是越大越好:连接多了锁竞争反而更严重

五、实战:把 API 的数据库层升到生产级

1. 项目结构

cp -r day22/ai-assistant-api-v2 day23/ai-assistant-api-v3
cd day23/ai-assistant-api-v3

新增:

src/ai_assistant/
├── db_orm.py                    # 修改:加连接池配置
├── api/
│   ├── deps.py                  # 修改:请求级 Session
│   └── ...
alembic/                         # ← 新增
alembic.ini                      # ← 新增

2. db_orm.py 升级

"""SQLAlchemy 数据库封装(生产级)"""
import os
from pathlib import Path
from contextlib import contextmanager
from typing import Generator

from sqlalchemy import create_engine, event
from sqlalchemy.engine import Engine
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool

from .orm_models import Base
from .config import config
from .logger import get_logger

logger = get_logger(__name__)


def build_database_url() -> str:
    """根据环境选择数据库"""
    # 优先环境变量
    url = os.getenv("DATABASE_URL")
    if url:
        return url

    # 默认 SQLite
    db_path = config.DATA_DIR / "assistant.db"
    db_path.parent.mkdir(exist_ok=True, parents=True)
    return f"sqlite:///{db_path}"


def create_db_engine() -> Engine:
    """创建引擎(带连接池)"""
    url = build_database_url()

    # SQLite 特殊配置
    if url.startswith("sqlite"):
        engine = create_engine(
            url,
            connect_args={"check_same_thread": False},
            poolclass=StaticPool,
            echo=False,
        )

        # SQLite 优化:开启 WAL 模式(提升并发读能力)
        @event.listens_for(engine, "connect")
        def set_sqlite_pragma(dbapi_conn, connection_record):
            cursor = dbapi_conn.cursor()
            cursor.execute("PRAGMA foreign_keys=ON")
            cursor.execute("PRAGMA journal_mode=WAL")
            cursor.close()

    else:
        # PostgreSQL / MySQL
        engine = create_engine(
            url,
            pool_size=10,
            max_overflow=20,
            pool_timeout=30,
            pool_recycle=3600,
            pool_pre_ping=True,
            echo=False,
        )

    logger.info(f"数据库引擎已创建: {url.split('://')[0]}")
    return engine


class DatabaseORM:
    """数据库封装(单例引擎 + Session 工厂)"""

    def __init__(self):
        self.engine = create_db_engine()
        self.SessionLocal = sessionmaker(
            bind=self.engine,
            expire_on_commit=False,
            autoflush=False,
        )

        # 建表(开发用,生产用 Alembic)
        if os.getenv("AUTO_CREATE_TABLES", "true").lower() == "true":
            Base.metadata.create_all(self.engine)

    @contextmanager
    def session(self) -> Generator[Session, None, None]:
        """上下文管理(自动 commit / rollback / close)"""
        s = self.SessionLocal()
        try:
            yield s
            s.commit()
        except Exception:
            s.rollback()
            logger.exception("数据库事务回滚")
            raise
        finally:
            s.close()

    def close(self):
        self.engine.dispose()


# 全局单例(引擎和连接池)
_db: DatabaseORM | None = None


def get_db() -> DatabaseORM:
    global _db
    if _db is None:
        _db = DatabaseORM()
    return _db

关键改进:

1. SQLite 的 PRAGMA journal_mode=WAL

WAL = Write-Ahead Logging,让"读"和"写"可以并行——不开启的话读会阻塞写。

这是 SQLite 生产化的关键配置。

2. expire_on_commit=False

默认情况下,commit 后对象属性会"过期",下次访问会重新查询。关掉它,提交后对象还能用,避免不必要的查询。

3. autoflush=False

默认 Session 在查询前会自动 flush(把未提交的改动发给数据库)。关掉它,让你完全控制 flush 时机。

4. 环境变量控制建表

AUTO_CREATE_TABLES=true 时才 create_all。 生产环境应该用 Alembic,设为 false。

3. api/deps.py 改造:请求级 Session

"""依赖注入"""
from functools import lru_cache
from typing import Generator

from sqlalchemy.orm import Session

from ..db_orm import get_db, DatabaseORM
from ..storage_orm import SessionStorageORM
from ..llm import MockLLMClient
from ..memory import MemoryManager
from ..config import config


@lru_cache
def get_database() -> DatabaseORM:
    """全局唯一的 DatabaseORM(连接池)"""
    return get_db()


def get_db_session() -> Generator[Session, None, None]:
    """
    请求级 Session

    - 每个请求一个独立 Session
    - 请求结束自动 commit
    - 出异常自动 rollback
    - 无论如何都 close
    """
    db = get_database()
    session = db.SessionLocal()
    try:
        yield session
        session.commit()
    except Exception:
        session.rollback()
        raise
    finally:
        session.close()


def get_storage(
    session: Session = Depends(get_db_session),
) -> SessionStorageORM:
    """存储对象(复用请求级 Session)"""
    return SessionStorageORM(session)


def get_llm() -> MockLLMClient:
    return MockLLMClient()


def get_memory() -> MemoryManager:
    return MemoryManager(
        max_context_tokens=8000,
        recent_window=10,
        summary_threshold=20,
    )


# 别忘导入 Depends
from fastapi import Depends

关键变化:

  • get_database() 全局唯一(管理连接池)
  • get_db_session() 请求级(事务边界)
  • get_storage() 复用请求级 Session

同一个请求里多次 Depends(get_db_session) 会拿到同一个 Session。

4. SessionStorageORM 改造:接收 Session 参数

原来它是 __init__(self, db),现在改成 __init__(self, session):

"""SQLAlchemy 版存储"""
from sqlalchemy.orm import Session
from sqlalchemy import select, func

from .models import Session as SessionDTO, Message
from .orm_models import SessionModel, MessageModel
from .logger import get_logger

logger = get_logger(__name__)


class SessionStorageORM:
    """会话存储 - 复用请求级 Session"""

    def __init__(self, session: Session):
        self.session = session

    def save(self, session_dto: SessionDTO) -> None:
        """保存会话(不 commit,由上层控制)"""
        orm_session = self.session.get(SessionModel, session_dto.session_id)

        if orm_session is None:
            orm_session = SessionModel(
                id=session_dto.session_id,
                title=session_dto.title,
                created_at=session_dto.created_at,
                updated_at=session_dto.updated_at,
            )
            orm_session.set_user_profile(session_dto.user_profile)
            self.session.add(orm_session)
        else:
            orm_session.title = session_dto.title
            orm_session.updated_at = session_dto.updated_at
            orm_session.set_user_profile(session_dto.user_profile)
            orm_session.messages.clear()

        for seq, msg in enumerate(session_dto.messages):
            orm_session.messages.append(MessageModel(
                seq=seq,
                role=msg.role,
                content=msg.content,
                timestamp=msg.timestamp,
            ))

        # 注意:不 commit!由 get_db_session 统一提交
        self.session.flush()

    def load(self, session_id: str) -> SessionDTO | None:
        from sqlalchemy.orm import selectinload

        stmt = (
            select(SessionModel)
            .where(SessionModel.id == session_id)
            .options(selectinload(SessionModel.messages))
        )
        orm_session = self.session.scalars(stmt).first()
        if not orm_session:
            return None
        return self._to_dto(orm_session)

    # ... 其他方法类似,都不 commit

关键改动:

  • save() 里不再 commit:由 get_db_session 统一提交
  • 只 flush:把变更发给数据库,但保留回滚可能

这样"事务边界"就集中在一个地方了——get_db_session 的 yield 之后。

5. 初始化 Alembic

cd day23/ai-assistant-api-v3
alembic init alembic

修改 alembic.ini:

# 留空,用 env.py 动态设置
sqlalchemy.url =

修改 alembic/env.py:

import sys
from pathlib import Path

# 加到路径
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))

from logging.config import fileConfig
from sqlalchemy import engine_from_config, pool
from alembic import context

from ai_assistant.orm_models import Base
from ai_assistant.db_orm import build_database_url

# Alembic 配置对象
config = context.config

# 动态设置 URL(从环境变量或默认配置)
config.set_main_option("sqlalchemy.url", build_database_url())

# 日志配置
if config.config_file_name is not None:
    fileConfig(config.config_file_name)

# 目标元数据(关键!)
target_metadata = Base.metadata


def run_migrations_offline() -> None:
    """离线模式:只生成 SQL,不执行"""
    url = config.get_main_option("sqlalchemy.url")
    context.configure(
        url=url,
        target_metadata=target_metadata,
        literal_binds=True,
        dialect_opts={"paramstyle": "named"},
        render_as_batch=True,   # SQLite 需要
    )

    with context.begin_transaction():
        context.run_migrations()


def run_migrations_online() -> None:
    """在线模式:直接执行"""
    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,
            render_as_batch=True,   # SQLite 需要
        )

        with context.begin_transaction():
            context.run_migrations()


if context.is_offline_mode():
    run_migrations_offline()
else:
    run_migrations_online()

关键点:render_as_batch=True

SQLite 不支持 ALTER TABLE 修改字段,只能"建新表 + 拷数据 + 删旧表"。render_as_batch=True 让 Alembic 自动生成这种"批处理"脚本。

6. 生成第一个迁移

# 确保数据库文件不存在或为空
rm -f data/assistant.db

# 生成初始迁移
alembic revision --autogenerate -m "initial schema"

输出:

INFO  [alembic.autogenerate.compare] Detected added table 'sessions'
INFO  [alembic.autogenerate.compare] Detected added table 'messages'
INFO  [alembic.autogenerate.compare] Detected added index '...' on '[...]'
Generating alembic/versions/xxx_initial_schema.py ... done

检查生成的脚本:

# alembic/versions/xxx_initial_schema.py
def upgrade() -> None:
    op.create_table(
        'sessions',
        sa.Column('id', sa.String(64), primary_key=True),
        sa.Column('title', sa.String(200), nullable=False),
        ...
    )
    op.create_table('messages', ...)
    op.create_index(...)


def downgrade() -> None:
    op.drop_table('messages')
    op.drop_table('sessions')

应用:

alembic upgrade head

数据库建好了。

7. 加字段试试

假设现在要加一个 tags 字段到 sessions:

# orm_models.py
class SessionModel(Base):
    __tablename__ = "sessions"

    id: Mapped[str] = mapped_column(String(64), primary_key=True)
    title: Mapped[str] = mapped_column(String(200), default="新对话")
    tags: Mapped[str] = mapped_column(Text, default="[]")   # ← 新增
    ...

生成迁移:

alembic revision --autogenerate -m "add tags to sessions"

应用:

alembic upgrade head

数据保留,字段加上了。 ✅

8. 加一个迁移脚本到 pyproject

[project.scripts]
ai-assistant = "ai_assistant.main:main"
ai-assistant-api = "ai_assistant.api.run:main"
ai-assistant-migrate = "ai_assistant.db_migrate:main"

db_migrate.py:

"""数据库迁移入口"""
import subprocess
import sys


def main():
    """执行 alembic upgrade head"""
    result = subprocess.run(
        ["alembic", "upgrade", "head"],
        check=False,
    )
    sys.exit(result.returncode)

以后部署时:

ai-assistant-migrate

比记 alembic upgrade head 更友好。

9. 测试

启动服务:

ai-assistant-api

测试并发:

# 用 ab (Apache Benchmark) 或 hey 压测
hey -n 100 -c 10 -m POST \
  -H "Content-Type: application/json" \
  -d '{"message": "测试"}' \
  http://localhost:8000/api/chat

观察日志:

  • 每个请求有独立的 Request ID
  • 数据库操作都成功(没有"对象不属于当前 Session"的错误)
  • 连接池状态正常(没有"pool exhausted")

验证迁移:

# 查看当前版本
alembic current
# abc123 (head)

# 回滚
alembic downgrade -1

# 再前进
alembic upgrade head

10. 复盘:这次升级的价值

之前现在
全局共享 Session请求级 Session
手动 commit / rollbackget_db_session 统一管理
加字段删库重来Alembic 平滑迁移
默认连接配置连接池 + WAL 优化
开发生产同一套环境变量切换

这套配置能直接上生产。 后面 Day 30 上线时不用再改数据库层。


六、前端视角:FastAPI 的事务 vs 前端的状态管理

做过前端的你,可以把"事务"理解成"批量状态更新":

前端概念后端事务
Redux dispatch 一个 action一个 HTTP 请求
reducer 里同步更新多个 statesession 里修改多个对象
要么全改,要么不改事务的原子性
commit 才生效session.commit() 才写库
dispatch 失败回滚session.rollback()

举个前端场景:

// 用户点击"提交订单"
dispatch(createOrder());    // 内部:
// 1. 扣库存
// 2. 创建订单
// 3. 扣积分
// 4. 发通知
// 任何一步失败,前面的都要撤销

后端的事务逻辑完全一样:

with session.begin():
    reduce_stock(product_id)     # 扣库存
    create_order(user_id)         # 创建订单
    deduct_points(user_id)        # 扣积分
    send_notification(user_id)    # 发通知
    # 出 with 时,要么全提交,要么全回滚

前端的"一致性思维"直接迁移到后端。


七、今日踩坑

  1. 忘了 expire_on_commit=False → commit 后访问对象属性会重新查询,性能差
  2. session.add() 后没 commit → 数据没写入,调半天
  3. 多个请求共享 Session → "对象不属于当前 Session"的错
  4. get_db_session 忘了 yield 后 commit → 所有写操作都不生效
  5. Alembic 的 env.py 忘了 target_metadata → autogenerate 生成空脚本
  6. SQLite 的 render_as_batch=True 没设 → 改字段的迁移会失败
  7. 删表迁移没生成 → Alembic 默认不检测删表(防止误操作),要手动写
  8. 重命名字段当删+加 → 数据丢失,迁移要手写
  9. 连接池设太大 → 数据库连接数爆了,要按公式算
  10. SQLite 没开 WAL → 并发读写会互相阻塞,加 PRAGMA journal_mode=WAL
  11. 生产用 create_all → 会偷偷改表结构,要用 Alembic
  12. 迁移脚本提交后又改 → 别人已经 apply 了,冲突无法解决

八、今日总结

今天核心收获三件事:

  1. 请求级 Session 是 FastAPI 数据库的标准姿势:yield + commit/rollback,事务边界清晰
  2. Alembic 是数据库的 Git:revision 生成、upgrade 应用、downgrade 回滚,改字段不用删库
  3. 连接池 + WAL + 环境变量:一套配置支持 SQLite 开发和 PostgreSQL 生产

一个感受:

"能跑"和"用对"之间隔着一条鸿沟。

  • 前者只看结果
  • 后者要考虑并发、资源、边界、回滚、可维护性

Day 21 的 API 能跑,Day 23 的 API 能撑。
这个差别就是"业余"和"专业"的分水岭。

从今天起,你的数据库层可以承担生产流量了。 后面 Day 30 部署上线时,数据库这块不用再操心。

Day 23 完成度:✅

明日预告(Day 24)

  • API 测试:用 httpx + pytest 测 FastAPI
  • TestClient 与 AsyncClient
  • 测试夹具:临时数据库、覆盖依赖
  • 集成测试:从请求到响应全链路
  • 给 Day 23 的 API 写一套完整测试

最后

如果这篇对你有用,点个赞让我知道有人在看。

评论区互动: 你之前做数据库迁移怎么做的?手动改?还是工具?欢迎分享。

有问题评论区见,我们第 24 天继续。

100 天,第 24 天见。


附录:系列目录

已完成 ✅

阶段天数内容
基础01-06Python 语法、数据结构、函数、OOP、文件、依赖管理
网络07-08HTTP 请求、异步 asyncio
工程09-12正则、装饰器、Pydantic、日志调试
项目13-14命令行 AI 助手 v1 / v2
复盘15阶段总结
数据16-17SQLite 数据库、SQLAlchemy ORM
测试18-19pytest 入门 + 进阶
工具20CLI 打包与发布
后端21-23FastAPI 入门 + 进阶 + 数据库整合 ✅

进行中 🎯

阶段天数内容
后端24-30API 测试 + 部署上线
大模型31-45LLM API + Prompt 工程
RAG46-60向量数据库 + 知识库
Agent61-80LangChain / 工具调用
实战81-100完整 Agent 项目上线

100 天后见,一起从切图仔变成 Agent 开发者。