先讲一个我实际遇到的事。早年在做一个订单系统的数据层梳理时,发现同一个业务表里出现了三种时间风格:有的行是2024-06-01 10:30:00,有的是2024/6/1 10:30,还有的是字符串和数字混在一起的脏值。更麻烦的是,负责写入的程序在 PostgreSQL 和 MySQL 之间换过驱动,时区处理方式又不一样,导致一张订单表里相差 8 小时的记录随处可见。那天之后我把 MySQL 的 DATETIME 类型从存储到插入再到格式化完整过了一遍,很多之前模糊的认知被彻底纠正。这篇文章就是那条排查线的完整记录:DATETIME 到底怎么存、怎么插、怎么格式化,以及哪些地方最容易踩坑。
适合谁来读?如果你是刚接触 MySQL 的新人,这篇文章能帮你建立 DATE 类型家族的整体概念;如果你已经写过几年 SQL,但没认真看过 DATETIME 的底层行为和边界条件,这篇也会帮你补上不少容易忽略的细节。我会尽量把原理和实操案例放在一起讲,不搞教科书式罗列。
1. DATETIME 存储的本质:8 字节、无时区,几个关键设计
1.1 字节分配与二进制拼装思路
先纠正一个很常见的误解:MySQL 的 DATETIME不是存成 Unix 时间戳,也不存字符串本身。它用的是 8 字节定长存储,内部结构是“把日期时间的各个组成部分按数值打包”,本质上接近于把YYYYMMDDHHMMSS这个十四位数字分段存进字节里。这种设计带来的第一个好处是范围极大,可以从1000-01-01 00:00:00一直装到9999-12-31 23:59:59,没有 2038 年的问题;第二个好处是 DATETIME 不依赖任何基准时刻,不需要做“从某个纪元到现在的秒数”这种换算,所以它是严格时区无关的。
很多人在建表时根本没意识到“时区无关”四个字意味着什么。你写入2024-06-01 12:00:00,它就把这个字面量原样保存下来;你把会话时区从东八区切到 UTC,再查这一行,它还是返回2024-06-01 12:00:00。这个特性和 TIMESTAMP 完全不同。TIMESTAMP 内部存的是 UTC 秒数,查询时根据当前会话的time_zone换算成本地时间,所以同一时刻在不同时区会话里读出来会不一样。DATETIME 则完全不掺和时区,它只忠实记录“你给它的那个日历时间”。
MySQL 5.6.4 之后引入了小数秒支持。默认的DATETIME(不带括号)精度是秒,占用 8 字节;如果声明成DATETIME(3),也就是保留毫秒,要额外占用 2 字节;声明成DATETIME(6),保留微秒,额外占用 3 字节。实际分配的规则是:
| 小数秒精度 | 存储所需总字节 |
|---|---|
| 0(默认) | 8 |
| 1 ~ 2 | 9 |
| 3 ~ 4 | 10 |
| 5 ~ 6 | 11 |
这里有个容易被忽略的工程问题。一张表如果列特别多,DATETIME 的精度从秒级升级到毫秒级,行平均长度会变大,InnoDB 一个 16KB 的页能容纳的行数就会下降,扫描性能会受影响。千万行级别的大表尤其明显。所以我的建议是:不要为了“万一以后要用毫秒”这种理由把全部时间列都建成DATETIME(6),只有真正需要毫秒或微秒精度的列(比如埋点事件流水)才值得加精度。普通业务表用秒级DATETIME完全够。
1.2 合法范围与“极限值慎用”原则
DATETIME 的合法范围是1000-01-01 00:00:00到9999-12-31 23:59:59。这听起来很宽裕,但有一个现实问题:很多编程语言和驱动在处理边界日期时并不那么友好。比如某个后端语言的日期对象能表示的上限是 9999 年,但某些中间件在解析9999-12-31 23:59:59这样的值时会因为内部实现差异产生溢出;更常见的是0000-00-00 00:00:00这种零日期,JDBC 驱动在默认配置下碰到它直接抛异常。
所以正确的心态是:底层类型的上限只是数据库能力的边界,不是业务数据的合法范围。业务上设计时间字段时,应该规定应用逻辑允许的最小值和最大值,比如“注册时间不得早于 2000 年,不得晚于 2039 年”,并在写入前用代码校验,而不是指望着数据库字段去背锅。否则某一天数据里混进了零日期或极端边界值,后面做报表、做数据迁移时就会像拆盲盒一样不断踩雷。
另外要说清楚0000-00-00 00:00:00的来历。它并不是 MySQL 造出来的特殊魔法值,而是因为历史上 MySQL 的 SQL 模式允许“非法或缺失日期被降级为零日期”。在非严格模式下,往 DATETIME 列插入'2024-13-40 99:99:99'这种乱值,MySQL 不会报错,只给个警告然后存成零日期。MySQL 5.7 及以后默认开启严格模式,这类插入会直接报ERROR 1292。关于这个问题,在第 5 章我会专门讲排查链路,这里先建立一个意识:看到线上库里出现0000-00-00 00:00:00,基本可以判断建表或写入时用的是旧版默认配置或非严格模式。
2. DATETIME 还是 TIMESTAMP:这份对比表解决选型纠结
2.1 六项关键差异对比
在 MySQL 里选日期时间类型,最纠结的往往不是 DATE 和 DATETIME 之间,而是 DATETIME 和 TIMESTAMP。两者看起来都能存“年月日时分秒”,但底层行为存在本质差别。我直接给一张对照表,然后逐条解释。
| 对比项 | DATETIME | TIMESTAMP |
|---|---|---|
| 存储字节 | 8 字节 | 4 字节 |
| 支持范围 | 1000-01-01 到 9999-12-31 | 1970-01-01 到 2038-01-19 |
| 时区行为 | 不换算,存什么取什么 | 以 UTC 存储,按会话时区换算显示 |
| 默认值支持 | 5.6.5 之后支持 DEFAULT CURRENT_TIMESTAMP | 5.6.5 之后同样要显式声明 |
| 索引体积 | 较大 | 较小 |
| 典型场景 | 业务日期、远期计划、合同期限 | 事件时间戳、日志时间、版本更新时间 |
重点说时区这一行的实际影响。假如你有一个全球化的应用,用户在东京写入了一条记录,时间是东京当地 11:00。如果字段是 TIMESTAMP,MySQL 会先把东京当地 11:00 换算成 UTC 的 02:00 存进磁盘;同一个字段在伦敦的会话里查出来,就会自动显示成伦敦当地 02:00。这就是“客观时刻”的语义。如果字段是 DATETIME,它存的就是11:00这个字面量,不管用户在新西兰还是美国,查出来都是11:00——除非应用层自己处理时区换算,否则这个值对不同地区的人来说意义完全不同。
所以选型的判断标准并不复杂:如果你要记录的是“某个客观时刻”,并且希望展示时区化时间,TIMESTAMP 更顺手;如果你要记录的是“某个日历上的日期时间”,比如活动开始时间、订单预约时间,那么 DATETIME 更合适。生日、合同到期日、节假日这种字段,绝对不能用 TIMESTAMP,因为这类日期不属于任何时区的“某一刻”,它就是个日历概念。
2.2 默认 DATETIME 基线,什么时候破例
现在很多大型项目的数据库规范里会写“新表默认使用 DATETIME,除非有充足理由”。这个基线不是拍脑袋定的,核心原因就是 2038 年问题。TIMESTAMP 的上限是2038-01-19 03:14:07,如果你现在要设计一个有效期到 2060 年的会员权益表,用 TIMESTAMP 会在写入时就失败。而 DATETIME 能撑到 9999 年,远期计划完全无压力。
那什么时候该破例用 TIMESTAMP?第一,日志流水场景,关注的是事件发生顺序,且可能需要配合全球时区展示,TIMESTAMP 的 4 字节能省不少空间,索引也更紧凑;第二,你知道数据一定落在 1970 到 2038 年之间,且所有访问方都能接受时区换算语义。第三,从其他数据库迁移过来的历史字段,如果原本就用 Unix 时间戳,改成 TIMESTAMP 反而和业务代码的转换逻辑更贴合。
还有一点容易被误解:有些人以为“用 DATETIME 存 UTC 时间字符串,就能完全避开时区问题”。能避开一部分,但代价是要放弃在 SQL 层直接做时间函数运算的便利,所有时间判断都得先解析字符串。更稳的工程实践是:应用层统一用 UTC 作为内部表示,数据库字段用 DATETIME,写入时把确定的 UTC 字符串写进去,读取时也按 UTC 拿出来,只在最外层的展示里转成当地时间。这样既保住 DATETIME 的宽范围和简单语义,又避免多时区会话互相干扰。会话时区参数反而要显式固定,这些细节在 5.2 节展开。
3. 插入 DATETIME:标准字面量、函数生成与默认值设计
3.1 MySQL 能识别的字面量写法
插入 DATETIME 最标准、最保险的写法是 ISO 风格的字符串:
INSERT INTO orders (order_no, created_at) VALUES ('A10001', '2024-06-01 10:30:00');但 MySQL 的解析器在这个问题上一直很宽松,你拿下面这些写法喂给它,它也能认:
-- 不带分隔符的纯数字 INSERT INTO t (dt) VALUES (20240601103000); -- 日期和时间之间用 T 分隔,常见于 ISO 8601 字符串 INSERT INTO t (dt) VALUES ('2024-06-01T10:30:00'); -- 日期部分可以不带前导零 INSERT INTO t (dt) VALUES ('2024-6-1 10:30:00');宽松归宽松,我在实际项目中强烈建议团队只允许两种写法:'YYYY-MM-DD HH:MM:SS'和'YYYY-MM-DDTHH:MM:SS'。原因很简单,一个表里如果混进'2024-6-1 10:30'这种非标准写法,排序、分组、格式化都会出现不一致,导出到其他系统时更是麻烦。用统一标准写,哪怕后面接入数据管道做校验,规则也清晰得多。
再提醒一个安全层面的问题:如果你的代码里用字符串拼接方式构造这样的 SQL,比如"INSERT INTO t (dt) VALUES ('" + userInput + "')",这不仅是格式隐患,还是典型的注入入口。所有时间参数都应该通过预处理语句的占位符传入,让驱动和数据库按参数类型处理,而不是在 SQL 文本里手动拼出字面量。
3.2 用函数生成时间值,并让数据库自动填充
除了直接写字面量,MySQL 提供了一批生成日期时间值的函数。最常见的几个:
-- NOW() 和 CURRENT_TIMESTAMP() 等价,返回当前日期时间 INSERT INTO audit_log (event_time) VALUES (NOW()); -- CURDATE() / CURRENT_DATE() 只取日期,时间部分归零 INSERT INTO daily_task (task_date) VALUES (CURDATE()); -- UTC_TIMESTAMP() 返回 UTC 时间,不随会话时区变化 INSERT INTO sync_log (utc_time) VALUES (UTC_TIMESTAMP());这里有一个容易混淆的概念:NOW()返回的是“当前会话时区下所显示的时间”。如果会话时区是东八区,NOW()返回的就是东八区当前时间;如果会话时区是 UTC,它返回的就是 UTC 当前时间。因为 DATETIME 本身不换算时区,所以NOW()算完的结果是什么,存进去就是什么。换句话说,这条 SQL 最终落库的值,和你执行时的会话时区强相关。这一点在程序里部署多时区服务时特别关键,我见到过不少事故都是因为连接池里的time_zone没固定,导致NOW()写入的时间和预期相差数小时。
常见的建表写法是这样让数据库自动管理创建时间和更新时间:
CREATE TABLE events ( event_id INT UNSIGNED NOT NULL AUTO_INCREMENT, event_name VARCHAR(100) NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (event_id) ) ENGINE=InnoDB;这个写法有两个细节要注意。第一,DATETIME列允许直接使用DEFAULT CURRENT_TIMESTAMP是从 MySQL 5.6.5 开始的,更早的版本只有 TIMESTAMP 支持这类默认值,如果你在维护老实例或兼容性分支,需要先确认版本。第二,ON UPDATE CURRENT_TIMESTAMP并不是“任何 UPDATE 都会刷新”,它指的是这一行发生数据变化时自动更新;如果UPDATE语句里改的列值最终和旧值一样,MySQL 判断没有实际变化,updated_at不会无端跳变。
3.3 插入时最容易翻车的三种写法
第一种是字符串里带毫秒,但列定义没开精度。表里是DATETIME,你传进去'2024-06-01 10:30:00.123',MySQL 会按精度截断或四舍五入,具体取决于版本和模式。如果你根本不在乎毫秒,建议在应用层先格式化再写入;如果你在乎,就应该把列类型直接定义成DATETIME(3)或DATETIME(6),别让数据库替你偷偷做取舍。
第二种是直接把 Unix 时间戳数字塞进去。有些开发从接口拿到的就是1700000000000这样的毫秒级时间戳,图省事直接拼到 SQL 里。DATETIME 列接收数字时会尝试把它当成数字字面量解析,结果几乎一定不是你想要的日期。正确做法是先转换:
-- 秒级时间戳 INSERT INTO t (dt) VALUES (FROM_UNIXTIME(1700000000)); -- 毫秒级时间戳,先除以 1000 INSERT INTO t (dt) VALUES (FROM_UNIXTIME(1700000000000 / 1000));第三种是应用层和数据库层的时区口径不一致。这个坑特别隐蔽。后端代码用本地时区的LocalDateTime序列化成了'2024-06-01 10:30:00',但数据库连接字符串里设定的时区是 UTC,你以为是同时刻,实际上你写入的字符串已经和预期偏离了。因为 DATETIME 不做换算,它只会默默把这个字符串存进去,问题不会在写入时报出来,只会等到对账、统计时突然发现时间差了好几小时。排查时最有效的办法是:先看连接参数里的time_zone是什么,再确认生成字符串的代码在哪个时区,最后核对这两者是否一致。别嫌这个核对繁琐,时间类故障很难一眼定位,把口径统一规则写到项目规范里是最划算的。
4. DATE_FORMAT 格式化实战:从零散说明符到业务展示
4.1 常用格式说明符与大小写陷阱
数据库里存的是标准YYYY-MM-DD HH:MM:SS,但业务展示经常要各种形态,比如“2024年6月1日”“06/01/2024”“周六上午 10:30”。MySQL 为此提供了DATE_FORMAT()函数,核心就是一套格式说明符。这里必须列一份速查表,因为说明符太多,而且大小写含义完全不同,特别容易记混。
| 说明符 | 含义 | 示例 |
|---|---|---|
| %Y | 四位年份 | 2024 |
| %y | 两位年份 | 24 |
| %m | 两位数字月份 | 06 |
| %c | 数字月份,无前导零 | 6 |
| %M | 英文月份全称 | June |
| %b | 英文月份缩写 | Jun |
| %d | 两位数字日 | 01 |
| %e | 数字日,无前导零 | 1 |
| %H | 24 小时制两位小时 | 14 |
| %h / %I | 12 小时制两位小时 | 02 |
| %i | 两位分钟 | 30 |
| %s | 两位秒 | 05 |
| %p | AM / PM | PM |
| %W | 英文星期全称 | Saturday |
| %a | 英文星期缩写 | Sat |
| %w | 数字星期,0 为周日 | 6 |
| %j | 一年中的第几天,001-366 | 153 |
| %u | 一年中的第几周,01-53 | 22 |
我见过太多人把%m当英文月份用,结果输出06而不是June;也见过把%Y和%y混用的。记住一句话:小写%m是数字月,大写%M才是英文月;小写%y是两位年,大写%Y才是四位年。把这张表放到团队文档里,比一遍遍教新人效率高得多。
4.2 业务场景的格式化写法与分组统计技巧
直接上几个常见场景的 SQL:
-- 订单列表展示“2024年6月1日 10:30” SELECT DATE_FORMAT(created_at, '%Y年%m月%d日 %H:%i') AS display_time FROM orders; -- 按天分组统计订单量 SELECT DATE_FORMAT(created_at, '%Y-%m-%d') AS day, COUNT(*) FROM orders GROUP BY day; -- 12 小时制带上午下午 SELECT DATE_FORMAT(created_at, '%Y-%m-%d %h:%i:%s %p') FROM events; -- 取季度号配合年份使用(%q 在 MySQL 中不可用,需要自行组合表达式) SELECT DATE_FORMAT(created_at, '%Y') AS year, FLOOR((MONTH(created_at) + 2) / 3) AS quarter, COUNT(*) FROM orders GROUP BY year, quarter;按天分组的写法在实际报表里出现频率最高。这里有个细节:GROUP BY DATE_FORMAT(created_at, '%Y-%m-%d')和GROUP BY DATE(created_at)的语义在绝大多数场景是等价的,但前者输出的是字符串,后者输出的是 DATE 类型,对接下游系统时类型有差别。建议统一用DATE()提取日期列再做分组,这样字段类型干净,后面接 BI 工具时少一层转换。
如果你需要把日期时间拆成“日期 + 时间”两列,也可以用DATE()和TIME()函数:
SELECT DATE(created_at) AS d, TIME(created_at) AS t FROM orders;MySQL 还有一个逆操作函数STR_TO_DATE(),接收一个格式化字符串并按指定模板解析成日期时间,正好是DATE_FORMAT()的反向。批量导入外部文件时它几乎是救星,第 5 章会用到。
4.3 格式化与索引:函数包裹列导致索引失效的典型现场
很多人在 WHERE 条件里习惯直接写WHERE DATE_FORMAT(created_at, '%Y-%m-%d') = '2024-06-01',觉得很好读。这个写法能跑,但在created_at上有索引时,这个条件会完完整整地让索引失效,MySQL 只能做全表扫描。原因是索引里存的是原始 DATETIME 值,不是'2024-06-01'这种格式化后的字符串;你对列做任何函数变换,就是在告诉优化器“我需要计算后的新值”,而计算后的结果没有索引可走。
正确写法是把查询条件改成范围比较:
-- 能用到 created_at 索引 SELECT * FROM orders WHERE created_at >= '2024-06-01 00:00:00' AND created_at < '2024-06-02 00:00:00'; -- 这条会触发全表扫描,别这么写 SELECT * FROM orders WHERE DATE_FORMAT(created_at, '%Y-%m-%d') = '2024-06-01';这个经验适用于所有日期函数:DATE()、YEAR()、MONTH()包裹列时同样会造成索引失效。查询“某一天的数据”永远用左闭右开区间,是成本最低、最标准的做法。这个习惯早养成,线上少很多慢查询。
5. 实操踩到的坑:严格模式、时区会话、批量导入与恢复
5.1 严格模式与零日期的排查链路
如果你在线上库看到0000-00-00 00:00:00,第一反应不该是“删掉它”,而是先搞清楚它怎么来的。完整排查链路通常是这样:
- 先查当前会话和全局的
sql_mode:SELECT @@sql_mode; - 看
NO_ZERO_DATE和NO_ZERO_IN_DATE是否在列表里。MySQL 5.7 及以上默认启用严格模式,这两个参数默认是开启的,开启时插入零日期会直接报错。 - 如果库里已经存在零日期,说明这批数据很可能是从旧版本数据库迁移来的,或者写入时连接会话把
sql_mode临时改成了宽松模式。 - 定位零日期出现的范围,评估它是否影响业务计算。很多统计 SQL 在遇到零日期时会返回 NULL 或产生错误结果,这些行需要单独处理。
处理零日期不能一刀切。如果业务上确实有“未知时间”的语义,我建议用一个业务可接受的最小日期,比如2000-01-01 00:00:00,并给列加 CHECK 约束或写入校验,而不是依赖数据库的零日期特性。如果这些零日期纯粹是历史脏数据,就写脚本分批订正,同时把连接层的sql_mode固定住,防止新数据再进来。
5.2 会话时区对 NOW() 比较值的隐性影响
DATETIME 本身不换时区,所以很多人误以为“选 DATETIME 就没时区问题了”,但一个地方仍然会受时区影响——查询条件。你写WHERE updated_at > NOW(),这里的NOW()是受会话时区影响的。如果应用服务器和数据库服务器物理部署在不同时区,或者连接池的time_zone参数没固定,NOW()返回的值可能和你代码里预期的时间相差数小时,最终导致增量更新的数据被漏掉或被重复拉取。
举个例子。应用层统一用 UTC 生成时间并写入 DATETIME 字段,但数据库连接的会话时区被设成了东八区。这时候你执行WHERE updated_at > NOW(),NOW()返回的是东八区时间,而updated_at里存的是 UTC 时间,前后相差 8 小时,过滤出的数据自然全是错的。更危险的是这类问题不报错,数据同步链路里表现为“偶尔拉多、偶尔拉少”,很难排查。
根治办法很简单:所有数据库连接的time_zone必须显式统一。在连接串里写死time_zone=UTC,应用层获取当前时间也用 UTC,SQL 里的时间比较条件统一用UTC_TIMESTAMP()生成,这样 DATETIME 字段的写入值和比较值在同一口径下,不再依赖服务器本地时钟。连接池层面也要核对,因为一个连接池可能复用多个物理连接,任何一个连接的时区不干净,都会污染结果。
5.3 LOAD DATA 批量导入的转换方案
批量导入算是 DATETIME 问题的重灾区。业务方给你的 CSV 里时间格式可能是2024/6/1 10:30、06/01/2024 10:30 PM、2024-06-01T10:30:00Z这种五花八门的形态。LOAD DATA INFILE默认按 MySQL 的严格字面量格式解析,一遇到非标准格式,轻则整行变成 NULL,重则直接中断导入。
最稳妥的做法分为三步。第一步,先把 CSV 的原始时间列导入到临时表里的 VARCHAR 字段,不做任何解析;第二步,用STR_TO_DATE()把字符串转成目标格式,同时利用函数失败返回 NULL 的特性做数据质量校验;第三步,把校验通过的数据写入正式表。
实际写法用LOAD DATA的SET子句可以一步到位:
LOAD DATA INFILE '/tmp/events.csv' INTO TABLE events FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '"' LINES TERMINATED BY '\n' (@raw_time, event_name) SET event_name = NULLIF(event_name, ''), created_at = STR_TO_DATE(@raw_time, '%m/%d/%Y %h:%i %p');留意@raw_time这个输入变量。LOAD DATA允许你先用@变量接收原始值,然后在SET子句里调用函数转换。如果STR_TO_DATE()解析失败,返回 NULL,created_at会写入 NULL;配合非空约束,你能很快发现哪些行格式不合格。为了避免导入失败导致整个任务回滚,建议先导入临时表,确认质量后再INSERT ... SELECT进正式表。
5.4 备份恢复时 sql_mode 不一致的坑
备份恢复看起来和 DATETIME 没什么关系,但实际操作中我踩过一次很有代表性的坑。某个系统从旧实例迁移到新实例,旧实例的sql_mode比较宽松,表里已经有了一批零日期数据。mysqldump导出的 SQL 文件里会原样包含'0000-00-00 00:00:00'这样的值,但新实例的默认sql_mode是严格模式,导入时直接报错中断。整批恢复任务变得不可用,还要先排查数据文件里到底哪些行带了零日期。
这类问题有两个预防手段。一是迁移前先对源库做一轮健康检查,把零日期和非法规整出来清理掉,别让脏数据进迁移链路;二是如果必须原样迁移,导入前在会话级调整目标库的sql_mode,临时去掉NO_ZERO_DATE和NO_ZERO_IN_DATE,导入完成后再恢复并固定。但注意,这只是给历史数据留出路,新数据一定要在严格模式下写入,否则零日期会被持续产生,永远清不干净。
同样的逻辑也适用于应用连接。我建议所有新项目在启动时就把数据库连接的sql_mode通过初始化语句固定成严格模式,比如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'。这样做的好处是让写入行为可预期,坏数据在入口处就被拦截,而不是等到统计时再爆炸。
最后,说点实际的
DATETIME 这个类型看起来简单,但当你把它放到真实的分布式环境里,时区、严格模式、格式化、批量导入这些因素会交织在一起,任何一个环节的口径不一致都会在数据链路上埋下隐患。我个人的经验是:与其迷信某个工具或某条 SQL 写法,不如在项目初期就把三件事定死——第一,所有时间字段语义明确,是“客观时刻”还是“日历时间”;第二,所有连接层的时区统一,应用生成字符串和数据库比较函数用同一个时钟;第三,所有导入导出通道做格式校验,绝不让非标准时间字符串跨过系统边界。把这三条规定写进工程规范,DATETIME 相关的坑会少掉一大半。
如果你正准备重构一张包含多个时间字段的表,或者正在从其他数据库往 MySQL 迁移,建议先按这篇文章的顺序过一遍现有数据的格式、时区和零日期情况,再动手改表结构。时间字段一旦存成脏数据,修复成本远比预防成本高。