MySQL毫秒精度全链路:DATETIME(3)避坑与排障

发布时间:2026/11/3 4:18:12
MySQL毫秒精度全链路:DATETIME(3)避坑与排障

1. 毫秒精度为什么总在细节上翻车

MySQL 日期时间字段精确到毫秒这件事,听起来像是个建表时顺手加个括号就能解决的问题:DATETIME(3)、TIMESTAMP(3),完事。但我这些年接手过的库里面,真正把毫秒精度用对的不到一半,剩下的大多踩过至少一个坑——要么精度声明了但写进去永远是.000,要么查询条件写=结果一条都匹配不上,要么改完字段类型之后发现整个业务链路上的时间对象根本不带毫秒。问题的根源从来不在DATETIME(3)这个语法本身,而在精度这条链路上:应用层的时间对象精度、驱动与连接层的处理策略、数据库列定义的 fsp(小数秒精度)、查询与聚合函数的截断行为、时区转换的舍入顺序,五个环节任何一处掉链子,最终落到表里的值就是错的,而且错得很安静,不报错、不告警,只在某天对账或者排查顺序问题时才暴露出来。

这篇内容适合三类人看:正在设计订单、日志、监控、支付流水这类对时序敏感的表结构的同学;手上已经有一张秒级精度的表、要平滑升级到毫秒级又不敢随便ALTER的同学;还有一类是明明字段定义写了(3),但从代码里读出来永远差几毫秒、对不上账,想搞清楚到底哪一层把精度吃掉了的同学。我不打算只讲语法手册上那几行,重点是那几个环节各自会怎么坑你、怎么验证、怎么改。文中所有涉及的表名、业务场景都是脱敏后的通用例子,你把字段换成自己项目里的名字直接套就行。

1.1 三个高频痛点的共性

第一类痛点最典型:订单表用DATETIME记录创建时间,两个订单在同一个秒内先后生成,按时间排序出来顺序随机。翻页的时候更明显,ORDER BY created_at LIMIT 10 OFFSET 10这种写法,同一秒内的数据在两次查询里位置会漂,前端就出现"翻到第二页看到第一页已经看过的记录"这种现象。第二类痛点出现在对账环节,外部系统给的流水时间精确到毫秒,你入库的时候字段是秒级,落库那一刻被四舍五入到整秒,回头按时间去跟对方比对,永远差那么几百毫秒,一批数据全成了"疑似不一致"。第三类痛点藏得最深:字段定义是DATETIME(3),但业务代码用的是某些语言里精度只有秒或者只有毫秒的对象,序列化再反序列化几道之后,值已经变形了,你看着字段类型是对的,数据却是错的。

这三个场景表面上毫无关系,底层原因其实是同一个——时间在整条链路上是被"分段"处理的,而精度取决于最短的那块板。数据库层面能存到微秒((6)),不代表你的驱动、你的语言运行时、你的 JSON 序列化器都愿意陪它走到微秒。所以处理这类问题,思路不是"给字段加个 (3) 就完事",而是先把链路上每一段的精度上限盘出来,找到那个瓶颈位置,再决定是统一抬高还是主动降级。我个人的习惯是先做一次端到端探针:造一条带明显毫秒尾巴的数据,从应用写到库,再从库读回应用,全程打日志,看它在哪一步尾巴被切掉。这一步花不了十分钟,但能省掉后面几天的瞎猜。

1.2 方案横向对比:DATETIME(3)、TIMESTAMP(3)、BIGINT 与字符串

存毫秒级时间,市面上常见的无非四种方案,各自的取舍差别挺大,选之前最好想清楚自己的查询模式和时间跨度。

方案存储字节(参考)时间范围时区行为适用场景主要短板
DATETIME(3)5 + 2 = 7 字节1000-01-01 至 9999-12-31不做任何转换,存什么读什么绝大多数业务表不带时区语义,跨时区展示要靠应用层
TIMESTAMP(3)4 + 2 = 6 字节约 1970 至 2038 年写入按会话时区转 UTC,读出再转回需要自动时区换算的场景有年份上限,受会话时区变量影响
BIGINT存毫秒时间戳8 字节极大完全由应用决定日志、监控、时序类数据可读性差,函数与索引使用受限
VARCHAR(23)存字符串23 字节以上无限无极少数需要保留原始格式的场景比较与范围查询必须靠字符串规则,排序隐患大

