← 回文章列表

2023年9月12日

從 MSSQL 增量同步到 Elasticsearch:用異動欄位與 Checkpoint 只同步「變動的資料」

ElasticsearchMSSQL資料同步ETL系統設計

整批重建的極限

讀取改由 Elasticsearch 提供之後,接下來的問題就是:MSSQL 的資料改了,索引要怎麼跟上?

最直覺的方式是定期整批重建:每隔一段時間把所有資料讀出來,全部重新寫進 Elasticsearch。它的優點是簡單、一定正確,但:

  • 物件資料有幾十萬筆,一次重建要幾十分鐘,期間資料庫與 Elasticsearch 的負載都很高
  • 重建的間隔就是資料的延遲:一小時重建一次,經紀人改了價格,前台最久要一小時後才看得到
  • 每次重建處理的資料,99% 根本沒有變動

整批重建仍然需要(第一次建立索引、Mapping 調整時),但日常的更新應該只處理「有變動的資料」,也就是增量同步。

核心概念:異動欄位 + Checkpoint

增量同步示意圖:Houses 資料表依 ChangeMark 排序,上次同步到 1002,本次要同步的範圍是 ChangeMark 大於 1002 的三筆資料(改價、新增、下架);每一輪同步排程先讀出 Checkpoint,查詢 ChangeMark 大於 1002 的一批資料,逐筆發出異動事件或寫入 Elasticsearch,全部成功後才把 Checkpoint 更新為 1005,失敗就不更新、下一輪重做這一批

增量同步需要兩個東西:

  • 異動欄位(ChangeMark):資料表上的一個欄位,每次資料新增或修改時,它都會變成一個比之前更大的值
  • Checkpoint(水位):記錄「上一次同步到哪個值」,存在一張獨立的資料表裡

每一輪同步的流程:

  1. 讀出 Checkpoint,例如 1002
  2. 查詢 ChangeMark > 1002 的資料,依 ChangeMark 排序,一次取一批(例如 500 筆)
  3. 把這批資料同步到 Elasticsearch
  4. 全部成功後,把 Checkpoint 更新為這批資料中最大的 ChangeMark
SQL
SELECT TOP (@BatchSize) *
FROM   Houses
WHERE  ChangeMark > @Checkpoint
ORDER BY ChangeMark;

ChangeMark 上要建立索引,這條查詢才會是索引範圍掃描,而不是全表掃描。

異動欄位要怎麼產生?

用 DATETIME 的問題

最常見的異動欄位是 UpdatedAt,但時間戳記有幾個問題:

  • 精度不夠:DATETIME 的精度約 3 毫秒,同一批交易更新的多筆資料可能有完全相同的時間。如果剛好在這些資料的中間切批,用 > 會漏掉同時間的剩餘資料,用 >= 又會重複處理
  • 依賴每一段程式都記得更新:只要有一支預存程序、一個手動修正的 SQL 忘了更新 UpdatedAt,那筆資料就永遠不會被同步
  • 時鐘可能倒退:不同應用程式伺服器的時間不完全一致,寫入的時間不一定是遞增的

用 ROWVERSION

SQL Server 的 ROWVERSION(舊名 TIMESTAMP,但和時間無關)是更好的選擇:

SQL
ALTER TABLE Houses ADD ChangeMark ROWVERSION;
  • 資料庫層級自動維護:任何 INSERT、UPDATE,不論來自哪個程式、哪支 SQL,都會自動更新
  • 在整個資料庫內唯一且遞增,不會有兩筆資料相同的問題
  • 它是 8 位元組的二進位值,程式中可以轉成 long(BIGINT)來比較與儲存

如果因為各種原因不能修改資料表結構,也可以和 DBA 約定,在資料異動的流程中一併更新一個遞增的異動欄位——重點是每一個寫入的路徑都要涵蓋到。

容易漏資料的地方:還沒提交的交易

ROWVERSION 是在資料被修改的當下就配發的,不是在交易提交時。想像這個情況:

  1. 交易 A 修改了物件 H-0877,拿到 ChangeMark 1003,但還沒提交
  2. 交易 B 修改了物件 H-1003,拿到 1004,並且立刻提交
  3. 同步排程執行,讀到已提交的 1004(1003 還沒提交,讀不到),把 Checkpoint 推進到 1004
  4. 交易 A 提交,1003 出現了——但 Checkpoint 已經在 1004,1003 永遠不會被同步

