不遵循正規形式會為前面提過的異常敞開大門。有些失敗模式在業界太常見,足以稱為反模式(anti-pattern)。其中最糟糕的設計選擇之一,就是 EAV 模型。
Entity Attribute Values#
**實體屬性值(Entity Attribute Values, EAV)**是一種為了應付「規格不明」而生的設計:應用程式要管理參數,每次發版可能新增參數,也不清楚到底需要哪些,只想要一個好塞東西的地方——反正都有資料庫了:
begin;
create schema if not exists eav;
create table eav.params
(
entity text not null,
parameter text not null,
value text not null,
primary key(entity, parameter)
);
commit;你很可能在現場看過這個模型或其變形。它加東西很容易,但要理解累積的資料、或在 SQL 中有效使用,非常困難——這正是反模式的特徵。
insert into eav.params(entity, parameter, value)
values ('backend', 'log_level', 'notice'),
('backend', 'loglevel', 'info'),
('api', 'timeout', '30'),
('api', 'timout', '40'),
('gold', 'response time', '60'),
('gold', 'escalation time', '90'),
('platinum', 'response time', '15'),
('platinum', 'escalation time', '30');範例故意打錯字(loglevel、timout)以凸顯 EAV 的極限:這種錯誤無從攔截,程式碼各處可能查的是不同拼法。
EAV 反模式的主要問題:
value是 text 型別以便裝下任何東西,但有些參數其實是 integer、interval、inet 或 boolean。entity與parameter同樣是自由文字,任何打錯字都會創造新的項目,而且可能根本沒被應用程式用到。- 撈出某實體的所有參數來組應用程式物件時,參數名是每列中的一個值而非欄位名,需要額外的處理與迴圈。
- 要在 SQL 查詢中處理參數,每個參數都得多加一個 join。
最後一點的實例:先建一組客戶與支援合約的表(support_contract_type、support_contract(含 exclude using gist 防止效期重疊)、customer、support),然後要取回客戶支援合約的回應時間與升級時間,每個參數各要一個 join:
select customer.id,
customer.name,
ctype.name,
rtime.value::interval as "resp. time",
etime.value::interval as "esc. time"
from eav.customer
join eav.support
on support.customer = customer.id
join eav.support_contract as contract
on support.contract = contract.id
join eav.support_contract_type as ctype
on ctype.id = contract.type
join eav.params as rtime
on rtime.entity = ctype.name
and rtime.parameter = 'response time'
join eav.params as etime
on etime.entity = ctype.name
and etime.parameter = 'escalation time';每加一個參數就得在查詢裡多加一個 join;而且若有人輸入的 response time 值不符 interval 型別的表示法,查詢直接失敗。
若業務確實存在「屬性易變」的問題,正解是:模型盡可能扎實,然後用 jsonb 欄位作為擴充點。
單一欄位塞多值#
回顧 1NF 的條件:無重複列、每個儲存格單一值(無重複群組或陣列)、同欄項目同類。違反這些規則的常見反模式,就是在綱要中放多值欄位:
create table tweet
(
id bigint primary key,
date timestamptz,
message text,
tags text
);資料用分號、豎線 |,甚至 §、¶、¦ 這類花俏的 Unicode 分隔符塞進去,例如 #Endomondo;#endorphins。
PostgreSQL 雖有 regexp_split_to_array()、regexp_split_to_table() 可以相對理智地處理這種資料,但違反 1NF 的問題在於資料集幾乎無法維護——前面列過的所有資料庫異常一應俱全。把多個 tag 藏在帶分隔符的文字欄位裡,以下幾件事都變得很難:
- Tag 搜尋:找出包含某個 tag 的訊息被迫用子字串搜尋,效率遠差於直接搜尋。正規化模型會有獨立的
tags表與tweet_tags關聯表,搜尋只是一個帶條件的 join;要搜「同時含多個 tag」或「含清單中任一 tag」也容易。在 CSV 式反模式上做這些複雜搜尋則困難得多,甚至不可行——與其硬撐,不如修模型。 - 各 tag 使用統計:同理,
tags欄位是被當成一整塊看待的,逐 tag 統計很難做。 - Tag 正規化:人會打錯字、用不同拼法,我們會想在資料庫裡正規化 tag(原始訊息在另一欄,不會遺失資料)。有 tags 參照表時在輸入端正規化是小事;在 CSV 欄位上則成了大工程——得掃過所有訊息、每次都拆分 tags。
這個例子很像過早最佳化(premature optimization)。高德納(Donald Knuth)的原話:
程式設計師浪費大量時間去想、去擔心程式中非關鍵部分的速度,而這些對效率的企圖在考慮除錯與維護時其實有強烈負面影響。約 97% 的情況下我們應該忘掉小處的效率:過早最佳化是萬惡之源。但在那關鍵的 3%,我們也不該放過機會。
用 CSV 格式的欄位取代兩張額外的表看似最佳化,實際上讓幾乎一切都更糟:除錯、維護、搜尋、統計、正規化,全部中招。
UUID#
PostgreSQL 的 UUID 型別提供 128 位元的合成鍵,相對於 serial 的 32 位元、bigserial 的 64 位元。
- serial 家族建立在序列(sequence)之上,衝突行為有標準定義。序列是非交易性的,讓多個並行交易各自取號、再各自 commit 或 rollback——因此號碼單調遞增、取用順序無法預知、且中間會有空洞;但序列作為合成鍵預設值保證不會碰撞。
- UUID 則靠 128 位元空間內的隨機數產生,理論上有很強的防碰撞保證,但仍可能(極少數情況)需要重試。
UUID 適用於分散式運算:當你無法讓所有並行、分散的交易同步到一個集中式序列(那會成為單點故障,Single Point Of Failure, SPOF)時。
話說回來,如同主鍵一節所見:序列與 UUID 都不是資料的自然主鍵。