结论先行:在 800 万行用户行为日志上计算「连续活跃 ≥ N 天」标签,窗口函数(LAG + 行号差值法)P99 = 2.1s,自连接 P99 = 25.3s,临时表迭代 P99 = 18.7s——12 倍和 9 倍差距。但窗口函数方案有一个反直觉的陷阱:MySQL 8.0 会在 CTE 物化阶段把 800 万行全部拉进 tmp 表,Buffer Pool 命中率从 99.6% 掉到 87.2%,如果同时有在线点查,P99 会被连带打高 4 倍。下面给完整对比、执行计划、可复现的 benchmark 脚本和取舍判断框架。
一、现象与背景:运营要一个标签,三种写法差了 12 倍
环境:
- MySQL 8.0.36(主 16C64G,NVMe Intel P4510 3.84TB,
innodb_buffer_pool_size=48G,sort_buffer_size=2M,tmp_table_size=512M,max_heap_table_size=512M) - 核心表
t_user_activity:807 万行、单行 48B(含user_id BIGINT, act_date DATE, action_type VARCHAR(16)),InnoDB,已有索引idx_uid_date (user_id, act_date) - 需求:找出「连续活跃天数 ≥ 7 天」的用户(活跃 = 当天有 ≥ 1 条
action_type IN ('login','order','pay')的记录),输出user_id, max_streak_days - 在线业务:同一实例承载用户点查(主键 + 二级索引),QPS ~2000,基线 P99 = 0.8ms
运营催得急,数据组三个同学各写了一版,跑在同一实例上(innodb_flush_log_at_trx_commit=1,无特殊优化),结果:
| 方案 | P50 | P99 | 峰值 Buffer Pool 命中 | 在线点查 P99 连带影响 | |------|-----|-----|----------------------|---------------------| | 窗口函数(LAG) | 1.8s | 2.1s | 87.2%(执行期间) | 0.8ms → 3.4ms | | 自连接(gap 检测) | 22.1s | 25.3s | 99.4%(保持) | 0.8ms → 1.1ms | | 临时表迭代(7 轮) | 16.4s | 18.7s | 94.1%(执行期间) | 0.8ms → 2.2ms |
注意最后一列:窗口函数虽然总耗时最短,但它把在线点查 P99 从 0.8ms 打到 3.4ms——这是因为 CTE 物化阶段一次性把 800 万行拉进内存临时表,冲刷了 Buffer Pool 的 hot page。这个代价在「报表窗口(凌晨 2-5 点)」可接受,在「白天在线高峰」不可接受。
二、原理:三种写法的执行计划差异
窗口函数(LAG + 行号差值法)
核心思路:给每个 (user_id, act_date) 分配行号,用 act_date - row_number 的差值是否连续来判断 streak。
WITH ranked AS (
SELECT user_id, act_date,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY act_date) AS rn
FROM (
SELECT DISTINCT user_id, act_date
FROM t_user_activity
WHERE action_type IN ('login','order','pay')
AND act_date >= DATE_SUB(CURDATE(), INTERVAL 90 DAY)
) d
),
grouped AS (
SELECT user_id, act_date, rn,
DATE_SUB(act_date, INTERVAL rn DAY) AS grp,
ROW_NUMBER() OVER (PARTITION BY user_id, DATE_SUB(act_date, INTERVAL rn DAY) ORDER BY act_date) AS streak_len
FROM ranked
)
SELECT user_id, MAX(streak_len) AS max_streak_days
FROM grouped
GROUP BY user_id
HAVING MAX(streak_len) >= 7;
执行计划关键点(EXPLAIN FORMAT=JSON):
- 最内层
DISTINCT user_id, act_date→ 走idx_uid_date的 index range scan,命中 ~210 万行(807 万行中 ~26% 是目标 action_type) - CTE
ranked被物化为内存临时表(Using temporary),210 万行 × 24B ≈ 48MB,tmp_table_size=512M装得下 - 窗口函数
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY act_date)→ 在临时表上做 partition sort(Using filesort),sort buffer 2M 装不下 48MB → 溢出到磁盘临时表(Sort: temporary table on disk) - 第二层 CTE
grouped再次物化 → 又一次 210 万行临时表 - 最终 GROUP BY + HAVING → 又一次全量扫描
根因:MySQL 8.0 的窗口函数实现是「物化 → 排序 → 流式计算」,不支持 streaming window。800 万行(去重后 210 万行)的 materialize + 两次 filesort 是主要代价。
自连接(gap 检测)
SELECT a.user_id, COUNT(*) AS max_streak
FROM (
SELECT DISTINCT user_id, act_date
FROM t_user_activity
WHERE action_type IN ('login','order','pay')
AND act_date >= DATE_SUB(CURDATE(), INTERVAL 90 DAY)
) a
LEFT JOIN (
SELECT DISTINCT user_id, act_date
FROM t_user_activity
WHERE action_type IN ('login','order','pay')
AND act_date >= DATE_SUB(CURDATE(), INTERVAL 90 DAY)
) b ON a.user_id = b.user_id
AND b.act_date = DATE_ADD(a.act_date, INTERVAL 1 DAY)
WHERE b.user_id IS NULL -- streak 的起点(前一天没有活跃)
GROUP BY a.user_id
HAVING COUNT(*) >= 7;
执行计划关键点:
- 两个子查询各 210 万行 → 物化为临时表
- LEFT JOIN 的 ON 条件是
b.act_date = DATE_ADD(a.act_date, 1)→ 无法走索引(函数表达式),退化为 Nested Loop Join with no index - 210 万 × 210 万 的最坏情况,但实际因为
user_id等值过滤,每个 user 只匹配自己的 90 天内的日期,平均 ~12 天/人 → 实际 ~210 万 × 12 ≈ 2520 万次比较 - 但每次比较都是内存哈希 + 临时表扫描,CPU 密集
根因:自连接的本质是 O(n × k)(k = 平均活跃天数),在 210 万行 × 12 天 = 2520 万次 join 下,每次 join 都是一次哈希查找 + 临时表随机访问,CPU 和内存带宽都吃满。
临时表迭代(7 轮)
CREATE TEMPORARY TABLE t_streak (user_id BIGINT PRIMARY KEY, days INT DEFAULT 1);
INSERT INTO t_streak (user_id, days)
SELECT user_id, 1
FROM (SELECT DISTINCT user_id, act_date FROM t_user_activity
WHERE action_type IN ('login','order','pay') AND act_date >= DATE_SUB(CURDATE(), INTERVAL 90 DAY)) d;
-- 迭代 6 次(第 2~7 天)
SET @i = 2;
WHILE @i <= 7 DO
INSERT INTO t_streak (user_id, days)
SELECT t.user_id, t.days + 1
FROM t_streak t
INNER JOIN (SELECT DISTINCT user_id, act_date FROM t_user_activity
WHERE action_type IN ('login','order','pay')) a
ON t.user_id = a.user_id
AND a.act_date = DATE_ADD(
-- 这里需要知道 streak 起始日,简化为:前一天也在 t_streak 里
CURDATE() - (90 - t.days), INTERVAL 1 DAY)
WHERE NOT EXISTS (
SELECT 1 FROM t_streak t2 WHERE t2.user_id = t.user_id AND t2.days = t.days + 1
);
SET @i = @i + 1;
END WHILE;
SELECT user_id, MAX(days) FROM t_streak GROUP BY user_id HAVING MAX(days) >= 7;
执行计划关键点:
- 每轮迭代都是一次 t_streak(增量)× t_user_activity(全量 DISTINCT) 的 join
- 第 1 轮 t_streak = 210 万行,第 7 轮 t_streak ≈ 30 万行(只有连续 6 天的用户)
- 总 join 次数 ≈ 210 万 + 180 万 + 120 万 + 80 万 + 50 万 + 30 万 + 15 万 ≈ 685 万次
- 每轮都要回
t_user_activity做 DISTINCT → 7 次全表扫描(~210 万行/次)
根因:7 次全表扫描 × 210 万行 = 1470 万行被反复读取,Buffer Pool 命中率在每轮扫描间被冲刷(第 1 轮热了 user_id 索引页,第 2 轮换了日期范围后又冷了)。
三、具体做法:可复现的 benchmark 脚本
# 1. 准备测试数据(807 万行)
mysql -e "
SET GLOBAL innodb_flush_log_at_trx_commit=0;
SET GLOBAL sync_binlog=10000;
-- 用 sysbench 或自写脚本灌入 807 万行
"
# 2. 跑三版 SQL,每版跑 3 次取中位数
mysql -e "
SET profiling=1;
-- 执行 SQL...
SHOW PROFILES LIMIT 3;
"
# 3. 监控 Buffer Pool 命中率(另开终端)
watch -n 1 "mysql -e 'SHOW GLOBAL STATUS LIKE \"Innodb_buffer_pool_read%\"'"
# 4. 监控在线点查 P99(压测脚本,持续 60s)
k6 run -e VU=2000 -e DURATION=60s loadtest.js
关键参数确认(避免误判):
-- 确认 sort_buffer 是否溢出
SHOW SESSION STATUS LIKE 'Sort_merge_passes'; -- > 0 说明 filesort 溢出了
-- 确认临时表是否落盘
SHOW SESSION STATUS LIKE 'Created_tmp_disk_tables'; -- > 0 说明内存临时表不够
四、踩过的坑与反直觉之处
坑 1:窗口函数的 CTE 物化不是「免费的」
很多 8.0 教程把窗口函数当作「零成本改写」,但在 210 万行去重后,两次 CTE 物化 + 两次 filesort 的代价比自连接还高(如果 sort buffer 够大且数据在内存)。我们实测发现:把 sort_buffer_size 从 2M 调到 16M 后,窗口函数 P99 从 2.1s 降到 1.4s(-33%),但在线点查 P99 连带从 3.4ms 升到 4.1ms——因为更大的 sort buffer 占了更多物理内存,Buffer Pool 可用空间被压缩。
坑 2:自连接在「活跃天数稀疏」时反而比窗口函数快
如果每个用户 90 天内只活跃 3~5 天(而不是 12 天),自连接的 join 次数从 2520 万降到 ~1000 万,而窗口函数的 210 万行物化不变。我们在一组「低活跃用户」子集(50 万用户 × 3.2 天/人)上实测:自连接 P99 = 3.8s,窗口函数 P99 = 2.6s——差距从 12 倍缩到 1.5 倍。判断依据:如果用户平均活跃天数 < 5 天,自连接可能更优。
坑 3:临时表方案的 NOT EXISTS 子查询是性能杀手
上面临时表迭代 SQL 里的 WHERE NOT EXISTS (SELECT 1 FROM t_streak t2 WHERE ...) 在 t_streak 增长到 100 万行后,每轮迭代都变成 100 万 × 100 万的半连接,P99 从 18.7s 飙到 42s。改成 LEFT JOIN ... WHERE t2.user_id IS NULL 后回到 18.7s——半连接在大数据量下比 anti-join 慢 2.2 倍,因为 MySQL 8.0 的 semi-join 策略选择(BNL vs hash join)在 100 万行时没有自动切到 hash join。
坑 4:窗口函数结果在 HAVING 之前的 GROUP BY 是「全量物化」
GROUP BY user_id HAVING MAX(streak_len) >= 7 这步,MySQL 8.0 会先物化所有 user 的 streak(~120 万个 user × 平均 3.2 条 = ~384 万行),然后再过滤。如果把 HAVING 条件推到 CTE 内部(WHERE streak_len >= 7),可以提前剪枝,但会改变语义——「连续 7 天」和「任意 7 天」不等价。
五、结论与可复用清单
取舍决策树
用户平均活跃天数 ≥ 5 天?
├─ 是 → 窗口函数(LAG + 行号差值法)
│ ├─ 在线高峰?→ 加 `SET SESSION sort_buffer_size=32M` + 限流到 1 并发
│ └─ 凌晨窗口?→ 直接跑,P99 2.1s 可接受
├─ 否(< 5 天)→ 自连接(gap 检测)
│ └─ 用户量 > 500 万?→ 加 `user_id` 范围分片,每片 100 万
└─ 需要增量更新(每天只算新 streak)?
└─ 预聚合表(`t_user_streak`,每天 UPDATE)
└─ 读侧点查 < 1ms,写侧 T+1 可接受
可复用清单
- 窗口函数 ≠ 零成本:MySQL 8.0 的窗口函数实现是「物化 → 排序 → 流式」,200 万行以上会有 2 次 filesort + 2 次 CTE 物化,P99 受
sort_buffer_size和 Buffer Pool 可用空间双重约束 - 在线点查的连带 P99 是隐藏成本:跑批 SQL 的 Buffer Pool 冲刷会让同实例的点查 P99 涨 2~5 倍,必须在压测时同时监控在线接口,不能只看批处理总耗时
- 自连接在稀疏数据上更优:用户平均活跃天数 < 5 天时,自连接的 O(n × k) 比窗口函数的 O(n log n) 更快
- 临时表迭代的
NOT EXISTS要改LEFT JOIN:半连接在 100 万行以上比 anti-join 慢 2 倍以上(MySQL 8.0 semi-join 策略选择的已知短板) - 给报表跑批加「Buffer Pool 冲刷」监控:
Innodb_buffer_pool_reads在批处理期间如果从基线 200/s 跳到 5000+/s,说明 hot page 被冲刷了,在线点查 P99 大概率已经涨了 sort_buffer_size不是越大越好:从 2M 调到 16M 省了 33% 的批处理时间,但在线点查 P99 反升 20%——sort buffer 和 Buffer Pool 是竞争关系
给数据组的落地建议
- 凌晨 2-5 点跑批:用窗口函数,
sort_buffer_size=16M,限 1 并发,P99 目标 < 3s - 白天在线查询:用预聚合表
t_user_streak(T+1 更新),点查 P99 < 1ms - 监控:批处理期间
Innodb_buffer_pool_reads > 5000/s告警,在线点查 P99 > 2ms 告警 - 代码卡点:SQL review 时检查「窗口函数 + CTE 物化」是否超过 50 万行,超过则必须加
sort_buffer_size调优或改用预聚合