M8 · Scale catalog
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
GET /api/events/{id}/seat-mapjoinevents+seats(vài nghìn dòng), trả JSON ~200 KB. 5k req/s là 5k query nặng vào primary.- Primary vừa phục vụ đọc vừa nhận ghi từ booking → ghi bị chậm theo, hold ghế timeout.
- Cache ngây thơ gây lỗi khác: sơ đồ ghế cũ hiển thị ghế đã bán là còn trống → user bấm, bị từ chối, bấm lại → tải tăng thêm.
- Key nóng hết hạn đúng lúc đông → 1000 request cùng miss, cùng chọc DB (cache stampede).
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
- Cache strategies: cache-aside, TTL + jitter, stampede, xóa thay vì cập nhật.
- Replication, partition, sharding: replication lag, chọn shard key, hot shard.
- Scale app & DB: thứ tự scale: cache → replica → partition → shard.
- Redis data types: lưu seat map dạng STRING JSON hay HASH, đánh đổi.
- Transactional pitfalls:
readOnly = truevà routing datasource. - Connection pool: hai pool cho primary/replica, cỡ pool.
- Prometheus & Grafana: expose và vẽ hit ratio.
3. Đề bài
Yêu cầu chức năng
- Cache 2 tầng cho
GET /api/events/{id}vàGET /api/events/{id}/seat-map. - Khi ghế đổi trạng thái (HELD/CONFIRMED/EXPIRED/RELEASED) catalog nhận event từ
booking.events, xóa cache seat map của event đó ở cả L2 và L1 mọi instance trong ≤ 1s. - Chống stampede: tại một thời điểm, mỗi key chỉ có tối đa 1 request/instance (tốt hơn: 1 request toàn cụm) đi xuống DB.
- Đọc đi read replica, ghi đi primary. Endpoint cần "đọc sau ghi" (vd admin vừa sửa event) được đánh dấu đi primary.
- Metric hit ratio theo tầng và theo cache name.
- Sharding: chỉ viết tài liệu
docs/adr/xxx-seat-sharding.md, không code.
API
| Method | Path | Body | Response |
|---|---|---|---|
| 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 fields | 200, 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:
- 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. - 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? - Trước khi shard, partition theo thời gian (
PARTITION BY RANGE (event_date)) có đủ không? - Rebalance khi thêm shard: consistent hashing hay bảng directory?
- 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
- 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.
- p95 seat-map khi hit < 10 ms (L1) / < 30 ms (L2).
- Ghế đổi trạng thái → seat map phản ánh trên mọi instance trong ≤ 1s (p99), đo bằng test.
- 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).
- Không có lỗi ghi đè cache cũ: event version N+1 không bị cache version N đè lên (test race).
- Query đọc chạy trên replica (kiểm tra
pg_stat_activitytrên replica hoặc log datasource), ghi chạy trên primary. - Redis chết: catalog vẫn trả dữ liệu (L1 + DB), không 5xx.
- Có dashboard hit ratio theo tầng và ADR sharding đủ 5 câu.
5. Test cases
| ID | Kịch bản | Input / điều kiện | Kỳ vọng |
|---|---|---|---|
| CA-01 | Miss lần đầu | cache rỗng | 1 query DB, lần 2 hit L1 |
| CA-02 | L1 hết hạn, L2 còn | chờ quá TTL L1 | hit L2, không query DB |
| CA-03 | Invalidation qua event | publish SeatHeld cho event E | seatmap E bị xóa ở L2 và L1 cả 2 instance |
| CA-04 | Stampede | 500 thread cùng get key vừa bị xóa | loader chạy 1 lần/instance |
| CA-05 | Race ghi đè | load chậm version N, trong lúc đó event version N+1 tới | cache cuối cùng là N+1 hoặc trống |
| CA-06 | Routing | gọi method @Transactional(readOnly=true) | connection từ replica pool |
| CA-07 | Replica lag | pause replication (Toxiproxy) | đọc ra dữ liệu cũ có giới hạn; endpoint cần đọc-sau-ghi vẫn đúng |
| CA-08 | Redis down | stop Redis | vẫ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ạo | Hành vi mong đợi |
|---|---|---|
| Redis chết | docker stop redis | Bỏ qua L2 (circuit breaker), dùng L1 + replica; timeout Redis ngắn |
| Redis chậm | Toxiproxy latency 500ms | Timeout ~50ms rồi đi DB; p95 không tăng quá 2 lần |
| Replica chết | stop replica | Fallback đọc primary (có rate limit) hoặc lỗi rõ ràng; ghi ADR lựa chọn |
| Replica lag lớn | Toxiproxy cắt replication 30s | Metric 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 invalidation | stop Kafka consumer catalog 1 phút | TTL ngắn của seatmap giới hạn độ cũ ≤ 30s; sau khi bật lại thì đồng bộ |
| Pub/Sub L1 mất | chặn kênh Pub/Sub | L1 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ình | RPS | p50/p95/p99 | Hit L1 / L2 | DB 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
- ☐ Acceptance criteria 1–8 có test tự động, xanh.
- ☐ Failure scenario đã chạy thật, ghi kết quả.
- ☐ Benchmark 4 cấu hình trong
docs/benchmarks/. - ☐ ADR: TTL từng cache và lý do; fallback khi replica chết; tài liệu sharding.
- ☐ Cache key có prefix + version, value có
seat_map_version. - ☐ Không cache dữ liệu theo user (giá cá nhân hóa, token) trong L2 chung.
- ☐ Không hardcode URL/password DB.
- ☐ Qua lượt review và phỏng vấn AI.
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:
- Race giữa reader và invalidation: request A miss, đọc DB (bản cũ), chậm; trong lúc đó event tới, xóa key; A ghi bản cũ vào cache sau. Vẽ timeline ra giấy. Hướng sửa: so version trước khi ghi (Lua
SETchỉ khi version mới hơn), hoặc xóa trễ lần 2. - Đọc từ replica: event tới ngay sau commit primary, nhưng loader đọc replica chưa kịp replicate → nạp bản cũ vào cache. Gợi ý: event mang
seatMapVersion; loader thấy version trên replica nhỏ hơn thì đọc primary hoặc không cache. - Kiểm tra cả L1: bạn xóa L2 nhưng instance khác còn L1 không?
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?
- Nếu N nhỏ: coalescing trong JVM là đủ, ghi ADR "chấp nhận N query".
- Nếu N lớn hoặc query rất nặng: lock
SET NX PX; ai không lấy được lock thì trả bản cũ (stale-while-revalidate) hoặc chờ ngắn rồi đọc lại. - Phương án khác không cần lock: refresh sớm (Caffeine
refreshAfterWrite, hoặc probabilistic early expiration) để key nóng không bao giờ hết hạn cùng lúc.
Đ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
- Invalidation đi theo thứ tự: commit DB → event → xóa cache. Có chỗ nào xóa cache trước commit không?
- Có "cập nhật cache" thay vì "xóa cache" không (dễ race)?
- TTL có jitter; TTL L1 ngắn hơn L2.
- Lỗi Redis có bị nuốt đúng cách (log + metric) chứ không ném 500.
@Transactional(readOnly = true)có thật sự đi replica (thiếuLazyConnectionDataSourceProxylà sai âm thầm).- Serializer Redis: không dùng Java serialization; có version trong key để đổi schema.
- Metric hit ratio có tag tầng, không có tag cardinality cao (eventId).
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.