做数据查询绕不开日期和时间。平时不管统计今天的订单量、按月拉报表,还是清理三个月前的历史日志,SQL里到处都要跟一串 YYYY-MM-DD HH:MM:SS 打交道。我见过不少人,业务 JOIN 写得挺溜,一碰到日期函数就卡壳,在 NOW()、GETDATE()、DATE_FORMAT、DATEDIFF 之间来回翻文档,不同数据库的写法还经常搞混,最后要么语法报错,要么统计出来的数据对不上。
这篇文章就专门聊聊 SQL 查询里常用到的日期函数。我会把这套东西分成几个板块:先讲清楚日期在数据库里的底层逻辑,再把高频函数逐个拆解,最后落到真实业务场景,附上我实际踩过的坑和排查思路。适合刚接触 SQL 的初学者,也适合平时写报表查询、但没系统梳理过日期函数的人。内容以 MySQL 和 SQL Server 为主,同时补充 PostgreSQL、Oracle 和国产达梦的差异点,这样你不管在哪个数据库环境下,都能快速找到对应的写法。
1. 先把底层的逻辑捋清楚:日期函数为什么难写
1.1 日期在数据库里不是一个“数”,而是一个有边界的区间
很多人写日期条件时,习惯性把日期当成一个普通字符串去匹配,结果经常出现“明明有数据,查询却查不到”的诡异情况。根源在于:数据库里的日期时间类型,底层存储远比表面看到的要复杂。
拿 MySQL 举例,DATETIME 和 TIMESTAMP 虽然显示格式差不多,但内部存储逻辑完全不同。DATETIME 是一个纯粹的时间值,不依赖时区,存储范围也更大;而 TIMESTAMP 本质上是从 1970-01-01 00:00:00 UTC 开始计算的秒数,写入和读取时会根据数据库或会话的时区设置来回转换。SQL Server 里也有类似的区别,DATETIME 精确到 3.33 毫秒,DATETIME2 精确到 100 纳秒,DATE 则只保留日期部分。
这就引出写日期 SQL 的第一个关键认知:日期字段的精度直接决定比较结果。如果字段是 DATETIME 类型,存储的是 2024-01-15 14:30:22,而你用 WHERE create_time = '2024-01-15' 去查,表面上看好像能匹配上,实际在大多数数据库里,字符串 '2024-01-15' 会被隐式转换成 '2024-01-15 00:00:00',跟存储值根本不相等,于是这一天的数据一条都查不出来。所以处理日期类型字段,第一原则是搞清楚字段精度,再决定用等号还是范围。
1.2 日期函数的五大类,先有全局观
日期函数看起来多,其实归归类就清晰了。按功能划分,我习惯分成五类:
| 功能类别 | 要解决什么问题 | 代表函数 |
|---|---|---|
| 获取当前时间 | 取数据库当前的日期时间 | NOW、CURDATE、GETDATE、SYSDATE、CURRENT_TIMESTAMP |
| 格式化 | 把日期转成指定格式的字符串 | DATE_FORMAT、FORMAT、TO_CHAR、CONVERT |
| 提取部分值 | 从完整日期中取出年、月、日、时、分、秒 | YEAR、MONTH、DAY、DATEPART、EXTRACT |
| 日期运算 | 对日期做加减,得到另一个日期 | DATE_ADD、DATE_SUB、DATEADD、INTERVAL |
| 计算差值 | 求两个日期之间相差多少天、多少月 | DATEDIFF、TIMESTAMPDIFF、MONTHS_BETWEEN |
这五类基本覆盖了日常 90% 以上的日期处理需求。你遇到一个需求,先判断它属于哪一类,再去找对应的函数,比东翻一个西翻一个要高效得多。后面我逐个函数拆解,也是按这个分类来讲的。
1.3 不同数据库的差异,不是坑而是规律
很多人抱怨“日期函数在不同数据库里长得完全不一样”,这确实是事实,但背后的差异是有规律的。MySQL 和很多开源数据库偏向直观风格,函数名字本身就说明用途;SQL Server 和 Oracle 保留了很多传统命名,参数顺序也各有讲究。
比如获取当前时间:
sql复制-- MySQL / PostgreSQL
SELECT NOW();
-- SQL Server
SELECT GETDATE();
-- Oracle / 达梦
SELECT SYSDATE FROM DUAL;
同一个需求,三个数据库三种写法,没有任何一种能全兼容。所以我建议你记住的不是每个数据库的所有函数,而是记住“当前我在哪个数据库环境下”,然后对应查一套写法。文章后面的对照表,就是帮你快速定位的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 高频日期函数逐个拆解,附可直接抄的写法
2.1 获取当前时间:NOW、GETDATE、SYSDATE 不止是名字不同
获取当前时间是最常见的场景,用来做默认值、记录操作时间、计算相对日期。但这几个函数看着差不多,细节差异其实不少。
MySQL 里 NOW() 和 SYSDATE() 经常被当成同一个函数,实际上它们有个重要区别:NOW() 在一条 SQL 语句开始执行时就固定取值,不管这条语句跑多久,值都不变;SYSDATE() 是函数执行到的那个时刻才取值,语句里多次调用会得到不同时间。这意味着,如果一条慢 SQL 里用了多个 SYSDATE(),前后可能差出好几秒,导致时间比较出现细微偏差。规范做法是用 NOW(),保证语句内取值一致。
SQL Server 里,GETDATE() 是最常用的,返回 DATETIME 类型;SYSDATETIME() 返回 DATETIME2,精度更高。如果只是取当前日期(不带时间),SQL Server 可以用 CAST(GETDATE() AS DATE),MySQL 可以用 CURDATE(),PostgreSQL 可以用 CURRENT_DATE。
建议做记录插入时,数据库时间统一在 SQL 里取,而不是把应用服务器时间传进去。原因很简单:应用服务器和数据库服务器的时间很可能有偏差,一旦偏差超过几秒,日志或订单的创建时间就对不齐,排查问题时会非常痛苦。
2.2 格式化日期:DATE_FORMAT、FORMAT、TO_CHAR 的格式符差异
格式化日期,本质是把日期类型转成指定格式的字符串,用于报表展示、分组统计、导出文件的前置处理。很多新手在这块最容易懵,因为不同数据库的格式符规则完全不同。
MySQL 用 DATE_FORMAT,格式符是 % 开头:
sql复制SELECT DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s');
-- 结果:2024-11-09 14:23:45
SQL Server 用 FORMAT 函数,格式符是 .NET 风格:
sql复制SELECT FORMAT(GETDATE(), 'yyyy-MM-dd HH:mm:ss');
-- 结果:2024-11-09 14:23:45
PostgreSQL 和 Oracle 用的是 TO_CHAR:
sql复制SELECT TO_CHAR(NOW(), 'YYYY-MM-DD HH24:MI:SS');
-- 结果:2024-11-09 14:23:45
这里最容易踩的坑就是格式符混用:在 MySQL 里写 yyyy-MM-dd,它认不出来;在 SQL Server 里写 %Y-%m-%d,同样报错或返回乱码。我把常用的格式对照放一起,方便对照:
| 含义 | MySQL | SQL Server | Oracle / PostgreSQL |
|---|---|---|---|
| 四位年份 | %Y | yyyy | YYYY |
| 两位年份 | %y | yy | YY |
| 两位月份 | %m | MM | MM |
| 两位日期 | %d | dd | DD |
| 24小时制 | %H | HH | HH24 |
| 12小时制 | %h | hh | HH12 |
| 分钟 | %i | mm | MI |
| 秒 | %s | ss | SS |
顺带提一句:SQL Server 的 FORMAT 函数虽然好用,但底层依赖 .NET CLR,在海量数据上做类型转换时性能明显不如传统的 CONVERT。我的建议是:小表上随意用 FORMAT,大表做统计时尽量用 CONVERT(varchar(10), [create_time], 120) 这种原生写法,性能差距能到好几倍。
2.3 提取年月日:YEAR、MONTH、DAY、DATEPART 与 EXTRACT
从日期里取年、月、日,是做分组统计时最常用的操作。比如“按年份汇总销售额”“按月统计用户注册数”,本质上都是先提取日期中的某个部分,再进行分组聚合。
MySQL 的写法最简单直接:
sql复制SELECT
YEAR(create_time) AS year_num,
MONTH(create_time) AS month_num,
DAY(create_time) AS day_num
FROM orders;
SQL Server 里除了 YEAR()、MONTH()、DAY() 三个快捷函数,还有一个更通用的 DATEPART:
sql复制SELECT
DATEPART(year, create_time) AS year_num,
DATEPART(month, create_time) AS month_num,
DATEPART(day, create_time) AS day_num
FROM orders;
PostgreSQL 和 Oracle 则统一用 EXTRACT:
sql复制SELECT
EXTRACT(YEAR FROM create_time) AS year_num,
EXTRACT(MONTH FROM create_time) AS month_num,
EXTRACT(DAY FROM create_time) AS day_num
FROM orders;
这里有个细节值得注意:提取函数返回的是数字,不是字符串。如果要做字符串拼接,比如生成 '2024-11' 这种月份键,直接用提取函数拼接会出现数字运算的问题。我之前见过有人写 YEAR(create_time) + '-' + MONTH(create_time),结果得到一堆数字相加的乱码。正确做法是先格式化,再拼接,或者直接用格式化函数一步到位。另外,周数提取也有讲究,MySQL 的 WEEK() 函数有一个参数控制一周从周一开始还是周日开始,默认是周日,国内业务通常建议用 WEEK(date, 1),否则周一和周日的数据会被归到不同周,对不齐业务口径。
2.4 日期加减运算:DATE_ADD、DATE_SUB 与 DATEADD
日期加减的需求太常见了:查最近 7 天、上个月的数据、三个月前的日志,核心都是给当前日期加上或减去一个时间间隔。这里的关键词是“间隔”,不同数据库表达时间间隔的方式差别很大。
MySQL 的语法用 INTERVAL,可读性很好:
sql复制SELECT
NOW() + INTERVAL 7 DAY,
DATE_ADD(NOW(), INTERVAL 1 MONTH),
DATE_SUB(NOW(), INTERVAL 3 HOUR);
SQL Server 没有 INTERVAL 关键词,用 DATEADD 函数,注意参数顺序是“单位、数值、日期”:
sql复制SELECT
DATEADD(day, 7, GETDATE()),
DATEADD(month, 1, GETDATE()),
DATEADD(hour, -3, GETDATE());
PostgreSQL 支持 INTERVAL 字符串写法,而且可以直接跟日期做算术运算:
sql复制SELECT
NOW() + INTERVAL '7 days',
NOW() + INTERVAL '1 month',
NOW() - INTERVAL '3 hours';
Oracle 的写法最朴素:日期加一个数字就代表加一天。想做更复杂的加减,用 ADD_MONTHS:
sql复制SELECT
SYSDATE + 7,
ADD_MONTHS(SYSDATE, 1)
FROM DUAL;
做日期加减的时候,最需要注意的就是“月份最后一天”的边界问题。比如 1 月 31 日加一个月,MySQL 和 SQL Server 会返回 2 月 28 日(或 29 日),Oracle 的 ADD_MONTHS 也是类似规则。但如果你用“加 30 天”来模拟“加一个月”,那 1 月 31 日加 30 天得到 3 月 2 日,口径就对不上了。所以,业务上要求“按月”的,必须用月为单位去加减,不要用固定天数替代。
2.5 日期差值:DATEDIFF 的参数方向千万别搞反
计算两个日期相差多少天、多少月,是大促活动统计、用户活跃周期分析里的高频需求。但 DATEDIFF 这个函数在不同数据库里的参数顺序完全相反,我问过很多人,十个里有六个在这里栽过跟头。
MySQL 的 DATEDIFF(date1, date2),返回值是 date1 减去 date2 的天数:
sql复制SELECT DATEDIFF('2024-02-01', '2024-01-30');
-- 结果:2
SQL Server 的 DATEDIFF(unit, start_date, end_date),返回的是 end_date 减去 start_date:
sql复制SELECT DATEDIFF(day, '2024-01-30', '2024-02-01');
-- 结果:2
同样叫 DATEDIFF,一个是第一个参数减第二个参数,一个是第三个参数减第二个参数。如果你从 MySQL 跳到 SQL Server,沿用原来的习惯,结果就会多一个负号或者对不上。更隐蔽的是 SQL Server 的 DATEDIFF 是按“边界跨越次数”计算的,不是按实际时间差取的整数值。比如 DATEDIFF(YEAR, '2023-12-31', '2024-01-01') 返回 1,因为跨越了年份边界;DATEDIFF(MONTH, '2024-01-31', '2024-02-01') 也返回 1,因为跨了月份边界。这在计算年龄、工龄时很容易出现“虚岁”偏差,需要特别注意。
MySQL 里如果想精确计算月差,推荐用 TIMESTAMPDIFF,它是按实际时间差取整,更符合直觉:
sql复制SELECT TIMESTAMPDIFF(MONTH, '2024-01-15', '2024-02-14');
-- 结果:0(不满一个月)
SELECT TIMESTAMPDIFF(MONTH, '2024-01-15', '2024-02-15');
-- 结果:1
3. 业务场景落地:这几个报表查询可以拿去改
3.1 按天、按月分组统计订单,推荐哪种写法
分组统计是日期函数最主流的应用场景。这里的关键不是“取年月日”本身,而是分组键的类型和格式,必须用格式化函数生成一个字符串键,直接用原始日期字段分组会因为秒级精度的差异把每一天拆成无数组。
MySQL 按天统计订单时最常用的写法:
sql复制SELECT
DATE_FORMAT(create_time, '%Y-%m-%d') AS stat_day,
COUNT(order_id) AS order_cnt,
SUM(pay_amount) AS total_amount
FROM orders
WHERE create_time >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
GROUP BY DATE_FORMAT(create_time, '%Y-%m-%d')
ORDER BY stat_day;
按月统计的写法类似,把日期格式改成 '%Y-%m' 就行。SQL Server 里有一个自己很顺手的方式,用 CONVERT 把日期截断到天或月:
sql复制SELECT
CONVERT(varchar(10), create_time, 120) AS stat_day,
COUNT(*) AS order_cnt
FROM orders
WHERE create_time >= DATEADD(day, -30, GETDATE())
GROUP BY CONVERT(varchar(10), create_time, 120)
ORDER BY stat_day;
CONVERT(varchar(10), 日期, 120) 这个写法在 SQL Server 里有两个好处:一是能直接得到 'yyyy-MM-dd' 格式的字符串,二是比 FORMAT 函数快不少。按月统计就把 varchar(10) 改成 varchar(7),得到的键就是 'yyyy-MM'。
另外提一个进阶技巧:有时候报表需要按“周”统计,MySQL 用 YEARWEEK 函数。但注意它默认周日作为一周起点,国内习惯周一作为一周起点,要加参数 1:
sql复制SELECT
YEARWEEK(create_time, 1) AS week_key,
COUNT(*) AS order_cnt
FROM orders
GROUP BY YEARWEEK(create_time, 1);
3.2 查询最近 7 天和上个月整月的数据
“最近 7 天”这类需求看起来简单,但写法里藏着边界坑。很多人会写 WHERE create_time >= DATE_SUB(NOW(), INTERVAL 7 DAY),这本身没问题,但注意 NOW() 包含时间部分。假设现在是当天下午 14:00,最近 7 天其实是从 7 天前的 14:00 开始,而不是从 7 天前的 00:00 开始。如果业务期望的是“最近 7 个自然日”,应该用 CURDATE() 作为基准:
sql复制-- 最近7个自然日(从7天前零点开始)
WHERE create_time >= DATE_SUB(CURDATE(), INTERVAL 7 DAY)
SQL Server 对应写法:
sql复制WHERE create_time >= DATEADD(day, -7, CAST(GETDATE() AS DATE))
再复杂一点,查“上个月整月”的数据,这个需求写起来要稳,关键是把上个月的起止点算对。MySQL 里有一个经典写法:
sql复制-- 上个月第一天
SELECT DATE_FORMAT(DATE_SUB(CURDATE(), INTERVAL 1 MONTH), '%Y-%m-01');
-- 结果:2024-10-01
-- 上个月最后一天
SELECT LAST_DAY(DATE_SUB(CURDATE(), INTERVAL 1 MONTH));
-- 结果:2024-10-31
实际上不用算最后一天,用半开区间 >= 起 且 < 止 的方式写最稳妥,可以避免月底 23:59:59 的边界误差:
sql复制WHERE create_time >= DATE_FORMAT(DATE_SUB(CURDATE(), INTERVAL 1 MONTH), '%Y-%m-01')
AND create_time < DATE_FORMAT(CURDATE(), '%Y-%m-01')
这个写法的好处是,即使 create_time 字段带时间部分,也能把上个月整月的数据完整覆盖,不会多也不会少。
3.3 字符串日期和乱数据清洗:STR_TO_DATE、TRY_CONVERT 与容错
业务系统的数据来源多样,经常有“字符串形式的日期”直接入库,比如 Excel 导入的 '2024/11/09'、前端传过来的 '20241109'、甚至 '2024-11-9' 这种不规范的格式。在 SQL 里直接用字符串比较日期,往往结果不可控,所以需要先把字符串转成标准日期类型。
MySQL 提供 STR_TO_DATE,可以灵活解析:
sql复制SELECT STR_TO_DATE('2024/11/09', '%Y/%m/%d');
SELECT STR_TO_DATE('20241109', '%Y%m%d');
SQL Server 里,CAST('2024-11-09' AS DATE) 是最直接的,但遇到不规范格式会直接报错。从 SQL Server 2012 开始,TRY_CONVERT 和 TRY_CAST 这两个函数做容错非常有价值:转换失败时返回 NULL,而不是让整条 SQL 报错。
sql复制SELECT TRY_CONVERT(DATE, '2024-11-09', 120);
-- 正常返回 2024-11-09
SELECT TRY_CONVERT(DATE, 'not-a-date', 120);
-- 返回 NULL,不报错
这里务必注意一个隐藏问题:如果原表中本来就存在 NULL 值或空字符串,转出的日期会是 NULL。后续用这些日期做条件过滤时,NULL 参与比较的结果是 UNKNOWN,导致数据被过滤掉。所以清洗时要用 COALESCE 或 WHERE ... IS NOT NULL 把脏数据单独处理,不要把脏数据直接放进统计口径里。还有一种特殊情况是 MySQL 非严格模式下允许字段存在 '0000-00-00' 这类非法日期,如果你在这种表上做日期运算,最好先用 IF 判断:
sql复制SELECT IF(birth_date = '0000-00-00', NULL, birth_date) AS valid_birth
FROM users;
3.4 时间戳换算和时区细节:UNIX 时间戳与 UTC 的取舍
日志系统里很常见的是存 UNIX 时间戳,也就是从 1970-01-01 00:00:00 UTC 到现在的秒数。这类字段在 SQL 里查可读性很差,必须做转换。
MySQL 里:
sql复制-- 当前时间转时间戳
SELECT UNIX_TIMESTAMP(NOW());
-- 时间戳转可读时间
SELECT FROM_UNIXTIME(1700000000);
SQL Server 里没有直接的 FROM_UNIXTIME,但可以用 DATEADD 从纪元日期开始加秒数:
sql复制SELECT DATEADD(second, 1700000000, '1970-01-01 08:00:00');
注意这里如果用 '1970-01-01 00:00:00',返回的是 UTC 时间,要看你的业务需不需要转成北京时间。如果字段存的是 UTC 时间戳,展示时要转成“北京时间”,可以统一加 8 小时。为了避免到处加 8 小时导致代码混乱,我的建议是:数据库统一存 UTC 时间,应用层负责转换为本地时区。如果必须在 SQL 里转,MySQL 有专门的 CONVERT_TZ 函数:
sql复制SELECT CONVERT_TZ(NOW(), '+00:00', '+08:00');
但 CONVERT_TZ 依赖数据库里的时区表,部分云数据库默认没加载时区数据,会直接返回 NULL。遇到这种情况,可以先把时区表加载好,或者用 DATE_ADD(NOW(), INTERVAL 8 HOUR) 这种“硬加”方式代替,前提是业务明确固定为北京时间。
4. 写日期 SQL 最常踩的 5 个坑(含排查思路)
4.1 语法报错:You have an error in your SQL syntax
运行日期相关 SQL 时,最常见的报错就是 MySQL 那句经典的:
text复制You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near ...
报错位置往往就指向日期函数附近,反复看了半天也找不到问题。根据我的经验,这类报错大概率是下面几种原因:
- 把 SQL Server 的函数名写到了 MySQL 里,比如 MySQL 里写了
DATEADD,实际上 MySQL 只有DATE_ADD,多了个下划线,少了个 S。反过来也一样,在 SQL Server 里写DATE_ADD也会报错。 - 字符串日期没有加引号,比如
WHERE create_time > 2024-11-09,数据库会把2024-11-09理解成算术表达式,最容易报语法错误。 - MySQL 的
INTERVAL用错了位置,比如写成INTERVAL 7 DAY + NOW()这种顺序,MySQL 不认。 - 括号不匹配,嵌套函数太多,少了一个右括号,报错位置往往停在语句末尾。
排查办法也很简单:把日期函数单独拿出来跑一条 SELECT,逐步缩小范围。比如 SELECT DATEADD(day, 1, NOW()) 在 MySQL 里跑一下,立刻就能看到函数不存在的提示,比在 200 行业务 SQL 里找要快得多。
4.2 BETWEEN AND 查当天数据会少一条
这是日期查询里最经典的边界坑。假设 create_time 是 DATETIME 类型,你想查整个 1 月份的数据,随手写了:
sql复制WHERE create_time BETWEEN '2024-01-01' AND '2024-01-31'
表面上看没问题,实际上 BETWEEN ... AND ... 是闭区间,等价于 >= '2024-01-01' AND <= '2024-01-31'。字符串 '2024-01-31' 被隐式转换成 '2024-01-31 00:00:00',于是 1 月 31 日零点以后的所有数据全部被排除,整整少了一天的数据。
这个坑很隐蔽,因为大部分情况下数据不是全没,只是少一部分,报表数字对不上时很难想到是这里出了问题。正确做法有两个,要么把结束时间写成当天最后一秒,要么用半开区间:
sql复制-- 写法一:写完整时间
WHERE create_time BETWEEN '2024-01-01 00:00:00' AND '2024-01-31 23:59:59'
-- 写法二:推荐,半开区间
WHERE create_time >= '2024-01-01 00:00:00'
AND create_time < '2024-02-01 00:00:00'
同样的坑在 WHERE DATE(create_time) = '2024-01-15' 这种写法里也会出现,虽然结果没错,但函数包裹列会导致索引失效,后面专门讲。
4.3 DATEDIFF 参数顺序反了,统计结果对不上
我自己就在这个坑里栽过跟头。之前从 MySQL 迁移一个统计口径到 SQL Server,原来写的 DATEDIFF(create_time, NOW()),迁移时按 SQL Server 的语法改成了 DATEDIFF(day, create_time, GETDATE()),然后发现所有和预期相反:本该是正数的差值全变成了负数,仔细排查才发现,MySQL 的 DATEDIFF(date1, date2) 是第一个参数减第二个,SQL Server 是第三个参数减第二个,方向正好相反。
不仅是 DATEDIFF,TIMESTAMPDIFF 的参数顺序也值得注意。MySQL 的 TIMESTAMPDIFF(unit, start, end) 返回的是 end - start,跟 DATEDIFF 刚好相反:
sql复制SELECT DATEDIFF('2024-02-01', '2024-01-30'); -- 2
SELECT TIMESTAMPDIFF(DAY, '2024-01-30', '2024-02-01'); -- 1(注意结果不同)
所以我的建议是:在项目里写日期差值逻辑时,统一在函数上方加一行注释,写明是“结束日期减开始日期”还是“date1 减 date2”,尤其是团队里同时用多款数据库的时候,这个注释能救不少人。排查这类问题也很简单,先跑一条 SELECT DATEDIFF('2024-02-01', '2024-01-30'); 看看结果的正负和数值是否符合预期,再嵌套进业务 SQL,避免被其他逻辑干扰。
4.4 在日期列上套函数,索引直接失效
性能问题往往在小数据量时看不出来,等表数据涨到百万级,一条慢 SQL 能把整个报表拖垮。日期函数导致的索引失效,是我见过最典型的慢 SQL 元凶之一,尤其是下面这种写法:
sql复制-- MySQL:在 create_time 上套了 DATE 函数
WHERE DATE(create_time) = '2024-01-15'
-- SQL Server:同样的问题
WHERE CONVERT(varchar(10), create_time, 120) = '2024-01-15'
这两条 SQL 的语义都没有问题,问题出在数据库执行时,为了让每一行都能计算 DATE(create_time),优化器只能放弃 create_time 上的索引,做全表扫描。数据量上去之后,扫一次全表可能就要几十秒。
正确做法是把条件改写成范围查询,让索引能直接命中:
sql复制WHERE create_time >= '2024-01-15 00:00:00'
AND create_time < '2024-01-16 00:00:00'
这条经验极其重要。数据量小的表上感觉不到区别,一旦上了生产环境,同样的逻辑用两种写法,查询耗时可能从 30 秒降到 0.1 秒。我排查线上慢 SQL 时,第一眼就找 WHERE 条件里有没有对字段套函数,几乎一抓一个准。
4.5 NULL 和 '0000-00-00' 导致统计少算
日期字段里的脏数据,是报表统计“对不上账”的隐藏杀手。最常见的两种情况:一是字段本身允许 NULL,某些行没有值;二是 MySQL 非严格模式下,日期字段被写入 '0000-00-00' 这种非法值。
先看 NULL。SQL 里 NULL 参与任何比较运算结果都是 UNKNOWN,不会进 WHERE 过滤,也不会进 GROUP BY 分组。如果你统计“本月的订单”,用 WHERE order_date >= '2024-11-01',那 order_date 为 NULL 的订单会被过滤掉,这通常是你期望的。但如果你统计“所有订单的创建天数”,用 DATEDIFF(NOW(), create_date),只要 create_date 有一个是 NULL,这一行的结果就是 NULL,不会报错但会悄悄丢失数据,导出的报表里出现空白格。
再看 '0000-00-00'。这种值在非严格模式 MySQL 里是真实存在的。直接对 '0000-00-00' 做 DATE_FORMAT 或 DATEDIFF,在大多数版本的 MySQL 里会直接报错或者返回 NULL,一个非法值能把整条统计 SQL 带崩。处理这个问题的思路是,在清洗阶段就把非法值转成 NULL,或者用条件语句隔离出来。我常用的方式:
sql复制SELECT
CASE WHEN create_date = '0000-00-00' OR create_date IS NULL
THEN '未知'
ELSE DATE_FORMAT(create_date, '%Y-%m-%d')
END AS valid_date
FROM orders;
这里再额外提醒一点:如果你在新建表的时候有权限,尽量把日期字段设置为 NOT NULL DEFAULT '1970-01-01 00:00:00' 或者直接用 TIMESTAMP 并赋予默认值,从源头上减少脏数据的产生。数据清洗的成本永远大于写入时的约束成本,这是我在运维线上数据库时最深刻的体会之一。
最后分享一个我自己的习惯:凡是涉及日期函数的 SQL,我都会先单独写一条 SELECT 把函数结果打出来,比如 SELECT DATEDIFF('2024-02-01', '2024-01-30');、SELECT DATE_FORMAT(NOW(), '%Y-%m'); 先跑一遍,确认结果跟预期一致,再往业务 SQL 里嵌。日期函数的坑大多不在语法本身,而在参数顺序、边界值、隐藏格式和隐式转换上,很多问题靠肉眼很难看出来,但函数单独验证一下就能暴露。做报表统计这行,宁可多花十秒钟做一次函数级验证,不要等到数据对不上再通宵排查。
