M8 · Scale catalog

4 câu hỏi~3 ngày code + 1 ngày viết tài liệuTừ khóa: Caffeine, Redis, cache-aside, invalidation, stampede, read replica, sharding, hit ratio

Flash sale chạy được rồi. Nhưng lúc k6 bắn, catalog-service (xem sự kiện, sơ đồ ghế) cũng đuối theo. Giờ mình muốn scale phần đọc.

Đúng thứ tự. Catalog là service đọc nhiều, ghi ít: một người mua vé có thể tải sơ đồ ghế 20 lần trước khi bấm. Milestone này là bài cache + replica + tư duy sharding, và quan trọng nhất là đo.

1. Bối cảnh & bài toán

client ─▶ catalog-service
            ├─ L1 Caffeine (trong JVM, ~1–5s TTL, ns)
            ├─ L2 Redis (dùng chung các instance, 30–60s TTL, ~1ms)
            └─ DB: read replica cho đọc  ·  primary cho ghi
booking ── seat.status đổi ──▶ Kafka booking.events ──▶ catalog: xóa L2 + phát lệnh xóa L1

2. Ôn lại trước khi code

3. Đề bài

Yêu cầu chức năng

API

MethodPathBodyResponse
GET/api/events/{id}–200 event + header X-Cache: L1|L2|MISS (chỉ bật ở profile dev)
GET/api/events/{id}/seat-map–200 {eventId, version, seats:[{code,status}]}, có ETag
PUT/api/admin/events/{id}event fields200, xóa cache event
GET/actuator/prometheus–cache_gets_total{cache,tier,result}

Key và lệnh invalidation

Redis:   catalog:event:{eventId}        TTL 300s ± jitter
         catalog:seatmap:{eventId}      TTL 30s ± jitter
         catalog:lock:seatmap:{eventId} SET NX PX 3000  (khóa nạp lại)
Pub/Sub: catalog.invalidate  →  {"cache":"seatmap","key":"<eventId>","version":42}

Gợi ý thêm seat_map_version (tăng mỗi lần ghế đổi) vào events để chống ghi đè cache bằng dữ liệu cũ.

Skeleton

public interface TwoLevelCache<K, V> {
    /** L1 → L2 → loader (có chống stampede). */
    V get(K key, Function<K, V> loader);
    /** Xóa L2 rồi publish để mọi instance xóa L1. */
    void evict(K key);
}

@Component
class SeatStatusChangedListener {
    // @KafkaListener(topics = "booking.events") — lọc SeatHeld/SeatReleased/BookingConfirmed
    void on(BookingEvent e) { /* TODO: evict seatmap của e.eventId() */ }
}

// Routing datasource
enum DbRole { PRIMARY, REPLICA }
class RoutingDataSource extends AbstractRoutingDataSource {
    @Override protected Object determineCurrentLookupKey() { /* TODO */ return null; }
}
// Bọc bằng LazyConnectionDataSourceProxy để readOnly được biết trước khi lấy connection.
# application.yml (catalog-service)
catalog:
  cache:
    l1: { maximum-size: ${CATALOG_L1_MAX:10000}, ttl: ${CATALOG_L1_TTL:3s} }
    l2: { seatmap-ttl: ${CATALOG_SEATMAP_TTL:30s}, jitter: 0.1 }
  datasource:
    primary: { url: ${CATALOG_DB_PRIMARY_URL}, username: ${CATALOG_DB_USER}, password: ${CATALOG_DB_PASSWORD} }
    replica: { url: ${CATALOG_DB_REPLICA_URL}, username: ${CATALOG_DB_USER}, password: ${CATALOG_DB_PASSWORD} }

Docker-compose: thêm một Postgres replica streaming (bitnami/postgresql có biến POSTGRESQL_REPLICATION_MODE, hoặc tự cấu hình primary_conninfo).

Bài tập sharding (chỉ tài liệu) — trả lời trong ADR:

  1. Giả sử 50k event/năm, mỗi event 5–80k ghế, giữ 3 năm. Ước lượng số dòng và dung lượng seats.
  2. So sánh shard key: event_id, seat.id, venue_id, hash(event_id). Với mỗi cái: truy vấn seat map có chạm 1 shard không? Flash sale một event có tạo hot shard không?
  3. Trước khi shard, partition theo thời gian (PARTITION BY RANGE (event_date)) có đủ không?
  4. Rebalance khi thêm shard: consistent hashing hay bảng directory?
  5. Query xuyên shard nào sẽ đau (báo cáo doanh thu theo ngày) và xử lý ra sao (CDC sang kho phân tích).

