阶段 1.1:用 SQLAlchemy 和 Alembic 建立可演进的数据库基线
项目早期直接使用 PyMySQL:每个数据库方法自己创建连接,Flask 启动时顺便执行 CREATE TABLE、SHOW COLUMNS 和 ALTER TABLE。这种方式在 Demo 阶段很直接,但随着表和部署环境增多,会出现两类问题:
- 业务请求反复建立 MySQL 连接,连接成本和生命周期难以统一管理。
- 数据库结构变更没有版本,无法准确回答“这个环境已经执行过哪些改表”。
这次改造先不引入全量 ORM,而是把两个基础问题分开处理:
SQLAlchemy Engine / QueuePool
-> 管理 Flask 运行期的数据库连接
Alembic / NullPool
-> 管理部署期的数据库结构版本
一、SQLAlchemy 不只是 ORM
很多人第一次看到 SQLAlchemy,会直接把它理解成“用 Python 类操作数据库”。实际上 ORM 只是 SQLAlchemy 的一部分:
SQLAlchemy
├─ URL / Dialect:描述数据库地址和 MySQL 特性
├─ Engine:访问某个数据库的统一入口
├─ Pool:创建、借出、回收和检测连接
├─ Core:连接、事务、Metadata 和 SQL 表达式
└─ ORM:Declarative Model、Session 和对象关系映射
当前项目主要使用 URL、Engine 和 Pool,业务层仍然保留熟悉的 PyMySQL %s 占位符和手写 SQL。这是有意为之的过渡设计:先统一连接和事务基础,不在同一次改造中重写所有数据访问逻辑。
二、Engine 不是一条数据库连接
Engine 可以看成“数据库访问工厂”。它保存连接信息、MySQL 方言、底层驱动、超时配置和连接池策略,但 Engine 本身不是某一条固定的 TCP 连接。
项目中的调用链是:
Flask 业务代码
-> SQLAlchemy Engine
-> QueuePool
-> PyMySQL DBAPI 连接
-> MySQL
mysql+pymysql 也可以拆开理解:
mysql告诉 SQLAlchemy 使用 MySQL Dialect。pymysql告诉 SQLAlchemy 底层由 PyMySQL 真正建立连接和发送 SQL。
所以 SQLAlchemy 并没有取代 PyMySQL,而是在它之上增加了统一的连接管理层。
三、Flask 为什么使用 QueuePool
Flask 是长期运行的 Web 服务。如果每次请求都重新连接 MySQL,就会反复支付建连和认证成本。QueuePool 会保留一定数量的可复用连接:
请求 A 借出连接 1
-> 执行 SQL
-> close()
-> 连接 1 归还池中
请求 B
-> 再次借出连接 1
这里的 close() 一般不是立即断开 MySQL,而是将连接归还给 SQLAlchemy。项目中还配置了:
pool_size:常驻池容量。max_overflow:高峰期允许的额外连接。pool_timeout:池满后等待连接的最长时间。pool_recycle:定期回收过久的连接。pool_pre_ping:取出连接时先检查其是否仍然有效。- 建连、读取和写入超时:避免连接无限卡住。
每个 Gunicorn Worker 是独立进程,因此也各有一个连接池:
数据库理论峰值连接数
= Worker 数 × (pool_size + max_overflow)
调大 Worker 或连接池参数时,必须和 MySQL max_connections 一起评估,不能只看单个进程的配置。
四、为什么连接池不等于事务
连接池解决“连接怎样借和还”,事务解决“多条 SQL 是否一起成功”,两者不是同一件事。
项目封装了两个上下文:
with connection() as conn:
# 适合查询,保证最后归还连接
...
with transaction() as conn:
# 正常结束时 commit
# 发生异常时 rollback
# 最后归还连接
...
当前 Flask 侧使用 Engine.raw_connection() 取得底层 DBAPI 连接,继续使用 PyMySQL DictCursor、commit() 和 rollback()。这样可以不改写业务 SQL,同时让连接生命周期由 Engine 统一管理。
五、Alembic 解决的是结构版本问题
SQLAlchemy 连接池不会记录数据库结构历史。Alembic 才负责把每次结构变更写成可审核、可重放的 revision:
20260918_0001 legacy baseline
-> 下一个 revision
-> 再下一个 revision
MySQL 中的 alembic_version 记录当前位置。执行:
alembic upgrade head
Alembic 会读取当前版本,按顺序只执行尚未应用的 revision,成功后再更新 alembic_version。
从此 Flask 启动不再执行建表或改表 SQL,而是检查数据库是否已经迁移。职责边界变成:
部署阶段:Alembic 修改数据库结构
运行阶段:Flask 使用已经准备好的结构
这也避免了多个 Gunicorn Worker 在启动时同时执行 DDL。
六、为什么 Alembic 使用 NullPool
Alembic 也通过 SQLAlchemy Engine 连接 MySQL,但它使用的是 NullPool:
Alembic 命令启动
-> 建立临时连接
-> 执行迁移
-> 关闭连接
-> 命令退出
迁移是短时管理任务,执行完后不会继续处理业务请求,因此没有必要保留长期连接池。
最终项目形成两个相互独立的 Engine:
SQLAlchemy
/ \
Alembic Engine Flask Engine
NullPool QueuePool
| |
执行数据库迁移 执行业务 SQL
| |
└──────── MySQL ──────────┘
它们连接同一个 MySQL,但不共享 Engine、Pool 或物理连接。
七、同时兼容空库和历史库
首个 revision 是 legacy baseline,它需要处理两种环境:
空数据库
当表不存在时,baseline 创建当前项目已有的基础表,使新环境可以从零执行 alembic upgrade head。
已经运行过旧代码的数据库
当基础表已经存在时,baseline 不重复创建,而是把这个数据库接入 Alembic 版本链。
baseline 的 downgrade() 不会自动删除基础表,因为这些表可能早在 Alembic 引入前就存在,盲目回滚会删除真实业务数据。
八、以后修改数据库结构的流程
以后不再到 Flask 启动函数中补一条 ALTER TABLE,而是为每次变更创建新 revision:
alembic revision -m "add_xxx_to_sessions"
然后人工编写并检查:
def upgrade():
# 如何从旧结构升级到新结构
...
def downgrade():
# 如何撤销,或明确说明为什么不能无损撤销
...
本地验证流程是:
迁移前检查
-> 备份数据库
-> alembic upgrade head
-> alembic current
-> 验证表结构和业务测试
MySQL 的 DDL 不能被简单当成普通业务事务,因此迁移前备份仍然是必要的。
九、当前方案和 ORM 的边界
当前 Alembic revision 是手写的,migrations/env.py 也没有配置业务 Model 的 target_metadata,所以现在不应依赖 alembic revision --autogenerate。
未来如果引入 SQLAlchemy Declarative Models,可以将表结构集中到 Metadata,再让 Alembic 根据 Model 与实际数据库的差异生成迁移草稿。但 autogenerate 仍然不是无人审核的自动改表,改名、数据回填、外键和 MySQL 特有 DDL 都必须人工检查。
即使以后引入 ORM,生产环境也不应在 Flask 启动时调用 create_all() 自动改表;结构变更仍应当继续通过 Alembic revision 进行版本化管理。
十、本小节的交付边界
这一小节只建立数据库基础设施:
- SQLAlchemy Engine 和 QueuePool。
- 连接、事务、超时与慢查询基础。
- Alembic 运行环境和 legacy baseline。
- Flask 启动时只校验迁移状态,不再自动改表。
- 迁移前检查和备份工具。
chat_runs、幂等记录、消息顺序并发保护和新聊天接口属于后续小节,不是理解这套 SQLAlchemy/Alembic 基础体系的前置条件。
这次改造的核心不是“换一个更高级的库”,而是建立两条清晰规则:
运行时连接由 SQLAlchemy Engine 管理,数据库结构只由 Alembic revision 改变。