ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

五大数据库获取当月第一天:SQL写法、原理与避坑指南

五大数据库获取当月第一天:SQL写法、原理与避坑指南 做数据库开发的人每个月总有那么几天要和月初较劲。月度报表要对齐日期区间、对账程序要圈定结算周期、归档任务要筛出到期数据——无论哪个场景第一步往往都是拿到这个月的第一天。这个需求看起来是人畜无害的两个字取月初。可真上手写不同数据库语法完全不同而且稍不注意就会掉进隐式转换、索引失效、时区偏移的坑里。这篇内容我把实际项目中用过的方案统一梳理一遍覆盖 SQL Server、MySQL、Oracle、PostgreSQL、SQLite 这五种常见数据库每个方案都会讲清楚背后的原理和适用场景最后再聊几个我总结出来可以少走弯路的实战经验。无论你是在写一次性临时查询还是在做跨数据库迁移这篇文章应该都能直接帮上忙。1. 为什么取月初这事值得单独写一篇1.1 月初日期在业务里到底有多常见先说一个最简单的事实很多业务逻辑的起点就是这个月从哪天开始算。我做过的项目里至少有下面几类需求绕不开月初日期月度销售报表统计当月累计销售额SQL 里必然要写WHERE order_date 当月第一天。订阅或会员周期按月扣费、按到期日续费计算当前计费周期范围时月初是第一道门槛。数据归档和清理定期把超过 N 个月的历史数据挪到归档表通常要先算出 N 个月前的月初作为归档边界。定时任务抽取数据ETL 任务里常常需要按月份增量抽取月初日期就是天然的断点标记。这些需求放到业务系统里可能只是 SQL 里的一个条件片段但如果这个片段写得不对轻则报表数据少一天重则把整张分区的数据都扫错。所以我一直觉得取月初这种基础函数值得每个 SQL 开发者认真对待而不是能跑就行。1.2 看似简单其实藏着一堆边界问题有朋友可能会说取月初不就把日期字符串截断一下吗真没这么简单。首先日期类型在不同数据库里的底层实现不同。比如 SQL Server 的datetime本质是浮点数offset 基准是 1900-01-01Oracle 的DATE类型内部还带时分秒PostgreSQL 的DATE则精确到天。所以同样一个本月1号在这些数据库里有的直接返回2025-05-01有的却带着00:00:00甚至带时区信息。如果没处理好日期范围对比经常出现边界遗漏。其次不同数据库提供的日期函数设计思路截然不同。有的适合向前推比如DATEADD(MONTH, value, date)有的适合截断比如 Oracle 的TRUNC有的适合格式化比如 MySQL 的DATE_FORMAT。如果生搬硬套一种思路去写另一种数据库的 SQL很快会遇到语法不支持或者结果不对的情况。第三个容易踩坑的点是字符串隐式转换。很多新手图省事直接拼一个2025-05-01字符串去和日期列比较。在特定数据库的配置下这么写能跑但换了会话语言、换了日期格式参数就可能直接报错或者返回完全错误的结果。这类问题非常隐蔽上线之后才会爆发。所以这篇文章的核心任务是帮大家在取月初这个小问题上建立一套通用认知知道每种数据库的正解是什么知道每种写法的原理和边界并且有一套排查异常的方法。2. 五大数据库实现月初日期的核心方法2.1 SQL Server四种写法各有利弊SQL Server 的日期函数是出了名的组合拳同一个需求能写出一堆等价 SQL。我整理出四种比较有代表性的按推荐程度从高到低排。方法一使用DATEFROMPARTS重组日期最推荐SQL Server 2012SELECT DATEFROMPARTS(YEAR(GETDATE()), MONTH(GETDATE()), 1) AS month_start;这段 SQL 的原理最直观从当前日期里拿出年、月两个数再拼上日期的固定值1交给DATEFROMPARTS构造出一个日期。代码可读性好几乎没有理解成本而且DATEFROMPARTS三个参数都是整数完全不会出现字符串解析偏差。我在生产环境的报表存储过程里最常用这种写法。方法二基于 1900-01-01 做月份偏移经典写法适合任意版本SELECT DATEADD(MONTH, DATEDIFF(MONTH, 0, GETDATE()), 0) AS month_start;很多老资料里都有这个写法。它的原理是DATEDIFF(MONTH, 0, GETDATE())算出从1900-01-01到当前日期跨越了多少个完整月份再用DATEADD把这些月份加回1900-01-01上。因为1900-01-01本身就是一个月的第一天加完整月份后自然还是某个月的1号所以结果就是当前月的第一天。这种写法优点是不用提取年、月、日性能也稳定缺点是代码看起来晦涩对新手很不友好。而且它返回的是DATETIME类型时分秒部分是00:00:00如果你只需要DATE还得再CAST一下。方法三使用EOMONTH加一天SQL Server 2012SELECT DATEADD(DAY, 1, EOMONTH(GETDATE(), -1)) AS month_start;EOMONTH(GETDATE(), -1)返回上个月的最后一天再加一天自然就到了本月1号。这个思路很符合日常语义先拿到上个月的尾巴再往前走一步。白璧微瑕的是EOMONTH在很多旧版本 SQL Server 里不可用如果你的系统还在维护 SQL Server 2008请绕过它。方法四字符串格式化拼接不推荐仅应急用SELECT CAST(CONVERT(VARCHAR(7), GETDATE(), 120) -01 AS DATE) AS month_start;用CONVERT把日期转成yyyy-MM格式拼接-01后再转回日期。这个写法之所以不推荐是因为它依赖字符串解析而且会产生隐式转换开销。但它也不是一无是处比如在写调试脚本、临时查数据时能省很多代码。生产环境建议还是老老实实交给日期函数。补充一个 SQL Server 2022 的新函数DATETRUNCSELECT DATETRUNC(MONTH, GETDATE()) AS month_start;DATETRUNC的作用就是按指定粒度截断日期一句话就能拿到月初。如果你所在的项目已经升级到现代版本这个写法最干净。不过目前很多企业还停留在 SQL Server 2016/2019用之前先确认版本。2.2 MySQL函数简单但要当心隐式转换MySQL 的日期函数整体上比 SQL Server 更人性化取月初有几种非常顺手的写法。方法一DATE_FORMAT格式化SELECT DATE_FORMAT(CURDATE(), %Y-%m-01) AS month_start;这是最省事的方案直接把当前日期格式化为%Y-%m-01因为日子直接写死成01所以结果就是本月1日。注意DATE_FORMAT返回的是字符串如果你要和日期列比较MySQL 会尝试做隐式转换。在大多数情况下 MySQL 能正确把它转成日期但为了稳妥建议外面再套一层DATE()或CAST()SELECT CAST(DATE_FORMAT(CURDATE(), %Y-%m-01) AS DATE) AS month_start;方法二LAST_DAY配合日期加减SELECT DATE_ADD(LAST_DAY(CURDATE() - INTERVAL 1 MONTH), INTERVAL 1 DAY) AS month_start;这句话翻译成人话是先退回到上个月再用LAST_DAY拿到上个月最后一天最后再加一天。逻辑清晰、可读性强而且不需要依赖字符串格式化返回类型就是DATE。我在 MySQL 8 项目里经常这么写。方法三日期减法直接归位SELECT DATE_SUB(CURDATE(), INTERVAL DAY(CURDATE()) - 1 DAY) AS month_start;其原理是当前日期减去当天日号减一天的天数比如今天是5月21号减 20 天就回到了5月1号。这也很容易理解而且不需要月、年边界判断在处理任何日期变量时通用性都不错。方法四字符串拼接SELECT CONCAT(YEAR(CURDATE()), -, LPAD(MONTH(CURDATE()), 2, 0), -01) AS month_start;这个方案依赖LPAD补零输出也是字符串类型。它在需要拼报表文件名的特殊场景下还算方便但从取日期的目的出发我不推荐作为首选。2.3 OracleTRUNC 一把梭如果只允许我选一种写法我会选 Oracle 的TRUNC它是所有数据库里最优雅的月初函数没有之一。SELECT TRUNC(SYSDATE, MM) AS month_start FROM DUAL;TRUNC本身是截断函数第二个参数传MM就是按月份粒度截断直接返回本月1日凌晨。它既不用拼接字符串也不用做日期偏移一行搞定返回类型还是原生的DATE边界处理得干干净净。如果出于特殊原因想用字符串方案Oracle 也可以这样写SELECT TO_DATE(TO_CHAR(SYSDATE, YYYY-MM) || -01, YYYY-MM-DD) FROM DUAL;但这个方案需要对 TO_CHAR、TO_DATE 两个函数都很熟练还得提防 NLS 日期格式设置不同导致的解析错误。说实话在 Oracle 里放着TRUNC不用去写字符串拼接属于自己给自己找麻烦。我就见过因为NLS_DATE_FORMAT被改成了DD-MON-YYYY导致按YYYY-MM-DD解析字符串直接报ORA-01843的案例。所以我的原则很明确Oracle 取月初永远优先考虑TRUNC。2.4 PostgreSQLDATE_TRUNC 与 MAKE_DATE 哪个更顺手PostgreSQL 的日期处理同样很规范一般有两种推荐思路。方法一DATE_TRUNC截断SELECT DATE_TRUNC(month, CURRENT_DATE)::date AS month_start;这和 Oracle 的TRUNC思路一致区别是 PostgreSQL 要求传粒度字符串month返回的类型默认带时间精度timestamptz或timestamp。如果只需要日期用::date转一下类型。这也是我日常写 PostgreSQL 的首选方案。方法二MAKE_DATE构造日期SELECT MAKE_DATE(EXTRACT(YEAR FROM CURRENT_DATE)::int, EXTRACT(MONTH FROM CURRENT_DATE)::int, 1) AS month_start;MAKE_DATE需要年、月、日三个整数参数所以要先用EXTRACT把当前日期的年、月提取出来各自转成整数然后直接构造出月初。逻辑很直白和 SQL Server 的DATEFROMPARTS玩的是一个套路。如果只想用最朴素的日期表达式也可以用上个月末1天的写法SELECT DATE_TRUNC(month, CURRENT_DATE) INTERVAL 1 month - INTERVAL 1 day;等等这是月末的写法。取月初更简单的日期算术是SELECT (DATE_TRUNC(month, CURRENT_DATE))::date;这就回到方法一了。PostgreSQL 的好处是函数稳定、类型管控严格不需要担心隐式转换问题。只要记得DATE_TRUNC结果如果进报表查询一定要根据列类型显式转成DATE或保留TIMESTAMP避免类型不匹配影响比对。2.5 SQLite轻量场景也有标准解SQLite 虽然轻量但日期函数的思路很另类它把所有日期统一当字符串处理用strftime控制输出格式。SELECT DATE(now, start of month) AS month_start;DATE函数配合修饰符start of month直接返回本月初日期返回格式是YYYY-MM-DD字符串。这个写法简洁到让人想拍桌子也是 SQLite 社区公认的推荐解法。如果需要在特定日期上取月初可以这样写SELECT DATE(2025-05-21, start of month) AS month_start; -- 返回 2025-05-01另外也可以用strftime自定义格式SELECT strftime(%Y-%m-%d, now, start of month) AS month_start;SQLite 的坑在于日期都是文本没有真正的日期类型所以在排序、比较时需要额外注意字符串格式必须统一为YYYY-MM-DD。如果你在表里存的是2025/05/21或者21-05-2025那取月初的 SQL 再正确也会被杂乱的格式数据坑到。3. 取月初值的落地场景与性能观察3.1 月度报表的日期区间怎么拼才不浪费索引写代码不能只满足于结果对。结果对但性能崩的 SQL 我也见过不少。最典型的例子报表需求要查2025年5月1日到5月31日的数据新手很容易写成WHERE order_date BETWEEN 2025-05-01 AND 2025-05-31这个写法存在两个隐患。第一如果order_date是DATETIME类型BETWEEN 2025-05-01 AND 2025-05-31会把 5月31号当天所有记录都包含进来这个没问题但如果你的业务数据里有未来时间或者当天被多个时区数据污染边界就会不可控。第二如果表里的时间列带时分秒那么用BETWEEN 2025-05-01 AND 2025-05-31 23:59:59这种写法又会出现 23:59:59.x 被漏掉的问题。更稳的做法是用左闭右开区间也就是大于等于月初、小于下个月的月初WITH month_range AS ( SELECT DATEFROMPARTS(YEAR(GETDATE()), MONTH(GETDATE()), 1) AS month_start, DATEADD(MONTH, 1, DATEFROMPARTS(YEAR(GETDATE()), MONTH(GETDATE()), 1)) AS next_month_start ) SELECT ... FROM orders WHERE order_date (SELECT month_start FROM month_range) AND order_date (SELECT next_month_start FROM month_range);这样写的好处很明显不管order_date是DATE还是DATETIME都不会出现边界遗漏而且对order_date上的索引非常友好可以直接走 range scan不会因为函数套了一层导致索引失效。同理在 MySQL 里可以这样拼WHERE order_date DATE_FORMAT(CURDATE(), %Y-%m-01) AND order_date DATE_FORMAT(CURDATE() INTERVAL 1 MONTH, %Y-%m-01);Oracle 则可以写成WHERE order_date TRUNC(SYSDATE, MM) AND order_date ADD_MONTHS(TRUNC(SYSDATE, MM), 1);左闭右开区间是处理日期范围的最佳实践这比猜到月末哪一天要稳妥得多。加上我们前面写的月初函数这个过渡非常自然先精确拿到月初再计算出下月月初作为区间上界所有边界问题都消解了。3.2 动态日期变量当月初不再来自当前日期实际系统里很多报表并不是取当前月份而是由用户在前端选择一个业务月份。这时候关键点就变成了如何把这句取月初写成一个通用的、接受任意日期参数的表达式。在 SQL Server 里我常封装成一个标量函数CREATE FUNCTION dbo.GetMonthStart (InputDate DATE) RETURNS DATE AS BEGIN RETURN DATEFROMPARTS(YEAR(InputDate), MONTH(InputDate), 1); END; GO调用的时候就很惬意SELECT dbo.GetMonthStart(2024-12-15); -- 返回 2024-12-01MySQL 则可以直接用DATE_FORMAT(input_date, %Y-%m-01)如果你想保留DATE类型外面套CAST就行SET input_date 2024-12-15; SELECT CAST(DATE_FORMAT(input_date, %Y-%m-01) AS DATE) AS month_start;Oracle 更简单SELECT TRUNC(TO_DATE(2024-12-15, YYYY-MM-DD), MM) FROM DUAL;PostgreSQL 则依然推荐DATE_TRUNCSELECT DATE_TRUNC(month, DATE 2024-12-15)::date;动态日期还有个常见需求用户选了2025-02这种年月组合想让系统自动拼出月初。这种场景我最推荐的是先拼接再用日期函数解析而不是自己手动判断闰年、大小月。比如 SQL Server 里可以写成SELECT DATEFROMPARTS(2025, 2, 1) AS month_start;传2025和2进去其他交给数据库判断合法性。这样写既安全又简单。3.3 大数据量下不同写法对性能的影响关于性能我先说一个很多人都忽略的事实在取月初这层逻辑上不同写法的性能差异其实微乎其微真正的性能杀手在于你是否对日期列做了函数处理。举个例子SQL Server 里有两种写法-- 写法A对日期列用函数 SELECT * FROM orders WHERE DATEPART(YEAR, order_date) 2025 AND DATEPART(MONTH, order_date) 5; -- 写法B对所有可能的日期区间做范围查询 SELECT * FROM orders WHERE order_date 2025-05-01 AND order_date 2025-06-01;写法A虽然也能算出目标月份的数据但只要在索引列上套了DATEPARTSQL Server 的优化器大概率不会走索引只能全表扫描。写法B把条件全部转换为范围比较索引就能正常利用。所以凡是涉及日期列的条件你都不应该试图用各种函数去处理这一列而是应该先把目标范围算成一个确定值。切入点正好就是月初日期先算出月初始再算出下月月初直接拿这两个值去过滤。这是我在所有数据库里都遵守的规则。数据量小的时候这两种写法看起来没什么区别但一旦表数据量过亿日期区间不命中索引的代价就是分钟级和秒级的差别。3.4 报表缓存与月初计算时机还有一个小细节值得注意并非所有场景都需要在查询时动态计算月初。如果报表每天凌晨跑一次而且结果可以被缓存那我更推荐在调度脚本里把月初日期算好然后作为参数传给 SQL。比如一访来自 Python、Java 或 Shell 脚本的值传入 SQL这样 SQL 本身会变得更简单也方便后续排查问题。我自己踩过的一个真实案例有一张月度汇总报表每天都在手机端展示本月累计数据本来用 SQL 的动态函数计算月初完全没问题。后来某一天 DBA 调整了数据库服务器的系统时区结果GETDATE()返回的日期产生偏移报表里的当天数据突然少了一部分。后面改成在应用层把日期边界算好再交给 SQL就把这个不稳定因素隔离掉了。这个教训让我更深刻地理解数据库的函数虽好但并不是越快执行越好站在整个系统边界上想问题会更稳。4. 常见问题与排查技巧实录4.1 字符串和日期类型混用导致的脏数据这是整个取月初需求里最常见的一类坑。很多人在 MySQL 里写WHERE create_date DATE_FORMAT(CURDATE(), %Y-%m-01)这里的DATE_FORMAT产生的是字符串而create_date是日期类型。MySQL 通常在比较时会把字符串转成日期做隐式转换看起来没问题可一旦数据库的sql_mode或字符集设置不同某些 MySQL 版本可能在比较时先尝试把日期列转成字符串导致索引失效。更可怕的是如果表里的create_date是VARCHAR类型存的格式却不统一比如2025/05/01混进来那这个隐式转换的结果就是完全不匹配报表整整一个月空数据。排查这类问题的方法很直接先确认列的真实数据类型SHOW CREATE TABLE table_name;。再确认函数返回的类型比如 MySQL 里DATE_FORMAT返回字符串CAST(... AS DATE)返回日期。有条件的话统一用范围查询代替等值查询并且保证边界值类型和列类型一致。4.2 时区问题为什么月初日期偶尔会偏移时区问题在传统数据库里不明显但一旦你用了云数据库或开启了数据库实例的时区设置就要留个心眼。比如 MySQL 里CURDATE()返回的是数据库会话时区下的当前日期而记录数据时的时间很可能来自应用服务器时区或者 UTC。两边时区不一致时月初的边界会变得很微妙。典型现象是明明本地已经到5月1号早上但数据库里CURDATE()还是4月30号导致取本月第一天的结果也跟着后退了一天。排查思路是看三处的时区应用服务器时区。数据库会话时区MySQL 可以用SELECT session.time_zone;查看。数据写入时的时区来源。我在项目里解决时区问题的标准做法是业务时间全部用 UTC 存储展示层再按用户时区转换凡是做月度统计必须在 SQL 里显式指定时区或传入已经算好的日期区间不能依赖数据库会话的默认设置。这不是一个纯粹 SQL 语法的问题但在实际生产里比语法错更让人头疼。4.3 闰年和月末带来的边界陷阱取月初本身不用考虑闰年因为任何月份的第1天都真实存在。但取月末或求下个月初的时候闰年问题就被放大了。比如 SQL Server 里用EOMONTH求2024年2月的最后一天SELECT EOMONTH(2024-02-01); -- 返回 2024-02-29但如果用DATEADD(MONTH, 1, 2024-02-01)返回的是2024-03-01再减去一天能得到 2月29号。这套逻辑在非闰年也能正确返回 28号所以在月初1个月-1天这个套路里数据库引擎已经帮你处理了闰年规则。真正容易出错的场景是求下月月初时手滑写成了当前日期加31天。比如 2月13号加31天是3月16号完全不是下个月初。这种错误在新手代码里非常常见本质上是因为他们没有把问题拆解成月份计算和纯日期偏移两个层面。牢记一个原则月份级别的偏移一定要用月份函数DATEADD(MONTH, ...)、ADD_MONTHS(...)、DATE_TRUNC(month, ...) INTERVAL 1 month不要想着用固定的天数去替换。4.4 函数结果类型不一致导致的多数据库迁移问题如果你负责把一个系统的数据库从 SQL Server 迁移到 MySQL 或 PostgreSQL日期函数是重灾区。原因是每种数据库的日期类型精度、默认格式、函数名完全不同。我列了一张我在迁移项目里常用的对照表方便大家查阅目标SQL ServerMySQLOraclePostgreSQLSQLite当前日期GETDATE()CURDATE()SYSDATECURRENT_DATEDATE(now)本月第一天推荐DATEFROMPARTS(YEAR(...), MONTH(...), 1)DATE_FORMAT(CURDATE(), %Y-%m-01)TRUNC(SYSDATE, MM)DATE_TRUNC(month, CURRENT_DATE)::dateDATE(now, start of month)下月第一天DATEADD(MONTH, 1, 月初)DATE_FORMAT(DATE_ADD(CURDATE(), INTERVAL 1 MONTH), %Y-%m-01)ADD_MONTHS(TRUNC(SYSDATE, MM), 1)(DATE_TRUNC(month, CURRENT_DATE) INTERVAL 1 month)::dateDATE(now, start of month, 1 month)类型转换CAST(... AS DATE)CAST(... AS DATE)TO_DATE(..., YYYY-MM-DD)::date无独立日期类型迁移时的另一个忠告不要把字符串拼接方案直接跨库搬。比如 SQL Server 里的CONVERT(VARCHAR(7), date, 120)和 Oracle 里的TO_CHAR(date, YYYY-MM)虽然能拼出2025-05-01但一旦遇到数据库日期语言设置差异很容易出错。最省心的做法是把逻辑统一改成月份起始日期的语义函数而不是套用某个数据库独有的语法。5. 代码封装与跨库迁移建议5.1 把月初计算封装成函数或存储过程无论你用哪种数据库我都不建议在业务 SQL 里散落满天飞的DATEADD、TRUNC、DATE_FORMAT。更好维护的方式是封装。比如 SQL Server 里把这个逻辑做成一个标量函数MySQL 里做一个存储函数PostgreSQL 里做一个 PL/pgSQL 函数。这样一旦业务规则调整比如要返回某财政月的第一天你只需要改一处所有调用点自动生效。封装时留意一点函数内的返回类型要表达清楚。如果是日期范围查询的上界我通常返回日期类型如果是报表标题展示我可能返回字符串。两种目的不同函数接口设计也不一样。5.2 从 SQL Server 迁到 MySQL 时我踩过的细节坑有一个细节我特别想提醒大家SQL Server 中的0在日期语境下代表1900-01-01所以DATEADD(MONTH, DATEDIFF(MONTH, 0, GETDATE()), 0)能成立。但 MySQL 里没有这个隐藏常量如果你想照搬这个套路必须显式写一个基准日期SELECT DATE_ADD(1900-01-01, INTERVAL TIMESTAMPDIFF(MONTH, 1900-01-01, CURDATE()) MONTH) AS month_start;这句完全等价但代码明显变长可读性也不高。所以我迁移经验里第一条就是不要硬搬 SQL Server 的偏移 0套路直接用 MySQL 的DATE_FORMAT方案更顺。5.3 最终建议每个数据库我只留一种写法最后分享一个我自己的习惯在多数据库环境里我不追求一种语法通吃所有库因为那不可能我追求的是每个数据库里都只保留一种团队统一认可的写法。在 SQL Server 里我用DATEFROMPARTS在 MySQL 里我用CAST(DATE_FORMAT(...) AS DATE)在 Oracle 里我用TRUNC在 PostgreSQL 里我用DATE_TRUNC加::date在 SQLite 里我用DATE(..., start of month)。选型的标准不是谁最短而是可读性最好、类型转换最少、在生产环境验证过足够稳。这样做的最大好处是团队成员互相 review 代码时没有任何理解成本报表 SQL 的风格也统一迁移工具做自动化转换时只需要处理一套模式。维护到后期你真的会发现这个决定能救你无数次。按我这几年的使用体会取月初这件事只要把每种数据库的推荐写法固定下来后面所有日期范围报表、结算逻辑、数据归档都会顺畅很多。它是个小功能却能在项目里切切实实减少事故。希望这篇梳理对你也有用。
返回列表