用FLOOR配合时间戳转秒再除步长实现非标准分组:15分钟→/900,4小时→/14400,7天→/604800;须用FLOOR保证左闭右开窗口,注意时区转换和函数索引优化。

用 DATE_TRUNC 或 EXTRACT 配合算术实现非标准时间步长分组
PostgreSQL 和 BigQuery 支持 DATE_TRUNC,但只接受预定义单位(如 'hour'、'day'),无法直接按 15 分钟、4 小时、7 天这类自定义步长分组。MySQL 和 SQL Server 更不支持。真正通用的做法是把时间戳转为“距某个基准点的步长序号”,再按序号分组。
核心思路:用 EXTRACT(EPOCH FROM ts)(秒级时间戳)或 UNIX_TIMESTAMP(ts)(MySQL)转为整数,除以步长秒数取整,得到每段的唯一编号。
- 15 分钟步长 → 除以
15 * 60 = 900 - 4 小时步长 → 除以
4 * 3600 = 14400 - 7 天步长 → 除以
7 * 86400 = 604800
示例(PostgreSQL):
SELECT TO_TIMESTAMP(FLOOR(EXTRACT(EPOCH FROM log_time) / 900) * 900) AS window_start, COUNT(*) AS event_count, AVG(duration_ms) AS avg_duration FROM logs GROUP BY FLOOR(EXTRACT(EPOCH FROM log_time) / 900) ORDER BY window_start;
避免跨日/跨月边界错位:必须用 FLOOR 而非 ROUND 或截断
如果用 ROUND,14:59:59 和 15:00:00 可能被分到不同窗口(前者归入 14:45–15:00,后者归入 15:00–15:15),但实际你想让所有 15:00:00 之前的都进前一个窗口。只有 FLOOR 能保证左闭右开语义([start, start + step))。
- 错误写法:
ROUND(EXTRACT(EPOCH FROM ts)/900)→ 14:59:59.9 会向上舍入 - 正确写法:
FLOOR(EXTRACT(EPOCH FROM ts)/900)→ 所有[t, t+900)内的时间统一映射到同一整数 - 注意:某些方言(如 SQLite)无
FLOOR,可用CAST(x AS INTEGER)替代,但仅当 x ≥ 0;负时间需额外处理
MySQL 用户注意 UNIX_TIMESTAMP 的时区陷阱
UNIX_TIMESTAMP() 默认将输入时间按系统时区解释并转为 UTC 秒数。如果你的 log_time 是 DATETIME 类型(无时区信息),且数据库时区与日志原始时区不一致,聚合结果会偏移。
- 确认当前时区:
SELECT @@time_zone, @@system_time_zone; - 稳妥做法:显式转为 UTC 时间再计算,例如
UNIX_TIMESTAMP(CONVERT_TZ(log_time, '+08:00', '+00:00')) - 若日志时间已是 UTC 存储,且 MySQL 时区设为
+00:00,可直接用UNIX_TIMESTAMP(log_time)
错误示例(未考虑时区):
-- 假设 log_time 是北京时间存储,但 MySQL 时区是 SYSTEM(CST),会导致重复计算或漏窗 GROUP BY FLOOR(UNIX_TIMESTAMP(log_time) / 900);
性能关键:给 log_time 字段建函数索引(PostgreSQL / MySQL 8.0+)
上面的 FLOOR(EXTRACT(...)) 表达式无法命中普通 B-tree 索引。若数据量大、查询频繁,必须建函数索引,否则每次都要全表扫描。
- PostgreSQL:
CREATE INDEX idx_logs_15min ON logs (FLOOR(EXTRACT(EPOCH FROM log_time) / 900));
- MySQL 8.0+:
CREATE INDEX idx_logs_15min ON logs ((FLOOR(UNIX_TIMESTAMP(log_time) / 900)));
没建这个索引,千万级日志表的 15 分钟窗口聚合可能从毫秒级退化到数秒甚至更久。
真正麻烦的不是写法,而是步长单位换算时漏掉时区、用错舍入函数、或者忘了索引——这三个地方一错,结果就偏,性能就崩,而且不容易一眼看出来。

















