使用PostgreSQL_Notify實現多例項快取同步

墨航發表於2017-08-07

Parallel與Hierarchy是架構設計的兩大法寶,快取是Hierarchy在IO領域的體現。單執行緒場景下快取機制的實現可以簡單到不可思議,但很難想象成熟的應用會只有一個例項。在使用快取的同時引入併發,就不得不考慮一個問題:如何保證每個例項的快取與底層資料副本的資料一致性。

分散式系統受到CAP定理的約束,分割槽一致性P是一般來說是不允許犧牲的,不可能讓兩個例項對同樣的請求卻給出不同的結果。用快取是為了更好的效能,所以如果還要追求可用性A,就一定會犧牲C。我們能做的,就是通過巧妙設計讓AP系統的一致性損失最小化。

傳統方法

最簡單粗暴的辦法就是定時重新拉取,例如每個整點,所有應用一起去資料庫拉取一次最新版本的資料。很多應用都是這麼做的。當然問題也很多:拉的間隔長了,變更不能及時應用,使用者體驗差;拉的頻繁了,IO壓力大。而且例項數目和資料大小一旦膨脹起來,對於寶貴的IO資源是很大的浪費。

非同步通知是一種更好的辦法,尤其是在讀請求遠多於寫請求的情況下。接受到寫請求的例項,通過傳送廣播的方式通知其他例項。RedisPubSub就可以很好地實現這個功能。如果原本下層儲存就是Redis自然是再方便不過,但如果下層儲存是關係型資料庫的話,為這樣一個功能引入一個新的元件似乎有些得不償失。況且考慮到後臺管理程式或者其他應用如果在修改了資料庫後也要去redis釋出通知,實在太麻煩了。一種可行的辦法是通過資料庫中介軟體來監聽RDS變動並廣播通知,淘寶不少東西就是這麼做的。但如果DB本身就能搞定的事情,為什麼要加一箇中介軟體呢?通過PostgreSQL的Notfiy-Listen機制,可以方便地實現這種功能。

目標

無論從任何渠道產生的資料庫記錄變更(增刪改)都能被所有相關應用實時感知,用於維護自身快取與資料庫內容的一致性。

原理

PostgreSQL行級觸發器 + Notify機制 + 自定義協議 + Smart Client

  • 行級觸發器:通過為我們感興趣的表建立一個行級別的寫觸發器,對資料表中的每一行記錄的Update,Delete,Insert都會出發自定義函式的執行。
  • Notify:通過PostgreSQL內建的非同步通知機制向指定的Channel傳送通知
  • 自定義協議:協商訊息格式,傳遞操作的型別與變更記錄的標識
  • Smart Client:客戶端監聽訊息變更,根據訊息對快取執行相應的操作。

實際上這樣一套東西就是一個超簡易的WAL(Write After Log)實現,從而使應用內部的快取狀態能與資料庫保持實時一致(compare to poll)。

實現

DDL

這裡以一個最簡單的表作為示例,一張以主鍵標識的users表。

-- 使用者表
CREATE TABLE users (
  id   TEXT,
  name TEXT,
  PRIMARY KEY (id)
);

觸發器

-- 通知觸發器
CREATE OR REPLACE FUNCTION notify_change() RETURNS TRIGGER AS 
$$

BEGIN
  IF    (TG_OP = `INSERT`) THEN 
    PERFORM pg_notify(TG_RELNAME || `_chan`, `I` || NEW.id); RETURN NEW;
  ELSIF (TG_OP = `UPDATE`) THEN 
    PERFORM pg_notify(TG_RELNAME || `_chan`, `U` || NEW.id); RETURN NEW;
  ELSIF (TG_OP = `DELETE`) THEN 
    PERFORM pg_notify(TG_RELNAME || `_chan`, `D` || OLD.id); RETURN OLD;
  END IF;
END; 
$$
 LANGUAGE plpgsql SECURITY DEFINER;

這裡建立了一個觸發器函式,通過內建變數TG_OP獲取操作的名稱,TG_RELNAME獲取表名。每當觸發器執行時,它會向名為<table_name>_chan的通道傳送指定格式的訊息:[I|U|D]<id>

題外話:通過行級觸發器,還可以實現一些很實用的功能,例如In-DB Audit,自動更新欄位值,統計資訊,自定義備份策略與回滾邏輯等。

