PostgreSQL 是扎實的 ACID 關聯式資料庫管理系統,用 SQL 處理、管理與查詢資料,主要使命是在應用程式並行讀寫的同時,隨時保證業務整體的一致視圖。要達到強一致性,PostgreSQL 也需要應用程式設計者設計扎實的資料模型,有時還得思考並行問題(下一章〈資料操作與並行控制〉處理)。

  • 近年業界大型玩家面對前所未見的規模:數千萬、上億的並行使用者,人人都在產生新資料,有些商業模式(多半是廣告網路)還得對新資料即時反應。單一實體不可能應付這種規模,因此必須分散式運作。
  • 為此出現了放寬一項或多項 ACID 保證的新系統,統稱 NoSQL,能力與行為五花八門:不支援交易、缺原子操作、缺隔離性(因而無法做線上備份)、沒有查詢語言只有 API、沒有一致性規則甚至沒有資料型別、只支援 key/value 等少量操作、不支援 join 或分析、不支援業務約束、不支援耐久性……
  • 放寬傳統資料庫的強保證,讓部分 NoSQL 方案能以分散節點、分散資料集(每個節點只看得到部分資料)處理更高的並行量。有些系統後來又加回了類 SQL 的查詢語言;NoSQL 運動也催生了 NewSQL 運動。

PostgreSQL 本身就提供多種放寬 ACID 保證的方式,在單一實體撐得住的並行水準內,與多數 NoSQL/NewSQL 方案相比毫不遜色。擴展(scale-out)方案以擴充或分支形式存在,本書不涵蓋;本章聚焦在把 PostgreSQL 當成「內建電池」的 NoSQL 方案——用於報表、分析、資料一致性與品質等業務需求。

PostgreSQL 中的無綱要設計#

NoSQL 系統的一大賣點是打破正規化規則、跳過建模這道難關:應用程式送什麼就存什麼,即所謂**無綱要(schemaless)**做法。

其實根本沒有「無綱要設計」這回事——它的真正意思是:文件屬性的名稱與型別被硬編碼在應用程式碼裡。文件仍然有結構、有欄位、有型別,只是對資料庫系統不透明,改由應用程式碼維護。

實作範例:mtgjson.com 提供 CC0 授權的《魔法風雲會》(Magic: the Gathering)卡片 JSON 資料,載入很簡單:

begin;

create schema if not exists magic;

create table magic.allsets(data jsonb);

commit;

搭配一支小 Python 腳本把整份 JSON 塞進去:

#! /usr/bin/env python3

import psycopg2

PGCONNSTRING = "user=appdev dbname=appdev"

if __name__ == '__main__':
    pgconn = psycopg2.connect(PGCONNSTRING)
    curs = pgconn.cursor()

    allset = open('MagicAllSets.json').read()
    allset = allset.replace("'", "''")
    sql = "insert into magic.allsets(data) values('%s')" % allset

    curs.execute(sql)
    pgconn.commit()
    pgconn.close()

把一份 27 MB 的巨型 JSON 文件塞進單一資料表,其實已超出本章要談的無綱要設計。好在用 PostgreSQL 很容易拆開:

begin;

drop table if exists magic.sets, magic.cards;

create table magic.sets
     as
select key as name, value - 'cards' as data
  from magic.allsets, jsonb_each(data);

create table magic.cards
    as
  with collection as
  (
     select key as set,
            value->'cards' as data
       from magic.allsets,
            lateral jsonb_each(data)
  )
  select set, jsonb_array_elements(data) as data
    from collection;

commit;

查詢時使用泛用的包含運算子 @>(在一份 JSON 文件中尋找另一份 JSON 文件),GIN 索引正好支援這個運算子:

select jsonb_pretty(data)
  from magic.cards
 where data @> '{"type":"Enchantment",
                 "artist":"Jim Murray",
                 "colors":["White"]
                }';

在 34,207 張卡的集合上,GIN 索引查找約 1.5 毫秒就找到目標卡片。

查詢結果:Angelic Chorus 卡片(節錄)
                              jsonb_pretty