SQL Server 提供 MIN_ACTIVE_ROWVERSION(),回傳目前還在進行中的交易所持有的最小 ROWVERSION。同步時只處理小於這個值的資料,就不會越過還沒提交的交易:

SQL
SELECT TOP (@BatchSize) *
FROM   Houses
WHERE  ChangeMark > @Checkpoint
  AND  ChangeMark < MIN_ACTIVE_ROWVERSION()
ORDER BY ChangeMark;

刪除要怎麼同步?

增量查詢只找得到「還存在的資料」。如果資料被 DELETE 掉了,查詢根本查不到它,Elasticsearch 裡的文件就會一直留著。

常見的做法:

  • 軟刪除:不真的刪除資料,而是設定 IsDeleted = 1。這也是一次 UPDATE,ChangeMark 會更新,同步時看到 IsDeleted = 1 就從 Elasticsearch 刪除這份文件
  • 刪除紀錄表:如果一定要實體刪除,用 Trigger 把被刪除的 ID 寫進一張刪除紀錄表(同樣帶 ROWVERSION),同步時一起處理

房仲物件本來就有「下架」狀態,用軟刪除是最自然的做法。

失敗時不掉資料:先寫入、再推進 Checkpoint

順序很重要:資料同步成功之後,才更新 Checkpoint。

  • 同步到一半失敗:Checkpoint 沒有推進,下一輪會從同一個位置重做整批,不會漏
  • 同步成功、但更新 Checkpoint 前程式當掉:下一輪會重複處理這一批

重複處理是可以接受的,前提是寫入 Elasticsearch 的動作是冪等的:以物件 ID 作為文件 ID,用 Index(存在就覆蓋)而不是 Create;刪除不存在的文件也不視為錯誤。這樣同一批處理兩次,結果和一次相同。

反過來「先推進 Checkpoint、再同步資料」是錯的:同步失敗時,Checkpoint 已經往前走,這一批資料就永遠被跳過了。

中間多一層訊息佇列

同步排程可以直接寫 Elasticsearch,也可以只負責「找出變動的資料、發出事件」,把寫入交給訊息佇列後面的消費者:

  • 偵測與寫入解耦:排程跑得快,不會因為 Elasticsearch 暫時變慢而拖住整個同步
  • 多個下游:同一筆物件異動,可能有好幾個索引要更新、還有通知服務要發推播,各自訂閱事件即可
  • 失敗重試:單筆寫入失敗時,在佇列中重試或進入死信佇列,不會讓整批同步卡住

這時 Checkpoint 推進的條件就變成「事件成功發布到佇列」(並且開啟 Publisher Confirm),後續的寫入可靠性交給佇列的 Ack 與重試機制。

其他選擇:CDC 與 Change Tracking

SQL Server 本身也有內建的變更追蹤功能:

  • Change Tracking:記錄「哪些資料列變了」,不記錄變更前後的內容,負擔較輕
  • Change Data Capture(CDC):從交易記錄讀出每一筆變更的前後內容,可以搭配 Debezium 之類的工具直接串到訊息佇列

這兩者都由資料庫維護,不會漏掉任何寫入路徑,但需要 DBA 開啟、會增加資料庫的負擔與維運項目。異動欄位的做法則是最容易理解、最容易除錯的起點。

使用情境:房仲平台的資料同步

在房仲平台的重構中,買屋、行情、社區三大中台的讀取全部改由 Elasticsearch 提供,資料同步分成兩條路線:

  • 全量:由批次程式把 MSSQL 的資料整批轉入 Elasticsearch,用在第一次建立索引或 Mapping 調整後的重建
  • 增量:和 DBA 約定資料異動時一併更新異動欄位,同步排程定期比對 Checkpoint,把有變動的資料包成事件送進 RabbitMQ,再由消費者更新索引

經紀人改價後,前台通常在數秒內就能看到新價格,不再需要等待整批重建。

一句話總結

增量同步靠「每次異動都會遞增的異動欄位」加上「記住上次同步到哪裡的 Checkpoint」:異動欄位優先用資料庫自動維護的 ROWVERSION,查詢時用 MIN_ACTIVE_ROWVERSION() 避開還沒提交的交易,刪除用軟刪除表達;永遠先同步成功、再推進 Checkpoint,並且讓寫入是冪等的,重做也不會出錯。