

1/1/1970
Cách Chuyển Đổi từ Firestore sang Postgres trong 2 Ngày
Firebase. Chỉ một từ thôi cũng đủ khiến bạn tốn gấp 10-20 lần so với một cơ sở dữ liệu rẻ hơn, đồng thời có thể vừa hạn chế vừa làm phức tạp thiết kế cơ sở dữ liệu của bạn.
Vậy nên bạn có thể tưởng tượng tôi đã nhẹ nhõm thế nào khi hoàn thành việc chuyển khỏi Firestore chỉ trong 2 ngày. À, 3 ngày nếu tính cả thời gian xây dựng "PostgresSynchronizer" ban đầu. Hoặc 3,5 ngày nếu tính cả thời gian chuyển đổi hệ thống workshop. Dù sao thì cũng nhanh. Nhanh thật sự.
Ước tính ban đầu của tôi cho dự án này là cả một tháng làm việc! Nhưng nhờ vào một vài prompt LLM cực đỉnh, và một người bạn đã dạy tôi về CDC, tôi đã hoàn thành nhanh hơn đáng kể.
Nếu bạn đang tìm kiếm pub/sub, Firebase hoặc Supabase nghe có vẻ rất hấp dẫn. Suy cho cùng, không có nhiều cơ sở dữ liệu hỗ trợ pub/sub sẵn có. Nhưng kiến trúc đơn giản dưới đây là tất cả những gì bạn cần cho hầu hết các trường hợp doanh nghiệp nhỏ (và có thể mở rộng tốt lên khoảng 10k-100k ops/giây).
Tuy nhiên, trước khi đi sâu vào chi tiết cách pub/sub hoạt động, tôi sẽ điểm qua các bước tôi đã thực hiện theo thứ tự.
Đầu tiên, tôi tạo một prompt trong Cursor để xây dựng PostgresSynchronizer. Class này có một hàm handleWrite, nhận vào đường dẫn Firestore, loại thao tác (tạo, cập nhật, xóa) và dữ liệu. Sau đó nó upsert dữ liệu vào Postgres. Tôi bọc instance Firestore store của mình để bất kỳ thay đổi nào trên Firestore cũng gọi handleWrite.
Tất nhiên điều này không hoàn hảo 100%: nếu máy chủ bị ngắt kết nối hoặc gặp sự cố, có thể một vài thay đổi nhỏ không được lưu lại. Nhưng với trường hợp sử dụng của Foony thì đủ tốt, và schema Postgres mang lại cho chúng tôi các đảm bảo về toàn vẹn dữ liệu tốt hơn (ví dụ ràng buộc khóa ngoại) so với Firestore.
Tiếp theo, tôi tạo và chạy một backfill duyệt qua tất cả các collection Firestore và gọi handleWrite cho mỗi document. Điều này đảm bảo chúng tôi lấy được toàn bộ dữ liệu lịch sử từ Firestore. Để tăng tốc, tôi giới hạn chỉ với người dùng đã đăng ký (xin lỗi các khách: đáng lẽ bạn nên đăng ký). Cách này hoạt động tốt và an toàn để chạy nhiều lần.
Giờ tôi đã có cách điền dữ liệu vào Postgres và giữ nó (gần như) đồng bộ với Firestore, tôi có thể giải quyết vấn đề lớn và đáng sợ: pub/sub.
Làm quái gì để có pub/sub với Postgres? Hay với bất kỳ cơ sở dữ liệu SQL nào?
Postgres Change Data Capture (CDC) cứu nguy!
CDC là một thuật ngữ hoa mỹ chỉ đơn giản nghĩa là "đọc các thay đổi trong cơ sở dữ liệu và xuất bản chúng lên hệ thống pub/sub". Ở một doanh nghiệp lớn, bạn có thể dùng thứ gì đó như Debezium + Kafka. Nhưng Kafka rất khó cài đặt, chúng tôi đã có sẵn Redis, và cơ sở dữ liệu của chúng tôi chỉ có khoảng 30 ops/giây. Ngay cả khi mở rộng quy mô gấp 100 lần lên ~100.000 người dùng đồng thời, có thể chúng tôi vẫn nhét vừa vào một instance Redis duy nhất. Vậy nên đó là điều tôi đã làm.
(Bạn cũng có thể gắn pub/sub vào Postgres bằng LISTEN/NOTIFY, nhưng nó không sống sót qua các lần kết nối lại và sụp đổ khi bạn thực sự cần fanout. CDC là câu trả lời nhàm chán nhưng bền bỉ.)
Bạn tôi, Eric, đã dạy tôi về CDC và mã nguồn mở code CDC của anh ấy, thứ đọc WAL của Postgres (Write-Ahead Log: nơi Postgres ghi mọi thay đổi mà nó thực hiện trên cơ sở dữ liệu) thông qua một logical replication slot, rồi lưu nó vào một sink (ví dụ Redis).
Với một prompt đơn giản và chi tiết trong Cursor, tôi đã có thể gần như one-shot phần code CDC realtime và gateway (với một vài chỉnh sửa nhỏ và review code kỹ lưỡng). Điều này bao gồm hai dịch vụ rất đơn giản:
- một dịch vụ CDC đơn lẻ đọc WAL và xuất bản lên Redis Pub/Sub (có thể đạt độ bền cao hơn với Streams, nhưng đánh đổi bằng độ phức tạp)
- một dịch vụ gateway tự mở rộng theo chiều ngang, xử lý xác thực JWT và kết nối websocket từ các client web.
Cả hai dịch vụ đều viết bằng golang, và tôi đặc biệt thích phần gateway tự mở rộng. Client web đăng ký theo cách giống hệt như trước với Firestore, và nhận lại dữ liệu cùng định dạng. Client thấy "usersPublic/" và "usersPrivate/" là hai collection riêng biệt với quyền riêng biệt. Gateway chịu trách nhiệm dịch yêu cầu đó sang các bảng Postgres bên dưới (trong trường hợp này là users), và xác thực rằng client có quyền truy cập dữ liệu đó.
Cách này hoạt động cực kỳ hiệu quả. Toàn bộ hệ thống pub/sub chỉ tốn khoảng 0,50 USD/tháng cho chi phí compute + egress, và không có nhiều bộ phận có thể hỏng (code CDC, gateway và client đều đơn giản, không có dependency bên thứ ba nào ngoài Redis/Postgres).
Từ góc nhìn DevEx, hệ thống mới được cho là cũng đơn giản hơn: lập trình viên không cần biết cách mô hình hóa dữ liệu trong Firestore hay quy tắc bảo mật Firestore. Miễn là họ hiểu SQL, họ có thể nhanh chóng chỉnh sửa schema.sql, thêm hoặc sửa một route trong all.go, và xong rồi. Tài liệu cũng đủ chắc để LLM dễ dàng tuân theo và thực hiện thay đổi. Về mặt bảo mật, đây cũng là một điểm thắng vì chúng tôi không còn lộ ra trước cuộc tấn công denial-of-wallet (Wallet DoS) nguy hiểm nữa, vì hạ tầng mới có chi phí cố định và rẻ hơn nhiều.
Tổng cộng, bao gồm cả instance Postgres và backup S3 hàng ngày, chúng tôi đã giảm chi phí hạ tầng cơ sở dữ liệu từ 550 USD/tháng xuống chỉ còn 40 USD/tháng. Chúng tôi cũng giảm được kích thước bundle client khoảng ~100KB, cũng khá tuyệt.
Giờ đã có hệ thống mới, tôi kiểm thử cục bộ để đảm bảo mọi thứ hoạt động như mong đợi. Sau đó, tôi triển khai lên môi trường thực. Nếu có vấn đề gì, dễ dàng rollback phía client vì tại thời điểm đó Firestore vẫn là nguồn dữ liệu chính.
Lên Sóng
Việc chuyển đổi máy chủ khá đáng sợ (migration cơ sở dữ liệu lúc nào cũng vậy). Đến thời điểm này, Firestore vẫn là nguồn chân lý: hệ thống CDC pub/sub chỉ đọc từ một bản mirror Postgres mà synchronizer giữ cập nhật. Việc triển khai các game server mới mới là thứ thực sự chuyển Postgres thành nguồn ghi chính thức.
Đây là cách tôi triển khai:
- Cập nhật đối tác trước. Foony được tích hợp với các đối tác như FRVR, nên chúng tôi triển khai một bản build client mới trước vài ngày, để dùng gateway CDC mới của chúng tôi.
- Cảnh báo người chơi. Khoảng mười phút trước khi chuyển đổi, chúng tôi đăng thông báo về một khoảng thời gian bảo trì (hy vọng là ngắn).
- Sao lưu cơ sở dữ liệu mới. Đây luôn là ý tưởng hay với các migration cơ sở dữ liệu lớn. LLM gọi đây là "thắt lưng và đeo dây quần", về cơ bản nghĩa là cực kỳ cẩn trọng và có kế hoạch dự phòng.
- Triển khai cả hai cụm cùng lúc. Không blue/green lần này. Tôi muốn đảm bảo tất cả các cụm đều ghi vào cùng một cơ sở dữ liệu để tránh bất kỳ sự không nhất quán nào.
Tôi cũng có sẵn kế hoạch rollback rất đơn giản: nếu có gì đó sai sót, tôi sẽ triển khai lại client trước đó (vẫn đọc từ Firestore), triển khai lại máy chủ, khởi động lại backfill, và thử lại sau. Việc này sẽ gây ra khoảng 5 phút downtime trong khi máy chủ triển khai lại, và khoảng nửa ngày để bắt cơ sở dữ liệu Postgres bắt kịp lại với Firestore.
Việc chuyển đổi thực tế chỉ mất khoảng 1 phút downtime. Lỗi nghiêm trọng duy nhất xuất hiện là khi cập nhật experience: hàm LOWER() của Postgres trên cột bigint đang ngầm ép kiểu sang text. Hừm. Đây là một sửa đổi đủ dễ với một cast đơn giản về bigint, nên tôi tiếp tục migration bằng cách triển khai máy chủ một lần nữa.
Tôi đã rất bất ngờ khi thấy mọi thứ diễn ra suôn sẻ. Rất ít người báo cáo vấn đề, và những vấn đề đó đều rất nhỏ (ngoại trừ chuyện experience kia). Điều đặc biệt khiến tôi sốc là migration này diễn ra trơn tru thế nào dù phần lớn được vibe-code. Khá khác với những câu chuyện đáng sợ bạn có thể đọc trên báo.
Viết lại Workshop
Khi Postgres giờ đã là nguồn ghi chính thức, chỉ còn một mảng lớn của codebase còn vướng víu với Firestore: hệ thống workshop. Người chơi dùng nó để chia sẻ map tùy chỉnh trong Dino-Might Bomber Online, danh sách từ trong Draw & Guess, v.v. Kiểu như Steam Workshop, nhưng dành cho game của chúng tôi. Đây là tính năng còn dính líu nhiều nhất tới Firestore, cả ở client lẫn server, và có cấu trúc khá vụng do hạn chế của mô hình dữ liệu Firestore mà tôi cần đơn giản hóa.
Để bắt đầu, tôi đưa Cursor prompt này (dùng Opus 4.7 high trong plan mode):
Now that we've finished most of the migration to Postgres, we have the last big part of the migration: rewriting the workshop system.
We'll need to update schema.sql to support the new workshop tables.
Think of the workshop sort of like Steam's workshop.
In other words, the workshop must:
- Support multiple games.
- Support different data formats (e.g. maps in dinomight, word lists in paintjob)
- Support favoriting
- Support like / dislike
- Support a description
Use best practices to ensure that the SQL is efficient.
After updating schema.sql, you will need to update both the backend and the frontend to use the new workshop system. You will also need to either use the `realtime.use` hook or the API for workshop info (up to you which you choose--realtime requires modifying all.go, API calls require updates to Action.ts, etc.). I'd probably go with the cdc gateway (realtime.use / all.go).
Keep your implementation simple where possible. At the end of this migration, we should no longer have `firestore.use` or any mention of firestore on the client. We should also no longer need firestore on the backend.
As part of this change, you will need to also create a backfill migration (there's already a migration backfill--just modify that to work with these firestore workshop collections) that migrates the firestore data to the postgres schema you decide on.
You shouldn't run the backfill--I will do that myself manually once you're done with your code. You can leave a call to the backfill (commented out) at the bottom of server/src/index.ts for me. The backfill should only handle migration of these remaining tables--it shouldn't do any backfilling of old tables that we've already migrated (e.g. no `userItems`, `usersPublic`, ...).
Với những gì tôi đã thấy về năng lực của Opus 4.7 trong harness của Cursor, tôi dự đoán migration này sẽ hoàn thành qua đêm.
Điều lớn mà tôi bỏ sót trong prompt ban đầu, mà agent yêu cầu tôi làm rõ, là cách xử lý hiệu quả việc sắp xếp theo "Ngày Trước", "Tuần Trước", "Tháng Trước" và "Mọi Thời Đại". Agent muốn dùng cách tiếp cận bucket theo timestamp (hoặc bỏ luôn việc sắp xếp theo thời gian), nhưng người đồng sáng lập khăng khăng giữ sắp xếp theo thời gian dù nó phức tạp hơn.
Sau một hồi suy nghĩ, tôi nghĩ ra một giải pháp đơn giản dùng hệ số suy giảm. Mỗi mục workshop có các cột played_count_day, played_count_week, played_count_month, và played_count_all, và một cron job hàng giờ nhân các cột cuốn chiếu lần lượt với 23/24, 167/168, và (720-1)/720. Kết hợp với partial indexes (WHERE private = false AND played_count_day >= 0.368) trên mỗi trục sắp xếp, ta có được truy vấn "phổ biến nhất theo khoảng thời gian" cực rẻ mà không cần thêm hạ tầng nào. Đây là điều tôi sẽ không làm với Firestore do chi phí đọc và ghi cắt cổ của nó, nhưng ở Postgres thì gần như miễn phí.
Xong, tôi đi ngủ. Sáng dậy, tôi hớn hở kiểm tra công việc của nó! Agent đã thêm các bảng mới workshop_items, workshop_item_votes, và user_subscriptions, kết nối client để đọc từng item thông qua gateway CDC (realtime.use('workshopItems/{id}')), và viết lại cả sáu workshop action ở backend để giao tiếp trực tiếp với Postgres. Có một vài điểm dang dở mà tôi phải dọn dẹp (một truy vấn tới Firestore ở server, một lỗi với backfill do thiếu dữ liệu trong Firestore, v.v.), nhưng nhìn chung code gần như hoàn hảo.
Sau khi chạy backfill và kiểm thử để đảm bảo mọi thứ hoạt động cục bộ, tôi triển khai thay đổi lên môi trường thực. Cuối cùng, codebase đã hoàn toàn không còn Firestore. Tuyệt vời.
Việc Cần Làm Trong Tương Lai
Trong tương lai, tôi cũng muốn hỗ trợ patch trong gateway. Hiện tại, gateway gửi lại toàn bộ document dưới dạng JSON cho mỗi cập nhật. Hơi lãng phí, nhưng băng thông egress của chúng tôi gần như vô hạn nhờ ở trên Hetzner. Tôi sẽ triển khai ngay bây giờ, nhưng chưa thể biện minh cho phần phức tạp thêm được.