Tối ưu 7 — Tách DB đọc/ghi (read/write splitting)

13 thg 7, 2026 3 lượt xem
#scaling
#postgresql
#sql
#read-write-split
#proxy
#replica

Tối ưu 7 — Tách DB đọc/ghi (read/write splitting)

Đến một lúc nào đó, một node PostgreSQL duy nhất không còn gánh nổi lượng truy vấn. Nhưng nếu nhìn kỹ tải, bạn sẽ thấy phần lớn ứng dụng web đọc nhiều hơn ghi rất nhiều — tỉ lệ 10:1, 100:1, thậm chí hơn. Trang chủ, danh sách sản phẩm, timeline, tìm kiếm, báo cáo... tất cả đều là đọc. Ghi thì chỉ xảy ra khi ai đó đăng bài, đặt hàng, hay cập nhật hồ sơ.

Điều này gợi ra một cách mở rộng rất tự nhiên: để một node lo việc ghi, và dồn việc đọc ra nhiều node khác. Đó chính là tách đọc/ghi (read/write splitting), một trong những kỹ thuật scaling đầu tiên nên nghĩ tới trước khi bước vào những thứ phức tạp hơn như phân vùng hay sharding.

Bài này giả định bạn đã có sẵn streaming replication — nếu chưa, hãy đọc Replication trước, vì đó là nền móng bắt buộc.

Ý tưởng cốt lõi

Kiến trúc gồm hai vai trò:

  • Primary (còn gọi là writer/master): nhận mọi lệnh ghiINSERT, UPDATE, DELETE, DDL (CREATE, ALTER...) — và cả những lệnh đọc cần nhất quán tuyệt đối. Đây là "nguồn sự thật" duy nhất.
  • Replica (hot standby, còn gọi là reader): là bản sao chỉ-đọc của primary, được cập nhật liên tục qua streaming replication. Replica phục vụ đọc: báo cáo, truy vấn nặng, các trang đọc phổ biến.

Lợi ích chính:

  • Giảm tải primary. Primary chỉ còn lo ghi và một phần đọc nhạy cảm, giải phóng CPU/IO cho công việc ghi vốn khó song song hoá.
  • Scale đọc gần như tuyến tính. Cần thêm sức đọc? Thêm một replica nữa. Với workload đọc nhiều, đây là cách rẻ và nhanh để tăng thông lượng đọc.
  • Cô lập truy vấn nặng. Một báo cáo chạy 30 giây có thể đẩy sang replica riêng mà không làm chậm giao dịch trên primary.

Nhưng phải nói thẳng một giới hạn: tách đọc/ghi chỉ scale đọc, không scale ghi. Primary vẫn là điểm nghẽn duy nhất cho mọi lệnh ghi. Khi lượng ghi vượt sức một node, bạn phải bước sang phân vùng/sharding — xem Phân vùng & scaling.

Điều kiện tiên quyết

Trước khi tách đọc/ghi, bạn cần:

  1. Streaming replication đang chạy ổn định — primary phát WAL, các replica ở chế độ hot standby (hot_standby = on, mặc định bật) và cho phép truy vấn chỉ-đọc. Chi tiết cấu hình xem bài Replication.
  2. Giám sát replication lag — bạn phải đo được độ trễ của từng replica so với primary. Không có số liệu này thì không thể ra quyết định định tuyến an toàn. Xem Giám sát.

Trên replica, bất kỳ lệnh ghi nào cũng bị PostgreSQL từ chối:

-- Trên một hot standby (minh hoạ)
INSERT INTO orders(...) VALUES (...);
-- ERROR: cannot execute INSERT in a read-only transaction

Đây là cơ chế an toàn tốt: route nhầm một lệnh ghi sang replica sẽ báo lỗi thay vì âm thầm làm sai.

Cách định tuyến truy vấn

Có hai trường phái chính để quyết định "truy vấn này đi về primary hay replica".

