Nhiều team bắt đầu scale hệ thống đọc bằng cách thêm read replica cho PostgreSQL hoặc MySQL. Trên slide kiến trúc, mô hình này rất đẹp: write vào primary, read từ replica, giảm tải database chính và tăng thông lượng đọc. Nhưng khi lên production, một bug rất khó chịu thường xuất hiện gần như ngay lập tức: người dùng vừa bấm lưu xong nhưng reload lại vẫn thấy dữ liệu cũ.

Đó không phải bug UI ngẫu nhiên. Đó là bài toán read-after-write consistency bị phá bởi replica lag. Nếu không thiết kế rõ flow nào cần read-your-writes, hệ thống sẽ tự rò eventual consistency vào những chỗ product không hề chấp nhận.

Sơ đồ read-after-write consistency với primary replica lag và fallback về primary
Write commit thành công ở primary chưa đảm bảo request đọc kế tiếp sẽ thấy dữ liệu mới nếu read path đi vào replica đang lag.

Read-after-write consistency là gì?

Read-after-write consistency, hay còn gọi là read-your-writes, là kỳ vọng rất tự nhiên: sau khi một actor ghi dữ liệu thành công, các lần đọc kế tiếp của chính actor đó phải phản ánh ít nhất thay đổi vừa ghi. Đây không đồng nghĩa với strong consistency cho mọi user trên toàn hệ thống. Nhưng với cùng một user vừa thao tác, việc API trả 200 OK rồi lại hiện dữ liệu cũ là trải nghiệm rất tệ.

  • user đổi tên hồ sơ, reload vẫn thấy tên cũ;
  • admin vừa khóa tài khoản nhưng request kế tiếp vẫn pass permission check;
  • sau khi checkout, order detail endpoint trả về not found vì replica chưa bắt kịp;
  • feature flag vừa bật nhưng một số request sau vẫn đọc trạng thái cũ.

Replica lag phá UX ra sao?

Trong mô hình primary-replica, primary nhận write trước, còn replica nhận log thay đổi và apply bất đồng bộ. Khoảng chênh giữa lúc primary commit xong và replica phản ánh được thay đổi đó chính là replica lag. Lag chỉ vài chục mili giây vẫn có thể đủ gây bug nếu request chain diễn ra rất nhanh. Trong giờ cao điểm, sau deploy, khi có query dài hoặc I/O bất ổn, lag có thể tăng lên vài giây.

Mối liên hệ giữa replica lag stale read và lỗi trải nghiệm người dùng sau khi lưu dữ liệu
Stale read thường biểu hiện như bug frontend, nhưng gốc rễ nằm ở consistency model của read path.

Dấu hiệu đáng nghi là: write trả thành công, GET ngay sau đó mới sai; refresh lần hai hoặc chờ vài giây thì dữ liệu tự đúng lại; lỗi khó tái hiện trên local nhưng hay xảy ra dưới load; dashboard không có database error rõ ràng, chỉ có spike replication lag.

Eventual consistency khi nào ổn, khi nào không?

Không phải mọi stale read đều là bug. Số like, view count, analytics dashboard, feed recommendation hoặc thống kê nội bộ thường chấp nhận eventual consistency. Nhưng các flow sau cần được bảo vệ chặt hơn:

  • cập nhật profile rồi redirect sang trang xem lại;
  • cấp quyền, thu hồi quyền, khóa tài khoản;
  • order status, payment, refund;
  • preference/config vừa được chỉnh sửa;
  • wizard nhiều bước mà bước sau phụ thuộc bước trước.

Nguyên tắc thực dụng là: đừng hỏi hệ thống có eventual consistency không; hãy hỏi flow nào chịu được eventual consistency.

Pattern 1: sticky read to primary trong một cửa sổ ngắn sau write

Cách đơn giản nhất là: sau khi actor vừa ghi dữ liệu, ép các lần đọc kế tiếp của actor đó đi vào primary trong một khoảng thời gian ngắn. Ví dụ backend set cờ read_from_primary_until = now + 5s trong session/token/context, và các GET tiếp theo của user đó đọc từ primary trước khi quay về replica.

Timeline sticky read to primary sau write để tránh stale read từ replica
Sticky read theo actor rất hữu ích cho flow redirect sau POST hoặc trang detail ngay sau khi cập nhật.

Ưu điểm của sticky read là dễ hiểu, dễ triển khai, cứu UX nhanh. Nhược điểm là nếu mở quá rộng, primary sẽ bị kéo tải lên. Vì vậy chỉ nên áp dụng cho các flow quan trọng như profile/settings, admin detail page, multi-step form hoặc redirect ngay sau mutation.

Pattern 2: session consistency token hoặc expected version fence

Sticky read dựa trên thời gian có một nhược điểm: nó giả định 5 giây là đủ, trong khi replica lag thật không cố định. Cách chặt chẽ hơn là trả về một marker sau write — có thể là expected version logic nghiệp vụ hoặc commit marker nội bộ — rồi mang marker đó theo request đọc kế tiếp.

Session consistency token với expected version để bảo đảm read your writes
Read path có thể thử replica trước, nhưng chỉ serve nếu bản ghi đã đủ mới theo expected version.

Nếu replica đã có version >= expectedVersion thì serve replica. Nếu chưa đạt, route về primary hoặc wait ngắn có giới hạn. Cách này tốt hơn timeout đoán mò vì nó dựa trên trạng thái đủ mới thật sự.

