Làm dev đau lưng

Database migrations with zero downtime

Bài viết lần này về một dự án mà mình may mắn được tham gia, cũng như dành toàn bộ thời gian để thực hiện kể từ lúc vào công ty.

Có bao giờ mọi người phải chuyển từ database này sang một database khác chưa? Vì nhiều lý do như performance ngày càng chậm, hay chi phí quá cao chẳng hạn. Làm cách nào để chuyển đổi mà vẫn giữ hệ thống không bị downtime, đồng thời giảm rủi ro nhất có thể nhỉ?

Đây là pattern mình đã và đang áp dụng để chuyển đổi một con Mongo sang PostgreSQL:

  1. Thực hiện Write song song giữa 2 databases.
  2. Chuyển dữ liệu sang database mới.
  3. Chuyển Read requests sang database mới.
  4. Chuyển Write requests sang database mới.

Đấy, đơn giản vậy thôi, dùng AI làm cho rồi =)) Đùa chứ bắt đầu vào chi tiết từng bước nào.

Thiết kế Postgres table

Mình sẽ làm ví dụ đơn giản quy trình chuyển đổi từ Mongo sang PostgreSQL, vì vậy ở bước này chỉ cần map từng field từ Mongo sang Postgres datatype:

Mongo to PostgreSQL table.

1. Thực hiện Write song song giữa 2 databases

Kiểm tra lại ứng dụng, ở đâu có Write requests vào students table, hãy thêm một đoạn đồng bộ vào Postgres.

func Insert(s Student) error {
    err := insertMongo(s.toMongoEntity())
    if err == nil {
        err = syncPostgres(s.toPostgresEntity())
    }
    return err
} 

Ghi chú và những vấn đề mình đã gặp:

  • Write requests bao gồm tất cả requests thay đổi dữ liệu cụ thể là thêm, sửa, xóa.
  • Ứng dụng sẽ tăng latency một chút vì Write song song 2 databases.
  • Review kĩ lưỡng bước này vì nó là điều kiện tiên quyết của những bước sau.
  • Nếu sử dụng upsert, hãy cẩn cận với special datatypes như Object, Array… Mình đã viết một bài về vấn đề này.

2. Chuyển dữ liệu sang database mới

Sau khi deploy bước (1), mình tiếp tục chuyển dữ liệu từ bảng cũ sang bảng mới. Lúc này tùy thuộc vào độ lớn, độ phức tạp dữ liệu của các bạn, hãy research thử big data tools trên thị trường. Trường hợp của mình chỉ cần viết một script với worker pattern và thực hiện lần lượt:

  • Kéo Mongo data về.
  • Chuyển đổi sang Postgres datatype.
  • Đẩy vào Postgres.

Okay, sau khi hoàn thành bước này thì dữ liệu của 2 bảng đã có thể tự động đồng bộ với nhau. À, có một vài lưu ý nhỏ:

  • Đẩy dữ liệu từ từ thôi, coi chừng stress con master.
  • Khi sử dụng upsert nhớ cận thận race condition nó override các special datatypes như jsonb, array…

3. Chuyển Read requests sang database mới

Một lần nữa kiểm tra lại ứng dụng, ở đâu có Read requests vào students table, hãy đổi sang đọc từ Postgres (chuyển dần services có số lượng requests thấp trước để thử nghiệm):

func Retrieve(id string) (Student, error) {
    return retrieveMongo(id)
} 

// Switch to
func Retrieve(id string) (Student, error) {
    return retrievePostgres(id)
} 

3.1 Thêm một bước so sánh dữ liệu

Việc này phải trade-off latency một chút, đồng thời tích hợp alert nếu kết quả không khớp nhau.

func Retrieve(id string) (mgS Student, err error) {
    mgS, err = retrieveMongo(id)
    if err != nil {
        return
    }

    pgS, err := retrievePostgres(id)
    compareStudent(mgS, pgS) // compare Mongo & Postgres record
    return
} 

3.2 Tích hợp Canary release (sẽ mô tả rõ hơn ở bài viết sau)

Cẩn thận hơn bằng cách rollout dần dần đến từng tập người dùng thay vì release một phát 100% users luôn. Mình đã từng bị dính chưởng, mọi người đừng như mình =))

func Retrieve(id string) (Student, error) {
    isFlagged := isPostgresFlagged(id) 
    if isFlagged { // rollout to a small subset of users
        return retrievePostgres(id)
    }
    return retrieveMongo(id)
} 

Okay, vậy đã cắt hết Read requests vào Mongo. Tiếp tục nào.

4. Chuyển Write requests sang database mới

Mình sẽ đổi một xíu ở bước (1), thay vì write vào Mongo trước, mình write vào Postgres và sau đó đồng bộ qua Mongo:

func Insert(s Student) error {
    err := insertPostgres(s.toPostgresEntity())
    if err == nil {
        err = syncMongo(s.toMongoEntity())
    }
    return err
} 

Tada, đã xong quá trình migrate từ Mongo qua Postgres mà không có bất kỳ downtime nào. Từ giờ chỉ cần monitor xem requests qua database mới ổn định hay chưa, sau đó cắt Write requests và stop con database cũ thôi.

func Insert(s Student) error { // cut Mongo write request
    return insertPostgres(s.toPostgresEntity())
} 

...

Mọi người lưu ý, bài viết chỉ là một ví dụ rất đơn giản. Trong thực tế, việc migration cần rất nhiều thời gian monitoring để đảm bảo data consistency, không phải muốn switch là switch được. Hệ thống càng lớn càng phải kiên nhẫn. Migrate dần dần từng API, từng service, kết hợp với monitoring (điều quan trọng phải nhắc 3 lần), chứ đừng ham kết quả mà thay đổi tất cả chỉ trong một phát =)))

À, một note cuối cùng không kém phần quan trọng, tất cả những test cases được áp dụng với database cũ cũng phải passed khi chuyển qua database mới, chắc chắn rồi.

Như thường lệ, chúc mọi người thành công và không gặp phải những sai lầm của mình. Mọi người cũng có thể tham khảo thêm blog này: Online migrations at scale