很多人第一次被NO_ZERO_DATE坑到,都发生在同一个场景:一个跑了好几年的老库,升级到新版本或者换了台机器重新部署,建表脚本里那句DEFAULT '0000-00-00'突然就报Invalid default value,然后一查sql_mode,发现多了两个陌生的名字——NO_ZERO_DATE和NO_ZERO_IN_DATE。网上搜到的答案十有八九是"把这俩删了就行",但真删完又会遇到新的怪事:明明关掉了,为什么insert进来的日期变成了0000-00-00而不是报错?
这篇就来把sql_mode里这两个关于date的参数彻底讲清楚:它们各自管什么、什么时候才真正生效、和严格模式是怎么配合的、存量脏数据怎么扫出来怎么清、以及应用层和运维链路上会连带出哪些问题。适合正在做版本升级、数据迁移、或者被零日期数据困扰的后端和 DBA 看,也适合刚接触 MySQL 日期类型、想搞清楚"为什么我的日期能存成 0000-00-00"的同学。
1. 两个参数的分工:别再把 '0000-00-00' 和 '2024-00-00' 混为一谈
1.1 名字很像,管的东西完全不重叠
NO_ZERO_DATE和NO_ZERO_IN_DATE这两个名字只差一个IN,但它们的管辖范围是不交叠的,这是最容易搞混的地方。
NO_ZERO_DATE管的是全零日期,也就是年份、月、日三个部分全都是00的形式:'0000-00-00'。它对应的场景通常是"这个字段我不知道填什么,先塞个零占位"。
NO_ZERO_IN_DATE管的是月或日为00、但年份不为0000的部分零日期,比如'2024-00-00'、'2024-00-15'、'2024-03-00'。这类数据通常来自一个非常具体的产品需求:日期控件只让用户选到年和月,后端没做补齐就直接往DATE列里塞,于是日就成了00。
我在排查线上问题时见过一次典型的误判:某同学发现'2024-00-00'插不进去了,把NO_ZERO_IN_DATE从sql_mode里删掉,结果'2024-00-00'确实能进了,但顺手把'0000-00-00'也放进来了——因为他的sql_mode里本来就没有NO_ZERO_DATE。反过来也常见:把NO_ZERO_DATE删了之后抱怨'2024-00-00'还是报错。
有一个细节需要特别说明:'0000-00-00'严格来说也满足"月日为00"这个形态,但它归NO_ZERO_DATE管,不需要NO_ZERO_IN_DATE。换句话说,只要NO_ZERO_DATE开着、严格模式开着,'0000-00-00'就进不来,NO_ZERO_IN_DATE开不开都无所谓。
1.2 用一张对照表把边界钉死
与其记文字描述,不如直接看行为。下面这张表是我在实际环境(MySQL 5.7与8.0,严格模式开启)下逐个验证过的结果,注意前提是严格模式已开启,下一节会解释为什么这个前提如此重要。
| 写入值 | 形态说明 | NO_ZERO_DATE单独开 | NO_ZERO_IN_DATE单独开 | 两个都开 |
|---|---|---|---|---|
'0000-00-00' | 全零日期 | 报错 1292 | 允许,原样存入 | 报错 1292 |
'2024-00-00' | 年有效,月日为零 | 允许,原样存入 | 报错 1292 | 报错 1292 |
'2024-00-15' | 月为零,日有效 | 允许,原样存入 | 报错 1292 | 报错 1292 |
'2024-03-00' | 日为零,月年有效 | 允许,原样存入 | 报错 1292 | 报错 1292 |
'2024-02-31' | 月日各自合法但组合无效 | 允许(严格模式本身会拦) | 允许(同上) | 允许(同上) |
最后一行值得单独拎出来说:'2024-02-31'这种"2 月 31 日"的非法组合,跟零日期完全是两套校验逻辑。它是被严格模式里的日期合法性检查拦下来的,跟这两个参数无关;如果sql_mode里加了ALLOW_INVALID_DATES,这个值反而能存进去,因为该选项的语义是"只校验月 1-12、日 1-31 的范围,不校验组合是否真实存在"。所以千万别把ALLOW_INVALID_DATES和这两个参数混着用,它们解决的是不同的问题。
1.3 报错信息长什么样,怎么快速定位是哪个参数拦的
被拦下来的时候,报错文案是一模一样的:
ERROR 1292 (22007): Incorrect date value: '0000-00-00' for column 'birthday' at row 1'2024-00-00'被拦也是这条,一个字都不差。所以光看报错信息没法区分是哪个参数干的。快速判断的办法是拿报错里的那个值去对:年份是0000就怀疑NO_ZERO_DATE,年份非零而月或日是00就怀疑NO_ZERO_IN_DATE。
真要确认,直接做减法比猜快得多:
-- 先把当前会话的两个参数摘掉,再插一次同样的值 SET @old_mode = @@SESSION.sql_mode; SET SESSION sql_mode = REPLACE(@@SESSION.sql_mode, 'NO_ZERO_DATE', ''); INSERT INTO t (birthday) VALUES ('0000-00-00'); SET SESSION sql_mode = @old_mode;插成功了就说明确实是它拦的。这种"逐个摘参数试"的排查方式笨但绝对有效,比翻文档快。
2. 零日期能存进去,靠的是"非严格模式"这条后门
2.1 这两个参数本身不具备强制力
这是整篇文章最核心的一点,也是绝大多数误解的来源:NO_ZERO_DATE和NO_ZERO_IN_DATE单独存在时,并不会让插入失败。它们只负责声明"我不接受零日期",而真正决定"不接受"是变成一条报错还是一条警告的,是严格模式(STRICT_TRANS_TABLES或STRICT_ALL_TABLES)。
官方文档对这两个选项的描述里有一句很关键的话,大意是"在非严格模式下,此选项不生效"。这句话翻译成人话就是:如果你把STRICT_TRANS_TABLES从sql_mode里拿掉,那么无论NO_ZERO_DATE开不开,'0000-00-00'都能顺利写进DATE列,顶多在客户端看到一条 warning。
这就解释了一个非常常见的困惑:有人在测试环境把sql_mode设成'NO_ZERO_DATE,NO_ZERO_IN_DATE',然后发现零日期照样能插进去,于是断定这两个参数是摆设。其实不是参数没用,是他把严格模式一起删了,顺带把这两个参数的牙齿也拔了。
2.2 反过来也成立:只开严格模式挡不住零日期
更要命的是反过来的情况。假设sql_mode是这么配的:
sql_mode = 'STRICT_TRANS_TABLES,ONLY_FULL_GROUP_BY,NO_ENGINE_SUBSTITUTION'严格模式开着,但那两个参数没写。这时候往DATE列插'0000-00-00',是能成功的,不会报错。我实际验证过好几次,每次跟人讲对方都不信,因为直觉上"严格模式"听起来应该什么都拦。
原因在于 MySQL 把日期校验拆成了两层:一层管"这个日期长得对不对"(月份是不是 1-12、日期是不是合法、组合是否存在),这层归严格模式;另一层专门管"零日期这个特例允不允许",这层单独交给NO_ZERO_DATE/NO_ZERO_IN_DATE。两层是并列的,谁也替代不了谁。
所以正确的配置是三个一起上阵:
sql_mode = 'STRICT_TRANS_TABLES,NO_ZERO_DATE,NO_ZERO_IN_DATE,NO_ENGINE_SUBSTITUTION,ONLY_FULL_GROUP_BY,ERROR_FOR_DIVISION_BY_ZERO'这也是MySQL 5.7和8.0的默认配置(8.0相比5.7少了一个NO_AUTO_CREATE_USER,因为该用户授权语法在8.0已被移除)。默认值这样配是有道理的,它同时堵住了两个口子。
2.3 非严格模式下的静默降级:warning 去哪了
一旦掉进非严格模式,行为会从"报错"变成"尽量存,存不了就凑合"。这时你在命令行里敲完INSERT会看到:
Query OK, 1 row affected, 1 warning (0.00 sec)1 warning就是那个零日期被放行的痕迹。用SHOW WARNINGS;能看到具体内容,还是那条 1292,只不过级别是Warning不是Error。
问题在于,大量应用框架和客户端根本不会检查 warning。JDBC、各种 ORM、大部分语言的数据库驱动,默认只关心有没有抛异常,warning 通常被静默丢弃。于是数据就这么安安静静写进去了,直到某天做报表发现一堆0000-00-00,或者做数据同步时对面系统解析不了这个值。
这就是零日期数据最难缠的地方:它不是在你插入的那一刻出问题,而是在几个月后某个下游环节出问题,等你想追溯的时候,已经不知道是谁、在什么时候、通过哪条链路写进去的。
3. 四种 sql_mode 组合下的真实行为矩阵
3.1 把组合穷举一遍
把严格模式和这两个参数做笛卡尔积,会得到八种组合,但实际有意义的就四种(因为两个参数通常同进同退)。下面这张表是我在一台干净实例上逐条验证出来的,可以当作排查时的参照:
| 严格模式 | NO_ZERO_DATE/NO_ZERO_IN_DATE | 插入'0000-00-00' | 插入'2024-00-00' | 插入'2024-02-31' |
|---|---|---|---|---|
| 关 | 关 | 成功,无警告 | 成功,无警告 | 存成'0000-00-00',有警告 |
| 关 | 开 | 成功,有警告 | 成功,有警告 | 存成'0000-00-00',有警告 |
| 开 | 关 | 成功,存入'0000-00-00' | 成功,原样存入 | 报错 1292 |
| 开 | 开 | 报错 1292 | 报错 1292 | 报错 1292 |
第三行是需要重点记住的:严格模式开着、但这两个参数关着的情况下,零日期畅通无阻。很多人排查了半天以为哪里配置错了,其实就踩在这一格上。
3.2 建表阶段的校验比插入阶段更早
除了INSERT和UPDATE,这两个参数还会在建表时发挥作用。经典场景是老系统里这种写法:
CREATE TABLE user_profile ( id INT PRIMARY KEY, birthday DATE NOT NULL DEFAULT '0000-00-00' ) ENGINE=InnoDB;在严格模式 +NO_ZERO_DATE开启的环境下,这条DDL直接失败:
ERROR 1067 (42000): Invalid default value for 'birthday'注意报错码是 1067 不是 1292,这是DDL阶段的校验,走的是另一条路径。很多老项目的建表脚本在升级后批量失败,就是因为这个。而且这类失败往往发生在初始化阶段,服务起不来,日志里一堆 1067,看着像配置文件坏了,实际上就是日期默认值不合法。
修法有两条路:改默认值为合法日期(如'1970-01-01',前提是业务认可这个哨兵值),或者把列改成可空、用NULL表示"未填写"。我更倾向后者,因为NULL语义清晰,不会被误当成真实日期参与统计。
ALTER TABLE修改已有列的默认值时也一样,只要新默认值不合法,同样会被拦:
ALTER TABLE user_profile MODIFY birthday DATE NOT NULL DEFAULT '0000-00-00'; -- ERROR 1067 (42000): Invalid default value for 'birthday'3.3 一个容易被忽略的差异:TIMESTAMP 的表现不一样
上面讨论的主要是DATE和DATETIME。TIMESTAMP的坑稍微不同,因为它曾经有过一段"自动生成零值默认值"的历史。在很老的版本里,NOT NULL的TIMESTAMP列会被自动加上DEFAULT '0000-00-00 00:00:00' ON UPDATE CURRENT_TIMESTAMP,这也是零日期泛滥的一大来源。
5.6.6之后引入了explicit_defaults_for_timestamp参数来控制这个行为,8.0里它默认就是ON,也就是说不允许这种自动补全。如果你从很老的版本迁移过来,发现TIMESTAMP列的默认值行为和原来不一样了,先去看这个参数,而不是去动NO_ZERO_DATE。
SHOW VARIABLES LIKE 'explicit_defaults_for_timestamp';这两者的关系是:explicit_defaults_for_timestamp=OFF会自动帮你生成零值默认值,然后如果NO_ZERO_DATE又开着,你就得到了一个自相矛盾的配置——系统自己生成的默认值,系统自己又不接受。这种配置在某些版本下会导致建表直接失败,非常隐蔽。
4. 改配置这件事:会话、全局、持久化与版本差异
4.1 三个层级,别改错了地方
sql_mode的查看有三个等价写法,结果一样但语义不同:
-- 当前会话生效的值(最常用) SELECT @@sql_mode; SELECT @@SESSION.sql_mode; -- 全局默认值,只影响之后新建的连接 SELECT @@GLOBAL.sql_mode;改的时候层级更重要。SET SESSION sql_mode = ...只影响当前连接,断开就失效,适合临时做数据修复;SET GLOBAL sql_mode = ...影响新连接,但已有连接保持原值不动,这点在连接池环境下特别坑。
我遇到过不止一次:DBA 在数据库上执行了SET GLOBAL sql_mode,信心满满地说"改好了",结果应用继续报错。原因就是应用侧用的是连接池,池子里的十几条连接是改之前建立的,sql_mode还是老值。要么重启应用,要么等连接池把老连接回收掉,要么在连接池配置里加一个较短的最大存活时间强制换连接。最快的办法还是重启应用进程。
4.2 做数据修复时的标准操作流程
如果要临时关掉这两个参数去处理脏数据,建议用下面这个模板,把原值存下来最后再还原:
-- 1. 备份原值 SET @old_sql_mode = @@SESSION.sql_mode; -- 2. 只摘掉这两个参数,保留严格模式和其他选项 SET SESSION sql_mode = REPLACE( REPLACE(@@SESSION.sql_mode, 'NO_ZERO_DATE', ''), 'NO_ZERO_IN_DATE', '' ); -- 3. 确认一下现在的值(注意可能出现连续逗号) SELECT @@SESSION.sql_mode; -- 4. 做完操作后还原 SET SESSION sql_mode = @old_sql_mode;注意:用
REPLACE摘参数之后,sql_mode字符串里可能留下,,这种连续逗号。MySQL 解析时会忽略空项,通常不影响功能,但如果要把结果写进配置文件,最好手工整理一下。
还有一个更省事的办法:如果是用命令行客户端操作,启动时直接带上--init-command或者进客户端后先执行SET SESSION sql_mode=''。但注意这会同时关掉严格模式,INSERT时的行为会变,操作要更小心。
4.3 持久化:SET PERSIST 还是改配置文件
SET GLOBAL是临时的,重启就没了。MySQL 8.0提供了SET PERSIST:
SET PERSIST sql_mode = 'ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION';这条命令会把值写进数据目录下的mysqld-auto.cnf,重启后依然生效。配套的两个命令是RESET PERSIST(清掉某个变量)和RESET PERSIST不带参数(清掉全部)。5.7没有这个特性,只能改配置文件:
[mysqld] sql_mode = ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION有一个细节要留神:配置文件里一旦写了sql_mode,就是完整覆盖而不是追加。很多人只想加一个参数,结果把默认的一大串全冲掉了,严格模式也没了,于是又回到了"零日期随便存"的状态。
4.4 版本差异带来的升级冲击
5.7和8.0的默认sql_mode差异不大,主要是8.0去掉了NO_AUTO_CREATE_USER。但这不意味着从5.7升到8.0就没事——真正的冲击来自更早的版本,比如从5.6甚至5.5迁过来,默认sql_mode是空的,零日期、非法日期、超长截断全都不拦。一升上来,历史遗留问题集中爆发。
另外,这两个选项在新版本里已经被官方标记为过时(deprecated),意思是未来版本可能移除。这不代表"现在就别用了",而是提醒你:不要把业务逻辑建立在"靠这两个参数兜底"上,该在应用层做的日期合法性校验还是得做。真到了它们被移除的那天,严格模式会接管全部校验,那时候如果应用还在依赖非严格模式的宽松行为,改动量会更大。
我的建议是:新项目一律用默认的严格配置,同时在应用层做完整校验;老项目在升级前,先用后面的方法把存量零日期数据清干净,再逐步启用严格配置。
5. 存量零日期数据怎么扫、怎么清,流程走一遍
5.1 为什么直接用 = '0000-00-00' 去查会翻车
第一反应肯定是这么写:
SELECT COUNT(*) FROM user_profile WHERE birthday = '0000-00-00';在严格模式 +NO_ZERO_DATE开启的环境下,这条查询会直接报 1292。原因是在比较时,MySQL 需要把字面量'0000-00-00'转成日期类型,转换过程本身就被sql_mode拦下了。查不了,自然也就没法据此清理。
把字面量加引号也没用,问题不在引号。用CAST转成字符串比较呢?
SELECT COUNT(*) FROM user_profile WHERE CAST(birthday AS CHAR) = '0000-00-00';这条能跑,但只能扫到全零日期,'2024-00-00'这种部分零的扫不出来,还得再写一堆LIKE '%-00-%',性能和可读性都不好,而且会走全表扫描加函数计算。
5.2 用 YEAR/MONTH/DAY 函数扫,绕开字面量转换
最稳妥的写法是干脆不做日期类型转换,全部走函数提取:
-- 所有含零部分的日期(全零 + 部分零),一次扫全 SELECT COUNT(*) FROM user_profile WHERE YEAR(birthday) = 0 OR MONTH(birthday) = 0 OR DAY(birthday) = 0;这条语句在严格模式下能正常执行,因为全程没有出现日期字面量。函数作用于列值,'0000-00-00'提取出的年、月、日都是0,'2024-00-00'提取出年2024、月0、日0,都能命中。
需要说清楚一点:这种写法是全表扫描,在大表上会很慢。如果表有几千万行,最好分批做,或者先加一个辅助条件缩小范围(比如按自增主键区间扫描):
SELECT COUNT(*) FROM user_profile WHERE id BETWEEN 1 AND 1000000 AND (YEAR(birthday) = 0 OR MONTH(birthday) = 0 OR DAY(birthday) = 0);如果只是想单独看全零日期,用三个条件与起来:
SELECT COUNT(*) FROM user_profile WHERE YEAR(birthday) = 0 AND MONTH(birthday) = 0 AND DAY(birthday) = 0;顺手提一句:NULL值不会命中这些条件,因为YEAR(NULL)结果是NULL,而NULL = 0不成立。这正是我们想要的效果——只找零日期,不碰空值。
5.3 清洗策略:置 NULL、改哨兵值、还是改语义
扫出来之后怎么处理,得先跟业务方对齐,这不是纯技术问题。常见的三条路:
第一条,改成NULL。前提是列允许为空。语义最干净,统计时不会把假日期算进去,AVG、MAX、BETWEEN都不会被污染。需要先把列改成可空:
ALTER TABLE user_profile MODIFY COLUMN birthday DATE NULL;注意DATE NOT NULL改成DATE NULL在大表上可能是重建表操作,会锁表或者消耗大量 IO。8.0的在线 DDL 支持部分场景的INSTANT变更,但改可空性通常不在其中。生产环境上一定要挑低峰期,或者用在线变更工具做。
第二条,改成哨兵值。比如统一改成'1970-01-01'。好处是列可以保持NOT NULL,不用改表结构。坏处是引入了一个新的"假数据",如果业务方不知道这个约定,报表里会出现一堆 1970 年出生的用户。如果走这条路,一定要在文档和字段注释里写清楚,最好再加个is_birthday_unknown之类的标记位,把语义显式化。
第三条,直接删除。如果这些记录本身就是无效数据(比如测试残留),直接物理删除最省事,但要先确认没有外键依赖,也没有业务方在引用。
不管选哪条,更新语句都是同一个形态:
-- 分批更新,避免长事务和锁表 UPDATE user_profile SET birthday = NULL WHERE id BETWEEN 1 AND 1000000 AND (YEAR(birthday) = 0 OR MONTH(birthday) = 0 OR DAY(birthday) = 0);5.4 清完之后一定要做一致性复核
清洗完成、准备开启严格配置之前,务必再复核一遍。复核的方式是:先在一个会话里把严格模式打开,然后执行一次覆盖性查询:
SET SESSION sql_mode = 'ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION'; -- 用严格模式去碰一下所有日期列,有脏数据会立刻报错 SELECT MIN(birthday), MAX(birthday) FROM user_profile;如果还有残留的零日期,MIN/MAX这类聚合在做类型处理时可能报错,等于帮你做了一次兜底检查。不过更可靠的还是回到 5.2 的扫描语句,毕竟聚合函数不一定会触发校验。
另外一个常被忽略的点:别忘了检查默认值和建表语句。数据清干净了,但表定义里还留着DEFAULT '0000-00-00',那下次插入还是可能写进零日期(虽然新版本会在建表时拦截,但老表是历史遗留,定义还在)。用下面的语句把所有日期列的默认值捞出来看一遍:
SELECT TABLE_SCHEMA, TABLE_NAME, COLUMN_NAME, COLUMN_DEFAULT, IS_NULLABLE FROM information_schema.COLUMNS WHERE DATA_TYPE IN ('date', 'datetime', 'timestamp') AND COLUMN_DEFAULT = '0000-00-00 00:00:00' OR (DATA_TYPE IN ('date', 'datetime') AND COLUMN_DEFAULT = '0000-00-00');这条查询的优先级写法有点绕,更清晰的写法是两个OR都加上括号,或者干脆用LIKE '0000-00-00%'匹配:
SELECT TABLE_SCHEMA, TABLE_NAME, COLUMN_NAME, COLUMN_DEFAULT FROM information_schema.COLUMNS WHERE DATA_TYPE IN ('date', 'datetime', 'timestamp') AND COLUMN_DEFAULT LIKE '0000-00-00%';这个查询值得存成脚本,每次升级前跑一遍。
6. 应用层与运维链路上被牵连的几个环节
6.1 空字符串:比零日期更隐蔽的雷
这个坑我在三个不同的项目里都遇到过,值得单独说。前端日期控件没选值,提交上来的是空字符串"",后端没做处理,直接拼进 SQL:
INSERT INTO user_profile (birthday) VALUES ('');在非严格模式(或者严格模式但没开NO_ZERO_DATE)下,这个空串会被悄悄转成'0000-00-00'存进去。加上NO_ZERO_DATE之后,它变成 1292 报错。
问题在于,很多开发者第一反应是"那我把NO_ZERO_DATE关掉不就行了",于是又回到了脏数据的循环。正确做法是在应用层的入口做归一化:空串、纯空格、"null"字符串,统统转成NULL。
以 Java 为例,在 DTO 转实体或者 MyBatis 的参数处理环节做一次清洗:
public LocalDate parseBirthday(String raw) { if (raw == null || raw.trim().isEmpty()) { return null; } String s = raw.trim(); // 拦截部分零日期,避免把 '2024-00-00' 直接透传给数据库 if (s.matches("\\d{4}-00(-00)?") || s.matches("\\d{4}-\\d{2}-00")) { throw new IllegalArgumentException("日期不完整:" + s); } return LocalDate.parse(s); }如果你用的是MyBatis,还有一个更集中的位置:自定义一个TypeHandler,在setNonNullParameter之前做校验,这样所有走这个类型的字段都会被覆盖到,不用在每个业务方法里写一遍。
前端那边也该改:日期选择器如果允许只选年月,提交时应该补成当月第一天或者当月最后一天,而不是留个00。这个约定要跟产品确认,"用户选了 2024 年 3 月"到底代表 3 月 1 日还是整个 3 月——如果是后者,DATE类型本身就是选错了,应该用两个字段存起止日期。
6.2 备份恢复与跨环境导入
mysqldump默认会在导出文件头部写入一行SET @@SESSION.SQL_MODE='...',把导出时的sql_mode一并带上,所以同版本之间的导出导入通常不会因为零日期出问题。
但下面这几种情况会翻车:
- 用第三方工具或者自己写的导出脚本,没有带上
sql_mode; - 从宽松环境导出、往严格环境导入,中间那行
SET被手工删了或者被文本编辑器搞乱了; - 只导数据不导结构(
--no-create-info),导入时用的是目标库的sql_mode。
出问题的表现是导入到一半中断,报 1292 或者 1067,前面已经导入的部分留在库里,得手工清理。我的做法是:导入前先在目标库把sql_mode记录一份,导入时临时降级,导完立刻还原,然后把导入期间的会话级sql_mode改回来再跑一次 5.4 的复核查询。
# 导出时显式带上兼容参数,确保文件里有 SET SQL_MODE mysqldump --default-character-set=utf8mb4 \ --set-gtid-purged=OFF \ --single-transaction \ --routines --triggers \ your_db > backup.sql # 导入前先确认文件里确实有 SQL_MODE 设置 grep -n "SQL_MODE" backup.sql | head -3如果文件里没有,手工在开头补一行,值是导出库的sql_mode,这样导入行为和导出库保持一致,最不容易出意外。
6.3 主从复制:语句格式下的隐藏炸弹
这个坑更隐蔽,因为主库一切正常,出问题的是从库。在binlog_format=STATEMENT的情况下,主库执行的原始 SQL 会被传到从库重放。如果主库sql_mode宽松、从库严格(比如从库是新装的,用了默认配置),那么主库能成功执行的零日期插入,到从库就变成 1292,复制线程直接停掉。
排查的时候看从库状态:
SHOW SLAVE STATUS\G -- 8.0.22 之后推荐用 SHOW REPLICA STATUS\GLast_SQL_Error里会明明白白写着Incorrect date value: '0000-00-00'。修法是让主从的sql_mode完全一致,这是复制环境的基本要求,不只是针对这两个参数,整个sql_mode都该对齐。
如果一时半会儿没法统一,可以把binlog_format改成ROW。行格式记录的是实际写入的行数据,从库重放时不做 SQL 层的值校验,零日期能原样写进去,复制不会中断。但要注意这只是绕过了问题,脏数据依然存在于从库,后续做只读查询或者数据同步时还会再遇到。
6.4 时区与 TIMESTAMP 的交叉影响
DATE和DATETIME不受时区影响,存什么读什么。但TIMESTAMP会随时区转换,如果主从或者应用和数据库的时区设置不一致,同一个TIMESTAMP值读出来可能差几个小时,极端情况下跨天跨年,看起来像数据错乱。
这个跟NO_ZERO_DATE没有直接关系,但排查时经常被混淆。有人在从库看到日期不对,第一反应是零日期参数导致的截断,实际上是时区问题。区分方法很简单:看DATE列是否正常,如果只有TIMESTAMP列异常,那就是时区;如果DATE列也有零值,那才是sql_mode的事。
排查时区用这两条:
SELECT @@global.time_zone, @@session.time_zone, @@system_time_zone;7. 几个值得记下来的实操细节
7.1 改完配置记得验证"新连接"是否生效
前面提过连接池的问题,这里给一个可执行的验证步骤。改完SET GLOBAL之后,新开一个客户端连接执行:
SELECT @@SESSION.sql_mode;确认新连接拿到的是新值。然后再从应用侧发起一次真实的业务查询,看是否还报 1292。只有应用侧也正常了,才算真正改完。很多"改了没用"的反馈,都是只验证了命令行没验证应用。
7.2 给日期字段加约束,比依赖 sql_mode 更靠谱
sql_mode是实例级的,不同环境可能不一致,靠它来保证数据质量不够稳。MySQL 8.0.16之后支持CHECK约束,可以给日期列加一道独立防线:
ALTER TABLE user_profile ADD CONSTRAINT chk_birthday_valid CHECK (birthday IS NULL OR (YEAR(birthday) > 1900 AND MONTH(birthday) BETWEEN 1 AND 12 AND DAY(birthday) BETWEEN 1 AND 31));这样即使哪天sql_mode被人改宽松了,非法日期也进不来。代价是每次写入多一次约束检查,量大的表要评估一下开销。8.0.16之前的版本CHECK会被解析但忽略,等于没加,这一点要注意。
7.3 一个用来快速体检的小脚本
最后留一个日常巡检用的查询,把库里所有日期列的零值情况一次性摸清楚,避免逐表逐列手查:
-- 先列出所有日期列 SELECT TABLE_SCHEMA, TABLE_NAME, COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_DEFAULT FROM information_schema.COLUMNS WHERE DATA_TYPE IN ('date', 'datetime', 'timestamp') AND TABLE_SCHEMA NOT IN ('mysql', 'information_schema', 'performance_schema', 'sys') ORDER BY TABLE_SCHEMA, TABLE_NAME, COLUMN_NAME;拿到清单后,对每张表跑一次 5.2 的扫描语句。表多的话写个 shell 循环,把结果汇总成一张表,标出"有零值的列"和"默认值是零的列",升级前拿这份清单过一遍,基本不会在升级当天手忙脚乱。
我个人在实际操作中的体会是,这两个参数本身并不复杂,难的是它们生效依赖严格模式这个前提,而这个前提在很多环境里被有意无意地关掉了。所以每次排查零日期相关的问题,我的第一动作永远是先把@@GLOBAL.sql_mode和@@SESSION.sql_mode都打出来看一眼,比对一下和默认值的差异——十次里有八次,答案就在这个差异里。