async function updateProfile(userId, payload) {
  const updated = await primaryDb.profile.update({
    where: { user_id: userId },
    data: {
      display_name: payload.displayName,
      version: { increment: 1 }
    }
  });

  return {
    profile: updated,
    consistency: { expectedVersion: updated.version }
  };
}

async function getProfile(userId, expectedVersion) {
  const replicaRow = await replicaDb.profile.findUnique({ where: { user_id: userId } });

  if (!expectedVersion || (replicaRow && replicaRow.version >= expectedVersion)) {
    return { source: 'replica', profile: replicaRow };
  }

  const primaryRow = await primaryDb.profile.findUnique({ where: { user_id: userId } });
  return { source: 'primary', profile: primaryRow };
}

Pattern 3: fallback to primary có điều kiện

Một anti-pattern phổ biến là phát hiện stale read xong thì đẩy tất cả read sang primary “cho chắc”. Cách đó bỏ luôn lợi ích của read replica. Thiết kế đúng hơn là fallback có điều kiện: chỉ fallback cho actor vừa write, cho endpoint quan trọng, hoặc khi detect replica chưa đủ mới.

POST /profile -> primary commit success -> session requires version >= 42
GET /profile
  -> try replica
  -> if replica.version >= 42: serve replica
  -> else: read primary

Cách tiếp cận này giữ phần lớn traffic ở replica nhưng vẫn bảo toàn UX read-your-writes cho flow cần thiết.

Optimistic UI không thay thế được consistency ở backend

Frontend có thể dùng optimistic update để làm cảm giác mượt hơn, nhưng nếu request reload hoặc component khác vẫn đọc stale data từ replica thì UI sẽ “nhảy ngược”. Tệ hơn, toast báo lưu thành công, form local state hiện giá trị mới, nhưng sidebar hoặc header lấy từ endpoint khác lại hiện giá trị cũ. Optimistic UI nên đi kèm read-your-writes strategy ở backend, không thể thay thế nó.

Với authorization, payment và order flow, stale read có thể thành lỗi logic nghiêm trọng

Các ví dụ profile giúp dễ hình dung, nhưng nơi rủi ro thật nằm ở quyền truy cập và trạng thái giao dịch. Ví dụ admin vừa revoke quyền editor khỏi user A mà request tiếp theo vẫn pass do replica cũ; hoặc order vừa tạo mà detail báo chưa tồn tại, khiến user bấm lại và sinh duplicate submit. Đây là nơi bài Idempotency là gì? Cách thiết kế API chống double submit và retry lỗi liên quan trực tiếp: inconsistency sau write rất dễ kéo theo retry sai thời điểm và side effect bị nhân đôi.

Observability cần có để không debug bằng cảm giác

Nếu hệ thống có read replica mà chưa có dashboard consistency, sớm muộn team cũng sẽ mò trong bóng tối. Tối thiểu nên có:

  • replication lag theo p50/p95/p99 và max lag hiện tại;
  • fallback rate từ replica sang primary theo endpoint;
  • stale read detection count theo expected version/session fence;
  • primary read amplification sau write khi sticky read mở quá rộng;
  • telemetry cho pattern “POST thành công nhưng GET kế tiếp mismatch”.

Khi lag tăng vì query dài, I/O saturation, network jitter, burst write sau deploy hoặc cache stampede, những metric này sẽ giúp team thấy nguyên nhân thật thay vì đổ oan cho frontend state hay Redis invalidation.

Read model lag trong CQRS/event-driven system cũng là cùng một bài toán

Read-after-write violation không chỉ tồn tại ở primary-replica. Trong CQRS hoặc event-driven architecture, command side commit xong rồi event mới cập nhật read model. Khoảng trễ đó tạo ra cùng một triệu chứng: command báo thành công, query side chưa thấy dữ liệu mới. Lúc này tư duy vẫn giống nhau: pending state rõ ràng, endpoint đặc biệt cho actor vừa write, token/version để biết read model đã catch up chưa, và metric consumer lag. Nếu anh muốn nối tiếp tư duy event consistency ở lớp integration, bài Outbox Pattern trong Backend là một điểm móc rất hợp.

Checklist triển khai thực dụng cho team backend

  • Xác định 3-5 flow cần read-your-writes nhất.
  • Bật sticky read to primary theo actor trong cửa sổ ngắn cho các flow đó.
  • Thêm metric fallback rate, replica lag, stale read violation.
  • Log request source: primary hay replica.
  • Chuyển dần từ sticky time-based sang expected version/session token cho flow quan trọng.
  • Review query nặng trên replica và tách analytics/export nếu cần.
  • Kiểm tra các lớp cache/search/read model khác để tránh blame sai tầng stale.

Kết luận

Read replica là công cụ scale rất mạnh, nhưng nó luôn đi kèm một món nợ thiết kế: team phải quyết định flow nào cần read-your-writes và mã hóa quyết định đó vào hệ thống. Cách thực dụng nhất thường không phải strong consistency toàn cục, mà là sticky read hoặc session/version fence cho actor vừa write, fallback có điều kiện, và observability đủ tốt để biết khi nào replica lag đang biến thành bug người dùng.

Nếu muốn đào sâu thêm theo cùng mạch production engineering, anh có thể đọc thêm PostgreSQL Connection Pooling với PgBouncer, PostgreSQL MVCC, VACUUM và table bloat, và Distributed Tracing cho Microservices để nối phần database, request path và observability lại thành một bức tranh vận hành hoàn chỉnh.