Database Sharding system design
Tung Bui Thanh (TungDaDev)10 mins read

Quy tắc số một của sharding: còn cách khác để giải quyết thì đừng shard.
Đi phỏng vấn System Design hay lượn các diễn đàn công nghệ, Database Sharding lúc nào cũng là chủ đề được bàn nhiều.
Ai cũng hào hứng kể chuyện chia database ra 16 shard, 64 shard, xử lý hàng trăm triệu giao dịch mỗi ngày như Netflix hay Cash App. Nhưng chạy production rồi mới thấy sharding giống "bùa hai mặt": nó giải được bài toán mở rộng phần ghi (write scalability), đổi lại cả tầng nghiệp vụ vốn đơn giản bỗng thành một mớ rắc rối phân tán.
Vậy khi nào mới thật sự cần sharding? Ước tính dung lượng (capacity planning) thế nào cho sát, và chọn sharding key ra sao để khỏi tự đào hố chôn mình?
Mình sẽ đi từ mấy phép tính cơ bản tới những chỗ hay vấp khi làm thật trong các hệ thống Java/Spring Boot cỡ lớn.
# quy tắc ước tính dung lượng
Trước khi nghĩ tới sharding, nên bắt đầu bằng vài phép tính nhẩm "trên vỏ bao diêm" (back-of-the-envelope) để chọn đúng công nghệ. Sơ đồ dưới chỉ là mốc thô theo dung lượng; thực tế thứ quyết định thường là lượng ghi chứ không chỉ số TB:
# mấy mốc dung lượng kinh điển
- Dưới 100 GB: phần dữ liệu hay dùng nằm gọn trong RAM (buffer pool) của một máy. Một con database bình thường chạy mượt, đọc trúng cache thì độ trễ dưới mili giây. Redis lúc này là để cache, không phải lý do để bỏ SQL.
- Từ vài trăm GB đến 10 TB: đừng vội shard! Máy trên AWS/GCP bây giờ có loại hàng trăm vCPU, hàng TB RAM; một volume EBS io2 Block Express đạt tới 256.000 IOPS, ổ NVMe gắn trong máy còn cao hơn. Thêm read replica (ghi vào primary, đọc từ vài replica) và table partitioning (chia theo tháng/năm), một node SQL phần cứng tốt gánh được cỡ hàng chục nghìn transaction đơn giản mỗi giây. Con số thật thì tuỳ workload, cách duy nhất để biết là tự benchmark với query của mình (vd
pgbench,sysbench). Nhớ là read replica chỉ chia tải đọc, mọi lệnh ghi vẫn dồn về một primary. - Từ 10 TB trở lên và lượng ghi vượt sức phần cứng: khi primary đã bão hoà phần ghi (nghẽn disk I/O, WAL, lock) hoặc dữ liệu quá to để backup/restore, dựng replica trong thời gian chấp nhận được, lúc đó mới buộc phải đi đường sharding.
# partitioning khác sharding chỗ nào
Nhiều người hay lẫn hai khái niệm này:
| Tiêu chí | Table Partitioning | Database Sharding |
|---|---|---|
| Vị trí vật lý | Nằm trên cùng một máy database | Nằm trên nhiều máy / cụm database độc lập |
| Khả năng mở rộng | Index nhỏ lại, query nhanh hơn, nhưng không tăng sức ghi CPU/RAM | Tăng gần tuyến tính CPU, RAM, IOPS (nếu ít query xuyên shard) |
| Độ phức tạp | Rất thấp (database engine tự lo) | Rất cao (cần router, transaction phân tán, backup khó) |
# chọn sharding key: quyết định sống còn
Việc quan trọng nhất khi shard là chọn sharding key. Chọn sai là tương lai cả hệ thống phải đập đi xây lại!
# hash-based sharding
- Công thức:
ShardID = Hash(ShardingKey) % TotalShards - Ưu điểm: dữ liệu rải rất đều trên các node, gần như không có chuyện nghẽn cục bộ.
- Nhược điểm lớn nhất: thêm node mới (resharding) cực kỳ khổ! Với
% TotalShards, tăng từ 3 lên 4 node thì chỉ khoảng 1/4 số key giữ nguyên chỗ, cỡ 3/4 dữ liệu phải dời đi. Có hai cách hay dùng để bớt khổ:- Consistent hashing: thêm node chỉ phải dời khoảng 1/N dữ liệu.
- Chia sẵn nhiều shard logic: ví dụ hash ra 1.024 shard logic ngay từ đầu, rồi map nhiều shard logic vào một máy. Cần thêm máy thì chỉ chuyển nguyên vài shard logic sang, không phải băm lại từng key. Citus làm đúng kiểu này (số shard cố định, mặc định 32, rebalance bằng cách chuyển nguyên shard giữa các node); Vitess thì hash ra keyspace ID rồi chia theo range, resharding là tách một range thành nhiều range nhỏ.
- Cẩn thận trong Java:
hashCode()có thể âm, mà%của số âm trong Java ra số âm. DùngMath.floorMod(hash, n)thay chohash % n. Hàm hash cũng phải ổn định qua mọi lần chạy và mọi ngôn ngữ đang đọc dữ liệu đó, nên hay chọn hàm hash cố định như MurmurHash, CRC32 thay vìhashCode()của object.
# range-based sharding
- Cách chia: ví dụ shard 1 chứa ID từ 1 - 10 triệu, shard 2 chứa từ 10 - 20 triệu. Hoặc chia theo ngày tháng.
- Ưu điểm: query theo khoảng (
BETWEEN date1 AND date2) rất tiện. - Nhược điểm: hotspot shard! Dữ liệu mới hôm nay dồn hết vào một shard của hôm nay, node đó quá tải trong khi mấy node cũ ngồi chơi.
# entity-group sharding
- Lấy
TenantIDhoặcCustomerIDlàm sharding key. - Toàn bộ dữ liệu của một khách hàng (hồ sơ, giao dịch, ví, lịch sử chuyển khoản) nằm gọn trong một shard.
- Cái lợi lớn nhất: với từng khách hàng, bạn vẫn dùng
JOINvà transaction ACID bình thường, miễn là query chỉ đụng dữ liệu của một khách. - Cái giá: tenant khổng lồ ("cá voi") có thể to hơn sức một shard, làm shard đó thành hotspot. Hay gặp cách xử lý là cho tenant lớn một shard riêng, hoặc dùng một bảng tra
tenant -> shard(directory) thay cho công thức hash để còn chuyển được từng tenant.
TIP
Nên dùng cho FinTech / SaaS nhiều tenant: chọn entity-group sharding (theo TenantID hoặc OrganizationID). Làm vậy thì phần lớn query và transaction chỉ chạy trong một shard, khỏi cần tới distributed transaction tốn kém.
# đưa sharding lên production thì gặp gì
Nếu bạn nghĩ sharding chỉ là chia bảng thì đây là mấy thứ đang chờ bạn:
# bế tắc với cross-shard join
Ở monolith, bạn viết thoải mái:
SELECT u.name, o.total FROM users u JOIN orders o ON u.id = o.user_id;Khi users nằm ở shard 1 còn orders nằm ở shard 2, câu JOIN này không chạy được ở tầng database!
- Bạn buộc phải lấy dữ liệu từ cả 2 shard về Spring Boot rồi tự join trong RAM bằng code Java (application-side join), hoặc chấp nhận denormalization.
- Cách tránh từ gốc là shard
usersvàorderstheo cùng một key (user_id), để user và đơn hàng của họ luôn nằm chung shard (co-location). Mấy bảng nhỏ, ít đổi như danh mục tỉnh thành, tỷ giá thì chép nguyên ra mọi shard (Citus gọi là reference table, ShardingSphere gọi là broadcast table) để JOIN tại chỗ.
# distributed transaction: đau đầu thật sự
Người dùng A (ở shard 1) chuyển tiền cho người dùng B (ở shard 2):
- Transaction local thông thường (
@Transactionalvới mộtDataSourceTransactionManager, hayBEGIN ... COMMITtrên một connection) không bao được cả 2 máy độc lập cùng lúc. - Về lý thuyết có XA / Two-Phase Commit (JTA, Atomikos, ShardingSphere cũng hỗ trợ XA). Nhưng 2PC chậm, giữ lock suốt hai pha, và coordinator chết giữa chừng thì transaction treo ở trạng thái "in-doubt" chờ người xử lý. Nên đa số hệ thống lớn né nó.
- Cách hay dùng hơn là Saga Pattern (Orchestration/Choreography) hoặc Transactional Outbox kết hợp Kafka để đạt eventual consistency: trừ tiền A ở shard 1, ghi event vào bảng outbox trong cùng transaction local, rồi cộng tiền B ở shard 2 khi nhận event; bước sau lỗi hẳn thì chạy bước bù (hoàn tiền A). Phía nhận phải idempotent vì event có thể đến hơn một lần.
# báo cáo tổng hợp toàn hệ thống
Câu đơn giản như SELECT COUNT(*) hay SELECT SUM(balance) trước chạy mất 10ms, giờ phải bắn song song tới mọi shard (scatter-gather query) rồi gom kết quả lại, tốn khá nhiều băng thông mạng.
# làm routing datasource trong spring boot
Trong Java, bạn có thể tự định tuyến shard mà không cần cài thêm middleware nào, chỉ cần kế thừa AbstractRoutingDataSource:
package com.tungdadev.datasource;
import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;
public class ShardedRoutingDataSource extends AbstractRoutingDataSource {
// ThreadLocal lưu Shard Key của Request hiện tại
private static final ThreadLocal<String> CURRENT_SHARD = new ThreadLocal<>();
public static void setShard(String shardKey) {
CURRENT_SHARD.set(shardKey);
}
public static void clear() {
CURRENT_SHARD.remove();
}
@Override
protected Object determineCurrentLookupKey() {
return CURRENT_SHARD.get(); // Trả về "shard_0", "shard_1", hoặc "shard_2"
}
}Mỗi HTTP request đi vào, interceptor lấy customerId, tính Math.floorMod(hash(customerId), 3) rồi gọi ShardedRoutingDataSource.setShard("shard_" + index). Các truy vấn JPA/Hibernate phía sau sẽ trỏ vào database tương ứng.
Đoạn code trên mới là phần lõi, muốn chạy thật còn mấy chỗ dễ sai:
- Phải khai báo map
shard_0,shard_1,shard_2sang từngDataSourcequasetTargetDataSources(...). CósetDefaultTargetDataSource(...)thì quên set shard sẽ âm thầm rơi vào DB mặc định; theo mình nên để lookup keynulllà ném lỗi luôn cho dễ phát hiện. determineCurrentLookupKey()chỉ được gọi lúc lấy connection, mà với@Transactionalthì connection được lấy ngay khi transaction bắt đầu. Nên phải set shard trước khi vào method@Transactional; đổi shard giữa chừng transaction không có tác dụng gì.- Luôn gọi
clear()trongfinally(vdafterCompletioncủa interceptor). Thread của Tomcat được dùng lại cho request sau, quên dọn là request sau chạy nhầm shard. ThreadLocalkhông tự đi theo sang@Async,CompletableFuturehay thread pool khác, phải tự truyền shard key.
# tổng kết
Sharding giúp hệ thống lớn vượt qua giới hạn vật lý của một cỗ máy. Nhưng nên đi theo thứ tự ưu tiên khi tối ưu như sau:
Tối ưu hóa Query -> Thêm Indexes -> Bổ sung Redis Cache -> Tách Read Replicas -> Nâng cấp phần cứng (Vertical Scale) -> Sharding (Bước đường cùng)Chỉ khi đã tối ưu query, đánh index cẩn thận, thêm Redis cache, tách read/write và nâng phần cứng hết cỡ mà hệ thống vẫn không chịu nổi tải ghi, lúc đó mới là lúc bắt đầu tính chuyện sharding.
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 😎 👍🏻 🚀 🔥.