ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

MySQL毫秒精度实战:DATETIME(3)建表到查询全链路指南

MySQL毫秒精度实战:DATETIME(3)建表到查询全链路指南

1. 毫秒精度这件事,先搞清楚卡在哪

前段时间一位做后端的同行拿着他的订单表来问我,说他们系统里同一秒内下的两笔单,按创建时间排序翻页的时候顺序会飘,第二页偶尔蹦出第一页已经看过的记录。我让他把建表语句发过来,一眼就看到问题:create_time datetime DEFAULT CURRENT_TIMESTAMP。字段类型没带精度声明,默认值也没带精度参数,整条链路从上到下都在按秒处理,前端传过来的.123毫秒部分在第一个环节就被抹干净了。这类问题在日志、埋点、支付流水、消息投递记录这些场景里特别常见,业务方要的是"谁先谁后",数据库给的却是"同一秒",中间那点毫秒差就是排序错乱、去重失效、幂等判断出岔子的根源。

MySQL 的日期时间字段要精确到毫秒,从来不是改一个字段类型就完事的事情。它牵扯到建表语句里的小数秒精度声明、默认值和自动更新子句的写法、JDBC 驱动发送参数的开关、应用层时间对象的精度、SQL 里取当前时间的函数是否带参、格式化函数怎么输出、甚至索引体积和主从复制格式。任何一个环节掉链子,最终落库的都是.000。这篇内容我按"建表—写入—查询—性能—排查"的顺序,把这几个环节逐个拆开讲,每一处都给你可以直接抄的写法和踩过的坑。适合正在做时间敏感型业务的后端、DBA,也适合刚接手老系统、发现时间字段精度不够想升级的同行。

1.1 三种时间类型的能力边界与版本门槛

先把基础盘清楚。MySQL 里能存"日期+时间"的类型主要有三个:DATETIME、TIMESTAMP,外加只存时间的TIME。这三个类型都支持小数秒,写法是在类型名后面加括号,括号里填 0 到 6 的整数,表示小数秒位数,业内通常叫它fsp(fractional seconds precision)。DATETIME(3)就是精确到毫秒,DATETIME(6)精确到微秒,DATETIME(0)和不写括号等价,精确到秒。DATE类型只存年月日,压根没有时间部分,别指望它。

这个能力有明确的版本门槛:MySQL 5.6.4 才开始支持小数秒。5.5 及更早的版本,DATETIME(3)这种写法会直接报语法错误。所以如果你接手的是一个跑在老版本上的系统,第一步不是改表,是先确认版本。用SELECT VERSION();看一眼,或者SHOW VARIABLES LIKE 'version%';。版本低于 5.6.4 的话,只能退而求其次:用BIGINT存毫秒时间戳,或者在应用层把毫秒拼成CHAR(23)的字符串存。这两种土办法我在老项目里都见过,BIGINT的好处是排序、比较、索引都快,坏处是可读性差,写 SQL 排查问题时得手动转;字符串方案可读性好,但排序是按字典序,格式不统一就会出乱子,而且没法用日期函数。

还有一个容易被忽略的点:fsp 上限是 6。你写DATETIME(9)会报错。有些从其他数据库迁过来的同行习惯性地写 9 位纳秒,在 MySQL 这里行不通。真需要纳秒级,只能自己用BIGINT存纳秒数,或者拆成两列。实际业务里毫秒(3 位)足够覆盖 99% 的场景,微秒(6 位)主要用于金融撮合、高频埋点这类对顺序极度敏感的场合。

另外TIME类型也支持 fsp,TIME(3)表示精确到毫秒的时长或时刻,但它只存时分秒,不存日期,跨天的时间差计算要格外小心,负数时长的表示方式也和直觉不太一样。业务里用TIME存"营业时间""每日提醒时刻"是可以的,用它算两个时间点之间的间隔就容易踩坑。

1.2 精度丢失的四个典型现场

我把这些年遇到过的时间精度丢失归纳成四类现场,你可以对照自己的系统排查。

