六个 SQL 脚本,语法检查全过,每个都自带 USE,没有一个 DROP TABLE。按文件名 1 到 6 执行,第二个脚本停在第 248 行。
最郁闷的是,验收的每一条都是通过的。
数据库上线,通常不是单个 SQL 文件,而是一串。建表、补表、种子数据、改字段、修链接,各一个脚本,命名从 1 排到 6。
从 1 排到 6 这个命名方式,本身就在暗示你按数字顺序执行。于是绝大多数人就这么做了。
语法检查会全过,因为每个脚本单独看都没问题。可它们之间有依赖,而依赖关系不写在文件名里。
先说环境。数据库是 MySQL 8.4.11,容器镜像 mysql:8.4.11。1Panel 容器化,端口只映射到 127.0.0.1:3306。导入方式是 docker exec -i 容器 mysql < 脚本.sql,走批处理模式。六个脚本里,建表 2 个、种子 1 个、改表 1 个、修数据 1 个、补表 1 个,终态 29 张表。
命令里用变量代替容器名,先在终端跑一次:
bash
export MYSQL_CTN="mysql84"
export DB_NAME="<你的库名>"
报错原文、缺失范围、幂等性结论都来自这次真实部署,2026 年 9 月 17 日。脚本依赖检查的输出来自我用同一批脚本做的静态扫描,9 月 19 日复核。
按文件名的数字顺序 1 到 6 执行,跑到第二个脚本,报错原文是这样的:
ERROR 1146 (42S02) at line 248: Table '<库名>.v1_demand_i18n' doesn't exist
我当时看到 ERROR 1146 的第一反应,是某个表建漏了。后来才发现,漏的不是一张表。
重点不在那一行报错,在它后面那句没写出来的话。
mysql 客户端在批处理模式下,也就是 mysql < file.sql 这种用法,默认遇错即停,除非你显式加 --force。
所以真实的杀伤范围是,第 248 行之后的每一条语句,都没有执行。
它不只是这条语句失败。从这个位置开始,整个文件剩下的部分全部被跳过。
这就是为什么这条报错比一般的报错危险得多。它断在文件中间。
中断之后,跑一遍常规验收。表数量期望 29,实测 29。库级字符集和排序规则期望 utf8mb4 加 utf8mb4_unicode_ci,实测一致。数据抽查了三张主要表,都有数据。
我当时以为三条全过就算导入成功了。
原因很简单。中断发生在建表全部完成之后、插数据进行到一半。表结构是完整的,被抽查的那三张表恰好都在第 248 行之前插完了。
只有一条路能发现它,按行号精确核对缺失范围。
第 248 行之前已经写进去 11 张表。
服务、服务 i18n、推荐、推荐 i18n、工厂、工厂 i18n、工厂产品、
产品 i18n、工厂资质、资质 i18n、留言。
第 248 行之后缺了 6 张。
v1_demands、v1_demand_i18n、v1_articles,
v1_article_i18n、v1_notifications、v1_notification_i18n。
看一眼这 6 张,它们正好等于 3_create_phase2.sql 建的那 6 张。
依赖是这样长的。1_create_auto.sql 谁都不依赖,被 2 的 11 张表种子、4、5 依赖。3_create_phase2.sql 也不依赖别人,被 2 的 Phase 2 六张表种子依赖,陷阱在这。2_seed.sql 依赖 1 加 3,被 5 依赖。4_alter_messages.sql 依赖 1。5_update_recommends.sql 依赖 2 的数据。6_create_demand_match.sql 谁都不依赖。
2_seed.sql 里含有 Phase 2 那 6 张表的种子数据。而那 6 张表由 3_create_phase2.sql 创建。
于是正确顺序是:
1_create_auto.sql → 3_create_phase2.sql → 2_seed.sql → 4_alter_messages.sql → 5_update_recommends.sql → 6_create_demand_match.sql
数字顺序 1 到 3 是错的。文件名上的 2 和 3,业务上是 seed 和 phase2。编号是按写脚本的时间顺序给的,不是按该执行的顺序。
这个坑的通用形态是,脚本按编写顺序编号,而不是按依赖顺序编号时,数字顺序就一定是错的。
当时动手之前,本地核实过两件事。六个脚本都自带 USE 语句,命令行不用指定库名。没有任何 DROP TABLE,不会误删数据。
两条都对。但两条都不够。
漏掉的是第三件,脚本之间的表依赖。语法全对、顺序错,照样中断。
可复用的检查方法,思路很朴素。A 类是每个脚本 CREATE TABLE 建了哪些表。B 类是每个脚本 INSERT INTO、UPDATE、ALTER TABLE 引用了哪些表。判据是把脚本按顺序走一遍,维护一个已建表集合,如果某个脚本引用了此刻还没建的表,就存在顺序依赖。
python
import re, io
made = lambda f: set(re.findall(r"CREATE TABLE (?:IF NOT EXISTS )?`([a-z0-9_]+)`", io.open(f, encoding="utf-8").read()))
used = lambda f: set(re.findall(r"(?:INSERT INTO|UPDATE|ALTER TABLE) `([a-z0-9_]+)`", io.open(f, encoding="utf-8").read()))
ORDER = ["1_create_auto.sql", "3_create_phase2.sql", "2_seed.sql",
"4_alter_messages.sql", "5_update_recommends.sql", "6_create_demand_match.sql"]
built = set()
for f in ORDER:
gap = used(f) - built - made(f)
print("%-28s 此刻还没建:%s" % (f, sorted(gap) if gap else "---"))
built |= made(f)
把两个顺序都跑一遍,对比就出来了(实测输出,2026 年 9 月 19 日):
【顺序】1 -> 2 -> 3 -> 4 -> 5 -> 6
1_create_auto.sql 此刻还没建: ---
2_seed.sql 此刻还没建: ['v1_article_i18n', 'v1_articles', 'v1_demand_i18n',
'v1_demands', 'v1_notification_i18n', 'v1_notifications']
3_create_phase2.sql 此刻还没建: ---
...
【顺序】1 -> 3 -> 2 -> 4 -> 5 -> 6
1_create_auto.sql 此刻还没建: ---
3_create_phase2.sql 此刻还没建: ---
2_seed.sql 此刻还没建: ---
4_alter_messages.sql 此刻还没建: ---
5_update_recommends.sql 此刻还没建: ---
6_create_demand_match.sql 此刻还没建: ---
错误顺序下,它精准点出了那 6 张表,和实际中断后缺失的 6 张完全一致。这个检查在动手前 10 秒就能跑完,本可以省掉一整轮返工。
这段脚本没用任何第三方库,纯标准库。也不需要连数据库,纯文本扫描。
发现缺数据之后,第一反应是全部重跑一遍。这一步会把小问题变成大问题。
1_create_auto.sql 不幂等。重跑会报 ERROR 1050: Table 'v1_admins' already exists 然后停止。22 条全是裸 CREATE TABLE,没有 IF NOT EXISTS。
2_seed.sql 幂等,脚本里先 DELETE 再 INSERT,重跑正常。
3_create_phase2.sql 幂等,6 条都带 CREATE TABLE IF NOT EXISTS。
4_alter_messages.sql 不幂等。重跑报 ERROR 1060: Duplicate column name 'scene_type'。
5_update_recommends.sql 幂等,UPDATE 本身幂等。
6_create_demand_match.sql 幂等,带 CREATE TABLE IF NOT EXISTS。
所以真正需要重跑的只有两个。2 是补数据,5 是因为 2 重插了推荐表数据之后,链接修正需要重新应用。
有一点要记住,那两个报错是预期行为。1 和 4 重跑时必报错,因为它们的产物已经存在了。看到 ERROR 1050 或者 ERROR 1060 就以为又坏了,会把自己带进沟里。
另一点,4 为什么没有 IF NOT EXISTS 可用。因为 MySQL 不支持 ADD COLUMN IF NOT EXISTS。这是 MySQL 相对 PostgreSQL 的一个长期缺口,加列语句天生不幂等,只能靠人工判断该不该跑。
顺带一个设计建议。新建表一律用 CREATE TABLE IF NOT EXISTS,成本极低。本例里 3 和 6 用了,重跑毫无压力;1 没用,重跑必挂。
按修正后的顺序完整重跑一遍,六个脚本全部无报错。