题目与适用场景
有一张 PostgreSQL 事件表:
CREATE TABLE events (
event_id bigint PRIMARY KEY,
user_id bigint NOT NULL,
occurred_at timestamptz NOT NULL
);对每个用户按 occurredat、eventid 排序。第一条事件开启会话;之后若当前事件与前一条 事件的间隔大于或等于 30 分钟,就开启新会话。返回 user_id、从 1 开始的 sessionseq、sessionstart、sessionend、eventcount 和 session_duration。恰好 30 分钟属于新会话,这是本题明确的边界规则。
这类会话划分常用于点击流、产品分析和行为漏斗。它与“连续登录天数”不同:连续天数比较 日历日期是否相邻,会话划分比较同一用户相邻事件的实际时间间隔。主答案在完整、已去重的 事件集合上计算精确结果;限定时间范围和增量维护需要额外定义边界。
面试官在考察什么
第一个信号是候选人能否把自然语言变成可执行契约。> 30 分钟 与 >= 30 分钟 会给临界 事件不同归属;比较上一条事件还是会话首条事件,也会得到不同结果。本题采用相邻事件间隔, 因此持续活动可以形成超过 30 分钟的长会话。
第二个信号是能否把问题拆成三层窗口逻辑:先用 LAG() 读取上一条事件,再标记会话边界, 最后对边界标记做累计 SUM()。PostgreSQL 不允许把所需窗口计算随意嵌套在同一表达式中, CTE 也让每个中间结果可以单独检查。
第三个信号是排序是否确定。两个事件可能有相同 occurred_at。只按时间排序时,数据库可用 任意顺序处理这些同行;加入唯一 event_id 后,LAG() 与累计和共享同一全序。相同时间戳 之间的间隔为零,不应开启新会话。
第四个信号是时间语义。timestamptz 表示可比较的绝对时刻,30 分钟阈值应直接在这些时刻 之间计算。先转成当地墙上时间会把夏令时跳变混入时长。时区转换只用于结果展示,不用于本题 的会话边界。
最后还要看候选人是否意识到迟到事件会改写历史。一个插入旧时间位置的事件可能连接原本分离 的两段会话,所以增量系统不能只追加新的 session_seq;它需要有界重算、修订版本或明确的 最终水位。
回答前要先确认的问题
- 30 分钟临界点归哪边? 本题规定间隔
>= 30 minutes开新会话;若产品使用严格大于,
只需改变比较符,但测试期望也必须同步。
- 比较相邻事件还是会话首条事件? 本题比较相邻事件。若会话最长只能 30 分钟,就要额外
保存会话起点并采用不同状态逻辑。
- 重复事件如何处理?
event_id是逻辑事件唯一键,重复投递应在进入本查询前去重。
同一用户、同一时间的不同事件 ID 可以是真实事件,仍分别计数。
- 时间按什么时区? 间隔按绝对时间计算。命名时区只影响展示,不改变经过的秒数。
- 查询是否带时间范围? 全历史查询最简单。若只查报表窗口,必须说明是否要保留从窗口前
延续进来的完整会话,并至少读取每个用户在起点前的前驱事件。
- 迟到事件会出现多久? 临时查询可以重算。物化结果需要水位、允许修订的时间窗及下游
接受更新或撤回的协议。
- 空表与单事件用户如何返回? 空表返回零行;单事件用户得到时长为零的一条会话。
30 秒回答框架
“我会在每个用户内按 occurredat, eventid 建立确定顺序,用 LAG(occurred_at) 取得上一条 事件。首行或间隔大于等于 30 分钟时标记 1,其余标记 0。然后用显式 ROWS 窗口帧对标记做 累计和,这个累计值就是从 1 开始的会话序号。最后按用户和序号聚合开始、结束、事件数与持续 时间。验证会覆盖 29 分 59 秒、恰好 30 分钟、相同时间戳、单事件用户、重复 ID、迟到事件和 报表起点。若结果需要增量物化,我会按允许迟到窗口重算受影响用户,而不是假设会话只会追加。”
分步深入分析
先用同一排序规则取得前驱事件。event_id 不参与时间差,只负责在时间戳相同时稳定排序:
WITH ordered AS (
SELECT
event_id,
user_id,
occurred_at,
LAG(occurred_at) OVER (
PARTITION BY user_id
ORDER BY occurred_at, event_id
) AS previous_at
FROM events
),
marked AS (
SELECT
event_id,
user_id,
occurred_at,
CASE
WHEN previous_at IS NULL THEN 1
WHEN occurred_at - previous_at >= INTERVAL '30 minutes' THEN 1
ELSE 0
END AS is_new_session
FROM ordered
),
sessionized AS (
SELECT
event_id,
user_id,
occurred_at,
SUM(is_new_session) OVER (
PARTITION BY user_id
ORDER BY occurred_at, event_id
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS session_seq
FROM marked
)
SELECT
user_id,
session_seq,
MIN(occurred_at) AS session_start,
MAX(occurred_at) AS session_end,
COUNT(*) AS event_count,
MAX(occurred_at) - MIN(occurred_at) AS session_duration
FROM sessionized
GROUP BY user_id, session_seq
ORDER BY user_id, session_seq;显式写 ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW 很重要。累计和要按物理排序逐行吸收 边界标记,不应依赖带同行语义的默认窗口帧。这里虽然唯一 event_id 已消除同行,显式窗口帧 仍把意图固定下来,也避免后来删掉破同分列时悄悄改变行为。
用以下事件检查边界:
| userid | eventid | occurred_at | 与前一条间隔 | 期望会话 |
|---|---|---|---|---|
| 1 | 1 | 09:00 | 首条 | 1 |
| 1 | 2 | 09:05 | 5 分钟 | 1 |
| 1 | 3 | 09:35 | 30 分钟 | 2 |
| 1 | 4 | 09:50 | 15 分钟 | 2 |
| 1 | 5 | 10:10 | 20 分钟 | 2 |
| 2 | 6 | 09:00 | 首条 | 1 |
| 2 | 7 | 09:29 | 29 分钟 | 1 |
| 2 | 8 | 09:58 | 29 分钟 | 1 |
用户 1 产生两段会话:第一段 2 个事件、持续 5 分钟;第二段 3 个事件、持续 35 分钟。用户 2 虽然总跨度为 58 分钟,但相邻间隔都小于 30 分钟,所以仍是一段会话。这正好证明规则比较的 是相邻事件,不是会话总时长。
正确性可以用不变量说明。每个用户的第一条记录把累计和从 0 增到 1。之后只有满足边界条件的 记录把累计值增加 1;非边界记录保持原值。因此两条记录拥有相同累计值,当且仅当它们之间 没有被标记的会话边界。按用户和累计值聚合,恰好得到所有不能再扩展的连续段,不会跨用户 或跨边界合并。
若有 N 条事件,窗口方案需要按用户和时间排序,通常为 O(N log N) 时间;窗口扫描和聚合 为 O(N)。中间状态为 O(N),实际可能落盘。索引可以与逻辑排序一致:
CREATE INDEX events_session_order_idx
ON events (user_id, occurred_at, event_id);索引不保证所有计划都免排序:全表读取成本、可见性、并行计划和过滤条件都会影响选择。应在 代表性数据上检查 EXPLAIN (ANALYZE, BUFFERS) 的扫描、排序、临时 I/O、估算行数与实际行数, 不能只凭索引存在就宣布优化完成。
时间范围是最容易漏掉的正确性边界。若查询从 10:00 开始,而用户在 09:50 和 10:10 各有一条 事件,直接过滤后会把 10:10 错标为新会话。只需要窗口内归属时,至少为每个用户读取起点前 最近一条事件作为上下文,再在输出时排除上下文行。若要求返回完整会话起点,则要继续向前读取, 直到遇到真正的 30 分钟边界。
迟到事件还会合并历史会话。例如 09:00 与 09:50 原本相隔 50 分钟,属于两段会话;后来补入 09:25 后,两个相邻间隔都变为 25 分钟,结果合并为一段。批处理可直接重算受影响分区;增量 系统应按用户和允许迟到范围重算,并为输出提供版本或撤回机制。水位之后仍到达的事件,是丢弃、 隔离还是触发更大范围修订,必须成为明确产品契约。
验证时逐层检查。ordered 中每个非首行的 previous_at 必须等于同序前一条时间;marked 只在首行和阈值边界为 1;sessionized 的序号对每个用户从 1 开始、单调不减且每次最多增加 1。最终层还应满足 sessionstart <= sessionend、事件数之和等于输入逻辑事件数,并且相邻 会话的间隔至少 30 分钟。
高质量示范回答
“我会先确认边界是大于等于 30 分钟,并且比较相邻事件。原始事件用唯一 event_id 去重; 同一时间的不同事件仍保留。查询第一层在每个用户内按时间和事件 ID 排序,用 LAG 取前一条 时间。第二层把首行或间隔达到阈值的记录标成新会话。第三层对标记做显式 ROWS 累计和, 得到稳定的会话序号,再按用户和序号聚合起止时间、事件数和持续时间。
我会用 29 分 59 秒和恰好 30 分钟测试比较符,用相同时间戳测试破同分排序,用单事件用户和 空表测试边界。还要测试报表起点前存在相邻事件的情况,避免先过滤时间再做 LAG 导致伪造 新会话。复杂度主要来自排序,通常为 O(N log N);(userid, occurredat, event_id) 索引 可能提供所需顺序,但最终要看带缓冲信息的执行计划。
如果会话结果要持续物化,我不会把序号当作永不变化。09:00 与 09:50 原本是两段,迟到的 09:25 可以把它们合并。系统必须按允许迟到窗口重算受影响用户,并让下游接受版本更新;超出 水位的事件则按预先约定进入隔离或更大范围修订。”
常见错误
- 用当前事件减会话第一条事件 → 会把持续活跃但总时长超过 30 分钟的用户错误拆段 →
按题意比较相邻事件。
- 把恰好 30 分钟留在旧会话 → 与
>= 30 minutes契约冲突 → 为临界值写独立测试。 - 只按
occurredat排序 → 相同时间戳没有稳定全序 → **加入唯一eventid,并让两个
窗口使用同一排序。**
- 省略显式
ROWS窗口帧 → 默认帧的同行行为可能与逐行累计意图不同 → **写明从首行到
当前行的 ROWS 帧。**
- 把当地墙上时间用于间隔 → 夏令时跳变会制造或隐藏一小时 → **直接比较
timestamptz
绝对时刻,只在展示时转换时区。**
- 先按报表起点过滤 → 窗口第一条事件失去前驱,被误标为新会话 → **读取边界前上下文,
再裁剪输出。**
- 把重复投递当作真实事件 →
event_count被放大 → 用逻辑事件唯一键去重。 - 认为历史会话只会追加 → 迟到事件可能移动边界或合并会话 → 有界重算并发布可修订结果。
- 看到复合索引就断言没有排序 → 优化器可能因全表成本或过滤方式选择其他计划 → **检查
代表性执行计划与临时 I/O。**
追问与回答
追问 1:如果恰好 30 分钟仍属于旧会话,改哪里?
把边界判断从 >= INTERVAL '30 minutes' 改成 > INTERVAL '30 minutes'。其他窗口逻辑不变, 但所有语言、指标定义和测试数据都要同步。尤其要保留 29 分 59 秒、30 分钟和 30 分 1 秒三组 用例,防止以后再次混淆。
追问 2:如果会话最长不能超过 2 小时,累计和还够吗?
单纯比较前一条事件不够,因为连续小间隔可以无限延长会话。新边界同时依赖动态会话起点, 通常需要递归 CTE、顺序状态机或在流处理器中维护每个用户的会话状态。回答时应先明确“2 小时 固定窗口”还是“从首事件起最多 2 小时”,两者的边界也不同。
追问 3:迟到 24 小时的事件如何修订物化结果?
按 user_id 定位事件落点,读取覆盖迟到时间前后至少一个已确认边界的区间,重新计算这段 会话并与旧版本做差异。输出使用稳定业务键与版本,允许更新、合并和撤回;依赖方按版本幂等 应用。若产品水位禁止修改 24 小时前结果,则把事件放入隔离队列和数据质量指标,不能静默忽略。
追问 4:数据量达到数十亿条时怎么优化?
先按可裁剪的时间分区读取,并利用 (userid, occurredat, event_id) 顺序减少排序成本。 周期性任务保存每个用户在分区末尾的最后事件时间和未闭合会话状态,下一分区以该状态继续, 避免把分区边界误当作会话边界。优化必须用真实用户倾斜、排序溢写、扫描字节和端到端时延验证; 若单个超级用户形成热点,还要单独拆分其顺序处理路径。
追问 5:如何证明没有事件被漏算或重复计算?
对输入与输出建立守恒检查:最终所有 event_count 之和必须等于去重后的输入行数;每个 eventid 必须映射到恰好一个 (userid, session_seq);每个用户的会话序号从 1 连续增长; 会话内部相邻间隔小于 30 分钟,会话之间的边界间隔大于或等于 30 分钟。再用乱序输入、重复 投递、相同时间戳、分区交界和迟到合并做属性测试。