结论:用 row_number() 减去按日期排序后、以“是否达标”为分组依据的另一个 row_number(),差值相等的记录属于同一段连续达标区间——这是识别连续区间的最稳定、兼容性最好的方法。

直接说结论:用 ROW_NUMBER() 减去按日期排序后、以“是否达标”为分组依据的另一个 ROW_NUMBER(),差值相等的记录属于同一段连续达标区间——这是识别连续区间的最稳定、兼容性最好的方法。
为什么不能只用 LAG/LEAD 或窗口 SUM?
LAG/LEAD 只能看相邻行,无法跨多天累积判断;SUM(达标标志) 会把所有达标日累加成一个大数字,丢失“断点重置”的能力。而分组偏移量法本质是构造一个“动态分组 ID”,只要连续,这个 ID 就不变。
关键在于两层排序逻辑:
- 外层按
date升序排,保证时间顺序 - 内层按
date排,但分组依据是“上一次不达标之后的新起点”,这靠CASE WHEN is_pass = 0 THEN 1 ELSE 0 END累计求和来隐式定义
实际写法中,我们不用显式累计求和,而是用两个 ROW_NUMBER() 相减来等价实现。
标准写法:双 ROW_NUMBER() 差值分组
假设表叫 t_daily_check,字段有 date(DATE 类型)、is_pass(TINYINT 或 BOOLEAN,1=达标,0=未达标):
SELECT
MIN(date) AS start_date,
MAX(date) AS end_date,
COUNT(*) AS consecutive_days
FROM (
SELECT
date,
is_pass,
ROW_NUMBER() OVER (ORDER BY date)
- ROW_NUMBER() OVER (PARTITION BY is_pass ORDER BY date) AS grp
FROM t_daily_check
WHERE is_pass = 1
) t
GROUP BY grp
ORDER BY start_date;
注意:WHERE is_pass = 1 要提前过滤,否则 is_pass = 0 的行也会参与 PARTITION BY is_pass 分组,污染 grp 值。
如果想保留所有连续段(包括未达标段),就把 WHERE 拿掉,并在内层加 CASE 控制分组粒度,但绝大多数场景只关心“达标段”。
容易踩的坑:日期不连续、时区、NULL 值
这个方法默认假设数据按自然日存在,但现实常有缺日或节假日跳过:
- 如果某天没记录(比如系统宕机),它不会被当作“中断”,因为根本不在表里——你得先补全日期维度,再 LEFT JOIN
-
date字段如果是DATETIME且带时区,务必先CAST(date AS DATE)或用DATE(date)归一化,否则同一天不同时间戳会被拆成多行 -
is_pass为NULL时,PARTITION BY is_pass会让 NULL 自成一组,且ROW_NUMBER()在该组内仍会编号,导致 grp 异常;务必加WHERE is_pass IN (0, 1)显式排除
另外,MySQL 8.0+、PostgreSQL、SQL Server 2012+、Oracle 11gR2+ 都支持此写法;SQLite 3.25+ 也支持,但旧版不支持窗口函数。
扩展:只查“当前最长连续天数”或“截至某日的连续天数”
如果只要一个数字(比如“截至今天已连续达标几天?”),可以简化为单条查询:
SELECT COUNT(*) AS current_consecutive_days
FROM t_daily_check t1
WHERE is_pass = 1
AND NOT EXISTS (
SELECT 1 FROM t_daily_check t2
WHERE t2.date > t1.date
AND t2.date <p>但性能较差。更优做法仍是双 <code>ROW_NUMBER()</code>,然后加 <code>HAVING MAX(date) = CURDATE()</code> 过滤出终点为今天的组。</p><p>真正难的不是写出这个差值公式,而是确认你的 <code>date</code> 字段是否干净、是否覆盖全部业务日、以及“达标”定义是否在所有行中一致无歧义。</p>










