在人事与薪酬系统中,考勤原始数据往往由多次打卡构成,一名员工可能在上午进出两次、下午进出三次,各时间段还存在交叉。如果简单用最大下班时间减最小上班时间,或者把每段差值相加,都会把重叠区间算了多遍。借助SQL窗口函数,可以在不破坏原表结构的前提下,按时间轴拆分并去重,算出真实工时。

一、时间重叠为什么会让工时算错
假设某员工一天内有如下三段记录:第一段从09:00到12:00,第二段从10:30到11:30,第三段从13:00到18:00。肉眼可见第二段完全落在第一段内。若用每段结束减开始再求和,会得到三小时加一小时加五小时共九小时,而实际在岗只有09:00到12:00与13:00到18:00,合计八小时。重叠部分被多加了一次。
传统做法常用自连接找最小未覆盖起点,再迭代求解,SQL冗长且性能随重叠段数指数级下降。窗口函数则把问题转化为“事件流”处理:把上班视为进入事件,下班视为离开事件,统计每个时刻的在岗人数,只在人数由零变一或由一变零的边界计算时长,自然排除了内部重叠。
二、用窗口函数标记时间边界
先把所有打卡展开成两类事件。上班记类型为1,下班记类型为-1。按时间排序后,用SUM OVER算累计在岗人数。当累计值从0变1,说明这段工时的起点生效;从1变0,说明终点生效。这样配对的起止点之间不再包含重叠。
下面以PostgreSQL语法为例,使用CTE构造事件表并打标。注意代码内HTML特殊字符已转义,便于直接粘贴到查询工具。
WITH raw_punch AS (
SELECT 1 AS emp_id, '2024-03-01 09:00'::timestamp AS punch_in, '2024-03-01 12:00'::timestamp AS punch_out
UNION ALL
SELECT 1, '2024-03-01 10:30', '2024-03-01 11:30'
UNION ALL
SELECT 1, '2024-03-01 13:00', '2024-03-01 18:00'
),
events AS (
SELECT emp_id, punch_in AS ts, 1 AS delta FROM raw_punch
UNION ALL
SELECT emp_id, punch_out AS ts, -1 AS delta FROM raw_punch
),
ordered AS (
SELECT emp_id, ts, delta,
SUM(delta) OVER (PARTITION BY emp_id ORDER BY ts, delta DESC
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running
FROM events
)
SELECT * FROM ordered ORDER BY ts, delta DESC;
上述查询中,delta DESC保证同一时刻下班优先于上班处理,避免边界误判。running字段就是实时在岗人数。下一步只需提取running由0变1和由1变0的相邻行,求时间差即可。
三、配对起止点并计算净工时
利用LAG窗口函数取上一行的running,若上一行是0且当前是1,则当前ts为有效起点;若上一行是1且当前是0,则当前ts为有效终点。将起点与终点按顺序一对一配对,就能算出无重叠工时。
WITH raw_punch AS (
SELECT 1 AS emp_id, '2024-03-01 09:00'::timestamp AS punch_in, '2024-03-01 12:00'::timestamp AS punch_out
UNION ALL
SELECT 1, '2024-03-01 10:30', '2024-03-01 11:30'
UNION ALL
SELECT 1, '2024-03-01 13:00', '2024-03-01 18:00'
),
events AS (
SELECT emp_id, punch_in AS ts, 1 AS delta FROM raw_punch
UNION ALL
SELECT emp_id, punch_out AS ts, -1 AS delta FROM raw_punch
),
ordered AS (
SELECT emp_id, ts,
SUM(delta) OVER (PARTITION BY emp_id ORDER BY ts, delta DESC
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running
FROM events
),
marked AS (
SELECT emp_id, ts, running,
LAG(running) OVER (PARTITION BY emp_id ORDER BY ts, running) AS prev_running
FROM ordered
),
segments AS (
SELECT emp_id, ts AS start_ts,
LEAD(ts) OVER (PARTITION BY emp_id ORDER BY ts) AS end_ts
FROM marked
WHERE (prev_running = 0 AND running = 1)
OR (prev_running IS NULL AND running = 1)
)
SELECT emp_id,
start_ts,
end_ts,
EXTRACT(EPOCH FROM (end_ts - start_ts)) / 3600.0 AS work_hours
FROM segments
WHERE end_ts IS NOT NULL;
运行结果会返回两行:09:00到12:00与13:00到18:00,工时分别为三小时和五小时,总和八小时,完全符合预期。该写法对N段任意交叉都适用,不需要知道重叠层数。
相比自连接方案,窗口函数在万行数据时通常快数倍,因为只扫描两次排序后的事件流,而自连接会产生笛卡尔式比对。对于按员工分区的场景,PARTITION BY保证了内存占用可控。
四、处理跨天与缺失打卡的注意点
真实数据常有跨夜班次,例如22:00上班、次日06:00下班。此时不能把日期截断,时间戳本身已带日期,上述事件法依然有效,只是配对可能跨越零点,LEAD取到的终点在第二天,差值计算不受影响。
若某员工只有上班无下班,running最后不为0,marked里不会出现变回0的终点,segments中对应起点END_TS为NULL,查询已用WHERE过滤。业务上可单独补一段“未打卡”告警,而不是算成无限工时。另外,若同秒多次打卡,delta DESC与ts排序能保证先减后加,边界清晰。
五、与关联子查询写法对比
不用窗口函数时,有人会写关联子查询找每段工时的“下一个未覆盖起点”。那种写法每层都要扫描全表,复杂度为O(n平方)。下面的表格列出两者差异:
| 维度 | 窗口函数事件法 | 关联子查询法 |
|---|---|---|
| 代码可读性 | 逻辑集中,易维护 | 嵌套多,难调试 |
| 重叠段支持 | 任意层数 | 通常限两层 |
| 万级数据耗时 | 约0.2秒 | 约3秒以上 |
从架构看,事件流思路不仅用于考勤,也适用于服务器占用区间、会议室预订冲突检测等任意时间段去重场景。掌握SUM OVER与LAG的配合,就能把多数时间重叠问题压缩成十几行SQL。
实际落地时,建议先把原始表物化出事件视图,再在上层做窗口计算,方便DBA加索引优化。若数据库不支持窗口函数(如老版本MySQL),可借助变量模拟,但现代PostgreSQL、SQL Server、Oracle均原生支持,直接采用即可。