回到關聯式資料庫管理系統,它能為應用程式提供的是:

  • 存取資料、執行交易的服務
  • 在多個應用程式基座之間保證一致性的共同 API
  • 與資料庫服務交換資料的傳輸機制

本章的焦點是 ACID 中的 C——資料一致性(consistency)。應用程式一旦成長,就會拆成多個部分:管理面板、客服後台、對外前台、會計與財務報表,也許還有業務後台等;其中一些可能採用第三方方案,就算全部自製,不同部分也常用不同技術棧(Go 或 Java 後端、Python/Django 或 Ruby on Rails、PHP、Node.js…)。要讓這一群應用程式協同運作、遵守同一套商業規則,需要一個能保證整體一致性的核心系統——這正是 RDBMS 要解決的主要問題,也是關聯模型如此通用的原因。下一章〈資料塑模〉會把 schemaless 與關聯式塑模拿來比較;在那之前,先要理解 PostgreSQL 是怎麼保證一致性的。

屬性值、資料域與資料型別#

  • 資料域(data domain)就像數學裡的概念:一組被賦予共同名稱的值的集合,例如自然數域、有理數域。
  • 在關聯理論中,基本資料域可以組合成元組(tuple):元組是一串屬性的列表;關聯(relation)則是一串元組的列表,所有元組共享同一份屬性域清單——名稱與資料型別。
  • 關聯模型的基礎,就是在資料集合內建立一致性:把資料結構化到「我們知道自己在處理什麼」,並且能夠強制執行商業約束。

第一個被強制執行的商業約束是「資料必須正確」。例如 PostgreSQL 的 timestamp 型別實作了格里曆(Gregorian Calendar)——沒有零年、零月、零日。其他系統可能接受任何「長得像 timestamp」的文字,PostgreSQL 則真的檢查值在格里曆中是否成立:

select date '2010-02-29';
ERROR: date/time field value out of range: "2010-02-29"

2010 年不是閏年,所以 2010-02-29 不是合法日期。順帶一提,這種輸入語法稱為修飾字面值(decorated literal):用資料型別修飾字面值,PostgreSQL 就不必猜它是什麼。

再試試惡名昭彰的零時戳:

select timestamp '0000-00-00 00:00:00';
ERROR: date/time field value out of range: "0000-00-00 00:00:00"

格里曆沒有零年——西元前 1 年之後直接是西元 1 年:

select date(date '0001-01-01' + x * interval '1 day')
  from generate_series (-2, 1) as t(x);
     date
═══════════════
 0001-12-30 BC
 0001-12-31 BC
 0001-01-01
 0001-01-02
(4 rows)

實作格里曆不是必須忍受的限制,而是可以善加利用的強大選擇:PostgreSQL 懂閏年、懂時區,日期時間型別還支援有意義的字面值:

select date 'today' + time 'allballs' as midnight;
      midnight
═════════════════════
 2017-08-14 00:00:00
(1 row)

一致性與資料型別行為#

PostgreSQL 資料型別的關鍵之處在於行為(behavior)。與物件導向系統類似,PostgreSQL 實作了函式與運算子的多型(polymorphism),在執行期依參數型別派發程式碼。看一個極簡單的查詢:

select code from drivers where driverid = 1;

driverid = 1 這個運算式在欄位與字面值之間使用了 = 運算子。PostgreSQL 從系統目錄(catalog)得知 driverid 是 bigint,而字面值 1 被解析為 integer:

select pg_typeof(driverid), pg_typeof(1) from drivers limit 1;
 pg_typeof │ pg_typeof
═══════════╪═══════════
 bigint    │ integer
(1 row)

那 8 位元組整數與 4 位元組整數之間的 = 是怎麼實作的?這個決定是動態的:= 運算子依左右運算元的型別派發到一個既定的函式。我們可以直接查 PostgreSQL 的目錄:

  select oprname, oprleft::regtype, oprcode::regproc
    from pg_operator
   where oprname = '='
     and oprleft::regtype::text ~ 'int|time|text|circle|ip'
