返回

SQL 获取上个月数据的终极技巧 (含性能优化)

mysql

SQL 查询:如何获取上个月的所有数据?

在数据库操作中,经常会遇到一个需求:捞出上个月创建的所有记录。比如,现在是一月,就想拿到去年十二月的数据;现在是二月,就想看一月份的。听起来直接,但如果表里存的是精确到秒的时间戳,比如 date_created 字段,格式是 2007-06-05 14:50:17,怎么写 SQL 才能准确地、动态地抓取上个月的数据呢?

为什么需要专门处理?

直接的想法可能是用当前日期减去一个月或者 30 天。但这事儿没那么简单:

  1. 月份天数不同: 各个月份的天数不一样(28, 29, 30, 31),直接减固定天数肯定会出错。
  2. 跨年问题: 如果当前是一月份,上个月就是去年的十二月。年份也得跟着变。
  3. 精确边界: 我们需要的是从上个月第一天的 00:00:00 到上个月最后一天的 23:59:59(或者说,小于这个月第一天的 00:00:00)的所有记录。边界必须非常清晰。

简单粗暴的日期加减行不通,我们需要更精确的方法来定义“上个月”的范围。

解决方案

下面介绍几种主流数据库中实现这个功能的常用方法。核心思路都是计算出上个月的准确时间范围。

方法一:计算上个月的起止日期范围

这是最通用、性能也相对较好的方法。思路是计算出上个月第一天的开始时间 和 这个月第一天的开始时间,然后查询 date_created 在这个左闭右开区间 [start_date, end_date) 内的数据。

原理

  1. 获取当前月份的第一天: 不管今天是几号,先找到这个月 1 号的日期(时间部分通常设为 00:00:00)。
  2. 计算上个月的第一天: 从当前月份的第一天再往前推一个月,得到的就是上个月的第一天。这就是查询范围的 起始时间 (包含)。
  3. 查询范围的结束时间: 当前月份的第一天(时间部分 00:00:00)就是查询范围的 结束时间 (不包含)。
  4. 执行查询: 使用 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) 配合 DATEADDDATEFROMPARTS (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 字段中提取年份和月份进行比较。

原理

  1. 计算上个月的年份和月份: 得到上个月是哪年几月。例如,如果现在是 2024 年 1 月,那上个月就是 2023 年 12 月。
  2. 提取记录的年份和月份: 对表中的 date_created 字段,使用函数提取其对应的年份和月份。
  3. 执行查询: 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,并非所有数据库都支持或高效支持),这种方法才可能快起来。但对于通用的 DATETIMETIMESTAMP 列,这通常不是最优选择。

适用场景

  • 概念上可能对某些人来说更直接:按年、月匹配。
  • 数据量非常小,性能不是瓶颈。
  • 你确实有基于 YEAR(date_created)MONTH(date_created) 的函数索引。

总结哪种方法更好?

强烈推荐方法一:计算上个月的起止日期范围。

理由:

  • 性能更好: 利用 >= start AND < end 的形式,可以高效地使用 date_created 列上的标准索引,进行范围扫描。
  • 更通用: 适用于各种日期时间精度的列。
  • 精确性: 定义清晰的边界,避免了因函数处理可能引入的微小误差或时区混乱。

方法二(使用 YEAR() / MONTH() 函数)虽然看起来代码可能稍微少一点(如果不算计算年月那部分),但牺牲了性能,在大数据量的表上可能会慢很多倍,应该尽量避免。

选择正确的查询方式,特别是在处理日期范围时,对数据库性能至关重要。希望这几种方法的介绍能帮你搞定“查询上个月数据”这个常见任务。