1. Định tuyến ở tầng ứng dụng

Ứng dụng biết rõ đâu là kết nối writer, đâu là kết nối reader, và tự chọn.

Cấu hình hai datasource. Ứng dụng khai báo hai pool kết nối riêng — một trỏ vào primary, một trỏ vào (các) replica — và code chủ động chọn:

// Spring — đánh dấu giao dịch chỉ-đọc để route sang replica (minh hoạ)
@Transactional(readOnly = true)
public List<Article> listPublishedArticles() {
    return articleRepository.findByStatus("published");
}

@Transactional  // mặc định readOnly=false -> primary
public Article publish(Long id) {
    Article a = articleRepository.findById(id).orElseThrow();
    a.setStatus("published");
    return articleRepository.save(a);
}

Cơ chế này dựa trên một AbstractRoutingDataSource đọc cờ readOnly của giao dịch để chọn datasource. Các framework khác cũng tương tự — Django có database router, Rails có role reading/writing.

libpq multi-host. Bản thân driver libpq (dùng bởi psql, psycopg, và phần lớn client PostgreSQL) hỗ trợ liệt kê nhiều host trong chuỗi kết nối kèm thuộc tính target_session_attrs:

; Kết nối chỉ tới node đang ở chế độ ghi được (primary) (minh hoạ)
postgresql://app@host-a:5432,host-b:5432,host-c:5432/mydb?target_session_attrs=read-write

; Kết nối tới node chỉ-đọc (replica) (minh hoạ)
postgresql://app@host-a:5432,host-b:5432,host-c:5432/mydb?target_session_attrs=read-only

libpq sẽ lần lượt thử các host và chọn node phù hợp với thuộc tính yêu cầu. Đây là cách rất tiện để tự động tìm primary sau failover mà không cần đổi cấu hình.

Ưu: kiểm soát rõ ràng — ứng dụng biết chính xác truy vấn nào cần độ mới, truy vấn nào chấp nhận dữ liệu hơi cũ. Không thêm tầng hạ tầng nào.

Nhược: gánh nặng dồn lên lập trình viên. Chỉ cần một truy vấn read-your-writes bị route nhầm sang replica là sinh bug khó tái hiện. Logic định tuyến rải khắp codebase.

2. Định tuyến bằng proxy/middleware

Đặt một tầng trung gian giữa ứng dụng và các node, để tầng đó lo việc route. Ứng dụng chỉ kết nối tới một endpoint duy nhất.

  • PgBouncer — là connection pooler, KHÔNG tự route theo read/write. Nó gộp và tái dùng kết nối để giảm chi phí mở kết nối, nhưng không phân tích câu lệnh để biết đó là đọc hay ghi. Thường dùng làm lớp pooling phía sau một bộ định tuyến khác, không dùng một mình cho mục đích tách đọc/ghi.

  • HAProxy — TCP load balancer với health-check. Mẫu phổ biến: mở hai cổng, một cổng route về node primary, một cổng route về nhóm replica, dựa trên kết quả health-check (ví dụ hỏi node xem nó đang là primary hay standby). Ứng dụng kết nối cổng ghi cho ghi, cổng đọc cho đọc.

    # HAProxy — hai cổng: ghi -> primary, đọc -> replica (minh hoạ)
    frontend fe_write
      bind *:5433
      default_backend be_primary   # chỉ node primary vượt health-check
    
    frontend fe_read
      bind *:5434
      default_backend be_replicas  # load balance qua các replica
    
  • Pgpool-II — có khả năng tự tách read/write: nó phân tích câu lệnh SQL, gửi ghi về primary và load balance đọc sang các replica, đồng thời theo dõi trạng thái node. Đây là proxy PostgreSQL-aware đúng nghĩa cho tách đọc/ghi trong suốt.

  • Citus, pgcat và các proxy khác cũng cung cấp năng lực định tuyến/pooling ở các mức độ khác nhau.

