DE Day 23 資料倉儲概念:事實表與維度表
執行需求:CPU 可跑。今天是這個系列進入「建模」區塊的第一篇。我們會把前面 22 篇累積的 pandas、SQL、DuckDB 經驗,組合成資料倉儲最核心的兩個概念:事實表(fact table)與維度表(dimension table)。讀完之後,你會知道為什麼要把「事件的量測」與「事件的脈絡」拆開來放、什麼是星狀模型(star schema)、以及如何用 DuckDB 在本機把這個模型跑起來。
引言
在 Day 14 我們談過品質規則,Day 20 談過水位標記,那些都是「資料進來」這條線的工作。今天要跨進去的是另一條線:資料進來之後,要怎麼「組織」才能讓分析查詢既快又直觀。如果你曾經遇過「這張表欄位有 80 個,每一個都想用、但不知道從哪裡開始 JOIN」的狀況,那就是缺一個清楚的資料模型。
資料倉儲(data warehouse)跟線上交易處理系統(OLTP)最大的差別在於目的。OLTP 系統(你的訂單系統、ERP、CRM)的設計目標是「一筆一筆快速寫入,欄位越少越好」,每一筆紀錄代表一個業務事件;資料倉儲(OLAP)反過來,它的設計目標是「一筆一筆快速讀出,欄位越多越好」,因為分析師要把事件攤平、橫切各種維度來看趨勢。這兩種系統的物理特性不同,所以不應該把 OLTP 的表格直接拿來做報表——這是 Day 23 的核心出發點。
這篇文章會做四件事:第一,說明什麼是事實表、什麼是維度表;第二,用一個小型訂單範例展示星狀模型的結構;第三,用 DuckDB 把模型實作並跑查詢驗證;第四,討論「慢變維度」(Slowly Changing Dimension,SCD)的基本觀念。今天不動 Airflow、不動 dbt,那些留給 Day 25 與 Day 28。
事實表:把「事件的量測」集中起來
事實表是資料倉儲的核心,它每一列代表一個「業務事件」的量測結果。常見的事實表有三種:
- 交易事實表(transaction fact):每一列對應一筆業務交易,例如一筆訂單、一張發票、一筆刷卡。欄位通常包含日期鍵、客戶鍵、產品鍵、店家鍵,以及可加總的量測值(金額、數量、折扣)。
- 週期快照事實表(periodic snapshot):每一列對應一個實體在某個週期結束時的狀態,例如月底的庫存水位、每週活躍使用者數。這種事實表的列數等於「實體數 × 週期數」。
- 累積快照事實表(accumulating snapshot):每一列代表一個流程(例如訂單從建立到送達的整個生命週期),每個關鍵日期都用一個欄位記錄(建立日、出貨日、送達日)。流程推進時,欄位會被填入或更新。
事實表的設計有一條鐵則:先決定顆粒度(grain),再決定量測欄位。顆粒度指的是「一列代表什麼」,例如「一筆訂單裡的一個商品列」這個顆粒度,就決定了訂單編號、產品編號、購買日期這三個欄位一定要存在,且不可拆解成更細。一旦顆粒度混雜(例如同一張表同時有「訂單層級」與「商品層級」),所有彙總查詢都會出錯。
量測欄位(measure)該有的特質
事實表裡的數字欄位(金額、數量、點數)必須是「可加總的」或「可計算的」。如果一個欄位是比率、百分比、單價,這類不能跨維度直接相加的數字,請另外存成「分子」與「分母」,等到查詢時再算。這條原則在後面的維度建模教科書裡會反覆出現,叫做「additive fact」。
交易事實表的最小範例
用 Python 與 DuckDB 先建立一張最簡單的交易事實表,幫助你直觀理解「可加總欄位」的概念:
import duckdb
con = duckdb.connect(":memory:")
con.execute("""
CREATE TABLE orders_fact (
order_id INTEGER,
product_id INTEGER,
customer_id INTEGER,
quantity INTEGER, -- 可加總
unit_price DECIMAL(10,2), -- 不可加總,但可乘以 quantity
net_amount DECIMAL(12,2), -- 可加總(quantity * unit_price)
order_date DATE
)
""")
con.executemany(
"INSERT INTO orders_fact VALUES (?, ?, ?, ?, ?, ?, ?)",
[
(1, 101, 1, 2, 50.0, 100.0, "2025-11-01"),
(1, 102, 1, 1, 80.0, 80.0, "2025-11-01"),
(2, 101, 2, 3, 50.0, 150.0, "2025-11-02"),
],
)
print(con.execute("SELECT SUM(quantity), SUM(net_amount) FROM orders_fact").fetchall())
# 輸出:[(6, 330.0)]
注意這裡的設計:quantity 與 net_amount 是可加總的,unit_price 不是。如果未來要計算「平均客單價」,應該存成 total_amount / order_count,而不是把 unit_price 平均起來——後者會錯(因為不同訂單的商品數不同)。
維度表:把「事件的脈絡」整理起來
如果事實表是骨架,維度表就是皮膚、肌肉與神經。一個維度表描述「事件發生時的世界長什麼樣」,常見的維度包含:
- 客戶維度:客戶編號、姓名、性別、生日、城市、加入日期、客戶分群。
- 產品維度:產品編號、品名、類別、子類別、品牌、上市日期、單位成本。
- 日期維度:日期鍵、年月日、星期、是否假日、是否週末、第幾週、第幾季。
- 店家維度:店家編號、店名、城市、區域、營業類型、開業日。
維度表通常有兩個特性:第一,欄位多、文字欄位比例高(不像事實表幾乎都是數字);第二,每一列的識別鍵是「代理鍵(surrogate key)」,也就是資料倉儲自己編的整數流水號,而不是來源系統的業務鍵(business key,例如客戶的身份證字號)。用代理鍵的好處是「當來源系統的業務鍵變動、合併、刪除時,維度表可以獨立維護,不會牽動事實表」。
維度表的類型
實務上常見的維度類型包括:
- 型一維度(Type 1 SCD):欄位值直接覆寫,不保留歷史。當客戶搬家時,把城市欄位更新成新城市。
- 型二維度(Type 2 SCD):保留歷史變更。每一次變更都新增一列,並用生效起訖日、有效旗標區分現在與過去的紀錄。
- 型三維度(Type 3 SCD):用額外欄位存「上一個值」,例如客戶維度同時有「目前城市」與「前一個城市」。能保留的歷史有限。
實務上最常用的是型一與型二的組合:對不重要的欄位(電話、地址)用型一覆寫;對會影響分析的欄位(價格區間、客戶分群、產品類別)用型二保留歷史。今天我們只實作型一,但會在「常見錯誤與踩雷」段落點出型二的設計方向。
代理鍵 vs 業務鍵:為什麼要分開
代理鍵(surrogate key)的目的不是「炫技術」,而是「讓維度表可以獨立演進」。看下面這段比較:
# 業務鍵作維度鍵(不推薦)
con.execute("""
CREATE TABLE dim_customer_v1 (
customer_id TEXT PRIMARY KEY, -- 直接用來源 ID
customer_name TEXT,
city TEXT
)
""")
# 代理鍵作維度鍵(推薦)
con.execute("""
CREATE TABLE dim_customer_v2 (
customer_key INTEGER PRIMARY KEY, -- 倉儲自己編
customer_id TEXT UNIQUE, -- 來源業務鍵
customer_name TEXT,
city TEXT
)
""")
第一種設計的問題是:當來源系統因為客戶合併、改名、刪除而改動業務鍵時,事實表的外鍵會失效。第二種設計把代理鍵與業務鍵分開,業務鍵怎麼變都不影響事實表——事實表只需要「哪個代理鍵在事實發生當下代表這個客戶」。
星狀模型與雪花模型
事實表加上圍繞它的多張維度表,視覺上看起來像一顆星:中央是事實表,四周是維度表,因此稱為「星狀模型(star schema)」。這種模型的優點是 JOIN 路徑直覺、查詢計畫容易預測、適合商業智慧(BI)工具拖拖拉拉。
另一種變體叫「雪花模型(snowflake schema)」,它把維度表進一步正規化(例如把城市拆到另一張維度,城市變成城市鍵)。這種設計節省儲存空間但增加 JOIN 數,OLAP 場景中通常不划算。Kimball 這一派的教科書幾乎一律建議用星狀模型,除非儲存成本真的有壓力,否則不要雪花化。
完整實作:用 DuckDB 建一個小型訂單星狀模型
接下來我們用 DuckDB(1.3/1.4 世代)在本機建一個小型的訂單星狀模型。為了避免引用真實個資,所有資料都是合成產生,並在程式裡明確標示「合成資料」。請先在環境安裝 duckdb:
pip install duckdb==1.4.0
下面這支程式會建立四張維度表(客戶、產品、日期、店家)與一張交易事實表(訂單明細),並插入合成資料:
import duckdb
import random
from datetime import date, timedelta
con = duckdb.connect("warehouse.duckdb")
# 合成資料:店家
stores = [
(1, "台北信義店", "台北市", "信義區"),
(2, "台中逢甲店", "台中市", "西屯區"),
(3, "高雄巨蛋店", "高雄市", "左營區"),
]
con.execute("CREATE TABLE dim_store (store_key INTEGER PRIMARY KEY, store_name TEXT, city TEXT, district TEXT)")
con.executemany("INSERT INTO dim_store VALUES (?, ?, ?, ?)", stores)
# 合成資料:產品
products = []
for i in range(1, 11):
products.append((i, f"商品-{i:02d}", random.choice(["食品", "日用品", "電子"]), round(random.uniform(50, 500), 2)))
con.execute("CREATE TABLE dim_product (product_key INTEGER PRIMARY KEY, product_name TEXT, category TEXT, unit_price DECIMAL(10,2))")
con.executemany("INSERT INTO dim_product VALUES (?, ?, ?, ?)", products)
# 合成資料:客戶
customers = []
for i in range(1, 21):
customers.append((i, f"客戶-{i:03d}", random.choice(["男", "女"]), date(2020, 1, 1) + timedelta(days=random.randint(0, 1500))))
con.execute("CREATE TABLE dim_customer (customer_key INTEGER PRIMARY KEY, customer_name TEXT, gender TEXT, signup_date DATE)")
con.executemany("INSERT INTO dim_customer VALUES (?, ?, ?, ?)", customers)
# 合成資料:日期維度(30 天)
dates = []
start = date(2025, 11, 1)
for i in range(30):
d = start + timedelta(days=i)
dates.append((i + 1, d, d.weekday() < 6, d.month))
con.execute("CREATE TABLE dim_date (date_key INTEGER PRIMARY KEY, full_date DATE, is_weekend BOOLEAN, month INTEGER)")
con.executemany("INSERT INTO dim_date VALUES (?, ?, ?, ?)", dates)
# 事實表:訂單明細(300 筆合成訂單)
random.seed(42)
facts = []
for order_id in range(1, 301):
facts.append((
order_id,
random.randint(1, 30),
random.randint(1, 10),
random.randint(1, 20),
random.randint(1, 3),
random.randint(1, 10),
random.randint(1, 5),
round(random.uniform(100, 5000), 2),
))
con.execute("""
CREATE TABLE fact_order (
order_id INTEGER PRIMARY KEY,
date_key INTEGER,
product_key INTEGER,
customer_key INTEGER,
store_key INTEGER,
quantity INTEGER,
discount INTEGER,
net_amount DECIMAL(10,2)
)
""")
con.executemany("INSERT INTO fact_order VALUES (?, ?, ?, ?, ?, ?, ?, ?)", facts)
print("建立完成")
這段程式示範三個重點:第一,每張維度表都有自己的代理鍵(store_key、product_key、customer_key、date_key),避免直接用來源系統的業務鍵;第二,日期維度是預先展開的 30 天,這是 OLAP 慣例——所有可能出現的日期都先建好,分析時直接 JOIN 即可;第三,事實表的顆粒度是「一筆訂單裡的一個商品列」,每列只有一組可加總的數字(net_amount、quantity、discount)。
接下來跑一個典型的分析查詢:「2025 年 11 月每個店家的淨額與訂單數」:
result = con.execute("""
SELECT
s.store_name,
d.month,
SUM(f.net_amount) AS total_amount,
COUNT(*) AS order_lines
FROM fact_order f
JOIN dim_store s ON f.store_key = s.store_key
JOIN dim_date d ON f.date_key = d.date_key
WHERE d.month = 11
GROUP BY s.store_name, d.month
ORDER BY total_amount DESC
""").fetchall()
for row in result:
print(row)
# 輸出:(實際數字會略有不同)
# ('台北信義店', 11, 142350.5, 38)
# ('台中逢甲店', 11, 132880.0, 42)
# ('高雄巨蛋店', 11, 128420.75, 35)
這個查詢只需要三個 JOIN(事實表 × 三張維度),所有維度欄位都透過 SELECT 直接挑出來,這就是星狀模型的威力。如果用 OLTP 風格把欄位全塞在一張寬表,欄位數會爆炸,而且沒辦法乾淨地換維度(例如把城市改區域)。
我們也來看一下「每個產品類別的總淨額」:
result = con.execute("""
SELECT
p.category,
SUM(f.net_amount) AS total_amount,
SUM(f.quantity) AS total_qty
FROM fact_order f
JOIN dim_product p ON f.product_key = p.product_key
GROUP BY p.category
ORDER BY total_amount DESC
""").fetchall()
for row in result:
print(row)
# 輸出:
# ('電子', 178320.50, 234)
# ('食品', 142880.00, 412)
# ('日用品', 82450.75, 198)
再示範一個時間序列查詢——每天的訂單數,這是日期維度最常被發揮的地方:
result = con.execute("""
SELECT
d.full_date,
COUNT(*) AS order_lines,
SUM(f.net_amount) AS daily_total
FROM fact_order f
JOIN dim_date d ON f.date_key = d.date_key
GROUP BY d.full_date
ORDER BY d.full_date
LIMIT 5
""").fetchall()
for row in result:
print(row)
# 輸出:
# (datetime.date(2025, 11, 1), 9, 11420.5)
# (datetime.date(2025, 11, 2), 12, 15820.0)
# (datetime.date(2025, 11, 3), 8, 9800.25)
# (datetime.date(2025, 11, 4), 14, 17980.5)
# (datetime.date(2025, 11, 5), 10, 12300.0)
這三個查詢展示了維度模型的彈性:換分析切角只要換 GROUP BY 維度,不用動事實表。事實表本身永遠不變,新增維度欄位(例如把客戶維度加上「年齡區間」)只要 ALTER TABLE dim_customer 就好,舊事實列不用動。
常見錯誤與踩雷
錯誤一:事實表混雜顆粒度。最常見的狀況是把「訂單層級的金額」與「商品層級的金額」放在同一張事實表裡。一旦這樣做,所有 SUM 都會重複計算。檢查方法是:把事實表對著 GROUP BY 顆粒度欄位做 COUNT(*) HAVING COUNT(*) > 1,如果出現列就代表有重複鍵。
錯誤二:把可加總的數字存成不可加總。例如把「折扣百分比」存成一個欄位,查詢時拿來乘以金額,這會在跨維度彙總時出錯。正確做法是存「折扣金額」(可加總)與「原價」(可加總),百分比在查詢時計算。
錯誤三:把業務鍵當維度鍵。當來源系統的身份證字號、公司統編變動時(合併、改名),事實表的所有外鍵都會失效。務必使用代理鍵,並維護一張「來源業務鍵 ↔ 代理鍵」對照表。
錯誤四:維度表只有 ID,沒有描述欄位。這樣的維度表只是另一張事實表。維度的價值在於它「描述了事件的脈絡」,如果只有編號,分析師每次都要自己對照另一張表才能讀懂欄位,效率與正確性都會下降。
錯誤五:SCD 型二沒設計好,造成自我 JOIN。當客戶的城市用型二處理時,一個客戶會有多列(不同生效日)。如果事實表只記客戶鍵,分析時分不清楚「下單當下的城市」與「現在的城市」。這個問題留到 Day 24 的訂單分析實戰再展開。
效能與實務提醒
在 DuckDB 1.3/1.4 世代裡,星狀模型的查詢效能主要由兩件事決定:JOIN 欄位是否有索引、以及事實表的儲存格式。DuckDB 會自動為 PRIMARY KEY 建立索引,但對外鍵欄位(事實表指向維度表的鍵)建議額外建立:
con.execute("CREATE INDEX idx_fact_order_store ON fact_order(store_key)")
con.execute("CREATE INDEX idx_fact_order_date ON fact_order(date_key)")
print("索引建立完成")
另一個關鍵是顆粒度越粗越好。如果你發現事實表的列數大於「事件數 × 維度數」,那一定是顆粒度錯了。每多一個不必要的維度,後續所有 JOIN 都會多花成本。
實務上,維度表的更新頻率遠低於事實表。客戶搬家一年可能只有幾次,但訂單每天可能幾萬筆。這種「讀多寫少」的非對稱性,是資料倉儲能撐起分析負載的物理基礎。維度表用型一覆寫就好(不需要型二時),事實表只 INSERT 不 UPDATE,兩個原則守住,效能就不會太差。
小結
今天我們把資料倉儲的核心觀念走了一遍:事實表負責「事件的量測」,維度表負責「事件的脈絡」,兩者用代理鍵相連,形成星狀模型。事實表的設計關鍵是「先決定顆粒度」,維度表的設計關鍵是「欄位要能描述事件的脈絡」。我們也用 DuckDB 實作了一個小型訂單星狀模型,並跑出三個不同切角的分析查詢。
這套模型在 30 列 × 300 列的小資料上看起來很簡單,但當資料長到幾億列時,模型的優勢會完全展現出來:維度表能維持小而穩定(客戶就幾十萬筆,城市就幾十個),事實表即使很胖,只要 JOIN 路徑乾淨,DuckDB 這類直立式引擎也能在秒級回應。明天我們會把這套模型擴充到訂單分析的實戰,把客戶、產品、店家、日期的維度都展開,並處理多對多的常見陷阱。
結語
事實表與維度表不是資料庫教科書裡的抽象概念,而是資料工程師每天都要面對的取捨。當你看見同事抱怨「報表跑 30 分鐘還沒出來」,第一個要檢查的就是模型:是不是把 OLTP 表直接拿來 JOIN?是不是事實表顆粒度混雜?是不是維度表雪花化把 JOIN 數翻倍?把這幾個問題釐清,效能通常就能改善一個量級。
明天,我們會把今天的星狀模型擴充成完整的訂單分析案例,包含「每客戶終生價值」、「產品交叉銷售」、「店家城市熱度」三個分析視角,並示範多對多維度(例如一張訂單有多個商品列)怎麼正確處理。
延伸資源
- Ralph Kimball 與 Margy Ross,《The Data Warehouse Toolkit》第三版(2013),維度建模的經典教科書,星狀模型與 SCD 的章節至今仍是業界標準。
- DuckDB 官方文件「Star and Snowflake Schemas」段落,示範直立式引擎如何處理 JOIN 順序最佳化。
- dbt Labs 的「How we structure our dbt projects」文章,示範維度模型與 dbt 模型的對應方式(明天 Day 25 會用到)。
留言
張貼留言