Welcome to MikeDaDev's Blog – notes on Java, backend & system design.

Explore
MikeDaDev's Blog

Figma: scale PostgreSQL với Horizontal Sharding

Tung Bui Thanh (TungDaDev)14 mins read

Figma postgresql sharding.webp

Database đã chạm trần instance lớn nhất mà cloud cho thuê, mà vẫn muốn giữ PostgreSQL và làm Horizontal Sharding, thì phải xử lý cho xong ba chuyện: dữ liệu nào phải nằm chung một chỗ (colocation), proxy điều hướng query ra sao, và chuyển dữ liệu đang chạy sang shard mới thế nào cho an toàn.

Bài này dựa trên hai bài kỹ thuật chính thức của Figma: The growing pains of database architecture (2023, giai đoạn chia dọc) và How Figma's databases team lived to tell the scale (2024, giai đoạn chia ngang). Chỗ nào là phân tích hay ví dụ của mình thì mình ghi rõ.

# giới hạn vật lý của cụm PostgreSQL nguyên khối

Năm 2020, Figma chạy đúng một database PostgreSQL trên AWS RDS, đặt trên instance vật lý lớn nhất AWS có lúc đó. Đọc thì đã đẩy bớt sang read replica, nhưng mọi lệnh ghi (INSERT, UPDATE, DELETE) vẫn dồn hết vào một con primary.

Figma đã nâng primary từ r5.12xlarge lên r5.24xlarge (96 vCPU, 768 GiB RAM), CPU giờ cao điểm vẫn lên tới khoảng 65%. Bước đầu họ chia dọc (vertical partitioning): tách nhóm bảng ít liên quan sang database riêng, tới cuối 2022 đã có cả chục database như vậy, kèm cache và read replica. Nhưng chia dọc cũng tới trần:

  • Bảng quá lớn cho một máy: vài bảng đã lên tới vài terabyte, hàng tỷ dòng. Bảng không chia nhỏ hơn được nữa thì có tách sang database riêng cũng vẫn nằm gọn trên một máy.
  • Sắp chạm trần IOPS của RDS: lượng ghi tăng theo tốc độ đó thì chẳng bao lâu sẽ vượt mức IOPS tối đa RDS cho phép trên một instance.
  • VACUUM bắt đầu gây sự cố: bảng càng to, VACUUM (tiến trình nền dọn các phiên bản dòng cũ, bắt buộc với PostgreSQL) càng nặng, và Figma đã thấy ảnh hưởng tới độ ổn định.
  • Replica không gánh hết phần đọc: nhiều luồng đọc nhạy với replication lag (đọc ngay sau khi ghi), nên không chuyển sang replica được.

Thêm một giới hạn chung của PostgreSQL (phần này là kiến thức chung, không phải số liệu của Figma): PostgreSQL chạy kiểu mỗi kết nối một process (process-per-connection), mỗi process tốn vài MB bộ nhớ riêng. Hàng nghìn instance backend mở kết nối thẳng vào là RAM dành cho shared_buffers và page cache bị co lại, nên Figma đã đặt PgBouncer ở giữa từ giai đoạn trước.

Figma cân nhắc chuyển sang các database tự shard sẵn (CockroachDB, TiDB, Spanner, Vitess) hoặc NoSQL, nhưng chọn tự làm Horizontal Sharding ngay trên RDS PostgreSQL. Lý do họ nêu: chuyển sang một hệ lưu trữ khác đòi hỏi cuộc di chuyển dữ liệu phức tạp, bỏ đi kinh nghiệm vận hành RDS tích luỹ nhiều năm, còn NoSQL thì không hợp với mô hình dữ liệu quan hệ khá phức tạp của họ. Họ chủ động chọn đường "rủi ro đã biết" thay vì một lựa chọn có vẻ dễ hơn nhưng nhiều ẩn số.


# thiết kế Colocated Tables và phân cấp Shard Key