Ưu: trong suốt với ứng dụng — code không cần biết có bao nhiêu node hay node nào là primary. Khi failover, proxy tự trỏ lại; ứng dụng không phải triển khai lại.

Nhược: thêm một tầng hạ tầng phải vận hành, giám sát và làm cho sẵn sàng cao (nếu proxy chết thì cả cụm mất truy cập). Việc phân tích SQL để đoán read/write cũng không phải lúc nào cũng chính xác 100% (ví dụ SELECT ... FOR UPDATE là đọc nhưng cần primary; hàm/stored procedure có thể ghi ngầm).

So sánh nhanh

Tiêu chíTầng ứng dụngProxy/middleware
Kiểm soát chi tiếtCao (biết ngữ cảnh nghiệp vụ)Trung bình (đoán qua SQL)
Trong suốt với appKhông
Hạ tầng thêmKhôngCó (thêm 1 tầng)
Xử lý failoverCần logic/target_session_attrsProxy lo, app không đổi
Read-your-writesApp tự chủ động ép primaryCần cấu hình đặc thù

Nhiều hệ thống thực tế kết hợp cả hai: app dùng cờ readOnly để tách intent, còn proxy lo pooling và failover.

Replication lag & tính nhất quán

Đây là phần cốt lõi và cũng là nơi phần lớn bug phát sinh.

Streaming replication là bất đồng bộ theo mặc định: primary commit giao dịch xong là trả về cho client ngay, WAL được gửi và áp lên replica một lúc sau đó. Khoảng chênh đó gọi là replication lag — thường vài mili-giây, nhưng có thể lên vài giây khi ghi dồn dập, mạng chậm, hoặc replica đang bận truy vấn nặng.

Hệ quả: khi đọc từ replica, bạn có thể thấy dữ liệu cũ. Đây chính là eventual consistency (nhất quán cuối cùng) — replica rồi sẽ bắt kịp, nhưng không phải ngay lập tức.

Vấn đề read-your-writes

Đây là biểu hiện dễ gặp và khó chịu nhất:

  1. Người dùng bấm "Đăng bài" → lệnh INSERT chạy trên primary, thành công.
  2. Trình duyệt chuyển sang trang danh sách bài viết → truy vấn SELECT được route sang replica.
  3. Replica chưa kịp nhận bài mới → danh sách không có bài vừa đăng.

Với người dùng, đây là bug: "tôi vừa đăng mà sao không thấy?". Vấn đề này gọi là read-your-writes consistency — đảm bảo một người dùng luôn đọc thấy chính những gì họ vừa ghi.

Các chiến lược xử lý

Không có viên đạn bạc; chọn theo mức độ nhạy cảm của từng luồng:

  • Đọc từ primary trong một khoảng sau khi ghi ("sticky to primary"). Sau khi một người dùng thực hiện ghi, ép mọi truy vấn đọc của phiên đó về primary trong vài giây tiếp theo (đủ để lag qua đi). Có thể lưu một mốc thời gian last_write_at trong session và so với "ngưỡng an toàn".

  • Theo dõi lag và loại replica trễ quá ngưỡng. Đo lag của từng replica; nếu một replica trễ hơn ngưỡng (ví dụ > 1 giây), tạm thời không route đọc sang nó cho đến khi bắt kịp. Việc này cần Giám sát làm nền.

    -- Ước lượng độ trễ trên replica (minh hoạ)
    SELECT now() - pg_last_xact_replay_timestamp() AS replay_lag;
    
  • Chờ replica đạt tới một LSN cụ thể. Sau khi ghi, lấy LSN của lần commit trên primary rồi yêu cầu replica xác nhận đã replay tới LSN đó trước khi đọc. Chính xác nhưng phức tạp về lập trình.

  • Synchronous replica cho phần cần đọc mới. PostgreSQL cho phép cấu hình replica đồng bộ (synchronous_commit, synchronous_standby_names): primary chỉ trả kết quả commit sau khi replica đó xác nhận. Đọc từ replica đồng bộ luôn thấy dữ liệu mới nhất — đổi lại độ trễ ghi tăng vì phải chờ.

