1. 毫秒精度在 MySQL 里到底是个什么东西
先说一个我亲身经历过的场景。某业务系统的订单表用DATETIME存create_time,同一秒内进来的两笔订单,时间值完全一样。前端做时间倒序分页,翻到第二页时出现了重复行,隔天再查又正常了。运维查了慢查询、查了索引、查了分页 SQL 的LIMIT偏移量,都没有问题。最后还是把两笔订单的原始记录拉出来对比,才发现create_time一模一样,排序键退化成非确定性的,MySQL 返回顺序随执行计划变化。补上毫秒之后,这个问题当场消失。
这就是"日期时间字段精确到毫秒"这件事的真实价值:它不只是让你看到更细的时间,而是让时间字段重新具备唯一性和可排序性。这篇内容面向的是已经会用 MySQL、但被时间精度问题反复困扰的开发者和 DBA,也适合刚接手存量系统、需要做一次时间字段治理的人。下面我按"概念—建表—写入—查询—周边影响"的顺序,把自己踩过的坑和验证过的做法完整拆一遍。
1.1 一次分页重复的排查,把我们带到 FSP 上
接着上面那个案例往下说。当时第一步是把create_time的建表语句翻出来,看到的是create_time DATETIME DEFAULT NULL。第二步是在测试库上执行SHOW CREATE TABLE,确认没有隐藏的精度定义。第三步是插两条间隔 200 毫秒的记录,然后SELECT出来看,两条记录的create_time显示完全一致。到这一步基本就定位了:列的数据类型里根本没有声明小数秒精度。
MySQL 的行为很干脆——你没有在类型后面写括号,它就按 0 位小数处理,你喂进去的毫秒会被直接抹掉。注意这里的措辞,不是"忽略",而是会参与进位运算,下一节会详细说。所以排查的第一步永远是看 DDL 里的类型定义,而不是看客户端显示的结果。Navicat、DBeaver 这类工具默认的展示格式往往是yyyy-MM-dd HH:mm:ss,即使库里存了毫秒,你在结果集里也可能一眼看不出来,必须显式写SELECT create_time, create_time + 0 FROM t或者用DATE_FORMAT(create_time, '%Y-%m-%d %H:%i:%s.%f')才能确认。
| 排查动作 | 期望看到的结果 | 说明 |
|---|---|---|
SHOW CREATE TABLE看列类型 | datetime(3)这类带括号的写法 | 没括号就是 0 位精度 |
SELECT @@version | 5.6.4 及以上 | 低于此版本不支持小数秒 |
SELECT NOW(3) | 带三位小数 | 验证服务端能力 |
| 客户端结果集展示设置 | 显式格式化输出 | 避免被工具默认格式误导 |
1.2 FSP 取 0 到 6,毫秒只是其中一档
MySQL 把小数秒位数叫FSP(fractional seconds precision),取值范围 0 到 6,括号里能写的就是这个数。这一点很多人理解偏了,以为括号里填的是"要不要毫秒"的开关,实际上它是十进制小数位数。
DATETIME(0) -- 精确到秒 DATETIME(1) -- 精确到 0.1 秒 DATETIME(2) -- 精确到 0.01 秒 DATETIME(3) -- 精确到毫秒 DATETIME(4) -- 精确到 0.1 毫秒 DATETIME(5) -- 精确到 0.01 毫秒 DATETIME(6) -- 精确到微秒所以"精确到毫秒"对应的就是(3),而不是(6)。这两个选哪个,我给的判断很直接:业务需要的是毫秒级排序和去重,就写(3);只有在做高精度计时、性能打点、金融计息这类确实需要微秒的场景才写(6)。别嫌(6)"更保险"就一律上 6,因为每多两位小数就多占一个字节,而且会把应用层那些本来就只有毫秒精度的数据填上一堆无意义的尾零,读起来和做 diff 的时候很难受。
还有一点容易忽略:(3)表示存储和显示都精确到 3 位,它不是"存 6 位显示 3 位"。你在查询里SELECT出来的就是三位小数,四舍五入也已经发生在写入那一刻了,原始的高精度信息不可恢复。
1.3 存一个毫秒要花多少字节:精度是有成本的
这个细节做容量规划的时候绕不开。MySQL 对小数秒的存储是按ceil(FSP / 2)字节追加在基础类型后面的,也就是每两位小数占一个字节,奇数位向上取整。
| 类型 | 基础字节 | FSP=0 | FSP=3 | FSP=6 |
|---|---|---|---|---|
DATETIME | 5 | 5 | 7 | 8 |
TIMESTAMP | 4 | 4 | 6 | 7 |
TIME | 3 | 3 | 5 | 6 |
按DATETIME(3)算,比DATETIME(0)多 2 字节。一张千万行的表就是 20MB 的净增,加上索引里的副本还会再多一些。这个量级绝大多数业务都能接受,但如果是宽表、多时间列、行数上亿的场景,就值得算一算。我一般会建议:把"业务创建时间、更新时间"这类需要排序的列设成(3),把"归档时间、统计归属日期"这类只用来分组的列保持(0),甚至直接用DATE。精度不是越多越好,是够用就好。
2. 建表时把精度钉死在类型上
2.1 三种时间类型带精度时的语义差异
DATETIME(3)、TIMESTAMP(3)、TIME(3)都能带精度,但它们仨的脾气完全不同,选错的代价远大于精度选错。
DATETIME(3)存的是一段"字面意义上的日期时间",不带任何时区信息。你写进去2024-05-20 12:00:00.123,读出来还是这个值,换台服务器、换个时区设置都一样。这是它最大的优点,也是最常用的选择。
TIMESTAMP(3)存的是 UTC 时间戳,写入时按会话时区转换,读取时再转回来。它的范围卡死在1970-01-01 00:00:01到2038-01-19 03:14:07UTC 之间,这就是那个著名的"2038 问题"。如果你的系统里会出现远期日期(比如合同到期日、会员有效期),千万别用TIMESTAMP。
TIME(3)存的是时长或时刻,范围是-838:59:59到838:59:59,带精度后同样支持小数秒,一般用在耗时统计上。
CREATE TABLE order_info ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL, create_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), update_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), expire_time DATETIME(3) DEFAULT NULL, cost_ms TIME(3) DEFAULT NULL, PRIMARY KEY (id), KEY idx_create_time (create_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;注意:
create_time和update_time上的DEFAULT和ON UPDATE后面都必须带(3),否则默认值和自动更新会按 0 位精度计算,结果是"只有应用层手动写入的时候有毫秒,数据库自动填的时候没有",这种不一致极难排查。
2.2 默认值与自动更新的精度必须和列精度对齐
这一点单独拎出来说,因为我见过太多"精度写了但没生效"的案例。定义create_time DATETIME(3) DEFAULT CURRENT_TIMESTAMP,列是 3 位精度,但默认值表达式是 0 位精度。多数场景下 MySQL 会按上下文补齐,但一旦碰上触发器、存储过程、或者被中间件改写过 SQL,这个行为就不一定稳定了。最稳妥的做法是让精度处处一致,列写(3),CURRENT_TIMESTAMP也写(3),NOW()也写NOW(3),不给隐式推断留空间。
同一个坑还出现在ON UPDATE CURRENT_TIMESTAMP上。如果update_time是DATETIME(3)但ON UPDATE没带精度,可能出现"记录确实更新了,但update_time只变到了秒"的情况。做增量同步、做数据对比的时候,这类问题会直接导致漏数据。
2.3 已有表改精度:ALTER TABLE 的真实代价
存量系统改造是绕不过去的。把DATETIME改成DATETIME(3),语法很简单:
ALTER TABLE order_info MODIFY COLUMN create_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), ALGORITHM=INPLACE, LOCK=NONE;但这里有个硬约束:FSP 从 0 变成 3,行的物理存储长度变了,从 5 字节变成 7 字节。这种改变字段长度的 DDL,即使指定了ALGORITHM=INPLACE,InnoDB 在多数版本下仍然需要重建表。实际执行时要先看EXPLAIN或者直接跑一下看报错,不同小版本的行为并不完全一致。
我的处理流程一般是这样:先在小表上验证 DDL 能否在线执行、耗时多少;再评估大表的行数和磁盘剩余空间(重建表期间需要额外一份接近表大小的临时空间);最后选在业务低峰期用pt-online-schema-change或gh-ost这类工具做在线变更。千万级以上的表,不要直接裸跑ALTER TABLE,锁表几分钟在核心链路上就是事故。
改完之后的验证也很关键:抽查几条数据的DATE_FORMAT(create_time,'%f'),看是不是全变成000了——如果全零,说明原来的数据本来就没毫秒,改造只是让未来的数据有精度,不要期待历史数据被"补"上。
3. 写入链路上,毫秒是在哪一环悄悄没的
3.1 应用层:从 Date 到 Timestamp 的精度传递
数据库层面配好了,应用层该丢还是丢。我按遇到的频率从高到低列一下。
第一类是序列化/反序列化的格式化问题。很多项目里时间字段在 DTO 上打了@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss"),或者返回体统一走一个SimpleDateFormat("yyyy-MM-dd HH:mm:ss")。这个格式串里没有SSS,毫秒在出数据库的那一刻就没了,前端拿到的就是整秒。这类问题特别隐蔽,因为数据库里查是真的有毫秒,接口返回是真的没有。
第二类是 JDBC 参数类型。java.util.Date本身有毫秒精度,PreparedStatement.setTimestamp()会带上毫秒,这一点没问题。真正会丢的是用了老的java.sql.Date(只有日期部分),或者框架里把参数当字符串拼进了 SQL。用LocalDateTime的话精度更高(纳秒),MySQL 只收 6 位,超出的部分会被截断或四舍五入。
第三类是 ORM 的类型映射。MyBatis 里jdbcType写成DATE而不是TIMESTAMP,或者自定义 TypeHandler 里调用了Timestamp.valueOf(LocalDateTime)之后又经过一次格式化,都可能丢精度。写自定义 TypeHandler 的时候,我一般会在里面加一行断言,把getNanos()打出来确认。
第四类是批量写入。有些批处理框架为了拼 SQL 方便,统一把时间转成yyyy-MM-dd HH:mm:ss字符串再批量提交。这种情况下哪怕列是DATETIME(6)也没用,源头就是整秒。
| 丢精度环节 | 典型表现 | 排查方式 |
|---|---|---|
| JSON 序列化格式串 | 接口返回无毫秒,库里查有 | 检查@JsonFormat/ 全局 ObjectMapper 配置 |
| JDBC 参数类型 | 所有写入都是整秒 | 断点看setTimestamp入参 |
| ORM 类型映射 | 部分字段丢,部分不丢 | 检查jdbcType与 TypeHandler |
| 批量拼 SQL | 只有批处理路径丢 | 打印最终执行的 SQL 看字面量 |
3.2 SQL 层:NOW() 少了那个括号
这个是纯粹的 MySQL 侧问题,也是最容易一次性解决的一类。NOW()、CURRENT_TIMESTAMP、SYSDATE()、CURTIME()、UTC_TIMESTAMP()都支持带参数指定精度:
SELECT NOW(); -- 2024-05-20 12:00:00 SELECT NOW(3); -- 2024-05-20 12:00:00.123 SELECT NOW(6); -- 2024-05-20 12:00:00.123456 SELECT SYSDATE(3); -- 执行瞬间的时刻,带毫秒 SELECT UTC_TIMESTAMP(3);很多团队在存储过程、触发器、定时任务里直接写SET @t = NOW(),然后插入到DATETIME(3)列,出来的结果时有时无。原因是NOW()不带参数时返回 0 位精度,赋值给高精度列时是否补齐,取决于具体版本和上下文。别赌这个行为,显式写NOW(3)就完事了,多打两个字符的成本远低于事后排查。
顺便提一个容易混淆的点:NOW()返回的是语句开始执行的时刻,SYSDATE()返回的是函数被调用的瞬间。一条执行了 2 秒的大查询里,NOW()处处相同,SYSDATE()会变。做耗时统计要用SYSDATE,做业务时间戳要用NOW。这两个都支持(3)参数。
3.3 导入与字面量:CSV、mysqldump 和手写 SQL
LOAD DATA导入 CSV 时,时间字段是当字符串解析的。只要字符串里带.123,目标列是DATETIME(3),就能正确落库。但如果目标列是DATETIME(0),.123会参与四舍五入——这一点特别反直觉,2024-05-20 23:59:59.800插入DATETIME(0)列后会变成第二天的00:00:00。数据量大的时候,你会发现按天统计的结果比源文件多出若干条,追起来非常痛苦。
mysqldump导出时会把小数秒原样带出来,恢复时也没问题,前提是目标库版本支持。跨版本恢复是事故高发区:从 8.0 导出DATETIME(3)的数据,恢复到 5.5 的库上,建表语句里的(3)直接报语法错误,或者被工具自动降级成DATETIME,毫秒静默丢失。做迁移前务必确认源和目标都支持小数秒。
手写 SQL 的时候还有个小细节:字面量写成'2024-05-20 12:00:00.123'是完全合法的,MySQL 会按目标列的 FSP 处理。但如果写成'2024-05-20T12:00:00.123Z'这种带T和Z的 ISO 格式,在部分版本上解析行为不一致,老老实实用空格分隔的标准格式最稳。
4. 查询比较时精度反过来咬人的几种情况
4.1 BETWEEN 的右边界:23:59:59 之后还有 999 毫秒
这是精度改造之后最容易出事的一处。以前create_time是整秒,BETWEEN '2024-05-20 00:00:00' AND '2024-05-20 23:59:59'能覆盖全天。改成了毫秒之后,23:59:59.001到23:59:59.999这 999 毫秒的数据全部落在区间外,日报数字莫名其妙少几条,而且只在数据量大的日子才明显,测试环境根本复现不出来。
正确的写法是左闭右开:
-- 不推荐 SELECT * FROM order_info WHERE create_time BETWEEN '2024-05-20 00:00:00' AND '2024-05-20 23:59:59'; -- 推荐 SELECT * FROM order_info WHERE create_time >= '2024-05-20 00:00:00' AND create_time < '2024-05-21 00:00:00';左闭右开还有个额外好处:它天然适配"按天、按小时、按任意时间粒度切分"的逻辑,你只要算出区间的起点和下一个起点就够了,不用为"月末是 28 号还是 31 号""闰年 2 月"这些边界写特判。
模糊匹配也是重灾区。WHERE create_time LIKE '2024-05-20%'这种写法在时间列上会触发隐式类型转换,索引直接失效,全表扫。要按天过滤就用范围条件,别用LIKE。
4.2 函数包住时间列,索引直接作废
精度上来之后,很多人会想"我按秒聚合一下",于是写出WHERE DATE_FORMAT(create_time, '%Y-%m-%d %H:%i:%s') = '2024-05-20 12:00:00'。这条 SQL 逻辑上没问题,但左边是函数表达式,idx_create_time完全用不上。
-- 走不了索引 SELECT COUNT(*) FROM order_info WHERE DATE(create_time) = '2024-05-20'; -- 能走索引 SELECT COUNT(*) FROM order_info WHERE create_time >= '2024-05-20 00:00:00' AND create_time < '2024-05-21 00:00:00';如果确实需要频繁按天聚合,另一个思路是做冗余列:加一个create_date DATE生成列(MySQL 5.7+ 支持GENERATED ALWAYS AS (DATE(create_time)) STORED),在这个列上建索引。这样聚合查询的谓词直接作用在索引列上,既保留了原始毫秒精度,又拿到了聚合查询的性能。
还有一个关于索引的细节:DATETIME(3)的索引条目也跟着变长了 2 字节,二级索引的叶子节点存的是索引列 + 主键,时间列变长意味着索引文件变大、页分裂稍微频繁一点点。这个影响很小,但在写入极密集的表上值得知道。
4.3 和毫秒时间戳、字符串格式之间的来回转换
业务里经常要在"毫秒时间戳"和"MySQL 时间"之间互转。UNIX_TIMESTAMP()在 MySQL 5.6.4 之后支持小数返回:
SELECT UNIX_TIMESTAMP(NOW(3)); -- 1716192000.123 SELECT FROM_UNIXTIME(1716192000.123); -- 2024-05-20 12:00:00.123 SELECT FROM_UNIXTIME(1716192000.123, '%Y-%m-%d %H:%i:%s.%f');注意%f输出的是6 位微秒,哪怕列是DATETIME(3),DATE_FORMAT出来的也是123000。这个是 MySQL 的固定行为,不是你数据有问题。要三位就用SUBSTRING(DATE_FORMAT(t,'%f'),1,3)或者直接在应用层格式化。
反过来的坑更多。Java 里System.currentTimeMillis()返回 13 位毫秒,直接除以 1000 丢掉余数再转Date,毫秒就没了。Instant.ofEpochMilli(ts)不会丢,new Date(ts)也不会丢,丢的是格式化那一步。做接口对接的时候,我一般要求双方在协议里明确"时间字段用什么格式传输",最省事的方案是统一用毫秒时间戳的long类型,绕开所有时区和格式化的坑。
5. 时区、复制和周边工具带来的连带问题
5.1 TIMESTAMP(3) 的时区语义比 DATETIME(3) 更容易出事
TIMESTAMP的时区转换是在会话层面做的,精度改造之后这个行为没有变化,但出问题的概率变高了。因为以前整秒的时候,时区差导致的偏差至少还能对齐到秒;有了毫秒之后,应用服务器和数据库会话时区不一致,你会看到时间差了几毫秒甚至几百毫秒,排查时容易误以为是精度问题。
我的建议很明确:业务时间统一用DATETIME(3),只在明确需要跨时区自动转换的场景用TIMESTAMP(3)。用TIMESTAMP就必须保证 JDBC 连接串里的connectionTimeZone(8.0 驱动)或serverTimezone(5.1 驱动)配的是明确的时区值,别留空让驱动猜。留空的后果是:开发机在国内、容器在 UTC、数据库在另一个时区,三个地方各算各的,一条记录的时间能飘好几个小时。
5.2 主从、备份还原与中间件对精度的"抹平"
生产环境很少是单点,链路上的每一环都可能把精度吃掉。
主从复制上,如果主库是 8.0 而某个只读从库是 5.5 这类不支持小数秒的版本,DATETIME(3)的 DDL 在从库上根本执行不了。即使版本都支持,binlog_format=STATEMENT时,语句里的NOW()在从库重放时会重新取值,主从数据天然有毫秒级差异;ROW格式记录的是精确的行镜像,没有这个问题。做精确对账的系统,binlog_format一定要是ROW。
备份还原上,逻辑备份要确认工具带出了小数秒。物理备份(基于文件复制)没有这个问题。恢复演练时必须验证一次,别等到真出事才发现备份里全是整秒。
中间件是另一个隐蔽环节。分库分表中间件、数据库代理、数据同步工具在解析 DDL 时,对类型定义里括号的处理不一定完整。我遇到过某代理在转发建表语句时把DATETIME(3)简化成了DATETIME,应用侧的插入语句里明明带着.123,落库后就是整秒。这类问题的特征是"直连库没问题,走代理就有问题",一旦出现就往这个方向查。
5.3 一份可以照着核对的排查清单
最后给一份我实际用的核对清单,从数据库到应用从上往下过一遍,基本能覆盖 95% 的精度丢失场景。
| 层级 | 检查项 | 判定标准 |
|---|---|---|
| 版本 | MySQL 版本 | ≥ 5.6.4 |
| 建表 | 列类型 | datetime(3)/timestamp(3)带括号 |
| 建表 | DEFAULT与ON UPDATE | 表达式同样带(3) |
| SQL | 时间函数 | NOW(3)、SYSDATE(3)、CURRENT_TIMESTAMP(3) |
| SQL | 区间查询 | 使用左闭右开,不用BETWEEN 23:59:59 |
| SQL | 谓词 | 时间列上不套函数,不隐式转字符串 |
| 应用 | JSON 格式串 | 含SSS或 ISO-8601 |
| 应用 | JDBC 参数 | Timestamp/LocalDateTime,非字符串 |
| 应用 | ORM 映射 | jdbcType=TIMESTAMP |
| 链路 | binlog 格式 | ROW |
| 链路 | 中间件 DDL 透传 | 直连与走代理结果一致 |
| 链路 | 备份工具 | 恢复后抽查小数位 |
核对方法很简单,全程只用三条 SQL:
-- 1. 看能力 SELECT NOW(3), @@version; -- 2. 看定义 SHOW CREATE TABLE order_info; -- 3. 看实际存的值 SELECT id, DATE_FORMAT(create_time, '%Y-%m-%d %H:%i:%s.%f') AS ct FROM order_info ORDER BY id DESC LIMIT 5;如果第 3 步出来的%f部分一半是123000一半是000000,那说明写入链路有两条路径,其中一条丢了精度,往应用层的批量任务或者某个特定接口去查就对了。
我个人在做的几个项目里,时间字段的规范已经固化成一条团队约定:所有业务时间列一律DATETIME(3),所有时间函数的调用一律显式带精度参数,所有区间查询一律左闭右开。这三条执行下来,因为时间精度引发的线上问题基本绝迹了。真正麻烦的从来不是 MySQL 不支持毫秒,而是它支持得太"安静"——你不写括号,它不报错,只是悄悄把你的毫秒吃掉,等到数据对不上的那天才浮出水面。所以与其事后排查,不如在建表评审那一关就把括号补上。