连续出勤通过日期差为1天识别,MySQL 8.0+/PostgreSQL用ROW_NUMBER()构造期望序列并与实际日期作差得grp分组;MySQL 5.7需自连接子查询模拟,须过滤NULL、去重、加确定性排序、用TO_DAYS()防跨年错误,并建复合索引优化性能。

子查询怎么配合日期差识别连续出勤
连续出勤的本质是:某用户在 attendance_date 字段上,相邻两条记录的日期差为 1 天。直接用 GROUP BY user_id 没法捕获“相邻”关系,必须借助子查询生成序号或关联前一行数据。
主流做法是用窗口函数(如 ROW_NUMBER())构造“期望的连续序列”,再和实际日期做差——这个差值相等的组,就是一段连续出勤。但注意:MySQL 5.7 不支持窗口函数,此时得用自连接子查询模拟。
- MySQL 8.0+ 或 PostgreSQL:优先用
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY attendance_date) - MySQL 5.7:用
(SELECT COUNT(*) FROM attendance a2 WHERE a2.user_id = a1.user_id AND a2.attendance_date 手动算序号 - 日期差计算统一用
DATEDIFF(attendance_date, '2000-01-01')或TO_DAYS(attendance_date),避免DATE_SUB嵌套出错
为什么不能直接用 EXISTS + DATE_ADD 写两层子查询
常见错误是写成:EXISTS (SELECT 1 FROM attendance a2 WHERE a2.user_id = a1.user_id AND a2.attendance_date = DATE_ADD(a1.attendance_date, INTERVAL 1 DAY)) ——这只能查“下一天是否存在”,无法保证“连续三天及以上”。它漏掉了中间断点后重新开始的长序列,也容易把孤立的两天误判为连续。
真正要筛出“至少连续 3 天”的用户,必须先归组再统计长度,而不是逐日验证存在性。
- 该写法返回的是“有后续出勤日的记录”,不是“属于某连续段的记录”
- 性能差:对每行都触发一次子查询,无索引时全表扫描放大 N 倍
- 无法区分连续 2 天 vs 连续 5 天,业务上通常需要最小天数阈值
用子查询构造连续段 ID 的实操步骤
核心思路:对每个用户按日期排序,计算 attendance_date - ROW_NUMBER()。同一连续段内,这个差值恒定(因为日期和序号同步递增)。
SELECT user_id, MIN(attendance_date) AS start_date, MAX(attendance_date) AS end_date, COUNT(*) AS days
FROM (
SELECT
user_id,
attendance_date,
DATE_SUB(attendance_date, INTERVAL ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY attendance_date) DAY) AS grp
FROM attendance
) t
GROUP BY user_id, grp
HAVING COUNT(*) >= 3;-
grp是关键中间字段,代表逻辑上的连续段 ID;不同段的grp值一定不同 -
HAVING COUNT(*) >= 3放在外部 GROUP BY 后,不能写成WHERE COUNT(*) >= 3(语法错误) - 若数据库不支持窗口函数,
ROW_NUMBER()需替换为相关子查询,但要注意ORDER BY必须有确定性(比如加id作第二排序键)
容易被忽略的边界情况
真实数据里,同一天多次打卡、跨年日期、时区混用、NULL 出勤记录都会让连续性判断失效。
- 先用
WHERE attendance_date IS NOT NULL过滤掉空值,否则DATE_SUB(NULL, ...)返回 NULL,导致所有记录被归到同一grp - 去重必须做:
DISTINCT user_id, DATE(attendance_date),防止同天多条记录干扰ROW_NUMBER()序列 - 跨年场景下
TO_DAYS()比DAYOFYEAR()更安全,后者在 12 月 31 日和 1 月 1 日差值为 -364 - 如果表没建复合索引
(user_id, attendance_date),子查询里的PARTITION BY user_id ORDER BY attendance_date会极慢

















