有這樣一個需求:監聽某個PostgreSQL的業務數據庫,當特定表中的數據發生變化時,實時通知用戶。
數據庫→後端
方案1:輪詢
監聽數據表,最容易想到、實現最簡單的,就是輪詢
。
但是,考慮到實時性,高頻率的輪詢必然會對數據庫造成一定壓力。
隨着業務擴展,監聽的表增多,這種模式必然是不可持續
的。
方案2:基於日誌的CDC
類似於MySQL的數據變化捕獲(CDC),PostgreSQL也有基於日誌的CDC
方案。
但這種方式更多是用於庫-庫的數據同步
,還引入了較重的依賴(例如debezium
)。
方案3:觸發器
從通信方向來看,與常規的數據請求正好相反:
庫→後端→前端
而且又要求實時性,我很快想到了PostgreSQL的觸發器
。
但從觸發器到後端,仍然存在一道鴻溝。
方案3.1:pgsql-http
從觸發器到後端的事件傳遞,找到一個在PostgreSQL存儲過程中發起http請求的插件:
這樣就打通了從觸發器到後端的通信:
庫表→觸發器→pgsql-http→後端
但後來發現了更優的解決方案。
方案3.2:Listen-Notify
PostgreSQL從7.2版本開始支持Listen-Notify通知機制,這是一個語法非常簡潔的API。
在SQL中執行:
1 2 |
-- 監聽channelA頻道 LISTEN channelA; |
1 2 |
-- 向channelA廣播消息 NOTIFY channelA, 'test-message'; |
在存儲過程(pl/pgsql)中執行:
1 2 |
-- 監聽channelA頻道 PERFORM pg_listen('channelA'); |
1 2 |
-- 向channelA廣播消息 PERFORM pg_notify('channelA', 'test-message'); |
在Java中監聽(僞代碼):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
// 創建數據庫連接 Class.forName("org.postgresql.Driver"); String url = "jdbc:postgresql://localhost:5432/test"; Connection conn = DriverManager.getConnection(url,"test",""); org.postgresql.PGConnection pgconn = conn.unwrap(org.postgresql.PGConnection.class); // 監聽channelA頻道 Statement stmt = conn.createStatement(); stmt.execute("LISTEN channelA"); stmt.close(); // 將監聽到的信息print while (true) { org.postgresql.PGNotification notifications[] = pgconn.getNotifications(); if (notifications != null) { for (int i=0; i < notifications.length; i++) System.out.println("Got notification: " + notifications[i].getName()); } Thread.sleep(500); } |
具體見官方文檔:
Listen-Notify - PostgreSQL官方文檔
後端→前端
WebSocket vs Server-Sent Events
Server-Sent Events 教程 - 阮一峯的網絡日誌
選擇SSE的原因
-
無雙工通信需求。只需要從後端向前端發通知,不需要雙向通信。
-
輕量級。SSE基於http,WebSocket需要支持ws協議,而公司開發環境和部分小型雲廠商,出於各種考慮或技術限制,不開放ws協議。
-
實現簡單。SSE有自動重連機制,不需要手動處理連接;實現代碼簡單。
實現代碼
觸發器
信息流的起點——觸發器。
觸發器函數
首先創建一個觸發器函數notify_global_data_change
。
1 2 3 4 5 6 7 8 9 10 11 |
CREATE OR REPLACE FUNCTION "public"."notify_global_data_change"() RETURNS "pg_catalog"."trigger" AS $BODY$ BEGIN // 表發生數據變化時,把表名、操作名以JSON形式通知到`change_data_capture`頻道 // 其中`tg_table_name`是表名,`tg_op`是`INSERT`或`UPDATE`或`DELETE` PERFORM pg_notify('change_data_capture','{"table":"'||tg_table_name||'","operation":"'||tg_op||'"}'); RETURN null; END $BODY$ LANGUAGE plpgsql VOLATILE COST 100; |
項目需求只想知道哪張表發生了哪種類型的變化(增刪改),因此所有表使用同一個觸發器函數即可。
如果需要更多細節,例如哪個字段發生了變化,變化前後的值是多少,就需要爲每張表,單獨寫一個觸發器函數。
觸發器定義
有這樣一個名爲table_name
的數據表。
將前文的觸發器函數notify_global_data_change
,掛載到目標表的觸發器上。
1 2 3 |
CREATE TRIGGER "trigger_table_name" AFTER INSERT OR UPDATE OR DELETE ON "public"."table_name" FOR EACH ROW EXECUTE PROCEDURE "public"."notify_global_data_change"(); |
後端實現(Java)
參考GitHub的demo項目:https://github.com/aliakh/demo-spring-sse
SseEmitters.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 |
package demo.sse; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import java.io.IOException; import java.util.ArrayList; import java.util.List; import java.util.concurrent.CopyOnWriteArrayList; class SseEmitters { private static final Logger logger = LoggerFactory.getLogger(SseEmitters.class); private final List<SseEmitter> emitters = new CopyOnWriteArrayList<>(); SseEmitter add() { return add(new SseEmitter(-1L)); } SseEmitter add(SseEmitter emitter) { this.emitters.add(emitter); emitter.onCompletion(() -> { logger.info("Emitter completed: {}", emitter); this.emitters.remove(emitter); }); emitter.onTimeout(() -> { logger.info("Emitter timed out: {}", emitter); emitter.complete(); this.emitters.remove(emitter); }); return emitter; } void send(Object obj) { send(emitter -> emitter.send(obj)); } void send(SseEmitter.SseEventBuilder builder) { send(emitter -> emitter.send(builder)); } private void send(SseEmitterConsumer<SseEmitter> consumer) { List<SseEmitter> failedEmitters = new ArrayList<>(); this.emitters.forEach(emitter -> { try { consumer.accept(emitter); } catch (Exception e) { emitter.completeWithError(e); failedEmitters.add(emitter); logger.error("Emitter failed: {}", emitter, e); } }); this.emitters.removeAll(failedEmitters); } @FunctionalInterface private interface SseEmitterConsumer<T> { void accept(T t) throws IOException; } } |
SseController.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 |
package demo.sse; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import java.sql.*; import org.postgresql.PGConnection; import java.util.ArrayList; import java.util.HashMap; import java.util.Map; import java.util.UUID; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import javax.sql.DataSource; @RestController @RequestMapping("/datainterface") public class SseController { private final SseEmitters emitters = new SseEmitters(); private ArrayList<Map<String,String>> events = new ArrayList<Map<String,String>>(); private final ScheduledExecutorService scheduledThreadPool = Executors.newScheduledThreadPool(1); private Map<String, PGConnection> pgConns = new HashMap<String, PGConnection>(); private Boolean initStatus = true; // 建立SSE連接,這裏爲了簡化,將JDBC連接通過URL參數傳入,這樣做是不安全的 @RequestMapping(value = "/sse/listen", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter listenToBroadcast(@RequestParam String url) throws SQLException { String dbUrl = "jdbc:postgresql://localhost:5432/test"; if (url != null && url != '') { dbUrl = url; } if (this.initStatus) { this.initStatus = false; broadcast(); } if (dbUrl != "" && dbUrl != null) { this.initDbListener(dbUrl); } return emitters.add(); } public Boolean initDbListener(String url) throws SQLException { // 如果連接池已存在此連接,則跳過 if (this.pgConns.get(url) != null) { return false; } Class.forName("org.postgresql.Driver"); Connection conn = DriverManager.getConnection(url,"test",""); org.postgresql.PGConnection pgConn = conn.unwrap(org.postgresql.PGConnection.class); // 將pg數據庫連接存入連接池pgConn this.pgConns.put(url, pgConn); // 執行監聽change_data_capture頻道 Statement stmt = conn.createStatement(); stmt.execute("LISTEN change_data_capture"); stmt.close(); return true; } // 順手實現了廣播接口,從前端也可以發起全域廣播 @RequestMapping(value = "/sse/broadcast") public Boolean addToBroadcast(@RequestParam String type, @RequestParam String message) { if (message != null && message != "") { Map<String,String> typeMessage = new HashMap<String,String>(); typeMessage.put("type", type); typeMessage.put("message", message); events.add(typeMessage); return true; } else { return false; } } // 初始化廣播 private void broadcast() throws SQLException { scheduledThreadPool.scheduleAtFixedRate(() -> { try { // 定時接收數據庫通知 this.pgConns.forEach( (dbid,pgConn) -> { org.postgresql.PGNotification notifications[] = null; try { notifications = pgConn.getNotifications(); } catch (SQLException e) { e.printStackTrace(); } if (notifications != null) { for (int i = 0; i < notifications.length; i++) { String message = notifications[i].getParameter(); // 這裏做了重複信息過濾 if (i > 0) { String previousMessage = notifications[i-1].getParameter(); if (message == previousMessage) { continue; } } this.addToBroadcast(dbid, notifications[i].getParameter()); } } }); // 如果隊列中有消息,向所有客戶端推送 if (events.size() > 0) { Map<String,String> firstEvent = events.remove(0); String type = firstEvent.get("type"); String message = firstEvent.get("message"); emitters.send( SseEmitter.event() .id(UUID.randomUUID().toString()) .name(type) .data(message) ); } } catch (Exception e) {} }, 0, 1, TimeUnit.SECONDS); } } |
在瀏覽器輸入以下地址,回車:
http://localhost/datainterface/sse/listen?url=jdbc:postgresql://localhost:5432/test
會發現瀏覽器小圖標一直在轉,Network一直沒有response。這就是SSE的本質——長連接。
如果這時在table_name
表中插入一條數據,SSE會返回一個消息:
1 |
{"table":"table_name","operation":"INSERT"} |
然而此時連接並沒有結束/中斷,而是等待下一個消息。
前端實現
SSE本質是一種長連接,目前瀏覽器對連接數有一定限制(Chrome 6個)。
採用SharedWorker接口,實現全域只保持一個SSE連接。
main.js
1 2 3 4 5 |
const notificationWorker = new SharedWorker('./notification.js'); notificationWorker.port.onmessage = function(e) { console.log(e); } notificationWorker.port.start(); |
建立SSE連接,監聽數據庫。
接收到表數據變化的事件時,通過BroadcastChannel接口,向各頁面/組件分發通知。
notification.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 |
let source = null; let broadcastChannel = null; let events = []; // 建立SSE連接,監聽某數據庫 function listen(dbid) { source = new EventSource('/datainterface/sse/listen?url=' + url); source.addEventListener(dbid, function(event) { events.push(event.data); throttle(broadcast, 1000)(); }); source.onopen = function(e) {}; source.onerror = function(e) {}; } // 向各頁面/組件廣播事件,頻道=表名,消息=操作 function broadcast() { events = Array.from(new Set(events)); if (events.length > 0) { const event = events.splice(0, 1); let message; try { message = JSON.parse(event); } catch (e) { message = event; } if (message?.table && message?.operation) { broadcastChannel = new BroadcastChannel(message.table); broadcastChannel.postMessage(message.operation); } } } // 做了簡單的防抖處理 function throttle(func, wait) { let timeout; return function() { const context = this; const args = arguments; if (!timeout) { timeout = setTimeout(() => { timeout = null; func.apply(context, args); }, wait); } }; } listen('jdbc:postgresql://localhost:5432/test'); |
頁面/組件接收事件通知,做相應的操作。
pageA
1 2 3 4 5 6 7 8 9 10 |
const broadcastChannel = new BroadcastChannel('table_name'); broadcastChannel.onmessage = function(e) { if (e.data === 'INSERT') { ... } else if (e.data === 'UPDATE') { ... } else if (e.data === 'DELETE') { ... } } |
總結
全流程
文章看起來雖然很長,但全流程還是很清晰的:
數據庫表→觸發器Notify→後端Listen→SSE廣播→主頁監聽→分發給各頁面/組件
適用範圍
現代Web架構中,服務間通信已經有非常成熟的解決方案。
我想要知道發生了什麼,完全可以要求事件的發起人,通過服務間通信告訴我。
而非吭哧吭哧扒着數據庫監聽,這是下策,是不得不走的彎路。
比如:
1、老舊系統,已無人維護,沒人做服務間通信接口。
2、合作方,只會玩數據庫,或堅持不做服務間通信。
3、涉密或內外網隔離,只能通過數據庫通信。
擴展
經過調研,Oracle、MySQL、SQL Server等主流關係數據庫,都有類似Listen-Notify
的接口。
簡單調整,其他主流關係數據庫也可以採用這種模式,實現數據變化的實時通知。