先说要淘汰的两个。字符串存时间我是坚决不推荐的,除非你要保留的是"对方系统原样给的字符串"这种审计诉求。字符串排序是字典序,'2024-1-9'和'2024-01-09'排出来的结果和你想的不一样;范围查询也没法用上索引的最优效果,白白浪费存储。BIGINT存毫秒时间戳在日志类、埋点类场景里其实很常见,优点是跨语言无歧义、比较快,缺点是拿 SQL 客户端看一眼完全不知道是哪天,写报表的时候要套一层转换函数,对临时排查不友好。如果这张表主要是给程序读写、人看得少,BIGINT完全可用;如果表要给运营、客服、数据分析同学频繁查,还是老老实实用原生时间类型。

DATETIME(3)和TIMESTAMP(3)之间的选择,我的默认答案是DATETIME(3)。原因是行为可预测:存进去什么读出来就是什么,不受会话time_zone变量影响,也没有 2038 年这道坎。TIMESTAMP的优势是省一个字节,并且在时区换算上是自动的——写入时按当前会话时区转成 UTC 存,读取时再转回当前会话时区。这个特性在某些"服务器遍布多地、展示要跟随用户时区"的系统里是加分项,但它的代价是:同一个值在不同会话里读出来不一样,一旦连接池里的会话时区没统一,你会看到同一行数据"自己会变"。我见过最难受的一种情况是应用连接时区设成了本地时间、运维用客户端连进去默认是系统时区,两边看到的created_at差了八小时,排查了半天以为是数据被篡改。

提示:如果确实要用TIMESTAMP,务必在连接串或连接池初始化时显式固定会话时区,别依赖服务器默认值。这类"看起来能用、换个客户端就变样"的隐性依赖,后期维护成本极高。

至于DATETIME的年份起点 1000 年这个限制,正常情况下不影响业务,但如果你的数据里混进了 Excel 导入的1900-01-01这种占位值,反而正好落在合法区间内,不会报错,悄悄污染数据。所以对时间字段做非空校验和合理区间校验,比单纯改字段类型更能防脏数据。

1.3 精度声明和实际落库值的偏差

这一步是最反直觉的:MySQL 对小数秒不是截断,是四舍五入。你把2024-01-01 12:00:00.999写进一个DATETIME(1)的列,落库结果不是12:00:00.9,而是12:00:01.0——它进位到了下一秒。这个行为在单条数据上无所谓,但在两个环节会让你的数据看起来"莫名其妙多了一秒":一是批量对账时,一批时间戳各自被round,误差方向不统一,差值忽正忽负;二是当这个字段参与ON UPDATE CURRENT_TIMESTAMP(1)时,某些行的更新时间和相邻行的创建时间出现倒挂,顺序就乱了。

我遇到过最折腾的一次,是外部系统给的流水时间是xx:xx:59.987,我方字段精度是(2),舍入后变成下一分钟的xx:xx+1:00.00,然后按"分钟"做的分组统计,这笔数据就被算进了下一分钟,日终汇总对不上。查了两天才发现是精度舍入的锅。后来统一把字段精度提到(3),与上游保持一致,问题消失。

这里还有一层:列精度和表达式精度是两回事。NOW()默认返回秒级,NOW(3)才带毫秒;CURRENT_TIMESTAMP不加括号里的数字,也是秒级。所以如果你建表写的是DEFAULT CURRENT_TIMESTAMP,字段虽然是DATETIME(3),默认值永远会是.000——它会先按秒级算出一个值,再往三位精度的列里塞,当然是补零。正确写法是DEFAULT CURRENT_TIMESTAMP(3)。

-- 错误:字段有 3 位精度,但默认值永远是 .000 created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP -- 正确:默认值的精度与列精度对齐 created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)

还有一个隐蔽的坑:SYSDATE()和NOW()的语义不同,前者在语句执行期间会实时取值,后者取的是语句开始的时间戳。在高并发批量写入里,两者可能导致同一批数据的时间不完全一致。做耗时统计这类需求时,这一点要考虑进去,不然你算出来的耗时可能是个负数。