第一类是建表时压根没写精度。字段是DATETIME或TIMESTAMP,括号都没带,那这列天生就只能存到秒。这种最常见,也最好改,一条ALTER TABLE就能解决,但要注意存量数据的小数部分本来就已经是 0,改完类型也补不回来。

第二类是取当前时间的函数没带参数。NOW()、CURRENT_TIMESTAMP、SYSDATE()不带括号里的数字时,返回的都是秒精度。哪怕列是DATETIME(3),INSERT INTO t VALUES (NOW())落进去也是.000。必须写成NOW(3)或CURRENT_TIMESTAMP(3)。这个坑极其隐蔽,因为 SQL 不报错,数据看起来也"正常",只有做排序或比对时才发现少了三位。

第三类是应用层格式化时把毫秒截掉了。比如 Java 里用SimpleDateFormat("yyyy-MM-dd HH:mm:ss")转字符串再拼 SQL,Date对象里明明有毫秒,格式化模板里没有SSS,直接就没了。又比如某些序列化框架默认输出秒级时间戳,前端拿到再传回来,精度已经丢了一轮。

第四类是驱动或连接参数层面被截断。JDBC 驱动某些版本在特定配置下不会发送小数秒,或者服务端预处理语句的元数据里精度信息不对,导致setTimestamp的值到达服务端时已经被削平。这一类最难查,因为代码看起来完全正确,得抓包或者开 general_log 才能看到真实发送的 SQL。

把这四类记住,基本上排查方向就有了。下面按链路顺序展开。

2. 建表阶段:类型选型与精度声明的写法

建表是一切的地基。我见过太多项目在表结构设计阶段随手写个DATETIME,等业务跑了半年发现排序有问题,再回头改表、改代码、补数据,成本翻好几倍。所以在建表时就把精度定下来,是最省事的做法。

2.1 DATETIME(n) 和 TIMESTAMP(n) 怎么选

这两个类型都支持小数秒,但底层行为和适用场景差别不小,选错了后面会很难受。

DATETIME存的是"字面时间",不带时区信息。你写进去2024-03-15 10:00:00.123,读出来就是2024-03-15 10:00:00.123,不管服务器时区怎么变、会话时区怎么设,值都不变。范围是1000-01-01 00:00:00.000000到9999-12-31 23:59:59.999999,够用一辈子。

TIMESTAMP存的是 UTC 时间戳,写入时按当前会话的time_zone转换成 UTC 存储,读取时再转回会话时区。范围窄得多,带 fsp 的情况下是1970-01-01 00:00:01.000000UTC 到2038-01-19 03:14:07.999999UTC,就是著名的 2038 问题。它的好处是自动处理时区,跨时区业务读出来的本地时间是"对的"。

我的选择习惯是:业务时间用DATETIME(3),需要跨时区展示的用TIMESTAMP(3)。订单创建时间、支付时间、日志时间这类,业务方关心的是"当时当地是几点",用DATETIME最直观,也不会因为数据库服务器迁移到别的时区就整体偏移。而像"用户最后登录时间"这种要按用户所在时区展示的,TIMESTAMP更合适。

还有一点要注意:TIMESTAMP的小数秒部分在时区转换时是独立保存的,不会被转换过程吃掉。这一点不用担心,但前提是你的列定义带了 fsp。

给一张对照表,方便直接查:

类型存储字节(fsp=0)存储字节(fsp=3)存储字节(fsp=6)时区处理范围上限
DATETIME578不转换,存字面值9999 年
TIMESTAMP467按会话时区转 UTC2038 年
TIME356不转换838 小时

小数秒的字节数是这么算的:fsp 为 1 到 2 占 1 字节,3 到 4 占 2 字节,5 到 6 占 3 字节。所以DATETIME(3)是 5+2=7 字节,DATETIME(6)是 5+3=8 字节。这个账后面讲性能时还要用。

2.2 默认值与自动更新的精度对齐

这是最容易报错的地方。很多人建表时写了:

