Files
yunke/yunke-server/scripts/migrate_sqlite_to_mysql.py
T
浅念 00ae13b5c2 feat(deploy): 完成数据迁移与MySQL全链路(14表698k消息100%一致+5方言补丁+bind mount)
- migrate_sqlite_to_mysql.py: 逐表清空重导(幂等),复用repos强转,JSON列
  解析,67万消息按msg_id键集分批,467s跑完14表全一致
- 修4个MySQL实测bug: NULLS LAST语法(compat.py编译补丁)、message.text
  TEXT→MEDIUMTEXT(64KB字节上限,实测20.8万字符车源清单)、URL列
  String(1024)→Text(分享链接超1KB)、垃圾超长member_id源头清理
- compose: media改bind mount(不占C盘Docker虚拟磁盘,实测2.5GB卷把
  C盘撑爆引擎500);mysql不映射宿主机3306
- 前端Dockerfile: npm ci→install(本地/容器npm版本锁漂移)
- 五服务实测:nginx→backend→MySQL全链路返回真实数据(客户6859/A**杰25条
  /搜索56条),媒体49536文件bind mount直读

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-22 12:40:44 +08:00

163 lines
5.4 KiB
Python

#!/usr/bin/env python
"""SQLite → MySQL 数据迁移(本地 smoke.db → 服务器 MySQL,部署方式B)。
在目标环境(MySQL 可达)里跑:
python scripts/migrate_sqlite_to_mysql.py --sqlite ./smoke.db \
--mysql "mysql+asyncmy://yunke:pwd@mysql:3306/yunke"
* 逐表 DELETE 清空后全量导入(幂等,可重跑)
* 复用 repos 的 _build_column_values 强转(bool/int/日期字符串→ms,2026-08-21
的 3 个 MySQL 方言修复同样生效)
* JSON 列(raw_json 等)SQLite 里是文本,插入前 json.loads 回对象
* message(67万行)按 msg_id 键集分批,其余表全量读(最大 media 4.9万行)
* 跑完打印每表行数对照
"""
from __future__ import annotations
import argparse
import asyncio
import json
import logging
import sqlite3
import time
import sqlalchemy as sa
from sqlalchemy.ext.asyncio import create_async_engine
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("migrate")
BATCH = 500
# 迁移顺序(先组织后业务)
TABLES = [
"department",
"staff",
"wechat_account",
"whatsapp_account",
"customer",
"chat_group",
"group_member",
"message",
"phone_call",
"sms",
"voice_clip",
"media",
"sync_run",
"sync_state",
]
def _models_by_table() -> dict[str, type]:
from app.db import models
return {
obj.__tablename__: obj
for obj in vars(models).values()
if isinstance(obj, type) and hasattr(obj, "__tablename__")
}
def _json_cols(model: type) -> set[str]:
return {c.name for c in model.__table__.columns if isinstance(c.type, sa.JSON)}
def _coerce_row(model: type, row: dict, json_cols: set[str]) -> dict:
"""SQLite 行 → MySQL 可绑定的值(复用 repos 强转 + JSON 文本解析)。"""
from app.storage.repos import _build_column_values
out = _build_column_values(model, row)
for c in json_cols:
v = out.get(c)
if isinstance(v, str):
try:
out[c] = json.loads(v)
except (ValueError, TypeError):
out[c] = None
return out
async def migrate_table(conn, model: type, sqlite_con, table: str) -> tuple[int, int]:
"""清空 MySQL 表并从 SQLite 全量导入。返回 (sqlite_rows, mysql_rows)。"""
from app.storage.repos import _build_column_values # noqa: F401 (coerce 内用)
pk_cols = [c.name for c in model.__table__.primary_key.columns]
json_cols = _json_cols(model)
n_src = sqlite_con.execute(f"SELECT count(*) FROM {table}").fetchone()[0]
await conn.execute(sa.text(f"DELETE FROM {table}"))
inserted = 0
if table == "message":
# 67 万行:按第一主键(msg_id)键集分批,避免 OFFSET 全扫
keyset_col = pk_cols[0]
last_key = None
while True:
if last_key is None:
rows = sqlite_con.execute(
f"SELECT * FROM {table} ORDER BY {keyset_col} LIMIT {BATCH}"
).fetchall()
else:
rows = sqlite_con.execute(
f"SELECT * FROM {table} WHERE {keyset_col} > ? ORDER BY {keyset_col} LIMIT {BATCH}",
(last_key,),
).fetchall()
if not rows:
break
batch = [_coerce_row(model, dict(r), json_cols) for r in rows]
if batch:
await conn.execute(sa.insert(model), batch)
inserted += len(batch)
last_key = rows[-1][keyset_col]
if inserted % 50_000 < BATCH:
log.info(f" {table}: {inserted}/{n_src}")
else:
rows = sqlite_con.execute(f"SELECT * FROM {table}").fetchall()
for i in range(0, len(rows), BATCH):
batch = [_coerce_row(model, dict(r), json_cols) for r in rows[i:i + BATCH]]
if batch:
await conn.execute(sa.insert(model), batch)
inserted += len(batch)
return n_src, inserted
async def main() -> None:
parser = argparse.ArgumentParser(description="SQLite → MySQL 迁移")
parser.add_argument("--sqlite", required=True, help="smoke.db 路径")
parser.add_argument("--mysql", required=True, help="目标 MySQL async URL")
args = parser.parse_args()
t0 = time.time()
sqlite_con = sqlite3.connect(args.sqlite)
sqlite_con.row_factory = sqlite3.Row
models_by_table = _models_by_table()
engine = create_async_engine(args.mysql, pool_pre_ping=True, pool_recycle=3600)
results: list[tuple[str, int, int]] = []
async with engine.begin() as conn:
for table in TABLES:
model = models_by_table.get(table)
if model is None:
log.warning(f"跳过(无模型): {table}")
continue
t1 = time.time()
n_src, n_ins = await migrate_table(conn, model, sqlite_con, table)
results.append((table, n_src, n_ins))
log.info(f"{table}: {n_src} → {n_ins} ({time.time()-t1:.1f}s)")
await engine.dispose()
sqlite_con.close()
log.info("=" * 50)
ok = True
for table, n_src, n_ins in results:
mark = "✓" if n_src == n_ins else "✗"
if n_src != n_ins:
ok = False
log.info(f"{mark} {table}: {n_src:,} → {n_ins:,}")
log.info(f"总耗时 {time.time()-t0:.0f}s — {'全部一致 ✓' if ok else '存在差异 ✗'}")
if __name__ == "__main__":
asyncio.run(main())