Nguyên tắc chung: route đọc sang replica là mặc định, ép về primary là ngoại lệ có chủ đích cho những luồng thực sự cần độ mới.

Khi nào BUỘC đọc từ primary

Có những luồng mà đọc dữ liệu cũ là không chấp nhận được. Với chúng, hãy đọc thẳng từ primary:

  • Giao dịch tài chính. Số dư, chuyển khoản, hạn mức — mọi quyết định phải dựa trên trạng thái mới nhất.
  • Luồng đọc-sau-ghi tức thời. Vừa cập nhật hồ sơ/cài đặt rồi hiển thị lại ngay; vừa tạo tài nguyên rồi chuyển tới trang chi tiết của nó.
  • Dữ liệu tồn kho/số dư. Kiểm tra còn hàng trước khi cho đặt, trừ điểm/số dư trước khi cho tiêu — đọc từ replica trễ có thể dẫn tới bán quá số lượng (oversell).

Với các luồng còn lại (nội dung đã publish, danh mục, tìm kiếm, báo cáo, feed công khai), độ trễ vài trăm mili-giây thường vô hại và nên tận dụng replica.

Kết hợp với connection pooling

Mỗi kết nối PostgreSQL tốn một process và bộ nhớ trên server; số kết nối đồng thời có giới hạn. Khi có nhiều replica và nhiều instance ứng dụng, tổng số kết nối dễ bùng nổ.

Giải pháp: đặt PgBouncer ở chế độ transaction pooling phía trước mỗi reader (và cả writer). PgBouncer gộp một số lượng lớn kết nối client thành một số nhỏ kết nối thực tới PostgreSQL, tái dùng chúng theo từng giao dịch:

; PgBouncer transaction pooling trước reader (minh hoạ)
[databases]
mydb_read = host=replica-lb port=5432 dbname=mydb

[pgbouncer]
pool_mode = transaction
max_client_conn = 5000
default_pool_size = 40

Lưu ý transaction pooling có ràng buộc: không dùng được các tính năng bám phiên như prepared statement mức session, SET toàn cục hay advisory lock kéo dài phiên. Cần cân nhắc nếu ứng dụng dùng chúng.

Managed PostgreSQL

Nếu chạy trên cloud, phần lớn việc này đã được đóng gói:

  • Amazon RDS read replica cung cấp endpoint riêng cho từng read replica để bạn trỏ đọc vào.
  • Amazon Aurora cung cấp một reader endpoint tự động load balance qua tất cả replica trong cụm, và một writer/cluster endpoint trỏ vào instance ghi hiện tại; khi failover, các endpoint tự trỏ lại. Bạn chỉ cần dùng đúng endpoint cho đọc và ghi.

Managed service giúp bỏ bớt việc vận hành replication và failover, nhưng vấn đề replication lag & read-your-writes vẫn còn nguyên — bạn vẫn phải tự quyết định luồng nào ép về writer.

Use case thực tế: web app đọc nhiều

Hình dung một blog/CMS: tỉ lệ đọc/ghi khoảng 50:1. Trang chủ, trang bài viết, tìm kiếm chiếm gần hết tải. Ghi chỉ xảy ra khi tác giả đăng hoặc sửa bài.

Thiết lập:

  • 1 primary + 2 replica, streaming replication bất đồng bộ.
  • App dùng hai datasource: writer (primary) và reader (load balance 2 replica). Giao dịch được đánh dấu readOnly để route.

Định tuyến mặc định:

  • Đọc trang chủ, trang bài đã publish, tìm kiếm → replica. Nội dung đã publish trễ 200ms là hoàn toàn chấp nhận được.
  • Đăng bài, sửa bài, đăng nhập/đổi mật khẩu → primary.

Xử lý read-your-writes cho trang "vừa đăng":