CREATE TABLE t_order ( id BIGINT PRIMARY KEY, create_time DATETIME(3) DEFAULT CURRENT_TIMESTAMP, update_time DATETIME(3) DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP );

执行下去大概率直接抛ERROR 1067 (42000): Invalid default value for 'create_time'。原因就是列声明了 fsp,默认值函数也必须带同样的 fsp。正确写法是:

CREATE TABLE t_order ( id BIGINT PRIMARY KEY, 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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

DEFAULT和ON UPDATE两处的函数括号都要跟列精度一致,少一个就报错。这个规则在 5.6.4 之后的版本都成立,我经手过的 5.7 和 8.0 都验证过。

还有一个和默认值相关的变量:explicit_defaults_for_timestamp。在 5.6 和 5.7 里它默认是OFF,意味着表里第一个TIMESTAMP列如果没显式声明NULL和默认值,MySQL 会自动给它加上NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,而且这个自动行为是不带 fsp 的。到了 8.0,这个变量默认变成ON,自动加默认值的行为取消了。跨版本迁移的时候,这一条会让表结构悄悄变化,建议建表时把所有默认值都显式写全,不依赖隐式规则。

另外提醒一句,sql_mode里如果开了NO_ZERO_DATE和NO_ZERO_IN_DATE(5.7 之后默认开启),DATETIME列不允许'0000-00-00 00:00:00'这种零值默认,写DEFAULT '0000-00-00 00:00:00.000'一样报 1067。要么给个合法默认值,要么允许NULL。

2.3 存储字节、索引与分区的连带影响

精度不是白给的,多出来的字节会在存储和索引上放大。单个列多 2 字节看着不多,但一张日增百万行的流水表,一年就是三亿多行,主键加几个时间索引,累计的额外空间能到 GB 级别。我在一个日志表上做过对比:把三个DATETIME列改成DATETIME(3),单行数据从 186 字节涨到 192 字节,看起来只涨了 3%,但加上二级索引之后整体表空间涨了接近 8%。这个数字不算吓人,但你要提前有预期。

索引方面,DATETIME(3)作为索引列时,InnoDB 的二级索引叶子节点存的是"索引列值 + 主键值",索引列从 5 字节变 7 字节,如果主键是BIGINT(8 字节),一条索引记录从 13 字节变 15 字节,涨了 15% 左右。范围扫描的页数会略微增加,但通常感知不到。真正需要警惕的是把高精度时间列拿来做联合索引的第一列,再配合大主键,索引膨胀会比较明显。

分区表这里有个细节:用RANGE COLUMNS(create_time)做分区时,分区键支持DATETIME类型,包括带 fsp 的。但如果你用RANGE (TO_DAYS(create_time))这种函数分区,MySQL 8.0 里对函数分区的支持是受限的,而且分区裁剪的效率不如RANGE COLUMNS。我一般建议按RANGE COLUMNS加日期边界来做,比如每月一个分区,边界值写成'2024-04-01 00:00:00.000'。注意边界值是"小于"语义,PARTITION p202403 VALUES LESS THAN ('2024-04-01 00:00:00.000'),这样三月的最后一条23:59:59.999会稳稳落在三月分区里,不会因为精度问题越界。

3. 写入链路:从应用层到库表的全链路保真

建表正确只是第一步。数据从应用内存到最终落盘,中间要过驱动、连接、SQL 解析好几道关,每一道都可能把毫秒削掉。

3.1 驱动与连接参数里被忽略的开关

Java 生态里最常见的是 JDBC。Connector/J 8.0 系列默认会把java.sql.Timestamp里的小数秒发出去,前提是列的类型元数据里带 fsp。但有几个参数会改变这个行为。

sendFractionalSeconds这个参数控制是否向服务端发送小数秒,默认是true。有些团队为了兼容老版本数据库或者排查问题,会把它关掉,结果就是所有时间都被截到秒。这个参数在连接串里长这样:jdbc:mysql://host:3306/db?sendFractionalSeconds=true。还有一个sendFractionalSecondsForTime,专门控制TIME类型,默认也是true。

serverTimezone(8.0 之后推荐用connectionTimeZone)必须配。不配的话,驱动可能报"无法确定服务端时区"的错,或者干脆按 UTC 处理,导致TIMESTAMP列读出来差好几个小时。这个和毫秒精度没直接关系,但会把时间整体搞错,排查时容易混淆。

还有一个隐蔽的点:使用服务端预处理语句(useServerPrepStmts=true)时,参数类型和列类型的匹配更严格。如果代码里用setObject(index, localDateTime)而不指定类型,某些驱动版本会按字符串发送,服务端再隐式转换,精度可能保不住。稳妥的做法是用setTimestamp或setObject时显式传java.sql.Types.TIMESTAMP。

至于java.util.Date,它本身只有毫秒精度,转成Timestamp不会丢;但LocalDateTime在 Java 8 上通过LocalDateTime.now()拿到的实际精度取决于系统时钟,Linux 上通常是毫秒,Java 9 之后可以到微秒。如果你要写DATETIME(6),用 Java 8 的LocalDateTime.now()后三位永远是 0,得用Clock.systemUTC().instant()再转换,或者直接依赖数据库的NOW(6)。

给一段可以直接用的写入代码作为参考:

String sql = "INSERT INTO t_order (order_no, create_time, update_time) VALUES (?, ?, ?)"; try (PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, orderNo); ps.setTimestamp(2, Timestamp.valueOf(LocalDateTime.now())); ps.setTimestamp(3, Timestamp.valueOf(LocalDateTime.now())); ps.executeUpdate(); }