order by oprleft;
查詢輸出:24 個 = 運算子實例
 oprname │            oprleft          │         oprcode
═════════╪═════════════════════════════╪══════════════════════════
 =       │ bigint                      │ int84eq
 =       │ bigint                      │ int8eq
 =       │ bigint                      │ int82eq
 =       │ smallint                    │ int28eq
 =       │ smallint                    │ int2eq
 =       │ smallint                    │ int24eq
 =       │ int2vector                  │ int2vectoreq
 =       │ integer                     │ int48eq
 =       │ integer                     │ int42eq
 =       │ integer                     │ int4eq
 =       │ text                        │ texteq
 =       │ abstime                     │ abstimeeq
 =       │ reltime                     │ reltimeeq
 =       │ tinterval                   │ tintervaleq
 =       │ circle                      │ circle_eq
 =       │ time without time zone      │ time_eq
 =       │ timestamp without time zone │ timestamp_eq
 =       │ timestamp without time zone │ timestamp_eq_date
 =       │ timestamp without time zone │ timestamp_eq_timestamptz
 =       │ timestamp with time zone    │ timestamptz_eq_timestamp
 =       │ timestamp with time zone    │ timestamptz_eq
 =       │ timestamp with time zone    │ timestamptz_eq_date
 =       │ interval                    │ interval_eq
 =       │ time with time zone         │ timetz_eq
(24 rows)
  • 這個查詢只依運算子左側期望的型別過濾;目錄裡當然也存了右側期望的型別與結果型別(等值比較的結果型別是 Boolean)。
  • 輸出中的 oprcode 欄位就是運算子被使用時實際執行的 PostgreSQL 函式名稱。
  • driverid = 1 來說,PostgreSQL 會用 int84eq 函式來實作這個查詢——除非 driverid 上有 index:那時 PostgreSQL 會改走索引找出符合的資料列,只比較索引內容而不碰資料表內容。

使用 PostgreSQL 時,資料型別提供的是:

  • 輸入的資料表示法(輸入字面值的預期格式)
  • 輸出的資料表示法
  • 一組操作該型別的函式
  • 既有函式針對新型別的特化實作
  • 運算子針對該型別的特化實作
  • 該型別的索引支援

索引支援涵蓋多種索引:B-tree、GiST、GIN、SP-GiST、hash 與 BRIN(本書不深入各索引類型的細節)。以「型別、支援函式、運算子與索引之間的關係」為例,可以看 ip4r 擴充套件型別的 GiST 支援:

select amopopr::regoperator
    from pg_opclass c
         join pg_am am on am.oid = c.opcmethod
         join pg_amop amop on amop.amopfamily = c.opcfamily
   where opcintype = 'ip4r'::regtype
     and am.amname = 'gist';
    amopopr
════════════════
 >>=(ip4r,ip4r)
 <<=(ip4r,ip4r)
 >>(ip4r,ip4r)
 <<(ip4r,ip4r)
 &&(ip4r,ip4r)
 =(ip4r,ip4r)
(6 rows)
  • pg_opclass 是運算子類別(operator class)的清單,每個類別屬於 pg_opfamily 中的一個運算子家族;每種索引實作一個 pg_am 中的存取方法;可搭配某索引存取方法使用的運算子則列在 pg_amop
  • 這些目錄查詢屬於相當進階的素材,日常應用開發用不到;但理解 PostgreSQL 的運作方式,能讓你更聰明地使用這個你已經依賴的系統。

PostgreSQL 的資料型別實作是一套完全動態的系統:函式與運算子在執行期派發,擴充套件作者還有 API 可以在執行期(也就是你輸入 create extension 時)註冊新的索引支援。理解這一點,才能體會「資料型別」這個核心概念在 PostgreSQL 裡能做到多少事。