MySQL SQL_MODE 从连接继承,迁移后错误为何不一致
同一条 INSERT,命令行报错,应用却成功写入;新启动的应用报错,运行很久的旧实例仍然成功。排查这种差异,先别急着比较 ORM 版本。把 @@SESSION.sql_mode 放进出问题的那条连接,通常比查看服务器配置文件更直接。
sql_mode 有全局值,也有会话值。MySQL 8.4 中,修改全局值影响随后建立的连接,已经存在的会话不会随之换档。连接池恰好会把这个差异保存很久:一次请求结束只把物理连接归还给池,下一次请求仍可能使用原来的会话。
用两条连接复现,不改全局配置
以下操作只用于隔离实验库,表使用 InnoDB。打开两个命令行窗口,连接同一实例、同一数据库。先在任意窗口创建表:
CREATE TABLE mode_import_sample (
id BIGINT NOT NULL,
external_code VARCHAR(4) NOT NULL,
units TINYINT UNSIGNED NOT NULL,
PRIMARY KEY (id)
) ENGINE=InnoDB;
窗口 A 保存原设置,开启严格模式:
SET @old_mode_a = @@SESSION.sql_mode;
SET SESSION sql_mode = 'STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION';
SELECT CONNECTION_ID(), @@GLOBAL.sql_mode, @@SESSION.sql_mode;
-- 预期失败:字符串超过 external_code 的长度。
INSERT INTO mode_import_sample (id, external_code, units)
VALUES (1, 'ABCDE', 3);
窗口 B 则为这次实验显式关闭严格模式,别在正式库照搬这一设置:
SET @old_mode_b = @@SESSION.sql_mode;
SET SESSION sql_mode = 'NO_ENGINE_SUBSTITUTION';
INSERT INTO mode_import_sample (id, external_code, units)
VALUES (2, 'ABCDE', 3);
SHOW WARNINGS;
SELECT id, external_code, units
FROM mode_import_sample
ORDER BY id;
在非严格模式下,第二条写入会截断过长字符串并产生警告。表里出现 ABCD,不意味着原值合法,更不意味着应用获得了一次无损导入。很多驱动只把 SQL 异常交给业务层,警告没有进入日常日志,数据调整就这样安静地发生了。
接着在 A 写一个合法值,在 B 写一个越界值:
-- 窗口 A:合法输入。
INSERT INTO mode_import_sample (id, external_code, units)
VALUES (3, 'OK', 10);
-- 窗口 B:255 是该列能存储的最大整数。
INSERT INTO mode_import_sample (id, external_code, units)
VALUES (4, 'MAX', 300);
SHOW WARNINGS;
SELECT id, external_code, units
FROM mode_import_sample
WHERE id IN (3, 4);
这里关注的不是错误编号,而是同一张表、同一列能否保存原始业务输入。严格模式阻止错误数据进入;非严格模式可能调整输入后继续。表结构没有变化,会话条件已经足以改变结果。
为什么改完全局值,问题只消失一半
假设管理员将全局模式改成预期配置。此刻应用池内有 20 条已经建立的连接,另一个实例刚扩容,开始新建连接。两组请求可能同时连接同一台 MySQL,却有不同的会话值。
判断时要把证据记到具体连接,而不是记录一句“数据库已经开严格模式”。至少记录:
SELECT
CONNECTION_ID() AS connection_id,
VERSION() AS server_version,
DATABASE() AS current_database,
@@GLOBAL.sql_mode AS global_mode,
@@SESSION.sql_mode AS session_mode;
这条查询必须在执行失败 SQL 的物理连接上运行。另开管理窗口查看会话值,只能证明管理窗口自己的状态。经过读写代理、分库路由或故障切换时,还应一起核对实际实例身份,避免把不同实例当成同一环境。
如果需要从管理侧抽查会话,MySQL 8.4 的 Performance Schema 提供按线程展示的变量表。在具备读取权限且相关会话可见的条件下,可以这样关联连接编号:
SELECT
t.PROCESSLIST_ID,
t.PROCESSLIST_USER,
t.PROCESSLIST_HOST,
v.VARIABLE_VALUE AS session_sql_mode
FROM performance_schema.variables_by_thread AS v
JOIN performance_schema.threads AS t
ON t.THREAD_ID = v.THREAD_ID
WHERE v.VARIABLE_NAME = 'sql_mode'
AND t.PROCESSLIST_ID IS NOT NULL
ORDER BY t.PROCESSLIST_ID;
这比只看全局值更接近连接池现状,但仍应以异常请求自身的连接证据为准。采样查询结束之后,连接可能断开,也可能被应用重新设置。
把模式放在建连契约里
迁移验收只比较表定义和数据行数,容易漏掉这个条件。一个导入程序如果允许截断,一个在线应用却要求严格写入,迁移完成后两个入口产生的数据质量就不同。
比较稳妥的处理是明确应用要求的模式集合,在创建物理连接时设置并校验,而不是每个请求随手修改。连接池常有初始化 SQL;它通常只在物理连接创建时执行,是否在借出、归还或重置时再次执行,要查看所用池和驱动的具体行为。不能因为配置项名字里有 init,就认为一次请求改过的会话状态必然被恢复。
模式字符串也不宜用简单的全文相等比较。顺序不同不代表含义不同。应用可按逗号拆分为集合,验证必须包含的项目,并明确是否允许额外模式。比如业务要求 ONLY_FULL_GROUP_BY,就不能只检查“包含 STRICT”后放行。严格写入与分组合法性是两件事。
不建议为了让迁移脚本跑通,直接把生产全局模式清空。那会把原本可定位的失败变成后续难追溯的静默调整。先把失败行隔离出来,决定业务上应拒绝、修正还是补默认值,再让正式导入使用与在线应用一致的会话设置。
先判定这是继承差异,还是后来的代码改写
同一连接刚建立时模式正确,过一段时间变了,已经不能用“继承了旧全局值”解释。此时应查连接初始化、导入脚本和业务执行过的 SET SESSION。有些批处理为了兼容历史输入,在任务开始时清空模式,却没有在异常路径恢复;归还给连接池后,后续请求继续承接这个状态。
普通事务回滚不会把 sql_mode 自动回到进入事务前的值。修改了会话条件,就要把恢复动作放进可靠的清理路径;如果任务代码无法保证恢复,使用独立物理连接完成导入比共享在线连接池更清楚。仅仅在 catch 里执行 ROLLBACK,没有清理会话配置。
可以把定位过程压到两个观察点:物理连接创建完成后记录一次,每次导入前在同一连接核对一次。如果第一处就错,查初始化和实际实例;如果第一处正确、第二处错,查连接复用期间的修改。这样能把范围缩小到具体生命周期,不必在所有 SQL 文件里盲目搜索。
失败行留存比“导入成功”更有价值
迁移脚本还应区分 SQL 语句成功和数据验收成功。上面的 B 窗口没有抛异常,但原始外部编号已经被截短;后续如果拿它与源系统做关联,错误会表现为找不到对象,而不是一个明显的数据库异常。
因此,试导入时应保留原始输入、行号、错误或警告信息,并检查业务键是否保持原样。SHOW WARNINGS 要紧接着目标语句读取,别在中间执行其他普通语句再来追溯。大批量导入不能只依赖客户端最后显示的“影响多少行”,更不能把所有警告都归类为可忽略的脏数据。
这里的实验只讨论事务表。混入非事务引擎后,严格模式遇到多行写入错误时还涉及已经写入行能否撤回,不能把 InnoDB 的结论直接推广过去。INSERT IGNORE 也会改变部分错误的处理方式;若两个入口一个用了 IGNORE、一个没有,即使模式一致,观察结果仍可能不同。先固定完整 SQL 和引擎,才能把剩下的差异归因到模式。
另外,存储程序可能保留创建时的 SQL 模式。若普通 INSERT 与存储过程内部同类语句仍有差异,要检查对象定义元数据,不能继续只改调用连接。以存储过程为例:
SELECT ROUTINE_SCHEMA, ROUTINE_NAME, ROUTINE_TYPE, SQL_MODE
FROM information_schema.ROUTINES
WHERE ROUTINE_SCHEMA = DATABASE();
完成两窗口实验后分别恢复原会话值。关闭连接也会结束这些会话设置,但显式恢复更方便接着做其他实验。
-- 窗口 A。
SET SESSION sql_mode = @old_mode_a;
-- 窗口 B。
SET SESSION sql_mode = @old_mode_b;