Kịch bản nguy hiểm: tác giả bấm "Đăng" rồi được chuyển sang trang chi tiết bài viết. Nếu trang đó đọc từ replica còn trễ, tác giả gặp lỗi 404 — "bài của tôi đâu?".

Cách xử lý: khi một request thực hiện ghi, ghi mốc last_write_at = now() vào session của người dùng đó. Ở mọi request đọc tiếp theo, nếu now() - last_write_at < ngưỡng (ví dụ 5 giây), ép đọc về primary cho riêng người dùng đó:

// Chọn datasource theo "sticky to primary" sau khi ghi (minh hoạ)
DataSource pick(HttpSession session, boolean isReadQuery) {
    Instant lastWrite = (Instant) session.getAttribute("last_write_at");
    boolean recentlyWrote =
        lastWrite != null &&
        Duration.between(lastWrite, Instant.now()).getSeconds() < 5;

    if (!isReadQuery || recentlyWrote) {
        return primaryDataSource;   // ghi, hoặc vừa ghi xong -> primary
    }
    return replicaDataSource;       // đọc thông thường -> replica
}

Kết quả: chính tác giả vừa đăng luôn thấy bài của mình (vì đọc từ primary trong 5 giây), trong khi hàng nghìn độc giả khác vẫn được phục vụ từ replica. Ta có được read-your-writes cho người viếtkhông hy sinh khả năng scale đọc cho người đọc.

Chống lag bất thường: một job giám sát đo replay_lag mỗi vài giây; nếu một replica trễ > 1 giây, tạm loại nó khỏi pool đọc cho tới khi bắt kịp, tránh phục vụ dữ liệu quá cũ trên diện rộng.

Ghi nhớ

  • Tách đọc/ghi scale ĐỌC, không scale ghi. Primary vẫn là điểm nghẽn ghi duy nhất; khi ghi quá tải, chuyển sang Phân vùng & scaling.
  • Điều kiện tiên quyết là streaming replication ổn định (Replication) và giám sát lag (Giám sát).
  • Hai cách định tuyến: tầng ứng dụng (kiểm soát rõ, gánh nặng cho dev) và proxy (trong suốt, thêm hạ tầng). PgBouncer chỉ pool — không tự route; Pgpool-II tự tách read/write; HAProxy route theo cổng + health-check.
  • Replication bất đồng bộ = eventual consistency. Đọc từ replica có thể thấy dữ liệu cũ.
  • Read-your-writes là bug hay gặp nhất: xử lý bằng "sticky to primary" sau khi ghi, theo dõi & loại replica trễ, hoặc synchronous replica cho phần cần đọc mới.
  • Buộc đọc primary cho: giao dịch tài chính, đọc-sau-ghi tức thời, tồn kho/số dư.
  • Kết hợp PgBouncer transaction pooling trước reader để chịu nhiều kết nối.
  • Managed (Aurora reader endpoint, RDS read replica) lo giúp replication/failover, nhưng lag & read-your-writes vẫn là việc của bạn.

Tách đọc/ghi là bước scaling "được nhiều nhất với ít công nhất" cho ứng dụng đọc nhiều. Nắm chắc phần nhất quán, và nó sẽ đưa bạn đi rất xa trước khi cần đến sharding.

Bài viết liên quan

Cách index hoạt động (B-Tree), đọc EXPLAIN, seq scan vs index scan và mẫu tối ưu truy vấn.

13 thg 7, 2026 4

Khoá, ràng buộc, quan hệ và chuẩn hoá 1NF/2NF/3NF — thiết kế lược đồ đúng từ đầu.

13 thg 7, 2026 4

Kết nhiều bảng đúng cách: các loại JOIN, bẫy thường gặp và phép hợp tập.

13 thg 7, 2026 3

Lọc, sắp xếp, gộp nhóm và hàm tổng hợp — nền tảng mọi truy vấn phân tích.

13 thg 7, 2026 3