递归CTE生成连续日期序列需明确起止时间与深度限制,锚点单行、递归用UNION ALL,LEFT JOIN原表补空并COALESCE转0,多维分组需先CROSS JOIN再LEFT JOIN。
用递归CTE生成连续日期序列
直接用递归 CTE 构造日期范围,是补全缺失日期最可控的方式。关键不是“能不能”,而是递归深度和起止时间必须明确,否则容易报错或性能崩掉。
是标准写法(PostgreSQL、SQL Server、SQLite 支持;MySQL 8.0+ 也支持,但需显式声明
)
锚点(anchor)必须是单行,比如
,不能带
表
递归部分必须引用自身别名,且
(不能用
),否则终止条件失效
默认递归上限通常是 100 层,超限会报错
,PostgreSQL 可加
(SQL Server)或设
参数(PG 中用
不起作用,得靠
配置)
把生成的日期序列和原表 LEFT JOIN 补空
构造完日期后,不能直接
原表——那只会保留有数据的日期。必须用
,让日期主表驱动,再用
或
把空值转成 0。
JOIN 条件要严格对齐:比如原表的
和序列的
比较,避免时区/类型隐式转换导致漏匹配
如果原表有多个分组维度(如
,
),则需要先
所有分组组合 × 日期序列,再
原事实表——否则会漏掉“某用户某状态在某天完全没记录”的情况
别在递归 CTE 里直接
,先生成完整序列,再在外面套一层查询聚合,逻辑更清晰、也更容易调试
MySQL 8.0 下递归 CTE 的典型写法与坑
MySQL 对递归 CTE 限制较多,不支持
在递归体中,也不允许子查询出现在递归部分。最稳妥的方式是只做日期递增,其他逻辑后置。
注意 MySQL 中日期相加要用
,不能写
中的终止条件必须写在递归
后面,且不能引用外部表字段
如果原始数据量大,但日期跨度小(比如只查 30 天),这种写法比用日历表或数字表更轻量;反之,若要查 5 年日期,建议预建
表
替代方案:用数字表 + DATE_ADD 构造(兼容老版本 MySQL)
当数据库不支持递归 CTE(如 MySQL 5.7),就退回到「数字表 + 时间偏移」方式。核心是先有一列连续整数,再用它生成日期。
临时数字表可用
拼出来,最多到 1000 行以内还行;超过建议建物理
表
是通用写法,所有主流 SQL 引擎都支持
注意
起始为 0,否则会跳过首日;结束值应为
,即 30,不是 31
这种方式没有递归深度限制,但手拼数字表易出错,且难以动态控制跨度——所以一旦项目稳定,还是推荐迁移到递归 CTE 或日历表
实际补日期最难的不是语法,是想清楚“缺失”到底指什么:是整个时间轴上所有日期都要有?还是每个分组下各自的时间轴?后者必须先
出所有分组组合,再和日期交叉,少这一步,补出来的就是假全。
WITH RECURSIVERECURSIVESELECT '2024-01-01'::DATE AS dtFROMUNION ALLUNIONmaximum recursion depth exceededMAXRECURSION 0search_pathSET statement_timeoutmax_recursive_depthJOINLEFT JOINCOALESCECOALESCE(count_col, 0)DATE(created_at)dtuser_idstatusCROSS JOINLEFT JOINGROUP BYORDER BYWITH RECURSIVE date_series AS (
SELECT '2024-01-01' AS dt
UNION ALL
SELECT dt + INTERVAL 1 DAY
FROM date_series
WHERE dt < '2024-01-31'
)
SELECT ds.dt, COALESCE(t.cnt, 0) AS cnt
FROM date_series ds
LEFT JOIN (
SELECT DATE(created_at) AS dt, COUNT(*) AS cnt
FROM orders
WHERE created_at >= '2024-01-01' AND created_at < '2024-02-01'
GROUP BY DATE(created_at)
) t ON ds.dt = t.dt;+ INTERVAL 1 DAYdt + 1WHERESELECTcalendar(SELECT 0 n UNION SELECT 1 UNION SELECT 2 ...)numbersDATE_ADD('2024-01-01', INTERVAL n DAY)nDATEDIFF('2024-01-31', '2024-01-01')DISTINCT