資料工程師的資料建模
範圍 — 分析用的邏輯資料建模,以及它會引出的面試題:建模層次、ER 與正規化、OLTP vs OLAP、Kimball 維度建模(粒度、事實、維度、bus matrix)、事實與維度的設計模式、緩慢變化維度、Inmon、Data Vault、One Big Table、語意層,以及一個完整的案例。實體表格設計 —— 分區、鍵、演進、型別 —— 屬於
schema_design.md。 另見:../faq_DE_interview.md—— 總覽與考前檢查清單;schema_design.md、sql_for_data.md、batch_pipeline.md與sql_vs_nosql.md—— 相鄰主題的深入篇;../faq_data_model.md與../db/faq_DB_DW.md—— 同一塊領域較早的筆記。
除非程式區塊另有註明,SQL 都是 PostgreSQL。每一題都先給你會說出口的那句話, 再講機制,然後是面試官下一步會追問的取捨。
1) 建模層次與 ER 優先度 3/5 — 值得會 — 多半是必備模式的變形
1-1) 概念、邏輯、實體模型
概念模型說出業務實體以及它們之間的關係;邏輯模型加上屬性、鍵與基數,但不綁定任何 資料庫;實體模型則是針對某一個引擎的 DDL。
| 層次 | 回答的問題 | 對象 | 內容 |
|---|---|---|---|
| 概念(Conceptual) | 業務在談論哪些東西? | 業務、產品 | 實體與關係,沒有屬性 |
| 邏輯(Logical) | 到底存什麼、怎麼識別? | 分析師、工程師 | 屬性、主鍵與外鍵、基數、正規化形式 |
| 實體(Physical) | 在這個引擎上怎麼存? | 工程師 | 型別、分區、clustering、索引、約束 |
面試時把這幾層一路講下來:說出實體、畫出關係、宣告鍵,然後 —— 也只有到這時 ——
才談分區(見 schema_design.md)。
1-2) ER 建模與基數
實體關係模型由實體(有身分的東西)、屬性,以及兩端各帶一個基數的關係組成 —— 一對一、一對多或多對多。
| 基數 | 例子 | 落到表格上的樣子 |
|---|---|---|
| 1 : 1 | user ↔ user_profile | 同一張表,或一個帶 UNIQUE 約束的外鍵 |
| 1 : N | customer → orders | 外鍵放在「多」的那一邊(orders.customer_id) |
| M : N | students ↔ courses | 一張關聯(junction)表 enrollment(student_id, course_id, ...) |
customer ──1────N── order ──1────N── order_line ──N────1── product
│
N
│
1
address
- 可選性(optionality)(零或多 vs 一或多)和基數是兩個不同的問題:一張訂單可以
沒有任何明細嗎?它決定之後的
NOT NULL,以及用 inner 還是 outer join。 - Junction 表通常會長出自己的屬性(
enrolled_at、grade)—— 這表示它其實是個真正的 實體,在資料倉儲裡它常常會變成一張事實表。
2) 正規化 優先度 4/5 — 高價值 — 這裡有缺口就會掉關
2-1) 為什麼要正規化:三種異常
正規化移除冗餘,讓每個事實只存一次 —— 這正是避免更新、新增與刪除異常的方法。
| 異常 | 在一張重複存放客戶城市的反正規化 orders 表中的症狀 |
|---|---|
| 更新異常 | 客戶搬家了;你得更新他所有的訂單列,否則它們會互相矛盾 |
| 新增異常 | 客戶下第一張訂單之前,你沒辦法記錄這位新客戶 |
| 刪除異常 | 刪掉客戶唯一的一張訂單,也就刪掉了這位客戶唯一的紀錄 |
2-2) 從 1NF 到 BCNF,用同一張表走一遍
每一個正規化形式移除一種依賴:1NF 移除重複群組,2NF 移除部分鍵依賴,3NF 移除遞移 依賴,BCNF 移除任何左邊不是鍵的依賴。
從一張以 (order_id, product_id) 為鍵的寬表開始:
order_id | order_date | customer_id | customer_city | product_id | product_name | qty | tags
1001 | 2026-11-01 | C1 | Taipei | P1 | Mug | 2 | "gift,red"
1001 | 2026-11-01 | C1 | Taipei | P2 | Pen | 1 | "office"
| 形式 | 規則 | 上表的違規 | 修正 |
|---|---|---|---|
| 1NF | 值是原子的,沒有重複群組 | tags 存了一個清單 |
移到 order_line_tag(order_id, product_id, tag) |
| 2NF | 沒有非鍵屬性只依賴鍵的一部分 | order_date、customer_id 只依賴 order_id;product_name 只依賴 product_id |
拆出 orders(order_id, order_date, customer_id) 與 product(product_id, product_name) |
| 3NF | 沒有非鍵屬性依賴另一個非鍵屬性 | customer_city 依賴的是 customer_id,不是 order_id |
拆出 customer(customer_id, customer_city) |
| BCNF | 每個決定因子都是候選鍵 | (這裡已經沒有了) | — |
結果是四張表 —— customer、orders、product、order_line(order_id, product_id, qty) —— 每個事實都只住在一列裡。
只有當一張表有互相重疊的候選鍵時,BCNF 才和 3NF 不同。經典例子是
(student, course, instructor),每位講師只教一門課:instructor → course,但 instructor 不是鍵,所以這張表符合 3NF,卻不符合 BCNF。
4NF 與 5NF(多值依賴與連接依賴)在 DE 面試中很少出現;知道它們存在就夠了。
2-3) 反正規化與它的取捨
當讀取遠多於寫入、而 join 的成本比冗餘還高時就反正規化 —— 這正是分析型資料倉儲的 常態。
| 正規化(3NF) | 反正規化(星型、寬表) | |
|---|---|---|
| 寫入路徑 | 只有一個地方要更新 | 很多列要更新,或整個重建 |
| 讀取路徑 | 很多 join | 很少或沒有 join |
| 儲存 | 最小 | 較大,但欄式壓縮會縮小差距 |
| 一致性 | 由結構保證 | 由建立它的管線保證 |
| 適合 | OLTP | OLAP、dashboard、ML 特徵 |
維持反正規化表正確的是管線,所以反正規化的那一份是從正規化或原始來源衍生出來的, 隨時可以重建。
3) OLTP vs OLAP 優先度 4/5 — 高價值 — 這裡有缺口就會掉關
OLTP 系統負責運作業務 —— 大量依鍵的小交易、正規化;OLAP 系統負責分析業務 —— 少量大型掃描與彙總、維度化。
| OLTP | OLAP | |
|---|---|---|
| 典型查詢 | SELECT ... WHERE order_id = ? |
跨好幾年的 SUM(amount) GROUP BY month, region |
| 碰到的列數 | 很少 | 數百萬到數十億 |
| 模型 | 3NF 實體 | 星型 schema、寬表 |
| 儲存配置 | 列式、B-tree 索引 | 欄式、壓縮、zone map |
| 寫入 | 持續、併發、交易式 | 批次或 micro-batch 載入 |
| 歷史 | 只有現況,會被覆寫 | 保留完整歷史 |
| 例子 | PostgreSQL、MySQL、Oracle | BigQuery、Snowflake、Redshift、ClickHouse、Databricks SQL |
資料工程師的工作就在兩者之間:從 OLTP 擷取(最好用 CDC,才不會壓垮來源), 再重新建模成 OLAP 的形狀。
4) Kimball 維度建模 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現
4-1) 四步驟設計流程
選業務流程、宣告粒度、找出維度、找出事實 —— 順序不能換,因為粒度決定了另外兩者 能是什麼。
- 業務流程 —— 會產生事件的營運活動:接單、出貨、訂閱續約。不是部門,也不是報表。
- 粒度 —— 一筆事實列到底代表什麼。
- 維度 —— 在那個粒度下成立的誰、什麼、哪裡、何時、為什麼、如何。
- 事實 —— 在那個粒度下成立的數值度量。
4-2) 粒度:模型裡最重要的一句話
用業務語言把粒度寫成一句話 ——「每一筆訂單明細的每個商品一列」—— 並拒絕任何在 這個粒度下不成立的維度或事實。
- 優先選**最細(atomic)**的粒度(來源能提供的最低層級)。往上彙總永遠做得到; 往下鑽到比你存的更細則永遠做不到。
- 一張表裡混了不同粒度,是大部分重複計算 bug 的根源:把訂單層級的
shipping_fee複製到每一筆明細上,加總時就會每筆明細算一次。 - 對粒度較粗的事實,解法是把它**分攤(allocate)**下去(依明細金額分攤運費), 或把它放在另一張訂單層級的事實表。
4-3) 事實與可加性
一個事實若能沿所有維度加總就是可加的,能沿部分維度加總是半可加的,都不能加總則是 不可加的 —— 這個類型決定 BI 工具該怎麼彙總它。
| 類型 | 例子 | 可以加總的方向 | 改用的彙總方式 |
|---|---|---|---|
| 可加(Additive) | 銷售金額、數量 | 所有維度 | SUM |
| 半可加(Semi-additive) | 帳戶餘額、庫存量 | 帳戶、商品 —— 但不能跨時間 | 期末值,或跨時間取 AVG |
| 不可加(Non-additive) | 單價、毛利率 %、比率 | 都不行 | 存分子與分母,最後才算比率 |
-- semi-additive: month-end balance per branch, not the sum of daily balances
SELECT d.year_month,
b.branch_name,
SUM(f.balance_amount) AS month_end_balance
FROM fact_account_balance_daily f
JOIN dim_date d ON d.date_key = f.date_key
JOIN dim_branch b ON b.branch_key = f.branch_key
WHERE d.is_month_end
GROUP BY d.year_month, b.branch_name;
4-4) 星型 schema vs 雪花型 schema
星型 schema 讓事實表直接 join 扁平、反正規化的維度;雪花型則把維度正規化成子表。 預設選星型。
dim_date dim_date
│ │
dim_customer ─ fact_sales ─ dim_product dim_customer ─ fact_sales ─ dim_product
│ │ │
dim_store dim_store dim_subcategory
│ │
(star) dim_city dim_category
(snowflake)
| 星型 | 雪花型 | |
|---|---|---|
| 每個查詢的 join 數 | 每個維度一個 | 更多 |
| 維度儲存 | 有冗餘(每個商品都重複存類別名稱) | 正規化 |
| 對 BI 使用者的查詢簡單度 | 高 | 較低 |
| 何時是對的 | 幾乎總是 | 一個巨大的維度帶著一個很大、被共用的子維度;某個工具要求這樣做 |
在欄式資料倉儲上,星型維度裡的冗餘成本很低,因為重複的值壓縮得很好, 所以雪花型多出來的 join 幾乎換不到什麼。
4-5) Bus matrix 與一致化維度
一致化維度(conformed dimension)在每一張使用它的事實表中都代表同一件事、用同一組鍵 與屬性值 —— 這才讓你能比較不同流程的事實。Bus matrix 則是一張計畫表,列出哪些流程 共用哪些維度。
Date Customer Product Store Promotion Carrier
Orders X X X X X
Shipments X X X X
Returns X X X X
Inventory snapshot X X X
Web sessions X X
- 每一列會變成一張或多張事實表;每一欄是一個只建一次的一致化維度。
- Drill-across(跨事實鑽取):要比較訂單與退貨,先把每張事實表各自彙總到共用的 一致化屬性,再 join 兩份結果 —— 永遠不要把兩張事實表逐列 join(那會造成 fan-out)。
-- drill-across: return rate per product category per month
WITH o AS (
SELECT d.year_month, p.category, SUM(f.quantity) AS units_sold
FROM fact_order_line f
JOIN dim_date d ON d.date_key = f.order_date_key
JOIN dim_product p ON p.product_key = f.product_key
GROUP BY d.year_month, p.category
), r AS (
SELECT d.year_month, p.category, SUM(f.quantity) AS units_returned
FROM fact_return f
JOIN dim_date d ON d.date_key = f.return_date_key
JOIN dim_product p ON p.product_key = f.product_key
GROUP BY d.year_month, p.category
)
SELECT o.year_month,
o.category,
o.units_sold,
COALESCE(r.units_returned, 0) AS units_returned,
ROUND(COALESCE(r.units_returned, 0)::numeric / NULLIF(o.units_sold, 0), 4) AS return_rate
FROM o
LEFT JOIN r ON r.year_month = o.year_month AND r.category = o.category;
5) 事實表類型 優先度 4/5 — 高價值 — 這裡有缺口就會掉關
5-1) 四種類型
| 類型 | 每一列代表 | 新增 / 更新 | 例子 |
|---|---|---|---|
| 交易型(Transaction) | 一個事件,在它發生的當下 | 只新增 | 一筆訂單明細、一筆付款、一次點擊 |
| 週期快照(Periodic snapshot) | 每個實體每個固定週期 | 每個週期新增 | 每日帳戶餘額、每月庫存 |
| 累積快照(Accumulating snapshot) | 一個有明確起點與終點的流程實例 | 新增一次,隨里程碑發生而更新 | 訂單:下單 → 付款 → 出貨 → 送達 |
| 無事實(Factless) | 沒有度量值的事件或涵蓋關係 | 新增 | 學生出席了課程;商品在促銷中 |
怎麼選:問的是發生了什麼,用交易型事實;問的是每個週期結束時的狀態,用週期快照; 問的是流程各步驟之間隔了多久,用累積快照;問的是什麼沒有發生(有促銷卻沒賣出), 則需要一張無事實的涵蓋表來比對。
5-2) 一張累積快照,從頭到尾
累積快照每個里程碑一個日期鍵,以及里程碑之間的時間差作為事實,而每當流程往前推進, 它的那一列就會被更新。
CREATE TABLE fact_order_fulfillment (
order_id BIGINT PRIMARY KEY, -- degenerate dimension
customer_key BIGINT NOT NULL,
placed_date_key INT NOT NULL,
paid_date_key INT, -- NULL until the milestone happens
shipped_date_key INT,
delivered_date_key INT,
order_amount NUMERIC(12, 2) NOT NULL,
days_placed_to_paid INT,
days_paid_to_ship INT,
days_ship_to_deliver INT
);
-- a shipment event arrives: move the row along
UPDATE fact_order_fulfillment f
SET shipped_date_key = sd.date_key,
days_paid_to_ship = s.ship_date - pd.full_date -- DATE - DATE = days
FROM stg_shipments s
JOIN dim_date sd ON sd.full_date = s.ship_date,
dim_date pd
WHERE f.order_id = s.order_id
AND pd.date_key = f.paid_date_key
AND f.shipped_date_key IS NULL;
- 里程碑的日期鍵可以指向
dim_date裡一個特別的「尚未發生」列,而不是NULL, 讓 inner join 照樣能用;兩種做法都行,只要一致。 - 因為列會被更新,這張表不適合只能 append 的儲存;在 lakehouse 上用
MERGE(Delta、Iceberg),並預期會有檔案重寫。
5-3) 彙總事實與合併事實
- 彙總事實(Aggregate fact) —— 預先加總、粒度較粗的副本(
fact_sales_daily_store), 給 dashboard 用。它是衍生的,必須和最細粒度的表一致,並由它重建。 - 合併事實(Consolidated fact) —— 把兩個相同粒度的流程合成一張表(每個商品每月的 實際 vs 預測),因為它們幾乎總是被一起分析。
6) 維度設計模式 優先度 4/5 — 高價值 — 這裡有缺口就會掉關
6-1) 代理鍵與日期維度
維度以代理鍵為鍵 —— 一個由資料倉儲指派、沒有意義的整數 —— 讓歷史(SCD2)、多個來源 與未知成員都各有自己的鍵。
自然鍵(app 的 customer_id)仍以屬性的身分留在維度裡供查找。鍵的生成方式與型別選擇
屬於 schema_design.md。
日期維度是每個資料倉儲都有的那一個維度。它是生成的而不是載入的,並承載了 沒有人該在每個查詢裡重新推導的日曆邏輯:
CREATE TABLE dim_date (
date_key INT PRIMARY KEY, -- 20261105, readable and sortable
full_date DATE NOT NULL UNIQUE,
year INT NOT NULL,
quarter INT NOT NULL,
month INT NOT NULL,
year_month CHAR(7) NOT NULL, -- '2026-11'
iso_weekday INT NOT NULL, -- 1 = Monday
is_weekend BOOLEAN NOT NULL,
is_month_end BOOLEAN NOT NULL
);
INSERT INTO dim_date
SELECT CAST(TO_CHAR(g.d, 'YYYYMMDD') AS INT),
g.d::date,
EXTRACT(YEAR FROM g.d)::int,
EXTRACT(QUARTER FROM g.d)::int,
EXTRACT(MONTH FROM g.d)::int,
TO_CHAR(g.d, 'YYYY-MM'),
EXTRACT(ISODOW FROM g.d)::int,
EXTRACT(ISODOW FROM g.d) IN (6, 7),
g.d::date = (date_trunc('month', g.d) + INTERVAL '1 month - 1 day')::date
FROM generate_series(DATE '2020-01-01', DATE '2030-12-31', INTERVAL '1 day') AS g(d);
會計期間、假日與「去年同一天」也都屬於這裡,這樣它們只需定義一次。
6-2) 退化維度與雜項維度
退化維度(degenerate dimension)是一個已經沒有屬性可以描述的鍵 —— 例如訂單編號 —— 所以它住在事實表裡,沒有維度表。雜項維度(junk dimension)把低基數的旗標收進一張小表, 讓事實表不必帶著一打旗標。
| 模式 | 是什麼 | 例子 |
|---|---|---|
| 退化(Degenerate) | 留在事實表上的識別碼,沒有維度表 | order_id、invoice_no、transaction_id |
| 雜項(Junk) | 小旗標所有出現過的組合,每個組合一個鍵 | is_gift、payment_type、order_channel → order_profile_key |
CREATE TABLE dim_order_profile (
order_profile_key INT PRIMARY KEY,
is_gift BOOLEAN NOT NULL,
payment_type TEXT NOT NULL, -- 'card' | 'wallet' | 'cod'
order_channel TEXT NOT NULL, -- 'web' | 'ios' | 'android'
UNIQUE (is_gift, payment_type, order_channel)
);
退化的 order_id 就是讓你能把訂單明細重新分組回訂單的東西。
6-3) 角色扮演維度
同一個實體維度以不同角色被 join 好幾次 —— 同一張 dim_date 分別當作下單日、出貨日
與送達日。
SELECT od.year_month AS order_month,
sd.year_month AS ship_month,
COUNT(*) AS orders
FROM fact_order_fulfillment f
JOIN dim_date od ON od.date_key = f.placed_date_key
JOIN dim_date sd ON sd.date_key = f.shipped_date_key
GROUP BY od.year_month, sd.year_month;
在 BI 工具或語意層裡,把每個角色各自暴露成一個 view(dim_order_date、
dim_ship_date),使用者就不必自己替 join 取別名。
6-4) 支架維度與迷你維度
- 支架維度(Outrigger) —— 一個參照另一個維度的維度,例如
dim_customer帶一個 指向dim_date的first_purchase_date_key。適量使用沒問題;太多會把星型變成雪花型。 - 迷你維度(Mini-dimension) —— 用於大型維度上經常變動的屬性(年齡區間、收入區間、
會員等級)。在
dim_customer上用 SCD2 追蹤它們,會讓數百萬客戶每變一次就多一列。 改成把這些區間值的每一種組合放進一張小的dim_customer_demographics,並讓事實表 同時帶兩個鍵。
fact_order_line
customer_key ──► dim_customer (slow attributes: name, signup date)
demographics_key ──► dim_customer_demographics (age_band, income_band, loyalty_tier
as they were when the order happened)
6-5) 多對多用的橋接表
當一筆事實對應到某個維度的多個成員時 —— 一個帳戶有多位持有人、一次門診有多個診斷 —— 就在它們之間放一張橋接表(bridge table),並帶一個權重因子,讓總計不會重複計算。
fact_account_balance ──► dim_account_group ◄── bridge_account_customer ──► dim_customer
(account_group_key) (account_group_key,
customer_key,
weight) -- weights per group sum to 1
-- balance per customer city without double-counting joint accounts
SELECT c.city,
SUM(f.balance_amount * b.weight) AS weighted_balance
FROM fact_account_balance f
JOIN bridge_account_customer b ON b.account_group_key = f.account_group_key
JOIN dim_customer c ON c.customer_key = b.customer_key
WHERE f.date_key = 20261031
GROUP BY c.city;
沒有權重時,聯名帳戶的餘額會依持有人數被算好幾次。有時候這正是業務想要的 (「影響力」報表),所以加權與不加權兩種查詢都應該向使用者說明清楚。
6-6) 階層
| 階層形狀 | 例子 | 建模方式 |
|---|---|---|
| 固定深度 | 商品 → 子類別 → 類別 | 攤平成同一個維度裡的欄位 |
| 不規則 / 深度可變 | 組織圖、物料清單、科目樹 | 父子欄位,再加一張橋接(closure)表做快速彙總 |
| 多重階層 | 日曆年 vs 會計年 | 同一個維度裡的不同屬性組 |
-- every employee under a given manager, any depth (parent-child, recursive CTE)
WITH RECURSIVE reports AS (
SELECT employee_key, manager_key, 1 AS depth
FROM dim_employee
WHERE manager_key = 42
UNION ALL
SELECT e.employee_key, e.manager_key, r.depth + 1
FROM dim_employee e
JOIN reports r ON e.manager_key = r.employee_key
)
SELECT * FROM reports;
Closure 表(ancestor_key, descendant_key, depth)預先算好每一對祖先與後代,所以彙總
就是一個普通的 join;樹不大時,查詢時用遞迴 CTE 也沒問題。
6-7) 未知成員與遲到的資料
- 未知 / 不適用成員 —— 在每個維度中保留像是鍵
-1=「Unknown」與-2= 「Not applicable」的列,讓事實指向它們而不是NULL。這樣 inner join 永遠不會丟掉事實, BI 使用者也會看到一個有標籤的分組。 - 遲到的維度(事實比它的維度列先到)—— 插入一個推定成員(inferred member):
只有自然鍵與佔位屬性的一列,並標記
is_inferred。真正的資料到了之後,原地覆寫這一列 (type 1),之後照常做 SCD2。 - 遲到的事實(事實在事件發生好幾天後才到)—— 查的是在事件日期當時有效的那個 維度版本,而不是現在的版本(見 7-4)。
7) 緩慢變化維度(SCD)優先度 5/5 — 必備 — 幾乎每一輪面試都會出現
7-1) Type 0 到 7
SCD Type 1 覆寫、不保留歷史;Type 2 關閉舊列並插入新列,讓每筆事實 join 到它發生當時 為真的那個版本。大多數面試只需要 1、2、3,並知道其他類型存在。
| 類型 | 行為 | 歷史 | 用於 |
|---|---|---|---|
| 0 | 保留原值,永不改變 | 只有原始值 | 生日、最初註冊的管道 |
| 1 | 原地覆寫 | 沒有 | 更正錯誤、沒有人會做歷史分析的屬性 |
| 2 | 新增一列,帶 valid_from / valid_to / is_current |
完整 | 地址、客群、價格等級 —— 任何要「照當時」報告的東西 |
| 3 | 新增一個 previous_<attr> 欄位 |
前一個值 | 一次性的組織調整(舊區域 vs 新區域) |
| 4 | 現況表 + 另一張歷史表(或一個迷你維度) | 完整,分開存 | 變動頻繁的屬性 |
| 5 | 迷你維度(4)+ 一個指向目前迷你維度列的 type-1 支架 | 完整 + 現值 | 人口統計分析同時看「當時」與「現在」 |
| 6 | 1 + 2 + 3:SCD2 的列再帶一個 type-1 的 current_<attr> 欄位 |
完整 + 每一列都帶現值 | 依歷史值或現值來報告歷史 |
| 7 | type-2 維度,事實表同時帶代理鍵與持久的自然鍵 | 完整 + 透過兩種 join 取得現值 | 和 6 一樣有彈性,但不必更新歷史列 |
7-2) 用 SQL 做 SCD2
關閉有變動的現行列,再為每個有變動或新的鍵插入新版本 —— 在同一個交易裡,並用 NULL-safe 的比較。
-- PostgreSQL. dim_customer(customer_sk identity PK, customer_id, address, segment,
-- valid_from, valid_to, is_current)
BEGIN;
UPDATE dim_customer d
SET valid_to = CURRENT_DATE - 1,
is_current = FALSE
FROM stg_customer s
WHERE d.customer_id = s.customer_id
AND d.is_current
AND (d.address, d.segment) IS DISTINCT FROM (s.address, s.segment); -- NULL-safe
INSERT INTO dim_customer (customer_id, address, segment, valid_from, valid_to, is_current)
SELECT s.customer_id, s.address, s.segment, CURRENT_DATE, DATE '9999-12-31', TRUE
FROM stg_customer s
LEFT JOIN dim_customer d
ON d.customer_id = s.customer_id AND d.is_current
WHERE d.customer_id IS NULL; -- brand-new customers, and the ones closed just above
COMMIT;
<>會漏掉變成NULL或從NULL變過來的變更,因為NULL <> 'x'的結果是 unknown,不是 true。- 要比較很多欄位時:比較每一列上存好的被追蹤欄位雜湊值(
hash_diff)。 valid_to = CURRENT_DATE - 1搭配包含兩端的BETWEENjoin,能讓日期區間不重疊。 若用時間戳,改用半開區間(valid_from <= t AND t < valid_to)。
7-3) 在 lakehouse 上用 MERGE 做 SCD2
在 Delta Lake(以及一般的 Spark SQL)上,SCD2 是對一份暫存來源做一次 MERGE,
這份來源把每個有變動的鍵列兩次:一次用來匹配並關閉舊列,另一次帶 NULL 的 merge key,
讓它落到 insert。
-- Spark SQL on Delta Lake. <=> is Spark's NULL-safe equality.
-- dim_customer.customer_sk is GENERATED ALWAYS AS IDENTITY.
MERGE INTO dim_customer AS t
USING (
SELECT s.customer_id AS merge_key, s.*
FROM stg_customer s
UNION ALL
SELECT CAST(NULL AS STRING) AS merge_key, s.* -- the new version of a changed key
FROM stg_customer s
JOIN dim_customer d
ON d.customer_id = s.customer_id AND d.is_current
WHERE NOT (d.address <=> s.address AND d.segment <=> s.segment)
) AS u
ON t.customer_id = u.merge_key AND t.is_current
WHEN MATCHED AND NOT (t.address <=> u.address AND t.segment <=> u.segment) THEN
UPDATE SET valid_to = date_sub(current_date(), 1), is_current = false
WHEN NOT MATCHED THEN
INSERT (customer_id, address, segment, valid_from, valid_to, is_current)
VALUES (u.customer_id, u.address, u.segment, current_date(), DATE '9999-12-31', true);
CAST(NULL AS STRING) 假設 customer_id 是字串;要配合鍵的型別。因為在 Delta 上
MERGE 是一次原子提交,所以不會有只套用一半的狀態。在 dbt 裡,snapshot 會幫你產生
同樣的邏輯(見 9-2)。
7-4) 把事實 join 到 SCD2 維度
在載入事實時,就依事件日期決定代理鍵;之後查詢只要用代理鍵 join,就會自動看到 「當時」的歷史。
-- at fact load time: pick the version valid on the order date
INSERT INTO fact_order_line (order_line_id, order_id, order_date_key, customer_sk, product_key, quantity, net_amount)
SELECT o.order_line_id,
o.order_id,
CAST(TO_CHAR(o.order_date, 'YYYYMMDD') AS INT),
COALESCE(c.customer_sk, -1), -- unknown member, never NULL
COALESCE(p.product_key, -1),
o.quantity,
o.net_amount
FROM stg_order_lines o
LEFT JOIN dim_customer c
ON c.customer_id = o.customer_id
AND o.order_date BETWEEN c.valid_from AND c.valid_to
LEFT JOIN dim_product p
ON p.product_id = o.product_id AND p.is_current;
| 問題 | Join 方式 |
|---|---|
| 依客戶當時的客群看營收 | fact.customer_sk = dim.customer_sk |
| 依客戶現在的客群看營收 | 透過持久鍵 join 到現行列(dim.customer_id = ... AND dim.is_current)—— 或用 type 6 / 7 |
8) 企業級做法:Inmon、Data Vault、One Big Table 優先度 3/5 — 值得會 — 多半是必備模式的變形
8-1) Inmon 的企業資訊工廠
Inmon 先建一個正規化(3NF)的企業資料倉儲,作為單一整合的事實來源,再從它衍生出 各部門的維度 mart。
Kimball (bottom-up) Inmon (top-down)
sources ─► staging ─► conformed stars sources ─► staging ─► 3NF EDW ─► dimensional marts
= the warehouse (integrated) (per department)
| Kimball | Inmon | |
|---|---|---|
| 第一個交付物 | 一個業務流程的星型 schema | 整合的企業模型 |
| 產生第一份價值的時間 | 短 | 長 |
| 整合方式 | 透過一致化維度 | 透過 3NF 核心 |
| 風險 | 沒有落實一致化時,mart 會各自漂移 | 在任何人拿到報表之前就是一個漫長的專案 |
8-2) Data Vault 2.0
Data Vault 把模型拆成 hub(業務鍵)、link(鍵之間的關係)與 satellite(隨時間變化的 描述屬性),所以新的來源或屬性就是一張新表,永遠不必修改既有的表。它是一個整合層 —— 報表用的星型仍要建在它上面。
| 表 | 存放 | 何時變動 |
|---|---|---|
| Hub | 每個業務鍵一列,加上它的雜湊鍵、載入時間、紀錄來源 | 出現新的鍵 |
| Link | 每個 hub 鍵之間的關係一列 | 出現新的關係 |
| Satellite | hub 或 link 的描述屬性,每次變更一列,帶 hash_diff |
屬性改變 |
-- PostgreSQL. Hash keys are MD5 of the normalised business key (32 hex chars).
CREATE TABLE hub_customer (
hub_customer_hk CHAR(32) PRIMARY KEY,
customer_bk TEXT NOT NULL, -- business key from the source
load_dts TIMESTAMP NOT NULL,
record_source TEXT NOT NULL
);
CREATE TABLE hub_order (
hub_order_hk CHAR(32) PRIMARY KEY,
order_bk TEXT NOT NULL,
load_dts TIMESTAMP NOT NULL,
record_source TEXT NOT NULL
);
CREATE TABLE link_order_customer (
link_order_customer_hk CHAR(32) PRIMARY KEY, -- hash of both business keys
hub_order_hk CHAR(32) NOT NULL REFERENCES hub_order,
hub_customer_hk CHAR(32) NOT NULL REFERENCES hub_customer,
load_dts TIMESTAMP NOT NULL,
record_source TEXT NOT NULL
);
CREATE TABLE sat_customer_details (
hub_customer_hk CHAR(32) NOT NULL REFERENCES hub_customer,
load_dts TIMESTAMP NOT NULL,
hash_diff CHAR(32) NOT NULL, -- hash of the attributes, to detect change
name TEXT,
email TEXT,
address TEXT,
record_source TEXT NOT NULL,
PRIMARY KEY (hub_customer_hk, load_dts)
);
- 只新增(insert-only):每張表都只 append;歷史就是 satellite。
- 平行載入:雜湊鍵由業務鍵算出,所以 hub、link、satellite 可以同時載入, 不必查序號。
- 代價:要重組任何東西都需要很多 join,所以 Data Vault 需要一個呈現層(星型、view) 與工具(像 AutomateDV 這類 dbt 套件)才管得動。
8-3) One Big Table 與 activity schema
- One Big Table(OBT) —— 把事實預先 join 上 dashboard 需要的每一個維度屬性,成為 一張寬表。在欄式引擎上又快又簡單,因為它只讀查詢用到的欄位。代價是:維度一改就得重建 這張表、歷史語意在建表時就被凍結,而且同一個屬性會在很多張 OBT 之間重複。把它從 星型建出來,當作服務用的表,而不是用它取代星型。
- Activity schema —— 每個實體一條窄的、依時間排序的串流(
customer_stream:activity_id、ts、customer、activity、幾個特徵欄位、revenue_impact),每個 業務事件都是一種 activity 類型。問題會變成在同一張表上做「第一次 / 最後一次 / 之間的 activity」查詢。它用前期少做建模,換來更複雜的查詢。
8-4) 什麼情況用哪一種
| 情境 | 合理的答案 |
|---|---|
| 產品團隊這一季就需要 dashboard | 在 raw / staging 層之上建 Kimball 星型 |
| 數十個來源、稽核要求高、來源經常變動 | 整合層用 Data Vault,上面再建星型 |
| 一個問題固定、非常熱門的 dashboard | 從星型建出一張 OBT |
| 已經有一個 3NF 企業資料倉儲 | Inmon 式:從它衍生 mart |
| 以事件為主的產品分析 | 事件事實表,也許再加 activity schema |
大多數面試官能接受的現代預設:raw(bronze)→ 清理並一致化(silver)→ 維度 mart 與 服務用的表(gold),而 mart 採 Kimball 的形狀。
9) 現代技術棧中的建模 優先度 4/5 — 高價值 — 這裡有缺口就會掉關
9-1) 在 medallion lakehouse 中,建模發生在哪裡
| 層 | 內容 | 建模風格 |
|---|---|---|
| Bronze / raw | 原樣收到的來源資料,只 append,加上載入中介資料 | 不建模 —— 保持可重播 |
| Silver / 清理後 | 去重、有型別、一致化的鍵,每個實體版本一列 | 對齊來源、接近 3NF 的實體,或 Data Vault |
| Gold / mart | 以事實與維度表達的業務流程、彙總表、OBT | Kimball 維度建模 |
分層講的是信任與重新處理,不是技術:silver 有 bug,就從 bronze 重建 silver 與 gold。
各層怎麼載入屬於 batch_pipeline.md。
9-2) dbt 專案的模型分層
dbt 的慣例對應的是同一個概念:staging 模型各自清理一張來源表,intermediate 模型把它們 組合起來,mart 則暴露事實與維度。
models/
staging/ stg_<source>__<table>.sql 1:1 with a source table: rename, cast, dedupe
intermediate/ int_<verb>_<entity>.sql joins and business logic, not exposed
marts/
core/ dim_customer.sql, fct_order_line.sql
finance/ fct_revenue_daily.sql
snapshots/ customers_snapshot.sql SCD2 of a mutable source table
-- snapshots/customers_snapshot.sql — dbt builds and maintains the SCD2 table
{% snapshot customers_snapshot %}
{{ config(
target_schema = 'snapshots',
unique_key = 'customer_id',
strategy = 'timestamp',
updated_at = 'updated_at'
) }}
select * from {{ source('app', 'customers') }}
{% endsnapshot %}
dbt 會加上 dbt_valid_from 與 dbt_valid_to 欄位;dbt_valid_to IS NULL 標示現行列。
在 mart 上的測試(unique、not_null、relationships)負責守住粒度與外鍵,而這些通常是
資料倉儲本身不會強制的。
9-3) 語意層與指標定義
語意層把每個指標只定義一次 —— 它的度量、彙總方式、過濾條件,以及能用哪些維度切分 —— 讓每個 dashboard、notebook 與 API 算出來的「營收」都一樣。
- 沒有語意層時,「活躍使用者」最後會在五個 dashboard 裡有五種定義。
- 工具:dbt Semantic Layer(MetricFlow)、LookML(Looker)、Cube,以及好幾家資料倉儲的 metric view。
- 底下的模型仍然是星型:語意層知道實體(join 鍵)、維度與度量,並自動產生 SQL。
# MetricFlow-style definition (the spec has changed across dbt versions — check yours)
semantic_models:
- name: order_lines
model: ref('fct_order_line')
defaults:
agg_time_dimension: order_date
entities:
- name: order_line
type: primary
expr: order_line_id
- name: customer
type: foreign
expr: customer_sk
dimensions:
- name: order_date
type: time
type_params:
time_granularity: day
measures:
- name: net_revenue
agg: sum
expr: net_amount
metrics:
- name: net_revenue
label: Net revenue
type: simple
type_params:
measure: net_revenue
10) 案例:電商資料倉儲 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現
10-1) 從需求到 bus matrix
面試題:「為一家網路商店設計分析模型。」先問它必須回答哪些問題,列出業務流程, 畫出 bus matrix,再詳細建模其中一個流程。
業務給的問題:
- 依商品類別、依日與月的營收與銷量;
- 依管道看從 session 到下單的轉換率;
- 依類別看退貨率;
- 依客群看營收,包括當時的與現在的客群。
Process Grain Date Customer Product Channel Promo
Orders one row per order line X X X X X
Returns one row per returned order line X X X X
Web sessions one row per session X X X
Inventory one row per product per warehouse per day X X
10-2) 訂單明細的星型模型
CREATE TABLE dim_customer (
customer_sk BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id TEXT NOT NULL, -- natural key
segment TEXT NOT NULL, -- SCD2-tracked
country TEXT NOT NULL, -- SCD2-tracked
signup_date DATE NOT NULL, -- type 0
valid_from DATE NOT NULL,
valid_to DATE NOT NULL,
is_current BOOLEAN NOT NULL
);
CREATE TABLE dim_product (
product_key BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
product_id TEXT NOT NULL,
product_name TEXT NOT NULL,
subcategory TEXT NOT NULL, -- flattened fixed-depth hierarchy
category TEXT NOT NULL,
brand TEXT NOT NULL,
is_current BOOLEAN NOT NULL
);
CREATE TABLE fact_order_line (
order_line_id BIGINT PRIMARY KEY,
order_id BIGINT NOT NULL, -- degenerate dimension
order_date_key INT NOT NULL REFERENCES dim_date (date_key),
customer_sk BIGINT NOT NULL REFERENCES dim_customer (customer_sk),
product_key BIGINT NOT NULL REFERENCES dim_product (product_key),
channel_key INT NOT NULL,
promotion_key INT NOT NULL, -- -2 = "no promotion"
quantity INT NOT NULL,
gross_amount NUMERIC(12, 2) NOT NULL,
discount_amount NUMERIC(12, 2) NOT NULL,
net_amount NUMERIC(12, 2) NOT NULL -- additive
);
值得說出口的設計決策:
- 粒度是訂單明細,所以商品是合法的維度;訂單層級的運費依金額分攤到明細, 或放在訂單層級的事實表;
unit_price不存成事實(不可加);需要時用net_amount / quantity算;- 客戶是 SCD2,所以「當時的客群」是一個普通的 join,「現在的客群」則透過
customer_id連到現行列; - 每個外鍵都是
NOT NULL,用未知與不適用成員取代NULL。
10-3) 回答這些問題
-- 1. revenue by category by month
SELECT d.year_month, p.category, SUM(f.net_amount) AS net_revenue, SUM(f.quantity) AS units
FROM fact_order_line f
JOIN dim_date d ON d.date_key = f.order_date_key
JOIN dim_product p ON p.product_key = f.product_key
GROUP BY d.year_month, p.category;
-- 4a. revenue by segment as it was when each order happened
SELECT c.segment, SUM(f.net_amount) AS net_revenue
FROM fact_order_line f
JOIN dim_customer c ON c.customer_sk = f.customer_sk
GROUP BY c.segment;
-- 4b. revenue by segment as it is today
SELECT cur.segment, SUM(f.net_amount) AS net_revenue
FROM fact_order_line f
JOIN dim_customer hist ON hist.customer_sk = f.customer_sk
JOIN dim_customer cur ON cur.customer_id = hist.customer_id AND cur.is_current
GROUP BY cur.segment;
問題 3 就是 4-5 的 drill-across 查詢。問題 2 需要 session 事實表:各自從自己的事實表算出 每個管道的 session 數與有下單的 session 數,再相除。
11) 設計題與答題方式 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現
11-1) 面對「為 X 建模」的固定流程
釐清問題、說出流程、宣告粒度,然後是維度、事實、SCD 的選擇,最後才是實體層面的考量 —— 照這個順序說出口。
- 釐清:誰會用、要回答哪些問題、要多新、要多少歷史、量有多大。
- 流程:列出來源系統產生的事件;畫出 bus matrix。
- 粒度:每張事實表一句話,盡可能用最細粒度。
- 維度:跨流程一致化;對每個屬性決定 SCD 類型。
- 事實:盡量可加;標出半可加與不可加的事實,以及它們該怎麼彙總。
- 邊界情況:遲到資料、未知成員、多對多、階層、刪除。
- 實體層:分區、clustering、載入模式 —— 交給
schema_design.md與batch_pipeline.md。
11-2) 常出現的題目
| 題目 | 關鍵的建模決定 |
|---|---|
| 叫車(行程) | 粒度是每一趟完成的行程一列;司機與乘客用 SCD2;上下車地點是角色扮演的地點維度 |
| 訂閱制業務 | 每月訂閱狀態的週期快照用來算 MRR;方案變更用交易型事實 |
| 音樂或影音串流 | 播放事件是交易型事實;一張無事實的「內容在某區域可播放」涵蓋表 |
| 銀行 | 每日餘額快照(半可加);聯名帳戶用橋接表 |
| 廣告 | 曝光與點擊是不同粒度的事實,只透過 drill-across 結合 |
| 訂單履約 SLA | 累積快照,每個里程碑一個日期鍵,加上時間差事實 |
| 社群網路 | 使用者與追蹤關係做成邊表;圖形問題可能值得用圖資料庫(見 sql_vs_nosql.md) |
12) 快問快答 優先度 4/5 — 高價值 — 這裡有缺口就會掉關
- 問:星型還是雪花型? —— 預設星型:join 較少、對 BI 使用者更簡單,而在欄式儲存上 冗餘會被壓縮掉。只有在有一個非常大、被共用的子維度,或某個工具需要時才用雪花型。
- 問:為什麼用代理鍵,而不是來源的 id? —— 代理鍵讓 SCD2 成為可能(同一個自然鍵有 多列),讓資料倉儲不受來源鍵變動與不同來源之間的鍵衝突影響,也讓未知成員有自己的鍵。
- 問:價格是事實還是維度屬性? —— 一筆明細實際付的金額是事實。描述商品的定價是維度 屬性(常用 SCD2)。明細上的單價是不可加的:改存延伸金額。
- 問:什麼是 fan trap? —— join 一個一對多關係後,又加總「一」那邊的度量,結果它在每個 子列都被重複一次。先在正確的粒度彙總。
- 問:什麼是 chasm trap? —— 兩個一對多的事實透過一個共用維度 join 起來,彼此的列數 互相放大。改用 drill-across。
- 問:用 SCD2,還是每天對整個維度做快照? —— SCD2 只存變更,很精簡;每日完整快照在 便宜的儲存上很好建也很好查,但大小會隨列數 × 天數成長。小維度負擔得起快照;大維度要用 SCD2。
- 問:事實的維度列還沒到,要怎麼處理? —— 插入一個帶自然鍵的推定成員,讓事實指向它, 等真正的資料到了再更新它。
- 問:來源端的刪除要怎麼建模? —— 不要在資料倉儲裡實體刪除歷史:關閉 SCD2 列並設一個
is_deleted旗標;而讓刪除一開始就看得見的,是 CDC。 - 問:什麼時候 One Big Table 才是對的答案? —— 當一組固定的問題要在欄式引擎上取得最低 延遲,而且它是從一個你可以拿來重建它的星型建出來的。
- 問:資料倉儲裡要正規化還是維度化? —— 兩者都要,放在不同層:整合層用正規化或 Data Vault,消費層用維度化。