接手一个跑了三年的老项目,数据全躺在 SQLite 里。刚开始单机版跑得挺好,后来架构改成 B/S 模式,多个客户端要同时写库,SQLite 直接给我上了一课——database is locked 这种报错从偶发变成日常,写并发稍微高一点,整个服务就像卡死一样。撑了半个月,业务方终于拍板:全量数据迁到 MySQL。接到任务的时候我心里还想着,数据库之间导数据能有多难?结果从导表结构开始就一路踩坑,今天把完整过程连同当时的排查思路一起记录下来,给正准备做同类型数据迁移的同学当个参考。
这篇文章不是那种"三步完成迁移"的教程,而是把你真正会遇到的所有细节摊开讲清楚:类型怎么映射、SQL 方言怎么改、字符集有哪些暗坑、外键索引为什么迁完就崩、最后怎么验证数据没丢。不管你是准备用图形界面工具导,还是像我一样写脚本手动迁,这中间的经验应该都能用得上。
1. 为什么放着好好的 SQLite 不用,非要迁到 MySQL
1.1 单机到 B/S 架构:SQLite 的"舒适区"边界
先说说我这次迁移的决策背景,不把这个讲清楚,后面很多选择看起来会莫名其妙。
SQLite 是个嵌入式关系型数据库,它的优势大家都清楚:零配置、单文件、部署简单、读取性能好。对于单机应用、数据量在几万到几十万级别、并发写入不高的场景,它几乎是完美的选择。但问题恰恰出在"并发写"这三个字上。
SQLite 的锁机制是数据库级别的写锁,也就是说同一时刻只允许一个写事务修改数据库文件。在单机桌面应用里,用户自己点操作,这个限制完全感知不到。可一旦上了 Web 架构,多个后端实例、多个客户端同时写,写锁的碰撞概率直线上升。我遇到最多的报错就是 SQLITE_BUSY 和 database is locked,一开始还在代码里重试,后面发现重试也没用,写操作排队的等待时间已经超过了业务能容忍的临界值。
更麻烦的是,SQLite 的文件存储格式决定了它不适合通过网络文件系统直接共享。有人说可以用 NFS 挂载,我试过,在高并发下磁盘 IO 的锁竞争比本地盘严重得多,整体表现只会更差。所以到了这个阶段,SQLite 已经不是"够不够用"的问题了,而是架构层面根本不匹配。迁到独立的数据库服务,是绕不过去的事情。
1.2 为什么是 MySQL 而不是 PostgreSQL
团队讨论的时候确实也考虑过 PostgreSQL,最后选 MySQL 主要是三方面原因:
第一,团队现有的技术栈里 MySQL 的经验积累最多,后续维护成本最低。第二,项目用的是云服务器,云平台提供的托管数据库服务对 MySQL 的支持最成熟,备份、监控、高可用这些能力基本开箱即用。第三,业务本身是标准的 CRUD + 事务场景,没有特别复杂的地理空间、JSON 全文检索之类的需求,MySQL 的 InnoDB 引擎完全覆盖得住。
我在这里想提醒一句:如果你的业务对数据一致性要求极高,或者需要复杂窗口函数、递归查询、多表关联的冷门 SQL 特性,建议你认真评估一下 PostgreSQL。但如果只是普通业务系统,MySQL 是成本更低、资料更多、出了问题更容易找到人问的选择。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 迁移前的"体检":把 SQLite 表结构彻底摸一遍
2.1 从 sqlite_master 里把家底翻出来
很多人拿到迁移任务,第一反应是拿工具直接导。我之前也这么干过,结果导到一半才发现源表里有一个字段是 SQLite 特有的写法,导出的 SQL 对着 MySQL 的语法怎么改都报错,回头重来浪费了大半天。正确的第一步是把结构完整导出来,逐列过一遍。
用命令行工具连接 SQLite 库,执行下面这条 SQL,就能看到所有表的建表语句:
bash复制sqlite3 old.db "SELECT sql FROM sqlite_master WHERE type='table';"
我这里是先用 sqlite3 old.db ".tables" 看有哪些表,然后针对每个表单独看建表语句和索引定义。索引和触发器也要一起查出来:
sql复制SELECT name, tbl_name, sql FROM sqlite_master WHERE type='index';
SELECT name, sql FROM sqlite_master WHERE type='trigger';
这一步的核心目的不是确认"有哪些表",而是找出 SQLite 特有的、MySQL 不认的语法。比如 WITHOUT ROWID 表、INTEGER PRIMARY KEY AUTOINCREMENT、部分索引(partial index)、表达式索引,这些在 MySQL 里都没有对应实现,需要在迁移前做出取舍。
我这次检查完发现,整个库 25 张表里有 3 张表用了 AUTOINCREMENT,5 张表有复合索引,还有 1 张表用了 SQLite 的 STRICT 表模式(这个还好,MySQL 天然是严格类型)。如果没有这一步摸底,后面写迁移脚本的时候会不断被意外的建表语句打断。
2.2 数据量、脏数据和"隐藏"的表
列完结构之后,我做了两件事:统计行数、检查数据质量。
统计行数可以用一个简单的循环,把每张表的 COUNT(*) 打出来。数据量最大的表有 120 多万行,这个规模对单机 SQLite 其实已经有点吃力了,迁移到 MySQL 反而没什么压力。数据量小的时候,迁移方式可以随意;数据量大了,导出导入的每一步都要考虑分批提交和内存占用。
接下来这一步容易被忽略,但它恰恰是数据迁移里最花时间的部分:检查脏数据。
SQLite 是动态类型(type affinity)数据库,字段声明成什么类型只是一个"亲和性"建议,实际上往里存任何类型都行。这导致了一个经典问题:一个声明为 INTEGER 的字段里,可能躺着字符串;一个声明为 DATETIME 的字段里,什么格式都有。
我写了一个简单的 Python 脚本遍历所有表的所有字段,统计每个字段的非 NULL 值分布情况,重点看类型是否和声明一致:
python复制# 检查SQLite每张表每个字段的值类型分布
import sqlite3
conn = sqlite3.connect('old.db')
cur = conn.cursor()
cur.execute("SELECT name FROM sqlite_master WHERE type='table'")
tables = [row[0] for row in cur.fetchall()]
for table in tables:
if table.startswith('sqlite_'):
co
