LeetCode 数据库题单做到后面,会发现大量题目其实是在为真实世界的报表需求做“降维”。这道“报告系统状态的连续日期”就是非常典型的一道。题面很短:系统每天运行,成功日期和失败日期分别记在两张表里,要求把某个时间段内所有“连续状态区间”找出来,输出每个区间是成功还是失败、从哪天开始到哪天结束。很多人第一反应是“按状态分组然后取MIN、MAX不就行了”,真动手写才发现,难点全在“连续区间”四个字上。这篇文章就从头梳理这道题的完整推导过程、最稳的窗口函数解法,以及我实际写 SQL 和做报表时踩过的几个坑。
1. 题目到底在问什么:从两张状态表还原系统运行时间线
1.1 表结构与我对题意的理解
先看数据结构。题目给了两张表,一张记录失败日期,一张记录成功日期:
| 表名 | 字段 | 说明 |
|---|---|---|
| Failed | fail_date | 系统失败的日期,主键 |
| Succeeded | success_date | 系统成功的日期,主键 |
题目有个隐含前提:系统每天运行,所以每一天的状态是唯一的,要么成功、要么失败,两张表的日期不会重叠。需要输出的结果包含三列:period_state(状态)、start_date(区间开始日期)、end_date(区间结束日期),按开始日期排序。
光说概念有点抽象,我构造一个简化例子。假设只关注 2019 年 1 月 1 日到 1 月 8 日,状态分别是:
- 1、2 日成功
- 3、4 日失败
- 5、6、7 日成功
- 8 日失败
那么期望输出应该是:
| period_state | start_date | end_date |
|---|---|---|
| succeeded | 2019-01-01 | 2019-01-02 |
| failed | 2019-01-03 | 2019-01-04 |
| succeeded | 2019-01-05 | 2019-01-07 |
| failed | 2019-01-08 | 2019-01-08 |
注意最后一行,失败状态只有一天,也要作为一个独立区间输出,不能因为“太短”就丢。
1.2 真实业务里“连续区间”问题的样子
这道题看起来是刷题,实际上是很多报表需求的缩影。比如监控系统要统计“服务连续可用了多少天”“最近一次故障从哪天持续到哪天”;用户增长团队要统计“每个用户连续登录了多少天”“最大连续签到天数”;电商运营要看“优惠券连续发放了多少天”。这些需求背后的数学模型都一样:给一堆带有日期、带有状态标签的记录,把相同状态且日期连续的记录归成一段,然后计算每段的起止时间。
如果只按状态分组,会出大问题。比如系统 1 月成功、2 月失败、3 月又成功,直接 GROUP BY 状态会把 1 月和 3 月的成功记录合并成一条“成功区间 1 月 1 日到 3 月 31 日”,中间的失败段被彻底吞掉。所以必须先识别“连续段”,再对段做聚合。这也是为什么不能简单用GROUP BY period_state解决。
2. 连续区间分组的核心原理:为什么不是简单的前后行比较
2.1 自连接与逐行判断的困境
我第一次见这道题时,脑子里冒出来的方案是:先按日期排序,然后看每一天和前一天的日期是否相差 1 天。如果相差 1 天,说明连续;如果不连续,说明是新区间开头。这个思路没有错,但要落地非常麻烦。你要么用LAG()逐行判断后,再想方设法把“是否连续”的标记转换成区间编号;要么用自连接,把每一天往前 JOIN 半天,找出所有连续段的起点和终点,再把它们拼接起来。后者在数据量小的时候能跑,但 SQL 写出来又长又脆,遇到时间跨度大、状态切换频繁的情况,自己都能把自己绕晕。
这类问题的本质,不是“判断两个日期是否连续”,而是“给每个连续段分配一个稳定且唯一的编号”。一旦拿到了段编号,之后所有统计都变成普通的GROUP BY。所以问题核心在这里:怎么一分钱不花地给每个连续段编上号。
2.2 两个序号相减:把连续行变成同一个分组标识
这里要引入两个窗口函数编号:
rn_all:按日期全局排序,得到每一行在整个时间线里的位置。rn_state:按状态分区,再按日期排序,得到每一行在“同一种状态内部”的位置。
看下面这个表,我按 1 月 1 日到 8 日的数据演示:
| 日期 | 状态 | rn_all | rn_state | rn_all - rn_state |
|---|---|---|---|---|
| 2019-01-01 | succeeded | 1 | 1 | 0 |
| 2019-01-02 | succeeded | 2 | 2 | 0 |
| 2019-01-03 | failed | 3 | 1 | 2 |
| 2019-01-04 | failed | 4 | 2 | 2 |
| 2019-01-05 | succeeded | 5 | 3 | 2 |
| 2019-01-06 | succeeded | 6 | 4 | 2 |
| 2019-01-07 | succeeded | 7 | 5 | 2 |
| 2019-01-08 | failed | 8 | 3 | 5 |
仔细观察最后两列。连续的 1 号、2 号都是成功状态,rn_all和rn_state同步递增,差值都是 0;5 号到 7 号成功状态里,全局序号在涨,状态内序号也在涨,两者差值恒定在 2。反而是状态切换时,比如 3 号变成失败,rn_all从 2 跳到 3,但失败状态的rn_state从 0 开始计为 1,差值一下子从 0 跳到 2。这就是关键:同一段连续状态内,差值保持不变;一旦状态切换或日期断档,差值就会改变。
所以GROUP BY period_state, rn_all - rn_state就能把每一段连续区间分开。成功状态里差值 0 是 1-2 号,差值 2 是 5-7 号;失败状态里差值 2 是 3-4 号,差值 5 是 8 号,完美切分。
2.3 一个帮助理解的生活化类比
我给同事讲这个思路时,习惯用电影院座位来形容。假设观众按到达顺序拿到排队号,这是“全局编号”;但影厅座位是按排编号的,同一排从 1 号开始,这是“分区编号”。如果一拨人连续买走同一排的 1、2、3 号座位,他们的排队号和座位号同步增长,排队号减座位号的差值恒定。一旦中间有人买走了前一排的票,再往后的人虽然座位号也从 1 开始,但排队号已经高出很多,差值一下就变了。状态切换就像换了一排座位,“全局序号”和“分区序号”错位的程度,恰好告诉我们谁和谁属于同一个连续小团体。
2.4 只按状态编号也能做:原理等价但更脆
有朋友可能会问,我不用rn_all,只按状态内部编号行不行?比如直接ROW_NUMBER() OVER (PARTITION BY status ORDER BY date) AS rn_state,然后拿date - rn_state作为分组锚点。在“每天只有一条记录”的干净数据下,这样确实也能分组:连续两天日期每天加 1,状态内编号也每天加 1,相减后锚点不变;一旦断档,锚点右移,新段就有了新锚点。
那为什么很多题解仍然用两个编号相减?因为真实场景常有脏数据,比如同一天出现多条状态记录。如果某天系统里状态被记录了两笔,只按状态编号做日期减法就会被多出来的行干扰,连续区间的边界容易算错。两个编号相减的写法,对“先把时间线压平、再分组”这件事更鲁棒。这也是我推荐大家理解并掌握两个ROW_NUMBER()写法的原因。
3. 从零推导完整 SQL:合并时间线、编号、分组取端点
3.1 第一步:把成功和失败记录合并成一条时间线
两张表各管一种状态,但我们的连续区间要看完整时间线,所以第一步是把它们合并起来,给每一行补上状态标签。我用 CTE 来组织,逻辑清晰:
WITH all_dates AS ( SELECT fail_date AS event_date, 'failed' AS period_state FROM Failed WHERE fail_date BETWEEN '2019-01-01' AND '2019-12-31' UNION ALL SELECT success_date AS event_date, 'succeeded' AS period_state FROM Succeeded WHERE success_date BETWEEN '2019-01-01' AND '2019-12-31' )这里用UNION ALL而不是UNION,因为两张表日期不会重叠,而且每行都有状态标签,不需要去重;即便以后遇到可能有重复的脏数据,也应该在这里先做去重,而不是依赖UNION悄悄过滤。
为什么要在这里做日期范围过滤?先记住这个动作,第 4 章我会专门讲不这么写的坑。
3.2 第二步:分别计算全局编号和状态内编号
接着在all_dates之上加窗口函数:
numbered AS ( SELECT event_date, period_state, ROW_NUMBER() OVER (ORDER BY event_date) AS rn_all, ROW_NUMBER() OVER (PARTITION BY period_state ORDER BY event_date) AS rn_state FROM all_dates )PARTITION BY period_state是给状态分区,让成功和失败各自从 1 开始编号。ORDER BY event_date保证每个分区内按时间先后编号。两个窗口函数放在同一个查询里没有冲突,它们各自独立计算。
3.3 第三步:按“状态+差值”分组,取每个区间的起止日期
分组和聚合就很简单了:
SELECT period_state, MIN(event_date) AS start_date, MAX(event_date) AS end_date FROM numbered GROUP BY period_state, rn_all - rn_state ORDER BY start_date;rn_all - rn_state就是上一节说的差值,它和period_state共同充当区间编号。同一段连续的日期,差值相同,MIN、MAX自然取到段首和段尾。这里要注意,SELECT里没有直接选rn_all - rn_state,只把它放在GROUP BY中,避免某些数据库对 SELECT 列和 GROUP BY 列不一致的报错。
3.4 建表验证:从数据到结果的完整过程
为了让大家能直接复现,我给出一个 MySQL 8 下的完整示例:
CREATE TABLE Succeeded ( success_date DATE PRIMARY KEY ); CREATE TABLE Failed ( fail_date DATE PRIMARY KEY ); INSERT INTO Succeeded VALUES ('2019-01-01'), ('2019-01-02'), ('2019-01-05'), ('2019-01-06'), ('2019-01-07'); INSERT INTO Failed VALUES ('2019-01-03'), ('2019-01-04'), ('2019-01-08');然后把前面三段 SQL 拼起来执行,结果应该是:
| period_state | start_date | end_date |
|---|---|---|
| succeeded | 2019-01-01 | 2019-01-02 |
| failed | 2019-01-03 | 2019-01-04 |
| succeeded | 2019-01-05 | 2019-01-07 |
| failed | 2019-01-08 | 2019-01-08 |
和我的示例完全一致。把数据换成长达一年的真实数据,逻辑也不会变。
4. 提交之后我才注意到的几个坑:过滤、边界和方言差异
4.1 日期过滤放在外层,会让连续区间跨年并产生“幽灵段”
这是最容易翻车的点。假设系统在 2018 年 12 月 31 日成功,2019 年 1 月 1 日也成功,如果不在合并前过滤日期,这两行会一起进入编号,被划到同一个连续区间里。结果start_date变成 2018 年最后一天,而题目只关心 2019 年。有人可能想“那我再对结果加一个WHERE start_date >= '2019-01-01'不就行了”,但这样会把 12 月 31 日和 1 月 1 日连在一起的区间直接砍掉,而不是把起点修正为 1 月 1 日,语义就错了。
最稳妥的办法,就是像我在 3.1 写的那样:在UNION ALL之前,对每张表都单独加上BETWEEN '2019-01-01' AND '2019-12-31'。先把时间线裁剪成题目关心的范围,再谈连续性。这样跨年段自然被排除在计算之外。
4.2 单日区间不是 bug,别用 HAVING COUNT > 1 过滤
我看到不少人在写完分组后,顺手加一句HAVING COUNT(*) > 1,理由是“只有一天的区间没什么分析价值”。在真实业务里这个需求可能存在,但在这道题里是错的。失败一天也是一个完整区间,8 号单独一段,必须出现在结果里。真想过滤单日段,说明业务上对“至少连续 N 天”有要求,这属于需求变形,不是题目原本的意思。可以留到第 5 章变体里做,不要在基础解法中画蛇添足。
4.3 不同数据库的窗口函数与方言差异
窗口函数现在已经是主流数据库的标配了:MySQL 8.0、PostgreSQL、SQL Server、Oracle 都支持ROW_NUMBER() OVER (...)。但要注意几个细节:
- SQL Server 中
GROUP BY对表达式的处理比较严格,如果你SELECT rn_all - rn_state但没把它放进GROUP BY会报错。我的写法里没有把它放到 SELECT 列表,所以问题不大。 - MySQL 8.0 之前没有窗口函数,老项目里如果还在用 5.7,需要改成用户变量模拟累加,或者先做自连接。核心思想仍然一样:构造一个随连续段变化的差值。
- 不同数据库对日期加减的函数名不同,比如 MySQL 用
DATE_SUB/DATE_ADD,SQL Server 用DATEADD。如果只把“差值”当作整数分组键,就可以完全避开日期函数,这也是我喜欢这种写法的原因之一。
4.4 真实数据没有题目承诺的那么干净:先压平再编号
题目保证两张表日期不重叠、每天恰好一条,但真实业务流里常常不是这样。比如一张“系统状态变更流水表”,一天可能记录三次状态切换,既有成功又有失败。如果直接拿流水表跑上面的 SQL,同一天会有多行,窗口函数的编号彻底乱掉,连续区间也会被切成碎片。
正确做法是先做“日粒度折叠”:按日期分组,取出当天的最终状态(或者业务上定义的快照状态),得到一张干净的“每日状态表”,然后再套用窗口函数。这一步很关键,可以说它才是真实报表里真正的重点。我遇到过不少同事,把刷题逻辑背得很熟,结果一遇到流水表就崩,原因就是漏了“压平”预处理。
5. 这套解法能迁移到哪些场景:从系统状态到用户行为分析
5.1 变体一:统计每个状态最长的一段连续日期
理解了分段原理后,所有后续统计都变成了在“段”上的普通聚合。想找“最长连续失败天数”,可以在分组结果上再加一层:
SELECT period_state, MAX(continuous_days) AS max_continuous_days FROM ( SELECT period_state, DATEDIFF(MAX(event_date), MIN(event_date)) + 1 AS continuous_days FROM numbered GROUP BY period_state, rn_all - rn_state ) t GROUP BY period_state;这里DATEDIFF的方言差异比较大,MySQL 里DATEDIFF(MAX, MIN)返回天数差;SQL Server 里要写成DATEDIFF(day, MIN, MAX)。加 1 是因为首尾都要算进连续天数,比如 1 号和 2 号相差 1 天,但实际连续 2 天。
5.2 变体二:只有一张流水表时,统计每个用户的连续登录天数
这是最常被问到的变形。假设有一张user_login(user_id, login_date)表,每个用户每天可能登录多次,也可能漏登。先压平成“每个用户每天一条登录记录”,然后用同一个思路,只不过分区变成用户和状态标记:
SELECT user_id, MIN(login_date) AS start_date, MAX(login_date) AS end_date, COUNT(*) AS consecutive_days FROM ( SELECT user_id, login_date, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY login_date) AS rn_user, ROW_NUMBER() OVER (PARTITION BY user_id, is_login ORDER BY login_date) AS rn_flag FROM daily_login ) t GROUP BY user_id, rn_user - rn_flag;这里的关键是is_login这个标记列。因为每日登录表已经只有登录记录,没有一个“未登录”的显式状态,所以更常见的做法是:只按用户分区编号,然后用login_date - rn_user做锚点。连续登录的日期,日期和编号同步增加,相减后锚点相同;一旦某天没登录,日期跳变但编号只加 1,锚点右移,自然地分出新段。这个写法和 2.4 里说的“状态内部编号 + 日期减法”本质一致。
5.3 变体三:连续 N 次以上才算有效区间的等级化判定
业务上经常有“连续签到 7 天发奖励”“连续失败超过 3 天触发告警”的规则。这只需要在分组聚合后加一个HAVING:
SELECT period_state, MIN(event_date) AS start_date, MAX(event_date) AS end_date, COUNT(*) AS days FROM numbered GROUP BY period_state, rn_all - rn_state HAVING COUNT(*) >= 3;比如“连续失败超过 3 天”就取period_state = 'failed'且HAVING COUNT(*) >= 3。段编号的思想完全不变,过滤条件随意加。
5.4 面试里最常被追问的点:为什么先要把时间线压平
很多面试官顺着这道题会继续问:同一个用户一天登录三次怎么办?系统一天内有多次状态切换怎么办?这些追问的核心都在考察一件事——你清不清楚数据粒度。窗口函数、GROUP BY都只是工具,真正决定结果正确与否的,是你在编号前有没有把时间线整理成“每天一行”的稳定粒度。
我的经验是,遇到所有“连续区间”类问题,先按下面的顺序自查一遍:
- 记录能不能折叠成“时间 + 状态”的日粒度?
- 同一时间、同一状态会不会有多条重复记录?
- 连续的定义是什么:日期连续,还是业务上的自然日连续?
- 空表、单行数据、单日区间这些边界有没有验证?
这四件事都想清楚,再落 SQL 就不容易出偏差。
我个人在这道题上最大的收获,还不是记住了这个 SQL 模板,而是养成了先画“时间线”再写代码的习惯。以前遇到连续在线、连续登录、持续故障这些需求,我第一反应是写递归查询,或者在应用层循环遍历;现在反而会先停下来想:这些记录能不能被折叠成一条干净的每日状态线?如果能,后面就是两个ROW_NUMBER()加一个GROUP BY的事。这道题好就好在它把连续区间问题浓缩得很干净,不炫技,却把窗口函数、CTE、分组思想全串起来了。
多说一句:SQL 里的连续问题,本质上都是“给连续段找一个稳定身份”。你找到了这个身份,后面所有统计都水到渠成。看完文章如果觉得理解了,一定自己建一张表把示例数据跑一遍——跑通一次,比盯着题解看十遍都管用。