SQL 获取上个月数据的终极技巧 (含性能优化)
2025-04-02 10:50:11
SQL 查询:如何获取上个月的所有数据?
在数据库操作中,经常会遇到一个需求:捞出上个月创建的所有记录。比如,现在是一月,就想拿到去年十二月的数据;现在是二月,就想看一月份的。听起来直接,但如果表里存的是精确到秒的时间戳,比如 date_created
字段,格式是 2007-06-05 14:50:17
,怎么写 SQL 才能准确地、动态地抓取上个月的数据呢?
为什么需要专门处理?
直接的想法可能是用当前日期减去一个月或者 30 天。但这事儿没那么简单:
- 月份天数不同: 各个月份的天数不一样(28, 29, 30, 31),直接减固定天数肯定会出错。
- 跨年问题: 如果当前是一月份,上个月就是去年的十二月。年份也得跟着变。
- 精确边界: 我们需要的是从上个月第一天的
00:00:00
到上个月最后一天的23:59:59
(或者说,小于这个月第一天的00:00:00
)的所有记录。边界必须非常清晰。
简单粗暴的日期加减行不通,我们需要更精确的方法来定义“上个月”的范围。
解决方案
下面介绍几种主流数据库中实现这个功能的常用方法。核心思路都是计算出上个月的准确时间范围。
方法一:计算上个月的起止日期范围
这是最通用、性能也相对较好的方法。思路是计算出上个月第一天的开始时间 和 这个月第一天的开始时间,然后查询 date_created
在这个左闭右开区间 [start_date, end_date)
内的数据。
原理
- 获取当前月份的第一天: 不管今天是几号,先找到这个月 1 号的日期(时间部分通常设为 00:00:00)。
- 计算上个月的第一天: 从当前月份的第一天再往前推一个月,得到的就是上个月的第一天。这就是查询范围的 起始时间 (包含)。
- 查询范围的结束时间: 当前月份的第一天(时间部分 00:00:00)就是查询范围的 结束时间 (不包含)。
- 执行查询: 使用
WHERE date_created >= start_of_previous_month AND date_created < start_of_current_month
来筛选。
这种 大于等于
开始时间、小于
结束时间 的方式,能精确覆盖整个上个月,并且对索引友好。
实现方式
不同数据库的日期函数略有差异,这里给出 MySQL、PostgreSQL 和 SQL Server 的示例。
MySQL:
SELECT *
FROM your_table
WHERE date_created >= DATE_SUB(DATE_FORMAT(CURDATE(), '%Y-%m-01'), INTERVAL 1 MONTH) -- 上个月第一天
AND date_created < DATE_FORMAT(CURDATE(), '%Y-%m-01'); -- 这个月第一天
CURDATE()
: 获取当前日期 (YYYY-MM-DD)。DATE_FORMAT(CURDATE(), '%Y-%m-01')
: 将当前日期格式化为该月第一天的字符串,MySQL 会自动将其识别为日期。DATE_SUB(..., INTERVAL 1 MONTH)
: 从某个日期减去一个月。这里是从本月第一天减去一个月,得到上个月第一天。- 查询条件
date_created >=上个月第一天
并且date_created < 这个月第一天
,精确命中上个月所有记录。
PostgreSQL:
SELECT *
FROM your_table
WHERE date_created >= date_trunc('month', now() - interval '1 month') -- 上个月第一天
AND date_created < date_trunc('month', now()); -- 这个月第一天
now()
: 获取当前时间戳。date_trunc('month', ...)
: 将日期时间截断到月份的开始。date_trunc('month', now())
就是这个月的第一天 00:00:00。now() - interval '1 month'
: 获取大约一个月前的时间点。date_trunc('month', now() - interval '1 month')
: 将大约一个月前的时间点截断到它所在月份的第一天,也就是上个月的第一天 00:00:00。- 查询逻辑同 MySQL,
>=
上个月第一天,<
这个月第一天。
SQL Server:
SELECT *
FROM your_table
WHERE date_created >= DATEADD(month, -1, DATEADD(month, DATEDIFF(month, 0, GETDATE()), 0)) -- 上个月第一天
AND date_created < DATEADD(month, DATEDIFF(month, 0, GETDATE()), 0); -- 这个月第一天
-- 或者使用 EOMONTH (SQL Server 2012+) 稍微清晰一点:
-- DECLARE @StartOfCurrentMonth DATE = DATEFROMPARTS(YEAR(GETDATE()), MONTH(GETDATE()), 1);
-- DECLARE @StartOfPreviousMonth DATE = DATEADD(month, -1, @StartOfCurrentMonth);
--
-- SELECT *
-- FROM your_table
-- WHERE date_created >= @StartOfPreviousMonth
-- AND date_created < @StartOfCurrentMonth;
GETDATE()
: 获取当前日期和时间。DATEDIFF(month, 0, GETDATE())
: 计算从 SQL Server 的“零”时间点 (1900-01-01) 到当前日期经过了多少个月。DATEADD(month, ..., 0)
: 将这个月数加回到“零”时间点,得到的就是当前月份的第一天(时间部分为 00:00:00)。这是一种获取月初日期的经典技巧。DATEADD(month, -1, ...)
: 从本月第一天减去一个月,得到上个月第一天。EOMONTH()
函数 (End Of Month) 配合DATEADD
和DATEFROMPARTS
(SQL Server 2012+) 可以写出更易读的代码,如第二个注释掉的示例。
性能和安全
- 索引!索引!索引!
date_created
字段上必须要有索引。这是性能的关键。上面的查询条件date_created >= ? AND date_created < ?
的形式,数据库优化器可以很好地利用 B-Tree 索引进行范围扫描 (Range Scan),效率很高。 - 避免在
WHERE
子句中对date_created
列使用函数: 比如WHERE MONTH(date_created) = MONTH(DATE_SUB(NOW(), INTERVAL 1 MONTH))
这种写法,虽然逻辑上可能也对,但通常会导致索引失效,数据库不得不对每一行都计算MONTH(date_created)
的值,变成全表扫描,性能极差。我们介绍的方法是把计算放在查询条件的右侧(常量部分),让左侧的date_created
保持“干净”,这样索引才能发挥作用。 - 参数化查询: 如果是在应用程序代码中构建这个 SQL,强烈建议使用参数化查询或预编译语句 (Prepared Statements)。直接拼接字符串有 SQL 注入的风险,虽然在这个场景下,日期计算看起来风险不高,但养成好习惯总是没错的。
进阶技巧
- 时区处理: 如果你的应用涉及多个时区,或者服务器时区与数据存储时区不一致,
CURDATE()
,now()
,GETDATE()
获取的“当前时间”可能需要特别处理。确保计算边界时使用的时区与date_created
存储的时区一致。可能需要使用带时区的时间函数,如 PostgreSQL 的AT TIME ZONE
。 - 高频查询优化: 如果这个“查询上个月数据”的操作非常频繁,且数据量巨大,可以考虑:
- 分区表: 如果数据库支持,并且数据量确实庞大,可以考虑按月对表进行分区。查询上个月数据就可能只需要扫描一个或两个分区,效率更高。
- 物化视图: 创建一个物化视图,预先计算好每个记录属于哪一年、哪个月,查询时直接筛选年月字段。不过这会增加存储和维护成本。
- 汇总表: 如果不是要原始明细,而是聚合统计数据,可以定时任务(比如每天凌晨)计算上个月的聚合结果,存入专门的汇总表。
方法二:使用年份和月份函数筛选
这种方法是先计算出上个月对应的年份和月份数字,然后直接用数据库提供的 YEAR()
和 MONTH()
(或类似函数) 从 date_created
字段中提取年份和月份进行比较。
原理
- 计算上个月的年份和月份: 得到上个月是哪年几月。例如,如果现在是 2024 年 1 月,那上个月就是 2023 年 12 月。
- 提取记录的年份和月份: 对表中的
date_created
字段,使用函数提取其对应的年份和月份。 - 执行查询:
WHERE YEAR(date_created) = previous_year AND MONTH(date_created) = previous_month
。
实现方式
MySQL:
-- 先计算上个月的年份和月份 (这部分通常在应用代码或 SQL 变量中完成)
-- 假设我们已得到 @prev_year = 2023, @prev_month = 12
SELECT *
FROM your_table
WHERE YEAR(date_created) = @prev_year -- 替换为实际计算出的年份
AND MONTH(date_created) = @prev_month; -- 替换为实际计算出的月份
-- 完全在 SQL 中计算 @prev_year 和 @prev_month:
SET @prev_month_date = DATE_SUB(CURDATE(), INTERVAL 1 MONTH);
SET @prev_year = YEAR(@prev_month_date);
SET @prev_month = MONTH(@prev_month_date);
SELECT *
FROM your_table
WHERE YEAR(date_created) = @prev_year
AND MONTH(date_created) = @prev_month;
PostgreSQL:
-- 同样, 假设先计算好 prev_year 和 prev_month
SELECT *
FROM your_table
WHERE EXTRACT(YEAR FROM date_created) = :prev_year -- 使用变量或直接替换
AND EXTRACT(MONTH FROM date_created) = :prev_month;
-- 在 SQL 中计算:
WITH prev_month_info AS (
SELECT
EXTRACT(YEAR FROM (now() - interval '1 month')) AS prev_year,
EXTRACT(MONTH FROM (now() - interval '1 month')) AS prev_month
)
SELECT t.*
FROM your_table t
CROSS JOIN prev_month_info pmi
WHERE EXTRACT(YEAR FROM t.date_created) = pmi.prev_year
AND EXTRACT(MONTH FROM t.date_created) = pmi.prev_month;
SQL Server:
-- 假设 @prev_year, @prev_month 已计算好
SELECT *
FROM your_table
WHERE YEAR(date_created) = @prev_year -- 替换值
AND MONTH(date_created) = @prev_month;
-- 在 SQL 中计算:
DECLARE @prev_month_date DATETIME = DATEADD(month, -1, GETDATE());
DECLARE @prev_year INT = YEAR(@prev_month_date);
DECLARE @prev_month INT = MONTH(@prev_month_date);
SELECT *
FROM your_table
WHERE YEAR(date_created) = @prev_year
AND MONTH(date_created) = @prev_month;
性能考量
非常重要: 这种方法通常 性能较差 !
原因在于 WHERE YEAR(date_created) = ... AND MONTH(date_created) = ...
这种写法,对索引列 date_created
应用了函数 (YEAR
, MONTH
, EXTRACT
)。大多数数据库在这种情况下 无法有效利用 date_created
列上的常规 B-Tree 索引 。数据库引擎需要扫描很多行(甚至全表),对每一行的 date_created
值都执行 YEAR()
和 MONTH()
函数计算,然后才进行比较。这个问题叫做 Sargability ,即查询谓词(WHERE 条件)是否能利用索引。
只有在特殊情况下,比如你为 YEAR(date_created)
和 MONTH(date_created)
分别创建了函数索引 (Function-Based Index,并非所有数据库都支持或高效支持),这种方法才可能快起来。但对于通用的 DATETIME
或 TIMESTAMP
列,这通常不是最优选择。
适用场景
- 概念上可能对某些人来说更直接:按年、月匹配。
- 数据量非常小,性能不是瓶颈。
- 你确实有基于
YEAR(date_created)
和MONTH(date_created)
的函数索引。
总结哪种方法更好?
强烈推荐方法一:计算上个月的起止日期范围。
理由:
- 性能更好: 利用
>= start AND < end
的形式,可以高效地使用date_created
列上的标准索引,进行范围扫描。 - 更通用: 适用于各种日期时间精度的列。
- 精确性: 定义清晰的边界,避免了因函数处理可能引入的微小误差或时区混乱。
方法二(使用 YEAR()
/ MONTH()
函数)虽然看起来代码可能稍微少一点(如果不算计算年月那部分),但牺牲了性能,在大数据量的表上可能会慢很多倍,应该尽量避免。
选择正确的查询方式,特别是在处理日期范围时,对数据库性能至关重要。希望这几种方法的介绍能帮你搞定“查询上个月数据”这个常见任务。