这里Timestamp.valueOf(LocalDateTime)会保留LocalDateTime的纳秒字段,驱动再按列精度发出去。如果你不确定驱动行为,最稳的办法是让数据库自己取时间,见下一节。

3.2 几种常见写法的时间生成位置

时间到底在哪一层生成,直接决定了精度和一致性。我把常见写法列一下,各自的取舍很清楚。

写法一:数据库取时间,NOW(3)。SQL 写成INSERT INTO t_order (order_no, create_time) VALUES (?, NOW(3))。好处是时间源统一,所有实例写入的时间都以数据库服务器时钟为准,不会因为应用服务器之间有几毫秒时钟差导致顺序错乱。坏处是数据库时钟如果没做好同步,整体会偏。另外要记住NOW(3)在一条语句里多次调用返回同一个值,这是 MySQL 的保证,所以同一行里create_time和update_time用NOW(3)会完全一致,做幂等比对时很方便。

写法二:应用生成LocalDateTime,参数传入。好处是应用可以精确控制时间,比如从上游消息里带过来的事件时间,必须原样落库。坏处是应用服务器之间的时钟偏差会带进数据里,多实例部署时尤其明显。这种场景我一般建议配好 NTP,并且接受"毫秒级乱序"这个事实,靠业务主键或序列号来保证真正的顺序。

写法三:前端传毫秒时间戳,后端转换。这种在对接前端或移动端时很常见。要注意FROM_UNIXTIME(1700000000123 / 1000)的写法,MySQL 里FROM_UNIXTIME接受带小数的秒数,返回值会带小数秒。但如果你在应用层用new Date(1700000000123L)再转LocalDateTime,精度就是毫秒,落DATETIME(6)列时后三位补 0,这个不算错,只是要知道。

写法四:SYSDATE(3)。它和NOW(3)的区别是:NOW()返回的是语句开始执行的时间,SYSDATE()返回的是函数被调用那一刻的时间。绝大多数场景用NOW(3)就够了,SYSDATE(3)更适合在长语句里记录"实际执行到这一步"的时间点。

还有个小细节:CURRENT_TIMESTAMP(3)和NOW(3)是完全等价的,选一个团队内统一就行,别一半人写这个一半人写那个,搜代码的时候容易漏。

3.3 批量写入与存量迁移

