Files
DjangoAdmin_FastAPI_EleVue/document/数据库迁移_runbook.md
T
2026-09-12 17:39:13 +08:00

226 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 数据库跨库迁移操作手册(MySQL → PostgreSQL / SQL Server / SQLite / Oracle)
> 适用范围:把本项目一个正在运行的 **MySQL 库** 的「结构 + 数据」整体迁到
> **PostgreSQL / SQL Server / SQLite / Oracle** 等目标库,后续应用切换 `DB_DRIVER` 即可。
>
> 结构迁移由 `Base.metadata.create_all()`(方言可移植 DDL)完成;
> 数据迁移由 `scripts/migrate_db.py` 完成(拓扑序逐表显式带 id 批量插入,
> 保住无外键裸列引用关系,再按方言重置自增序列)。
---
## 0. 原理速览
`migrate_db.py` 做什么:
1. **建目标库**(不存在才建,按方言连维护库:MySQL 无库连接 / PG 连 `postgres` / MSSQL 连 `master`)。
2. **建结构**:`Base.metadata.create_all()` 按目标方言生成 DDL(模型即 head,含
`city.parent_code nullable` 等已落地模型定义,无需再跑 alembic)。
3. **泵数据**:按拓扑序(基础表先、依赖表后),每表 `SELECT * ... ORDER BY id`,
分块 executemany **显式带 id** 插入目标 → 表间裸列引用(`user.dept_id`、
`role_menu.role_id/menu_id`、`menu.parent_id` 等)完整保留。
4. **重置自增序列**:PG `setval` / MSSQL `IDENTITY_INSERT` + `DBCC CHECKIDENT` /
Oracle 为每表重建 `sequence` + `BEFORE INSERT` 触发器(Oracle 无原生自增);
MySQL / SQLite 显式插 id 后自动跟随,无需处理。
5. **校验**:逐表 `COUNT(*)` 对比,不一致则退出码 1。
---
## 1. 前置条件
- 源 MySQL 可连接、账号有读权限;目标库已安装且维护库(`postgres`/`master`)账号有建库权限;
Oracle 目标需服务可达、schema 用户有 `CONNECT, RESOURCE` 权限。
- 依赖齐全:`pip install -r requirements.txt`(PyMySQL / psycopg / pymssql / oracledb 均已内置)。
- 项目 `.env` 存在且合法(脚本内部 `create_app()` 会做启动期安全配置校验,与 `init_db.py` 同约束)。
- 建议先用 `document/djangoadmin.fastapi.elevue.sql` 同源数据的库做一次演练。
---
## 2. 干跑(DRY-RUN,先核对计划)
只连源、逐表数行数、打印迁移计划,**不碰目标库**:
```bash
make db-migrate-dry-run ARGS=" \
--src-driver=mysql --src-host=127.0.0.1 --src-port=3306 \
--src-db=djangoadmin.fastapi.elevue --src-user=root --src-pass=root \
--dst-driver=postgresql --dst-host=127.0.0.1 --dst-port=5432 \
--dst-db=djangoadmin.fastapi.elevue --dst-user=postgres --dst-pass=xxx"
```
输出应为每张 `fastapi_*` 表的行数(如 `fastapi_city: 5xxx 行`),核对源端行数与预期一致。
> 口令可改用环境变量避免进 shell 历史:`SRC_DB_PASS` / `DST_DB_PASS`。
---
## 3. 正式迁移
在干跑计划之上加 `--drop-target-first`(先删目标库同名库再重建,**幂等可重跑**):
```bash
make db-migrate ARGS=" \
--src-driver=mysql --src-host=127.0.0.1 --src-port=3306 \
--src-db=djangoadmin.fastapi.elevue --src-user=root --src-pass=root \
--dst-driver=postgresql --dst-host=127.0.0.1 --dst-port=5432 \
--dst-db=djangoadmin.fastapi.elevue --dst-user=postgres --dst-pass=xxx \
--drop-target-first"
```
预期日志(阶段横幅 + 子步骤 + 逐表进度,每阶段带耗时,末尾汇总):
```
==============================================================
[迁移开始] 源: mysql:djangoadmin.fastapi.elevue → 目标: mssql:djangoadmin.fastapi.elevue
[迁移开始] 待迁移 23 张表
==============================================================
[·] [1/5] 建目标库...(可能耗时较长,请勿中断)
· 正在连接 SQL Server(master 维护库)...
· 正在删除旧目标库 [djangoadmin.fastapi.elevue](SINGLE_USER + DROP)...
· 正在创建目标库 [djangoadmin.fastapi.elevue]...
[✓] [1/5] 建目标库 完成(耗时 947ms)
[·] [2/5] 建表结构(23 张表 create_all)...(可能耗时较长,请勿中断)
[✓] [2/5] 建表结构(23 张表 create_all) 完成(耗时 827ms),23 张表
[·] [3/5] 逐表迁移数据...(可能耗时较长,请勿中断)
· [1/23] 正在迁移 fastapi_dept...
[✓ fastapi_dept] 共 5 行(耗时 12ms)
· [2/23] 正在迁移 fastapi_menu...
[✓ fastapi_menu] 共 54 行(耗时 110ms)
· [3/23] 正在迁移 fastapi_city...
[fastapi_city] +1000(累计 1000)
...
[✓ fastapi_city] 共 4150 行(耗时 4.2s)
...(其余表各一行 ✓ 汇总)
[✓] [3/5] 逐表迁移数据 完成,共 4843 行(耗时 5.7s)
[·] [4/5] 重置自增序列...(可能耗时较长,请勿中断)
[✓] [4/5] 重置自增序列 完成(耗时 113ms) ← PostgreSQL/MSSQL/Oracle 显示实际耗时;
MySQL/SQLite 显示「自动跟随,无需处理」
[fastapi_dept] source=5 target=5 PASS
...
[✓] [5/5] 逐表行数校验 完成,23 张表全部 PASS(耗时 103ms)
==============================================================
[迁移完成] 成功:4843 行 / 23 张表 / 总耗时 7.1s
==============================================================
```
`[·]` = 阶段进行中(含「请勿中断」提示),`[✓]` = 阶段完成。失败时相应节点会标 FAIL,
例如 `[✓] [5/5] 逐表行数校验 完成,FAIL N 张` 并在下面列出明细、`[✓] [4/5] 重置自增序列 完成,N 张失败`。
退出码 0 = 成功;1 = 至少一张表 count 不一致(`[FAIL]` 列出明细)。
---
## 4. 目标端校验清单
迁移后按需抽查(以 PostgreSQL 为例,MSSQL 用 SSMS 等价的 SQL):
1. **行数**:上一步已逐表 PASS。
2. **密码/盐原样**(bcrypt 双重加盐,原样搬):
```sql
SELECT id, username, password, salt FROM fastapi_user ORDER BY id LIMIT 5;
```
3. **自增序列已推进**(新插入不带 id 不应主键冲突):
```sql
SELECT last_value, is_called FROM fastapi_city_id_seq; -- 确认 last_value >= MAX(id)
SELECT MAX(id) FROM fastapi_city;
INSERT INTO fastapi_city (city_code, area_code, zip_code, name, short_name, parent_id)
VALUES ('010', '110100', '100000', '测试市', '测试', 0) RETURNING id; -- id 应为 MAX+1
-- 验证后删掉该测试行
```
4. **0002 生效**(`city.parent_code` 可空):
```sql
\d fastapi_city -- parent_code 应为 可空(nullable)
```
5. **树形引用完整**(父子都在):
```sql
SELECT COUNT(*) FROM fastapi_menu m LEFT JOIN fastapi_menu p ON m.parent_id = p.id
WHERE m.parent_id <> 0 AND p.id IS NULL; -- 应为 0(无悬空父引用)
```
6. **CJK / Text 保真**(utf8mb4 → PG UTF8):
```sql
SELECT name FROM fastapi_city WHERE id = 1; -- 中文正常
SELECT LENGTH(content) FROM fastapi_article ORDER BY id DESC LIMIT 3; -- 富文本长度合理
```
---
## 5. 切换应用指向目标库
1. 改项目根 `.env`:
```ini
DB_DRIVER='postgresql'
DB_HOST='127.0.0.1'
DB_PORT=5432
DB_DATABASE='djangoadmin.fastapi.elevue'
DB_USERNAME='postgres'
DB_PASSWORD='xxx'
DB_PREFIX='fastapi_' # 必须与迁移时一致
```
2. 重启应用:`python src/main.py`(或 docker-compose)。
3. 回归验证:`pytest`(contract 快照守护表名/权限串/路由,integration 需真实库)。
---
## 6. 目标库补 alembic 基线(可选)
应用若走 alembic 增量迁移,在目标端把库标记到 head(幂等,`create_all` no-op):
```bash
DB_DRIVER=postgresql python scripts/init_db.py
```
> `init_db.py` 会按当前 `DB_DRIVER` 连维护库判存在、`create_all` 补表(已是全量,无缺失)、`stamp head`。
---
## 7. 参数参考
| 参数 | 说明 | 默认 |
|---|---|---|
| `--src-driver` / `--dst-driver` | mysql/postgresql/mssql/oracle/sqlite | mysql |
| `--src-host` / `--dst-host` | 地址 | 127.0.0.1 |
| `--src-port` / `--dst-port` | 端口,缺省按驱动(3306/5432/1433) | 驱动默认 |
| `--src-db` / `--dst-db` | 库名;**oracle 时即服务名 service_name,sqlite 时即数据库文件完整路径** | 必填 |
| `--src-user` / `--dst-user` | 账号 | root / postgres |
| `--src-pass` / `--dst-pass` | 口令,留空回落 `SRC_DB_PASS`/`DST_DB_PASS` | 空 |
| `--drop-target-first` | 先删目标同名库再重建(幂等重跑) | off |
| `--dry-run` | 只连源、逐表数行数、打印计划 | off |
| `--chunk-size` | 每批插入行数 | 1000 |
| `--only-tables` | 逗号分隔子集(全名或短名),续传用 | 全部 |
| `--skip-existing` | 目标已等于源行数的表跳过 | off |
| `--single-transaction` | 全库一个大事务(默认每表一个事务) | off |
| `--skip-verify` | 跳过迁移后逐表行数校验 | off |
---
## 8. 常见问题
- **迁移中断**:默认每表一个事务,失败只回滚当前表;修好源数据后重跑
`--drop-target-first`(全量)或 `--only-tables=<未完成表>`(续传)。
- **重跑非幂等**:不 `--drop-target-first` 会重复行导致 count 校验 FAIL。规范做法是
每次迁到全新目标库或加 `--drop-target-first`。
- **MySQL 零日期 `0000-00-00`**:PyMySQL 默认转 None,脚本另有 `sanitize` 兜底,
统一落目标为 NULL;本项目时间列全部可空。
- **PG 主键冲突(新插入 id 撞已有值)**:序列没重置,按第 4 节第 3 步核对并手动
`setval`,或重跑一次迁移。
- **MSSQL**:脚本对每张表自动 `SET IDENTITY_INSERT ON` → 插入 → `OFF`,
并 `DBCC CHECKIDENT` 重置;日志无报错即正常。
- **目标库建库权限不足**:让 DBA 预建空库后去掉 `--drop-target-first` 重跑
(建库分支检测已存在会跳过)。
---
## 9. 大数据量场景(可选:pgloader)
自研泵面向本项目量级(city 5k 行 + 其余小表,秒级完成)。若源库达到**百万级行**,
MySQL → PostgreSQL 建议改用官方推荐的 **pgloader**(类型/索引/外键自动处理,并行加载):
```bash
# Ubuntu/Debian
sudo apt install pgloader
pgloader mysql://root:pass@127.0.0.1/djangoadmin.fastapi.elevue \
postgresql://postgres:pass@127.0.0.1/djangoadmin.fastapi.elevue
```
注意:pgloader 只覆盖 MySQL → PostgreSQL 一条路径;MSSQL / SQLite 目标仍用自研泵。
两种方式可任选其一,不要混跑同一目标库。