SQL取月初的多种写法与避坑指南:从数据类型到索引优化

SQL取月初的多种写法与避坑指南:从数据类型到索引优化 我最早被问到“SQL 获取月份中的第一天”时心里想的是把日期里的日改成 1 不就行了后来在给一家做电商报表的客户处理月度数据时同事用字符串拼了个“月初”结果月底跑数把下个月的数据也带了出来排查了半天才发现是隐式转换和半开区间的问题。从那时起我就明白取月初这个操作看起来一句话实际上牵扯到日期类型、时区、索引和业务口径不同数据库写法还不一样。这篇文章就把这类需求从业务场景、主流数据库写法、性能注意事项到实战区间查询完整盘一遍供刚接触日期处理的同学直接参考也让老手能对照检查自己有没有踩坑。1. 月初这个时间点到底在业务里承担什么角色1.1 从报表筛选到数据分片到处都是“月初”很多初学者会觉得“取月初”只是一个函数调用没什么可讲的。但实际项目里它最常见的用途是划定一个时间区间的左边界。比如财务月结时要统计“本月收入”运营看板要展示“本月新增用户”数据仓库做增量抽取时要用“月初分区”甚至订单表按月分表时也需要用月初来判断数据落在哪个表。举一个很典型的例子统计本月订单量。新手往往会写成WHERE MONTH(created_at) MONTH(GETDATE())这在数据量小的时候看不出问题一旦订单表有百万级数据MONTH(created_at)会让索引失效全表扫描几分钟就出来了。更合理的做法是先把“月初”这个时间点算出来再写成范围条件created_at 月初 AND created_at 下月初这样既能利用索引语义也更清楚。所以“月初”不是一个孤立的日期显示问题它本质上是在帮你构造一个稳定、高效、准确的时间区间。理解这一点之后再去记函数才有意义。1.2 “第一天”不等于“1号”“月份中的第一天”听起来就是“1号”但在 SQL 的世界里这个答案会因为字段类型不同而产生偏差。如果你处理的是date类型那“2025-06-01”确实完整表示一天但如果字段是datetime、timestamp那么“2025-06-01 00:00:00”才是完整语义而“2025-06-01 08:30:00”虽然也是1号却不是你想要的月初。这引出一个关键点取月初时不光要把“日”置成1还应该把“时、分、秒、毫秒”归零。不同数据库的截断方式不一样有的函数只保留日期部分有的函数返回字符串有的函数返回带时间部分的 timestamp。后续做比较运算时这些细微差别都可能成为 Bug 的来源。我习惯把“月初”理解成“当月第一天零点”这个瞬间。这是所有日期计算中边界最清晰的锚点因为任何一条合法的业务时间记录要么小于它要么大于等于它不会产生歧义。后面讲区间查询时你会发现这个锚点特别有用。1.3 日期处理的核心思路平移、截断、构造如果只背函数换个数据库又不会了。所以我先把取月初的三种实现思路讲清楚后面再看代码会顺很多平移法先算出今天是当月第几天然后往前平移“天数减1”天。比如今天是6月15日减14天就到了6月1日。截断法直接把时间截到月份粒度。Oracle 的TRUNC、PostgreSQL 的date_trunc、SQL Server 2022 的DATETRUNC都是这个思路。构造法从当前时间拆出年份和月份拼成一个“1号”日期。这种方式最直观也最好读。这三个思路没有绝对优劣要看数据库支持哪些函数更要看团队维护时哪段代码更容易被看懂。后面每个数据库的写法我都会对应到这三种思路上。2. 各数据库取月初写法一次给你备齐2.1 SQL Server三种写法都行但推荐度不一样SQL Server 可能是这类问题里被问得最多的。老版本里最经典的是DATEADD DATEDIFFDECLARE d DATETIME 2025-06-15 14:30:00; -- 思路先把 d 与基准日1900-01-01之间相差的月份数算出来再把基准日加上这么多月 SELECT DATEADD(MONTH, DATEDIFF(MONTH, 0, d), 0) AS month_start;这个写法在 SQL Server 2005 时代就开始流行短小精悍但问题也很明显里面的0依赖“1900-01-01”这个隐藏基准日不熟悉的人根本看不懂。我见过不少新人把0改成2000-01-01结果日期从 2000 年开始算差了很多个月。如果你的 SQL Server 版本是 2012 及以上我更推荐用DATEFROMPARTS构造法SELECT DATEFROMPARTS(YEAR(d), MONTH(d), 1) AS month_start;这段代码是自解释的从当前日期里面取年、取月然后拼成 1 号。没有隐藏规则后续维护的人一眼就能看懂。返回类型是date不带时间做比较时也干净。如果是 SQL Server 2022 或 Azure SQL Database可以直接用DATETRUNCSELECT DATETRUNC(MONTH, d) AS month_start;这是真正的“截断”思路直接把时间部分砍到月初零点。和DATEADD/DATEDIFF相比语义更明确也没有神秘数字。旧项目如果还要兼容 SQL Server 2008/2012我建议统一用DATEFROMPARTS这是目前兼容性和可读性平衡最好的方案。2.2 MySQLDATE_FORMAT 最省事但小心字符串陷阱MySQL 里最简洁的写法是DATE_FORMATSELECT DATE_FORMAT(CURDATE(), %Y-%m-01) AS month_start;这个写法把日期格式化成“2025-06-01”这种字符串直观、好记。但它有一个隐藏问题返回的是字符串不是日期类型。如果你拿它去和DATETIME列做比较MySQL 通常会做隐式转换转换得当倒还好一旦列上建了索引就可能导致索引失效或结果偏差。所以我建议在需要参与运算时明确把它转成日期SELECT CAST(DATE_FORMAT(CURDATE(), %Y-%m-01) AS DATE) AS month_start;如果你不喜欢格式化字符串也可以用日期运算SELECT DATE_ADD(CURDATE(), INTERVAL -DAYOFMONTH(CURDATE()) 1 DAY) AS month_start;这段代码的思路是“平移法”把当前日期往回拨DAYOFMONTH(CURDATE()) - 1天落到当月1号。可读性稍差但好处是返回类型是DATE类型更安全。实际项目中查询单条语句用DATE_FORMAT没问题如果这段逻辑会被大量 SQL 复用我更习惯把它封装成存储函数或直接建日期维表避免每个查询都写一遍。2.3 PostgreSQLdate_trunc 是真正的“截断”PostgreSQL 处理日期时间一直很优雅取月初最推荐的就是date_truncSELECT date_trunc(month, CURRENT_DATE) AS month_start;这个函数会把时间截断到月份结果是一个timestamp比如2025-06-01 00:00:00。如果你只需要日期不想要零点之后的时间部分可以再转一下SELECT date_trunc(month, CURRENT_DATE)::date AS month_start;PostgreSQL 也支持用数学方式算月初但一般不推荐因为date_trunc已经足够清晰SELECT CURRENT_DATE (1 - EXTRACT(DAY FROM CURRENT_DATE)) * INTERVAL 1 day AS month_start;这里有一个容易忽略的点date_trunc返回的是timestamptz还是timestamp取决于你传入的参数。如果你传入的是timestamptz返回的也是带时区的时间戳后续做时区转换时要特别小心。2.4 OracleTRUNC 函数一个词搞定Oracle 的日期处理里TRUNC就是为这种需求设计的SELECT TRUNC(SYSDATE, MM) AS month_start FROM DUAL;SYSDATE返回当前时间TRUNC(date, MM)表示截断到月份结果仍然是DATE类型时间是零点。这个写法在 Oracle 中可以说没有任何坑既直观又稳定。Oracle 中还有LAST_DAY可以拿月末配合起来可以做很多日期运算SELECT TRUNC(SYSDATE, MM) AS month_start, LAST_DAY(SYSDATE) AS month_end FROM DUAL;如果你用的是 Oracle 12c 及以上也可以用TRUNC(SYSDATE, MONTH)效果完全一样只是写法更完整。Oracle 的TRUNC还能截到年、季度、周属于一套函数覆盖多种场景值得多花点时间熟悉。2.5 SQLitestrftime 直接拼字符串SQLite 里没有date_trunc也没有DATEFROMPARTS最常用的方法是strftimeSELECT strftime(%Y-%m-01, now) AS month_start;这段代码把当前时间格式化成“年-月-01”的字符串。SQLite 的时间函数本就以字符串处理为核心所以返回文本并不意外。如果你需要的是日期类型可以用date()包一层SELECT date(strftime(%Y-%m-01, now)) AS month_start;或者更直接SELECT date(now, start of month) AS month_start;start of month是 SQLite 的修饰符会自动回到当月1号零点返回也是日期文本。这个写法是 SQLite 里专门做月初的惯用法比strftime更好记也更贴近“一个月开始”的语义。2.6 其他引擎简要参考如果你的日常环境不是上面这几个可以参考这个表引擎写法返回类型Hive / SparkSQLTRUNC(CURRENT_DATE, MONTH)datePresto / Trinodate_trunc(month, CURRENT_DATE)timestampClickHousetoStartOfMonth(NOW())datetimeBigQueryDATE_TRUNC(CURRENT_DATE, MONTH)dateSnowflakeDATE_TRUNC(MONTH, CURRENT_DATE)timestamp_ntz这些函数的思路基本都能对上“截断法”或“构造法”你只要知道了底层原理换引擎也就是换函数名的事。3. 类型、时区、索引取月初翻车的三个重灾区3.1 返回类型不统一比较时就容易出幺蛾子不同数据库的“月初”函数返回类型差异很大SQL Server 的DATEFROMPARTS返回datePostgreSQL 的date_trunc返回timestampMySQL 的DATE_FORMAT返回字符串Oracle 的TRUNC返回DATE。这些类型差异在你单独查询时看不出来一旦放到WHERE条件里和业务时间字段比较就开始出问题。举个例子MySQL 里如果时间列是DATETIME而你写WHERE created_at DATE_FORMAT(CURDATE(), %Y-%m-01)MySQL 可能把字符串常量转成日期也可能把表的列转成字符串具体取决于 collation 和上下文。结果就是明明只是取一条区间数据执行计划却走了全表扫描。我在慢查询日志里见过不止一次这种案例。所以我的原则是在条件里出现“月初”时尽量把它显式转成和业务字段一致的类型。要么用CAST要么选择本来就返回日期类型的写法。宁可多写几个字符也不要把类型转换交给数据库猜。3.2 时区让你以为的“月初”不是数据库里的“月初”大部分业务系统的时间字段喜欢存 UTC但报表看板显示的是“北京时间”或“美国东部时间”。这时如果直接在 UTC 时间上取月初结果会和你肉眼看到的“本月”完全对不上。比如现在是北京时间 6 月 1 日凌晨 1 点UTC 时间还是 5 月 31 日下午 5 点。如果直接按 UTC 截取月初得到的是 5 月 1 日而业务上要的是 6 月 1 日。PostgreSQL 的正确做法是先把时间转到业务时区再截断SELECT date_trunc(month, now() AT TIME ZONE Asia/Shanghai) AS month_start;SQL Server 2016 及以上可以用AT TIME ZONESELECT DATETRUNC(MONTH, SYSDATETIMEOFFSET() AT TIME ZONE China Standard Time) AS month_start;这里的重点是时区转换必须发生在取月初之前。你先明确“谁的一月”再去截断才不会把跨时区记录分到错误的月份里。3.3 写错 where索引直接失效取月初最常见的性能错误就是把它用在了“列的运算”上而不是“常量的边界”上。看这两段查询-- 错误示范对列做了函数运算索引基本失效 WHERE MONTH(created_at) MONTH(GETDATE()) AND YEAR(created_at) YEAR(GETDATE())-- 正确示范列只和常量比较索引能正常使用 WHERE created_at DATEFROMPARTS(YEAR(GETDATE()), MONTH(GETDATE()), 1) AND created_at DATEADD(MONTH, 1, DATEFROMPARTS(YEAR(GETDATE()), MONTH(GETDATE()), 1))第二段写法的核心在于created_at没有被任何函数包裹数据库可以直接用 B-tree 索引去定位范围。也就是说我们要把“取月初”的计算全部放在条件右侧当成一个确定的边界值来用。如果你在 MySQL 里用EXPLAIN看执行计划正确写法通常能看到typerange或ref错误写法多半是typeALL。这就是最直接的证据。3.4 字符串拼月初的隐式转换风险很多项目里有人图省事直接把“月初”拼成字符串-- 非常不建议 WHERE date_col CONCAT(YEAR(CURDATE()), -, MONTH(CURDATE()), -01)这个写法有一个容易被忽视的问题MONTH(CURDATE())在 1 到 9 月时不会补前导零拼出来是2025-6-1。虽然 MySQL 很多时候能识别但到了排序、比较、日志记录等场景字符串的字典序和日期序会不一致。比如2025-10-01排在2025-6-1前面这在字符串排序里是正常的但会让人怀疑数据错了。所以我强烈建议只要是想拿来参与日期运算的月初值一律用数据库提供的日期类型函数构造不要用字符串手工拼。字符串只适合给前端展示不适合塞进 SQL 条件。4. 从“取月初”到“区间查询”报表里的实战套路4.1 用半开区间替代 BETWEEN取月初本身不是目的目的是查询某一整月的数据。很多开发者会这样写WHERE created_at BETWEEN 2025-06-01 AND 2025-06-30这个写法看似没错但对DATETIME字段极不友好。因为 6 月 30 日 23:59:59.5 是存在的而BETWEEN的右边界是2025-06-30 00:00:00所以你会漏掉当天几乎一整天带时间的数据。正确的做法是用半开区间WHERE created_at month_start AND created_at DATEADD(MONTH, 1, month_start)其中month_start就是当月第一天零点DATEADD(MONTH, 1, month_start)是下个月第一天零点。这个区间的数学含义是[月初, 下月初)。它天然覆盖了整月所有瞬间不会漏也不会包含下月数据。如果你嫌和麻烦也可以用BETWEEN month_start AND EOMONTH(month_start)但有个前提你的字段是date类型没有时间部分。否则请坚持半开区间。4.2 按月份分组如何把没有数据的月份也补出来做月度趋势图时最常见的问题就是某个月没有订单结果查询结果里直接少了这个月。先看基础写法SELECT DATEADD(MONTH, DATEDIFF(MONTH, 0, created_at), 0) AS month_start, COUNT(*) AS order_cnt FROM orders WHERE created_at 2024-01-01 AND created_at 2025-01-01 GROUP BY DATEADD(MONTH, DATEDIFF(MONTH, 0, created_at), 0) ORDER BY month_start;这个写法能完成基本分组但如果有 2024 年 3 月完全没有订单结果里就没有 3 月这一行。前端画图时横轴会直接断掉。要补出缺失月份可以先用递归 CTE 生成一个“月份序列”再左连接订单数据WITH months AS ( SELECT DATEFROMPARTS(2024, 1, 1) AS month_start UNION ALL SELECT DATEADD(MONTH, 1, month_start) FROM months WHERE month_start DATEFROMPARTS(2024, 12, 1) ) SELECT m.month_start, COUNT(o.id) AS order_cnt FROM months m LEFT JOIN orders o ON o.created_at m.month_start AND o.created_at DATEADD(MONTH, 1, m.month_start) GROUP BY m.month_start ORDER BY m.month_start OPTION (MAXRECURSION 0);这里用到了月初作为“分组键”和“关联条件”逻辑很干净。递归 CTE 默认最多 100 次循环生成 12 个月没问题但如果你要生成 5 年数据记得加OPTION (MAXRECURSION 0)。如果你要在保留订单明细的同时做月累计可以用窗口函数SELECT created_at, order_id, DATEADD(MONTH, DATEDIFF(MONTH, 0, created_at), 0) AS month_start, SUM(amount) OVER (PARTITION BY DATEADD(MONTH, DATEDIFF(MONTH, 0, created_at), 0)) AS month_total FROM orders;窗口函数的好处是不需要GROUP BY每一行都保留原始明细同时附带当月总额做报表明细加总两不误。4.3 月累计、同比环比月初作为分组键做同比环比时经常需要“今年本月”和“去年本月”对比。如果你已经算出了month_start那么“去年本月”也可以很简单SELECT CURRENT_START.month_start, CURRENT_START.order_cnt AS current_cnt, LAST_START.order_cnt AS last_cnt, (CURRENT_START.order_cnt - LAST_START.order_cnt) * 1.0 / LAST_START.order_cnt AS yoy FROM ( SELECT DATEADD(MONTH, DATEDIFF(MONTH, 0, created_at), 0) AS month_start, COUNT(*) AS order_cnt FROM orders WHERE created_at start_date GROUP BY DATEADD(MONTH, DATEDIFF(MONTH, 0, created_at), 0) ) CURRENT_START LEFT JOIN ( SELECT DATEADD(MONTH, DATEDIFF(MONTH, 0, created_at), 0) AS month_start, COUNT(*) AS order_cnt FROM orders WHERE created_at DATEADD(YEAR, -1, start_date) GROUP BY DATEADD(MONTH, DATEDIFF(MONTH, 0, created_at), 0) ) LAST_START ON CURRENT_START.month_start DATEADD(YEAR, 1, LAST_START.month_start);这段查询里月初值既承担了分组键的角色也是两个区间对齐的纽带。如果你在数据库里直接维护一张日期维表上面这些DATEDIFF/DATEADD的写法都可以被一张dim_date表替代字段直接写明year_month、month_start_date查询会清爽很多。4.4 存储过程与报表工具的默认月初在存储过程里把月初作为默认参数是很常见的需求。比如月末跑批时希望不传参就默认跑本月CREATE PROCEDURE usp_MonthlyReport StartDate DATE NULL, EndDate DATE NULL AS BEGIN SET StartDate ISNULL(StartDate, DATEFROMPARTS(YEAR(GETDATE()), MONTH(GETDATE()), 1)); SET EndDate ISNULL(EndDate, DATEADD(MONTH, 1, StartDate)); SELECT ... FROM orders WHERE created_at StartDate AND created_at EndDate; END;这样既能显示指定月份也能直接默认跑当月。报表工具里也可以把月初作为默认日期参数免去用户每次手动选 1 号的麻烦。这个模式几乎适用于所有报表系统。5. 踩坑实录这些“月初”问题我挨个遇到过5.1 月初带时间直接等于“1号”丢数据有一次我在做数据修复写的条件是WHERE date_col 2025-06-01。本来以为date_col是date类型结果查出来只有部分记录。后来一查表结构发现这个字段是datetime大多数记录都是2025-06-01 08:12:33根本不可能等于“零点那一瞬间”。这就是月初最常见的误用拿“月份第一天”当成“那一天”。正确做法永远是范围条件WHERE date_col 2025-06-01 AND date_col 2025-07-01如果你必须写成等值条件也要确保字段类型是date或者先把 datetime 统一转成 date。但转成 date 又会回到函数运算的问题所以还是推荐范围查询。5.2 财年的第一天不是自然月的第一天大多数系统默认“月”是自然月但财务系统里经常有“财年”。比如某些公司财年从 4 月 1 日开始3 月 31 日结束。这时候“月份中的第一天”仍然可以用自然月函数算但“财年第一季度”就不能粗暴地用DATEFROMPARTS(YEAR(...,1,1)了。我处理过的项目中最稳妥的方案是在日期维表里加一个fiscal_year_month字段业务上要财年统计时直接GROUP BY fiscal_year_month。绝不要在查询里临时推算财年月初因为边界条件太多极容易算错。5.3 “工作日第一天”不等于月份第一天还有一次需求写的是“每月的第一个工作日”结果开发直接取自然月 1 号等到 6 月 1 日是周六时数据全错了。自然月第一天的函数并不能自动跳过周末和节假日。如果你要的是“第一个工作日”需要结合日历表或CASE判断-- 以 SQL Server 为例粗略判断周一至周五 SELECT month_start, CASE WHEN DATEPART(WEEKDAY, month_start) IN (1, 7) THEN DATEADD(DAY, (8 - DATEPART(WEEKDAY, month_start)) % 5 1, month_start) ELSE month_start END AS first_workday FROM ...但这个写法很脆弱法定节假日还得额外处理。所以我建议凡是涉及“工作日”“交易日”等业务日历一律建日历表不要尝试用日期函数硬算。5.4 从DATEDIFF(MONTH, 0, d)理解“基准日0”在 SQL Server 经典写法里DATEDIFF(MONTH, 0, d)中的0会让很多人摸不着头脑。其实它代表1900-01-01。DATEDIFF算出从 1900-01-01 到当前日期经过了多少个月然后DATEADD再加回去得到的自然就是月初。这个写法很巧妙但也很容易让维护者产生误解。尤其当系统里有一个真正的月份偏移参数时新人很容易把这个0当成“不偏移”从而导致逻辑错误。相比之下DATEFROMPARTS把“年、月、日”三个部分显式写出来更适合团队协作。5.5 日期维表是绕开所有函数坑的终极方案如果你发现自己不停地在各个查询里写取月初、判断月末、算工作日那么最值得做的投资是建一张日期维表。字段可以包括date_key主键日期full_date完整日期year、quarter、month、daymonth_start_date当月第一天month_end_date当月最后一天fiscal_year_month财年月is_workday是否工作日然后在业务查询里直接SELECT ... FROM orders o JOIN dim_date d ON CAST(o.created_at AS DATE) d.full_date WHERE d.month_start_date 2025-06-01这样不仅不用记一大堆日期函数还能统一口径。特别是团队里有人用 SQL Server、有人用 MySQL、有人用 PostgreSQL日期维表能让跨数据库的报表逻辑保持一致。我自己维护的数据仓库项目里日期维表是上线第一天就建好的基础设施。在我接触过的团队里凡是能把“取月初”这个细节讲清楚的人写出来的统计 SQL 通常都不会太差。日期处理看着基础但它决定了报表数据的正确性、查询性能和维护成本。希望这些经验和踩坑记录能让你下次写月初逻辑时少走一些弯路。