ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

数据库取月初写法全解析:主流SQL的优劣与性能避坑指南

数据库取月初写法全解析:主流SQL的优劣与性能避坑指南 做报表的人大概都有过这种经历月底被业务方一个电话叫起来说“这个月的数据怎么又对不上”。我排查过不少这类问题最后发现有一大半都出在“月初”这俩字上。SQL 获取月份中的第一天听起来就是一行代码的事可真要把当月第一天取对、取稳、取快里面门道并不少有人直接拼字符串有人把字符串当天用有人用函数包住日期列导致索引失效还有人因为漏了 23:59:59 之后的数据报表整整少了一天。这篇内容我会从 SQL Server、MySQL、Oracle、PostgreSQL 到 SQLite把主流写法都过一遍讲清楚每种写法背后的原理、适用版本、性能影响再结合实际报表场景给出可以照抄的“区间写法”。适合刚接触 SQL 的新手也适合想把自己的取数逻辑打磨得更严密的开发、数据分析师和 DBA。1. 需求场景与分析思路1.1 哪些业务场景天天需要“月初”“月初”不是一个只在月末才会用到的概念恰恰相反它的使用频率比很多人想象得高得多。最典型的是月度统计报表。无论是统计本月订单金额、本月新增用户数、本月退款笔数还是做环比、同比首先都要回答一个问题“这个月从哪天开始算”。很多报表 SQL 写出来一大串WHERE 条件里的时间范围却写得模棱两可最后查出来的数据跟财务对不上原因就是月初的起点没卡准。其次是定时任务和批处理。比如每天凌晨跑一次“昨日数据汇总”或者“本月累计销售进度”这种任务一般不能写死日期必须动态算出若干天前的日期或者当月的第一天。如果写死数字跨月那天必挂。再就是对账和数据修正。上游业务系统偶尔会重推数据需要把某个月的数据先清掉再重新汇总这个时候同样要动态定位“这个月的开始边界”。所以“获取月份中的第一天”不是一个孤立的小技巧而是很多业务 SQL 的地基。1.2 获取月初的本质三个子问题把问题拆开来看任何一个数据库要获取“月份中的第一天”其实都绕不开三个子问题从当前日期或给定日期中取出“年份”和“月份”。把“日”的部分置为 1。确保最终返回的结果是日期类型而不是字符串或带时间的怪东西。很多人只盯着第 2 步想着“把日变成 1 不就行了”结果忽略了第 1 步和第 3 步于是写出了 CONCAT(YEAR(日期), -, MONTH(日期), -01) 这种 SQL。这种写法表面上能跑实际上埋着不少雷返回的是字符串不是日期类型月份小于 10 时拼出来的是“2024-1-1”在不同数据库里的解析规则也不一样后续跟日期列比较时会发生隐式转换轻则慢重则数据对不上。所以正规做法一定是以数据库内置的日期函数为主让它直接返回 DATE 类型。这一点是后面所有方案的前提。1.3 为什么拼字符串不是好方案再展开说说字符串拼接的问题。我见过不少同事在 MySQL 里这样写SELECT CONCAT(YEAR(CURDATE()), -, MONTH(CURDATE()), -01);单看结果2024-05-01好像没毛病。但你要真把它拿去跟表中的 DATETIME 列比较事情就复杂了。MySQL 会把字符串转成日期但转的规则不一定是你想的规则如果月份是 5拼出来是“2024-5-01”跟“2024-05-01”在字符串排序时也不是一回事。更麻烦的是这种写法一旦放进 WHERE 子句很多人还会顺手把表的日期列也格式化一遍比如WHERE DATE_FORMAT(order_time, %Y-%m-%d) CONCAT(...)这一下就把 order_time 上的索引彻底废掉了。所以无论用哪个数据库我都强烈建议把“获取月初”当成一个日期运算问题而不是字符串格式化问题。让数据库原生函数去算出来的类型是日期后面想怎么用都顺手。2. 各大数据库的实现方式与原理剖析2.1 SQL ServerDATEFROMPARTS 优先老版本用 DATEADDSQL Server 2012 及以上版本我最推荐的写法是 DATEFROMPARTSSELECT DATEFROMPARTS(YEAR(GETDATE()), MONTH(GETDATE()), 1);它的逻辑非常直白把当前日期的年份取出来月份取出来然后直接构造一个“1 号”的日期。返回值类型是 DATE干净利落没有任何字符串参与后续跟 DATETIME 列比较也不会有隐式转换的问题。但如果是 SQL Server 2008 R2 这种老版本DATEFROMPARTS 还不存在那就用另一套经典写法SELECT DATEADD(MONTH, DATEDIFF(MONTH, 0, GETDATE()), 0);这行的原理很多初学者看不明白我拆开讲一下DATEDIFF(MONTH, 0, GETDATE())中的 0 在 SQL Server 里代表基准日期 1900-01-01。这个函数计算的是“从 1900-01-01 到当前日期之间经历了多少个完整月份”返回一个整数。然后DATEADD(MONTH, 这个整数, 0)又从 1900-01-01 这个基准出发把这个整数个月加回去得到的日期自然就落在当前月份的 1 号而且时间部分是 00:00:00。这个写法看起来绕但它在数据统计里非常经典尤其在老系统里到处可见。我个人的建议是如果代码要维护一定在旁边写注释交代一下“基准 0 代表 1900-01-01”不然后面接手的人真不一定看得懂。另外还有一种思路是“先回到本月 1 号再往前扣掉天数”比如SELECT CONVERT(DATE, DATEADD(DAY, 1 - DAY(GETDATE()), GETDATE()));先把当前日期的“日”取出来比如今天是 5 月 15 日DAY 返回 151 - 15 -14也就是从今天往前推 14 天正好回到 5 月 1 日。这条写法也好理解但不如 DATEFROMPARTS 直观所以我不常用。还有一个需要避开的写法是 FORMATSELECT FORMAT(GETDATE(), yyyy-MM-01);FORMAT 在 SQL Server 2012 以后确实能用性能却让人头疼。它在底层会走 CLR还要处理区域化规则在一个大查询里用它会明显拖慢速度。如果是取 TOP 几条展示数据感觉不到一旦在几百万行的表上做批量转换差距就出来了。2.2 MySQLDATE_FORMAT 简洁但要转类型MySQL 里最常见的写法是 DATE_FORMATSELECT DATE_FORMAT(CURDATE(), %Y-%m-01);但要注意这个函数返回的是字符串。如果你只需要在界面上显示个“2024-05-01”这样的文本那当然没问题可如果要跟 DATETIME 列比较或者要参与日期加减建议先转成 DATESELECT STR_TO_DATE(DATE_FORMAT(CURDATE(), %Y-%m-01), %Y-%m-%d);如果不想绕这一圈MySQL 还有一种纯日期运算的写法SELECT DATE_SUB(CURDATE(), INTERVAL DAYOFMONTH(CURDATE()) - 1 DAY);DAYOFMONTH(CURDATE()) 是今天是这个月的第几天比如 5 月 15 日就是 15减掉 1也就是 14 天然后从今天往前推 14 天自然回到 5 月 1 日。这种写法的好处是完全不产生字符串返回的还是 DATE 类型在旧版本 MySQL 中也能用。顺便提一句如果你用的是 MySQL 8.0还可以结合窗口函数做很多按月分组的高级查询但“取月初”这个动作本身上面几种已经够用了。不要在业务 SQL 里为了“短”而牺牲类型清晰度这是原则问题。2.3 OracleTRUNC 一步到位Oracle 的日期处理风格跟 SQL Server、MySQL 都不太一样它更倾向于“截断”思路。取月初只需要一行SELECT TRUNC(SYSDATE, MM) FROM DUAL;TRUNC 函数在这里的意思是“把日期截断到月份”得到的 DATE 类型就是当月 1 日 00:00:00。这种写法最大的优势是语义清晰不涉及字符串拼接不涉及从基准日期的推算一句“截断到月”就完了。如果输入不是当前日期而是一个显式的日期值记得先转成 DATESELECT TRUNC(TO_DATE(2024-05-15, YYYY-MM-DD), MM) FROM DUAL;这里有一个容易被坑的地方Oracle 的 DATE 类型本身就带时间部分TRUNC(SYSDATE, MM) 会把时间部分一起清成 00:00:00所以拿来当月度分组的下界非常合适。有些从 MySQL 转过来的开发习惯了 DATE 和 DATETIME 分离到了 Oracle 会问“怎么 DATE 还带时间”这一点要先适应。2.4 PostgreSQL 与 SQLite 的简洁实现PostgreSQL 里最标准的写法是 DATE_TRUNCSELECT DATE_TRUNC(month, CURRENT_DATE)::date;DATE_TRUNC 返回的是一个 timestamp/timestamptz 类型所以后面接::date转成纯日期。这一步很关键别省。如果直接拿 DATE_TRUNC 的结果跟纯日期列比较PostgreSQL 的隐式转换大概率不会帮你做或者会转换得让人意外。SQLite 的写法是最省心的SELECT date(now, start of month);SQLite 的 date() 函数本身就支持修饰符start of month这个修饰符会自动把日期截断到月初返回值是 TEXT 类型。考虑到 SQLite 里日期本身多半就是字符串存储这个副作用基本可以接受。2.5 横向对比速查表数据库推荐写法返回类型注意事项SQL Server 2012DATEFROMPARTS(YEAR(GETDATE()), MONTH(GETDATE()), 1)date语义清晰首推SQL Server 2008R2DATEADD(MONTH, DATEDIFF(MONTH, 0, GETDATE()), 0)datetime经典写法务必注释MySQLSTR_TO_DATE(DATE_FORMAT(CURDATE(),%Y-%m-01),%Y-%m-%d)date别拿裸 DATE_FORMAT 当日期用OracleTRUNC(SYSDATE, MM)date时间部分自动清零PostgreSQLDATE_TRUNC(month, CURRENT_DATE)::datedate记得转 typeSQLitedate(now, start of month)text简单但本质是字符串这张表可以直接存下来当团队工具手册。核心思想都一样优先用数据库原生的日期函数拿回“日期类型”不要在 SQL 里搞字符串拼接。3. 实操案例从月初到月度报表3.1 按月统计订单金额正确区间写法假设有一张订单表 orders字段包括 id、order_timeDATETIME、amountDECIMAL现在要统计 2024 年 5 月的订单总金额。很多新手第一反应是SELECT SUM(amount) FROM orders WHERE DATE_FORMAT(order_time, %Y-%m) 2024-05;如果表很小可能跑得出来一旦数据量到百万级、千万级这个查询就会非常慢。问题出在DATE_FORMAT(order_time, %Y-%m)把 order_time 这个列包进了函数里MySQL 无法直接使用 order_time 上的索引只能一行一行全表扫。正确的做法是用“半开区间”SELECT SUM(amount) FROM orders WHERE order_time 2024-05-01 AND order_time 2024-06-01;这个写法有几个好处order_time 上的索引可以被有效使用优化器知道这是一个范围查询。不会漏掉 5 月 31 日 23:59:59 之后的数据因为条件卡到 6 月 1 日 0 点之前。语义一目了然大于等于月初小于下月初。我特别要强调一下为什么不用“小于等于月末”。如果你写order_time 2024-05-31那 5 月 31 日 23:59:59.500 的数据就会被排除掉。不同数据库的日期精度还不一样SQL Server 的 DATETIME 只能精确到约 3.33 毫秒DATETIME2 能精确到 100 纳秒你永远不知道用户会在哪个微妙的时间点下单。所以查询日期范围只要涉及月末一律用“下月 1 日作为开区间上界”这是不会错的行业惯例。3.2 动态生成当月和下月月初报表不可能永远写死“2024-05-01”更多时候要跟着系统时间走。以 SQL Server 为例动态生成本月月初和下月月初可以这样写DECLARE month_start DATETIME DATEFROMPARTS(YEAR(GETDATE()), MONTH(GETDATE()), 1); DECLARE next_month_start DATETIME DATEADD(MONTH, 1, month_start); SELECT SUM(amount) FROM orders WHERE order_time month_start AND order_time next_month_start;MySQL 的等价写法SET month_start STR_TO_DATE(DATE_FORMAT(CURDATE(), %Y-%m-01), %Y-%m-%d); SELECT SUM(amount) FROM orders WHERE order_time month_start AND order_time month_start INTERVAL 1 MONTH;这样的 SQL 无论在哪天跑都能自动框住“当前自然月”不用频繁改代码。特别是每天凌晨的定时任务跨月当天不用人肉干预很省心。3.3 接收任意日期的参数化写法有些报表页面会给用户一个日期选择器用户选哪天就统计那一天的所在月份。这种情况需要把写死的 GETDATE() 或 CURDATE() 换成入参。SQL Server 中典型的参数化写法DECLARE input_date DATE 2024-05-15; -- 应用层传入 DECLARE month_start DATE DATEFROMPARTS(YEAR(input_date), MONTH(input_date), 1); DECLARE next_month_start DATE DATEADD(MONTH, 1, month_start); SELECT ... FROM orders WHERE order_time month_start AND order_time next_month_start;Oracle 中如果是从 Java 传入一个 java.util.Date通常 SQL 里写SELECT ... FROM orders WHERE order_time TRUNC(:input_date, MM) AND order_time ADD_MONTHS(TRUNC(:input_date, MM), 1);这种写法把“月初”和“下月月初”都交给数据库算应用层只需要把用户选的那个日期传进去就行。无论用户选的是 5 月 1 日还是 5 月 31 日结果都是整个 5 月的数据。3.4 生成连续月份序列报表还有一个常见需求统计过去 12 个月每个月的订单金额但某几个月可能没有订单如果只按订单表 GROUP BY那些空档月份会直接不显示。这时候就要先生成连续的月初序列。SQL Server 里可以用递归 CTEWITH months AS ( SELECT DATEFROMPARTS(YEAR(GETDATE()), 1, 1) AS month_start UNION ALL SELECT DATEADD(MONTH, 1, month_start) FROM months WHERE month_start GETDATE() ) SELECT month_start FROM months;这条语句从今年 1 月 1 日开始一直递归到当前月。拿到月份序列之后再跟订单表做 LEFT JOIN空月份就能补成 0。PostgreSQL 用 generate_series 更简单SELECT generate_series( date_trunc(month, CURRENT_DATE - INTERVAL 11 months), date_trunc(month, CURRENT_DATE), interval 1 month )::date AS month_start;这类连续月份的生成本质上就是把“月初”当成分组键。只要你前面能把某一天的月初算对后面这些扩展需求都能顺利展开。3.5 索引与性能为什么函数套列会慢在这一节我想再展开一下 SARG 的概念。SARG 全称是 Search ARGument翻译成“搜索参数”指 SQL 条件能否利用索引快速定位。判断标准很简单WHERE 子句里列是否独立出现在比较运算符的一侧且没有被函数包裹。能使用索引的写法order_time 2024-05-01 AND order_time 2024-06-01列没被包裹。不能使用索引的写法DATE_FORMAT(order_time, %Y-%m) 2024-05列被函数包住了。很多慢查询问题的根源就是这个。如果你在 EXPLAIN 里看到 type 是 ALL或者 rows 预估行数接近全表先检查 WHERE 条件里的列是不是被某个函数或运算给包住了。尤其是日期时间处理DATE_FORMAT、DATE_TRUNC、YEAR、MONTH 这些函数一旦出现在 WHERE 的列一侧索引基本就报废了。4. 常见问题与踩坑记录4.1 月初带了时间结果不等于“第一天”有时候你明明取到了月初一看结果却是“2024-05-01 00:00:00”觉得多出来一串时间很碍眼。这通常不是错误而是数据库返回了 DATETIME 类型。SQL Server 的经典写法是返回 DATETIMEOracle 的 TRUNC 也是 DATE 类型自带时间部分只有 DATEFROMPARTS 和 MySQL 的 STR_TO_DATE 才是纯 DATE。遇到这种情况要看下游怎么用。如果只是显示格式化一下就行如果要去跟另一个 DATETIME 比较时间部分反而不该去掉因为“2024-05-01 00:00:00”才是标准的区间下界。别为了显示好看把时间清掉结果在处理边界时把自己坑了。4.2 字符串与日期隐式转换导致的错漏我遇到过一种很隐蔽的错法直接把月份字符串当成日期用。比如WHERE order_time 2024-05这行在某些数据库里返回的其实是 2024-05-01看起来好像歪打正着。但换一个数据库或者换一个格式比如“2024-5”结果可能完全不同。SQL 的隐式转换规则没有你想象的那么统一越依赖它越容易在不经意间出错。正确做法是永远写完整的日期字面量2024-05-01或者用数据库提供的函数构造出 DATE 类型。不要信任隐式转换。4.3 月末漏数据的经典场景前面提过的“小于等于月末”问题我再具体化一次-- 错误 WHERE order_time BETWEEN 2024-05-01 AND 2024-05-31; -- 正确 WHERE order_time 2024-05-01 AND order_time 2024-06-01;BETWEEN 是闭区间会包含两端的值。如果 5 月 31 日晚上 11 点 59 分 59 秒有人下单前一条 SQL 可能包含它但要是订单时间精确到 2024-05-31 23:59:59.500BETWEEN 就把它丢了。为了避免这类边界纠纷日期范围统一用“左闭右开”是最高效、最稳妥的约定。4.4 跨年跨月的坑跨年时最容易犯的错是字符串拼接。比如有人为了取“去年同月”的月初写了这种 SQLCONCAT(YEAR(DATE_SUB(CURDATE(), INTERVAL 1 YEAR)), -, MONTH(CURDATE()), -01)如果当前是 2025 年 1 月这个拼接出来的结果是“2024-1-01”字符串长度不一致排序时“2024-10-01”反而排在“2024-1-01”前面。做月度环比时这种数据错位会直接导致图表乱跳。所以日期运算尽量用 DATEADD、DATE_SUB、ADD_MONTHS 这种原生函数让数据库自己去处理跨年和跨月不要在 SQL 里手工拼字符串。4.5 时区导致“日期不对”PostgreSQL 里有一个比较隐蔽的时区问题如果数据库连接使用的时区和服务器时区不一致now()转换成日期时可能被推到前一天或后一天。比如某个订单在 UTC 时间的 5 月 1 日 00:30 创建换算到北京时间是 5 月 1 日 08:30但如果连接时区设置错误日期可能变成 4 月 30 日。我的习惯是在数据库连接串里显式设置时区或者统一用CURRENT_DATE而不是now()转换。对于全球业务最好在应用层先算好目标时区的日期再传参数让数据库只负责按参数过滤不去猜时区。5. 性能检查与书写习惯5.1 慢查询排查EXPLAIN 重点看什么如果月初相关的报表变慢了别急着改 SQL先跑一遍 EXPLAIN。以下面这个常见慢查询为例EXPLAIN SELECT * FROM orders WHERE DATE_FORMAT(order_time, %Y-%m) 2024-05;在 MySQL 里看执行计划重点关注这几列字段重点关注说明type是不是 ALLALL 表示全表扫描大概率没有利用索引key有没有命中索引NULL 说明没用到索引rows预估扫描行数行数越大越慢Extra有没有 Using temporary / filesort有的话表示还额外的排序或临时表如果你把这条 SQL 改成区间写法EXPLAIN SELECT * FROM orders WHERE order_time 2024-05-01 AND order_time 2024-06-01;你会看到 type 从 ALL 变成 rangekey 变成了 order_time 上的索引rows 大幅下降。这就是“函数套列”和“区间条件”在性能上的直观差距。明白了这一点你以后看见任何把日期列包进函数的 SQL都会本能地想把它改掉。5.2 区间条件如何命中索引在 order_time 上建了普通索引再写区间查询数据库会做索引范围扫描。如果表里还有一个维度是用户 ID可能需要组合索引比如 (user_id, order_time)。这时候要注意组合索引的最左前缀原则user_id 要写在前面。如果你只按 order_time 查却建了(user_id, order_time)的组合索引那这个索引对纯时间范围查询帮助不大。对于 SQL Server情况类似。聚集索引和非聚集索引的选择以及统计信息是否过期都会影响最终执行计划。但抛开这些复杂因素有一个通用原则让 WHERE 的过滤条件尽量“简单、直接”不要围绕列做计算。5.3 避开昂贵的表达式FORMAT 一类函数除了破坏索引还有一个问题是开销高。比如 SQL Server 的 FORMAT文档里都建议避免在高频查询里使用因为它会引入 .NET 运行时开销。MySQL 的 DATE_FORMAT 虽然开销没有那么大但一旦放到 WHERE 子句里索引失效的损失远大于函数本身的开销。我见过一些团队为了方便在报表查询里大量使用 FORMAT数据量小时没感觉换到核心业务表以后直接超时。这种问题通常不是单条 SQL 写得多烂而是习惯性使用了“看起来方便、实际上昂贵”的写法。前期不觉得后期优化成本很高。5.4 封装成函数或视图的边界有人喜欢把“获取月初”做成自定义函数团队统一调用这是好习惯。但要注意使用场景如果函数是在“应用层算好一个值然后作为参数传给 SQL”那没问题。如果函数是在 WHERE 子句里对每一行的列做转换那很可能破坏索引。比如 SQL Server 里写WHERE dbo.GetMonthStart(order_time) 2024-05-01这就是典型的“在查询中对列调用自定义函数”索引不会起作用。正确做法是把右边换成区间WHERE order_time 2024-05-01 AND order_time 2024-06-01视图也是类似的道理。视图本身只是个封装解析后最终执行的还是底层 SQL。如果视图里放了函数包裹列的条件查询优化器同样很难处理。结尾几句真实体会我把这些经验总结出来其实都是踩过坑换来的。现在写月度报表我基本不看“取月初的语法是什么”而是先确认三件事日期是不是 DATE 类型、过滤是不是半开区间、日期列有没有被函数包住。只要这三条都符合无论换哪个数据库SQL 大概率又快又准。如果你现在正在写按月的统计 SQL不妨把这段规则发给同样写报表的同事不在 WHERE 里包列函数不写“小于等于月末”区间永远是从当月第一天到下月第一天。坚持一段时间你会发现半夜被业务电话叫醒的次数会少很多。
返回列表