-- 為使用者表建立行級觸發器,監聽INSERT UPDATE DELETE 操作。
CREATE TRIGGER t_user_notify AFTER INSERT OR UPDATE OR DELETE ON users
FOR EACH ROW EXECUTE PROCEDURE notify_change();

建立觸發器也很簡單,表級觸發器對每次表變更執行一次,而行級觸發器對每條記錄都會執行一次。這樣,資料庫的裡的工作就算全部完成了。

訊息格式

通知需要傳達出兩個資訊:變更的操作型別,變更的實體標記。

  • 變更的操作型別就是增刪改:INSERT,DELETE,UPDATE。通過一個打頭的字元`[I|U|D]`就可以標識。
  • 變更的物件可以通過實體主鍵來標識。如果不是字串型別,還需要確定一種無歧義的序列化方式。

這裡為了省事直接使用字串型別作為ID,那麼插入一條id=1的記錄,對應的訊息就是I1,更新一條id=5的記錄訊息就是U5,刪除id=3的記錄訊息就是D3

完全可以通過更復雜的訊息協議實現更強大的功能。

SmartClient

資料庫的機制需要客戶端的配合才能生效,客戶端需要監聽資料庫的變更通知,才能將變更實時應用到自己的快取副本中。對於插入和更新,客戶端需要根據ID重新拉取相應實體,對於刪除,客戶端需要刪除自己快取副本的相應實體。以Go語言為例,編寫了一個簡單的客戶端模組。

本例中使用一個以User.ID作為鍵,User物件作為值的併發安全字典Users sync.Map作為快取。

作為演示,啟動了另一個goroutine對資料庫寫入了一些變更。

package main

import "sync"
import "strings"
import "github.com/go-pg/pg"
import . "github.com/Vonng/gopher/db/pg"
import log "github.com/Sirupsen/logrus"

type User struct {
    ID   string `sql:",pk"`
    Name string
}

// Users 內部資料快取
var Users sync.Map 

// 輔助函式:載入全部使用者,初始化時使用
func LoadAllUser() {
    var users []User
    Pg.Query(&users, `SELECT ID,name FROM users;`)
    for _, user := range users {
        Users.Store(user.ID, user)
    }
}

// 輔助函式:根據ID過載單個使用者,當插入和更新時執行
func LoadUser(id string) {
    user := User{ID: id}
    Pg.Select(&user)
    Users.Store(user.ID, user)
}

// 列印快取內部的Key列表
func PrintUsers() string {
    var buf []string
    Users.Range(func(key, value interface{}) bool {
        buf = append(buf, key.(string));
        return true
    })
    return strings.Join(buf, ",")
}

// ListenUserChange 會監聽PostgreSQL users資料表中的變動通知,並維護快取狀態
func ListenUserChange() {
    go func(c <-chan *pg.Notification) {
        for notify := range c {
            action, id := notify.Payload[0], notify.Payload[1:]
            switch action {
            case `I`: fallthrough
            case `U`: LoadUser(id);
            case `D`: Users.Delete(id)
            }
            log.Infof("[NOTIFY] Action:%c ID:%s Users: %s", action, id, PrintUsers())
        }
    }(Pg.Listen("users_chan").Channel())
}

// MakeSomeChange 會向資料庫寫入一些變更
func MakeSomeChange() {
    Pg.Exec(`TRUNCATE TABLE users;`)
    Pg.Insert(&User{"001", "張三"})
    Pg.Insert(&User{"002", "李四"})
    Pg.Insert(&User{"003", "王五"})  // 插入
    Pg.Update(&User{"003", "王麻子"}) // 改名
    Pg.Delete(&User{ID: "002"})    // 刪除
}

func main() {
    LoadAllUser()
    ListenUserChange()
    go MakeSomeChange()
    <-make(chan struct{})
}

執行結果如下:

[NOTIFY] Action:I ID:001 Users: 001          
[NOTIFY] Action:I ID:002 Users: 001,002      
[NOTIFY] Action:I ID:003 Users: 002,003,001  
[NOTIFY] Action:U ID:003 Users: 001,002,003  
[NOTIFY] Action:D ID:002 Users: 001,003      

可以看出,快取確是與資料庫保持了同樣的狀態。

應用場景

讀遠大於寫的場景。


相關文章