先说一个我在不少项目里都见过的场景:数据库初始化脚本,第一次跑,一切正常,建表、插配置、初始化字典数据,一气呵成。第二天你想把本地环境清掉重新来一遍,于是直接 source 了一下同一个脚本,红字一片:Duplicate entry '1001' for key 'PRIMARY',紧接着发现某个字典表的数据莫名其妙翻了一倍。这不是手滑,而是脚本本身就没有考虑“再跑一次”的情况。
这篇文章要解决的就是这件事:把数据库初始化脚本里最让人头疼的配置数据插入语句,写成幂等、稳定的版本。所谓幂等,就是同一份脚本对同一份数据执行一百次,结果和执行一次完全一致。适合谁看?日常工作要维护初始化脚本的后端开发、运维,被数据库课程设计折磨的同学,以及想给达梦、人大金仓这类国产数据库写兼容脚本的人,都能从中找到可以直接抄的写法。
1. 先认清翻车原因:你的脚本不是“初始化”,是“一次性操作”
1.1 还原一个典型的翻车现场
我见过太多次这种报错了,几乎每个项目里都有一个“祖宗级”初始化脚本,它大概长这样:
sql复制CREATE TABLE sys_config (
code VARCHAR(64) PRIMARY KEY,
val VARCHAR(512),
remark VARCHAR(255)
);
INSERT INTO sys_config (code, val, remark) VALUES ('order.timeout', '300', '订单超时时间');
INSERT INTO sys_config (code, val, remark) VALUES ('upload.max.size', '10240', '上传文件大小上限');
第一次跑,没问题。第二次跑,CREATE TABLE 直接报 Table 'sys_config' already exists。就算你把建表语句改成 CREATE TABLE IF NOT EXISTS,紧随其后的 INSERT 也会因为主键冲突再次爆炸。而一旦你把 INSERT 全部删掉重跑,又会面临“刚才插到一半挂了,前面成功后面失败”的烂摊子。
这就是典型的“一次性操作”脚本:它只对空库、全新环境成立。可现实是,大多数人跑初始化脚本的次数不止一次,目标库也远没有你想的那么干净。
1.2 初始化脚本背后的两个幻觉
为什么所有人都知道要写幂等脚本,但写出来的还是不能重跑?因为大家在动手时默认了两个幻觉:
- 幻觉一:脚本只会在全新环境里跑一次。 真实情况是,开发库、测试库、预发库、生产库可能都已经有数据了,初始化脚本要反复执行,甚至同一个库要跑好几遍。
- 幻觉二:目标数据库永远是“干净”的。 你手一抖,测试环境被同步工具灌入了线上的一部分数据;或者你自己开发时手动改过某一行配置;再或者上一个版本的初始化脚本只跑了一半,留下一个半新半旧的库。这些情况都比“全新空库”常见得多。
所以,判断一个初始化脚本是否合格,标准只有一个:它可以对任意状态的数据库重复执行,最终状态总是你想要的。
1.3 “幂等”到底是什么:和“快速幂”真不是一回事
搜索“幂等”这个词的时候,经常会看到“快速幂”“幂和数”“自幂数”这些算法名词,很多人就懵了:数据库初始化脚本和算法题有什么关系?
其实完全是两码事。算法里的“快速幂”是快速计算乘方的技巧,而“幂等”来自数学里的幂等律:f(f(x)) = f(x),一个操作重复执行多少次,结果都一样。
这个概念更常见的场景是 HTTP 接口。GET、PUT、DELETE 理论上都是幂等请求,POST 不是;所以做接口幂等性设计的时候,通常会让客户端传一个唯一请求号,服务端发现同一个请求号已经处理过了就直接返回旧结果,不重复下单、不重复扣款。数据库初始化脚本里的幂等,思想完全一样:不管这个 INSERT 语句执行了几次,表里的最终数据不能多、不能少、不能错。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 五种最常见的“不幂等写法”:看看你中了几条
2.1 无脑 INSERT,第二次执行必炸
最简单的错误,就是没有任何存在性判断,上来直接插入:
sql复制INSERT INTO sys_config (code, val, remark) VALUES ('order.timeout', '300', '订单超时时间');
只要目标表有主键或唯一索引,这条语句第二次执行就会报 Duplicate entry。很多刚入门的同学第一反应是“那我把脚本放到只跑一次的流水线里不就行了”,但一台数据库往往被多个流程共享,没有哪个环境能保证永远只执行一次。
正确做法是给配置表设计有业务含义的唯一键,比如 code 字段,插入时使用后文要讲的 ON DUPLICATE KEY UPDATE 或 NOT EXISTS 写法,而不是依赖“人工保证只跑一次”。
2.2 DELETE 再 INSERT,自作聪明的反面教材
有人为了解决重复插入问题,会改成“先删后插”:
sql复制DELETE FROM sys_config WHERE code = 'order.timeout';
INSERT INTO sys_config (code, val, remark) VALUES ('order.timeout', '300', '订单超时时间');
看起来逻辑没问题,但它有两个致命隐患。第一,如果 DELETE 和 INSERT 之间的条件没对上,比如你删的是 code='order.timeout',插入的却是 code='order.timeout.v2',脚本跑完后旧配置没了,新配置也没插进去。第二,这条语句不是原子的,如果 DELETE 成功而 INSERT 因为某种原因失败,中间态就是“配置彻底丢失”,比重复更可怕。更别提有些配置表还挂着外键,DELETE 会被外键约束挡住,或者把关联子表一起级联删掉,事故现场惨不忍睹。
2.3 依赖自增 ID,换个环境就串位
配置数据里经常会有“关联关系”,比如菜单表 id 关联按钮表 menu_id。如果你初始化脚本写成:
sql复制INSERT INTO sys_menu (id, name, path) VALUES (NULL, '订单管理', '/order');
SET @menu_id = LAST_INSERT_ID();
INSERT INTO sys_menu_button (menu_id, btn_name) VALUES (@menu_id, '查询');
第一次跑,@menu_id 是 1001,第二次跑,@menu_id 变成了 2001。如果业务代码里硬编码了菜单 ID,或者两张表的关联关系提前约定好了(比如前端写死 menu_id=1001),数据就全串了。
对于配置类数据,我强烈建议使用有明确业务含义的固定主键,比如菜单编码用 'M001',配置项用 'order.timeout'。这样插入语句本身就是自描述的,重复执行也不会因为自增 ID 变化产生蝴蝶效应。
2.4 对字符集和大小写毫无感知的“隐形不幂等”
有一种不幂等,脚本本身没问题,但跑在不同环境结果不一样。最常见的就是大小写和字符集问题。
比如你的唯一键是 code,本地库排序规则是 utf8mb4_general_ci,大小写不敏感,那 'Order.Timeout' 和 'order.timeout' 会被视为重复;而生产库如果用了 utf8mb4_bin,大小写敏感,两边就是两条不同的配置。同一个脚本,开发环境跑得好好的,生产环境一执行就出现重复数据。
还有一类是 Unicode 和 emoji。数据库是 utf8 字符集的话,插入 emoji 会直接报错,但同样的脚本在 utf8mb4 库上又能成功。你以为是脚本幂等性问题,实际上是字符集兼容性决定脚本能否稳定复现。要解决这类问题,唯一靠谱的办法是:在脚本里显式 SET NAMES utf8mb4,建表时显式指定 CHARACTER SET utf8mb4 COLLATE utf8mb4_bin,不要让数据库默认值替你做决定。
2.5 一坨存储过程解决所有问题:维护成本爆炸
还有一种写法,是把所有判断逻辑都塞进存储过程:
sql复制CREATE PROCEDURE init_config()
BEGIN
IF NOT EXISTS (SELECT 1 FROM sys_config WHERE code = 'order.timeout') THEN
INSERT INTO sys_config ...
END IF;
END;
这种方式确实能保证一定程度的幂等,但问题在于:存储过程的语法在不同数据库之间差异巨大。MySQL 的 IF NOT EXISTS 语法,到达梦、人大金仓、Oracle 里可能就完全不认;而且存储过程里字段多、分支多,一旦业务调整,改起来极其痛苦。等你把这个存储过程从开发库同步到测试库,可能已经出现三四个不同版本了。
初始化脚本最重要的是“一眼能看懂、到处能复用”,最好不要用存储过程这种重武器。SQL 语句本身就能解决的幂等问题,没必要引入额外的抽象层。
3. 幂等插入的常用姿势:INSERT IGNORE、ON DUPLICATE KEY UPDATE、NOT EXISTS、MERGE
3.1 INSERT IGNORE:最省事,但会吞掉真正的错误
MySQL 里最简单的幂等插入写法是 INSERT IGNORE:
sql复制INSERT IGNORE INTO sys_config (code, val, remark) VALUES ('order.timeout', '300', '订单超时时间');
它的行为是:插入时如果遇到主键冲突或唯一键冲突,就静默跳过这行,不报错。注意,这是最容易上手的写法,但不是无脑用的。因为 INSERT IGNORE 会“吞掉”很多不只是重复键的错误,比如字段超长、违反非空约束、违反外键约束,它都会一并忽略。你可能以为插入成功了,实际上那行数据根本没进去。
我见过一个真实的例子:某字段是 VARCHAR(50),初始化脚本里塞了个 60 字节的字符串,普通 INSERT 会报错提示超长,但用了 INSERT IGNORE 之后,数据被静默截断,业务跑起来才发现异常,排查了半天。所以用 INSERT IGNORE 的前提是:你已经确定这段插入语句不可能产生“非重复类错误”,或者你完全不在意被截断。
3.2 ON DUPLICATE KEY UPDATE:有则更新、无则插入
比起 INSERT IGNORE,我更常推荐 ON DUPLICATE KEY UPDATE:
sql复制INSERT INTO sys_config (code, val, remark) VALUES ('order.timeout', '300', '订单超时时间')
ON DUPLICATE KEY UPDATE
val = VALUES(val),
remark = VALUES(remark);
这段 SQL 的含义是:如果 code 对应的记录不存在,就插入;如果存在,则把 val 和 remark 更新为脚本里的值。用它,整个初始化脚本执行完,配置永远是脚本里的最终形态,而且天然幂等。
在 MySQL 8.0.20 之后,VALUES() 函数被标记为废弃,官方推荐使用行别名的新写法:
sql复制INSERT INTO sys_config (code, val, remark) VALUES ('order.timeout', '300', '订单超时时间') AS new
ON DUPLICATE KEY UPDATE
val = new.val,
remark = new.remark;
老项目如果还在用 5.7,VALUES() 完全没问题;新项目建议直接上新语法,省得以后升级报错。
这里有一个新手容易踩的坑:如果你不想每次重跑初始化脚本都去刷新业务侧已经人工改过的配置,就不要一律 UPDATE,而是加一层判断:
sql复制ON DUPLICATE KEY UPDATE
val = IF(LENGTH(sys_config.val) > 0 AND sys_config.val != VALUES(val), sys_config.val, VALUES(val));
这种策略就复杂了。所以我一般分场景处理:
- 字典类、枚举类数据:脚本是权威来源,直接
UPDATE覆盖,免得人工改乱了。 - 系统参数类:如果线上允许运维人工调参,初始化脚本应只负责“插入缺失项”,不覆盖已有配置,这时用
INSERT IGNORE或INSERT ... ON DUPLICATE KEY UPDATE val = sys_config.val都不完美,最清晰的是NOT EXISTS写法。
3.3 NOT EXISTS + INSERT:标准 SQL 风格,兼容性最好
NOT EXISTS 写法的核心思想是“先查后插”,只有不存在才插入:
sql复制INSERT INTO sys_config (code, val, remark)
SELECT 'order.timeout', '300', '订单超时时间'
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM sys_config WHERE code = 'order.timeout'
);
它在 MySQL、PostgreSQL、达梦、人大金仓这些数据库里都能跑,通用性极强,适合那些不支持 ON DUPLICATE KEY UPDATE 或者你不想依赖数据库私有语法的场景。
但它有一个并发隐患:如果两个连接同时执行这条语句,两个 NOT EXISTS 都判断为“不存在”,然后两个 INSERT 都执行,还是会出现主键冲突。所以用这种写法时,目标表上必须保留主键或唯一索引作为兜底,一旦撞上重复键至少会报错,而不是静默产生脏数据。在单机、低并发初始化场景下,这个隐患实际影响不大,但也别完全裸奔。
3.4 MERGE 和 ON CONFLICT:什么时候才值得用
PostgreSQL 用 INSERT ... ON CONFLICT (code) DO UPDATE SET ... 处理冲突,本质上和 MySQL 的 ON DUPLICATE KEY UPDATE 是一回事;SQL Server、Oracle、达梦则支持标准 MERGE 语法:
sql复制MERGE INTO sys_config t
USING (SELECT 'order.timeout' AS code, '300' AS val FROM DUAL) s
ON (t.code = s.code)
WHEN MATCHED THEN UPDATE SET t.val = s.val
WHEN NOT MATCHED THEN INSERT (code, val) VALUES (s.code, s.val);
MERGE 的能力很强,可以处理多条数据、复杂匹配条件、甚至同时做插入更新删除。但它的语法也复杂,写错条件很容易把全表数据改得面目全非,所以我在初始化脚本里一般只用来做单个表的数据同步,逻辑超过三行就果断放弃,改用逐条幂等插入。
3.5 选型对比表格与我的建议
| 写法 | 适用数据库 | 作用 | 最大风险 | 推荐度 |
|---|---|---|---|---|
| INSERT IGNORE | MySQL 等 | 存在则跳过 | 静默吞掉非重复类错误 | 可选,需确认无其他错误 |
| ON DUPLICATE KEY UPDATE | MySQL、达梦兼容模式 | 存在则更新/可选择不覆盖 | 新版本语法差异、误覆盖人工配置 | 常用 |
| NOT EXISTS + INSERT | 几乎所有 SQL 数据库 | 仅插入缺失项 | 并发下可能撞唯一键 | 通用方案 |
| INSERT ... ON CONFLICT | PostgreSQL | 冲突时更新或忽略 | 写法与 MySQL 不同 | 适用 PG 项目 |
| MERGE | Oracle、SQL Server、达梦 | 复杂同步 | 语法重、改写风险高 | 复杂场景才用 |
我的个人建议是:MySQL 项目优先用 ON DUPLICATE KEY UPDATE,需要兼容多数据库(比如同时发往达梦、人大金仓)就退回到 NOT EXISTS + INSERT;纯新增、绝不允许覆盖的配置,可以直接 INSERT IGNORE,但必须在脚本注释里写明“本处忽略重复键,但不忽略其他错误”。
4. 批量导入配置数据时,幂等性怎么保住
4.1 批量导入的三种乱象:部分成功、顺序依赖、隐式事务
配置数据很少只有一条,实际初始化脚本往往是几百上千行 INSERT。批量场景下,三个问题会叠加放大:
- 部分成功:默认
autocommit=1,每一条 INSERT 都是独立事务。跑了 500 条,第 501 条因为数据问题失败,脚本中断,前面 500 条已经写进库了。你再跑一次,前面 500 条就撞主键。 - 顺序依赖:有些配置表有外键或父子关系,比如先插菜单、再插按钮,先插字典类型、再插字典项。如果脚本顺序被打乱,或者中途挂了,依赖关系就断裂。
- 隐式事务:你以为一条 INSERT 报错不会影响其他数据,确实不会,但“脚本必须一次性完整执行”这件事就没法保证了。
所以批量导入的第一步,是用显式事务包裹整个数据段:
sql复制START TRANSACTION;
INSERT INTO sys_dict ...;
INSERT INTO sys_dict ...;
-- 中间任何一条失败,手动 ROLLBACK 或让脚本直接中断
COMMIT;
在 MySQL 里,如果事务中途连接断掉,客户端通常会自动回滚;但如果脚本写的是多个独立的 INSERT 没有事务包裹,断点之前的数据就已经落库了。
4.2 分批提交与错误隔离:命中第 500 行出错也不影响前面
有人问:“那我把 1000 条数据放在一个事务里,是不是就行了?”理论上是,但实践中有两个问题。第一,事务太大,持有锁的时间太长,可能引发数据库死锁或锁等待超时;第二,如果脚本里某一行数据真的是脏的,事务回滚后你完全不知道是第几行的问题,排查成本很高。
我的做法是“分段事务 + 批次标记”。比如把 1000 条数据拆成 10 批,每批 100 条,放在一个事务里:
sql复制START TRANSACTION;
-- 批量插入第 1 批(100 条)
INSERT INTO sys_dict (...) VALUES (...), (...), ...;
COMMIT;
只要每一批用幂等插入语法,那么“跑完一批成功、跑下一批失败”之后,整体的最终状态依然正确:重跑一次脚本,已经在库里的批次会被幂等掉,失败的批次继续补跑。这种错误隔离,对大型初始化脚本非常实用。
4.3 把数据文件本身设计成“可重复导入”
很多人用数据文件(CSV、Excel)导入配置,脚本则是:
sql复制LOAD DATA INFILE '/tmp/config.csv' INTO TABLE sys_config;
这个语句本身是不幂等的,跑两次数据就翻倍。要给 LOAD DATA 加幂等性,通常要先处理目标表:
sql复制START TRANSACTION;
DELETE FROM sys_config WHERE source_file = 'config_v1.csv';
LOAD DATA INFILE '/tmp/config_v1.csv'
INTO TABLE sys_config
FIELDS TERMINATED BY ',';
COMMIT;
这里我用 source_file 字段标记“这批数据来自哪个文件”,导入前先按文件批次删除旧数据,再导入新数据。这样同一个文件重复导入,最终数据也不会翻倍。没有这个标记列的话,就只能用 TRUNCATE 或大范围 DELETE,风险大得多。
5. 从“单条幂等”到“全脚本稳定”:事务、锁和版本管理
5.1 DDL 和 DML 混在一个脚本里,是事务碎掉的万恶之源
很多人不管三七二十一,把建表、加索引、插数据全部写在一个脚本里,然后用事务包起来。写 MySQL 的人都知道,CREATE TABLE、ALTER TABLE 这些 DDL 语句会触发隐式提交,也就是说,你事务里前面的 DML 在 DDL 出现的时候就已经悄悄提交了,后面的 DML 如果失败,整体回滚根本做不到。
所以,我强烈建议把初始化脚本拆成两个物理文件:
schema.sql:只负责表结构、索引、约束,里面每条 DDL 都用IF NOT EXISTS或等价语法;这个文件允许单独重跑。data.sql:只负责配置数据插入,全部使用幂等插入语法,外面包一个显式事务。
只要拆成两个文件,你就能清楚地知道:结构变更失败,重新跑 schema.sql 不会损害数据;数据插入失败,重新跑 data.sql 不会产生重复。两个文件职责清晰,排查问题时也能直接定位。
5.2 并发执行初始化脚本:死锁和唯一键冲突可能同时来
现实中还有一个魔鬼细节:两个发布流程同时跑初始化脚本。比如你这边在插入字典数据,那边 CI 流水线也在初始化同一张表。两条 SQL 同时做 NOT EXISTS 判断,随后同时插入,就会出现唯一键冲突;如果还带了更新操作,锁竞争加剧,可能直接触发数据库死锁。
我的对策有三层:
- 脚本入口加“执行锁”:MySQL 可以用
GET_LOCK实现应用级锁。 - 插入语句全部走幂等语法:即使锁没抢到,撞到唯一键,也不至于把脚本整体弄崩。
- 配置表一律保留唯一约束:这是最后一道物理防线,所有判断在并发情况下都可能失效,但约束不会。
sql复制SELECT GET_LOCK('init_script_lock', 10);
-- 如果返回 1,说明拿到锁;返回 0,说明等锁超时,脚本直接退出
START TRANSACTION;
-- ... 幂等插入 ...
COMMIT;
SELECT RELEASE_LOCK('init_script_lock');
GET_LOCK 在某些主从架构下可能不被同步,但它对“防止同一台主库上并发执行初始化脚本”非常有效,是我在关键项目里必加的保险。
5.3 用 migration 表记录执行过的脚本版本
要真正做到“全脚本稳定”,单条 SQL 的幂等还不够,最好在数据库里加一张版本记录表:
sql复制CREATE TABLE IF NOT EXISTS schema_migrations (
version VARCHAR(64) PRIMARY KEY,
description VARCHAR(255),
executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
每个初始化脚本开头都带上自己的版本号,执行前先查这张表:
sql复制INSERT INTO schema_migrations (version, description)
SELECT '20240601_001', '初始化订单配置'
WHERE NOT EXISTS (SELECT 1 FROM schema_migrations WHERE version = '20240601_001');
如果插入影响行数为 0,说明这个脚本已经执行过,直接跳过。这种“脚本版本记录 + 幂等插入”的组合,是目前工程实践里最稳的初始化方案。但要注意:如果脚本内容改了,版本号必须跟着变,否则旧版本号会让新脚本被跳过,变更永远不生效。所以版本号一定要绑定“脚本内容变化”,而不是绑定“日期”。
5.4 多环境差异:字符集、排序规则、sql_mode 才是隐形杀手
最后一种稳定性问题,是同一个脚本在不同环境的数据库参数下表现完全不同。最常见的三个变量:
lower_case_table_names:Linux 上通常为 0,区分表名大小写;Windows 上通常为 1,不区分。你在 Windows 建的表叫Sys_Config,Linux 上访问sys_config可能直接报Table doesn't exist。sql_mode:严格模式下,给非空字段插入空串都可能会报错;宽松模式下能成功。初始化脚本一旦触发了严格模式的报错,整个脚本中断。- 字符集和排序规则:前面提到过,
utf8mb4_general_ci和utf8mb4_bin对大小写、重音字符的识别完全不同。
应对方法没有捷径:在脚本头部显式设置环境参数,而不是依赖数据库的默认值。
sql复制SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
同时,建表语句里显式指定字符集和排序规则,比如 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin。这样无论数据库默认配置是什么,脚本都能按预期执行。
6. 一份经过实战打磨的配置数据初始化脚本模板
6.1 脚本骨架:头部注释、SET、幂等 DDL、幂等 DML、收尾校验
我把自己在项目中常用的初始化脚本模板整理如下,它兼顾了可重跑、可审计、可排错三个目标:
- 头部注释:写清楚脚本版本、用途、目标环境、变更记录。
- 环境设置:
SET NAMES、GET_LOCK、必要时SET FOREIGN_KEY_CHECKS=0。 - 幂等 DDL:所有建表语句都带
IF NOT EXISTS,显式指定字符集。 - 幂等 DML:所有配置插入都用
ON DUPLICATE KEY UPDATE或NOT EXISTS。 - 收尾校验:用
SELECT COUNT(*)检查关键数据条数,脚本结尾返回明确成功信号。 - 释放
GET_LOCK。
6.2 完整示例:字典表 + 配置表的幂等插入
sql复制-- ============================================
-- 初始化脚本 v20240601_001
-- 目标环境:test / staging / prod
-- 说明:系统配置与字典数据初始化,可重复执行
-- 变更记录:
-- v20240601_001 新增订单超时时间、菜单基础配置
-- ============================================
SET NAMES utf8mb4;
SELECT GET_LOCK('init_script_lock', 30);
SET FOREIGN_KEY_CHECKS = 0;
-- 1. 建表(可重复执行)
CREATE TABLE IF NOT EXISTS sys_config (
code VARCHAR(64) NOT NULL COMMENT '配置编码',
val VARCHAR(512) NOT NULL COMMENT '配置值',
remark VARCHAR(255) DEFAULT NULL COMMENT '备注',
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (code)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_bin COMMENT '系统配置表';
CREATE TABLE IF NOT EXISTS sys_dict (
dict_type VARCHAR(64) NOT NULL COMMENT '字典类型',
dict_code VARCHAR(64) NOT NULL COMMENT '字典项编码',
dict_name VARCHAR(128) NOT NULL COMMENT '字典项名称',
sort_no INT DEFAULT 0 COMMENT '排序号',
PRIMARY KEY (dict_type, dict_code)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_bin COMMENT '数据字典表';
-- 2. 配置数据(可重复执行)
-- 策略说明:配置表以脚本为权威,重跑时覆盖 val 和 remark。
INSERT INTO sys_config (code, val, remark) VALUES
('order.timeout', '300', '订单超时时间,单位秒'),
('upload.max.size', '10240', '上传文件大小上限,单位KB')
ON DUPLICATE KEY UPDATE
val = VALUES(val),
remark = VALUES(remark);
-- 3. 字典数据(可重复执行)
-- 策略说明:字典项以脚本为权威,重跑时更新名称和排序。
INSERT INTO sys_dict (dict_type, dict_code, dict_name, sort_no) VALUES
('order_status', '0', '待支付', 1),
('order_status', '1', '已支付', 2),
('order_status', '2', '已发货', 3)
ON DUPLICATE KEY UPDATE
dict_name = VALUES(dict_name),
sort_no = VALUES(sort_no);
-- 4. 收尾校验
SELECT COUNT(*) AS config_count FROM sys_config;
SELECT COUNT(*) AS dict_count FROM sys_dict;
SET FOREIGN_KEY_CHECKS = 1;
SELECT RELEASE_LOCK('init_script_lock');
这套模板在空库上跑,是插入;在非空库上跑,是校验和覆盖;跑多少次都不会出现重复数据和中断状态。对于需要兼容达梦、人大金仓这类数据库的情况,把 ON DUPLICATE KEY UPDATE 换成 NOT EXISTS + INSERT 即可,其他骨架可以原样保留。
6.3 我建议你养成的几个习惯
最后分享几个我在实际项目里反复踩坑后才养成的习惯。
第一,初始化脚本必须纳入版本管理,和代码一起提交。线上环境跑了什么版本的脚本,必须能在 Git 历史里查到。很多人维护脚本只用“生产服务器上一份、测试服务器上一份”,最后两边差异大得根本没法对账。
第二,每次动初始化脚本,先在空库上验证一次,再在带数据的库上验证一次。空库验证能发现建表遗漏,带数据验证能发现幂等逻辑缺陷。用 Docker 起一个 MySQL 临时实例,跑完脚本再删掉,成本很低,效果却比任何纸面审查都好。
第三,脚本头部一定要注释“该脚本可重复执行”还是“仅限一次性执行”。这个看似不起眼的注释,能救回很多后来接手的人。我见过太多同事拿到一个脚本,完全不知道能不能重跑,只好小心翼翼地备份、导出、试跑、对比,浪费一下午。
初始化脚本不是写出来就完了,它是会持续陪伴项目运行的基础设施。把每一条配置数据插入语句写成幂等的,把每一段批量操作放进可控的事务里,再给整套脚本加一个版本记录,这三个习惯做到位,绝大多数“脚本跑挂了”“数据翻倍了”“环境同步不上了”的破事,都能在发生之前被拦下来。