4. Acceptance criteria

  1. k6 đọc seat map 5 event phổ biến, 2k req/s trong 5 phút: hit ratio tổng ≥ 95%, query DB/giây giảm ≥ 20 lần so với không cache.
  2. p95 seat-map khi hit < 10 ms (L1) / < 30 ms (L2).
  3. Ghế đổi trạng thái → seat map phản ánh trên mọi instance trong ≤ 1s (p99), đo bằng test.
  4. Stampede: xóa key nóng khi đang có 500 request đồng thời → số query DB cho key đó ≤ số instance (hoặc = 1 nếu dùng lock Redis).
  5. Không có lỗi ghi đè cache cũ: event version N+1 không bị cache version N đè lên (test race).
  6. Query đọc chạy trên replica (kiểm tra pg_stat_activity trên replica hoặc log datasource), ghi chạy trên primary.
  7. Redis chết: catalog vẫn trả dữ liệu (L1 + DB), không 5xx.
  8. Có dashboard hit ratio theo tầng và ADR sharding đủ 5 câu.

5. Test cases

IDKịch bảnInput / điều kiệnKỳ vọng
CA-01Miss lần đầucache rỗng1 query DB, lần 2 hit L1
CA-02L1 hết hạn, L2 cònchờ quá TTL L1hit L2, không query DB
CA-03Invalidation qua eventpublish SeatHeld cho event Eseatmap E bị xóa ở L2 và L1 cả 2 instance
CA-04Stampede500 thread cùng get key vừa bị xóaloader chạy 1 lần/instance
CA-05Race ghi đèload chậm version N, trong lúc đó event version N+1 tớicache cuối cùng là N+1 hoặc trống
CA-06Routinggọi method @Transactional(readOnly=true)connection từ replica pool
CA-07Replica lagpause replication (Toxiproxy)đọc ra dữ liệu cũ có giới hạn; endpoint cần đọc-sau-ghi vẫn đúng
CA-08Redis downstop Redisvẫn 200, metric L2 error tăng
@SpringBootTest
@Testcontainers
class SeatMapStampedeIT {
    @Container static PostgreSQLContainer<?> pg = new PostgreSQLContainer<>("postgres:16");
    @Container static GenericContainer<?> redis = new GenericContainer<>("redis:7-alpine").withExposedPorts(6379);
    // @DynamicPropertySource: trỏ cả primary và replica vào pg cho test này

    @Autowired SeatMapService service;
    @SpyBean SeatMapRepository repo;

    @Test
    void onlyOneLoaderPerKey() throws Exception {
        // arrange: evict seatmap của event E
        // act: 500 virtual threads cùng gọi service.getSeatMap(E), chờ bằng CountDownLatch
        // assert: verify(repo, times(1)).loadSeatMap(E)
    }
}
// tests/k6/m08-catalog-read.js — TODO: phân phối Zipf cho eventId (vài event rất hot)
export const options = { scenarios: { read: { executor: 'constant-arrival-rate',
  rate: 2000, timeUnit: '1s', duration: '5m', preAllocatedVUs: 300 } } };

6. Failure scenarios

Sự cốCách tạoHành vi mong đợi
Redis chếtdocker stop redisBỏ qua L2 (circuit breaker), dùng L1 + replica; timeout Redis ngắn
Redis chậmToxiproxy latency 500msTimeout ~50ms rồi đi DB; p95 không tăng quá 2 lần
Replica chếtstop replicaFallback đọc primary (có rate limit) hoặc lỗi rõ ràng; ghi ADR lựa chọn
Replica lag lớnToxiproxy cắt replication 30sMetric lag hiển thị; seat map có thể cũ nhưng hold ghế vẫn đúng vì booking kiểm tra ở primary
Mất message invalidationstop Kafka consumer catalog 1 phútTTL ngắn của seatmap giới hạn độ cũ ≤ 30s; sau khi bật lại thì đồng bộ
Pub/Sub L1 mấtchặn kênh Pub/SubL1 cũ tối đa bằng TTL L1 (vài giây)

7. Benchmark / số liệu cần thu

