將資料從 MariaDB 遷移至 AWS DynamoDB 過程紀錄

前情提要

前陣子將公司一個 Laravel 專案當中通知的功能部分切出到另一個用 Golang 構建的服務,並且把資料從 MariaDB 遷移到 AWS DynamoDB。想說稍微紀錄一下這個過程,算是一個收穫滿多的經驗。

由於原本存在 MariaDB 的通知資料量日益肥大,雖然當前仍不算是巨量資料,大約千萬級資料量而已,但是光是這個等級的資料量,只要不小心下一個範圍性的 select 語句都有可能會造成整個 DB 卡死然後服務掛掉(真實發生過在 production 環境上的事件…)。然而通知在我們的系統當中其實僅是一個輔助的功能,但一有問題卻可能直接導致整個服務停擺,怎麼想都不太合理。因此,「隔離」就是我們首要的目的——通知的資料不該和主服務的資料擺在同個資料庫裡

📝 Note: 當資料量到達百萬級以上後,count(*) 語法明顯速度變很慢,像這樣的查詢語句 select count(*) from UserNotification where id > 51306320 and id <= 57615005 order by id 執行起來就要兩秒多。

既然要把資料搬出去,要搬到哪裡就是就是第二個問題,而我們後來會選用 AWS DynamoDB 的原因包括:

  • 面對乘載資料量的變化,DB 可以自動隨之擴展和縮減
  • 查詢有既定的模式和規則,不太會有變動(如:取得通知未讀數量、將通知已讀 etc.),也不需要複雜的 join 或 sub queries 等等
  • 不需要嚴格的的資料一致性,只要有最終一致即可(DynamoDB Consistency Model 的預設值,也是能保持最佳效能的推薦選項)
  • 原本就有在使用 AWS 的服務,之後若有需要和 AWS 其他服務整合時也比較方便

而之所以會另建一個服務,主要是為了將隔離性提高到應用層的部分,以便之後將其他通知相關的功能都切出去(ex: 推播),選擇 Golang 則是因為其簡單快速的特性,也很適合拿來搭建微服務(還有一部分私心是想實際將 Golang 運用在公司專案上)。 此外,使用 Golang 還有一個好處就是能夠使用迸發程式對 DynamoDB 進行操作。DynamoDB 本身就有能力消化同時數千個以上寫入/讀取的請求(每秒的請求吞吐量上限取決於所選擇的計價方式/ Read/write capacity mode),非常適合搭配支援 concurrency 的程式語言使用,在需要的時候可以透過迸發程式大幅提高處理大量資料的效率。

Migrate 之前的準備

在 migrate 資料之前,我們所進行的服務替換是這樣的:

原本所有 notification 相關 API 都不動,分別新增每一隻 API 對應的 API 加上 v2 前綴。而 v2 所做的事情都和 v1 相同,只是原本操作資料時是對 MariaDB 操作,都改成對外部通知服務操作。

而在新增通知資料的部分,由於通知是在使用者進行某些特定行為時才會被建立,所以這個部分的改動是在每個操作行為之後原本會觸發新增通知的地方,全部也都加上觸發新增 v2 的通知。

除此之外,新增通知推播通知是獨立運作的:

  • 使用者進行某些特定行為,觸發新增通知事件: 當下立即新增通知資料,同時派發一個推播通知的 queue job(新增資料的 id 會傳給 queue job)
  • Queue worker 消化 job 推播通知: 透過傳進來的 id 找到欲推播的資料,再進行推播

在這個版本當中,新舊的 Notification API 會並存(API 主要就是提供讀取通知列表、更新通知已讀狀態),且同時儲存新舊版通知資料,但在推播的 job 當中是到新 DB 找資料。

Migrate 時遇到的問題與解法

我們打算要 migrate 最近三個月的資料,先前已經有準備好一隻將 MariaDB 資料 migrate 到 DynamoDB 的程式。照原本計劃在上版前要先跑 migrate,總共有 600 多萬筆資料,估計至少要一個小時以上才跑得完,但舊資料有可能會在這段期間內被更新(已讀狀態),導致新舊資料不同步的問題:

migrate 過程中,舊資料可能會被更新,新資料卻沒有被更新,導致資料不同步。

migrate 過程中,舊資料可能會被更新,新資料卻沒有被更新,導致資料不同步。

解決這個問題的方法其實顯而易見,就是 在更新舊 DB 資料的同時,也連同更新在新 DB 的同一筆資料。 同樣地,為了相容性(前端 web/APP 也要更換成打 v2 API 的版本,但 web 會先更新、APP 尚未準備好),更新新 DB 資料時也要連同更新在舊 DB 的同一筆資料,這樣就算前端換成打 v2 後 APP 仍在打 v1,兩邊的資料也都會是同步的。如此一來, migrate 也就不需要在上版前做,而是在上版之後還能夠很有餘裕地慢慢跑。

Untitled

於是加上修改也同步之後,接下來的流程大概是這樣:

  1. 後端上版(新增修改皆同步資料的版本)
  2. 跑 migrate 指令
  3. migrate 完成後,前端/APP 再上版 (改成打 v2 API 的版本)

其實更無縫的方法是直接把 v1 API 背後都改成串接新服務,並提供相同的回傳資料格式,如此的話前端也無需替換接口。但由於在「讀取列表」API 的分頁參數有做改動(原本 v1 是使用頁數去取不同分頁的資料,但在 DynamoDB 取資料時無法用指定頁數的方式,不支持像是 SQL 的 offset 語法的用法或可以達到類似效果的方法,而是要指定從哪筆資料開始往下取幾筆的方式,類似 Cursor 的概念),前端必定得進行相對應的修改,無法直接抽換。

不過透過以上的做法,已經能夠在不讓使用者覺察的前提下,將資料庫進行抽換並完成資料的搬移。後續就只需要持續追蹤 Log,如果 v1 API 都沒有再被呼叫的話,就可以直接把 v1 都拔掉,也無需再同步將資料寫入舊 DB、更新舊 DB 的資料 。