1. 前缀:为什么大家都在折腾日期转换
先问你一个问题:如果把数据库里的20240806这个字符串,和2024-08-06 17:30:00这个标准日期,直接用>比较大小,结果会是什么?
很多人第一反应是“能比较”,因为数据库做了一个隐式转换。但实际开发中,因为字符串格式不规范、时间戳位数不一致、时区处理不对,导致索引失效、查询结果偏移、数据插入报错的案例,我见过太多了。做数据处理、做报表、写同步任务,几乎每周都会碰到日期和时间戳互相转换的需求,这个技能根本绕不开。
这篇博文,我不讲那些文档里翻来覆去的老话,重点放在真正可落地的操作上:字符怎么转成 DATE、怎么转成 TIMESTAMP,DATE 和 TIMESTAMP 怎么再转回字符串,以及那些最容易踩的坑——隐式转换、索引失效、时间戳单位搞错、时区偏差这些,统统给你说透。
适合谁看?想搞懂 MySQL 日期函数、写 SQL 总在日期类型上报错的人,或者正在做数据清洗、接口对接的同学。我尽量用大白话,把关键参数和验证方法讲清楚,你看完可以直接对着操作。
2. 先把类型搞清楚,不然转换全是白忙
2.1 DATE、DATETIME、TIMESTAMP 三者到底差哪了
很多新手一上来就写CAST('2024-08-06' AS TIMESTAMP),结果发现 8.0 版本能出结果,5.7 就不行,然后就懵了。要想转换不出错,首先得把 MySQL 里的这几个时间类型分清楚。
- DATE:只存日期,格式是
YYYY-MM-DD,范围从1000-01-01到9999-12-31。它没有时分秒,适合存生日、节日这类只关心“哪天”的数据。 - DATETIME:存日期加时间,格式
YYYY-MM-DD HH:MM:SS,范围同上。它不关心时区,你存进去什么,查出来就是什么。适合存业务发生时间,比如下单时间、支付时间。 - TIMESTAMP:也是存日期加时间,但它内部是以 UTC 标准时间存储的,范围只有
1970-01-01 00:00:01到2038-01-19。当你查询的时候,MySQL 会按照当前会话的时区,把内部 UTC 值转换成你本地的时间显示。
这三者的区别,用大白话讲就是:DATE 像是日历上圈一个日子,DATETIME 像是相册里那张照片的时间戳,TIMESTAMP 则更像是闹钟上的时间——它自身不带时区信息,但会根据你所在的位置(时区)显示不同的时间。
实际使用中,如果业务只关心日期,用 DATE;如果关心具体时刻,并且系统分布在多个时区,优先考虑 TIMESTAMP,因为它会自动做时区转换;如果应用和数据库都在同一个时区,用 DATETIME 最省心。
2.2 字符到 DATE 和字符到 TIMESTAMP 的转换路径
字符到什么类型,路径是不一样的。最简单的规则就两条:
- 字符串转 DATE:用
CAST('2024-08-06' AS DATE)或者STR_TO_DATE('2024-08-06', '%Y-%m-%d')。 - 字符串转 TIMESTAMP:没有直接
CAST到 TIMESTAMP 的写法(至少不推荐这么干),标准做法是先STR_TO_DATE得到 DATETIME,再CAST成 TIMESTAMP;或者直接用TIMESTAMP('2024-08-06 17:30:00')这个函数。
有人会用CAST('2024-08-06 17:30:00' AS DATETIME),这也很常见。注意,DATETIME 和 TIMESTAMP 在绝大多数场景下存储占用和查询效果非常接近,真正常见的转换路径是:字符 → DATETIME,然后根据需求 CAST 成 TIMESTAMP 或 DATE。
判断到底转成哪种,我的原则很简单:存着给业务查询用,就 DATETIME;涉及多时区同步、数据要跨区域流转,就 TIMESTAMP;只需要算日期差、按天分组,直接 DATE。
3. 核心函数逐一说透:不只是查文档,是理解背后的逻辑
3.1 STR_TO_DATE:把任意格式字符解析成日期
STR_TO_DATE(str, format)是字符转日期最核心、最灵活的函数。它的工作原理,是告诉 MySQL 你字符串里的“位置几”代表年份、“位置几”代表月份,MySQL 按你给的模板去解析。
示例:
SELECT STR_TO_DATE('2024-08-06', '%Y-%m-%d'); -- 输出 DATE 类型: 2024-08-06 SELECT STR_TO_DATE('2024/08/06 17:30:45', '%Y/%m/%d %H:%i:%s'); -- 输出 DATETIME 类型: 2024-08-06 17:30:45 SELECT STR_TO_DATE('20240806', '%Y%m%d'); -- 输出 DATE: 2024-08-06格式符是灵魂,这里列几个命中率最高的:
| 格式符 | 含义 | 示例输入 | 解析结果 |
|---|---|---|---|
| %Y | 四位年份 | 2024 | 2024 |
| %y | 两位年份 | 24 | 2024(按当前世纪推断) |
| %m | 两位月份 | 08 | 8月 |
| %c | 月份,可为1或2位 | 8 | 8月 |
| %d | 日,两位 | 06 | 6日 |
| %e | 日,可1或2位 | 6 | 6日 |
| %H | 24小时制 | 17 | 17点 |
| %h | 12小时制 | 05 | 5点 |
| %i | 分钟 | 30 | 30分 |
| %s | 秒 | 45 | 45秒 |
| %p | AM或PM | PM | 下午 |
注意%i是分钟,不是%M。%M是月份的英文全名(比如 August),很多人写%m写习惯了,哪天数据里带着英文字母就懵了。
再强调一个点:STR_TO_DATE返回的是 DATE 或 DATETIME 类型,取决于格式串里有没有时间部分。如果你给的是'%Y-%m-%d',返回 DATE;给了'%Y-%m-%d %H:%i:%s',返回 DATETIME。
3.2 DATE_FORMAT:日期转字符,报表输出的第一选择
转过去还得转回来。前端报表、文件导出、接口返回,几乎都是字符串格式,而数据库里存的是 DATE 或 DATETIME,这时候就得靠DATE_FORMAT(date, format)。
SELECT DATE_FORMAT('2024-08-06 17:30:45', '%Y-%m-%d'); -- 输出 '2024-08-06' SELECT DATE_FORMAT('2024-08-06 17:30:45', '%Y年%m月%d日 %H:%i'); -- 输出 '2024年08月06日 17:30' SELECT DATE_FORMAT('2024-08-06', '%Y%m%d'); -- 输出 '20240806'这就是STR_TO_DATE的逆操作。格式符几乎一一对应,%Y-%m-%d转过去再转回来,数据完全不变形。
我一般在做报表时,按天、按月分组就直接用 DATE_FORMAT 控制粒度:
SELECT DATE_FORMAT(create_time, '%Y-%m') AS month, COUNT(*) FROM orders GROUP BY month;这里有个小技巧:DATE_FORMAT出来的字符串,照样可以用在GROUP BY的别名里,个别数据库不允许,但 MySQL 是允许的,省一次子查询。
3.3 CAST 和 CONVERT:类型转换的万能钥匙
当字符串格式已经符合 MySQL 默认标准'YYYY-MM-DD'或'YYYY-MM-DD HH:MM:SS'时,用CAST最省事:
SELECT CAST('2024-08-06' AS DATE); -- 得 DATE 类型 SELECT CAST('2024-08-06 17:30:45' AS DATETIME); -- 得 DATETIME 类型 SELECT CONVERT('2024-08-06', DATE); -- 和上面等价CONVERT语法类似:CONVERT(expr, type)。
但注意,CAST出来的结果,取决于你给的目标类型。你把一个带时间的字符串CAST AS DATE,时间部分直接会被丢掉,比如:
SELECT CAST('2024-08-06 17:30:45' AS DATE); -- 输出 2024-08-06从字符串安全的角度看,凡是外部传进来的数据,我都推荐先用STR_TO_DATE校验一遍格式,再用 CAST 做类型收口。直接 CAST 虽快,但遇到'2024-8-6'这种不补零的格式,有的版本能解析,有的版本直接报错,很尴尬。
3.4 UNIX_TIMESTAMP 和 FROM_UNIXTIME:时间戳和日期的双向奔赴
说到 TIMESTAMP,很多人第一时间想到的是 Unix 时间戳,也就是从 1970-01-01 00:00:00 UTC 开始计算的秒数。这个和 MySQL 的 TIMESTAMP 类型是两个概念,别混。但在数据对接时,接口传过来的经常是1722323444这种数字,或者1722323444000这种毫秒数,这时候你必须和数据库时间类型互相转。
-- 日期转时间戳(秒) SELECT UNIX_TIMESTAMP('2024-08-06 17:30:45'); -- 输出 1722951045 -- 时间戳(秒)转日期 SELECT FROM_UNIXTIME(1722951045); -- 输出 '2024-08-06 17:30:45' -- 毫秒时间戳处理:先除以1000再转 SELECT FROM_UNIXTIME(1722951045000 / 1000);注意:UNIX_TIMESTAMP函数对字符串参数是“尽力解析”的,它接受'2024-08-06 17:30:45',也接受'20240806173045'这种。但它返回的是十进制字符串或数字,如果你要精确到毫秒,需要自己用UNIX_TIMESTAMP(...) * 1000或者存成BIGINT。
反过来,收到毫秒时间戳,不要直接FROM_UNIXTIME(1722951045000),因为超出秒范围,结果会变成负数或 NULL。先除以 1000,再四舍五入,最后转。
这两个函数是我做数据接口对接时用最多的,几乎每天都会碰见“接口返回的是毫秒时间戳,库里存的是 datetime,怎么对上”这种问题。核心公式就三句话:
毫秒 → 秒:除以 1000
秒 → DATETIME:FROM_UNIXTIME
DATETIME → 秒:UNIX_TIMESTAMP
4. 实操演练:从一个真实需求看完整转换链路
4.1 场景描述
假设你接到一个任务:从第三方接口拿到一批订单数据,文件里日期字段长这样:2024/08/06 17:30,还有些是2024-08-06T17:30:45(带 T 的 ISO 格式),要入库到 orders 表,字段类型是 TIMESTAMP,同时要生成一个按日分组的统计报表。
这个需求里就串联了前面所有函数:字符解析、标准化格式、转时间戳类型、再按日期分组统计。
4.2 第一步:清洗字符到标准 DATETIME
先把两种格式统一。
-- 情况一:2024/08/06 17:30 SELECT STR_TO_DATE('2024/08/06 17:30', '%Y/%m/%d %H:%i'); -- 结果 2024-08-06 17:30:00 -- 情况二:2024-08-06T17:30:45 SELECT STR_TO_DATE('2024-08-06T17:30:45', '%Y-%m-%dT%H:%i:%s'); -- 结果 2024-08-06 17:30:45T只是一个普通字符,放在格式串里直接用,不需要转义。这条对拉取 API 数据特别有用,因为 ISO8601 格式在接口里很常见。
如果数据里还带着时区后缀,比如2024-08-06T17:30:45Z,那Z表示 UTC 零时区。先去掉Z或者直接用REPLACE处理掉,再解析,库里的时区偏移逻辑单独算。
4.3 第二步:统一转成 TIMESTAMP 存储
INSERT INTO orders (order_id, order_time) VALUES (1001, CAST(STR_TO_DATE('2024/08/06 17:30', '%Y/%m/%d %H:%i') AS TIMESTAMP));写成插入语句,就是先把字符串用 STR_TO_DATE 变成 DATETIME,再 CAST 成 TIMESTAMP。在大多数情况下,MySQL 会自动把合法 DATETIME 值转成 TIMESTAMP 存入,所以这里甚至直接STR_TO_DATE(...)也行。但显式 CAST 是好习惯,至少别人看你的 SQL 时,一眼就知道你要存什么类型。
4.4 第三步:统计报表输出
统计每天订单量:
SELECT DATE_FORMAT(order_time, '%Y-%m-%d') AS day, COUNT(*) AS order_cnt FROM orders WHERE order_time >= STR_TO_DATE('2024-08-01', '%Y-%m-%d') AND order_time < STR_TO_DATE('2024-09-01', '%Y-%m-%d') GROUP BY day;注意这里 WHERE 条件写的是order_time >= STR_TO_DATE('2024-08-01', '%Y-%m-%d'),而不是DATE_FORMAT(order_time, '%Y-%m-%d') >= '2024-08-01'。区别巨大:前者让索引生效,后者因为对字段做了函数操作,索引直接失效,全表扫描。
这一条就是我在实战中反复强调的:能用范围比较就绝不对字段做函数处理。很多慢查询的根源,就是WHERE DATE_FORMAT(create_time, '%Y-%m-%d') = '2024-08-06'这种写法。
4.5 项目中的完整处理流程参考
如果这个订单同步任务要正规化设计,我一般会按下面几步来做:
- 先建一个 raw_stage 临时表,字段全是
VARCHAR,原始数据先落库。 - 用 STR_TO_DATE 把各种各样字符时间统一清洗成
DATETIME,这一步做数据质量校验。 - 清掉解析失败的脏数据,比如格式错位、日期越界。
- 再把清洗后的数据插入正式表,时间字段用 CAST 转 TIMESTAMP。
- 报表层再按 DATE_FORMAT 或 GROUP BY 聚合。
这种分层做的好处是,脏数据的追溯非常容易,哪个环节出了问题,定位很快。
5. 常见坑与排查技巧:那些官方文档里不会写明白的事
5.1 坑一:隐式转换让你查出了错误数据
把日期字段和字符串直接比较,比如:
WHERE order_time = '2024-08-06'表面上能跑,结果也对,因为 MySQL 自动把字符串转成了日期。但如果你比较的是'2024-08-06 17:30:45'这个精确到秒的字符串,而字段是 TIMESTAMP,转换后也能对上。真正危险的是:
WHERE order_time = '2024-08-06 17:30:45.123'字符串带了毫秒,字段不带,可能匹配不上。更危险的场景是字符串格式不规范,比如'2024-8-6',MySQL 在某些版本会当作合法日期解析,某些版本直接报错,或者解析成 NULL。
我的排查习惯:所有外部传入的时间参数,一律先 STR_TO_DATE 预处理,再做比较。不要嫌多写几行。
5.2 坑二:TIME 和 DATE 遇上隐式转换的类型优先级
MySQL 有类型转换优先级:DATETIME/TIMESTAMP 跟字符串比较时,通常字符串会转成日期再比。但 DATE 跟 TIMESTAMP 比较时,可能有意外。
SELECT TIMESTAMP('2024-08-06 00:00:00') = DATE('2024-08-06 00:00:00');这个看起来应该相等,但实际上因为 TIMESTAMP 内部带时区,DATE 会被解释成2024-08-06 00:00:00(无时区),转换过程中可能有时区偏移偶发差异。结论:跨类型比较尽量先用 CAST 统一类型,别指望隐式转换百发百中。
5.3 坑三:0000-00-00 这种“合法脏数据”
MySQL 允许0000-00-00这种日期存在,尤其历史数据里很常见。当你用 STR_TO_DATE 转换时,它会直接报错或返回 NULL。
SELECT STR_TO_DATE('0000-00-00', '%Y-%m-%d');某些版本返回 NULL,某些版本直接报Incorrect datetime value。处理方案:在清洗阶段用 CASE WHEN 先把这种脏数据改成 NULL,或者用NULLIF拦截,必要时开启sql_mode里的NO_ZERO_DATE,干脆不允许这玩意进库。
5.4 坑四:UNIX_TIMESTAMP 的时区陷阱
UNIX_TIMESTAMP转换结果是 UTC 秒数,它不受时区影响。但 FROM_UNIXTIME 在把秒数转回日期时会用当前会话时区,如果你的数据库连接设置了不同time_zone,同一个秒数转出来的本地时间会不同。
排查方法:
SELECT @@session.time_zone, @@global.time_zone; SELECT FROM_UNIXTIME(1722951045);默认是系统时区间。如果数据对接的双方位于不同时区,建议统一用 UTC 或统一使用同一时区配置,否则会出现“时间差了8小时”的经典问题。
5.5 坑五:字符编码引起的日期字符串乱码
之前的同事接过一批数据,日期字段输出全是2024?8?6这种乱码。排查到最后,不是日期函数的问题,是源文件编码是 GBK,连接字符集是 utf8,转换时把/转成了?。这种问题,用 STR_TO_DATE 前必须确认字符集配置:
SET NAMES utf8mb4;然后查看字段原始字节,确认是不是编码问题。日期转换报错时,先排字符集再排格式,能省不少时间。
5.6 版本差异:5.7 和 8.0 在转换上的行为区别
MySQL 5.7 里CAST('2024-08-06 17:30:45' AS TIMESTAMP)会直接报语法错误(实际不支持直接 CAST 到 TIMESTAMP 类型),而 8.0 版本宽容了一些。但别高兴太早,8.0 更严格的是日期校验,'2024-02-30'这种不存在的日期,5.7 某些版本会转成0000-00-00加警告,8.0 直接报错。
所以跨版本迁移时,日期转换逻辑一定要回归测试,尤其是异常日期样本,准备一批'2024-02-30'、'2024-13-01'、'0000-00-00'这种,一个个跑一遍。
5.7 常见问题速查表
| 问题现象 | 可能原因 | 第一排查动作 |
|---|---|---|
导入报错Incorrect date value | 字符串格式不匹配 | 用 SELECT 逐条 STR_TO_DATE 排查 |
| 查询结果差8小时 | 时区配置不一致 | 检查time_zone参数 |
| DATE_FORMAT 分组慢 | 对字段做函数操作导致索引失效 | 改成范围条件 |
| 字符串字段不能转 TIMESTAMP | 直接 CAST 到 TIMESTAMP 不支持 | 先 STR_TO_DATE 再 CAST |
| 秒数结果负数 | 毫秒时间戳直接给 FROM_UNIXTIME | 除以 1000 再转 |
| 转出来全是 NULL | 数据含脏字符或编码问题 | 检查字符集和原始字节 |
6. 实用场景扩展:这块内容还能用在哪些地方
6.1 数据仓库同步里的时间转换
写同步任务从业务库抽数到数据仓库时,经常出现源库是 DATETIME,目标数仓要求字符串YYYY-MM-DD HH:MM:SS,或者相反。这种活写 SQL 就能完成,比如:
-- 大查询里的时间格式化 SELECT DATE_FORMAT(created_at, '%Y-%m-%d %H:%i:%s') AS created_at_str FROM source_table;倒过来,从数仓的字符串分区字段过滤数据时,也是先转成日期范围再查。
6.2 接口对接和报表导出场景
接口返回时间给前端,如果给的是'2024-08-06T17:30:45Z'这种 ISO 格式,前端解析没问题;但传统报表导出用 Excel,最好就是'2024-08-06 17:30:45'这种纯字符串,Excel 识别日期最稳定。这一步就是在查询里加个 DATE_FORMAT,没啥难的。
反过来,Excel 导入时,日期列经常是'8/6/2024'美国格式,用 MySQL 处理就是:
SELECT STR_TO_DATE('8/6/2024', '%c/%e/%Y');%c和%e就是给这种“不补零”的格式准备的。
6.3 定时统计任务的日期边界
定时任务处理“昨天”“上周”这类业务窗口时,我一般统一在代码层计算好边界:
-- 昨天的数据 WHERE create_time >= DATE_SUB(CURDATE(), INTERVAL 1 DAY) AND create_time < CURDATE()注意,CURDATE()返回 DATE 类型,和 DATETIME 字段比较时又撞上隐式转换。这里我建议用DATE_SUB和CURDATE()组合,不踩任何格式化路子。
7. 收尾:从实操中沉淀的几个实用习惯
做日期转换这么些年,我沉淀了几个习惯,供你参考。
第一,写转换函数时永远带样本验证。不要写完 SQL 就往生产上冲,先SELECT STR_TO_DATE(...)粘贴几个样本试跑,尤其是边界日期、月末、闰年 2 月 29 日,跑一遍保证没错再上线。
第二,时间字段设计优先选 DATETIME,除非明确有多时区需求。用 TIMESTAMP 省不了多少存储,反而引入时区问题,对团队协作来说 DATETIME 是穷人的确定性。
第三,所有外部数据入口先清洗再入库,清洗的动作里一定包含日期格式校验。宁可多花一点时间在清洗层,也别把脏日期放给下游表,不然后面修数据的成本是清洗的十倍。
我个人在实际操作中的体会是,日期和时间戳转换这个问题,看起来是函数记忆的问题,本质上是类型思维的问题——你只有想清楚数据从哪来、要去哪、中间经过哪些类型变化,代码才能写稳。写 SQL 最怕的就是“当时能跑就行”,日期相关的隐性风险,往往会在数据量变大、格式变得多的时候集中爆发。你手头如果正有日期转换报错或者类型混乱的 SQL,拿这篇文章里的排查表对一遍,大概率能定位到问题。
最后再分享一个小技巧:凡是拿不准的日期转换表达式,先跑一句SELECT CAST(STR_TO_DATE('2024-08-06', '%Y-%m-%d') AS DATE);验证一下返回类型,再嵌套进正式 SQL。这算是最简单的自测方式,不花什么成本,但能拦住大部分低级事故。