接手一个跑了三年的老项目,数据全躺在 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_'):
continue
cur.execute(f"PRAGMA table_info('{table}')")
columns = [row[1] for row in cur.fetchall()]
for col in columns:
try:
cur.execute(f"SELECT typeof(\"{col}\"), COUNT(*) FROM \"{table}\" GROUP BY typeof(\"{col}\")")
type_dist = cur.fetchall()
print(f"{table}.{col}: {type_dist}")
except Exception as e:
print(f"{table}.{col}: error {e}")
conn.close()
这个脚本会输出每个字段在 SQLite 内部实际存储的类型分布。如果某个声明为 INTEGER 的字段出现了 text 类型的值,就说明这里有脏数据,迁移前必须处理。我这次就发现一个 status 字段声明是 INTEGER,但里面混了几个字符串类型的值,如果不处理,迁移到 MySQL 后这些行会因为类型不匹配直接插入失败,或者被隐式转换成 0,造成业务数据错误。
后面还查了外键关系对应的孤立记录,这个放到第六章外键部分细说。
3. 类型映射的连环坑:自增主键、布尔值、日期时间都是重灾区
3.1 INTEGER PRIMARY KEY:SQLite 的 rowid 和 MySQL 的 AUTO_INCREMENT
SQLite 有一种很特殊的表结构设计:如果一个表的主键列声明为 INTEGER PRIMARY KEY,那么这个列实际上是这个表的 rowid 的别名,插入数据时可以不指定值,SQLite 会自动分配一个比当前最大值大 1 的整数。如果显式指定了 AUTOINCREMENT,则 SQLite 会用内部的 sqlite_sequence 表来记录自增值,保证严格单调递增,即使删除了最大 ID 也不会复用。
MySQL 这边对应的是 AUTO_INCREMENT,但有几个差别需要注意。
第一,SQLite 里 INTEGER PRIMARY KEY 可以用于 BIGINT 级别的整型,MySQL 要用 BIGINT AUTO_INCREMENT 才能对应上,千万不要随手写成 INT。数据量大的时候,INT 最大就到 21 亿多,看着好像够用,但自增主键这东西一旦满了,迁移完再想改就麻烦了。
第二,MySQL 的 AUTO_INCREMENT 列必须是索引列(通常就是主键),而且一张表只能有一个自增列。SQLite 没有这个限制?其实也有,一张表只能有一个 INTEGER PRIMARY KEY,其他字段没法设自增。这个映射倒是不难,但要在脚本里直接写成:
sql复制-- SQLite 原表
CREATE TABLE orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_no TEXT,
amount REAL
);
-- MySQL 目标表
CREATE TABLE orders (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
order_no VARCHAR(64),
amount DECIMAL(10,2) -- REAL转DECIMAL,原因见3.2
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
第三,也是最容易被忽略的:SQLite 的 INTEGER PRIMARY KEY 在没有指定值插入时,如果当前表里已经存在 1, 2, 3,删掉 3 之后再插入,新的 rowid 可能是 4 而不是重新使用 3(只有在使用 AUTOINCREMENT 时才保证不重用)。MySQL 的 AUTO_INCREMENT 在 InnoDB 引擎下,删除最大值后重启实例,自增计数会基于当前最大值重新计算,因此可能复用已删除的 ID。这个差异对业务影响不大,但如果你的业务逻辑对 ID 有"严格递增且不重用"的预期,就得在迁移后的数据库层面额外保证,比如不主动删除最大 ID 的行,或者用发号器服务。
3.2 布尔值、浮点数、BLOB 的映射策略
SQLite 没有原生的布尔类型,开发到这里普遍用 INTEGER 存 0 和 1。MySQL 的 BOOL 只是 TINYINT(1) 的别名,所以这个映射基本无脑。
但有个细节需要注意:SQLite 的宽松类型可能导致布尔字段中间出现 0、1 之外的值,比如 2 或者 -1。在 SQLite 里查询 WHERE flag = 1 时,这些非 0/1 的值会被排除,但如果你在应用代码里写的是 if flag:,那么 2、-1 这些值都会被认为是"真",业务逻辑没错,但数据规则却很乱。迁移到 MySQL 后,如果忘了清洗,后面做统计报表的时候会得出和原来不一致的结果。我在迁移前就把所有布尔字段的值做了一次 SELECT DISTINCT status FROM table;,确认只有 0 和 1 才继续。
浮点数方面,SQLite 的 REAL 对应 MySQL 的 DOUBLE,这个倒好办。麻烦的是开发人员经常会在 SQLite 里用 NUMERIC 或 DECIMAL(10,2) 来声明金额字段,但实际存进去的值可能是浮点数。MySQL 的 DECIMAL 是定点数,存储规则严格很多,如果源数据里有 2.345 这种三位小数的值,迁移时就要决定是四舍五入还是报错截断。我的建议是:金额相关字段都映射成 DECIMAL,在迁移脚本里用 Python 的 Decimal 做精确转换,而不是依赖数据库的隐式转换,这样可以避免精度的意外丢失。
BLOB 类型两边都有,直接映射即可。唯一要注意的是,SQLite 单库文件可能很大,如果 BLOB 字段里存了比较大的文件内容,导数据的时候内存占用会很高,建议分批读取而不是一次性把所有行加载到内存。我这次有一个表存了用户上传的图片二进制,单行最大有 2MB,用流式游标逐行写入 MySQL 才没把内存打爆。
3.3 日期时间:不确定格式是最大的隐患
日期时间字段的映射,是这次迁移中实际耗时最长的一个环节。
SQLite 官方推荐的日期存储格式是 ISO8601 字符串,也就是 'YYYY-MM-DD HH:MM:SS',但这只是推荐,不是强制。真正在业务代码里,我见过有人用 'YYYY/MM/DD',有人用 Unix 时间戳(INTEGER),还有人把日期拆成年、月、日三个 INT 字段。
MySQL 的 DATETIME 类型对格式要求严格,必须是 'YYYY-MM-DD HH:MM:SS',范围从 1000-01-01 00:00:00 到 9999-12-31 23:59:59。如果把 '2023/01/01' 这种字符串直接塞给 MySQL,轻则存成 '0000-00-00 00:00:00'(在非严格模式下),重则直接报 Incorrect datetime value 错误。
我在迁移脚本里加了一个日期清洗函数,对每个日期字段先探测格式,再统一转成标准格式。核心逻辑大概是这样:
python复制from datetime import datetime
def parse_date(value):
if value is None:
return None
if isinstance(value, (int, float)):
# 认为是Unix时间戳
return datetime.fromtimestamp(value).strftime('%Y-%m-%d %H:%M:%S')
text = str(value).strip()
if not text:
return None
formats = [
'%Y-%m-%d %H:%M:%S',
'%Y-%m-%d',
'%Y/%m/%d %H:%M:%S',
'%Y/%m/%d',
'%Y%m%d', # 这种也要考虑到
]
for fmt in formats:
try:
return datetime.strptime(text, fmt).strftime('%Y-%m-%d %H:%M:%S')
except ValueError:
continue
raise ValueError(f'无法解析的日期格式: {value}')
这个函数在正式跑批量迁移之前,我专门拿出来对全表日期字段跑了一遍,把所有解析不了的值都打出来人工确认。排查出来的问题包括:空字符串(不是 NULL)、'0000-00-00'、'2023-13-01' 这种不合法的月份。这些脏数据在 SQLite 里都"安然无恙"地躺着,因为 SQLite 根本不校验日期合法性。迁到 MySQL 后,它们就是定时炸弹。
4. SQL 方言差异:同一句话,两种理解
4.1 自增、分页、字符串拼接:三个高频翻车点
表结构迁过去了,数据导入脚本也写好了,但当我把应用里用到的 SQL 原封不动切到 MySQL 时,第一个报错就来了。
首先是自增语法。SQLite 建表用 AUTOINCREMENT(而且要跟在 INTEGER PRIMARY KEY 后面),MySQL 用 AUTO_INCREMENT。这个差异建表的时候就会暴露,只要改一次就行,不算特别坑。
真正麻烦的是分页查询。SQLite 里写 LIMIT 10 OFFSET 20,MySQL 同样支持这种写法(8.0 也支持),但很多老 MySQL 开发习惯写成 LIMIT 20, 10,意思是跳过 20 行取 10 行。如果你把这两种写法混在一起,代码审查的时候特别容易漏。我当时把所有 SQL 里的分页都统一改成了 LIMIT ? OFFSET ? 的格式,用问号占位符,避免混淆。
字符串拼接是两个数据库差异最大的地方。SQLite 用 || 运算符,MySQL 默认用 CONCAT() 函数。我之前项目里有一个 SQL 是这个样子的:
sql复制-- SQLite 写法
SELECT first_name || ' ' || last_name AS full_name FROM users;
到了 MySQL 里直接报语法错误。而且 || 在 MySQL 里默认是逻辑 OR 运算符,所以就算不报错,结果也是错的。我排查到一个 WHERE 条件里,原本想拼字符串,结果变成了 0 OR 0,查出来的数据完全不对。统一替换成 CONCAT() 之后才正常。
4.2 REPLACE INTO、引号处理和 NULL 语义
还有一个更微妙的坑:REPLACE INTO。
SQLite 和 MySQL 都有 REPLACE INTO 语法,字面上看都是"存在就替换,不存在就插入",但 MySQL 的实现有一个非常重要的副作用:当唯一键冲突时,MySQL 的 REPLACE 实际上是先 DELETE 掉旧行,再 INSERT 新行。这意味着什么?如果表上带有 AUTO_INCREMENT 主键,REPLACE 会消耗一个新的自增 ID;如果表上有外键关联,DELETE 旧行可能触发级联删除,把关联子表的数据给删了;如果表上有触发器,DELETE 触发器和 INSERT 触发器都会执行。
SQLite 的 REPLACE 是 INSERT OR REPLACE 的同义写法,行为上也会删除旧行再插入新行,这一点其实和 MySQL 一致。但这个行为的副作用(自增 ID 变化、级联删除、触发器)在两边都存在。更推荐的做法是使用 INSERT ... ON DUPLICATE KEY UPDATE 这种语句,它语义更明确,只更新冲突的列,不会删除旧行。迁移脚本里如果之前用的是 REPLACE INTO,建议都改成 INSERT ... ON DUPLICATE KEY UPDATE。
引号处理上,MySQL 有一个 ANSI_QUOTES 模式,开启后双引号会被当成标识符引用符而不是字符串字面量。默认情况下,MySQL 的双引号就是字符串字面量,和单引号等价。SQLite 对双引号的处理比较宽松,既能当标识符也能当字符串。迁移后的应用连接如果开了 ANSI_QUOTES,又用双引号包字符串,就会出现 Unknown column 'xxx' 这种奇怪报错。我当时的排查思路是:先确认连接串的 sql_mode 设置,然后全局搜索代码里的双引号 SQL,统一改成单引号。
NULL 语义的差异主要体现在分组和排序上。SQLite 排序时 NULL 默认排在前面,MySQL 的 ORDER BY 默认 NULL 排最前面,这一点其实一致。但 GROUP BY 的行为有差异:MySQL 的 GROUP BY 默认允许选择列表中出现非聚合列(非严格模式下),返回的值是不确定的;SQLite 则要求严格得多,一旦 SELECT 列表里出现非聚合列,直接报错或返回该组第一行的值。迁移后如果应用里有用到这种"不严谨"的 SQL,必须改造成标准写法,用 ANY_VALUE() 或改成聚合查询,否则 SQLite 太宽松,MySQL 虽然默认也能通过,但结果不可控。
5. 字符集暗坑:中文没丢,Emoji 却坏了
5.1 utf8 与 utf8mb4 的区别:不是所有"UTF-8"都一样
我刚开始搭建 MySQL 目标库的时候,按照网上的教程建了一个 utf8 编码的库。导完数据一看,中文全正常,但用户头像昵称里的 Emoji 表情全变成了问号,或者干脆报 Incorrect string value: '\xF0\x9F\x98\x80' 错误,插入失败。
原因很多人都知道:MySQL 早期版本的 utf8 其实是一个最多 3 字节的 UTF-8 实现(官方叫 utf8mb3),而 Unicode 的 Emoji 大多是 4 字节编码。只有 utf8mb4 才是真正完整的 UTF-8,支持所有 Unicode 字符,包括 Emoji。
但这里想多说一句:这个坑不是迁移特有的。就算你是从零开始用 MySQL 做新项目,只要设计表的时候把字符集设成 utf8,后面用户一旦输入 Emoji,同样会遇到问题。所以我的建议非常明确:新建 MySQL 库、表,一律使用 utf8mb4,不要用 utf8。别想着"我们的应用不会有 Emoji",用户输入永远比你想象的丰富。
5.2 连接串、库表字段、排序规则:三层配置缺一不可
字符集问题不是只在建库建表时设置一次就完事的,它涉及三层配置,任何一层遗漏都可能出现乱码。
第一层是 MySQL 服务端配置。my.cnf 里要设置:
ini复制[mysqld]
character-set-server=utf8mb4
collation-server=utf8mb4_unicode_ci
第二层是建库建表语句。建库时要显式声明,否则会继承服务端配置:
sql复制CREATE DATABASE app_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
建表时如果没有指定,也会继承库的默认配置。我建议在每张表后面都显式写上 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci,防止有人手工建表的时候用了默认的 latin1 或 utf8。
第三层是应用连接串。以 Python 的 pymysql 为例,连接参数里必须指定 charset='utf8mb4',否则即使数据库是 utf8mb4,客户端连接是 utf8,读写中文还是会出问题:
python复制conn = pymysql.connect(
host='localhost',
user='root',
password='******',
database='app_db',
charset='utf8mb4',
cursorclass=pymysql.cursors.DictCursor
)
排序规则 COLLATE 的选择上,utf8mb4_unicode_ci 和 utf8mb4_general_ci 的主要区别是排序和比较的准确性。utf8mb4_unicode_ci 基于 Unicode 排序算法,对多语言支持更完善,排序结果更准确,但性能略低;utf8mb4_general_ci 比较快但有些特殊字符排序不准确。对于中文应用,两者差别其实不大,我选 utf8mb4_unicode_ci 图个心里踏实。MySQL 8.0 默认的 utf8mb4_0900_ai_ci 是基于 Unicode 9.0 的,比 unicode_ci 更新,也可以直接用。
6. 外键和索引:SQLite 默认没启用,MySQL 默认全开着
6.1 外键约束的"从宽到严"反转
这是我在整个迁移过程中最痛的一个坑,因为它在数据量最大的那张表上炸了。
SQLite 支持外键语法,但默认情况下外键约束是关闭的。只有在每次连接时执行 PRAGMA foreign_keys = ON;,SQLite 才会检查外键完整性。很多老项目的代码压根没执行过这一句,所以业务里"看起来"一直运行正常。
MySQL 的 InnoDB 引擎则不同,外键约束默认就是启用的。迁移完成后,应用第一次尝试插入一条带外键的记录时,直接报 Cannot add or update a child row: a foreign key constraint fails。
我当时的排查链路是这样走的:
第一步,先确认报错来自哪张表。错误信息里包含了表名和约束名。
第二步,检查是"原表数据有问题"还是"迁移后的数据有问题"。把 SQLite 里所有子表的关联字段值都检查一遍,找出那些在父表中不存在的 ID。用一条 SQL 就能查出来:
sql复制-- 假设 order_items.order_id 外键指向 orders.id
SELECT DISTINCT order_id FROM order_items
WHERE order_id NOT IN (SELECT id FROM orders);
结果发现,有几千条 order_items 记录的 order_id 在 orders 表里根本不存在。这些孤儿数据在 SQLite 里躺了好几年,因为外键没启用,从来没人发现。
第三步,决定处理方式。不能直接删,因为可能影响历史报表逻辑。我的方案是:先把孤儿记录备份出来,在 MySQL 里给它们建一个 orders_orphan_backup 表,然后把 order_items 里这些关联字段置为 NULL(如果字段允许 NULL),或者保留原值但禁用外键检查(不推荐)。
最终选择的方案是为这些孤儿记录创建一行"未知订单"的占位记录,ID 设为 0。在 MySQL 的 orders 表里插入一行 id=0, order_no='UNKNOWN' 的虚拟订单,这样 order_items 的关联数据不会丢,外键也能正常检查。
迁移脚本的正确顺序应该是:先 SET FOREIGN_KEY_CHECKS=0; 导入全部数据,导完后做孤儿数据修复,最后 SET FOREIGN_KEY_CHECKS=1; 重新启用外键检查。这个顺序能保证批量导入数据时不会被外键约束打断,又能在导入完成后把数据一致性补齐。
6.2 索引重建与 MySQL 的索引命名规则
SQLite 和 MySQL 的 CREATE INDEX 语法非常接近,所以索引迁移本身不太难,但有两个细节需要注意。
第一个细节是索引名的全局唯一性。SQLite 里索引名是表级别的,也就是说两张不同的表可以有相同的索引名。MySQL 里索引名是库级别的,同一个库下所有索引名必须唯一。
我这次就遇到一个项目,不同表上的索引都叫 idx_created_at,从 SQLite 里导出的建索引语句直接拿到 MySQL 执行,第二张表就报 Duplicate key name 'idx_created_at'。解决办法很简单,在迁移脚本里给索引名统一加上表名前缀:
sql复制CREATE INDEX idx_orders_created_at ON orders (created_at);
CREATE INDEX idx_users_created_at ON users (created_at);
第二个细节是索引在数据量大的时候,如果先建索引再导数据,导入速度会显著变慢。更合理的顺序是:建空表(不含索引)→ 导入数据 → 统一创建索引 → 用 ANALYZE TABLE 更新统计信息。
具体的迁移流程我建议这样排:
- 根据转换后的建表语句创建所有表(不含索引和外键)
- 关闭外键检查:
SET FOREIGN_KEY_CHECKS=0; - 分批导入数据
- 打开外键检查:
SET FOREIGN_KEY_CHECKS=1; - 创建所有索引和外键约束
- 执行
ANALYZE TABLE让优化器拿到正确的统计信息
如果不执行最后一步 ANALYZE TABLE,MySQL 的查询优化器可能基于空的统计信息做出错误判断,导致迁移后查询计划不佳,本来该走索引的查询走了全表扫描。
7. 迁移后的验证:行数对上了,不代表数据没问题
7.1 三层校验法:行数、CHECKSUM、抽样对比
数据导完,索引建好,不等了,直接切生产?别急,校验这步省不得。
我用的校验方法分三层,每层解决不同维度的问题。
第一层:行数校验。对每张表分别在 SQLite 和 MySQL 里执行 SELECT COUNT(*) FROM table;,对比行数是否一致。这个最简单,但只能揪出"整表丢失"或"重复插入"的大问题。
第二层:CHECKSUM 校验。对每张表计算一个总和值,方法是把每行数据转换成一个可比较的字符串,取哈希或累加某个整数字段的值。有个稍微暴力但很实用的做法:对每张表的每行做一次拼接,算出 CRC32,然后求和,两边对比。
python复制# 在SQLite侧计算每张表的校验值
SELECT SUM(CAST(crc32 AS UNSIGNED)) FROM (
-- 这里对每行做拼接后算CRC32,需要借助自定义函数或逐行在外面算
);
实际写代码的时候,用 Python 遍历 SQLite 的每一行,计算每行的 CRC32 并累加,然后在 MySQL 侧同样遍历全部行算一遍。两张表各写一个脚本,只要累加结果一致,数据内容基本就没问题。
第三层:抽样对比。对数据量大的表,随机抽取 100 条记录,逐字段对比源库和目标库的值。抽样不是靠运气,而是用 SQL 的 ORDER BY RAND() LIMIT 100 随机取,或者按主键 ID 均匀间隔采样。抽样能发现 CHECKSUM 可能漏掉的细节问题,比如 NULL 和空字符串的差异、浮点数精度变化。
7.2 业务回归:最有效的验证方式
脚本校验完之后,我心里还是没底。真正让我确认迁移成功的是业务回归测试。
我当时先起了一个只读模式的测试服务,连到 MySQL 目标库,把核心接口都打了一遍。重点关注:
- 用户登录、鉴权流程是否正常
- 订单列表、详情接口返回的数据是否和源环境一致
- 报表统计的数字和从 SQLite 里查出来的是否一样
- 管理后台的分页、排序、筛选功能是否正常
很多问题在脚本层面根本发现不了。比如有一个接口依赖 SQLite 的 || 拼接字符串,代码在迁移前用的是 SQLite 语法,MySQL 上报错 500,只有真正调用接口才会暴露。还有一个接口依赖隐式类型转换,SQLite 里字符串数字可以自动转,MySQL 里严格模式直接报错。
灰度切换的策略上,我是先把一个小业务模块切到 MySQL,观察两三天日志,确认没有异常后再把全量流量切过去。这样即使有问题,影响的也只是部分用户,不会造成大面积故障。
最后再分享一个小技巧
迁移过程中我踩了一个比较隐蔽的坑,就是 sqlite_sequence 表。如果源库用到了 AUTOINCREMENT,SQLite 内部会自动维护一张 sqlite_sequence 表,用来记录每个表的自增值。迁移时很多人只导业务表,忘了这张系统表,结果 MySQL 里的自增起始值全从 1 开始。虽然业务上不会报错,但如果你有外部系统依赖自增 ID 作为唯一凭证(比如订单号拼接),ID 从头开始就可能造成数据混淆。
我当时的处理方式是:迁移完成后,用 SQL 把每张表的 MAX(id) 作为下一个自增值重设一遍。MySQL 里可以这样操作:
sql复制-- 重设自增起始值为max+1
ALTER TABLE orders AUTO_INCREMENT = 1000001;
或者用 AUTO_INCREMENT = (SELECT MAX(id) + 1 FROM orders) 这种动态写法(注意 MySQL 不允许在 ALTER TABLE 的子查询里直接引用目标表,需要先用变量取出来)。
另外一个建议是,迁移脚本里所有的数据读取和插入都用生成器或分批游标做,不要一次性 fetchall()。120 万行看着不大,但加上 BLOB 字段后一次性加载,内存占用轻松超过几个 G,程序会被直接杀掉。分批提交(每 1000 行一个事务)不仅能控制内存,还能在报错时快速定位到是哪一批数据出了问题。
这次迁移从开始摸底到切完生产,前后花了一周时间。回想起来,真正耗时间的不是 SQL 转换,而是数据质量问题。SQLite 把很多约束问题掩盖了,MySQL 会把它们全部暴露出来。如果你也在做类似的迁移,我的建议是:结构摸底花一天,数据清洗花三天,迁移执行花半天,校验和回归花一天半。按这个节奏走,踩坑的概率会小很多。
