cat blog/.md
Read Replica Lag: vì sao user vừa tạo xong đã không thấy đâu
Có một bug mà mình nghĩ là “ma” đến gần hai ngày: user tạo một sản phẩm mới, redirect sang trang chi tiết, gặp 404 Not Found. F5 một cái thì lại thấy. Lặp lại lúc có lúc không, theo kiểu 1/10 request. QA tag mình lúc 9h tối thứ Sáu, mình ngồi đọc log, đọc code, chạy lại cả flow trên local — chả ra gì. Đến khi nhớ ra cuối tuần trước team infra mới bật read replica cho Postgres để giảm tải, mới giật mình: à, replica lag.
Đây là một class bug rất kinh điển khi scale database: read-after-write consistency bị phá khi bạn đọc từ replica trong lúc replica chưa kịp nhận write từ primary. Bài này mình kể lại lý do nó xảy ra, đo lag thực tế trên Postgres, và bốn cách fix mình đã thử — mỗi cách có giá riêng.
Setup gây ra vấn đề
Kiến trúc rất phổ biến: 1 primary (write) + N replicas (read), giữa chúng là streaming replication async của Postgres. App thì có hai DB pool — write_engine trỏ vào primary, read_engine trỏ vào load balancer trước các replica.
# settings.py
DATABASE_WRITE_URL = "postgresql+asyncpg://app@primary:5432/app"
DATABASE_READ_URL = "postgresql+asyncpg://app@replicas:5432/app" # LB
# deps.py
WriteDbDep = Annotated[AsyncSession, Depends(get_write_session)]
ReadDbDep = Annotated[AsyncSession, Depends(get_read_session)]
Flow của bug:
1. POST /products → write_engine → primary INSERT
2. Server trả 201 với { id: 42 }
3. Browser redirect → GET /products/42
4. GET /products/42 → read_engine → replica SELECT
5. Replica chưa apply WAL chứa row 42 → trả về 404
Nếu replication lag chỉ 5–20ms thì 95% request OK vì user/browser cũng mất chừng đó để redirect. Nhưng đủ điều kiện (cluster busy, WAL backlog, network jitter) lag có thể nhảy lên 200ms–2s và bug rộ lên.
Đo lag thực tế trên Postgres
Trước khi fix phải biết lag đang là bao nhiêu. Postgres expose sẵn:
-- Chạy trên replica
SELECT
pg_last_wal_receive_lsn() AS received,
pg_last_wal_replay_lsn() AS replayed,
pg_last_xact_replay_timestamp() AS last_replay_at,
EXTRACT(EPOCH FROM (now() - pg_last_xact_replay_timestamp())) AS lag_seconds;
-- Chạy trên primary
SELECT
client_addr,
state,
pg_wal_lsn_diff(sent_lsn, write_lsn) AS send_lag_bytes,
pg_wal_lsn_diff(write_lsn, flush_lsn) AS write_lag_bytes,
pg_wal_lsn_diff(flush_lsn, replay_lsn) AS replay_lag_bytes,
write_lag, flush_lag, replay_lag
FROM pg_stat_replication;
Cột quan trọng nhất là replay_lag — khoảng thời gian từ khi primary commit đến khi replica apply xong. Đẩy replay_lag lên Prometheus/CloudWatch, set alert ở 500ms là một việc nên làm sớm.
Fix 1: Đọc từ primary trong window ngắn sau write (sticky read)
Cách đơn giản nhất: sau khi write, đánh dấu user/session trong N giây tới phải đọc từ primary. Lưu cờ trong Redis với TTL.
# middleware tracking write
async def mark_recent_write(user_id: str, ttl_sec: int = 3) -> None:
await redis.set(f"recent_write:{user_id}", "1", ex=ttl_sec)
async def should_read_primary(user_id: str) -> bool:
return await redis.exists(f"recent_write:{user_id}") == 1
# dependency
async def get_session(user_id: str = Depends(current_user_id)):
if await should_read_primary(user_id):
async with WriteSessionMaker() as s:
yield s
else:
async with ReadSessionMaker() as s:
yield s
Ưu: đơn giản, áp dụng cấp app, không cần đụng client. Nhược: chọn TTL khó — quá ngắn (1s) thì vẫn miss khi lag spike; quá dài (10s) thì primary bị tải trở lại. Mình thấy 3–5s là sweet spot cho hệ thống lag p99 dưới 500ms.
Fix 2: Đọc từ primary cho chính request đã write
Nếu bạn lo TTL không đủ, gắn cứng request-level: cứ request nào vừa thực hiện write thì những query đọc tiếp theo trong cùng request hoặc cùng transaction phải đi qua primary.
# request state
class DbRouter:
def __init__(self):
self._has_written = False
def mark_written(self) -> None:
self._has_written = True
async def get_session(self) -> AsyncSession:
if self._has_written:
async with WriteSessionMaker() as s:
yield s
else:
async with ReadSessionMaker() as s:
yield s
Cách này fix được bug “tôi vừa POST giờ phía server cùng request lại GET không thấy” — thường gặp khi BFF gọi nhiều downstream. Nhưng không cứu được bug “browser redirect rồi GET lại” vì đó là request khác.
Fix 3: LSN-based wait — gold standard nhưng đau
Postgres cho phép replica chờ đến khi apply tới một LSN cụ thể với pg_wal_replay_wait (PG 17+) hoặc tự implement bằng polling pg_last_wal_replay_lsn(). Ý tưởng: sau write, server lấy LSN hiện tại của primary, đính kèm vào response (cookie / header / token). Request đọc tiếp theo gửi LSN này lên, server bắt replica chờ đến khi apply xong rồi mới SELECT.
# sau khi commit trên primary
async with write_engine.connect() as conn:
await conn.execute(text("INSERT INTO products ..."))
await conn.commit()
lsn = (await conn.execute(text("SELECT pg_current_wal_lsn()"))).scalar()
response.set_cookie("min_lsn", lsn, max_age=10)
# request kế tiếp gửi cookie min_lsn lên
async def read_with_lsn(min_lsn: str | None):
async with read_engine.connect() as conn:
if min_lsn:
# PG 17+
await conn.execute(
text("SELECT pg_wal_replay_wait(:lsn, 2000)").bindparams(lsn=min_lsn)
)
return await conn.execute(text("SELECT * FROM products WHERE id = :id"), {"id": id_})
Ưu: chuẩn về mặt consistency, không cần overshoot TTL. Nhược: hai cái — (1) phải truyền LSN từ write response sang read request, hỏng kiểu API stateless đẹp đẽ; (2) nếu replica lag thật sự lớn thì request đọc bị block — phải set timeout và có fallback sang primary.
DynamoDB gọi cái này là “consistent read”, Aurora cũng có session pinning kiểu tương tự. Trên vanilla Postgres < 17 thì phải tự code polling — không khó nhưng cần test kỹ.
Fix 4: Read-your-write từ cache, không cần đụng DB
Nhiều khi cái bạn cần đọc lại ngay sau write chính là cái object vừa tạo. Cache nó luôn trong Redis hoặc in-memory với TTL ngắn, lookup theo id. Read path: cache trước, miss thì replica, vẫn miss thì primary.
async def get_product(id_: int):
# 1) cache (vừa được set khi tạo)
if cached := await redis.get(f"product:{id_}"):
return json.loads(cached)
# 2) replica
row = await read_session.scalar(select(Product).where(Product.id == id_))
if row:
return row
# 3) fallback primary
return await write_session.scalar(select(Product).where(Product.id == id_))
async def create_product(payload):
row = await write_session.scalar(insert(Product).returning(Product), payload)
await write_session.commit()
await redis.set(f"product:{row.id}", row.model_dump_json(), ex=10)
return row
Ưu: không phụ thuộc lag, lại còn giảm tải. Nhược: chỉ work cho lookup theo key cụ thể — list/search vẫn phải xử lý lag riêng. Và phải nhớ invalidate cache khi update.
Bảng so sánh nhanh
| Cách | Độ phức tạp | Đúng tuyệt đối | Tải primary | Khi nào dùng |
|---|---|---|---|---|
| Sticky read (TTL) | Thấp | Không | Tăng nhẹ trong TTL | Đa số case, lag ổn định |
| Same-request route | Thấp | Trong 1 request | Không đổi | Server-side fan-out |
| LSN wait | Cao | Có | Không (chờ replica) | Hệ thống cần guarantee |
| Cache read-after-write | Trung bình | Có (cho key lookup) | Giảm | Detail page kiểu CRUD |
Một số bài học mình rút ra
Đừng bật replica mà không monitor lag. Lag im lặng tăng dần khi load tăng, không ai phát hiện cho đến khi user kêu. Dashboard replay_lag + alert là việc tuần đầu.
Đừng route 100% read sang replica. Mình giờ mặc định: admin panel, internal report, analytics — full replica. User-facing CRUD path — primary cho write, primary cho read trong window 3s sau write, replica phần còn lại. Tỉ lệ replica/primary vẫn 80/20, không mất nhiều giá trị scale.
Test lag trên staging có nghĩa. Replica trên staging thường không có load thật, lag = 0, code chạy ngon. Production load lên, lag spike, bug rộ. Mình từng dùng pg_sleep chèn vào replica bằng EXPLAIN ANALYZE mock để giả lag — cách thô nhưng cứu được vài lần.
Aurora / RDS không miễn nhiễm. Aurora có shared storage layer nên lag thường thấp (< 100ms), nhưng vẫn có. Đừng tin “managed = không lo”.
API thiết kế sao cho client retry an toàn. Nếu GET sau POST có thể 404 trong vài trăm ms, BFF nên retry với backoff trước khi trả lỗi cho user. Idempotent GET + small retry budget là một safety net rẻ. (Mình có viết về idempotency trong bài Idempotency Key nếu bạn quan tâm sâu hơn về retry an toàn.)
Kết
Read replica giải quyết được vấn đề scale read, nhưng đẩy một class bug mới vào sản phẩm — đó là cái giá của eventual consistency. Trước khi bật replica, đọc lại các đường code nhạy cảm với consistency (signup → login, create → detail, payment → confirm), chọn 1–2 fix phù hợp, và quan trọng nhất là monitor replay_lag từ ngày đầu. Mình thường bắt đầu với Fix 1 + cache cho hot path, rồi mới nâng cấp lên LSN wait khi nào thật sự cần guarantee — đa số sản phẩm không cần.
Bug “user F5 thì thấy” trông buồn cười nhưng đắt: support ticket, lòng tin user, và đôi khi là tiền (vd: tạo đơn hàng xong không thấy, user tạo lại → trùng đơn). Đầu tư đúng chỗ ngay từ đầu rẻ hơn nhiều so với chữa cháy lúc 9h tối thứ Sáu.
cat comments.log