Lưu docs/benchmarks/m08-catalog.md:

Cấu hìnhRPSp50/p95/p99Hit L1 / L2DB QPS (primary/replica)CPU DB
Không cache, chỉ primary–
+ replica–
+ Redis
+ Caffeine (2 tầng)

Kèm: độ trễ invalidation (p50/p99), số query DB trong test stampede có/không có lock.

8. Checklist hoàn thành

Mình đã xóa key Redis khi nhận event, nhưng thỉnh thoảng seat map vẫn ra ghế đã bán là AVAILABLE, kéo dài tới hết TTL.

Triệu chứng kinh điển, có ít nhất 2 nguyên nhân. Gợi ý để bạn tự tìm:

Caffeine get(key, loader) đã chặn trùng trong một JVM. Có cần lock Redis nữa không?

Câu hỏi đúng. Tự tính: N instance → tối đa N query/key mỗi lần miss. Với 4 instance và query 50ms, có đáng thêm độ phức tạp không?

Đo cả hai, ghi số query DB vào benchmark rồi mới quyết.

Mình làm xong rồi, review và phỏng vấn mình đi.

Checklist review code AI sẽ soi

Câu hỏi phỏng vấn

1. Vì sao cần 2 tầng cache?

🇻🇳 L1 trong JVM nhanh nhất, không tốn mạng, gánh key cực nóng; L2 Redis dùng chung, giảm miss khi scale nhiều instance và sau deploy. Đổi lại phải lo invalidation L1 trên mọi instance.

🇬🇧 L1 is in-process and fastest for hot keys; L2 is shared so new instances start warm. The cost is invalidating L1 across all instances.

2. Cache stampede là gì, xử lý ra sao?

🇻🇳 Key nóng hết hạn, hàng loạt request cùng miss và cùng chọc DB. Xử lý: gộp request (single-flight), lock nạp lại, refresh sớm, TTL jitter.

🇬🇧 A hot key expires and many requests hit the DB at once. Fix with request coalescing, a reload lock, early refresh and TTL jitter.

3. Đọc từ replica có rủi ro gì với hệ thống đặt vé?

🇻🇳 Replication lag: seat map có thể cũ. Chấp nhận được cho hiển thị, nhưng quyết định giữ ghế luôn chạy trên primary với điều kiện atomic, nên không bán trùng.

🇬🇧 Replication lag makes reads stale. That's fine for display, but the hold decision always runs on the primary atomically, so no double booking.

4. Shard bảng seats theo gì?

🇻🇳 Theo event_id (hash) để seat map và giữ ghế một event nằm trên 1 shard. Nhược điểm: event flash sale thành hot shard, nhưng phần đó đã được Redis gánh ở M7. Trước khi shard nên partition theo thời gian.

🇬🇧 By hashed event_id so one event lives on one shard. A hot event makes a hot shard, which Redis absorbs from M7. Try time-based partitioning first.

Cách nhớ ngắn gọn

L1 vài giây · L2 vài chục giây · DB là nguồn sự thật
Ghi: commit → event → XÓA cache (không cập nhật)
Miss: gộp request · lock/refresh sớm · jitter
Đọc replica cho hiển thị, quyết định trên primary.

Tự kiểm tra

Vì sao nên xóa cache thay vì cập nhật cache khi dữ liệu đổi?

Hai luồng cập nhật cùng lúc có thể ghi theo thứ tự ngược, để lại giá trị cũ. Xóa thì lần đọc sau nạp lại từ nguồn đúng.

Làm sao xóa L1 Caffeine trên mọi instance?

Publish lệnh invalidation qua Redis Pub/Sub hoặc Kafka; mỗi instance subscribe và xóa key của mình. TTL L1 ngắn là lưới an toàn khi mất message.

Hit ratio tính thế nào và đo ở đâu?

hit / (hit + miss), theo từng tầng, từ metric cache_gets_total trên Prometheus, vẽ trên Grafana.

@Transactional(readOnly = true) không đi replica, vì sao hay gặp?

DataSource lấy connection trước khi transaction đánh dấu readOnly. Cần LazyConnectionDataSourceProxy để quyết định routing lúc thực sự cần connection.

Khi nào chưa cần shard?

Khi cache, replica, index và partition theo thời gian còn đủ. Shard tăng độ phức tạp vận hành và làm khó query xuyên shard.