

一道看似简单的SQL题,背后考察的是你对数据仓库分层设计的理解,特别是中间表的设计能力。
引子:那道让人”脑雾”的面试题#
最近,一位朋友分享了他在字节跳动面试大数据开发工程师的经历。面试官给了一道SQL题:
场景:抖音流量日志表visit_log,包含用户ID(uid)和访问时间戳(timestamp),按日期(date)分区。数据规模:日增百亿级记录。
需求:
- 计算T日的用户7日留存率
- 统计每个用户近1/7/30/365天的访问天数
- 找出近7天内访问间隔在24小时内的用户
朋友说:“我当时就写了个JOIN,面试官问:‘你们生产环境真的会这么查询吗?没有中间表吗?’ 我就懵了…”
关键洞察:面试官真正想了解的是,你是否知道如何设计中间表来支撑业务查询,而不是每次都从原始日志计算。
一、为什么直接查询原始表是错误的?#
生产环境的残酷现实#
-- ❌ 没有人会在生产环境这么写
SELECT COUNT(DISTINCT a.uid)
FROM visit_log a
JOIN visit_log b ON a.uid = b.uid
WHERE a.date = 'T' AND b.date BETWEEN 'T+1' AND 'T+7'sql问题不仅是性能:
- 原始日志表数据量巨大,查询成本极高
- 每次查询都要重新计算,浪费资源
- 无法支撑复杂的业务需求
- 数据口径不统一,容易出错
数据仓库的核心理念:空间换时间#
| 层级 | 表类型 | 数据特点 | 查询场景 |
|---|---|---|---|
| ODS | 原始日志 | 最细粒度,数据量大 | 几乎不直接查询 |
| DWD | 明细事实表 | 清洗后的明细 | 少量明细查询 |
| DWS | 轻度汇总表 | 用户粒度汇总 | 常规分析查询 |
| ADS | 应用层表 | 高度聚合 | 报表直接查询 |
二、核心解决方案:设计合理的中间表#
方案一:用户状态快照表(最常用)#
-- 每日创建用户状态快照表(T+1凌晨运行)
CREATE TABLE dws_user_daily_snapshot (
uid BIGINT COMMENT '用户ID',
date STRING COMMENT '快照日期',
-- 核心状态字段
first_visit_date STRING COMMENT '首次访问日期',
last_visit_date STRING COMMENT '最后访问日期',
total_visit_days INT COMMENT '累计访问天数',
-- 近期活跃度指标(这些字段直接支撑业务查询)
visits_last_1d INT COMMENT '近1天访问天数',
visits_last_7d INT COMMENT '近7天访问天数',
visits_last_30d INT COMMENT '近30天访问天数',
visits_last_365d INT COMMENT '近365天访问天数',
-- 访问间隔特征
last_visit_gap_hours INT COMMENT '最近两次访问间隔小时数',
is_frequent_user INT COMMENT '是否24小时内多次访问用户',
-- 留存标记(预计算)
is_new_user INT COMMENT '是否新用户',
is_retained_1d INT COMMENT '是否次日留存',
is_retained_7d INT COMMENT '是否7日留存',
is_retained_30d INT COMMENT '是否30日留存'
)
PARTITIONED BY (date STRING)
STORED AS PARQUET;sql基于快照表回答面试问题变得极其简单:
-- 问题1:7日留存率(直接查询,秒级返回)
SELECT
COUNT(CASE WHEN is_new_user = 1 THEN uid END) as new_users,
COUNT(CASE WHEN is_retained_7d = 1 THEN uid END) as retained_users,
COUNT(CASE WHEN is_retained_7d = 1 THEN uid END) * 100.0 /
COUNT(CASE WHEN is_new_user = 1 THEN uid END) as retention_rate_7d
FROM dws_user_daily_snapshot
WHERE date = DATE_SUB(CURRENT_DATE, 7); -- T+7查询T日留存
-- 问题2:用户访问天数(直接读取)
SELECT
uid,
visits_last_1d,
visits_last_7d,
visits_last_30d,
visits_last_365d
FROM dws_user_daily_snapshot
WHERE date = CURRENT_DATE;
-- 问题3:24小时内访问用户(已预计算)
SELECT uid
FROM dws_user_daily_snapshot
WHERE date = CURRENT_DATE
AND is_frequent_user = 1;sql三、中间表设计的最佳实践#
1. 分层设计原则#
-- ODS层:原始数据,保持原貌
CREATE TABLE ods_visit_log LIKE visit_log;
-- DWD层:清洗后的明细数据
CREATE TABLE dwd_visit_detail (
uid BIGINT,
visit_time TIMESTAMP,
session_id STRING,
-- 清洗:去重、过滤异常
) PARTITIONED BY (date STRING);
-- DWS层:轻度汇总表(用户粒度)
CREATE TABLE dws_user_daily_agg (
uid BIGINT,
date STRING,
visit_count INT,
total_duration INT
) PARTITIONED BY (date STRING);
-- ADS层:应用层指标表
CREATE TABLE ads_retention_metrics (
date STRING,
new_users INT,
retained_1d INT,
retained_7d INT,
retention_rate_1d FLOAT,
retention_rate_7d FLOAT
) PARTITIONED BY (date STRING);sql2. 更新策略选择#
| 更新策略 | 适用场景 | 优缺点 |
|---|---|---|
| 全量快照 | 数据量小,状态变化频繁 | 简单但存储成本高 |
| 增量快照 | 只记录变化数据 | 存储省但查询复杂 |
| 拉链表 | 缓慢变化维度 | 存储最优但维护复杂 |
| 累积快照 | 需要历史所有状态 | 查询方便但数据量大 |
四、面试官真正想看到什么?#
标准答案模板#
“对于这个问题,在生产环境中,我不会直接查询原始日志表,而是设计合理的中间表:
第一步:设计用户状态快照表
- 每日生成用户快照,包含累计和近期指标
- 预计算留存标记,查询时直接读取
- 存储成本可控,查询性能好
第二步:考虑存储优化
- 如果存储压力大,采用拉链表设计
- 只记录状态变化,大幅减少存储
- 适合用户状态这种缓慢变化维度
第三步:构建应用层宽表
- 面向具体业务需求设计宽表
- 一张表满足多个查询需求
- 用空间换时间,提升查询效率
实际查询时,基于这些中间表,原本复杂的计算变成简单的SELECT,性能提升百倍以上。“
五、总结:面试脑雾自救指南#
三句话搞定面试#
当你脑子一片空白时,记住这三句话:
- “数据太大,不能直接查”(展示你懂规模)
- “生产环境用中间表”(展示你有经验)
- “我先写个简化版”(展示你会SQL)
最小可行代码模板(背下来!)#
-- 万能模板:能应付80%的场景
WITH temp AS (
SELECT uid, date, [其他字段]
FROM visit_log
WHERE date = '条件' -- 记得分区裁剪
GROUP BY uid, date -- 记得去重
)
SELECT
COUNT(DISTINCT uid),
其他聚合函数
FROM temp;sql最后的真心话#
面试不是考试,面试官不期待你写出完美代码。他们想看到的是:
- 你遇到问题时的思考方式
- 你是否了解生产环境的做法
- 你能否清晰地沟通
记住:
- 简单正确 > 复杂错误
- 说出思路 > 闷头写代码
- 承认不会 > 胡编乱造
- 展示经验 > 炫技
当面试官看到你说:“这个数据量太大,生产环境一定要用中间表”,他就知道你是有实战经验的。这比写100行复杂SQL更有说服力。
面试是一场交流,不是考试。保持冷静,展示思维,你就已经成功了一半。