使用PostgreSQL_Notify實現多例項快取同步
Parallel與Hierarchy是架構設計的兩大法寶,快取是Hierarchy在IO領域的體現。單執行緒場景下快取機制的實現可以簡單到不可思議,但很難想象成熟的應用會只有一個例項。在使用快取的同時引入併發,就不得不考慮一個問題:如何保證每個例項的快取與底層資料副本的資料一致性。
分散式系統受到CAP定理的約束,分割槽一致性P是一般來說是不允許犧牲的,不可能讓兩個例項對同樣的請求卻給出不同的結果。用快取是為了更好的效能,所以如果還要追求可用性A,就一定會犧牲C。我們能做的,就是通過巧妙設計讓AP系統的一致性損失最小化。
傳統方法
最簡單粗暴的辦法就是定時重新拉取,例如每個整點,所有應用一起去資料庫拉取一次最新版本的資料。很多應用都是這麼做的。當然問題也很多:拉的間隔長了,變更不能及時應用,使用者體驗差;拉的頻繁了,IO壓力大。而且例項數目和資料大小一旦膨脹起來,對於寶貴的IO資源是很大的浪費。
非同步通知是一種更好的辦法,尤其是在讀請求遠多於寫請求的情況下。接受到寫請求的例項,通過傳送廣播的方式通知其他例項。Redis
的PubSub
就可以很好地實現這個功能。如果原本下層儲存就是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
可以看出,快取確是與資料庫保持了同樣的狀態。
應用場景
讀遠大於寫的場景。
相關文章
- 使用RxJava實現快取RxJava快取
- 使用ConcurrentHashMap實現快取HashMap快取
- 聊聊如何利用redis實現多級快取同步Redis快取
- canal同步mysql,監聽單例項,多例項配置MySql單例
- flutter 多例項實戰Flutter
- Flink - 旁路快取和非同步IO的實現快取非同步
- WEB 應用快取解析以及使用 Redis 實現分散式快取Web快取Redis分散式
- Android Flutter 多例項實踐AndroidFlutter
- SpringBoot中使用Redis實現快取Spring BootRedis快取
- 快取使用中的注意事項快取
- SDWebImage實現圖片展示、快取、清除快取Web快取
- LRU快取實現(Java)快取Java
- 使用ThreadLocal來實現一個本地快取thread快取
- 快速入門:使用 .NET Aspire 元件實現快取元件快取
- 兩級快取實現分析之快取設定快取
- Android 使用AsyncTask非同步的介紹及多例項並行方案詳解Android非同步並行
- js如何實現清空瀏覽器快取程式碼例項JS瀏覽器快取
- 快取同步的問題快取
- SpringBoot快取管理(二) 整合Redis快取實現Spring Boot快取Redis
- 第五節:基於Canal實現MySQL到Redis快取資料同步MySqlRedis快取
- mysql多例項部署MySql
- MySQL多例項配置MySql
- 實現AVPlayer離線快取快取
- 資料快取的實現快取
- 快取 LRU 和 LFU 實現快取
- 基於mysqld_multi實現MySQL 5.7.24多例項多程式配置MySql
- spring boot使用Jedis整合Redis實現快取(AOP)Spring BootRedis快取
- 快取注意事項快取
- 探討下如何更好的使用快取 —— Redis快取的特殊用法以及與本地快取一起構建多級快取的實現快取Redis
- 乾貨,使用布隆過濾器實現高效快取!過濾器快取
- 使用Go實現健壯的記憶體型快取Go記憶體快取
- 10行Java程式碼實現最近被使用(LRU)快取Java快取
- LRU cache快取簡單實現快取
- CefSharp自定義快取實現快取
- Android 清除快取功能實現Android快取
- iOS快取清理功能的實現iOS快取
- Memcached 分散式快取實現原理分散式快取
- Vue專案全域性配置頁面快取,實現按需讀取快取Vue快取