Làm sharding với RDBMS thì nguyên tắc số một là: hạn chế tối đa Cross-Shard JOIN và transaction xuyên shard. Query nào phải lấy dữ liệu từ nhiều máy rồi ghép lại qua mạng thì vừa chậm vừa tốn; còn transaction xuyên shard thì cần 2PC (Two-Phase Commit), thứ Figma chưa hỗ trợ.

Vì vậy Figma nhóm bảng theo colocation (họ gọi tắt là "colo"): các bảng dùng chung một shard key được xếp chung một cách chia shard vật lý, nên dữ liệu cùng một key luôn nằm cùng một máy. Trong cùng một colo, JOIN và transaction đầy đủ vẫn chạy được, miễn là chỉ đụng tới một giá trị shard key.

(Tên bảng trong sơ đồ là minh hoạ, Figma không công bố danh sách bảng cụ thể.)

# chuẩn hóa Shard Key cấp gốc

Figma không ép mọi thứ vào một shard key duy nhất. Họ chọn một nhóm nhỏ key: UserID, FileID, OrgID. Theo Figma, gần như bảng nào cũng shard được theo một trong mấy key này.

Hệ quả là bảng nào muốn nằm trong một colo thì phải mang theo cột shard key, kể cả khi bảng đã có khoá chính riêng. Ví dụ (minh hoạ) một bảng comment chỉ có comment_id thì router không biết nó nằm ở shard nào; phải có thêm file_id. Phần khó nhất trong thực tế là sửa code ứng dụng: query nào không truyền shard key thì phải viết lại. Figma làm một framework "shadow application readiness" chạy trên traffic production thật để dự đoán query nào sẽ hỏng với từng phương án shard key, từ đó các team sản phẩm biết chỗ nào cần refactor.

# xử lý khóa ngoại và định danh toàn cục

  • Foreign Key và unique index: khi dữ liệu nằm ở nhiều database độc lập, PostgreSQL không còn kiểm tra được khoá ngoại hay unique index toàn cục. Figma chấp nhận điều này: unique index chỉ được hỗ trợ khi có chứa shard key, còn unique index phân tán toàn cục được họ xếp vào việc cần làm tiếp.
  • ID và hotspot: nhiều shard key của Figma là ID tự tăng hoặc ID kiểu Snowflake (có tiền tố timestamp). Chia shard theo dải ID thì dữ liệu mới luôn dồn vào một shard. Thay vì đổi sang ID ngẫu nhiên (phải migrate dữ liệu rất tốn), Figma định tuyến theo giá trị hash của shard key. Hàm hash đủ ngẫu nhiên thì dữ liệu rải đều. Cái giá là range scan theo shard key kém hiệu quả hơn, vì các key liền nhau bị rải ra nhiều shard.

# phân định Global Tables và Sharded Tables

Figma không shard mọi bảng một lượt. Họ shard dần từng bảng: riêng bảng đầu tiên mất khoảng 9 tháng, xong vào tháng 9/2023. Những bảng chưa cần chia vẫn ở trong các database đã chia dọc như cũ.

Cách chia dùng hash range: mỗi shard logic phụ trách một khoảng giá trị hash, theo dạng

hash(shard_key) >= min_shard_range AND hash(shard_key) < max_shard_range

So với hash % N, chia theo khoảng có lợi khi cần tách shard: muốn chia đôi một shard chỉ cần cắt đôi khoảng hash của nó, không phải xáo lại dữ liệu của mọi shard như khi đổi N. Figma không công bố tên hàm hash cụ thể.


# kiến trúc Query Router: Phân tích cú pháp SQL AST

Figma muốn kỹ sư sản phẩm vẫn viết query gần như bình thường, không phải tự tính shard ở từng dòng code. Nên họ viết DBProxy, một service bằng Go nằm giữa tầng ứng dụng và PgBouncer.

