1. 先搞明白 DATE、DATETIME、TIMESTAMP 到底差在哪
做 MySQL 开发的这些年,我有个很深的体会:大部分人在字符和日期类型之间转换翻车,不是因为 SQL 写错,而是根本没搞清楚自己在跟谁打交道。你说要把字符串转成 TIMESTAMP,结果字段建的是 DATETIME,那转换逻辑从根上就是另一套。所以这一章先别急着写函数,把三种类型掰开揉碎看清楚。
DATE 类型只存日期,没有时分秒,范围是 1000-01-01 到 9999-12-31,底层占 3 个字节。DATETIME 是在 DATE 基础上加了时间部分,精确到秒(如果你愿意,可以扩展到微秒),范围不变,底层占 8 个字节。TIMESTAMP 表面上也存年月日时分秒,但本质完全不同:它底层只占 4 个字节,存的是从 1970-01-01 00:00:00 UTC 到某个时刻的秒数。这意味着三件事:
第一,TIMESTAMP 的范围被死死限制在 1970 到 2038 年之间,2038 年问题就是这么来的。你拿字符转 TIMESTAMP 时如果目标是 2040 年,直接报错或者变成 NULL。第二,TIMESTAMP 存进去的是 UTC 秒数,查出来的时候会自动换算成当前会话的时区时间。第三,因为存储内容是秒数,它在跨时区业务里有天然优势,但如果不理解时区机制,它就是最大的坑。
| 特性 | DATE | DATETIME | TIMESTAMP |
|---|---|---|---|
| 存的内容 | 仅日期 | 日期+时间 | UTC 秒数 |
| 底层字节 | 3 | 8 | 4 |
| 范围 | 1000-9999年 | 1000-9999年 | 1970-2038年 |
| 时区敏感 | 否 | 否 | 是 |
| 自动初始化/更新 | 不支持 | 支持靠配置 | 原生支持(CURRENT_TIMESTAMP) |
我见过不止一个项目把"定单创建时间"这个字段选成 TIMESTAMP,理由是"主流教程都这么写"。结果业务做到第五年,碰上 2038 年相关讨论才发现根源在于选型时没想清楚范围问题。反过来,有些纯内部系统存的只是排期日期,却用了 DATETIME 多占了 5 个字节,倒也没什么错,就是浪费。
在动手转换之前,你先问自己一个问题:转换的目标类型到底是什么。很多时候我们写 STR_TO_DATE 只是为了把字符串变成能比较的日期,那么用 DATE 还是 DATETIME 不关键,只要两边对齐就行。但如果你要把转换结果写进 TIMESTAMP 字段,就一定要额外考虑时区对存储值的影响——这一点我在第四章专门展开。
2. 字符转 DATE:STR_TO_DATE 和 CAST 的正确打开方式
2.1 STR_TO_DATE 的格式掩码规则
字符串转日期,最稳的函数是 STR_TO_DATE(str, format)。它有两个参数,第一个是待解析的字符串,第二个是格式掩码。格式掩码定义了字符串里每一位代表什么含义,从年份到秒,一一对应。
写掩码之前必须想清楚一件事:格式掩码的结构必须和字符串结构完全一致,否则结果可能就是 NULL。比如字符串是 "2024-01-15",那你格式掩码就得写成 '%Y-%m-%d'。这里的 %Y 代表四位年份,%m 代表两位月份,%d 代表两位日。如果你图省事写成 '%Y/%m/%d',解析直接失败。
我列一份高频格式符对照表,平时写转换基本就靠它:
| 格式符 | 含义 | 示例 |
|---|---|---|
| %Y | 四位年份 | 2024 |
| %y | 两位年份 | 24 |
| %m | 两位月份 | 01 |
| %c | 月份(可无前导零) | 1 |
| %d | 两位日期 | 15 |
| %e | 日期(可无前导零) | 15 |
| %H | 24小时制小时 | 14 |
| %h / %I | 12小时制小时 | 02 |
| %i | 分钟 | 30 |
| %s / %S | 秒 | 45 |
| %p | AM/PM | PM |
| %f | 微秒(6位) | 123456 |
举个完整例子。假设有一条日志里记录的原始时间是 "2024-01-15 2:30 PM",你想把它转成 DATE。这时候格式掩码必须照顾到 12 小时制:
SELECT STR_TO_DATE('2024-01-15 02:30 PM', '%Y-%m-%d %h:%i %p');这条语句会返回 '2024-01-15'。因为 STR_TO_DATE 解析成功后会生成一个 DATETIME 值,但当你把它作为 DATE 上下文使用时,MySQL 会直接把时间部分截掉。你可以试试手动显式转换:
SELECT CAST(STR_TO_DATE('2024-01-15 02:30 PM', '%Y-%m-%d %h:%i %p') AS DATE);结果同样是 '2024-01-15'。在这整个过程中,格式掩码里任何一个字符对不上,MySQL 都不会报错,而是返回 NULL。这个"静默失败"的特性非常坑人——你以为是数据问题,其实是掩码没配对。
2.2 CAST 与隐式分配:看似简单也有讲究
除了 STR_TO_DATE,很多人喜欢用 CAST('2024-01-15' AS DATE) 这种写法。对于标准格式的字符串,它确实能直接用。MySQL 支持一些固定的格式才能被 CAST 识别,比如 'YYYY-MM-DD'、'YYYY-MM-DD HH:MM:SS',还有带小数秒的变体。稍微偏离标准格式,CAST 就无能为力了。
除此之外,还有一个非常容易忽略的点。当你把字符串列和 DATE 列做比较时,MySQL 会尝试把两边转成同一种类型。注入数据的场景里最常见的写法是直接赋值:
INSERT INTO demo_table(create_date) VALUES ('2024-01-15');如果 create_date 字段是 DATE 类型,MySQL 会隐式地将字符串转换为日期,然后存入。这套机制跟 CAST 走的是同一套解析规则,只认标准格式。一旦你传入 '2024/01/15' — 注意是斜杠——MySQL 也能识别,因为它对日期的分隔符有一定的宽容度。但你传 '15-Jan-2024' 这种人类友好格式,它就完全不认识了。
我建议这类简单的转换统一用 STR_TO_DATE 配合掩码,原因很实际:格式写明白了,可读性高,别人接手时一眼就知道数据长什么样。CAST 虽然短,但对格式的要求藏在隐含规则里,排查问题要花更多时间。
2.3 一个常见误区:时间部分被静默丢弃
再强调一次 DATE 类型的行为:它根本不存时间,所以任何时间部分都会被丢弃,而且是悄悄丢弃,没有任何警告。这在数据处理流水线里特别容易出幺蛾子。
举个例子,某个报表系统把上游推送的时间字符串 "2024-01-15 14:30:00" 转成 DATE 存入"统计日期"字段。当天的报表显然应该包含 14:30 这个时刻的数据,但因为字段是 DATE,时间信息全丢了。等你后面想统计每天各小时段的分布,数据已经没法补救。
解决办法有两个思路:要么从一开始就把字段设计成 DATETIME,只在展示层再截取日期部分;要么你明确知道只需要日期,那就主动用 DATE(字符串) 或 DATE_FORMAT 处理,至少代码里写清楚"时间不重要"的意图。我个人更推荐第一种,保留完整时间信息永远有回旋余地。
3. DATE 转 TIMESTAMP:这层窗户纸到底该往哪边捅
3.1 为什么 DATE 转 TIMESTAMP 会问"几点"
DATE 转 TIMESTAMP 和上面说的字符转 DATE 完全是两码事。DATE 没有时间部分,但 TIMESTAMP 必须是"年月日时分秒"完整的时刻。转换过程中必须给时间部分填一个默认值。MySQL 选择的默认值是 00:00:00,代表当天零点。
比如你有一个 DATE 值 '2024-01-15',把它转到 TIMESTAMP,结果就是 '2024-01-15 00:00:00'。这个逻辑合理,但这里要提醒你一个容易犯的理解错误:并不是所有写 DATE 的地方都只把时间归零。当你查询某个日期区间的数据时,常见写法是:
WHERE create_time >= '2024-01-15'这里 create_time 是 TIMESTAMP 或者 DATETIME,右边传入的标准字符串会被隐式转成 '2024-01-15 00:00:00',取当天零点之后的数据,逻辑没问题。可如果你写的是:
WHERE DATE(create_time) = '2024-01-15'那这就不叫转换了,是把 create_time 整个变成 DATE 然后再比较。代价是无法走 create_time 索引。这个坑我在第六章还会细讲。
3.2 CAST 直接转和拼接时间字符串两条路
把 DATE 值转成 DATETIME 或 TIMESTAMP,最直接的方式是:
SELECT CAST('2024-01-15' AS DATETIME); -- 结果:2024-01-15 00:00:00 SELECT CAST('2024-01-15' AS TIMESTAMP); -- 结果:2024-01-15 00:00:00注意这里把字符串传给 CAST,MySQL 会先解析成日期,再补默认时间。也可以用一个日期类型的字段直接参与转换。假设某表有一个 date_field 列,类型是 DATE:
SELECT CAST(date_field AS DATETIME) FROM demo_table;另一种思路是拼接字符串。因为你知道目标格式是 'YYYY-MM-DD HH:MM:SS',把 DATE 值格式化成 'YYYY-MM-DD' 字符串,再拼上 ' 00:00:00',最后用 STR_TO_DATE 解析。这看起来绕,但在某些场景下反而是更显式、更稳妥的做法,尤其是你从外部系统拿到的是字符串,想先验证格式再写库。
SELECT STR_TO_DATE(CONCAT('2024-01-15', ' 00:00:00'), '%Y-%m-%d %H:%i:%s');就我个人的习惯,纯内部逻辑用 CAST 就够了,简洁;但只要设计到外部接口、每次导入的数据格式可能变化时,STR_TO_DATE 那套反而更可控。因为你可以把格式掩码做成配置,外部格式一变更,只改掩码就行。
3.3 严格模式下的行为差异
DATE 转 TIMESTAMP 还有一个你必须知道的分岔路口:sql_mode 里的 strict 模式直接影响转换失败时的表现。在非严格模式下,非法日期比如 '2024-02-30 00:00:00' 会被 MySQL 容忍为某种形式——通常是 NULL,也可能被自动调整为 '2024-03-01',取决于版本和具体语境。在严格模式下,同样的语句直接报错,整条 SQL 事务回滚。
这是个很容易被忽视的坑。生产库一般开着严格模式,开发环境可能没有。结果同一套脚本在本地跑得好好的,一提交到生产就报日期值非法错误。排查半天才想起来检查 sql_mode。
SELECT @@SESSION.sql_mode;你可以在转换前加一道数据清洗层,把明显不合法的字符串先过滤掉;也可以统一用 STR_TO_DATE 加掩码解析,因为掩码根本不认识 2 月 30 日,它直接返回 NULL,至少不会出现魔改后的脏数据。
4. 字符转 TIMESTAMP:真正的大坑藏在时区里
4.1 TIMESTAMP 的存储真相:UTC 与会话时区
讲字符转 TIMESTAMP,绕不开它的存储机制。TIMESTAMP 不是"存一个漂亮字符串",它存的是一个 UTC 秒数。写入时,MySQL 会把当前会话时区下的时间换算成 UTC 秒数存进去;读取时,再按当前会话时区换算回来显示给你。
用一个例子演示。假设你有一条语句:
SET time_zone = '+08:00'; CREATE TABLE ts_demo(ts TIMESTAMP); INSERT INTO ts_demo(ts) VALUES ('2024-01-15 12:00:00');此时 ts 内部的 UTC 值是 04:00:00 UTC。接下来换一个会话时区再查:
SET time_zone = '+00:00'; SELECT ts FROM ts_demo;你看到的会是 '2024-01-15 04:00:00'。同一份数据,时区一变,显示就变了。并不是数据错了,而是 TIMESTAMP 的语义就是这样——它表示的是"某个绝对时刻"。
这跟 DATETIME 完全不同。DATETIME 是字面存储,你写入 '2024-01-15 12:00:00',到时候查出来就是 '2024-01-15 12:00:00',不管会话时区怎么切换。所以跨时区系统我通常建议用 TIMESTAMP;但如果你的业务严格以本地时间为准,比如某机关单位只关心北京时间的上下班打卡,DATETIME 反而省心。
4.2 字符串里带着时区偏移怎么办
接下来是字符转 TIMESTAMP 最头疼的情况:外部系统传来的字符串自带时区偏移。比如某个海外服务商返回的支付完成时间是 "2024-01-15 12:00:00+08:00",或者 "2024-01-15T04:00:00Z"。
MySQL 的 STR_TO_DATE 并不直接支持时区偏移解析。你给它 '%Y-%m-%dT%H:%i:%sZ' 这种掩码,它能勉强解析 Z 字面量,但它不知道 Z 表示 UTC。你真正要做的是先把字符串拆出时间和偏移量,再用 CONVERT_TZ 转换。
举个例子,假设字符串是 "2024-01-15 12:00:00+08:00"。一个稳定的处理流程是:先提取 "2024-01-15 12:00:00",转成 DATETIME;再根据偏移 +08:00 把它从 +08:00 时区转换到目标时区。假如我要存的 TIMESTAMP 以北京时间(+08:00)为显示基准,但原始字符串是 UTC(Z),那么:
SET @dt = STR_TO_DATE('2024-01-15 04:00:00', '%Y-%m-%d %H:%i:%s'); SELECT CONVERT_TZ(@dt, '+00:00', '+08:00'); -- 结果:2024-01-15 12:00:00CONVERT_TZ 这个函数你务必记牢,它是处理跨时区字符串的钥匙。它的三个参数分别是:待转换的 DATETIME 值、源时区、目标时区。注意它要求第一个参数是一个 DATETIME 值,不识别 TIMESTAMP 内部秒数的概念,所以应用场景更多集中在"字符串/外部数据"到"标准时间"的转换上。
还有一个更省事的变种,如果你拿到的就是 UNIX 秒数(比如类似 1705305600 的数字),直接用 FROM_UNIXTIME 秒数,它会按当前会话时区给出对应的日期时间字符串,然后你再用 CAST 或 STR_TO_DATE 落到 TIMESTAMP 字段。反过来要把 TIMESTAMP 变成秒数,用 UNIX_TIMESTAMP(ts)。
4.3 2038 年的坑给转换题加了一行注释
前面说过 TIMESTAMP 只到 2038-01-19 03:14:07 UTC。2038 这个边界,在字符转 TIMESTAMP 的时候会以一种特别恼人的方式出现:如果你的字符串表示 2039-01-01,用 STR_TO_DATE 转出来的 DATETIME/日期没有任何问题,但你要把它存进 TIMESTAMP 字段,MySQL 会抛出一个"日期超出范围"错误,还可能因为在严格模式下导致整次写入失败。
这个问题在数据库运维圈被讨论过很多轮,但实际业务影响往往滞后。如果系统设计寿命超过 2038 年,或者合同中明确了未来几十年的服务周期,选 DATETIME 比硬撑 TIMESTAMP 要踏实得多。MySQL 官方对 TIMESTAMP 的支持文档也明确写了范围边界,解决思路不外乎两种:一是换 DATETIME,二是把日期按 32 位以外的存储方式处理——但 DATETIME 通常才是那个最简单的答案。
5. 日期时间回灌成字符串:输出格式化的反向操作
5.1 DATE_FORMAT 格式符一览与输出模板
转换从来不是单向的。数据库里存的是 DATE 或 TIMESTAMP,但到了接口返回、日志导出、报表文件这些环节,最终都得变成字符串。日期转字符串,第一主力函数是 DATE_FORMAT。
DATE_FORMAT(dt, format) 用法很简单,把日期时间值按掩码输出成字符串。一个典型场景是生成报表文件名里的日期标记:
SELECT DATE_FORMAT(NOW(), '%Y%m%d_%H%i%s'); -- 结果比如:20240115_143000或者你要输出成 ISO 风格带毫秒的字符串:
SELECT DATE_FORMAT('2024-01-15 14:30:00.123456', '%Y-%m-%dT%H:%i:%s.%f'); -- 结果:2024-01-15T14:30:00.123456DATE_FORMAT 能处理的格式符和 STR_TO_DATE 基本一一对应,区别在于 STR_TO_DATE 是拿格式当模板去解析输入,DATE_FORMAT 是拿格式当模板去渲染输出。这套格式符体系对你来说其实是同一套词汇表,熟练了以后两个方向都能玩得转。
我在实际项目里输出日期字符串时,最常碰到的三种需求:MySQL 客户端展示要人性化,接口返回要给标准机器格式,导出文件要符合业务模板。三种需求建议各写一套固定的格式模板,别混用。比如接口统一 ISO8601 的 'YYYY-MM-DDTHH:MM:SS' 格式,日志统一 'YYYY-MM-DD HH:MM:SS',文件名统一 'YYYYMMDD',这样下游消费方一目了然。
5.2 面向程序的字符串输出:秒级时间戳与 ISO 格式
程序对接场景里,还有一种"字符串"你经常要面对——纯数字的 UNIX 时间戳。很多人以为 UNIX_TIMESTAMP 和 FROM_UNIXTIME 只用于把日期时间转成秒数,其实它们是双向转换里的另一套表达体系。
一个典型的双向转换组合:
-- 日期时间 -> 秒数 SELECT UNIX_TIMESTAMP('2024-01-15 14:30:00'); -- 秒数 -> 日期时间字符串 SELECT FROM_UNIXTIME(1705300200);注意 FROM_UNIXTIME 的结果是字符串,它的内容依赖当前会话时区。如果你要的是一段固定格式的字符串,可以在外层套一层 DATE_FORMAT。比如把秒数变成 'YYYY-MM-DD HH:MM:SS':
SELECT DATE_FORMAT(FROM_UNIXTIME(1705300200), '%Y-%m-%d %H:%i:%s');如果秒数带小数,表示毫秒级别,把它直接扔给 FROM_UNIXTIME 会返回带小数秒的字符串,百分位以后的行为由 MySQL 版本决定。某些版本会把多余部分截断,某些版本会四舍五入。写入前最好明确自己期望的精度,避免下游解析时出现偏差。
5.3 避免 LOCALE 差异导致的格式化意外
MySQL 的 DATE_FORMAT 输出结果受会话级 locale 影响不大,但有一个地方例外:如果你的 SQL 用到了 %W(星期的完整名称)、%M(月份的完整名称)这些文字性输出,它们会依据系统内置的语言包产生不同文字。这在高版本 MySQL 里很常见——服务器语言包是英文,那你输出 %M 就是 January;某些多语言发行版可能输出出中文。
我记得有个项目要把日期展示成 "Monday, January 15, 2024" 这种人类可读格式,测试环境输出正常,部署到生产后变成 "星期一, 一月 15, 2024"。排查一圈,发现是生产库的 language 配置不同。从那以后我给自己定了一条规矩:输出给程序的字符串一律只用数字格式符,文字信息在应用层处理,数据库不去碰语言相关的格式化。
另外一个容易被忽略的点是 sql_mode 中的 NO_ZERO_DATE 和 NO_ZERO_IN_DATE。如果你存的数据包含 '0000-00-00' 这种特殊日期(老系统中很常见),DATE_FORMAT 对它的行为在不同模式下不一致,可能输出 '0000-00-00',也可能报错。如果确实有历史脏数据,建议转换前统一用 CASE 判断处理掉。
6. 实战视角:从表设计到接口对接的转换策略
6.1 表设计阶段:日期字段到底选 DATETIME 还是 TIMESTAMP
聊了这么多转换细节,最终都要落到一张张表上。日期字段选型问题,几乎每个 MySQL 项目都会遇到,我直接给判断框架。
第一,如果系统跨时区、用户分布在不同地区、且你需要记录的是某个绝对时刻(比如登录时间、下单时间),选 TIMESTAMP。它天然解决"全球同一时刻"的换算问题,只要客户端传来本地时间字符串,服务器按当前时区换算成 UTC 存储,各时区客户端读出来又按各自时区显示,体验一致。
第二,如果业务只关心本地日历语义(比如营业日报的日期、财务月结日期),选 DATE 或 DATETIME。这类字段不关心 UTC 秒数,更不该受会话时区影响。
第三,明确未来年限。2038 年不再是远得摸不着的事,很多系统的设计周期就是 10 到 20 年。2035 年上线的系统再叠加 15 年合同,2038 这个坎真的会踩到。TIMESTAMP 的死穴就在这里,怎么绕都绕不开。
下面是我常给团队的一张选型备忘:
| 场景 | 推荐类型 | 理由 |
|---|---|---|
| 用户跨时区访问的订单时间 | TIMESTAMP | 存绝对时刻,自动换算 |
| 财报的统计基准日 | DATE | 与日历日对应,无时间部分 |
| 系统内部的创建时间,单一时区 | DATETIME | 范围大,不涉及时区 |
| 需要时间戳自动更新 | TIMESTAMP | 原生支持 ON UPDATE |
6.2 接口对接的关键:统一外部字符串格式
接口对接时的日期转换问题,多半不是 SQL 技巧问题,而是格式约定问题。我见过最典型的反面教材:外部系统给的是 "15/01/2024",内部系统写的是 "2024-01-15",两边都觉得自己没错,结果对接表里堆满了 NULL。
比较稳妥的做法是,在系统边界统一做一道转换。外部进来的任意日期字符串,先经过一个标准化的解析层,解析成内部标准格式 'YYYY-MM-DD HH:MM:SS',再落库。解析层里你可以集中处理以下情况:
- 自动识别几种常见输入格式(斜杠、连字符、中文年月日)
- 对非法日期进行标记或拒绝入库
- 统一处理时区偏移,所有外部字符串先转成标准时区
- 对日期字段的分隔符做宽容处理
这道边界处理的意义在于:数据库内永远只有一种格式,程序内永远只用同一套解析函数,运维和排障时不会出现"到底是谁的格式出了问题"这种争论。
6.3 一条常用综合 SQL 的逐步拆解
最后放一条综合 SQL。它演示了从外部字符串提取、转换、格式化的完整闭环,也是我在某个数据同步项目里实际打磨过的写法。假设有一张外部导入表 external_events,里面有两列:raw_time 存原始时间字符串(含时区偏移),e_date 是 DATE 类型的目标字段。目标是把 raw_time 转成目标库的 DATE,同时排除非法数据。
INSERT INTO target_table(e_date) SELECT DATE(CONVERT_TZ( STR_TO_DATE(SUBSTRING_INDEX(raw_time, '+', 1), '%Y-%m-%d %H:%i:%s'), '+00:00', '+08:00' )) FROM external_events WHERE raw_time IS NOT NULL AND STR_TO_DATE(SUBSTRING_INDEX(raw_time, '+', 1), '%Y-%m-%d %H:%i:%s') IS NOT NULL;拆开看每个环节:SUBSTRING_INDEX(raw_time, '+', 1) 把 "2024-01-15 04:00:00+00:00" 截成 "2024-01-15 04:00:00"。STR_TO_DATE 把这个字符串按掩码解析成 DATETIME。CONVERT_TZ 把这个 DATETIME 从 +00:00 时区转到 +08:00 时区。DATE() 把你关心的日期部分抽取出来,最后写入 DATE 字段。WHERE 条件里对原始字符串再做一次同样的解析,并过滤 NULL,防止非法字符串混进来。
这样的一整条链路,哪怕外部数据源加一个雷鸣,你也能顺着每一步看到是哪一环节趴下了。字符串的截取、掩码对不上的解析、时区转换错位、日期被截断,每个环节都有充分的排查空间。
在实际处理中,我发现很多人为了"简洁"跳过了中间步骤,直接把 raw_time 扔给 CAST 或 STR_TO_DATE,然后抱怨 MySQL 不按预期工作。其实大多数问题不是函数的问题,而是你没给数据一个明确的"来路"和"去路"。转换工作从来都不是单行函数能解决的,设计好数据流,比背熟十个函数更重要。