══════════════════════════════════════════════════════════════════════
 {
     "id": "34b67f8cf8651964995bfec268498082710d4c6a",
     "cmc": 5,
     "name": "Angelic Chorus",
     "text": "Whenever a creature enters the battlefield under your control,
              you gain life equal to its toughness.",
     "type": "Enchantment",
     "types": [ "Enchantment" ],
     "artist": "Jim Murray",
     "colors": [ "White" ],
     "rarity": "Rare",
     "manaCost": "{3}{W}{W}",
     ...
 }
(1 row)

無綱要意味著達不到任何正規形式——而正規形式正是為長期保障資料品質而設計的。PostgreSQL 靠 JSON、XML、陣列與複合型別確實能處理無綱要資料,但只有在資料品質要求為零時才用這種做法。

耐久性的取捨#

耐久性(durability)是 ACID 的 D:資料庫系統在重啟或任何當機之後,不得遺失任何已提交的交易。這是非常強的保證,對效能行為影響很大。

PostgreSQL 預設對每筆交易套用強耐久性保證,但如文件〈asynchronous commit〉所述,可以放寬以換取寫入容量。synchronous_commit 可以逐並行交易設定不同值,甚至在交易進行中改變——它控制的正是伺服器在交易提交時的行為。

在同一應用程式中實作多種耐久性政策的一個方法:指派不同保證等級給不同使用者。

create role dbowner with login;
create role app with login;

create role critical  with login in role app inherit;
create role notsomuch with login in role app inherit;
create role dontcare  with login in role app inherit;

alter user critical  set synchronous_commit to remote_apply;
alter user notsomuch set synchronous_commit to local;
alter user dontcare  set synchronous_commit to off;

dbowner 負責資料庫模型與所有 DDL 腳本並擁有資料庫;app 擁有應用程式工作流程所需的權限;criticalnotsomuchdontcare 繼承 app 的權限但各帶不同設定。應用程式只要挑對連線字串或使用者,就能為資料變更取得較強保證(critical)或完全不保證耐久性(dontcare)。

需要在交易進行中改變 synchronous_commit,可用 SET LOCAL。也可以完全在資料庫端實作這種政策,例如以下觸發器在提交前檢查餘額變動量,超過門檻就升級耐久性保證:

SET demo.threshold TO 1000;
CREATE OR REPLACE FUNCTION public.syncrep_important_delta()
   RETURNS TRIGGER
   LANGUAGE PLpgSQL
AS
$$ DECLARE
   threshold integer := current_setting('demo.threshold')::int;
   delta integer := NEW.abalance - OLD.abalance;
BEGIN
   IF delta > threshold
   THEN
     SET LOCAL synchronous_commit TO on;
   END IF;
   RETURN NEW;
END;
$$;

不過有時即使放寬了耐久性保證,單一伺服器仍扛不住全部寫入流量——那就該考慮擴展(scale out)了。

向外擴展#

NoSQL 方案進展最有趣的領域之一,是生產環境原生 scale-out 的能力:能在執行期輕鬆加入運算節點,同時提升整體讀寫容量。這得益於它們的設計選擇——支援的操作少(尤其沒有 join)、一致性要求鬆(沒有交易、沒有完整性約束)——讓它們在分散式運算上得以創新。

高可用(high availability)與負載平衡(load balancing)是與 scale-out 不同的議題,NoSQL 系統與 PostgreSQL 架構都做得到,見 PostgreSQL 文件〈High Availability, Load Balancing, and Replication〉。

  • PostgreSQL 的原生 scale-out 目前還不存在。解決此問題的商業暨開源擴充與分支已經可用,例如 2ndQuadrant 的 Postgres-BDR、citusdata 的 Citus。
  • PostgreSQL 10 內建邏輯複寫(logical replication),已能支撐某種程度的擴展方案:若業務資料來自彼此獨立的區域(例如地理上獨立的單位),可讓每個地理單位由獨立的 PostgreSQL 伺服器服務,再用邏輯複寫把資料集彙整到單一全域伺服器,或複寫到各營運區域的本地副本。
  • 應用程式仍需知道所需資料在哪裡,方案還不算透明。但許多業務情境中寫入延遲比寫入擴展性更關鍵,維護一台聯邦式中央伺服器仍然可行,報表應用就能使用那個 PostgreSQL 實體。

評估任何 scale-out 方案時,永遠先問線上備份這一題:還需要嗎?需要的話做得到嗎?多數原生 scale-out 系統沒有全域交易,意味著沒有對並行活動的隔離,結果就是不可能實作一致的線上備份