Query engine của DBProxy có ba phần:

  1. Query parser: đọc SQL ứng dụng gửi tới, chuyển thành cây cú pháp trừu tượng (Abstract Syntax Tree, AST).
  2. Logical planner: duyệt AST để biết loại query (insert, update, select...) và query thuộc shard logic nào, dựa vào giá trị shard key trong điều kiện.
  3. Physical planner: ánh xạ shard logic sang database vật lý và viết lại query để chạy trên đúng shard.

Query có shard key thì chỉ đi tới một shard. Query không có shard key thì DBProxy làm scatter-gather: gửi tới mọi shard rồi ghép kết quả. Figma có cho phép, nhưng nói rõ cái giá: mỗi query scatter-gather tạo ra tải ngang với khi database chưa shard, nên dùng nhiều thì sharding mất ý nghĩa. Để query engine không quá phức tạp, họ chỉ hỗ trợ một tập con SQL đủ cho khoảng 90% query phổ biến: range scan và point query thì được, còn JOIN chỉ được khi hai bảng cùng colo và JOIN trên shard key.

Ngoài ra DBProxy còn lo load shedding, request hedging (gửi dự phòng request chậm sang chỗ khác), hỗ trợ transaction trong phạm vi một shard, và quản lý topology: một thư viện mô tả bảng nào thuộc colo nào, shard logic nào nằm trên database vật lý nào, kèm các bất biến (invariant) được kiểm tra tự động để cấu hình sai không lọt lên production.


# giải quyết bài toán bùng nổ kết nối M x N

Phần này là phân tích chung, không phải số liệu Figma công bố. Chia database thành N shard, backend có M instance, mỗi instance giữ pool riêng tới từng shard, thì số kết nối tối đa có thể lên tới:

Tổng kết nối = M × N × Pool Size

Giả sử 2.000 instance backend nối tới 48 shard, pool size 10, tổng số kết nối là 2.000 \times 48 \times 10 = 960.000. Không cụm PostgreSQL nào gánh nổi chừng đó.

Ở Figma, ứng dụng chỉ nói chuyện với DBProxy, còn kết nối thật xuống database vẫn đi qua tầng PgBouncer họ dùng từ trước. Cơ chế gom kết nối của PgBouncer ở chế độ transaction pooling như sau:

  • Ứng dụng giữ kết nối với PgBouncer (rẻ, PgBouncer chịu được rất nhiều kết nối client).
  • PgBouncer chỉ mượn một kết nối thật tới PostgreSQL khi transaction bắt đầu, COMMIT hoặc ROLLBACK xong là trả lại pool ngay.
  • Nhờ vậy số kết nối thật tới mỗi database chỉ phụ thuộc vào số transaction chạy đồng thời, không phụ thuộc số instance backend.

Đổi lại, transaction pooling cấm dùng trạng thái theo session: SET biến session, advisory lock theo session, LISTEN/NOTIFY, bảng tạm sống qua nhiều transaction. Prepared statement phía server trước đây cũng không dùng được; từ PgBouncer 1.21 thì đã hỗ trợ ở mức protocol (max_prepared_statements).


# quy trình chuyển dữ liệu live (gần như không downtime)

Cách Figma đi từ một bảng nằm trên một database tới bảng chia trên nhiều shard vật lý gồm hai giai đoạn tách biệt: shard logic trước, shard vật lý sau. Sơ đồ dưới là mình tóm lại từ mô tả của Figma, chi tiết từng bước họ không công bố hết:

# 1. nạp dữ liệu nền bằng logical replication

Figma tái sử dụng hạ tầng đã dùng cho đợt chia dọc. Ở đợt đó họ dùng PostgreSQL logical replication để copy một nhóm bảng sang database mới, rồi failover. Điểm khéo ở đợt chia ngang: họ không làm "filtered logical replication" (chỉ copy phần dữ liệu thuộc về từng shard), vì cái đó khó làm đúng. Thay vào đó họ copy toàn bộ bảng sang mỗi shard mới, rồi chỉ cho phép đọc/ghi phần dữ liệu thuộc khoảng hash của shard đó. Phần thừa dọn sau.

# 2. shard logic và đọc ngầm đối chiếu (Shadow Reads)