2. 把 (n) 这个后缀吃透:类型层面的精度机制

2.1 DATETIME 与 TIMESTAMP 到底差在哪

很多同学以为这两个类型的区别就是"一个带时区一个不带",其实更准确的表述是:TIMESTAMP在存储时做了一次时区归一化,DATETIME没有。TIMESTAMP内部的存储值是"从 UTC 纪元起算的秒数"(加上小数秒部分),写入时 MySQL 按当前会话的time_zone把它转换成这个内部值,读出时再按当前会话的time_zone转换回字面量。所以它天然具备"同一个时刻、不同时区展示不同字面量"的能力,代价是每次读写都依赖会话变量。

DATETIME则是存字面量本身,不做任何归一化。你在北京时间写入2024-01-01 08:00:00.000,无论从哪里、用什么时区连进去读,读出来都是这个字符串。这让它非常适合做"业务时间"——比如订单创建时间、审批通过时间,这类时间的语义是"在某个地方的某个时刻发生的业务事件",业务侧往往希望看到的就是原始记录,不想被时区转换换得面目全非。

范围的差别也要记住:TIMESTAMP上限是 2038 年前后,这是个硬约束,在设备有效期、合同到期日、证件有效期这类未来时间字段上使用,是有踩雷风险的。DATETIME到 9999 年,基本不用担心。至于存储字节,DATETIME在较新的版本里是 5 字节基础值加小数秒,TIMESTAMP是 4 字节加小数秒,差别不大,一个字节的省下换来年份限制,我觉得不太划算。

小数秒部分的存储也有规律,跟 fsp 取值相关,不是线性增长的:

fsp 取值小数秒占用字节典型用途
00秒级,传统业务表
1–21百毫秒级,精度要求不高的场景
3–42毫秒级,绝大多数业务场景
5–63微秒级,高频交易、性能埋点

所以DATETIME(3)是 5 + 2 = 7 字节,DATETIME(6)是 5 + 3 = 8 字节,TIMESTAMP(3)是 4 + 2 = 6 字节。做容量评估的时候,这两个字节的差别在亿级表上会被放大到几个 GB,值得算一算。但说句实话,除非你在做极致的存储优化,否则为了省一两个字节去降低精度、进而引发对账问题,是典型的小账算精细、大账算糊涂。

2.2 小数秒的存储成本与 fsp 取值决策

fsp 的取值范围是 0 到 6,代表小数秒保留几位。选择的时候不要"能多则多",也不要"能省则省",看两件事:上游数据源的天然精度和下游消费方的精度需求。

上游是设备采集、外部接口、传感器,如果对方给的就是毫秒级,那你选 3 是对的,选 6 也没错但浪费两个字节;如果对方给的是微秒级,你选 3 就等于主动截断,得在文档里写清楚这个降级是有意为之,否则将来对账又是一场事故。下游是报表、告警、审计,如果消费方只看到秒,那你选 6 存进去、展示的时候还是被截断,中间这两个字节就是纯浪费。

我自己的默认倾向是(3)。原因很朴实:绝大多数语言的时间对象、绝大多数序列化格式(ISO8601 带毫秒)都能无损承载毫秒,而微秒在很多链路上本来就撑不到,存了也未必传得到。真是高频交易、性能压测这类需要微秒的场景,再上(6)。

有一个细节容易被漏掉:不同精度的字段之间做比较和运算,MySQL 会按较高精度处理。DATETIME(3)和DATETIME(6)相减,结果是带小数秒的,这个没问题。但如果你把一个秒级的字符串字面量'2024-01-01 12:00:00'和一个DATETIME(3)列做等值比较,MySQL 会把字面量当成.000处理——所以那些实际值是.123的行,永远匹配不上。这就是下一个大坑的来源,后面单独讲。

2.3 隐式默认值、零值与严格模式

TIMESTAMP有一个历史遗留行为很值得警惕:在旧版本里,表里的第一个TIMESTAMP列会被自动加上DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,而且默认是NOT NULL。这导致很多人建表时没写默认值,结果字段自己"活"了,插入时自动填当前时间,更新时自动被刷新。这个隐式行为由一个系统变量控制,新版本已经改变了默认值,但如果你接手的是老库或者从别的环境导入的建表语句,很可能还带着这个历史包袱。