批量插入时,INSERT INTO t (a, b) VALUES (?, NOW(3)), (?, NOW(3))这种写法里,多个NOW(3)在同一条语句内返回同一个值。这意味着同一批插入的行时间戳完全一样,如果你指望靠时间排序区分批内顺序,是做不到的。真需要批内有序,得在应用层生成递增的时间,或者额外加一个自增序列列。

存量数据迁移到高精度列,一般的做法是:

ALTER TABLE t_order MODIFY COLUMN create_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), MODIFY COLUMN update_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), ALGORITHM=INPLACE, LOCK=NONE;

ALGORITHM=INPLACE和LOCK=NONE能不能用,取决于版本和列的变更类型。把DATETIME扩成DATETIME(3)属于"扩展存储长度",在 5.7 和 8.0 上通常可以走在线 DDL,但这个操作会重建表,大表上执行时间长,建议在低峰期做,并且先用pt-online-schema-change或gh-ost之类的工具在影子表上验证。我做过的几次都是几千万行的表,用在线工具跑了四十分钟左右,业务无感知,但磁盘 IO 和主从延迟要盯着。

迁移过程中还有个坑:主从延迟期间,从库上的表结构可能还是旧的。如果这时有读请求打到从库,读到的还是秒精度数据。要么在迁移窗口内把读流量切到主库,要么接受这个短暂的不一致。

4. 查询侧:格式化、比较与时区

数据存对了,读出来还得对。查询这一侧的问题主要集中在格式化函数和区间比较上。

4.1 DATE_FORMAT、STR_TO_DATE 与 UNIX_TIMESTAMP

DATE_FORMAT的%f占位符输出的是 6 位微秒,不管你的列是DATETIME(3)还是DATETIME(0),它都输出 6 位。DATETIME(3)的值2024-03-15 10:00:00.123经过DATE_FORMAT(create_time, '%Y-%m-%d %H:%i:%s.%f')会变成2024-03-15 10:00:00.123000。如果你要的是毫秒字符串,得自己截:LEFT(DATE_FORMAT(create_time, '%Y-%m-%d %H:%i:%s.%f'), 23),或者用CONCAT(DATE_FORMAT(create_time, '%Y-%m-%d %H:%i:%s'), '.', LPAD(FLOOR(MICROSECOND(create_time)/1000), 3, '0'))。后一种写法虽然啰嗦,但语义最清楚。

STR_TO_DATE反过来,格式串里带%f才能解析小数部分,返回值是DATETIME(6)。这里有个大坑:格式串带%f,但输入字符串里没有小数部分,函数直接返回NULL。比如STR_TO_DATE('2024-03-15 10:00:00', '%Y-%m-%d %H:%i:%s.%f')返回NULL,不是报错,是静默返回空值。如果你的导入脚本里有这种写法,数据会悄悄丢掉一批。稳妥做法是给输入补上.000,或者用两个格式串分别尝试。

UNIX_TIMESTAMP(create_time)返回的是DECIMAL类型,带小数秒。对DATETIME(3)的值,返回类似1710471600.123。这个函数在做时间戳比较、导出给前端时很方便。反过来FROM_UNIXTIME(1710471600.123)返回带小数秒的DATETIME,精度能保住。但要记住,FROM_UNIXTIME的结果最终落到哪个精度的列,还是由列定义决定。

4.2 区间统计和时区叠加下的精度表现

按天、按小时统计是最常见的查询。假设要统计 2024 年 3 月 15 日全天的订单,正确写法是:

SELECT COUNT(*) FROM t_order WHERE create_time >= '2024-03-15 00:00:00.000' AND create_time < '2024-03-16 00:00:00.000';

用左闭右开,不要用BETWEEN。因为BETWEEN '2024-03-15 00:00:00' AND '2024-03-15 23:59:59'在秒精度下没问题,但在DATETIME(3)下会漏掉23:59:59.001到23:59:59.999这 999 毫秒的数据。这个坑我见过不止一次,统计报表每天少几百单,排查了两天才定位到。