Trước khi đụng tới tách vật lý, Figma làm logical sharding: trên chính database chưa shard, mỗi shard được đại diện bằng một view PostgreSQL:

CREATE VIEW table_shard1 AS
SELECT * FROM table
WHERE hash(shard_key) >= min_shard_range
  AND hash(shard_key) < max_shard_range;

Từ lúc bật logical sharding, mọi đọc/ghi qua DBProxy đều cư xử như thể bảng đã chia ngang thật, dù dữ liệu vẫn nằm trên một máy. Cách này có mấy cái lợi:

  • Bật dần bằng feature flag, gặp bug thì rollback chỉ là đổi cấu hình, không phải chuyển dữ liệu ngược.
  • Theo đo đạc của Figma, view chỉ thêm chút overhead, trường hợp xấu nhất dưới 10%.
  • Họ chạy shadow read: đưa traffic đọc thật qua view để so sánh hiệu năng và kết quả với đường cũ, trước khi làm bước tách vật lý vốn rủi ro hơn nhiều.

# 3. failover có kiểm soát (Cutover)

Bước cuối là chuyển phần dữ liệu của từng shard logic sang database vật lý mới. Figma không mô tả chi tiết bước này trong bài 2024, chỉ nói họ dùng lại logic failover của đợt chia dọc. Ở đợt chia dọc, quy trình họ công bố là: chuẩn bị ứng dụng nói chuyện với nhiều database qua PgBouncer, copy bằng logical replication, tạm dừng hoạt động trên database gốc và thu hồi quyền query trên các bảng được chuyển, so LSN (Log Sequence Number, vị trí trong WAL) để chắc hai bên đã khớp, chuyển PgBouncer sang database mới, rồi mở lại. Đợt đó mất khoảng 30 giây sẵn sàng một phần (khoảng 2% request bị rớt).

Với bảng shard ngang đầu tiên (tháng 9/2023), Figma failover với chỉ khoảng 10 giây sẵn sàng một phần trên primary, không ảnh hưởng gì tới replica. Nói "zero downtime" thì không chính xác, nhưng 10 giây cho một thay đổi cỡ này là rất ấn tượng.


# tổng kết các nguyên tắc kiến trúc

Figma cho biết hạ tầng database của họ đã lớn lên gần 100 lần kể từ 2020. Mấy lựa chọn đáng học:

Khía cạnhCách làm quen thuộcCách Figma làm sharding
Chọn chỗ lưu trữVội nhảy sang NoSQL/NewSQLGiữ RDS PostgreSQL, chia dọc trước, chia ngang sau
Chia dữ liệuChia theo dải IDColo: nhóm bảng theo vài shard key, định tuyến theo hash của key
Ràng buộc toàn vẹnTrông vào Foreign Key trong DBChấp nhận mất FK và unique index toàn cục, unique phải chứa shard key
Kiểm soát queryỨng dụng muốn nối đâu thì nốiDBProxy parse AST, chỉ hỗ trợ tập con SQL, JOIN chỉ trong cùng colo
RolloutTách vật lý một lần rồi cầu mayShard logic bằng view + feature flag + shadow read, rồi mới tách vật lý

Những việc Figma tự nhận là chưa xong: thay đổi schema trên bảng đã shard, transaction nguyên tử xuyên shard, unique index phân tán, và tự động hoá việc chia lại shard. Họ cũng nói sẽ cân nhắc lại chuyện tự làm sharding khi các giải pháp NewSQL được quản lý sẵn đủ trưởng thành. Nếu bạn đang ở giai đoạn một database còn gánh được, bài database sharding đi qua các chiến lược chia shard nói chung, nên đọc trước khi quyết định tự làm như Figma.


Chỉ là những ghi chép cá nhân với hy vọng mang lại chút giá trị. Nếu thấy hữu ích, đừng ngại chia sẻ cho bạn bè & đồng nghiệp nhé!

Happy coding 😎 👍🏻 🚀 🔥.