EXPLICIT_DEFAULTS_FOR_TIMESTAMP这个变量建议显式检查一下,尤其在跨版本迁移前。它决定的是TIMESTAMP列是否沿用那些隐式规则。把它打开,行为就变得可预测:不写NOT NULL就是可空,不写默认值就没有默认值,一切按你写的来。迁移到新版本时如果这个变量取值不同,同一个建表语句建出来的表行为可能不一样,字段会不会自动更新,完全取决于环境,这种"环境依赖"是最难排查的一类问题。

零值日期0000-00-00 00:00:00同理,在严格模式下插入会被拒绝,宽松模式下会写入并给个警告。这个值一旦进了表,参与时间计算会得到NULL或者奇怪的结果,排序也会排在最前面。我的做法是在建表阶段就把字段全部设成NOT NULL加业务上的合理默认值,配合严格模式,让脏数据在写入那一刻就失败,而不是事后去清洗。

3. 建表阶段的决策与落地写法

3.1 精度选 3 还是 6:一次成本换算

把成本摊开算一遍,决策就没那么纠结了。假设一张订单流水表规划 2 亿行,除了主键还有created_at、updated_at、paid_at三个时间字段:

  • 用DATETIME(0):每行 5 + 5 + 5 = 15 字节,合计约 3.0 GB。
  • 用DATETIME(3):每行 7 × 3 = 21 字节,合计约 4.2 GB。
  • 用DATETIME(6):每行 8 × 3 = 24 字节,合计约 4.8 GB。

从 0 到 3 多了 1.2 GB,从 3 到 6 又多了 0.6 GB。看起来不少,但这是行数据的裸大小,实际还要乘以索引膨胀和页填充率,说翻一两倍也不夸张。问题是:这 1.2 GB 换来的是"同秒内顺序可辨、对账不再丢精度",这笔账我认为任何有并发写入的业务都该付。而从 3 到 6 多花的 0.6 GB 换来的微秒精度,绝大多数链路根本传不到,属于花钱买不到效果。

所以我的建议是:有并发、有对账、有排序需求的表,一律(3);没有并发(比如配置表、字典表)的,秒级足够。真要微秒,先问清楚上游到底给不给得到微秒,再问下游到底用不用得上微秒,两个都答"是"再上(6)。

3.2 可直接复用的建表语句与索引设计

下面这个结构我在好几个项目里改改就用,字段命名和注释可以按自己的规范调整,关键是几个细节:默认值的精度要和列精度对齐,ON UPDATE也要带精度,索引按实际查询模式来建。

