如何重构 Postgres 查询以获取当前账单周期内的所有购买记录?
支付老李
结论
不要把月份和日期拆开比较。应先算出每位 customer 当前账单周期的起始日期,再按完整的日期范围筛选 purchases。
判断条件应写成:
EXTRACT(DAY FROM CURRENT_DATE) >= c.billing_date
这里不能漏掉等号。当天正好是 billing day 时,周期应从本月的 billing day 开始,当天的 purchases 也应计入新周期。
重构后的查询
下面的写法假设 billing_date 在 1 到 28 之间,确保每个月都有对应日期:
SELECT
p.customer_id,
SUM(p.subtotal) AS total_period_purchases
FROM purchases AS p
JOIN customer AS c
ON c.id = p.customer_id
WHERE p.date >=
CASE
WHEN EXTRACT(DAY FROM CURRENT_DATE) >= c.billing_date THEN
date_trunc('month', CURRENT_DATE)
+ (c.billing_date - 1) * INTERVAL '1 day'
ELSE
date_trunc('month', CURRENT_DATE)
- INTERVAL '1 month'
+ (c.billing_date - 1) * INTERVAL '1 day'
END
AND p.date < CURRENT_DATE + INTERVAL '1 day'
GROUP BY p.customer_id;
具体逻辑如下:
- 今天已经到达或超过 billing day,周期从本月的 billing day 开始。
- 今天还没到 billing day,周期从上个月的 billing day 开始。
p.date < CURRENT_DATE + INTERVAL '1 day'会包含今天全天,但不会包含之后的日期。
假设 billing day 是 26:
- 当前日期是 10 月 19 日,周期起点为 9 月 26 日。
- 当前日期是 10 月 26 日,周期起点为 10 月 26 日。
- 当前日期是 10 月 30 日,周期起点仍为 10 月 26 日。
为什么不应使用 DATE_PART 分别比较
分别比较 MONTH 和 DAY,会把连续的日期范围拆成多个条件分支。查询会变得更复杂,也更容易漏掉年份边界。例如,只比较月份时,不同年份的同一个月份会被当成相同月份。
另外,在 p.date 上调用:
DATE_PART('MONTH', p.date)
通常会影响 PostgreSQL 使用 p.date 上的普通索引。改用完整日期的范围条件后,可以配合下面的索引:
CREATE INDEX ON purchases (customer_id, date);
如果 billing_date 可能是 29、30 或 31
这时需要先确定业务规则,因为有些月份不存在这些日期。常见处理方式是把 billing day 顺延,或者截取为当月最后一天。
如果采用“截取为当月最后一天”的规则,可以先计算实际有效的账单日期:
WITH customer_periods AS (
SELECT
c.id AS customer_id,
CASE
WHEN CURRENT_DATE >= current_bill_date THEN current_bill_date
ELSE previous_bill_date
END AS period_start
FROM customer AS c
CROSS JOIN LATERAL (
SELECT
date_trunc('month', CURRENT_DATE)::date AS current_month_start,
(
date_trunc('month', CURRENT_DATE) - INTERVAL '1 month'
)::date AS previous_month_start
) AS months
CROSS JOIN LATERAL (
SELECT
months.current_month_start
+ LEAST(
c.billing_date,
EXTRACT(
DAY FROM (
months.current_month_start
+ INTERVAL '1 month'
- INTERVAL '1 day'
)
)::integer
) - 1 AS current_bill_date,
months.previous_month_start
+ LEAST(
c.billing_date,
EXTRACT(
DAY FROM (
months.current_month_start
- INTERVAL '1 day'
)
)::integer
) - 1 AS previous_bill_date
) AS billing_dates
)
SELECT
p.customer_id,
SUM(p.subtotal) AS total_period_purchases
FROM purchases AS p
JOIN customer_periods AS cp
ON cp.customer_id = p.customer_id
WHERE p.date >= cp.period_start
AND p.date < CURRENT_DATE + INTERVAL '1 day'
GROUP BY p.customer_id;
这样,2 月份的 billing day 31 会自动按 2 月最后一天处理。
如果 purchases.date 是 date 类型,结束条件也可以写成:
AND p.date <= CURRENT_DATE
如果字段类型是 timestamp 或 timestamptz,使用半开区间更稳妥:
AND p.date >= period_start
AND p.date < CURRENT_DATE + INTERVAL '1 day'
使用 timestamptz 时,还要确认数据库 session 时区与账单业务时区一致,否则午夜前后的记录可能会被计入错误的账单日期。
备注:内容仅供参考。
