PostgreSQL中需用FLOOR(EXTRACT(MINUTE FROM ts)/30.0)配合DATE_TRUNC('hour', ts)和INTERVAL偏移实现稳定半小时分组,避免整数除法错误,并注意WHERE条件须向下对齐到最近半小时起点以防止漏数据。

用 date_trunc('hour', ts) + 条件偏移实现半小时分组
PostgreSQL 没有原生的 '30min' 或 'half_hour' 时间粒度,直接写 date_trunc('30min', ts) 会报错:ERROR: invalid time precision。必须把半小时看作“每小时内的第 0 或第 30 分钟起始”,用算术偏移绕过限制。
核心思路:先把时间减去 30 分钟,再按小时截断,最后加回 30 分钟——这样 00:00–00:29 变成前一小时的 23:30 截断结果,而 00:30–00:59 才落到 00:30 起始段。但更稳妥的做法是用 FLOOR 配合分钟数计算:
SELECT
(DATE_TRUNC('hour', ts) +
INTERVAL '30 min' * FLOOR(EXTRACT(MINUTE FROM ts) / 30.0)) AS half_hour,
COUNT(*) AS cnt
FROM logs
GROUP BY 1
ORDER BY 1;
这个表达式对任意 ts 值都稳定生成形如 2024-05-01 14:00:00 或 2024-05-01 14:30:00 的分组键。
注意 EXTRACT(MINUTE FROM ts) 的整除陷阱
FLOOR(EXTRACT(MINUTE FROM ts) / 30.0) 必须用 30.0(浮点数)而非 30(整数),否则在 PostgreSQL 中触发整数除法,EXTRACT(MINUTE FROM '14:29') / 30 得 0,EXTRACT(MINUTE FROM '14:30') / 30 得 1——看似没问题,但一旦写成 / 30 且字段类型为 integer,某些版本可能隐式截断导致边界错误。
- ✅ 正确:
FLOOR(EXTRACT(MINUTE FROM ts) / 30.0) - ❌ 危险:
EXTRACT(MINUTE FROM ts) / 30(无.0,无FLOOR) - ⚠️ 备选:用
(EXTRACT(MINUTE FROM ts) / 30)::int,但需确认输入非负且逻辑一致
WHERE 条件要对齐分组起点,别漏掉跨段数据
如果只想查今天从 09:00 开始的半小时统计,不能只写 WHERE ts >= '2024-05-01 09:00'——因为 08:45 的请求会被分到 08:30 段,但它在 WHERE 中被过滤掉了。实际应把时间下界“向下对齐”到最近的半小时起点:
WHERE ts >= (DATE_TRUNC('hour', '2024-05-01 09:00'::timestamp)
+ INTERVAL '30 min' * FLOOR(EXTRACT(MINUTE FROM '2024-05-01 09:00'::timestamp) / 30.0))
等价于直接写 WHERE ts >= '2024-05-01 09:00' 是侥幸成立的,因为 09:00 本身就是半小时边界;但换成 09:07 就必须对齐到 09:00,否则漏掉 08:30–09:00 段中属于 08:30 起始的那部分数据。
索引是否生效?别让 date_trunc 拖慢查询
直接在 WHERE 里用 DATE_TRUNC('hour', ts) + ... 表达式无法走 ts 字段的 B-tree 索引。真要高频查询,建议建表达式索引:
CREATE INDEX idx_logs_half_hour ON logs
(( (DATE_TRUNC('hour', ts) + INTERVAL '30 min' * FLOOR(EXTRACT(MINUTE FROM ts) / 30.0)) ));
但注意:这个索引只能加速「按该表达式分组或过滤」的场景,对纯时间范围查询(如 ts BETWEEN ...)无效。日常建议优先用原始 ts 加范围条件过滤,再分组——除非数据量极大且固定查半小时桶。
边界情况容易被忽略:当 ts 为 NULL 时,整个表达式返回 NULL,该行不会出现在任何分组中。如果日志表允许空时间戳,得额外用 WHERE ts IS NOT NULL 显式排除。