时区方面,TIMESTAMP(3)列在会话时区变化时,日期部分可能跨越边界,但小数秒部分不受影响。比如 UTC 时间2024-03-15 23:59:59.999,在东八区会话里读出来是2024-03-16 07:59:59.999,毫秒还是 999。所以按时区分组统计时,边界条件要跟着会话时区走,别拿 UTC 的日期边界去卡东八区的数据。

还有一个和精度相关的排序问题:ORDER BY create_time DESC LIMIT 10时,如果多行时间完全相同(比如批量插入用同一个NOW(3)),返回顺序是不确定的,翻页会出现重复或遗漏。解决办法是在排序里加一个唯一列兜底:ORDER BY create_time DESC, id DESC。这个改动很小,但能彻底解决翻页乱序。

5. 性能与稳定性:精度不是免费的

毫秒精度带来的额外字节和时钟依赖,在高并发场景下会以另一种方式体现出来。

5.1 存储与索引的实测代价

前面算过字节账,这里给一个更贴近真实的观测。我在一个测试环境里造了两张结构相同的表,各 500 万行,一张时间列用DATETIME,一张用DATETIME(3),都建了(create_time)的二级索引。结果如下:

指标DATETIME表DATETIME(3)表差异
表数据大小约 612 MB约 648 MB+5.9%
二级索引大小约 138 MB约 156 MB+13%
单行全表扫描耗时基准+2% 左右基本可忽略
时间范围查询(1 天)基准+1% 到 3%基本可忽略

结论是:存储和索引体积的增幅是实打实的,查询性能的影响基本可以忽略。所以决策点不在性能,而在你是否真的需要毫秒。日志表、流水表这类写入量大、查询以范围扫描为主的场景,多出来的 5% 存储换来的排序准确性是值得的。而配置表、字典表这种几万行的小表,随便用,不用纠结。

如果要压存储,有个折中方案:只在真正需要排序或去重的列上用毫秒精度,其他辅助时间列保持秒精度。比如订单表里create_time用DATETIME(3),而sync_time、report_time这种只做展示的列用DATETIME就行。

5.2 时钟回拨、单调性与并发写入

NOW(3)取的是数据库服务器的系统时间。服务器做了 NTP 同步,如果偏差较大被一次性校正,系统时间可能出现回拨,表现为后写入的数据时间反而更小。这会导致什么问题?如果你的业务逻辑依赖"时间更大 = 更新",比如增量同步用WHERE update_time > #{lastSyncTime},回拨期间写入的数据会被漏掉。

应对办法有几个。第一,增量同步不要只依赖时间,加一个自增 ID 或者版本号做兜底,用(update_time, id)组合条件。第二,对时间回拨极度敏感的场景,用应用侧生成的单调递增序列做主键,时间字段只做展示。第三,监控数据库服务器的时间偏移,SHOW GLOBAL STATUS LIKE 'Uptime'配合系统层面的时间检查,偏移超过阈值就告警。

并发写入时还有个小现象:同一毫秒内写入的多行,如果表上有基于时间的唯一索引,会冲突。比如你用(user_id, create_time)做唯一键来防重复提交,用户手速快一点,两次请求落在同一毫秒,第二次就报唯一键冲突。这种情况要么把唯一键换成业务令牌,要么接受冲突并做幂等处理,别指望毫秒精度能完全区分并发。

6. 常见问题速查与排查实录

这一节把我遇到过的问题整理成对照表,遇到现象可以直接查。

6.1 报错、现象与根因对照表

