資料工程師的資料建模

資料工程 更新於 Nov 12, 2026
章節優先度優先度 5/5 — 必備 — 幾乎每一輪面試都會出現必備優先度 4/5 — 高價值 — 這裡有缺口就會掉關高價值優先度 3/5 — 值得會 — 多半是必備模式的變形值得會優先度 2/5 — 冷門 — 讀過一次即可,除非目標公司已知會問冷門只標在真正需要的章節上 —— 沒標的是背景/參考資料。

範圍 — 分析用的邏輯資料建模,以及它會引出的面試題:建模層次、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, ...)
text
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) 為鍵的寬表開始:

text
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) 四步驟設計流程

選業務流程、宣告粒度、找出維度、找出事實 —— 順序不能換,因為粒度決定了另外兩者 能是什麼。

  1. 業務流程 —— 會產生事件的營運活動:接單、出貨、訂閱續約。不是部門,也不是報表。
  2. 粒度 —— 一筆事實列到底代表什麼。
  3. 維度 —— 在那個粒度下成立的誰、什麼、哪裡、何時、為什麼、如何。
  4. 事實 —— 在那個粒度下成立的數值度量。

4-2) 粒度:模型裡最重要的一句話

用業務語言把粒度寫成一句話 ——「每一筆訂單明細的每個商品一列」—— 並拒絕任何在 這個粒度下不成立的維度或事實。

  • 優先選**最細(atomic)**的粒度(來源能提供的最低層級)。往上彙總永遠做得到; 往下鑽到比你存的更細則永遠做不到。
  • 一張表裡混了不同粒度,是大部分重複計算 bug 的根源:把訂單層級的 shipping_fee 複製到每一筆明細上,加總時就會每筆明細算一次。
  • 對粒度較粗的事實,解法是把它**分攤(allocate)**下去(依明細金額分攤運費), 或把它放在另一張訂單層級的事實表。

4-3) 事實與可加性

一個事實若能沿所有維度加總就是可加的,能沿部分維度加總是半可加的,都不能加總則是 不可加的 —— 這個類型決定 BI 工具該怎麼彙總它。

類型 例子 可以加總的方向 改用的彙總方式
可加(Additive) 銷售金額、數量 所有維度 SUM
半可加(Semi-additive) 帳戶餘額、庫存量 帳戶、商品 —— 但不能跨時間 期末值,或跨時間取 AVG
不可加(Non-additive) 單價、毛利率 %、比率 都不行 存分子與分母,最後才算比率
sql
-- 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 扁平、反正規化的維度;雪花型則把維度正規化成子表。 預設選星型。

text
            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 則是一張計畫表,列出哪些流程 共用哪些維度。

text
                      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)。
sql
-- 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) 一張累積快照,從頭到尾

累積快照每個里程碑一個日期鍵,以及里程碑之間的時間差作為事實,而每當流程往前推進, 它的那一列就會被更新。

sql
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。

日期維度是每個資料倉儲都有的那一個維度。它是生成的而不是載入的,並承載了 沒有人該在每個查詢裡重新推導的日曆邏輯:

sql
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
sql
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 分別當作下單日、出貨日 與送達日。

sql
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,並讓事實表 同時帶兩個鍵。
text
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),並帶一個權重因子,讓總計不會重複計算。

text
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
sql
-- 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 會計年 同一個維度裡的不同屬性組
sql
-- 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 的比較。

sql
-- 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 搭配包含兩端的 BETWEEN join,能讓日期區間不重疊。 若用時間戳,改用半開區間(valid_from <= t AND t < valid_to)。

7-3) 在 lakehouse 上用 MERGE 做 SCD2

在 Delta Lake(以及一般的 Spark SQL)上,SCD2 是對一份暫存來源做一次 MERGE, 這份來源把每個有變動的鍵列兩次:一次用來匹配並關閉舊列,另一次帶 NULL 的 merge key, 讓它落到 insert。

sql
-- 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,就會自動看到 「當時」的歷史。

sql
-- 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。

text
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 屬性改變
sql
-- 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 則暴露事實與維度。

text
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
sql
-- 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。
yaml
# 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,再詳細建模其中一個流程。

業務給的問題:

  1. 依商品類別、依日與月的營收與銷量;
  2. 依管道看從 session 到下單的轉換率;
  3. 依類別看退貨率;
  4. 依客群看營收,包括當時的與現在的客群。
text
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) 訂單明細的星型模型

sql
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) 回答這些問題

sql
-- 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 的選擇,最後才是實體層面的考量 —— 照這個順序說出口。

  1. 釐清:誰會用、要回答哪些問題、要多新、要多少歷史、量有多大。
  2. 流程:列出來源系統產生的事件;畫出 bus matrix。
  3. 粒度:每張事實表一句話,盡可能用最細粒度。
  4. 維度:跨流程一致化;對每個屬性決定 SCD 類型。
  5. 事實:盡量可加;標出半可加與不可加的事實,以及它們該怎麼彙總。
  6. 邊界情況:遲到資料、未知成員、多對多、階層、刪除。
  7. 實體層:分區、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,消費層用維度化。