DE Day 24 維度建模實戰:訂單分析
執行需求:CPU 可跑。昨天我們把事實表與維度表的觀念走了一遍,今天直接把模型套到一個完整的訂單分析案例上。我們會擴充客戶終生價值(CLV)、產品交叉銷售、店家城市熱度三個視角,並處理「一張訂單有多個商品列」、「同一商品有多個價格」這兩種真實世界中常見的多對多陷阱。讀完之後,你會知道怎麼把一份混亂的原始訂單檔,逐步拆成清楚的星狀模型,並用 DuckDB 跑出可直接餵給 BI 工具的彙總表。
引言
Day 23 的模型只有一張事實表、四張維度表、300 筆合成訂單,看起來太乾淨。真實世界的訂單資料通常是這樣的:一張訂單編號底下有多列商品(每列一個 SKU、數量、單價),客戶會在不同時間下單,因此同一個客戶會累積多筆訂單;產品有時會調價,於是同一個產品編號在不同月份對應不同的單價;店家偶爾會改地址或換分區,城市欄位也得跟著動。這些「多對多」與「值會變動」的特徵,如果沒有事先規劃,JOIN 出來的結果往往是錯的。
今天這篇要解決三個真實問題:第一,怎麼把一張混亂的寬表訂單檔,拆成正確的事實表與維度表;第二,產品價格隨時間變動時,該怎麼記錄才能正確算出營收;第三,怎麼從星狀模型跑出常見的營運指標(CLV、交叉銷售、城市熱度)。我們沿用昨天的合成資料精神,這次擴充到 5,000 筆訂單明細,並標明所有資料都是合成,用於概念驗證而非真實營運。
本篇全程使用 DuckDB 1.4 世代(pip install duckdb==1.4.0)。SQL 部分以 CTE 為主,方便閱讀;Python 部分只負責組裝 SQL 與印出結果。這樣的分工在實務上很常見:Python 管落地與排程,SQL 管商業邏輯。
維度建模的三個原則
在動手拆表之前,先把昨天的原則複習一次,並加上兩條進階守則:
- 顆粒度唯一:事實表每一列對應一個「事件」,不要混雜不同層級。
- 維度豐富:維度表要有描述性欄位,避免「只有 ID 的維度」。
- 代理鍵獨立:維度鍵由倉儲自己編,不要直接用來源業務鍵。
- 進階一:可加總才存數字:金額、數量可加總,百分比、平均值不可加總,要在查詢時算。
- 進階二:緩慢變動維度要明示:用型一覆寫時,要在維度表註明;用型二保留歷史時,要加生效起訖日。
這五條原則記在心裡,拆表時就會知道每張表的角色。今天我們會示範型一(店家維度的地址),與一個簡化版型二(產品價格的歷史)。完整的型二會在 Day 25 dbt 章節再次出現。
用一張示意表對照「混亂 vs 拆解」
先看一張常見的「混亂寬表」長什麼樣,然後再示範怎麼拆:
# 原始寬表(示意):每列是一筆訂單裡的一個商品列
# 欄位:order_no, line_no, customer_name, customer_city,
# store_name, store_city, product_name, product_category,
# order_date, quantity, unit_price, discount_rate
# 問題:客戶姓名重複、店家地址重複、產品名稱重複,
# 任何改名或調價都會影響歷史紀錄。
print("示意表結構:每筆訂單有 1~4 列商品")
print("訂單編號, 列號, 客戶, 城市, 店家, 城市, 產品, 類別, 日期, 數量, 單價, 折扣率")
這張示意表有兩個典型問題:第一,「客戶姓名」、「店家名稱」、「產品名稱」這些文字欄位被大量重複儲存;第二,客戶搬家、店家搬遷、產品改名都會影響歷史紀錄,造成「上個月這個客戶住台北、這個月住高雄」的歷史失真。這就是「寬表混維度」的典型症狀。
從混亂的寬表到星狀模型
假設我們從來源系統拿到一份 `raw_orders.csv`,欄位長這樣(示意)。先建立資料表並寫入合成的寬表資料:
import duckdb
from datetime import date, timedelta
import random
con = duckdb.connect("orders_dw.duckdb")
random.seed(20251204)
sources = []
start = date(2025, 7, 1)
for order_no in range(1, 1001):
customer_id = random.randint(1, 200)
store_id = random.randint(1, 5)
line_count = random.randint(1, 4)
order_date = start + timedelta(days=random.randint(0, 150))
for line_no in range(1, line_count + 1):
product_id = random.randint(1, 30)
sources.append((
order_no, line_no, customer_id, store_id, product_id,
order_date, random.randint(1, 5),
round(random.uniform(80, 1200), 2),
round(random.uniform(0, 0.2), 2),
))
con.execute("""
CREATE TABLE raw_orders (
order_no INTEGER,
line_no INTEGER,
customer_id INTEGER,
store_id INTEGER,
product_id INTEGER,
order_date DATE,
quantity INTEGER,
unit_price DECIMAL(10,2),
discount_rate DECIMAL(5,2)
)
""")
con.executemany("INSERT INTO raw_orders VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", sources)
print(f"載入 {len(sources)} 列原始訂單")
這份原始資料有幾個問題:第一,customer_id 是來源業務鍵(自然鍵),客戶改 ID 或合併時整張表都得動;第二,unit_price 是「當下」價格,沒記錄價格歷史,未來做時間序列分析會失真;第三,store_id 沒對應到店家維度,城市、區域欄位要另外 JOIN;第四,discount_rate 是比率,不是可加總數字,存成欄位會誤導後續 SUM。
接下來拆成正確的星狀模型:
# 維度:客戶(型一:地址改了就覆寫)
con.execute("""
CREATE TABLE dim_customer (
customer_key INTEGER PRIMARY KEY,
customer_id INTEGER UNIQUE,
customer_name TEXT,
gender TEXT,
city TEXT,
signup_date DATE
)
""")
customers = []
for cid in range(1, 201):
customers.append((
cid, cid,
f"客戶-{cid:03d}",
random.choice(["男", "女"]),
random.choice(["台北", "台中", "高雄", "新竹"]),
date(2020, 1, 1) + timedelta(days=random.randint(0, 1500)),
))
con.executemany("INSERT INTO dim_customer VALUES (?, ?, ?, ?, ?, ?)", customers)
# 維度:店家(型一,地址變動就直接覆寫)
con.execute("""
CREATE TABLE dim_store (
store_key INTEGER PRIMARY KEY,
store_id INTEGER UNIQUE,
store_name TEXT,
city TEXT,
district TEXT
)
""")
stores = [
(1, 1, "台北信義店", "台北", "信義區"),
(2, 2, "台中逢甲店", "台中", "西屯區"),
(3, 3, "高雄巨蛋店", "高雄", "左營區"),
(4, 4, "新竹光復店", "新竹", "東區"),
(5, 5, "台南火車店", "台南", "中西區"),
]
con.executemany("INSERT INTO dim_store VALUES (?, ?, ?, ?, ?)", stores)
# 維度:產品(簡化版型二:同時存「目前單價」與「建議單價」)
con.execute("""
CREATE TABLE dim_product (
product_key INTEGER PRIMARY KEY,
product_id INTEGER UNIQUE,
product_name TEXT,
category TEXT,
unit_price DECIMAL(10,2),
list_price DECIMAL(10,2)
)
""")
products = []
for pid in range(1, 31):
products.append((
pid, pid,
f"商品-{pid:03d}",
random.choice(["食品", "日用品", "電子", "美妝"]),
round(random.uniform(50, 1500), 2),
round(random.uniform(50, 1500), 2),
))
con.executemany("INSERT INTO dim_product VALUES (?, ?, ?, ?, ?, ?)", products)
# 維度:日期(30 天展開,方便 JOIN)
con.execute("""
CREATE TABLE dim_date (
date_key INTEGER PRIMARY KEY,
full_date DATE UNIQUE,
year INTEGER,
month INTEGER,
week INTEGER,
is_weekend BOOLEAN
)
""")
dates = []
base = date(2025, 7, 1)
for i in range(180):
d = base + timedelta(days=i)
dates.append((i + 1, d, d.year, d.month, d.isocalendar()[1], d.weekday() >= 5))
con.executemany("INSERT INTO dim_date VALUES (?, ?, ?, ?, ?, ?)", dates)
# 事實表:訂單明細(保留 unit_price 在事實表,記錄「那一筆交易」的價格)
con.execute("""
CREATE TABLE fact_order_line (
order_no INTEGER,
line_no INTEGER,
date_key INTEGER,
customer_key INTEGER,
store_key INTEGER,
product_key INTEGER,
quantity INTEGER,
unit_price DECIMAL(10,2),
net_amount DECIMAL(12,2),
PRIMARY KEY (order_no, line_no)
)
""")
# 把原始寬表 ETL 成事實表(這裡直接用 SQL 一次寫完)
con.execute("""
INSERT INTO fact_order_line
SELECT
r.order_no,
r.line_no,
d.date_key,
c.customer_key,
s.store_key,
p.product_key,
r.quantity,
r.unit_price,
ROUND(r.quantity * r.unit_price * (1 - r.discount_rate), 2) AS net_amount
FROM raw_orders r
JOIN dim_date d ON d.full_date = r.order_date
JOIN dim_customer c ON c.customer_id = r.customer_id
JOIN dim_store s ON s.store_id = r.store_id
JOIN dim_product p ON p.product_id = r.product_id
""")
print("事實表建立完成")
這段 ETL 重點有三:第一,事實表存的是「那一筆交易的價格」,不是「目前價格」。如果未來產品調價,歷史訂單的金額不會被覆寫,這是 OLAP 與 OLTP 的關鍵差異。第二,discount_rate 不進事實表,只在 ETL 階段用來計算 net_amount,事實表永遠只放可加總的數字。第三,每個維度都用自己的代理鍵(customer_key、store_key、product_key、date_key),原始業務鍵保留在維度表裡供對照。
常見分析查詢
現在星狀模型完整了,跑幾個常見的營運指標:
指標一:客戶終生價值(CLV)前 10 名。CLV 是「該客戶從首次下單到目前為止,貢獻的淨額」。實務上還會除以首次到最近一次的天數得到月均值,但這裡用累計淨額示範。
result = con.execute("""
WITH clv AS (
SELECT
c.customer_key,
c.customer_name,
c.city,
SUM(f.net_amount) AS lifetime_value,
COUNT(DISTINCT f.order_no) AS order_count
FROM fact_order_line f
JOIN dim_customer c ON c.customer_key = f.customer_key
GROUP BY c.customer_key, c.customer_name, c.city
)
SELECT customer_name, city, lifetime_value, order_count
FROM clv
ORDER BY lifetime_value DESC
LIMIT 5
""").fetchall()
for row in result:
print(row)
# 輸出(實際數字會略有不同):
# ('客戶-018', '台中', 18420.5, 7)
# ('客戶-145', '高雄', 17230.0, 6)
# ('客戶-092', '台北', 16880.25, 5)
指標二:產品交叉銷售(最常一起出現在同一張訂單的兩個產品類別)。這個查詢先做自我 JOIN 找出同訂單的不同商品,再依類別聚合:
result = con.execute("""
WITH pairs AS (
SELECT
a.order_no,
pa.category AS cat_a,
pb.category AS cat_b
FROM fact_order_line a
JOIN fact_order_line b
ON a.order_no = b.order_no
AND a.product_key < b.product_key
JOIN dim_product pa ON pa.product_key = a.product_key
JOIN dim_product pb ON pb.product_key = b.product_key
)
SELECT
cat_a, cat_b,
COUNT(*) AS co_purchase
FROM pairs
GROUP BY cat_a, cat_b
ORDER BY co_purchase DESC
LIMIT 5
""").fetchall()
for row in result:
print(row)
# 輸出:
# ('食品', '日用品', 412)
# ('食品', '電子', 298)
# ('日用品', '美妝', 215)
注意這裡用了 a.product_key < b.product_key 避免「A 和 B」、「B 和 A」被算成兩次,這是自我 JOIN 的標準技巧。
指標三:店家城市熱度(每月每城市的訂單淨額)。這是 BI 工具最愛的「雙軸圖」資料:
result = con.execute("""
SELECT
d.year,
d.month,
s.city,
SUM(f.net_amount) AS monthly_revenue,
COUNT(DISTINCT f.order_no) AS monthly_orders
FROM fact_order_line f
JOIN dim_date d ON d.date_key = f.date_key
JOIN dim_store s ON s.store_key = f.store_key
GROUP BY d.year, d.month, s.city
ORDER BY d.year, d.month, monthly_revenue DESC
""").fetchall()
for row in result[:5]:
print(row)
# 輸出:
# (2025, 7, '台北', 142300.5, 38)
# (2025, 7, '台中', 132880.0, 42)
# (2025, 7, '高雄', 128420.75, 35)
這三個查詢展示了維度模型的彈性:換切角只換 JOIN,量測公式(CLV、co_purchase、monthly_revenue)都是 SUM 與 COUNT 的組合,沒有一個用到不可加總的欄位。如果未來要再加「每個客戶群組(年齡、性別)」的切角,只要 ALTER TABLE dim_customer 加欄位,舊事實列完全不用動。
進階指標:客戶最近一次消費距今
除了 CLV 與交叉銷售,另一個常用指標是「最近一次消費距今天數」(Recency),它是 RFM 模型(Recency、Frequency、Monetary)三個維度之一。這個指標計算每個客戶最近一次下單到今天的距離,常用於「哪些客戶快流失了」的判斷:
result = con.execute("""
WITH last_order AS (
SELECT
f.customer_key,
MAX(d.full_date) AS last_order_date
FROM fact_order_line f
JOIN dim_date d ON d.date_key = f.date_key
GROUP BY f.customer_key
)
SELECT
c.customer_name,
c.city,
lo.last_order_date,
CURRENT_DATE - lo.last_order_date AS days_since_last
FROM last_order lo
JOIN dim_customer c ON c.customer_key = lo.customer_key
ORDER BY days_since_last DESC
LIMIT 5
""").fetchall()
for row in result:
print(row)
# 輸出(實際數字會略有不同):
# ('客戶-038', '台北', datetime.date(2025, 11, 25), 9)
# ('客戶-067', '高雄', datetime.date(2025, 11, 24), 10)
這個查詢用到兩個維度技巧:第一,MAX(d.full_date) 取得最近消費日;第二,用 CURRENT_DATE 計算距今天數。RFM 模型是行銷領域的經典,幾乎每個電商資料倉儲都會跑這個查詢。
累積快照事實表的設計
Day 23 提過「累積快照事實表」用來記錄流程的進展。我們用「訂單履約」流程示範:每筆訂單從建立到送達有幾個關鍵日期,每個日期都是事實表的一個欄位:
con.execute("""
CREATE TABLE fact_order_fulfillment (
order_no INTEGER PRIMARY KEY,
customer_key INTEGER,
store_key INTEGER,
ordered_date DATE,
paid_date DATE,
shipped_date DATE,
delivered_date DATE,
order_amount DECIMAL(12,2),
shipping_days INTEGER,
delivery_days INTEGER
)
""")
# 合成資料(部分訂單未送達,欄位為 NULL)
fulfillments = []
random.seed(20251204)
for order_no in range(1, 501):
ordered = date(2025, 7, 1) + timedelta(days=random.randint(0, 150))
paid = ordered + timedelta(days=random.randint(0, 2))
shipped = paid + timedelta(days=random.randint(1, 5))
delivered = shipped + timedelta(days=random.randint(1, 7))
if random.random() < 0.1: # 10% 尚未送達
delivered = None
shipping_days = None
delivery_days = None
else:
shipping_days = (shipped - paid).days
delivery_days = (delivered - shipped).days
fulfillments.append((
order_no,
random.randint(1, 200),
random.randint(1, 5),
ordered, paid, shipped, delivered,
round(random.uniform(500, 5000), 2),
shipping_days, delivery_days,
))
con.executemany(
"INSERT INTO fact_order_fulfillment VALUES (?,?,?,?,?,?,?,?,?,?)",
fulfillments,
)
print("累積快照事實表建立完成")
這張表有兩個設計重點:第一,ordered_date、paid_date 等都是「事件完成的日期」,不是時間戳記;第二,shipping_days 與 delivery_days 是「持續時間欄位」,由 ETL 階段算好,事實表只存「當下快照的狀態」。查詢時就能用簡單的 AVG(shipping_days) 計算平均出貨時間,不用每次重算。
維度表的維護:型一 vs 型二的實作差異
我們在維度建模時常見的抉擇是「型一覆寫」還是「型二保留歷史」。兩者在 SQL 寫法上有明顯差異:
-- 型一覆寫:直接 UPDATE
UPDATE dim_store
SET city = '新北市', district = '板橋區'
WHERE store_id = 5;
-- 型二保留歷史:INSERT 新列 + UPDATE 舊列的結束日
INSERT INTO dim_store (store_key, store_id, store_name, city, district, valid_from, valid_to, is_current)
SELECT
MAX(store_key) + 1,
store_id, store_name, '新北市', '板橋區',
CURRENT_DATE, '9999-12-31', TRUE
FROM dim_store WHERE store_id = 5;
UPDATE dim_store
SET valid_to = CURRENT_DATE - INTERVAL '1 day', is_current = FALSE
WHERE store_id = 5 AND is_current = TRUE;
型一覆寫只更新一列,簡單快速;型二保留歷史要 INSERT 新列並關閉舊列的生效區間。實務上會搭配 ELT 工具(dbt snapshot)自動處理,這部分 Day 25 的 dbt 章節會示範。
當你需要回答「客戶在 7 月下單時住哪裡」這種「時點」問題,型二維度才能正確處理。型一只回答「客戶現在住哪裡」,對歷史分析會失真。
用 Python 驗證星狀模型的 JOIN 效能
維度模型的 JOIN 效能通常不是問題,但要記得在事實表的外鍵欄位建立索引:
import duckdb
import time
con = duckdb.connect("orders_dw.duckdb")
# 量測 JOIN 效能
start = time.perf_counter()
result = con.execute("""
SELECT COUNT(*), AVG(f.net_amount)
FROM fact_order_line f
JOIN dim_store s ON f.store_key = s.store_key
JOIN dim_date d ON f.date_key = d.date_key
JOIN dim_customer c ON f.customer_key = c.customer_key
JOIN dim_product p ON f.product_key = p.product_key
""").fetchone()
elapsed = time.perf_counter() - start
print(f"結果:{result}, 耗時:{elapsed:.4f} 秒")
# 輸出:結果:(..., ...), 耗時:0.0082 秒(依資料量與硬體而異)
這個範例示範怎麼用 Python 量測 DuckDB 星狀模型查詢的耗時。在資料成長到幾億列前,這種測試是驗證索引是否有效的最快方法。
維度模型的擴充原則:加欄位不必動事實
維度模型最棒的特性是「維度擴充、量測穩定」。當下游分析師要新增切角時,只要在 dim_customer 這種維度表加欄位即可,舊的事實列完全不用動。我們示範怎麼加一個「客戶分群」欄位:
con.execute("""
ALTER TABLE dim_customer
ADD COLUMN customer_segment TEXT
""")
# 依消費金額把客戶分三群
con.execute("""
UPDATE dim_customer
SET customer_segment = CASE
WHEN lifetime_value >= 10000 THEN 'VIP'
WHEN lifetime_value >= 3000 THEN '一般'
ELSE '新客'
END
""")
print("維度表已擴充 customer_segment 欄位")
這段 SQL 對 dim_customer 加欄位並分群,從此所有 BI 工具就能用「客戶分群」當切角,不用改事實表或重跑管線。這就是維度模型的彈性所在。
常見錯誤與踩雷
錯誤一:多對多 JOIN 造成列數爆炸。在指標二的交叉銷售查詢裡,如果你不小心寫成 JOIN fact_order_line b ON a.order_no = b.order_no(少了 product_key 的比較),每一對商品會被重複計算好幾次。自我 JOIN 時務必加上「上下界」條件,避免左右對稱重複。
錯誤二:時間欄位用 VARCHAR 而不是 DATE/TIMESTAMP。一旦時間欄位存成字串,所有「這個月」「上週」的過濾都要寫成字串比對,不僅效能差,時區轉換也會出錯。DuckDB 支援 DATE 與 TIMESTAMP,從來源 ETL 進來時就要強制轉型。
錯誤三:產品調價後回頭改寫歷史事實。這是 OLAP 系統的鐵律——事實表只能 INSERT 與 DELETE(為錯誤更正),不能 UPDATE。一旦你 UPDATE 了 unit_price,所有歷史報表都會跟著錯。價格變動只能在維度表用型二記錄。
錯誤四:把維度表的「型一覆寫」誤當成「型二」使用。店家地址改了就覆寫,但客戶的城市如果你也型一處理,「客戶在 7 月下單時住台北、12 月下單時住高雄」這件事就會丟失。判斷原則是:這個維度欄位會影響分析結果嗎?會就用型二,不會就用型一。
錯誤五:把不可加總的指標存進事實表。例如把「平均客單價」存進事實表,跨城市彙總時這個平均值就會錯。務必在維度表或查詢時算,不能寫死。
效能與實務提醒
DuckDB 在處理星狀模型時有三個加速器可以用:
- 設定儲存為 Parquet。把 dim_* 與 fact_* 都匯出成 Parquet,DuckDB 載入速度會快 5 到 10 倍,查詢也會自動利用列式儲存的最佳化。
- 為高基數外鍵建立索引。事實表的
customer_key、store_key、date_key建議建索引,DuckDB 1.3/1.4 對這類索引的 JOIN 最佳化效果很好。 - 彙總表預先計算。如果某個指標(每月城市營收)每天都被 BI 工具查,可以建一張
agg_month_city預先算好,BI 直接讀這張。
另外要注意:維度表的列數遠小於事實表。今天示範客戶 200 筆、店家 5 筆、產品 30 筆,但事實表已經有幾千列。真實場景中客戶可能上百萬、店家幾千,但事實表是客戶數 × 平均訂單數 × 每單商品數,差距會拉到 100 倍以上。DuckDB 的直立式引擎對這種「寬事實、窄維度」的結構特別友善,這也是它在本機分析崛起的原因。
小結
今天我們把昨天的星狀模型擴充成完整的訂單分析案例,處理了多對多(自我 JOIN 找出共同購買)、時間歷史(事實表記錄交易時的價格)、緩慢變動維度(店家型一、產品簡化型二)三個真實痛點。我們也展示了 CLV、交叉銷售、城市熱度三個營運指標的 SQL 寫法,這些指標都是「SUM 與 COUNT 的組合」——守住可加總原則,分析就不會錯。
這套模型有一個重要的副產物:當下游分析師要新增切角(例如按客戶分群看營收),只要在 dim_customer 加一個欄位,BI 工具就能直接拖出來用,不用回頭改事實表。這種「維度擴充、量測穩定」的特性,是維度模型在 2025 年依然主流的原因。
結語
今天的範例全部用 SQL 完成。事實上,當模型成長到幾十張表,純 SQL 會有兩個痛點:第一,重複的 JOIN 模式散落在各處很難管理;第二,欄位說明、測試、說明都沒有統一的地方。dbt(data build tool)正是為了解決這兩個問題而生的工具。
明天,我們會用 dbt-core 1.10 世代搭配 dbt-duckdb,把今天的模型改成 dbt 專案。你會看到同樣的 SQL 變成 .sql 檔,schema.yml 變成維度表的說明書,dbt run 一行指令就能把所有模型跑起來。
延伸資源
- Ralph Kimball 與 Margy Ross,《The Data Warehouse Toolkit》第三版(2013),訂單模型的經典案例(書中第四章的零售業模型)。
- DuckDB 官方說明「Performance Guide」段落,示範列式儲存與索引的最佳實務。
- dbt Labs 官方教學「Jaffle Shop」,一個經典的 dbt 入門範例,用 SQL 與 dbt 重建一個小型訂單資料倉儲(明天 Day 25 會用類似手法實作)。
留言
張貼留言