PostgreSQL的數據變化捕獲和實時通知——基於Listen-Notify和Server-Sent Events

有這樣一個需求:監聽某個PostgreSQL的業務數據庫,當特定表中的數據發生變化時,實時通知用戶。

數據庫→後端

方案1:輪詢

監聽數據表,最容易想到、實現最簡單的,就是輪詢

但是,考慮到實時性,高頻率的輪詢必然會對數據庫造成一定壓力。

隨着業務擴展,監聽的表增多,這種模式必然是不可持續的。

方案2:基於日誌的CDC

類似於MySQL的數據變化捕獲(CDC),PostgreSQL也有基於日誌的CDC方案。

但這種方式更多是用於庫-庫的數據同步,還引入了較重的依賴(例如debezium)。

方案3:觸發器

從通信方向來看,與常規的數據請求正好相反:

庫→後端→前端

而且又要求實時性,我很快想到了PostgreSQL的觸發器

但從觸發器到後端,仍然存在一道鴻溝。

方案3.1:pgsql-http

從觸發器到後端的事件傳遞,找到一個在PostgreSQL存儲過程中發起http請求的插件:

pgsql-http - GitHub

這樣就打通了從觸發器到後端的通信:

庫表→觸發器→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

WebSocket 教程 - 阮一峯的網絡日誌

Server-Sent Events 教程 - 阮一峯的網絡日誌

選擇SSE的原因

  1. 無雙工通信需求。只需要從後端向前端發通知,不需要雙向通信。

  2. 輕量級。SSE基於http,WebSocket需要支持ws協議,而公司開發環境和部分小型雲廠商,出於各種考慮或技術限制,不開放ws協議。

  3. 實現簡單。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的接口。

簡單調整,其他主流關係數據庫也可以採用這種模式,實現數據變化的實時通知。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章