前几天一个做内容平台的朋友跑来问我:文章在后台攒了几十条操作流水,怎么用 MySQL 窗口函数根据这些连续消耗记录,准确判断出文章的最终状态?我看了下他们现在的查询 SQL,清一色的 ORDER BY consume_time DESC LIMIT 1,看着简单,遇到状态回退、生命周期重开、操作回跳这些场景,结论就全错了。这篇文章就从一个完整案例出发,把这类“连续消耗判断最终状态”的问题彻底拆开来讲。
要先把“连续消耗”这个词拆明白。文章从创建到下线,中间每一次编辑、提交审核、驳回、发布、归档,都会在状态变更表里留下一条记录。这些记录按时间排成一串,前一后的状态变化如果符合业务上定义的合法流转,就构成一次连续的消耗链路。反过来,如果某条记录突然从“已发布”直接跳到“编辑中”,或者文章被归档后再次创建,就说明前一个生命周期已经结束,后面开启了新的一段。我们要判断的“最终状态”,不是简单取时间上最后那条记录,而是取最后一个连续生命周期内的最终落点。
为什么这个需求用窗口函数最合适?因为窗口函数能同时保留每一行明细,又能在行与行之间做跨行计算,正好对应了“给连续流水加上下文”的核心诉求。换成 GROUP BY 自连接,或者多层子查询,代码会膨胀到难以维护,而且一旦状态机规则调整,改起来就是一场灾难。
下面我会从数据模型、状态机规则、完整 SQL 实现、性能优化,以及如何把同一套思路迁移到签到、订单、库存等场景,逐一展开。
1. 业务本质:文章状态流水里的“连续”和“断点”
1.1 先看一个典型的状态流水
假设一张 article_consume_log 表,记录了文章每一次被操作的事件。字段通常包含:主键 ID、文章 ID、操作类型、操作时间、操作人、备注。操作类型就是动作本身:创建、编辑、提交审核、驳回、发布、下线、归档。
下面是一篇真实文章中可能出现的一串记录:
| id | article_id | action_type | consume_time |
|---|---|---|---|
| 1 | 1001 | CREATE | 2024-01-01 10:00 |
| 2 | 1001 | SUBMIT | 2024-01-01 11:00 |
| 3 | 1001 | REJECT | 2024-01-02 09:00 |
| 4 | 1001 | EDIT | 2024-01-02 15:00 |
| 5 | 1001 | SUBMIT | 2024-01-03 09:30 |
| 6 | 1001 | PUBLISH | 2024-01-04 08:00 |
| 7 | 1001 | OFFLINE | 2024-01-10 18:00 |
这段流水的时间线非常干净:创建后提交,被驳回后编辑,再提交,最终发布,发布后下线。每一步之间都有合法流转关系,整串记录构成一个连续的“生命周期”。这篇文章的最终状态就是 OFFLINE,也就是“已下线”。
这里比较坑的是:如果只看最后一条记录,状态确实也是 OFFLINE,好像没啥问题。但换个场景就不一样了。
1.2 只看最后一条,什么时候会出错
再看一篇文章的状态记录:
| id | article_id | action_type | consume_time |
|---|---|---|---|
| 8 | 1002 | CREATE | 2024-01-01 10:00 |
| 9 | 1002 | SUBMIT | 2024-01-01 11:00 |
| 10 | 1002 | PUBLISH | 2024-01-02 08:00 |
| 11 | 1002 | OFFLINE | 2024-01-05 18:00 |
| 12 | 1002 | EDIT | 2024-01-06 09:00 |
| 13 | 1002 | SUBMIT | 2024-01-06 10:00 |
| 14 | 1002 | PUBLISH | 2024-01-07 08:30 |
如果把“最终状态”定义为“最后一次操作后的状态”,结果是 PUBLISH(已发布),这没问题。但如果业务方问的是“这篇文章当前所处的生命周期是什么状态”,答案就微妙了:它先经历了发布、下线,后来又重新编辑、重新提交、重新发布。整段历史跨越了两个生命周期:第一个周期止于 OFFLINE,第二个周期结束于 PUBLISH。
在这类需求里,“连续”这两个字表达的是:只有当前一个状态到后一个状态之间存在合法流转关系,这两条记录才属于“同一段连续消耗”。一旦出现断点,比如 OFFLINE 后面跟着 EDIT,其实已经拉开了新一段生命周期。此时如果继续用“最后一条记录”来判断,虽然碰巧也能得到 PUBLISH,但如果在第二段生命周期刚开了个头、状态还很初级时,结论就会完全不同。比如下面这种情况:
| id | article_id | action_type | consume_time |
|---|---|---|---|
| 15 | 1003 | CREATE | 2024-01-01 10:00 |
| 16 | 1003 | SUBMIT | 2024-01-01 11:00 |
| 17 | 1003 | PUBLISH | 2024-01-02 08:00 |
| 18 | 1003 | ARCHIVE | 2024-01-03 09:00 |
| 19 | 1003 | CREATE | 2024-01-05 10:00 |
如果直接取最后一条,状态是 CREATE,文章看起来只是“刚创建”。但实际上,这篇文章之前已经被发布过又被归档,业务想要判断的“最终状态”,可能更关心“这篇文章最后一个完整生命周期是否成功发布过”,而不是“当前刚好停在哪个中间态”。这两种口径下,最后一条记录的判断都是不够的。
1.3 用“状态机”定义连续关系
要让计算机理解“连续关系”,就得把合法的状态流转规则定义出来。这是整篇文章里最重要的建模步骤。我把常见规则整理成一张状态迁移表:
| 当前状态 | 允许流向的下一个状态 |
|---|---|
| CREATE | EDIT, SUBMIT |
| EDIT | EDIT, SUBMIT |
| SUBMIT | REJECT, PUBLISH |
| REJECT | EDIT, SUBMIT |
| PUBLISH | OFFLINE, ARCHIVE |
| OFFLINE | EDIT, PUBLISH |
| ARCHIVE | CREATE |
表中没有列出的流转,比如 PUBLISH -> EDIT、REJECT -> PUBLISH,都属于非法跳转。一旦出现非法跳转,说明业务上出现了人工调整、时间补偿,或者生命周期重新开始。我们把这种位置标记为“断点”,后面从断点开始强制开启新的一段连续消耗。
这样一来,“根据连续消耗判断文章最终状态”的问题,就变成了:先给每一行流水打上“是否与前一行构成合法连续”的标记,再对连续的记录进行分段,最后找到最后一个分段,取其末端状态。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 为什么窗口函数是这一类问题的最优解
2.1 不用窗口函数的老办法有多痛苦
如果不会窗口函数,遇到这种跨行比较的需求,通常有几种土办法。
第一种是自连接。把表和自己按文章 ID 连接,再通过时间排序选前一条,但两条记录不一定相邻,中间可能隔着别的操作,所以在自连接之前还得用子查询算行号。一旦记录量大,这种写法性能很差,而且代码很难看。
第二种是各类子查询嵌套。比如用一个子查询去取“当前行之前最近的那一行”,再判断是否连续。这个方案逻辑上可行,但每多一个判断条件,就要多套一层子查询,整个 SQL 要拖出几百行,后续维护的人看着就头疼。
第三种是 MySQL 5.7 时代常用的用户变量模拟。思路是用 @prev_action、@curr_article_id 之类的变量在一次扫描中逐行记录状态。能跑,但用户变量的赋值顺序在 MySQL 中非常容易踩坑,SELECT 子句的求值顺序不是严格从左到右,一不小心就出现“上一条还没赋值,下一条已经读走了”的问题。调试成本很高。
2.2 窗口函数正好弥补了这三个痛点
MySQL 8.0 引入窗口函数后,这类“跨行计算”有了标准解法。窗口函数有几个核心特点:
- 不改变结果集的行数,每一行在计算结果中仍然保留。
- 可以在每一行上同时看到它所属分组内的聚合结果,也能看到前一行、后一行的值。
- 支持
PARTITION BY分组,ORDER BY排序,ROWS/RANGE定义窗口范围。
对应到我们的需求上:
- 用
ROW_NUMBER()给每篇文章的每次消耗按时间打上序号。 - 用
LAG()取出当前记录的前一条记录的action_type,用来判断是否合法连续。 - 用
SUM() OVER()配合CASE WHEN,每当遇到一个断点就加 1,给所有记录分段。 - 最后再用一次
ROW_NUMBER()或者MAX(),从分段结果里取出最后一个分段的最后一条记录。
这种“排序 + 看相邻 + 打标分段 + 取端”的四步流程,几乎能通吃所有“连续状态判断”类需求。比起自连接和层层子查询,可读性和维护性都高出不止一个量级。
2.3 一个核心思维转变:把表想象成时间线
写窗口函数 SQL 之前,我先在纸上把每篇文章的流水按时间画成一条线,从左到右标上状态。然后对着状态机规则,在状态跳转不正常的地方画一道竖线,把时间线切成几段。最后标出最后一个分段。
这个画图的过程,就是“连续消耗”模型的抽象过程。窗口函数干的事情,本质上就是把这张纸上的思考变成可以执行的 SQL。
3. 完整 SQL 实现:从建表到算出最终状态
3.1 建表:状态流水表设计
如果业务里还没有这张表,建表建议这样设计:
sql复制CREATE TABLE article_consume_log (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
article_id BIGINT NOT NULL,
action_type VARCHAR(32) NOT NULL COMMENT 'CREATE/EDIT/SUBMIT/REJECT/PUBLISH/OFFLINE/ARCHIVE',
consume_time DATETIME NOT NULL,
operator_id BIGINT DEFAULT NULL,
remark VARCHAR(255) DEFAULT NULL,
KEY idx_article_time (article_id, consume_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
索引一定要建 (article_id, consume_time) 的联合索引。后面所有窗口函数的 PARTITION BY article_id 和 ORDER BY consume_time,都能充分利用这个索引,避免不必要的排序。
这里要额外提醒一点:如果同一篇文章在同一秒内可能产生多条操作记录,consume_time 就不是唯一键,窗口函数的排序结果会不稳定。我习惯在窗口函数的 ORDER BY 后面再加一个 id,也就是 ORDER BY consume_time, id,确保排序完全确定。
3.2 造几条测试数据
用一个 INSERT 把刚才提到的几种场景插进去:
sql复制INSERT INTO article_consume_log (article_id, action_type, consume_time, operator_id) VALUES
(1001, 'CREATE', '2024-01-01 10:00:00', 1),
(1001, 'SUBMIT', '2024-01-01 11:00:00', 2),
(1001, 'REJECT', '2024-01-02 09:00:00', 3),
(1001, 'EDIT', '2024-01-02 15:00:00', 4),
(1001, 'SUBMIT', '2024-01-03 09:30:00', 2),
(1001, 'PUBLISH', '2024-01-04 08:00:00', 5),
(1001, 'OFFLINE', '2024-01-10 18:00:00', 5),
(1002, 'CREATE', '2024-01-01 10:00:00', 1),
(1002, 'SUBMIT', '2024-01-01 11:00:00', 2),
(1002, 'PUBLISH', '2024-01-02 08:00:00', 5),
(1002, 'OFFLINE', '2024-01-05 18:00:00', 5),
(1002, 'EDIT', '2024-01-06 09:00:00', 4),
(1002, 'SUBMIT', '2024-01-06 10:00:00', 2),
(1002, 'PUBLISH', '2024-01-07 08:30:00', 5),
(1003, 'CREATE', '2024-01-01 10:00:00', 1),
(1003, 'SUBMIT', '2024-01-01 11:00:00', 2),
(1003, 'PUBLISH', '2024-01-02 08:00:00', 5),
(1003, 'ARCHIVE', '2024-01-03 09:00:00', 5),
(1003, 'CREATE', '2024-01-05 10:00:00', 1);
其中:
- 1001 是一段完整生命周期,最终状态
OFFLINE。 - 1002 是两段生命周期,第二段结束于
PUBLISH。 - 1003 是两段生命周期,第二段刚创建,按“最后生命周期末端”口径,最终状态是
CREATE;但如果业务要的是“最后一次发布过什么”,结果就会不同。这里我们统一按“最后一个连续生命周期末端状态”来算。
3.3 第一步:打上邻接上下文
先写一个 CTE,把每行的前一条 action_type 取出来:
sql复制WITH base AS (
SELECT
id,
article_id,
action_type,
consume_time,
LAG(action_type) OVER (
PARTITION BY article_id
ORDER BY consume_time, id
) AS prev_action
FROM article_consume_log
)
SELECT * FROM base ORDER BY article_id, consume_time, id;
LAG(action_type) 表示当前行按 (article_id, consume_time, id) 排序后,前面一行的 action_type。对每个文章分组的第一条记录来说,prev_action 是 NULL。这一步就把原来的流水变成了一张带上下文信息的宽表。
3.4 第二步:识别断点,生成分段号
有了 prev_action,接下来把状态机规则翻译成一条 CASE 表达式。规则里能合法流转的,标记为 1;断点和每组第一条标记为 0:
sql复制WITH base AS (
SELECT
id,
article_id,
action_type,
consume_time,
LAG(action_type) OVER (
PARTITION BY article_id
ORDER BY consume_time, id
) AS prev_action
FROM article_consume_log
),
flagged AS (
SELECT
id,
article_id,
action_type,
consume_time,
CASE
WHEN prev_action IS NULL THEN 0
WHEN prev_action = 'CREATE' AND action_type IN ('EDIT', 'SUBMIT') THEN 1
WHEN prev_action = 'EDIT' AND action_type IN ('EDIT', 'SUBMIT') THEN 1
WHEN prev_action = 'SUBMIT' AND action_type IN ('REJECT', 'PUBLISH') THEN 1
WHEN prev_action = 'REJECT' AND action_type IN ('EDIT', 'SUBMIT') THEN 1
WHEN prev_action = 'PUBLISH' AND action_type IN ('OFFLINE', 'ARCHIVE') THEN 1
WHEN prev_action = 'OFFLINE' AND action_type IN ('EDIT', 'PUBLISH') THEN 1
WHEN prev_action = 'ARCHIVE' AND action_type = 'CREATE' THEN 1
ELSE 0
END AS is_continuation
FROM base
)
SELECT * FROM flagged ORDER BY article_id, consume_time, id;
这里的核心思路是:is_continuation = 1 表示“我和前一条记录在同一个连续生命周期内”,is_continuation = 0 表示“这里断开了,我是新周期起点”。
有了这个标记,分段号就很容易算:
sql复制WITH base AS (...),
flagged AS (...),
segmented AS (
SELECT
id,
article_id,
action_type,
consume_time,
SUM(CASE WHEN is_continuation = 1 THEN 0 ELSE 1 END) OVER (
PARTITION BY article_id
ORDER BY consume_time, id
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS seg_no
FROM flagged
)
SELECT * FROM segmented ORDER BY article_id, seg_no, consume_time, id;
SUM(CASE WHEN is_continuation = 1 THEN 0 ELSE 1 END) OVER (...) 是一个累计求和:遇到断点时累加 1,遇到连续时累加 0。这样每一个新的生命周期,seg_no 就自动加 1。这就是窗口函数的“累计分组”技巧,跑出来的结果非常直观:
| article_id | action_type | consume_time | seg_no |
|---|---|---|---|
| 1001 | CREATE | 2024-01-01 10:00:00 | 1 |
| 1001 | SUBMIT | 2024-01-01 11:00:00 | 1 |
| 1001 | REJECT | 2024-01-02 09:00:00 | 1 |
| 1001 | EDIT | 2024-01-02 15:00:00 | 1 |
| 1001 | SUBMIT | 2024-01-03 09:30:00 | 1 |
| 1001 | PUBLISH | 2024-01-04 08:00:00 | 1 |
| 1001 | OFFLINE | 2024-01-10 18:00:00 | 1 |
| 1002 | CREATE | 2024-01-01 10:00:00 | 1 |
| 1002 | SUBMIT | 2024-01-01 11:00:00 | 1 |
| 1002 | PUBLISH | 2024-01-02 08:00:00 | 1 |
| 1002 | OFFLINE | 2024-01-05 18:00:00 | 1 |
| 1002 | EDIT | 2024-01-06 09:00:00 | 2 |
| 1002 | SUBMIT | 2024-01-06 10:00:00 | 2 |
| 1002 | PUBLISH | 2024-01-07 08:30:00 | 2 |
| 1003 | CREATE | 2024-01-01 10:00:00 | 1 |
| 1003 | SUBMIT | 2024-01-01 11:00:00 | 1 |
| 1003 | PUBLISH | 2024-01-02 08:00:00 | 1 |
| 1003 | ARCHIVE | 2024-01-03 09:00:00 | 1 |
| 1003 | CREATE | 2024-01-05 10:00:00 | 2 |
1002 的第二段是 EDIT -> SUBMIT -> PUBLISH,1003 的第二段是 CREATE,分段完全正确。
3.5 第三步:取最后一个分段的末端状态
分段完成之后,问题就很简单了。对每篇文章,先找到最大的 seg_no,再在这个分段里找到最后一条记录。
把整个 SQL 串起来:
sql复制WITH base AS (
SELECT
id,
article_id,
action_type,
consume_time,
LAG(action_type) OVER (
PARTITION BY article_id
ORDER BY consume_time, id
) AS prev_action
FROM article_consume_log
),
flagged AS (
SELECT
id,
article_id,
action_type,
consume_time,
CASE
WHEN prev_action IS NULL THEN 0
WHEN prev_action = 'CREATE' AND action_type IN ('EDIT', 'SUBMIT') THEN 1
WHEN prev_action = 'EDIT' AND action_type IN ('EDIT', 'SUBMIT') THEN 1
WHEN prev_action = 'SUBMIT' AND action_type IN ('REJECT', 'PUBLISH') THEN 1
WHEN prev_action = 'REJECT' AND action_type IN ('EDIT', 'SUBMIT') THEN 1
WHEN prev_action = 'PUBLISH' AND action_type IN ('OFFLINE', 'ARCHIVE') THEN 1
WHEN prev_action = 'OFFLINE' AND action_type IN ('EDIT', 'PUBLISH') THEN 1
WHEN prev_action = 'ARCHIVE' AND action_type = 'CREATE' THEN 1
ELSE 0
END AS is_continuation
FROM base
),
segmented AS (
SELECT
id,
article_id,
action_type,
consume_time,
SUM(CASE WHEN is_continuation = 1 THEN 0 ELSE 1 END) OVER (
PARTITION BY article_id
ORDER BY consume_time, id
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS seg_no
FROM flagged
),
ranked AS (
SELECT
article_id,
action_type,
consume_time,
ROW_NUMBER() OVER (
PARTITION BY article_id
ORDER BY seg_no DESC, consume_time DESC, id DESC
) AS rn
FROM segmented
)
SELECT
article_id,
action_type AS final_status,
consume_time AS final_time
FROM ranked
WHERE rn = 1
ORDER BY article_id;
结果如下:
| article_id | final_status | final_time |
|---|---|---|
| 1001 | OFFLINE | 2024-01-10 18:00:00 |
| 1002 | PUBLISH | 2024-01-07 08:30:00 |
| 1003 | CREATE | 2024-01-05 10:00:00 |
这个结果和业务预期完全一致。1001 最终落点是“已下线”,1002 最终落点是“已发布”,1003 因为归档后重新创建,最终状态落到新周期的“已创建”。
这里有一个值得注意的细节:最终落点的排序逻辑是 seg_no DESC 优先,而不是 consume_time DESC 优先。这一步恰恰体现了“连续生命周期”和“纯时间线”的差异。假如不考虑生命周期,1003 的最后一条就是 CREATE,这没问题;但假如某篇文章在最新周期里有一条非法跳转的记录,纯时间线取末条就会取到中间态,而按分段取末条能取到当前周期真正结束时的状态。
3.6 如果要把中间结果查出来验证
写复杂 SQL 时,我强烈建议分步验证,不要一条 SQL 写完就以为万事大吉。先用 base 的 CTE 单独跑,看 prev_action 是否正确;再用 flagged 跑,看 is_continuation 是否符合状态机规则;最后再用 segmented 跑,看分段号是否符合预期。
MySQL 8.0 的 CTE 可以让我们把每段逻辑分隔得很清楚。如果排查问题,只要把最终 SQL 里的 CTE 一层层改成普通子查询依次执行,就很容易定位到底是哪一步出了问题。
3.7 从明细到最终状态的通用套件
这套 SQL 里真正可复用的,不是某个表、某个状态机,而是这个处理框架:
- 用
LAG()取出前一行上下文。 - 用
CASE WHEN判断当前行和前一行是否属于同一段连续区间。 - 用
SUM() OVER()累计生成分段号。 - 用
ROW_NUMBER()或MAX()取最后一个分段的最后一行。
在后面的扩展场景里,你会发现换汤不换药,核心逻辑完全相同。
4. 实战中避不开的坑:窗口函数在 MySQL 里的边界
4.1 版本限制是个硬门槛
窗口函数是 MySQL 8.0 才正式引入的。如果线上还是 5.7 或更早的版本,上面整套写法直接跑不起来。遇到这种情况,要么推动升级 MySQL,要么就得用用户变量模拟。
用户变量模拟的核心思路是:在 SELECT 里逐行维护状态。下面是一个简化版的示例:
sql复制SELECT
id,
article_id,
action_type,
consume_time,
@rn := IF(@curr_article = article_id, @rn + 1, 1) AS seq,
@prev_article := article_id
FROM article_consume_log
CROSS JOIN (SELECT @curr_article := NULL, @rn := 0) vars
ORDER BY article_id, consume_time, id;
不过用户变量有一个非常隐蔽的坑:MySQL 对 SELECT 子句中变量赋值的求值顺序并没有严格保证,同一个 SELECT 里读变量和写变量的顺序可能因为优化器的执行计划变化而改变。所以用变量模拟行号、模拟 LAG,都可能出现结果不确定的情况。我在 5.7 上踩过几次坑之后,基本不再推荐这种写法,除非只是临时应急。
4.2 排序字段不唯一导致行号漂移
窗口函数的 ORDER BY 如果只写 consume_time,而同一篇文章在同一秒内又产生了多条记录,那么 ROW_NUMBER() 的分配顺序是随机的,MySQL 不保证稳定排序。这样同一个数据,每次执行可能得到不同的行号,后面所有依赖行号的计算都会受影响。
解决办法很简单,排序里追加唯一字段:
sql复制ROW_NUMBER() OVER (
PARTITION BY article_id
ORDER BY consume_time, id
)
同理,所有用到 LAG()、SUM() OVER() 的地方,只要涉及排序,都建议把 id 带上,保证顺序完全确定。这也是我在前面 SQL 里一直坚持写 consume_time, id 的原因。
4.3 窗口函数不会减少行数,别把大表全量跑
窗口函数有一个特性和 GROUP BY 完全不同:它不会合并行,每行输入都会产生一行输出。所以如果一张很大的流水表,你用窗口函数全表跑了,它会扫描并排序所有符合条件的数据,性能可能非常差。
优化思路有几个层面:
- 尽量在进入窗口函数之前,先用 WHERE 过滤掉无关数据。比如只需要查最近一年、最近一个季度的数据,就先过滤时间范围。
- 确保
(article_id, consume_time)上有联合索引。窗口函数的PARTITION BY article_id和ORDER BY consume_time能借助索引减少文件排序的开销。 - 如果只是取每个文章的最新一条记录,不需要判断连续关系,那其实没必要用窗口函数。简单的
GROUP BY article_id配合MAX(consume_time)再 JOIN 回原表,可能更快。窗口函数的优势在于“需要跨行判断”时,而不是所有“取每组最新”的场景都适合。
4.4 CTE 物化可能导致临时表膨胀
MySQL 8.0 的 CTE 在某些情况下会被物化。如果中间结果集非常大,比如几百万行,多次 CTE 嵌套可能产生很大的临时表,内存放不下就会落到磁盘,拖慢整个查询。
我的经验是:先用 WHERE 把范围缩小到真正需要分析的记录,再交给 CTE。如果 CTE 仍然很重,可以改为把中间结果物化到临时表,建好索引,再分段查询。这样虽然多写了几条 SQL,但在数据量上来之后,执行效率和可维护性都要好得多。
4.5 状态机规则变化时要留好扩展位
文章状态流转规则不是一成不变的。比如将来业务新增了一个 AUDITING(人工审核中)的状态,SQL 里的 CASE 表达式就要同步扩展。为了让扩展方便,不建议把状态机规则硬编码在很深的子查询里,而是集中放在一段 CTE 中,并加上清晰注释。这样后面维护的人打开 SQL,一眼就能看到完整规则,不用从几百行代码里慢慢翻。
如果状态规则复杂到一张表都放不下,也可以额外建一张 state_transition_rule 表,用 JOIN 代替 CASE WHEN。规则表维护起来更方便,SQL 也更稳定。缺点是查询时多一次 JOIN。业务状态很多、规则变更频繁时,推荐用规则表方案。
4.6 警惕最后一个分段的“长度”
最后一个分段可能只有一条记录,也可能有几十条记录。如果业务上对“最终状态”还有额外含义,比如要求“最后一个分段必须至少包含一次 PUBLISH,否则视为异常”,就需要在分段结果基础上再做一次聚合判断。这里同样可以继续用窗口函数,比如对每个分段用 COUNT(*) 统计记录数,用 SUM(CASE WHEN action_type = 'PUBLISH' THEN 1 ELSE 0 END) 统计是否发布过。
把 seg_no 看出新的分组键,这一步就可以像普通 GROUP BY 一样处理了。
5. 这套“连续消耗”模型还能迁移到哪些场景
5.1 用户连续签到/连续活跃天数
用户签到表一般长这样:user_id, login_date。求“用户当前连续签到天数”,或者“最近一次连续签到是否中断”,本质上就是同一类问题。
先按用户分组,用 LAG(login_date) 看前一次签到日期。如果 DATEDIFF(login_date, prev_login_date) = 1,说明连续;否则断点。用 SUM 累计生成签到分组,最后取最大分组的行数即可。
sql复制WITH base AS (
SELECT
user_id,
login_date,
LAG(login_date) OVER (
PARTITION BY user_id ORDER BY login_date
) AS prev_login_date
FROM login_log
),
flagged AS (
SELECT
user_id,
login_date,
CASE
WHEN prev_login_date IS NULL THEN 0
WHEN DATEDIFF(login_date, prev_login_date) = 1 THEN 1
ELSE 0
END AS is_continuous
FROM base
),
segmented AS (
SELECT
user_id,
login_date,
SUM(CASE WHEN is_continuous = 1 THEN 0 ELSE 1 END) OVER (
PARTITION BY user_id ORDER BY login_date
) AS seg_no
FROM flagged
)
SELECT user_id, COUNT(*) AS current_streak
FROM segmented
WHERE seg_no = (
SELECT MAX(seg_no) FROM segmented s2 WHERE s2.user_id = segmented.user_id
)
GROUP BY user_id;
这里唯一的变化是“合法性判断”从状态机规则换成了日期差是否为 1。框架完全一致。
5.2 订单状态流转:判断订单是否被异常回退
订单表的状态流转一般是:待支付 -> 已支付 -> 已发货 -> 已完成,中间可能穿插已取消、退款中、已退款。如果某个订单中间出现了非法跳转,比如“已完成”之后又变成“待支付”,大概率是系统补偿或人工干预。这种场景也可以用同样的分段方式定位异常点。
只需要把 article_consume_log 换成 order_status_log,把状态机规则换成订单状态流转规则,再加一个 CASE WHEN 标记非法跳转,就能很快找出所有异常订单,或者准确判断每个订单当前处于哪个合法生命周期。
5.3 流量包/积分的连续消耗和到期重置
用户手里的流量包,每次扣减一条流水。流量包到期后会重新分配额度,这可以看作一次生命周期重置。要判断“当前流量包是否耗尽”,不能从创建日开始算,而应该从最后一次“重置”之后开始算。
在这个场景里,重置动作就是断点。用 SUM() OVER() 给每次重置后的流水分段,然后计算最后一个分段的累加消耗量,再和最新分配额度对比。思路和文章最终状态判断完全一致,只是把 action_type 换成了 consume_amount,把“取最后一条状态”变成了“统计最后一段的金额总和”。
5.4 一个更通用的表达
无论场景怎么变,核心都是同一条线:找到“断点” -> 分段 -> 取最后一段 -> 对最后一段做判断。这也是窗口函数在业务分析里最有价值的地方之一。与其为每一个新需求单独写一个复杂 SQL,不如先把数据组织成“带分段的宽表”,后面不管是取状态、算累计、算天数,都是轻量动作。
6. 我的实操建议:先物化中间结果,再逐步收敛
第一次写这种连续判断 SQL 时,别追求一条 SQL 从头到尾一步到位。我的习惯是分三步走:
第一步,先写一个中间查询,把 LAG() 算好的 prev_action 和 CASE 判断的 is_continuation 一起查出来,放到一张临时表里。然后肉眼检查几行数据,确认断点标记都正确。
第二步,在临时表上继续加累计分段逻辑,算出 seg_no,再次肉眼检查分段号是否符合预期。特别关注那些跨生命周期切换的文章,确认断点位置没有错。
第三步,最后用一次 ROW_NUMBER() 或 MAX() 取出最终结果。这时候因为中间结果已经被验证过,最终结论基本不会跑偏。
这套方法看起来多写了几条 SQL,实际上比直接写一个巨型 CTE 更省时间。因为窗口函数 SQL 的错误往往发生在中间某一步的规则判断上,如果直接跑最终 SQL,看到错误结果也很难定位,排查成本反而更高。
最后再分享一个小技巧:状态机规则里的 CASE WHEN,尽量把注释写在旁边,例如“PUBLISH 之后只能下线或归档”。代码块多几行注释,后面自己回来维护时能省下大量回忆时间。
MySQL 窗口函数的优势,就是让你可以像处理有序列表一样处理数据库里的行。遇到“连续消耗”“连续状态”“生命周期”这类关键字,先画时间线,再找断点,然后用窗口函数分段。这套思路吃透了,不管文章状态、订单状态,还是用户活跃、库存消耗,都能快速复用。
