前端转 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()
每个请求:
- 拿一个独立的 Session
- 用完自动 commit
- 出异常自动 rollback
- 无论如何都 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 有用:
- "一个请求一个事务":默认由
get_db_session管理 - "业务逻辑完整执行完再提交":中间报错就 rollback
- "不同请求不能共享 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
| 概念 | Git | Alembic |
|---|---|---|
| 初始仓库 | git init | alembic init |
| 提交 | git commit | alembic revision |
| 提交历史 | git log | alembic history |
| 应用变更 | git merge | alembic upgrade |
| 回滚 | git revert | alembic downgrade |
| 当前状态 | git status | alembic current |
Prisma 类比:
| Prisma | Alembic |
|---|---|
prisma migrate dev | alembic revision --autogenerate |
prisma migrate deploy | alembic upgrade head |
prisma migrate reset | alembic downgrade base + upgrade head |
schema.prisma | models.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 / rollback | get_db_session 统一管理 |
| 加字段删库重来 | Alembic 平滑迁移 |
| 默认连接配置 | 连接池 + WAL 优化 |
| 开发生产同一套 | 环境变量切换 |
这套配置能直接上生产。 后面 Day 30 上线时不用再改数据库层。
六、前端视角:FastAPI 的事务 vs 前端的状态管理
做过前端的你,可以把"事务"理解成"批量状态更新":
| 前端概念 | 后端事务 |
|---|---|
Redux dispatch 一个 action | 一个 HTTP 请求 |
reducer 里同步更新多个 state | session 里修改多个对象 |
| 要么全改,要么不改 | 事务的原子性 |
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 时,要么全提交,要么全回滚
前端的"一致性思维"直接迁移到后端。
七、今日踩坑
- 忘了
expire_on_commit=False→ commit 后访问对象属性会重新查询,性能差 session.add()后没 commit → 数据没写入,调半天- 多个请求共享 Session → "对象不属于当前 Session"的错
get_db_session忘了 yield 后 commit → 所有写操作都不生效- Alembic 的
env.py忘了target_metadata→ autogenerate 生成空脚本 - SQLite 的
render_as_batch=True没设 → 改字段的迁移会失败 - 删表迁移没生成 → Alembic 默认不检测删表(防止误操作),要手动写
- 重命名字段当删+加 → 数据丢失,迁移要手写
- 连接池设太大 → 数据库连接数爆了,要按公式算
- SQLite 没开 WAL → 并发读写会互相阻塞,加
PRAGMA journal_mode=WAL - 生产用
create_all→ 会偷偷改表结构,要用 Alembic - 迁移脚本提交后又改 → 别人已经 apply 了,冲突无法解决
八、今日总结
今天核心收获三件事:
- 请求级 Session 是 FastAPI 数据库的标准姿势:
yield+commit/rollback,事务边界清晰 - Alembic 是数据库的 Git:
revision生成、upgrade应用、downgrade回滚,改字段不用删库 - 连接池 + 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-06 | Python 语法、数据结构、函数、OOP、文件、依赖管理 |
| 网络 | 07-08 | HTTP 请求、异步 asyncio |
| 工程 | 09-12 | 正则、装饰器、Pydantic、日志调试 |
| 项目 | 13-14 | 命令行 AI 助手 v1 / v2 |
| 复盘 | 15 | 阶段总结 |
| 数据 | 16-17 | SQLite 数据库、SQLAlchemy ORM |
| 测试 | 18-19 | pytest 入门 + 进阶 |
| 工具 | 20 | CLI 打包与发布 |
| 后端 | 21-23 | FastAPI 入门 + 进阶 + 数据库整合 ✅ |
进行中 🎯
| 阶段 | 天数 | 内容 |
|---|---|---|
| 后端 | 24-30 | API 测试 + 部署上线 |
| 大模型 | 31-45 | LLM API + Prompt 工程 |
| RAG | 46-60 | 向量数据库 + 知识库 |
| Agent | 61-80 | LangChain / 工具调用 |
| 实战 | 81-100 | 完整 Agent 项目上线 |
100 天后见,一起从切图仔变成 Agent 开发者。