现象或报错可能原因处理方式
ERROR 1067 Invalid default value列声明了 fsp,DEFAULT或ON UPDATE的函数没带同样的 fsp两处都改成CURRENT_TIMESTAMP(3)这类带参写法
建表报语法错误MySQL 版本低于 5.6.4,不支持 fsp升级版本,或改用BIGINT存毫秒
落库的小数部分全是.000字段类型没带(3),或NOW()没带参数改列类型,SQL 里改用NOW(3)
应用传了毫秒但库里没有格式化模板缺SSS,或驱动sendFractionalSeconds=false检查序列化配置和连接串参数
插入.9995变成下一秒.000MySQL 对超出精度的部分做四舍五入,会进位业务侧先截断到目标精度再写入,避免跨秒
STR_TO_DATE返回NULL格式串带%f但输入串没有小数部分输入补齐.000,或改用不带%f的格式串
按天统计少数据用了BETWEEN ... 23:59:59,漏掉毫秒部分改成左闭右开区间
翻页出现重复记录排序值相同,返回顺序不稳定排序加唯一列兜底,如, id DESC
从库读到的时间精度不对主从表结构不一致,或迁移期间延迟确认表结构已同步,迁移窗口切主库读
TIMESTAMP读出来差几小时连接串没配时区,或会话time_zone不一致配connectionTimeZone,统一会话时区

关于四舍五入那条,值得单独说一句。MySQL 对小数秒的处理是四舍五入,不是截断。DATETIME(3)列插入'2024-03-15 23:59:59.9996',会进位成'2024-03-16 00:00:00.000'。如果你的业务是按天归档、按分区写入,这个进位会让数据落到下一天的分区里,跨天对账时对不上。稳妥做法是在应用层就把时间截断到毫秒,用LocalDateTime.truncatedTo(ChronoUnit.MILLIS),别让数据库替你做这个决定。

6.2 存量数据的精度升级方案

最后说一个完整的升级流程,从老表到新表,我按实际操作顺序写一遍。

第一步,盘点影响面。查information_schema.columns找出所有DATETIME和TIMESTAMP列,按表大小、是否有索引、是否被其他表外键引用排序。优先处理业务核心表,日志表可以放后面。

SELECT table_schema, table_name, column_name, column_type, column_default, extra FROM information_schema.columns WHERE data_type IN ('datetime', 'timestamp') AND table_schema = 'your_db' ORDER BY table_name, ordinal_position;

第二步,评估锁和耗时。在测试环境的同规模数据上跑一遍ALTER,记录耗时。几百万行以内、ALGORITHM=INPLACE可用的,直接在线改。上千万行的,用在线 DDL 工具。

第三步,改表结构。注意DEFAULT和ON UPDATE两处都要改,别只改类型。

ALTER TABLE t_order MODIFY COLUMN create_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), MODIFY COLUMN update_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3);

第四步,同步改代码。所有写入这一列的 SQL、ORM 映射、序列化配置、前端时间格式,都要跟着改。这一步最容易被遗漏,尤其是前后端约定的时间格式,前端如果按秒解析,多出来的.123可能直接导致解析报错。

第五步,验证。写一条测试数据,用SELECT create_time, MICROSECOND(create_time) FROM t_order ORDER BY id DESC LIMIT 1;确认小数部分不是 0。再跑一遍按天统计的 SQL,对比改表前后的数据量,确认没有因为边界问题漏数。

第六步,观察主从和监控。改表后的 24 小时内盯着主从延迟、慢查询、错误日志。DATETIME(3)的隐式转换比DATETIME更容易触发全表扫描,如果查询条件里的字面量没带小数部分,优化器可能用不上索引。这一点用EXPLAIN确认一下执行计划就行。

我个人在几次升级里体会到的是,改表本身不难,难的是把链路上所有环节都对齐。有一次改完表之后,落库还是.000,查了半天发现是持久层框架里的自定义 TypeHandler 在设置参数时调用了setObject,把一个LocalDateTime当成字符串处理了,格式模板里只有到秒。这种问题没有捷径,只能沿着链路一段一段抓日志验证。

还有一个建议:如果你们团队正在做新项目,别等到出问题再改。建表规范里直接约定"业务时间字段统一用DATETIME(3),默认值写CURRENT_TIMESTAMP(3),SQL 里取时间写NOW(3)",把这三条写进 code review checklist,能省掉后面 90% 的返工。

返回列表