CREATE TABLE `order_record` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键', `order_no` VARCHAR(32) NOT NULL COMMENT '业务单号', `amount` DECIMAL(18,2) NOT NULL DEFAULT '0.00' COMMENT '金额', `status` TINYINT NOT NULL DEFAULT '0' COMMENT '状态', `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间,毫秒', `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间,毫秒', PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_status_created` (`status`, `created_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='订单流水';

索引这里展开说两句。(status, created_at)这个联合索引的顺序是有讲究的:如果查询是"某个状态下按时间倒序分页",把选择性低但过滤必用的status放前面,created_at放后面,能吃到索引过滤加排序的双重好处。反过来如果查询是"某个时间段内按状态统计",那可能(created_at, status)更合适。索引顺序不是背出来的,是照着你的实际 SQL 反推的,上线前用EXPLAIN看一眼key和Extra列里有没有Using filesort,能省掉后面很多优化工作。

还要提醒一句:不要把毫秒时间字段当唯一索引的一部分去做幂等。同一毫秒内也可能写入两条不同业务的数据,用(biz_id, created_at)做唯一约束看似能防重,但一旦业务调整精度或者出现时间回拨,就会莫名其妙报唯一键冲突。幂等应该靠业务唯一键,时间字段只当辅助。

3.3 应用层类型映射与框架配置

建表之后,字段能不能真的存进毫秒,取决于你用的框架和驱动认不认这个精度。这块的坑按语言分开说。

Java 生态里,java.util.Date精度只到毫秒,勉强够用;java.time.LocalDateTime精度到纳秒,映射DATETIME(3)时会自动截断到毫秒(注意是截断还是舍入,取决于驱动版本和配置)。MyBatis 里用LocalDateTime做参数类型是主流做法,但要注意jdbcType别写成DATE,否则时间部分直接丢掉,这是新手最常犯的一个错。另外从驱动版本角度看,较新的 Connector/J 版本对时间类型的处理更规范,支持通过连接参数控制时区与瞬时值保留策略;老版本在这些参数上支持不全,升级驱动往往比改 SQL 更有效。

Python 的datetime.datetime本身带微秒,pymysql、mysqlclient这类驱动读DATETIME(3)回来就是带微秒的datetime,写入时也会带上微秒,只要你不自己做strftime('%Y-%m-%d %H:%M:%S')这种格式化。这个格式化操作是精度丢失的头号杀手,我在代码审查里见到一次说一次。往里写之前一定要用参数化传datetime对象,别手工拼字符串。

JavaScript 这边情况特殊,Date对象精度只有毫秒,Date.now()返回的就是毫秒整数,够用。但要注意 JSON 序列化的默认行为会把它转成 ISO8601 字符串,带Z后缀表示 UTC;如果服务端按本地时间解析,就会出现几小时偏差,这种情况下毫秒倒不是问题了,时区才是。另一个常见问题是某些日期库的format默认模板不带毫秒,只输出到秒,前端展示看起来没问题,一旦这个字符串被回传回服务端,毫秒就永久丢了。

Node 侧连 MySQL 时,驱动有个配置项控制日期是否自动转成Date对象。开着方便,但对零值日期会抛异常;关掉拿到的是字符串,需要自己解析。两种都行,关键是知道自己在用哪种,别一半代码当对象用、一半代码当字符串用。

4. 写入与查询链路上的精度漏点

4.1 应用层时间对象自带的精度上限

前面提过,精度取决于链路上最短的那块板。我把常见的"短板"列一下,方便对照自查:

环节典型精度上限常见丢精度操作
JavaLocalDateTime纳秒手写DateTimeFormatter只到秒
Javajava.util.Date毫秒用SimpleDateFormat默认模板格式化
Pythondatetime微秒strftime不带%f
JavaScriptDate毫秒某些库format默认无毫秒
JSON 序列化视配置自定义序列化器省掉小数部分
数据库驱动视版本老驱动不识别 fsp 参数
消息队列视序列化格式用秒级时间戳字段传递

自查方法很简单:找个能打日志的位置,把时间对象用"能输出全部精度"的方式打出来(Java 用toString(),Python 用isoformat(),JS 用toISOString()),看尾巴在不在。尾巴不在了就往上一层找,一层层往上顶,通常十分钟内能定位到那个罪魁祸首。

注意:很多"精度丢失"其实是自己写死的。比如日志里打印%Y-%m-%d %H:%i:%s,看日志以为只有秒,实际存进库的是带毫秒的。排查前先确认你看的是不是全精度输出,别被自己的日志格式骗了。

4.2 查询条件的边界写法

这是毫秒精度普及之后最容易出线上问题的地方。字段是DATETIME(3),你的查询写WHERE created_at = '2024-01-01 12:00:00',MySQL 会把右边当成.000,实际数据是.123,一条也匹配不上。很多人第一反应是"数据没写进去",跑去查库发现数据好好的,然后开始怀疑人生。

正确的写法有两种。一种是范围查询,左闭右开:

-- 查某一天的数据,左闭右开,毫秒精度下最稳 SELECT * FROM order_record WHERE created_at >= '2024-01-01 00:00:00.000' AND created_at < '2024-01-02 00:00:00.000';

这里千万别用BETWEEN '2024-01-01 00:00:00' AND '2024-01-01 23:59:59',因为23:59:59后面还有23:59:59.999这一段,凡是落在最后 999 毫秒内的数据全被漏掉了。这个 bug 在秒级时代不明显(因为端点就是最后一秒),到毫秒级就变成了"每天最后不到一秒的数据查不到",非常隐蔽。改成< 次日零点就一劳永逸,也不用管当月是 28 天还是 31 天。

另一种是模糊匹配到秒,用函数截断,但要注意这会放弃索引:

-- 能查到,但 created_at 上的索引基本用不上(除非建函数索引) SELECT * FROM order_record WHERE DATE_FORMAT(created_at, '%Y-%m-%d %H:%i:%s') = '2024-01-01 12:00:00';

这种写法在数据量小的时候没感觉,上百万行就是全表扫。真要按秒聚合,正确做法是用范围条件把数据框出来,再在应用层或者GROUP BY里截断。

4.3 函数、聚合与格式化的坑

DATE_FORMAT的%f输出的是六位微秒,不是三位毫秒。你DATE_FORMAT(created_at, '%Y-%m-%d %H:%i:%s.%f'),就算列是(3),返回的也是六位,后面补三个零。下游如果拿这个字符串去做解析、做键值,格式就对不上了。要么用SUBSTRING截三位,要么在下游解析时容忍位数差异。

UNIX_TIMESTAMP()对带小数秒的列会返回带小数的数值,这点是对的;但如果你传入的是秒级字面量,返回的就是整数。做时间差计算的时候要注意两端精度一致,否则算出来的差值精度是随机的。

SUM(TIMESTAMPDIFF(MICROSECOND, start_at, end_at))这类聚合,如果两个字段精度不同(一个 3 一个 6),结果会按高的来,这没问题。但如果字段本身是秒级,你硬算微秒差,得到的永远是一百万的整数倍,看起来"精度很高",实际是自欺欺人。

还有一个特别容易被忽略:GROUP BY带毫秒的时间字段,等于按毫秒分组。你想按分钟聚合,写GROUP BY created_at,结果每一行都是一个独立分组,聚合完全失效。应按DATE_FORMAT(created_at, '%Y-%m-%d %H:%i')分组,或者按范围分桶。这类错误不会报错,只会让你看到一个"分组数量异常多"的结果集。

5. 时区、迁移与大表改精度的处理

5.1 TIMESTAMP 的时区转换链

用TIMESTAMP(3)的时候,一条数据从写入到读出要经过两次转换,每一层都可能引入偏差。写入时,应用把本地时间字符串发给数据库,数据库按当前会话的时区变量把它转成 UTC 存储;读出时,再按读取时的会话时区转回字面量。这一来一回,只要有一端的会话时区跟你预期不一致,结果就不对。

最典型的场景是连接池。连接池在初始化时建立了一堆连接,每个连接建立时都会继承服务器默认时区,如果应用中有一部分连接是通过不同方式创建的(比如某个定时任务用了独立的连接配置),这些连接的会话时区可能不同,于是同一个查询在两种连接上读到的时间差了几个小时。排查这种问题,第一步就是确认所有连接建立后都执行了统一的时区设置。

稳妥的做法是在连接串里指定时区,或者连接池初始化时统一执行一次会话设置,不要依赖服务器默认值。服务器换了机器、改了配置、做了主从切换之后,默认时区变了,你的"一切正常"就崩了。这种事发生一次,你对隐式配置的容忍度会永久降低。

5.2 存量表从秒级升到毫秒级的操作路径

线上表已经是DATETIME(秒级),现在要升到DATETIME(3),怎么做?先明确一点:这会是一条ALTER TABLE ... MODIFY COLUMN语句,本质上是改列类型,通常不能走"仅改元数据"的快速路径,大概率要重建表。小表无所谓,几千万行以上的表,直接在业务高峰期执行风险很高,锁表、主从延迟、磁盘空间翻倍,三个问题一起来了。

我的操作顺序是这样的。第一步,先在从库或者预发环境上跑一遍,记录耗时和磁盘增量,这个数据是决策依据。第二步,评估能不能用影子表方案:建一张结构带毫秒的新表,用工具分批把数据搬过去,边搬边同步增量,最后短暂锁写切换。这种方案对业务侵入小,代价是要有靠谱的工具和足够的磁盘余量。第三步,如果确实是中小表(几百万行以内)而且有维护窗口,直接ALTER也能接受,但一定要在低峰期做,并且提前确认主从延迟监控能看到。

-- 存量表升级字段精度(会重建表,大表慎用) ALTER TABLE order_record MODIFY COLUMN `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间,毫秒', MODIFY COLUMN `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间,毫秒';

执行前有个细节必须确认:旧数据升级后小数部分是 .000,这是正常的,因为源头就没有毫秒信息,不可能凭空变出来。如果有人期待升级后历史数据自动带上毫秒,那是对这个操作的理解有误。历史数据只能保持原样,精度提升只对新增数据生效。这点一定要跟业务方说清楚,否则上线后又要来解释一轮。

还有一点,升级前先把所有相关的写入代码、连接配置、ORM 映射全部改好并验证,再动表结构。反过来的顺序会让新旧代码同时写两种精度,中间那段时间数据是混杂的,事后想区分都难。

5.3 上线后的校验手段

改完不能只看"没报错"就收工,得有几个验证动作。第一,写一条带明显毫秒尾巴的测试数据,读回来确认尾巴还在;第二,用一个跨秒边界的值验证舍入方向,确认是不是符合你的预期;第三,抽一批上游有精确时间的记录做抽样对账,比对差异是否在可接受范围内;第四,观察一段时间内的时间字段分布,看看是否出现了异常密集的.000(如果大量出现,说明某条写入路径的精度还没修好)。

-- 查看毫秒部分分布,.000 占比异常高说明有写入路径丢了精度 SELECT SUBSTRING(DATE_FORMAT(created_at, '%f'), 1, 3) AS ms_part, COUNT(*) AS cnt FROM order_record WHERE created_at >= NOW() - INTERVAL 1 DAY GROUP BY ms_part ORDER BY cnt DESC LIMIT 10;

这个查询我一般在上线后第二天跑一次,如果排第一的是000且占比超过九成,那就是有地方还在生成秒级时间,顺着写入路径查一圈基本能揪出来。

6. 排障速查与几条压箱底的经验

6.1 现象、原因与处理对照表

现象大概率原因处理方式
字段定义了(3)但查出来全是.000默认值写成了CURRENT_TIMESTAMP,没带精度改默认值为CURRENT_TIMESTAMP(3)并回填
用=匹配时间查不到数据字面量被当作.000,实际值带毫秒改用左闭右开范围条件
每天最后一条数据查不到用了BETWEEN ... 23:59:59改成< 次日零点
时间比自己写入的多了 1 秒精度不足导致四舍五入进位提高 fsp 到与上游一致
同一行数据在不同客户端显示差几小时TIMESTAMP会话时区不一致统一连接时区配置,或改用DATETIME
ORDER BY 时间翻页内容重复秒级精度下同秒无法区分顺序升级到毫秒,并加第二排序键(如主键)
聚合后分组数量远多于预期按带毫秒的字段直接GROUP BY先格式化到目标粒度再分组
DATE_FORMAT输出的位数对不上%f固定六位微秒下游解析时容忍位数差异,或截取前三位

6.2 排查顺序建议

碰到时间精度相关的问题,我习惯按这个顺序走,能省不少时间:先看数据本身——用 SQL 客户端直连查一行,确认库里存的是不是带毫秒的,这一步把"库里的问题"和"展示的问题"分开;再看写入路径——在应用侧打一条全精度日志,比对应用认为的时间和库里实际的值,差异在这一步基本就暴露了;然后看读取路径——同一个查询,用客户端和执行代码分别跑,结果不一致就是驱动或序列化的问题;最后看时区——如果差异是整数小时,直接往时区查,别再纠结精度。

有个我自己在用的"笨办法"值得分享:在测试环境专门建一张探针表,两个字段,一个DATETIME(3)一个DATETIME(6),每次上线前跑一段最小代码把当前时间写进去再读出来,比对打出来的值。看着很土,但它能在五分钟内验证整条链路的精度,比事后对账强太多。这张表常年留着不影响任何业务,却帮我在多个版本升级里提前发现了驱动行为变化。

最后分享一个教训。排序的稳定性不要靠时间字段单独承担。就算你把精度提到了毫秒,同一毫秒内依然可能有多条记录,ORDER BY created_at DESC的结果顺序依然是不确定的。正确做法是加上主键作为第二排序键:ORDER BY created_at DESC, id DESC。这条规律在秒级时代是"偶尔出问题",到毫秒级只是把概率降低了,问题本身没消失。我见过不止一个分页错乱的 bug,最后定位到都是排序键不够。加个id成本几乎为零,收益却是彻底消除不确定性,没有理由不做。