Mở đầu — vì sao bài này quan trọng
Có một sự thật mà hầu như dự án nào cũng phải đối mặt: máy chủ ứng dụng (application server) thì bạn có thể nhân bản thoải mái, đặt sau load balancer, chạy mười con, hai mươi con cũng được. Nhưng cơ sở dữ liệu (database) thì không đơn giản như vậy. Database là nơi lưu trạng thái (state), là "nguồn sự thật" duy nhất của hệ thống. Khi ứng dụng lớn lên, 90% các sự cố nghẽn cổ chai (bottleneck) mà tôi từng gặp đều quy về một chỗ: database không chịu nổi nữa.
Câu hỏi đặt ra rất thực tế: khi lượng truy vấn (query) tăng gấp mười, gấp trăm lần, bạn làm gì? Bạn không thể mãi mãi nâng cấp CPU và RAM cho một con server duy nhất — cách đó gọi là scale-up (mở rộng theo chiều dọc), và nó luôn có trần. Đến một lúc nào đó, con server mạnh nhất trên đời cũng không đủ.
Bài học hôm nay tập trung vào hai kỹ thuật vận hành database quan trọng bậc nhất để vượt qua giới hạn đó: Read Replicas (bản sao chỉ đọc) và Sharding (phân mảnh dữ liệu). Đây là hai công cụ khác nhau, giải quyết hai vấn đề khác nhau, và biết chọn cái nào đúng lúc là dấu hiệu của một người vận hành có kinh nghiệm. Chúng ta sẽ đi từ nguyên lý, đến ví dụ thật, đến cách triển khai và những cái bẫy dễ vấp.
Khái niệm cốt lõi
Read Replicas — nhân bản để chia tải đọc
Ý tưởng của Read Replica cực kỳ đơn giản và đẹp. Bạn có một database chính gọi là primary (hay master) — nơi duy nhất nhận cả lệnh ghi (write: INSERT, UPDATE, DELETE) lẫn lệnh đọc (read: SELECT). Sau đó bạn tạo ra một hoặc nhiều bản sao gọi là replica (hay slave), mỗi replica liên tục sao chép dữ liệu từ primary về. Điểm mấu chốt: replica chỉ phục vụ đọc, không nhận ghi.
Cơ chế đằng sau gọi là replication (sao chép). Mỗi khi primary có một thay đổi, nó ghi thay đổi đó vào một nhật ký gọi là binary log (với MySQL) hoặc Write-Ahead Log — WAL (với PostgreSQL). Các replica đọc nhật ký này và áp dụng (apply) lại đúng những thay đổi đó lên bản sao của mình. Nhờ vậy dữ liệu ở replica gần như giống hệt primary.
Tại sao kỹ thuật này hữu ích? Vì trong phần lớn ứng dụng thực tế, tỷ lệ đọc/ghi thường rất lệch — điển hình là 90% đọc, 10% ghi, thậm chí 95/5. Một trang thương mại điện tử: mỗi khách xem cả trăm sản phẩm (đọc) nhưng chỉ đặt một đơn (ghi). Với tỷ lệ đó, nếu bạn tách toàn bộ lượt đọc sang các replica, primary sẽ nhẹ gánh đi rất nhiều và chỉ còn lo việc ghi.
Kiến trúc điển hình: 1 primary (read + write) + N replica (chỉ read). Ứng dụng của bạn định tuyến (route) truy vấn theo nguyên tắc: lệnh ghi → primary, lệnh đọc → một trong các replica (thường qua load balancer hoặc thư viện của driver database).
Replication lag — cái giá phải trả
Không có bữa trưa miễn phí. Cái giá của Read Replica là replication lag (độ trễ sao chép). Từ lúc primary ghi xong một dữ liệu đến lúc replica áp dụng được thay đổi đó, luôn có một khoảng trễ. Bình thường lag rất nhỏ, khoảng 100 mili-giây đến vài giây; nhưng khi primary bị ghi dồn dập, hoặc mạng chậm, lag có thể vọt lên hàng chục giây, thậm chí hàng phút.
Hậu quả của lag là hiện tượng "read-your-own-write" bị vỡ. Ví dụ: người dùng vừa cập nhật ảnh đại diện (ghi vào primary), rồi ngay lập tức tải lại trang (đọc từ replica). Nếu replica chưa kịp cập nhật, người dùng sẽ thấy ảnh cũ và tưởng hệ thống bị lỗi. Đây là loại bug rất khó chịu vì nó xuất hiện không đều, khó tái tạo.
Cách xử lý: với những thao tác "vừa ghi xong phải đọc lại ngay", bạn buộc query đọc đó chạy trên primary (gọi là "read from primary" hoặc "sticky read" trong một khoảng thời gian ngắn). Đây là sự đánh đổi có chủ đích — bạn hy sinh một chút khả năng chia tải để đảm bảo tính nhất quán (consistency) ở đúng chỗ cần thiết.
Sharding — chia dữ liệu để chia tải ghi
Read Replica giải quyết bài toán đọc. Nhưng nó không giải quyết bài toán ghi: mọi lệnh ghi vẫn dồn về một primary duy nhất. Khi lượng ghi lớn đến mức một primary không kham nổi, hoặc khi tổng dung lượng dữ liệu vượt quá khả năng lưu trữ của một server, bạn cần đến Sharding.
Sharding là chia một tập dữ liệu lớn thành nhiều mảnh nhỏ gọi là shard, mỗi shard nằm trên một server database riêng biệt. Mỗi shard chỉ chứa một phần dữ liệu, và mỗi shard có primary riêng của nó (có thể kèm replica riêng). Khác biệt cốt lõi so với replica: replica là bản sao đầy đủ của cùng một dữ liệu; shard là các phần khác nhau của dữ liệu, không trùng lặp.
Điểm quyết định trong sharding là shard key (khóa phân mảnh) — cột dữ liệu dùng để quyết định một bản ghi thuộc về shard nào. Ví dụ chọn user_id làm shard key: user có id chia hết cho 4 dư 0 vào shard A, dư 1 vào shard B, cứ thế. Các chiến lược phổ biến:
- Range-based (theo khoảng): user_id 1–1 triệu ở shard 1, 1–2 triệu ở shard 2. Đơn giản nhưng dễ bị lệch tải (một shard "nóng" hơn hẳn).
- Hash-based (theo hàm băm): lấy hash của shard key rồi chia dư. Phân bổ đều hơn nhưng khó truy vấn theo khoảng.
- Directory/Lookup (theo bảng tra cứu): có một bảng ánh xạ riêng ghi rõ key nào ở shard nào. Linh hoạt nhất nhưng thêm một tầng phụ thuộc.
Tình huống thực tế
Ví dụ 1 — Sàn thương mại điện tử Việt Nam mùa sale 12/12
Một sàn TMĐT giả định tại TP.HCM, gọi là "ChợViệt", chạy trên một MySQL primary duy nhất. Ngày thường mọi thứ êm đẹp: khoảng 2.000 query/giây, CPU database quanh 40%. Nhưng đến 0h ngày 12/12, flash sale mở màn. Lượng người xem sản phẩm bùng nổ lên gần 30.000 query/giây — gấp 15 lần. CPU của primary lập tức chạm 100%, độ trễ query nhảy từ 20ms lên hơn 3 giây, trang sản phẩm gần như treo.
Đội vận hành phân tích và thấy: 94% số query là SELECT — người dùng lướt xem sản phẩm, xem tồn kho, xem đánh giá. Chỉ 6% là ghi (thêm giỏ hàng, đặt đơn). Giải pháp: dựng 4 read replica. Toàn bộ query đọc danh mục, chi tiết sản phẩm, đánh giá được route sang 4 replica này; primary chỉ còn lo phần ghi đơn hàng. Sau khi triển khai, tải đọc chia đều, CPU primary về 45%, độ trễ đọc quay lại dưới 50ms.
Nhưng họ vấp một bug tinh vi: khách thêm sản phẩm vào giỏ (ghi primary) rồi bấm xem giỏ (đọc replica), có lúc giỏ hiện trống. Nguyên nhân là replication lag ~800ms trong lúc cao điểm. Cách sửa: riêng trang "giỏ hàng" và "xác nhận đơn" đọc trực tiếp từ primary, còn trang duyệt sản phẩm vẫn đọc replica.
Bài học: Read replica là vũ khí tuyệt vời cho hệ thống đọc-nhiều, và bạn phải phân loại query — cái nào chấp nhận dữ liệu "hơi cũ vài trăm ms", cái nào bắt buộc nhất quán tức thì.
Ví dụ 2 — Ứng dụng gọi xe và bài toán ghi khổng lồ
Một startup gọi xe ở Đông Nam Á (bối cảnh tương tự Grab/Gojek thời đầu). Vấn đề của họ ngược với ChợViệt: bảng driver_locations nhận cập nhật vị trí tài xế liên tục — mỗi tài xế gửi tọa độ mới mỗi 3–4 giây. Với 200.000 tài xế online lúc cao điểm, đó là khoảng 60.000 lệnh ghi/giây. Đây là bài toán ghi, và read replica hoàn toàn vô dụng vì nó không san sẻ được lệnh ghi.
Thêm bao nhiêu replica cũng không cứu được primary đang nghẹt vì ghi. Giải pháp bắt buộc là sharding theo vùng địa lý: chia dữ liệu theo city_id. Toàn bộ tài xế Hà Nội vào shard 1, TP.HCM vào shard 2, Jakarta vào shard 3... Nhờ đó lệnh ghi được phân tán ra nhiều primary, mỗi shard chỉ gánh vài nghìn ghi/giây — hoàn toàn xử lý được.
Chọn city_id làm shard key rất hợp lý ở đây, vì hầu hết truy vấn đều mang tính cục bộ: "tìm tài xế gần khách trong cùng thành phố". Truy vấn không cần vượt shard, nên tránh được cross-shard query đắt đỏ. Cái giá phải trả: báo cáo toàn quốc ("tổng số cuốc xe cả nước hôm nay") phải gom dữ liệu từ tất cả shard — chậm và phức tạp, nên họ đẩy loại truy vấn này sang hệ thống phân tích riêng thay vì chạy trực tiếp trên database vận hành.
Bài học: Khi nghẽn nằm ở ghi hoặc ở dung lượng, replica không giúp được — bạn cần sharding. Và việc chọn shard key phải bám theo cách ứng dụng truy vấn dữ liệu trong thực tế.
Ví dụ 3 — Chọn nhầm shard key và cái giá của việc re-shard
Một fintech giả định ở Singapore lưu giao dịch vào bảng transactions. Khi dữ liệu lớn lên, họ sharding theo transaction_date (ngày giao dịch) — nghe có vẻ hợp lý. Nhưng sai lầm lộ ra nhanh chóng: mọi giao dịch hôm nay đều đổ vào đúng một shard (shard của tháng hiện tại), trong khi các shard chứa dữ liệu tháng cũ gần như nằm không. Đây gọi là hot shard — một shard nóng rực còn phần còn lại nhàn rỗi. Sharding coi như vô nghĩa vì tải không hề được chia đều.
Họ buộc phải re-shard (phân mảnh lại) sang shard key là account_id băm (hash) — vốn phân bổ đều. Nhưng re-shard là ác mộng: phải di chuyển hàng terabyte dữ liệu giữa các shard trong khi hệ thống vẫn đang chạy, cần cơ chế ghi kép (dual-write) tạm thời và kiểm tra đối soát cẩn thận. Toàn bộ quá trình mất gần hai tháng và một lần suýt mất dữ liệu.
Bài học: Shard key là quyết định gần như "một chiều" — chọn sai thì sửa cực kỳ tốn kém. Tiêu chí chọn shard key tốt: (1) phân bổ tải đều, tránh hot shard; (2) khớp với mẫu truy vấn phổ biến để tránh cross-shard; (3) có độ phân tán (cardinality) cao. Đừng bao giờ chọn shard key theo thứ tăng dần theo thời gian.
Hướng dẫn từng bước
Khi hệ thống của bạn bắt đầu nghẽn ở database, đây là trình tự nên đi theo — từ rẻ và đơn giản đến đắt và phức tạp:
- Đo trước, đừng đoán. Xác định rõ nghẽn ở đâu: đọc hay ghi? CPU, I/O hay dung lượng? Xem tỷ lệ read/write thực tế qua monitoring. Đừng sharding chỉ vì "nghe nói phải sharding".
- Tối ưu cái đang có. Thêm index cho query chậm, sửa query kém, thêm connection pooling. Rất nhiều trường hợp "nghẽn database" thực ra chỉ là thiếu một index. Bước này gần như miễn phí.
- Nếu nghẽn ở ĐỌC → thêm Read Replica. Dựng 1–2 replica trước, cấu hình replication, rồi định tuyến query đọc sang replica. Bắt đầu với những query đọc "chịu được dữ liệu hơi cũ" (danh mục, báo cáo, tìm kiếm).
- Xử lý read-your-own-write. Rà soát các luồng "vừa ghi xong đọc lại ngay" và ép chúng đọc từ primary. Đặt cảnh báo (alert) khi replication lag vượt ngưỡng, ví dụ trên 2 giây.
- Giám sát và mở rộng replica dần. Thêm replica khi tải đọc tiếp tục tăng. Đây là bước tuyến tính và tương đối an toàn.
- Nếu nghẽn ở GHI hoặc DUNG LƯỢNG → cân nhắc Sharding. Đây là bước cuối. Trước khi làm, hãy chắc chắn bạn đã vắt kiệt các bước trên.
- Chọn shard key thật kỹ. Dành thời gian phân tích mẫu truy vấn. Ưu tiên hash-based cho phân bổ đều. Thử nghiệm trên dữ liệu thật trước khi cam kết.
- Lên kế hoạch cho cross-shard query và re-shard. Xác định trước những truy vấn nào sẽ phải vượt shard và đẩy chúng sang hệ thống phân tích riêng nếu có thể. Thiết kế sao cho việc thêm shard trong tương lai đỡ đau đớn.
Lỗi thường gặp & mẹo
- Nhầm lẫn replica với backup. Replica không phải bản sao lưu. Nếu bạn lỡ chạy
DELETEsai trên primary, lệnh đó được sao chép ngay sang tất cả replica — dữ liệu bay sạch ở mọi nơi. Backup (chủ đề của bài khác) mới là thứ cứu bạn khỏi lỗi thao tác. - Cho ghi vào replica. Replica là chỉ-đọc. Vô tình route một lệnh ghi vào replica sẽ gây lỗi hoặc, tệ hơn, làm lệch dữ liệu giữa replica và primary. Hãy khóa cứng ở tầng ứng dụng.
- Bỏ quên replication lag. Đây là nguyên nhân số một của các bug "lúc được lúc không". Luôn giám sát lag và luôn xử lý riêng các luồng cần nhất quán tức thì.
- Sharding quá sớm. Đây là lỗi kinh điển của kỹ sư thích "làm cho đúng bài" ngay từ đầu. Sharding khi chưa cần khiến bạn gánh toàn bộ độ phức tạp mà không được lợi gì. Một primary mạnh + vài replica đủ phục vụ đại đa số startup trong nhiều năm.
- Chọn shard key theo thời gian hoặc theo cột tăng dần (auto-increment). Gần như chắc chắn tạo ra hot shard. Ưu tiên key có cardinality cao và phân bổ đều.
- Mẹo — tách read/write bằng driver có sẵn. Nhiều ORM và driver (như các thư viện đọc-ghi tách biệt) hỗ trợ tự động route đọc sang replica, ghi sang primary. Tận dụng thay vì tự viết logic route dễ sai.
- Mẹo — dùng replica cho việc nặng, cách ly khỏi traffic khách. Report phân tích, export dữ liệu, backup logic nên chạy trên một replica riêng để không đụng đến replica đang phục vụ người dùng thật.
Bài tập thực hành
- Phân loại query. Lấy một ứng dụng bạn biết (hoặc đang làm). Liệt kê 10 loại truy vấn phổ biến nhất và đánh dấu: cái nào là đọc, cái nào là ghi, cái nào bắt buộc nhất quán tức thì (phải đọc từ primary), cái nào chấp nhận dữ liệu trễ vài trăm ms (đọc từ replica được).
- Ước lượng tỷ lệ read/write. Với ứng dụng đó, ước tính tỷ lệ đọc/ghi. Nếu là 90/10, việc thêm read replica sẽ giảm tải primary khoảng bao nhiêu phần trăm về lý thuyết? Giải thích.
- Chọn shard key. Cho bảng
orderscủa một sàn TMĐT toàn quốc với các cột:order_id,user_id,store_id,city_id,created_at,total_amount. Nếu buộc phải sharding, bạn chọn cột nào làm shard key? Nêu ưu và nhược điểm của lựa chọn đó, và chỉ ra một loại truy vấn sẽ trở thành cross-shard.
- Chẩn đoán tình huống. Một hệ thống đang chạy 1 primary + 3 replica. Primary CPU 95%, các replica CPU chỉ 20%. Nghẽn nằm ở đọc hay ghi? Thêm replica có giúp không? Nếu không thì hướng xử lý là gì?
Tóm tắt
- Read Replica giải quyết bài toán đọc: 1 primary (đọc + ghi) + N replica (chỉ đọc), chia tải cho hệ thống đọc-nhiều. Cái giá là replication lag (~100ms đến vài giây) — phải xử lý riêng các luồng cần nhất quán tức thì bằng cách đọc từ primary.
- Sharding giải quyết bài toán ghi và dung lượng: chia dữ liệu thành nhiều shard, mỗi shard là một phần khác nhau trên server riêng. Cái giá là cross-shard query phức tạp, JOIN và transaction xuyên shard rất khó.
- Điểm khác biệt cốt lõi: replica là bản sao đầy đủ cùng dữ liệu; shard là các phần khác nhau của dữ liệu.
- Shard key là quyết định gần như một chiều — chọn sai (theo thời gian, theo cột tăng dần) gây hot shard và phải re-shard cực kỳ tốn kém. Chọn key phân bổ đều, cardinality cao, khớp mẫu truy vấn.
- Trình tự đúng: đo → tối ưu/index → read replica → (cuối cùng) sharding. Đừng sharding sớm. Và nhớ: replica không phải backup.