数据库取月初写法全解析:主流SQL的优劣与性能避坑指南
2026/9/13 2:41:44 网站建设 项目流程

做报表的人大概都有过这种经历:月底被业务方一个电话叫起来,说“这个月的数据怎么又对不上”。我排查过不少这类问题,最后发现有一大半都出在“月初”这俩字上。SQL 获取月份中的第一天,听起来就是一行代码的事,可真要把当月第一天取对、取稳、取快,里面门道并不少:有人直接拼字符串,有人把字符串当天用,有人用函数包住日期列导致索引失效,还有人因为漏了 23:59:59 之后的数据,报表整整少了一天。

这篇内容我会从 SQL Server、MySQL、Oracle、PostgreSQL 到 SQLite,把主流写法都过一遍,讲清楚每种写法背后的原理、适用版本、性能影响,再结合实际报表场景给出可以照抄的“区间写法”。适合刚接触 SQL 的新手,也适合想把自己的取数逻辑打磨得更严密的开发、数据分析师和 DBA。

1. 需求场景与分析思路

1.1 哪些业务场景天天需要“月初”

“月初”不是一个只在月末才会用到的概念,恰恰相反,它的使用频率比很多人想象得高得多。

最典型的是月度统计报表。无论是统计本月订单金额、本月新增用户数、本月退款笔数,还是做环比、同比,首先都要回答一个问题:“这个月从哪天开始算”。很多报表 SQL 写出来一大串,WHERE 条件里的时间范围却写得模棱两可,最后查出来的数据跟财务对不上,原因就是月初的起点没卡准。

其次是定时任务和批处理。比如每天凌晨跑一次“昨日数据汇总”,或者“本月累计销售进度”,这种任务一般不能写死日期,必须动态算出若干天前的日期,或者当月的第一天。如果写死数字,跨月那天必挂。

再就是对账和数据修正。上游业务系统偶尔会重推数据,需要把某个月的数据先清掉再重新汇总,这个时候同样要动态定位“这个月的开始边界”。所以,“获取月份中的第一天”不是一个孤立的小技巧,而是很多业务 SQL 的地基。

1.2 获取月初的本质:三个子问题

把问题拆开来看,任何一个数据库要获取“月份中的第一天”,其实都绕不开三个子问题:

  1. 从当前日期或给定日期中取出“年份”和“月份”。
  2. 把“日”的部分置为 1。
  3. 确保最终返回的结果是日期类型,而不是字符串或带时间的怪东西。

很多人只盯着第 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 Server:DATEFROMPARTS 优先,老版本用 DATEADD

SQL Server 2012 及以上版本,我最推荐的写法是 DATEFROMPARTS:

SELECT 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 返回 15,1 - 15 = -14,也就是从今天往前推 14 天,正好回到 5 月 1 日。这条写法也好理解,但不如 DATEFROMPARTS 直观,所以我不常用。

还有一个需要避开的写法是 FORMAT:

SELECT FORMAT(GETDATE(), 'yyyy-MM-01');

FORMAT 在 SQL Server 2012 以后确实能用,性能却让人头疼。它在底层会走 CLR,还要处理区域化规则,在一个大查询里用它会明显拖慢速度。如果是取 TOP 几条展示数据,感觉不到,一旦在几百万行的表上做批量转换,差距就出来了。

2.2 MySQL:DATE_FORMAT 简洁但要转类型

MySQL 里最常见的写法是 DATE_FORMAT:

SELECT DATE_FORMAT(CURDATE(), '%Y-%m-01');

但要注意,这个函数返回的是字符串。如果你只需要在界面上显示个“2024-05-01”这样的文本,那当然没问题;可如果要跟 DATETIME 列比较,或者要参与日期加减,建议先转成 DATE:

SELECT 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 Oracle:TRUNC 一步到位

Oracle 的日期处理风格跟 SQL Server、MySQL 都不太一样,它更倾向于“截断”思路。取月初只需要一行:

SELECT TRUNC(SYSDATE, 'MM') FROM DUAL;

TRUNC 函数在这里的意思是“把日期截断到月份”,得到的 DATE 类型就是当月 1 日 00:00:00。这种写法最大的优势是语义清晰:不涉及字符串拼接,不涉及从基准日期的推算,一句“截断到月”就完了。

如果输入不是当前日期,而是一个显式的日期值,记得先转成 DATE:

SELECT 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_TRUNC:

SELECT 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 2012+DATEFROMPARTS(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记得转 type
SQLitedate('now', 'start of month')text简单,但本质是字符串

这张表可以直接存下来当团队工具手册。核心思想都一样:优先用数据库原生的日期函数拿回“日期类型”,不要在 SQL 里搞字符串拼接。

3. 实操案例:从月初到月度报表

3.1 按月统计订单金额:正确区间写法

假设有一张订单表 orders,字段包括 id、order_time(DATETIME)、amount(DECIMAL),现在要统计 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 里可以用递归 CTE:

WITH 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 的经典写法是返回 DATETIME,Oracle 的 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.500,BETWEEN 就把它丢了。为了避免这类边界纠纷,日期范围统一用“左闭右开”是最高效、最稳妥的约定。

4.4 跨年跨月的坑

跨年时最容易犯的错是字符串拼接。比如有人为了取“去年同月”的月初,写了这种 SQL:

CONCAT(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 变成 range,key 变成了 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 里包列函数,不写“小于等于月末”,区间永远是从当月第一天到下月第一天。坚持一段时间,你会发现半夜被业务电话叫醒的次数会少很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询