不遵循正規形式會為前面提過的異常敞開大門。有些失敗模式在業界太常見,足以稱為反模式(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');

範例故意打錯字(logleveltimout)以凸顯 EAV 的極限:這種錯誤無從攔截,程式碼各處可能查的是不同拼法。

EAV 反模式的主要問題:

  • value 是 text 型別以便裝下任何東西,但有些參數其實是 integer、interval、inet 或 boolean。
  • entityparameter 同樣是自由文字,任何打錯字都會創造新的項目,而且可能根本沒被應用程式用到。
  • 撈出某實體的所有參數來組應用程式物件時,參數名是每列中的一個值而非欄位名,需要額外的處理與迴圈。
  • 要在 SQL 查詢中處理參數,每個參數都得多加一個 join

最後一點的實例:先建一組客戶與支援合約的表(support_contract_typesupport_contract(含 exclude using gist 防止效期重疊)、customersupport),然後要取回客戶支援合約的回應時間與升級時間,每個參數各要一個 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 都不是資料的自然主鍵