Chương 8. Caching và Message Queue
Hầu như hệ thống nào bạn làm ở năm thứ 2–4 cũng có Redis và một message broker. Vì vậy tôi không hỏi "Redis là gì" mà hỏi "cache lệch với DB thì xử lý thế nào", "consumer nhận trùng message thì sao", "lưu DB xong gửi Kafka lỗi thì sao". Cả hai chủ đề có chung bản chất: đánh đổi tính nhất quán tức thời để lấy hiệu năng và khả năng chịu tải, và ứng viên giỏi nói được cái giá của sự đánh đổi đó.
Người phỏng vấn muốn kiểm tra
- Hiểu cache-aside và vì sao thường xóa cache thay vì cập nhật cache khi ghi.
- Biết các sự cố kinh điển: cache penetration, breakdown (stampede), avalanche và cách chống.
- Nắm Kafka ở mức vận hành được: partition, key, consumer group, offset, acks, thứ tự message.
- Hiểu delivery semantics thực tế là at-least-once, nên consumer phải idempotent.
- Giải thích được vấn đề dual write và Transactional Outbox.
8.1 Các tầng cache và khi nào nên cache
Cache là gì, nói bằng lời thường
Hãy hình dung bạn làm thu ngân ở quán cà phê. Bảng giá đầy đủ nằm trong máy tính ở phòng sau (đó là database), mỗi lần tra phải đi ra sau mất 30 giây. Sau vài ngày, bạn chép giá 10 món bán chạy nhất ra một tờ giấy dán ngay quầy (đó là cache): khách hỏi giá cà phê sữa, bạn liếc tờ giấy là trả lời được ngay. Cache chính là một bản sao nhỏ, đặt gần nơi cần dùng, của dữ liệu mà lấy từ nguồn gốc thì chậm hoặc tốn kém.
Từ ví dụ đó, bốn thuật ngữ bạn sẽ gặp suốt chương này:
- Cache hit: dữ liệu cần tìm có sẵn trên tờ giấy, trả lời ngay mà không đi ra phòng sau.
- Cache miss: tờ giấy không có món khách hỏi, phải đi tra máy tính, và thường chép luôn kết quả lên giấy cho lần sau.
- TTL (time to live): hạn dùng của mỗi dòng trên tờ giấy, ví dụ "dòng này chỉ tin được trong 10 phút". Hết hạn thì coi như không có, lần sau phải tra lại. TTL tồn tại vì chủ quán có thể đổi giá mà bạn không biết; hạn dùng giới hạn thời gian bạn đọc giá sai.
- Eviction: tờ giấy chỉ có 10 dòng. Muốn chép món thứ 11 thì phải gạch một món cũ đi. Chọn gạch món nào (lâu rồi không ai hỏi, hay ít người hỏi nhất) chính là chính sách eviction, ví dụ LRU và LFU ở mục 8.3.
Chỉ số quan trọng nhất của cache là hit ratio (tỷ lệ trúng): 1.000 lần đọc có 950 lần hit thì hit ratio là 95%, DB chỉ còn nhận 50 truy vấn thay vì 1.000. Hit ratio thấp (dưới khoảng 70–80%) thường nghĩa là cache đang tốn bộ nhớ mà không giúp được bao nhiêu.
Đoạn code dưới đây mô phỏng một cache nhỏ bằng HashMap với đồng hồ giả, để bạn nhìn tận mắt hit, miss và TTL trước khi dùng Redis.
import java.util.*;
public class TinyCache {
record Entry(String value, long expireAt) {}
static final Map<String, Entry> cache = new HashMap<>();
static long now = 0; // fake clock, in seconds
static String get(String key) {
Entry e = cache.get(key);
if (e != null && e.expireAt() > now) { System.out.println("HIT " + key); return e.value(); }
System.out.println("MISS " + key + " -> query DB");
String fromDb = "Product #" + key; // pretend this query took 50ms
cache.put(key, new Entry(fromDb, now + 600)); // TTL = 600 seconds
return fromDb;
}
public static void main(String[] args) {
get("1"); // prints: MISS 1 -> query DB
get("1"); // prints: HIT 1
now = 601; // 601 seconds later, the entry has expired
get("1"); // prints: MISS 1 -> query DB
}
}Giải thích: lần gọi đầu cache rỗng nên miss, "truy vấn DB" rồi lưu kèm hạn now + 600. Lần thứ hai entry còn hạn nên hit, không chạm DB. Khi đồng hồ nhảy sang giây 601, entry vẫn nằm trong map nhưng đã quá hạn, nên bị coi như không có và lại miss. Cache thật (Caffeine, Redis) làm đúng việc này, chỉ thêm giới hạn dung lượng (eviction), an toàn đa luồng và xóa entry hết hạn khỏi bộ nhớ.
Cache có ở nhiều tầng: trình duyệt (Cache-Control, ETag), CDN, API Gateway, cache trong JVM (Caffeine), cache phân tán (Redis) và buffer pool của DB. Với backend, hai lựa chọn chính là local cache và distributed cache.
| Tiêu chí | Local cache (Caffeine) | Distributed cache (Redis) |
|---|---|---|
| Tốc độ | Nhanh nhất, không qua mạng | Chậm hơn, mất 1 round-trip mạng |
| Nhất quán giữa các instance | Mỗi pod một bản, dễ lệch | Một bản dùng chung |
| Dung lượng | Giới hạn bởi Heap, ảnh hưởng GC | Lớn, scale riêng |
| Hợp với | Config, danh mục ít đổi | Session, dữ liệu dùng chung, rate limit |
Dữ liệu đáng cache: đọc nhiều hơn ghi rất nhiều, chấp nhận cũ vài giây đến vài phút, và truy vấn gốc tốn kém. Số dư, tồn kho lúc thanh toán thì tôi chỉ cache để hiển thị, không dùng để ra quyết định.
Mẹo từ người phỏng vấn
Khi được hỏi về cache, hãy mở đầu bằng "dữ liệu này chấp nhận cũ bao lâu?". Câu hỏi ngược đó cho người phỏng vấn thấy bạn nghĩ về nghiệp vụ trước khi nghĩ về công cụ.
Ví dụ áp dụng tiêu chí "đáng cache"
| Dữ liệu | Đọc/ghi | Chấp nhận cũ | Quyết định |
|---|---|---|---|
| Danh mục tỉnh/thành | Đọc liên tục, gần như không ghi | Vài giờ | Local cache, TTL dài |
| Chi tiết sản phẩm | 1.000 đọc : 1 ghi | Vài phút | Redis, TTL 10 phút, xóa khi sửa |
| Giỏ hàng của user | Đọc và ghi xấp xỉ nhau | Không | Lưu thẳng Redis/DB, không phải cache |
| Tồn kho lúc thanh toán | Ghi liên tục | Không | Không cache để ra quyết định |
Giải thích: hai dòng đầu đáng cache vì đọc áp đảo ghi và nghiệp vụ chịu được dữ liệu cũ vài phút. Giỏ hàng ghi nhiều như đọc nên cache không giảm tải được bao nhiêu mà còn thêm nguy cơ lệch. Tồn kho lúc thanh toán mà đọc từ cache cũ sẽ bán quá số lượng, nên quyết định cuối cùng phải dựa trên DB.
8.2 Spring Cache abstraction
Nhắc lại kiến thức
Spring tạo proxy bọc quanh bean; annotation như @Transactional, @Cacheable chỉ có tác dụng khi lời gọi đi qua proxy từ bean khác, không có tác dụng khi gọi this.method() trong cùng class (xem mục 4.4 và phần Self-invocation ở mục 4.5).
Spring Cache cho phép khai báo cache bằng annotation, còn nơi lưu (Caffeine, Redis) do CacheManager quyết định. Cần @EnableCaching trên một class cấu hình.
Đoạn code dưới đây là tình huống điển hình: API chi tiết sản phẩm được gọi rất nhiều nên kết quả getById được cache theo id, và khi sản phẩm bị sửa thì phải xóa đúng key đó để người dùng không thấy tên cũ.
@Service
public class ProductService {
private final ProductRepository repo;
public ProductService(ProductRepository repo) { this.repo = repo; }
// key "product::<id>" found in cache: return it, the method body does NOT run
@Cacheable(cacheNames = "product", key = "#id", unless = "#result == null") // do not cache null
public ProductDto getById(Long id) {
return repo.findById(id).map(ProductDto::from).orElse(null); // runs only on cache miss
}
@Transactional
@CacheEvict(cacheNames = "product", key = "#cmd.id()") // evict the key after the method returns
public void update(UpdateProductCmd cmd) {
Product p = repo.findById(cmd.id()).orElseThrow();
p.rename(cmd.name()); // dirty checking, UPDATE runs at commit
}
}Giải thích: lần gọi getById(1) đầu tiên cache miss, method chạy query DB và kết quả được lưu vào cache product với key 1; các lần gọi sau trả thẳng từ cache, không có câu SQL nào. Khi update chạy xong, key tương ứng bị xóa nên lần đọc tiếp theo lại miss và nạp giá trị mới. Cơ chế nằm ở proxy: Spring bọc bean bằng proxy, proxy kiểm tra cache trước khi gọi vào method thật, nên thân method bị bỏ qua hoàn toàn khi có cache hit. unless = "#result == null" tránh lưu kết quả null cho id không tồn tại (nếu muốn chống cache penetration thì lại nên cache null với TTL ngắn, xem mục 8.5). Ý nghĩa từng annotation:
@Cacheable: có trong cache thì trả về luôn, không chạy method; chưa có thì chạy rồi lưu kết quả.@CachePut: luôn chạy method và ghi đè kết quả vào cache.@CacheEvict: xóa một key hoặcallEntries = true; mặc định xóa sau khi method chạy xong không lỗi.sync = truetrên@Cacheable: chỉ một thread được load giá trị cho một key, nhưng chỉ trong phạm vi một JVM.
Annotation không nói cache nằm ở đâu. Để cache ở Redis (dùng chung cho mọi instance) thay vì trong bộ nhớ từng JVM, cấu hình trong application.yml:
spring:
cache:
type: redis # use RedisCacheManager as the store
redis:
time-to-live: 10m # default TTL for every entry, safety net if an evict is missed
cache-null-values: false # do not store null values in Redis
data:
redis: # Spring Boot 3: prefix spring.data.redis
host: localhost
port: 6379Giải thích: với cấu hình này, mỗi lần @Cacheable lưu kết quả, Spring serialize object và ghi vào Redis với key dạng product::1, kèm TTL 10 phút; hết 10 phút Redis tự xóa key dù không có ai gọi @CacheEvict. cache-null-values: false nghĩa là method trả null thì không ghi gì. Phần spring.data.redis là thông tin kết nối, dùng chung cho cả cache lẫn RedisTemplate. Điều cần rút ra: luôn đặt TTL, vì mọi cơ chế evict bằng code đều có lúc sót (race, service khác ghi thẳng DB).
Ghi chú
Từ Spring Boot 3.0, cấu hình kết nối Redis đã chuyển từ spring.redis.* sang spring.data.redis.*. Nhiều tài liệu cũ vẫn dùng tiền tố cũ và cấu hình sẽ bị bỏ qua.
Bẫy thường gặp
Self-invocation: gọi this.getById(id) từ một method khác trong cùng class thì cache không hoạt động, y hệt @Transactional, vì lời gọi không đi qua proxy. Ngoài ra cache annotation trên method private cũng không có tác dụng.
8.3 Redis: cấu trúc dữ liệu, TTL và eviction
Redis thực thi lệnh trên một thread chính (từ Redis 6 có thêm I/O thread cho mạng), nên từng lệnh là nguyên tử. Điều này rất quan trọng khi dùng INCR, DECR, SET NX hoặc Lua script cho bài toán đếm và khóa.
| Kiểu | Lệnh tiêu biểu | Dùng cho |
|---|---|---|
| String | SET, GET, INCR | Cache object JSON, counter, rate limit |
| Hash | HSET, HGET | Object nhiều field, cập nhật từng field |
| List | LPUSH, RPOP | Hàng đợi đơn giản, danh sách mới nhất |
| Set | SADD, SISMEMBER | Tập duy nhất, ai đã mua, ai đã like |
| Sorted Set | ZADD, ZRANGE | Bảng xếp hạng, sliding window rate limit |
TTL đặt bằng SET key value EX 600 hoặc EXPIRE key 600. Redis xóa key hết hạn theo hai cách: lazy (khi có ai truy cập key) và active (định kỳ lấy mẫu ngẫu nhiên các key có TTL để xóa), nên key hết hạn không biến mất ngay lập tức khỏi bộ nhớ.
Khi chạm maxmemory, Redis áp dụng maxmemory-policy. Mặc định là noeviction: lệnh ghi sẽ báo lỗi. Với Redis dùng thuần làm cache, tôi thường chọn allkeys-lru hoặc allkeys-lfu. Các biến thể volatile-* chỉ loại bỏ các key có TTL, phù hợp khi Redis vừa làm cache vừa chứa dữ liệu không được mất.
Nói bằng lời thường: LRU (Least Recently Used) gạch món lâu nhất chưa ai hỏi tới; LFU (Least Frequently Used) gạch món ít người hỏi nhất. Ví dụ Redis còn chỗ cho 1 key, đang có A (được đọc 1.000 lần, lần cuối 5 phút trước) và B (đọc 2 lần, lần cuối 1 giây trước). LRU sẽ loại A vì nó lâu chưa được đụng tới; LFU sẽ loại B vì nó ít được dùng. LFU hợp với dữ liệu có "key nóng" ổn định, LRU hợp với dữ liệu mà cái mới truy cập có khả năng được truy cập tiếp.
Lệnh Redis thường dùng, kèm output mẫu
Phiên redis-cli dưới đây minh họa lần lượt: cache một object với TTL, xem TTL còn lại, đếm bằng INCR, lưu object dạng Hash, xếp hạng bằng Sorted Set và lấy khóa bằng SET NX.
127.0.0.1:6379> SET product:1 "{\"id\":1,\"name\":\"Ao thun\"}" EX 600
OK
127.0.0.1:6379> GET product:1
"{\"id\":1,\"name\":\"Ao thun\"}"
127.0.0.1:6379> TTL product:1
(integer) 594
127.0.0.1:6379> GET product:999
(nil)
127.0.0.1:6379> INCR view:product:1
(integer) 1
127.0.0.1:6379> INCR view:product:1
(integer) 2
127.0.0.1:6379> HSET user:7 name "An" city "HN"
(integer) 2
127.0.0.1:6379> HGET user:7 city
"HN"
127.0.0.1:6379> ZADD leaderboard 120 "an" 300 "binh" 90 "chi"
(integer) 3
127.0.0.1:6379> ZREVRANGE leaderboard 0 1 WITHSCORES
1) "binh"
2) "300"
3) "an"
4) "120"
127.0.0.1:6379> SET lock:job "uuid-1" NX PX 30000
OK
127.0.0.1:6379> SET lock:job "uuid-2" NX PX 30000
(nil)
127.0.0.1:6379> TTL no-such-key
(integer) -2Giải thích:
SET ... EX 600trảOKvà gắn TTL 600 giây;TTLtrả số giây còn lại (594 vì đã trôi 6 giây).TTLtrả-1nếu key tồn tại mà không có hạn,-2nếu key không tồn tại.GETkey không có trả(nil): đây chính là cache miss mà code Java nhận được dưới dạngnull.INCRtăng và trả giá trị mới trong một lệnh nguyên tử, nên 100 request đồng thời tăng đúng 100, không mất lượt nào như khiGETrồiSETtừ Java.HSETtrả số field mới được thêm (2); dùng Hash khi muốn sửa một field mà không phải ghi lại cả object.ZREVRANGE ... 0 1 WITHSCORESlấy top 2 điểm cao nhất, Redis tự giữ tập đã sắp xếp nên không cầnORDER BYmỗi lần.SET NXlần hai trả(nil)vì key đã tồn tại: pod thứ hai không lấy được khóa (chi tiết ở mục 8.6).
8.4 Các pattern đọc/ghi cache
Nhắc lại kiến thức
Transaction chỉ làm thay đổi hiện ra với người khác khi commit; trước commit, ở READ COMMITTED người khác vẫn đọc thấy giá trị cũ (xem mục 6.3). @Transactional commit khi method đi ra khỏi proxy, không phải tại dòng save() (xem mục 4.5).
| Pattern | Cách làm | Ưu | Nhược |
|---|---|---|---|
| Cache-aside | App đọc cache, miss thì đọc DB rồi set cache; ghi DB rồi xóa cache | Đơn giản, phổ biến nhất | App tự lo logic, vẫn có cửa sổ lệch |
| Read/Write-through | App chỉ nói chuyện với cache, cache tự đọc/ghi DB đồng bộ | Code app gọn | Cần provider hỗ trợ, ghi chậm hơn |
| Write-behind | Ghi vào cache, flush xuống DB bất đồng bộ theo lô | Ghi cực nhanh | Có thể mất dữ liệu nếu cache sập |
Dự án Spring thông thường gần như luôn dùng cache-aside. Write-behind chỉ hợp với dữ liệu chấp nhận mất mát như lượt view.
Từng bước của từng pattern
Để dễ hình dung, cả ba sơ đồ dùng cùng một ví dụ: API đọc và sửa sản phẩm id 1. "App" là service Spring của bạn.
Cache-aside (lazy loading): app tự điều phối cả cache lẫn DB.
READ product 1
1. App -> Redis : GET product:1
2a. Hit : Redis returns the value -> App returns it (done, DB untouched)
2b. Miss: Redis returns nil
3. App -> DB : SELECT * FROM product WHERE id = 1
4. App -> Redis : SET product:1 <json> EX 600
5. App returns the value
WRITE product 1
1. App -> DB : UPDATE product SET ... WHERE id = 1 ; COMMIT
2. App -> Redis : DEL product:1 (the next read reloads it)Write-through / read-through: app chỉ nói chuyện với lớp cache, lớp cache tự đọc/ghi DB.
WRITE product 1
1. App -> Cache : put(1, newValue)
2. Cache -> DB : UPDATE ... (synchronously, inside the same call)
3. Cache stores newValue, then returns OK to App
READ product 1
1. App -> Cache : get(1)
2. Miss: Cache -> DB : SELECT ... ; Cache stores it ; returns itWrite-behind (write-back): ghi vào cache là xong, DB được cập nhật sau.
WRITE view count of product 1
1. App -> Cache : INCR view:1 (returns immediately)
2. ... every 5 seconds, a background flusher:
3. Flusher -> Cache : read all dirty counters
4. Flusher -> DB : one batched UPDATE for 500 products
If the cache node dies between step 1 and step 4, those increments are lost.Giải thích: khác biệt nằm ở ai chịu trách nhiệm đồng bộ và khi nào DB được ghi. Cache-aside: app làm hết, DB luôn được ghi trước nên DB là nguồn sự thật, cache chỉ là bản sao có thể bị xóa bất kỳ lúc nào. Write-through: DB và cache được ghi đồng bộ trong cùng một lời gọi, đọc ngay sau khi ghi luôn thấy giá trị mới, nhưng mỗi lần ghi chậm bằng tổng hai lần ghi và cần thư viện hỗ trợ (ví dụ CacheWriter/CacheLoader của JCache, Redisson). Write-behind: ghi cực nhanh và gom được nhiều thay đổi thành một câu SQL, nhưng DB luôn "đi sau", và dữ liệu chưa flush sẽ mất nếu cache sập, nên chỉ dùng cho số liệu như lượt xem, lượt like.
Vì sao xóa cache chứ không cập nhật cache?
Hai request cùng cập nhật một bản ghi có thể ghi DB theo thứ tự A rồi B nhưng ghi cache theo thứ tự B rồi A: DB giữ giá trị B, cache giữ giá trị A cho tới khi hết TTL. Xóa cache thì lần đọc sau tự nạp giá trị mới nhất, và không tốn công cập nhật dữ liệu không ai đọc.
Bảng thời gian dưới đây cho thấy race đó với số cụ thể: giá sản phẩm ban đầu 100, request A đổi thành 120, request B đổi thành 150 ngay sau đó.
| Thời điểm | Request A (giá 120) | Request B (giá 150) | DB / Redis |
|---|---|---|---|
| t1 | UPDATE DB giá 120, commit | DB=120, Redis=100 | |
| t2 | bị GC pause 200ms | UPDATE DB giá 150, commit | DB=150 |
| t3 | SET Redis giá 150 | Redis=150 | |
| t4 | tỉnh lại, SET Redis giá 120 | DB=150, Redis=120 |
Giải thích: sau t4, mọi người đọc thấy giá 120 trong khi giá thật là 150, và tình trạng này kéo dài tới khi key hết TTL (có thể 10 phút). Nếu cả A và B đều DEL thay vì SET, dù thứ tự đảo thế nào kết quả cuối vẫn là "không có key", lần đọc sau đọc DB và thấy 150.
Xóa cache sau khi commit
Thứ tự đúng là: commit DB rồi mới xóa cache. Nếu xóa cache trước khi commit, một request đọc chen vào sẽ miss, đọc DB (vẫn là giá trị cũ vì chưa commit) và nạp lại giá trị cũ vào cache. Trong ví dụ ở mục 8.2, @CacheEvict và @Transactional cùng đặt trên một method, nên thời điểm evict so với commit phụ thuộc vào thứ tự các interceptor. Hai cách làm chắc chắn:
- Bật
transactionAware()khi buildRedisCacheManager: các thao tác put/evict được hoãn đến sau khi transaction commit. - Publish một event trong transaction và xóa cache trong
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT).
Kịch bản xóa cache trước commit gây lệch, trình bày theo thời gian (giá cũ 100, giá mới 150):
| Thời điểm | Request ghi (W) | Request đọc (R) | DB / Redis |
|---|---|---|---|
| t1 | BEGIN, UPDATE giá 150 (chưa commit) | DB đã commit vẫn 100 | |
| t2 | DEL product:1 | Redis trống | |
| t3 | GET miss, SELECT thấy 100 (bản đã commit) | ||
| t4 | SET product:1 = 100 | Redis=100 | |
| t5 | COMMIT | DB=150, Redis=100 |
Giải thích: R chen vào đúng khoảng giữa lúc xóa cache và lúc commit, đọc được bản cũ đã commit (đúng theo isolation, xem mục 6.3) và nạp nó ngược vào cache. Kết quả là cache giữ giá 100 tới hết TTL. Đổi thứ tự thành "commit xong mới DEL" thì ở t3 R vẫn đọc Redis thấy giá trị cũ (chưa bị xóa), nhưng ngay sau commit key bị xóa và lần đọc tiếp theo nạp giá 150.
Cách thứ hai trong danh sách trên viết bằng Spring như sau: service ghi DB và publish event, listener chỉ chạy sau khi transaction commit thành công.
@Service
public class ProductWriteService {
private final ProductRepository repo;
private final ApplicationEventPublisher events;
public ProductWriteService(ProductRepository repo, ApplicationEventPublisher events) {
this.repo = repo;
this.events = events;
}
@Transactional
public void rename(Long id, String name) {
repo.findById(id).orElseThrow().rename(name);
events.publishEvent(new ProductChanged(id)); // buffered until the transaction ends
}
}
@Component
class ProductCacheEvictor {
private final CacheManager cacheManager;
ProductCacheEvictor(CacheManager cacheManager) { this.cacheManager = cacheManager; }
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) // skipped on rollback
void on(ProductChanged e) { cacheManager.getCache("product").evict(e.id()); }
}Giải thích: publishEvent được gọi trong transaction nhưng listener AFTER_COMMIT không chạy ngay; Spring giữ event lại và chỉ gọi listener sau khi commit thành công. Nếu transaction rollback, listener không chạy và cache cũng không bị xóa oan. Nhờ vậy thứ tự "commit rồi mới xóa" được bảo đảm, không phụ thuộc thứ tự interceptor như khi đặt @CacheEvict cạnh @Transactional.
Ngay cả khi làm đúng vẫn còn một race hiếm gặp: request đọc miss, đọc giá trị cũ từ DB, bị chậm; trong lúc đó request ghi commit và xóa cache; request đọc mới set giá trị cũ vào cache. Vì vậy luôn đặt TTL làm lưới an toàn cuối cùng. Hệ thống cần chặt hơn có thể dùng "delayed double delete" (xóa thêm lần nữa sau vài trăm ms) hoặc lắng nghe binlog/WAL bằng CDC (Debezium) để xóa cache.
Race hiếm gặp đó, theo thời gian:
| Thời điểm | Request đọc (R) | Request ghi (W) | DB / Redis |
|---|---|---|---|
| t1 | GET miss | Redis trống, DB=100 | |
| t2 | SELECT thấy 100, rồi bị chậm | ||
| t3 | UPDATE 150, COMMIT | DB=150 | |
| t4 | DEL product:1 (key không có) | Redis trống | |
| t5 | SET product:1 = 100 | Redis=100 |
Giải thích: R đọc DB trước khi W commit nhưng ghi cache sau khi W đã xóa, nên giá trị cũ được đặt vào cache. Race này cần R chậm hơn cả một lần ghi DB, nên hiếm, nhưng với hàng triệu request mỗi ngày thì sẽ xảy ra. "Delayed double delete" chữa đúng trường hợp này: W xóa thêm lần nữa sau khoảng 500ms, khi R gần như chắc chắn đã ghi xong giá trị cũ. TTL là chốt cuối nếu cả lần xóa thứ hai cũng trượt.
8.5 Penetration, breakdown, avalanche
| Sự cố | Nguyên nhân | Cách chống |
|---|---|---|
| Penetration | Truy vấn key không tồn tại, cache luôn miss, DB gánh hết | Cache giá trị rỗng với TTL ngắn, Bloom filter, validate input |
| Breakdown (stampede) | Một key rất nóng hết hạn, hàng nghìn request cùng đập vào DB | Khóa theo key để một request nạp lại, refresh trước khi hết hạn, key nóng không đặt TTL cứng |
| Avalanche | Rất nhiều key hết hạn cùng lúc hoặc Redis sập | TTL cộng jitter ngẫu nhiên, Redis HA (Sentinel/Cluster), cache 2 tầng, circuit breaker bảo vệ DB |
Ví dụ thật: team warm-up cache lúc 0h với cùng TTL 24h, đúng 0h hôm sau DB lên 100% CPU. Sửa bằng TTL 24h cộng ngẫu nhiên 0–30 phút.
Penetration: kịch bản và cách chữa
Kịch bản: API GET /products/{id} dùng cache-aside. Một bot gửi 2.000 request/giây với id ngẫu nhiên từ 10.000.000 đến 99.999.999, trong khi DB chỉ có id tới 50.000. Mọi request đều miss (vì không có gì để cache), đều xuống DB, và vì unless = "#result == null" nên không có gì được lưu lại. Cache vô dụng, DB gánh 2.000 truy vấn/giây.
Cách chữa, từ rẻ đến đắt:
- Validate input: id âm, sai định dạng thì trả 400 ngay ở controller, không chạm cache lẫn DB.
- Cache giá trị rỗng với TTL ngắn: id không tồn tại thì vẫn ghi
product:99999999 = NULL_MARKERvới TTL 60 giây. Bot hỏi lại cùng id sẽ hit. - Bloom filter: cấu trúc dữ liệu nhỏ trả lời "chắc chắn không có" hoặc "có thể có". Nạp mọi id đang tồn tại vào đó; id bị Bloom filter nói "chắc chắn không có" thì trả 404 luôn. Bloom filter có thể báo nhầm "có thể có" (một ít request vẫn xuống DB) nhưng không bao giờ báo nhầm "không có".
- Rate limit theo IP/user ở gateway để chặn bot từ gốc.
Breakdown (stampede): kịch bản và cách chữa
Kịch bản: trang chủ gọi key home:banner với 5.000 request/giây. Key có TTL 10 phút, và truy vấn nạp lại mất 2 giây. Đúng lúc key hết hạn, trong 2 giây truy vấn nạp chạy, khoảng 10.000 request cùng miss và cùng chạy đúng câu truy vấn nặng đó. DB nghẽn, truy vấn càng chậm, càng nhiều request dồn vào: đó là stampede ("đàn trâu giẫm đạp").
Cách chữa là chỉ cho một request nạp lại, những request khác chờ kết quả đó. Trong một JVM, ConcurrentHashMap.computeIfAbsent đã làm đúng việc này: hàm nạp chỉ chạy một lần cho mỗi key còn thiếu, các thread khác gọi cùng key thì chờ. Đoạn code dưới đây cho 100 thread cùng xin một key đang trống.
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
public class SingleFlightDemo {
static final ConcurrentHashMap<String, String> cache = new ConcurrentHashMap<>();
static final AtomicInteger dbCalls = new AtomicInteger();
static String loadFromDb(String key) {
dbCalls.incrementAndGet();
try { Thread.sleep(200); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
return "banner-v2";
}
public static void main(String[] args) throws Exception {
try (ExecutorService pool = Executors.newFixedThreadPool(100)) {
for (int i = 0; i < 100; i++) {
pool.submit(() -> cache.computeIfAbsent("home:banner", SingleFlightDemo::loadFromDb)); // loader runs once per missing key
}
} // close() waits for all tasks
System.out.println("DB calls: " + dbCalls.get()); // prints: DB calls: 1
}
}Giải thích: cả 100 thread cùng thấy key thiếu, nhưng computeIfAbsent khóa theo bucket chứa key đó, nên chỉ thread đầu tiên chạy loadFromDb, 99 thread còn lại chờ và nhận luôn kết quả. In ra DB calls: 1. Nếu thay bằng "get, thấy null thì loadFromDb rồi put", số lần gọi DB sẽ gần 100. @Cacheable(sync = true) hoạt động theo cùng ý tưởng nhưng chỉ trong một JVM; với 10 pod, bạn có tối đa 10 lần nạp (chấp nhận được), hoặc dùng khóa Redis SET NX để chỉ một pod nạp. Các cách khác: refresh key nóng bất đồng bộ trước khi hết hạn (ví dụ còn 1 phút thì một job nạp lại), hoặc để key nóng không có TTL cứng và cập nhật bằng event. Lưu ý không nên giữ khóa computeIfAbsent quá lâu trong code thật: hàm nạp chậm sẽ chặn cả các key khác cùng bucket.
Avalanche: kịch bản và cách chữa
Kịch bản 1 (hết hạn đồng loạt): job warm-up lúc 0h nạp 200.000 sản phẩm vào Redis với cùng TTL 24h. Đúng 0h hôm sau, 200.000 key hết hạn trong cùng một giây, mọi request đều miss và DB nhận toàn bộ tải.
| TTL | Số key hết hạn trong phút 0h00 | Tải lên DB |
|---|---|---|
| 24h cố định | 200.000 | Đỉnh dựng đứng, DB 100% CPU |
| 24h + ngẫu nhiên 0–30 phút | khoảng 6.700 mỗi phút | Rải đều trong 30 phút |
Kịch bản 2 (Redis sập): Redis master chết, mọi lệnh GET timeout. Nếu code coi lỗi Redis như cache miss và xuống DB, DB nhận 100% traffic ngay lập tức.
Cách chữa: TTL cộng jitter ngẫu nhiên (chia 200.000 cho 30 phút ra khoảng 6.700 key mỗi phút như bảng trên); Redis HA (Sentinel hoặc Cluster) để có replica thay thế; cache hai tầng (Caffeine trong JVM giữ bản gần nhất vài chục giây khi Redis sập); circuit breaker hoặc rate limit trước DB để khi Redis sập thì một phần request nhận lỗi nhanh thay vì cả hệ thống chết theo. Timeout gọi Redis cũng phải ngắn (vài chục ms), nếu không thread sẽ bị giữ chờ Redis chết.
8.6 Distributed lock với Redis
Nhắc lại kiến thức
Chốt chặn đúng đắn cuối cùng nên nằm ở DB: optimistic lock bằng @Version, SELECT ... FOR UPDATE, hoặc atomic update UPDATE ... WHERE stock > 0 rồi kiểm tra số dòng bị ảnh hưởng (xem "Optimistic vs pessimistic lock" ở mục 6.3).
Khi nhiều instance cần đảm bảo chỉ một nơi làm một việc (chạy job, nạp lại một key nóng), cách cơ bản là một lệnh SET có điều kiện để lấy khóa và một Lua script để trả khóa. Ví dụ dưới đây: 3 pod cùng chạy job xuất báo cáo tháng 6 lúc 1h sáng, chỉ một pod được phép chạy.
-- Acquire: succeeds only if the key does not exist, auto-expires after 30 seconds
SET lock:report:2024-06 "8f3c-uuid-cua-instance" NX PX 30000
-- Release with Lua so that "check owner, then delete" is atomic
if redis.call("GET", KEYS[1]) == ARGV[1] then -- is the lock still mine?
return redis.call("DEL", KEYS[1]) -- owner matches: delete, return 1
else
return 0 -- lock expired and now belongs to someone else: leave it
endGiải thích: pod nào gửi SET ... NX đầu tiên nhận OK và được chạy job; hai pod còn lại nhận nil (key đã tồn tại) nên bỏ qua. Làm xong, pod giữ khóa chạy Lua script với KEYS[1] là tên khóa và ARGV[1] là uuid của mình. Phải dùng script vì nếu tách thành hai lệnh GET rồi DEL từ Java, giữa hai lệnh khóa có thể hết hạn và pod khác đã lấy được khóa mới, lệnh DEL sẽ xóa nhầm khóa của pod kia. Redis chạy trọn một Lua script mà không xen lệnh khác vào, nên bước kiểm tra và bước xóa không thể bị chen ngang. Các tham số quan trọng:
NX: chỉ set nếu key chưa tồn tại.PX 30000: tự hết hạn sau 30 giây để không kẹt khóa khi process chết.- Value là một id duy nhất để không xóa nhầm khóa của người khác.
Rủi ro cần nói được: nếu công việc chạy lâu hơn thời hạn khóa (GC pause dài, gọi mạng chậm), khóa hết hạn và một instance khác cũng vào được. Redisson giảm rủi ro này bằng watchdog tự gia hạn, nhưng không loại bỏ được hoàn toàn. Redis replication là bất đồng bộ nên khi master chết và replica lên thay, khóa có thể mất. Với nghiệp vụ mà sai là mất tiền, khóa Redis chỉ là lớp tối ưu; lớp bảo vệ cuối cùng phải là DB (unique constraint, optimistic lock, UPDATE ... WHERE có điều kiện) hoặc fencing token.
Kịch bản khóa hết hạn giữa chừng, với khóa TTL 30 giây:
| Giây | Pod A | Pod B | Khóa trong Redis |
|---|---|---|---|
| 0 | SET NX OK, bắt đầu job | của A, token 33 | |
| 10 | GC pause 25 giây | của A | |
| 30 | (vẫn đang dừng) | hết hạn, bị xóa | |
| 31 | SET NX OK, bắt đầu job | của B, token 34 | |
| 35 | tỉnh lại, ghi kết quả | đang ghi kết quả | của B |
Giải thích: từ giây 35, cả A và B đều tin mình giữ khóa và cùng ghi, ví dụ cùng xuất báo cáo hoặc cùng trừ tiền. Lua script vẫn ngăn A xóa nhầm khóa của B, nhưng không ngăn được A ghi dữ liệu. Fencing token chữa đúng chỗ này: mỗi lần cấp khóa kèm một số tăng dần (A được 33, B được 34), và nơi nhận dữ liệu lưu token lớn nhất từng thấy, từ chối mọi lần ghi có token nhỏ hơn. Khi B đã ghi với token 34, lần ghi muộn của A với token 33 bị từ chối. Trong SQL có thể viết UPDATE report SET ..., fence = 33 WHERE id = ? AND fence < 33.
8.7 Message Queue: vì sao và khi nào
Message queue là gì, nói bằng lời thường
Nghĩ tới quầy gọi món ở một quán ăn đông khách. Nhân viên thu ngân nhận order, ghi vào phiếu, ghim phiếu lên thanh treo, rồi phục vụ khách tiếp theo ngay, không đứng chờ bếp nấu xong. Đầu bếp lấy phiếu từ thanh treo theo tốc độ của mình. Trong phần mềm:
- Message là tờ phiếu: một gói dữ liệu mô tả việc cần làm hoặc chuyện đã xảy ra, ví dụ
{"event":"OrderCreated","orderId":1001}. - Producer là thu ngân: phần code gửi message.
- Consumer là đầu bếp: phần code nhận và xử lý message.
- Broker (message queue, như Kafka hay RabbitMQ) là thanh treo phiếu: hệ thống trung gian giữ message an toàn cho tới khi consumer lấy.
Điểm cốt lõi: producer và consumer không gọi trực tiếp nhau, không cần cùng sống một lúc, và không cần chạy cùng tốc độ. Bếp nghỉ 5 phút thì phiếu vẫn nằm trên thanh treo, không mất.
Ví dụ cụ thể: API đăng ký tài khoản cần lưu user (20ms) và gửi email chào mừng qua dịch vụ SMTP (800ms, đôi khi lỗi). Không có queue, API mất khoảng 820ms và báo lỗi cho user nếu SMTP chết. Có queue, API lưu user, gửi message UserRegistered (vài ms) và trả về ngay; một consumer riêng gửi email và tự retry nếu SMTP lỗi, user không hề biết.
Message queue mang lại ba lợi ích chính:
- Decoupling: producer không cần biết ai tiêu thụ, thêm consumer mới không phải sửa producer.
- Async: tác vụ chậm (gửi email, xuất báo cáo, gọi đối tác) chuyển xuống nền, API trả về nhanh.
- Load leveling: hứng đỉnh tải, consumer xử lý theo tốc độ của mình thay vì để DB bị đè chết.
Cái giá: eventual consistency, debug khó hơn, phải xử lý message trùng, lỗi, thứ tự, và thêm một hạ tầng cần vận hành.
8.8 Kafka ở mức phải nắm
Các khái niệm Kafka bằng lời thường
Topic: một "kênh" đặt tên theo loại sự kiện, ví dụ
order-events. Producer gửi vào topic, consumer đọc từ topic.Partition: một topic được chia thành nhiều "làn" song song. Topic
order-eventscó 3 partition nghĩa là có 3 cuốn sổ riêng P0, P1, P2. Chia làn để nhiều consumer đọc cùng lúc, mỗi người một làn.Offset: số thứ tự của message trong một partition, bắt đầu từ 0 và tăng dần, giống số dòng trong cuốn sổ. P0 và P1 đều có message offset 5, nhưng là hai message khác nhau.
Consumer group: một nhóm consumer cùng làm một việc, chia nhau các partition. Ví dụ group
email-servicecó 2 pod thì 2 pod chia nhau 3 partition. Group khác (ví dụanalytics) đọc lại toàn bộ topic độc lập, không ảnh hưởng group thứ nhất.Committed offset: "đánh dấu trang" của một group trên mỗi partition: group đã xử lý xong tới đâu. Consumer khởi động lại sẽ đọc tiếp từ dấu đó.
Consumer lag: khoảng cách giữa message mới nhất trong partition và dấu trang của group, tức là số message còn tồn chưa xử lý.
Topic chia thành nhiều partition. Mỗi partition là một log chỉ ghi nối đuôi; mỗi message có offset tăng dần trong partition đó.
Message không bị xóa khi đọc; Kafka giữ theo
retention(thời gian hoặc dung lượng), nên có thể đọc lại (replay) bằng cách lùi offset.Thứ tự chỉ được đảm bảo trong một partition. Producer gửi kèm key, partitioner mặc định hash key để chọn partition, nên mọi sự kiện cùng
orderIdsẽ vào cùng partition và giữ thứ tự.Consumer group: mỗi partition chỉ được giao cho đúng một consumer trong group. Số consumer lớn hơn số partition thì consumer thừa ngồi chơi, nên số partition là trần song song của một group.
Consumer commit offset để đánh dấu đã xử lý đến đâu. Khi rebalance (thêm/bớt consumer, consumer chết), partition được chia lại.
Replication: mỗi partition có một leader và các follower. ISR (in-sync replicas) là các replica theo kịp leader.
acks phía producer:
0không chờ gì;1chỉ chờ leader ghi;allchờ mọi replica trong ISR. Cấu hình bền vững phổ biến: replication factor 3,min.insync.replicas=2,acks=all.
Ví dụ số: partition chia cho consumer ra sao
Topic order-events có 3 partition (P0, P1, P2), group order-service lần lượt chạy 2, 3 rồi 4 consumer:
| Số consumer | Phân công | Nhận xét |
|---|---|---|
| 2 (C1, C2) | C1: P0, P1; C2: P2 | C1 làm gấp đôi C2 |
| 3 | mỗi consumer một partition | Cân bằng, song song tối đa |
| 4 | C1: P0, C2: P1, C3: P2, C4: không có gì | C4 ngồi chơi, chỉ làm dự phòng |
| 1 | C1: P0, P1, P2 | Vẫn chạy, chỉ chậm hơn |
Đoạn code dưới đây mô phỏng cách chia "theo dải liên tiếp" (giống RangeAssignor của Kafka cho một topic) để bạn tự thử với số khác.
import java.util.*;
public class GroupAssign {
// Simplified RangeAssignor for one topic: contiguous ranges
static Map<String, List<Integer>> assign(int partitions, int consumers) {
Map<String, List<Integer>> result = new LinkedHashMap<>();
int per = partitions / consumers, extra = partitions % consumers, p = 0;
for (int c = 0; c < consumers; c++) {
int count = per + (c < extra ? 1 : 0); // the first "extra" consumers take one more
List<Integer> mine = new ArrayList<>();
for (int i = 0; i < count; i++) mine.add(p++);
result.put("C" + (c + 1), mine);
}
return result;
}
public static void main(String[] args) {
System.out.println(assign(3, 2)); // prints: {C1=[0, 1], C2=[2]}
System.out.println(assign(3, 3)); // prints: {C1=[0], C2=[1], C3=[2]}
System.out.println(assign(3, 4)); // prints: {C1=[0], C2=[1], C3=[2], C4=[]}
}
}Giải thích: 3 partition chia 2 consumer được 1 dư 1, nên C1 nhận thêm một partition: C1=[0, 1], C2=[2]. Với 4 consumer, mỗi người được 0 dư 3, ba consumer đầu mỗi người một partition, C4 nhận danh sách rỗng. Đây là lý do "số partition là trần song song": muốn 6 consumer cùng làm việc thì topic phải có ít nhất 6 partition. Khi thêm C4 vào group, Kafka chạy rebalance: tạm dừng tiêu thụ, chia lại partition, rồi các consumer đọc tiếp từ committed offset của từng partition. Nếu có group thứ hai (ví dụ analytics) với 1 consumer, consumer đó nhận cả 3 partition và đọc mọi message, song song với order-service.
Ví dụ số: offset, commit và consumer lag
Lệnh dưới đây xem trạng thái của group trên từng partition; output mẫu là lúc hệ thống đang tồn việc.
kafka-consumer-groups.sh --bootstrap-server localhost:9092 --describe --group order-service
# GROUP TOPIC PARTITION CURRENT-OFFSET LOG-END-OFFSET LAG CONSUMER-ID
# order-service order-events 0 1500 1520 20 consumer-1-...
# order-service order-events 1 980 980 0 consumer-1-...
# order-service order-events 2 2000 7000 5000 consumer-2-...Giải thích: CURRENT-OFFSET là dấu trang đã commit (message tiếp theo cần xử lý), LOG-END-OFFSET là offset sẽ được gán cho message mới tiếp theo, và LAG là hiệu hai số đó. P0 tồn 20 message là bình thường, P1 đã theo kịp, còn P2 tồn 5.000 message: consumer-2 đang chậm hoặc bị kẹt ở một message lỗi. Nếu consumer-2 chết và C1 nhận P2 sau rebalance, C1 bắt đầu đọc từ offset 2000. Lag tăng liên tục là tín hiệu cần alert.
Ví dụ: key quyết định partition
Producer gửi 4 event với key là mã đơn. Partitioner mặc định tính murmur2(key) % 3, nên với cùng số partition, cùng key luôn ra cùng một partition (con số cụ thể dưới đây chỉ minh họa):
| Event | Key | Partition |
|---|---|---|
| OrderCreated | order-1001 | P2 |
| OrderCreated | order-1002 | P0 |
| OrderPaid | order-1001 | P2 |
| OrderShipped | order-1001 | P2 |
Giải thích: ba event của order-1001 nằm cùng P2 theo đúng thứ tự gửi, nên consumer của P2 xử lý Created, Paid, Shipped đúng thứ tự. Event của order-1002 nằm ở P0 và có thể được xử lý trước hoặc sau, không sao vì hai đơn độc lập. Gửi không có key thì Kafka rải message lên các partition, thứ tự giữa các event của cùng một đơn không còn được đảm bảo.
Ghi chú
Từ Kafka 3.0, producer mặc định acks=all và enable.idempotence=true, giúp retry của producer không sinh bản ghi trùng trong partition. Tài liệu cũ ghi mặc định acks=1 là đã lỗi thời. Lưu ý idempotent producer chỉ chống trùng do retry phía producer, không chống trùng phía consumer.
Bẫy thường gặp
Tăng số partition của topic đang chạy sẽ làm thay đổi ánh xạ key sang partition. Các message mới của cùng một orderId có thể rơi vào partition khác, phá vỡ thứ tự trong giai đoạn chuyển tiếp. Hãy chọn số partition đủ dư ngay từ đầu.
8.9 RabbitMQ và khi nào chọn cái nào
RabbitMQ theo mô hình broker thông minh: producer gửi vào exchange, exchange định tuyến đến queue theo binding và routing key. Các loại exchange: direct (khớp đúng routing key), topic (khớp mẫu như order.*.created), fanout (phát cho mọi queue), headers. Consumer ack thì message bị xóa khỏi queue; nack hoặc bị từ chối có thể đưa sang dead letter exchange.
| Tiêu chí | Kafka | RabbitMQ |
|---|---|---|
| Mô hình | Log phân tán, consumer pull | Queue, broker push kèm prefetch |
| Sau khi đọc | Giữ lại theo retention, replay được | Xóa sau khi ack |
| Thông lượng | Rất cao, hợp event streaming | Tốt, hợp task queue |
| Định tuyến | Đơn giản theo topic/partition | Linh hoạt qua exchange/binding |
| Chọn khi | Event sourcing, log, nhiều hệ thống cùng đọc, cần replay | Job nền, định tuyến phức tạp, cần priority, TTL từng message |
8.10 Delivery semantics, idempotent consumer, retry và DLQ
| Semantics | Cách đạt | Hệ quả |
|---|---|---|
| At-most-once | Commit offset trước rồi mới xử lý | Có thể mất message |
| At-least-once | Xử lý xong mới commit offset | Có thể nhận trùng |
| Exactly-once | Kafka transactions cho luồng đọc-xử lý-ghi trong Kafka | Không tự áp dụng cho DB hay API bên ngoài |
Nhắc lại kiến thức
Idempotent nghĩa là làm một lần hay nhiều lần cho cùng một kết quả; cách chắc nhất để chống trùng là unique constraint trong DB thay vì SELECT rồi INSERT (xem mục 7.2 và cơ chế Idempotency-Key ở mục 7.4).
Kịch bản at-least-once gây xử lý trùng, từng bước
Consumer của group loyalty-service cộng 100 điểm cho mỗi event OrderPaid. Message E1 (đơn 1001, offset 42 trên P0) được xử lý như sau:
- Consumer poll, nhận
E1ở offset 42. - Consumer cộng 100 điểm cho khách trong DB và commit transaction DB. Khách có 100 điểm.
- Trước khi kịp commit offset 43 lên Kafka, pod bị Kubernetes kill vì deploy phiên bản mới (hoặc xử lý quá
max.poll.interval.msvà bị coi là chết). - Rebalance: P0 được giao cho pod khác. Committed offset của P0 vẫn là 42.
- Pod mới đọc từ offset 42, nhận lại
E1, cộng thêm 100 điểm. Khách có 200 điểm cho một đơn.
Giải thích: không có bước nào "lỗi" theo nghĩa kỹ thuật; đây là hành vi đúng thiết kế của at-least-once: thà xử lý lại còn hơn làm mất. Nếu đảo thứ tự thành commit offset trước rồi mới xử lý (at-most-once), pod chết ở bước 3 sẽ khiến E1 bị bỏ qua và khách mất điểm. Vì không có thứ tự nào tránh được cả hai, cách chữa là làm bước 2 idempotent: ghi eventId vào bảng có khóa chính trong cùng transaction với việc cộng điểm, như code dưới đây. Các nguồn gây trùng khác cần kể được: producer gửi lại khi không nhận được ack (nếu tắt idempotent producer), outbox relay gửi lại, và người vận hành reset offset để replay.
Thực tế gần như mọi hệ thống chọn at-least-once cộng consumer idempotent, và gọi kết quả là "effectively once". Consumer idempotent nghĩa là xử lý một message hai lần cho kết quả như một lần. Cách phổ biến: bảng processed_message có khóa chính là message_id, ghi trong cùng transaction với thay đổi nghiệp vụ. Đoạn code dưới đây xử lý event "thanh toán thành công" và phải an toàn khi Kafka giao lại cùng một event lần thứ hai.
@Component
public class PaymentEventConsumer {
private final ProcessedMessageRepository processedRepo;
private final OrderService orderService;
public PaymentEventConsumer(ProcessedMessageRepository processedRepo, OrderService orderService) {
this.processedRepo = processedRepo;
this.orderService = orderService;
}
@Transactional // "mark processed" and the order update commit or roll back together
@KafkaListener(topics = "payment-events", groupId = "order-service")
public void onMessage(PaymentEvent event) {
// INSERT ... ON CONFLICT DO NOTHING, returns the number of inserted rows
if (processedRepo.markProcessed(event.eventId()) == 0) {
return; // eventId already in the table: this is a redelivery, skip it
}
orderService.markPaid(event.orderId()); // runs exactly once per eventId
}
}Giải thích: lần đầu event E1 đến, câu INSERT chèn được một dòng (trả về 1), đơn được chuyển sang PAID, và cả hai thay đổi commit cùng lúc. Giả sử consumer chết ngay sau khi commit DB nhưng trước khi commit offset: sau rebalance, Kafka giao lại E1. Lần này INSERT đụng khóa chính message_id đã tồn tại nên trả về 0, method return sớm, đơn không bị xử lý hai lần. Điểm then chốt là cùng một transaction: nếu markPaid ném lỗi thì dòng trong processed_message cũng rollback, lần giao lại sẽ được xử lý bình thường thay vì bị bỏ qua oan. Nếu ghi bảng processed_message ở transaction riêng, bạn có thể rơi vào tình huống "đã đánh dấu xử lý nhưng nghiệp vụ chưa chạy" và mất message.
Nếu nghiệp vụ tự nhiên đã idempotent (set trạng thái PAID, upsert theo khóa nghiệp vụ, unique constraint trên payment_ref) thì dùng luôn, không cần bảng riêng.
Với message lỗi, phân biệt hai loại: lỗi tạm thời (timeout, DB bận) thì retry có backoff; lỗi vĩnh viễn (dữ liệu sai format, vi phạm nghiệp vụ) thì retry vô ích và phải đẩy sang dead letter queue để người xem xét và phát lại. Trong Kafka, một message lỗi mà retry vô hạn sẽ chặn cả partition phía sau nó (poison message). Cấu hình Spring Kafka dưới đây hiện thực đúng cách phân loại đó cho mọi @KafkaListener: lỗi tạm thời được retry có giới hạn, hết lượt hoặc gặp lỗi vĩnh viễn thì chuyển sang DLT.
@Configuration
public class KafkaErrorConfig {
@Bean
public DefaultErrorHandler errorHandler(KafkaTemplate<Object, Object> template) {
var recoverer = new DeadLetterPublishingRecoverer(template); // publish to <topic>.DLT
var backOff = new ExponentialBackOff(1_000L, 2.0); // wait 1s, 2s, 4s, 8s...
backOff.setMaxElapsedTime(30_000L); // stop after about 30s of total retrying
var handler = new DefaultErrorHandler(recoverer, backOff);
handler.addNotRetryableExceptions(IllegalArgumentException.class); // bad data: straight to DLT
return handler;
}
}Giải thích: khi listener ném exception, DefaultErrorHandler không bỏ qua message mà seek lại offset đó và gọi listener lại sau 1 giây, rồi 2, 4, 8, 16 giây. Khi tổng thời gian chờ vượt khoảng 30 giây, nó gọi recoverer: message được gửi sang topic payment-events.DLT (mặc định cùng số partition với bản gốc, nên topic DLT cần có ít nhất bấy nhiêu partition), sau đó offset được commit và partition chạy tiếp. Với IllegalArgumentException (được coi là lỗi vĩnh viễn), bước retry bị bỏ qua và message vào DLT ngay lần lỗi đầu tiên. Cần nhớ đây là blocking retry: trong 30 giây đó mọi message phía sau trong cùng partition phải chờ, và tổng thời gian retry phải nhỏ hơn max.poll.interval.ms (mặc định 5 phút), nếu không consumer bị coi là chết và bị rebalance.
8.11 Dual write và Transactional Outbox
Bài toán: service lưu đơn hàng vào DB rồi gửi event OrderCreated lên Kafka. Hai thao tác trên hai hệ thống khác nhau, không có transaction chung. Nếu commit DB xong mà gửi Kafka lỗi, đơn tồn tại nhưng không ai biết. Nếu gửi Kafka trước rồi DB rollback, các service khác xử lý một đơn không tồn tại. Đó là dual write problem.
Transactional Outbox giải quyết bằng cách ghi event vào bảng outbox trong cùng transaction với dữ liệu nghiệp vụ. Một tiến trình relay (job polling, hoặc CDC như Debezium đọc WAL/binlog) đọc outbox và publish lên broker, thành công thì đánh dấu đã gửi. Bảng outbox tối thiểu trên PostgreSQL trông như sau:
CREATE TABLE outbox (
id UUID PRIMARY KEY, -- doubles as eventId for consumer dedup
aggregate_type VARCHAR(50) NOT NULL, -- e.g. 'Order'
aggregate_id VARCHAR(50) NOT NULL, -- e.g. order id, used as the Kafka key
event_type VARCHAR(100) NOT NULL, -- e.g. 'OrderCreated'
payload JSONB NOT NULL, -- serialized event body
created_at TIMESTAMP NOT NULL DEFAULT now(),
published_at TIMESTAMP -- NULL = not yet sent to the broker
);Giải thích: khi tạo đơn, service chạy hai câu INSERT (vào orders và vào outbox với published_at là NULL) trong cùng một transaction, nên chỉ có hai khả năng: cả hai cùng tồn tại hoặc cả hai cùng không. Relay định kỳ lấy các dòng published_at IS NULL theo thứ tự created_at, gửi lên Kafka, gửi thành công thì cập nhật published_at. Nếu chạy relay trên nhiều instance, thêm FOR UPDATE SKIP LOCKED khi lấy dòng để hai instance không gửi cùng một event, và nên có index cho điều kiện lọc cùng job dọn các dòng đã gửi lâu ngày để bảng không phình mãi.
Relay có thể gửi một event hai lần (gửi xong nhưng chưa kịp đánh dấu thì chết), nên outbox cho bạn at-least-once, và consumer vẫn phải idempotent. Dùng aggregate_id làm key Kafka để giữ thứ tự sự kiện của cùng một đơn.
Luồng outbox từng bước
Nhắc lại kiến thức
SELECT ... FOR UPDATE SKIP LOCKED khóa các dòng vừa đọc và bỏ qua các dòng đang bị transaction khác khóa, nên nhiều worker lấy việc không giẫm lên nhau (xem cuối phần "Optimistic vs pessimistic lock", mục 6.3).
- User đặt đơn. Order service mở transaction DB.
INSERT INTO orders ...(đơn 1001, trạng tháiCREATED).INSERT INTO outbox ...(ide-77,event_type = 'OrderCreated',aggregate_id = '1001',published_at = NULL).- COMMIT. Từ lúc này đơn và event cùng tồn tại; nếu bước 2 hoặc 3 lỗi thì cả hai cùng rollback. API trả về cho user, không chờ Kafka.
- Relay (một job chạy mỗi 500ms) đọc các dòng chưa gửi, gửi
e-77lên topicorder-eventsvới key1001, chờ ack của Kafka. - Nhận ack thì
UPDATE outbox SET published_at = now() WHERE id = 'e-77'. - Consumer ở inventory service nhận
e-77, dùnge-77làm khóa chống trùng, giữ tồn kho.
Câu SQL relay dùng ở bước 5 và 6:
BEGIN;
SELECT id, aggregate_id, event_type, payload
FROM outbox
WHERE published_at IS NULL
ORDER BY created_at
LIMIT 100
FOR UPDATE SKIP LOCKED; -- other relay instances skip these rows
-- ... send each row to Kafka and wait for the ack ...
UPDATE outbox SET published_at = now() WHERE id = ANY(:sentIds);
COMMIT;Giải thích: relay lấy tối đa 100 event cũ nhất chưa gửi và khóa chúng; instance relay thứ hai chạy cùng lúc sẽ bỏ qua 100 dòng này và lấy các dòng khác (hoặc không lấy gì). Gửi xong thì đánh dấu và commit. Các điểm có thể hỏng và hệ quả:
- Kafka sập ở bước 5: không có ack, relay không đánh dấu, lần chạy sau gửi lại. Event trễ chứ không mất.
- Relay chết sau khi Kafka đã ack nhưng trước COMMIT ở bước 6:
published_atvẫn NULL, lần saue-77được gửi lần hai. Đây là lý do consumer ở bước 7 phải chống trùng theoid. - Nếu nhiều relay chia nhau các dòng của cùng một đơn, thứ tự có thể đảo; hệ thống cần thứ tự chặt thường chạy một relay active hoặc chia theo
aggregate_id.
Bẫy thường gặp
Gửi Kafka bên trong method @Transactional rồi cho rằng "lỗi thì rollback cả hai" là sai. Transaction của DB không bao được Kafka. Tệ hơn, message có thể đã được gửi đi trong khi DB sau đó rollback.
8.12 Câu hỏi phỏng vấn thường gặp
Câu hỏi (Thực chiến): Bạn triển khai cache cho API chi tiết sản phẩm như thế nào? Khi cập nhật sản phẩm thì xử lý cache ra sao?
Tôi dùng cache-aside với Spring Cache và Redis: @Cacheable theo productId, TTL khoảng 10 phút cộng jitter. Khi cập nhật, tôi xóa key sau khi transaction commit, không ghi giá trị mới vào cache. Xóa thay vì cập nhật để tránh race hai lần ghi đảo thứ tự; xóa sau commit để request đọc chen vào không nạp lại dữ liệu cũ; TTL là lưới an toàn cho race còn sót.
Bẫy thường gặp
Trả lời "cập nhật DB rồi cập nhật cache luôn cho nhanh" mà không nhận ra race condition là dấu hiệu chưa từng xử lý cache lệch trên production.
Câu hỏi đào sâu
Nếu có 3 service cùng ghi vào bảng sản phẩm thì sao? Lúc đó xóa cache trong code một service là không đủ; nên xóa cache dựa trên CDC từ binlog/WAL hoặc dựa trên event chung mà mọi bên ghi đều phát ra.
Câu hỏi (Hiểu sâu): Cache breakdown và cache avalanche khác nhau thế nào?
Breakdown là một key rất nóng hết hạn khiến hàng loạt request đồng thời cùng miss và cùng truy vấn DB. Avalanche là rất nhiều key hết hạn cùng lúc hoặc cả cụm Redis sập, DB nhận toàn bộ tải.
- Chống breakdown: khóa theo key để chỉ một request nạp lại (
sync = truetrong một JVM, lock phân tán nếu nhiều instance), hoặc refresh bất đồng bộ trước khi hết hạn. - Chống avalanche: TTL cộng jitter, Redis HA, cache hai tầng Caffeine + Redis, rate limit hoặc circuit breaker trước DB.
Câu hỏi đào sâu
@Cacheable(sync = true) có chống được stampede khi chạy 10 pod không? Chỉ một phần: mỗi pod vẫn có một request đi xuống DB, tức 10 request thay vì hàng nghìn. Thường như vậy là đủ.
Câu hỏi (Cơ bản): Redis hết bộ nhớ thì chuyện gì xảy ra?
Phụ thuộc maxmemory-policy. Mặc định noeviction khiến lệnh ghi trả lỗi OOM. Redis làm cache thuần thì tôi đặt allkeys-lru hoặc allkeys-lfu; nếu còn chứa session hay khóa thì dùng volatile-lru hoặc tách instance riêng.
Bẫy thường gặp
Nói "Redis tự xóa key cũ khi đầy" mà không biết mặc định là noeviction.
Câu hỏi (Hiểu sâu): Kafka đảm bảo thứ tự message như thế nào?
Kafka chỉ đảm bảo thứ tự trong một partition. Muốn các sự kiện của cùng một đơn hàng được xử lý đúng thứ tự, tôi dùng orderId làm key để chúng vào cùng partition. Phía consumer, mỗi partition chỉ do một consumer trong group đọc tuần tự.
- Không cần thứ tự toàn cục; thứ tự theo thực thể là đủ và vẫn scale được.
- Nếu consumer tự xử lý song song bằng thread pool bên trong thì chính bạn phá vỡ thứ tự.
Câu hỏi đào sâu
Producer retry có làm đảo thứ tự không? Với idempotent producer (mặc định từ Kafka 3.0) thì thứ tự được giữ kể cả khi max.in.flight.requests.per.connection tới 5.
Câu hỏi (Hiểu sâu): Làm sao để không mất message và không xử lý trùng?
Không mất: producer acks=all với replication factor 3 và min.insync.replicas=2; consumer chỉ commit offset sau khi xử lý xong (Spring Kafka mặc định đã tắt auto commit của client; với AckMode mặc định là BATCH, offset được commit sau khi listener xử lý xong toàn bộ các record của một lần poll). Không xử lý trùng: chấp nhận rằng trùng sẽ xảy ra (rebalance, consumer chết trước khi commit, relay gửi lại) và làm consumer idempotent bằng bảng processed_message hoặc unique key nghiệp vụ.
Bẫy thường gặp
Nói "Kafka có exactly-once nên không lo trùng". Exactly-once của Kafka chỉ áp dụng cho luồng đọc từ Kafka và ghi lại vào Kafka; khi consumer ghi vào PostgreSQL hoặc gọi API thanh toán, bạn vẫn phải tự làm idempotent.
Câu hỏi (Thực chiến): Consumer gặp message lỗi thì bạn xử lý thế nào?
Tôi phân loại lỗi. Lỗi tạm thời thì retry với exponential backoff có giới hạn. Lỗi vĩnh viễn (deserialize lỗi, dữ liệu vi phạm nghiệp vụ) thì không retry mà đẩy thẳng sang DLQ. Message vào DLQ phải có đủ thông tin (topic gốc, offset, stacktrace trong header), có alert, và có công cụ để phát lại sau khi sửa lỗi.
Câu hỏi đào sâu
Retry chặn (blocking retry) giữ message ở đầu partition, làm trễ mọi message phía sau. Nếu cần retry lâu, dùng retry topic không chặn (@RetryableTopic trong Spring Kafka), đổi lại thứ tự không còn được đảm bảo.
Câu hỏi (Hiểu sâu): Lưu đơn hàng rồi gửi event, làm sao đảm bảo hai việc cùng thành công?
Đây là dual write. Tôi dùng Transactional Outbox: ghi đơn hàng và bản ghi outbox trong cùng một transaction DB, rồi relay publish lên Kafka. Như vậy nếu DB commit thì event chắc chắn sẽ được gửi (ít nhất một lần), nếu DB rollback thì không có event nào.
Câu hỏi đào sâu
Polling outbox hay CDC? Polling đơn giản, không cần hạ tầng thêm nhưng tăng tải DB và có độ trễ. CDC (Debezium) độ trễ thấp, không query DB nhưng phải vận hành Kafka Connect.
Câu hỏi (Cơ bản): Khi nào bạn chọn RabbitMQ thay vì Kafka?
Khi bài toán là task queue (email, xử lý ảnh, job nền), cần định tuyến linh hoạt theo routing key, cần priority hoặc TTL từng message và không cần đọc lại lịch sử. Chọn Kafka khi dữ liệu là dòng sự kiện nhiều hệ thống cùng đọc, cần thông lượng cao hoặc replay.
Bẫy thường gặp
Trả lời "Kafka nhanh hơn nên luôn dùng Kafka" mà không nói đến chi phí vận hành và mô hình tiêu thụ khác nhau.
Câu hỏi (Hiểu sâu): Bạn dùng Redis làm distributed lock như thế nào, rủi ro là gì?
Lấy khóa bằng SET key <uuid> NX PX <ttl>, giải phóng bằng Lua script kiểm tra đúng uuid. Rủi ro: chạy quá TTL thì hai nơi cùng giữ khóa, failover có thể mất khóa. Nên khóa Redis chỉ giảm tranh chấp, tính đúng đắn cuối cùng dựa vào ràng buộc trong DB.
Câu hỏi đào sâu
Fencing token là gì? Mỗi lần cấp khóa kèm một số tăng dần; tài nguyên đích từ chối thao tác mang token nhỏ hơn token đã thấy, nên client giữ khóa đã hết hạn không ghi đè được.
Câu hỏi (Cơ bản): Cache hit, cache miss, TTL và eviction là gì? Cho ví dụ số.
Hit là tìm thấy trong cache, miss là không thấy phải xuống nguồn gốc; TTL là hạn dùng của một entry; eviction là loại bỏ entry khi cache đầy. Ví dụ: API chi tiết sản phẩm nhận 1.000 request/phút, 950 request hit Redis (khoảng 1ms), 50 request miss và phải query DB (khoảng 30ms) rồi ghi lại vào Redis. Hit ratio 95%. Mỗi entry có TTL 10 phút, hết hạn thì lần đọc sau lại miss. Khi Redis chạm maxmemory với allkeys-lru, key lâu nhất không ai đọc bị loại để lấy chỗ.
- TTL giới hạn thời gian dữ liệu cũ có thể tồn tại; eviction giới hạn bộ nhớ. Hai cơ chế độc lập: key còn hạn vẫn có thể bị evict, key hết hạn thì bị xóa dù bộ nhớ còn trống.
Câu hỏi (Cơ bản): Local cache và Redis khác nhau thế nào? Khi nào dùng cả hai?
Local cache nhanh nhất nhưng mỗi pod một bản; Redis chậm hơn một round-trip mạng nhưng mọi pod dùng chung một bản. Ví dụ 5 pod cùng cache danh mục trong Caffeine: admin sửa danh mục, pod xử lý request sửa xóa được cache của mình, 4 pod còn lại vẫn trả bản cũ tới hết TTL. Với Redis, xóa một key là mọi pod thấy.
- Cache hai tầng (Caffeine TTL 30 giây phía trước Redis TTL 10 phút) hợp với key cực nóng: phần lớn request không tốn cả round-trip Redis, và khi Redis sập thì vẫn còn bản trong JVM. Đổi lại phải chấp nhận lệch tối đa 30 giây giữa các pod, hoặc phát event qua Redis pub/sub để các pod tự xóa local cache.
Bẫy thường gặp
Cache object lớn trong Heap mà không giới hạn maximumSize sẽ làm Heap đầy, GC chạy liên tục, rồi OutOfMemoryError. Local cache luôn phải có giới hạn kích thước.
Câu hỏi (Cơ bản): Cache penetration là gì và chống thế nào?
Penetration là truy vấn liên tục những key không tồn tại, cache không bao giờ hit nên mọi request đều xuống DB. Ví dụ bot quét GET /products/{id} với id ngẫu nhiên không có thật, 2.000 request/giây đều đi thẳng xuống DB.
- Validate input để chặn id vô lý từ controller.
- Cache kết quả rỗng với TTL ngắn (30–60 giây).
- Bloom filter chứa các id tồn tại, trả 404 ngay cho id "chắc chắn không có".
- Rate limit theo IP ở gateway.
Câu hỏi đào sâu
Cache giá trị rỗng có nhược điểm gì? Bot dùng id ngẫu nhiên khác nhau mỗi lần thì mỗi id chỉ hit một lần, cache đầy rác. Lúc đó Bloom filter và rate limit hiệu quả hơn. Ngoài ra khi sản phẩm id đó được tạo thật, phải xóa entry rỗng, nếu không người dùng thấy 404 tới hết TTL.
Câu hỏi (Cơ bản): Message queue giúp gì? Cho một ví dụ cụ thể trong dự án.
Queue tách người gửi và người xử lý: API trả về nhanh, tác vụ chậm chạy nền, và đỉnh tải được hứng bởi broker thay vì DB. Ví dụ đặt hàng: API lưu đơn rồi publish OrderCreated; email service, loyalty service, analytics service mỗi bên một consumer group đọc cùng event. Thêm service mới chỉ cần thêm group mới, không sửa order service. Ngày sale, 50.000 đơn trong 10 phút được xếp trong topic, consumer email gửi dần theo tốc độ SMTP cho phép.
Bẫy thường gặp
Dùng queue cho thao tác mà user cần kết quả ngay (ví dụ kiểm tra số dư trước khi chuyển tiền). Queue làm luồng đó phức tạp mà không có lợi gì; gọi đồng bộ là đúng.
Câu hỏi (Cơ bản): Topic, partition, offset là gì?
Topic là kênh sự kiện, partition là các làn log song song của topic, offset là số thứ tự của message trong một partition. Ví dụ topic order-events có 3 partition; message thứ 1.001 của P1 có offset 1000. Offset chỉ có nghĩa trong một partition: P0 và P2 đều có offset 1000 nhưng là hai message khác nhau. Consumer group lưu "đã xử lý tới offset nào" riêng cho từng partition.
Câu hỏi (Cơ bản): Topic có 3 partition. Group có 2 consumer thì chia thế nào? Thêm lên 4 consumer thì sao? Hai group khác nhau thì sao?
Mỗi partition chỉ thuộc một consumer trong group; 2 consumer thì một người 2 partition, người kia 1; 4 consumer thì một người ngồi chơi. Cụ thể với RangeAssignor: 2 consumer là C1 (P0, P1), C2 (P2); 3 consumer là mỗi người một partition; 4 consumer thì C4 không được giao partition nào. Mỗi lần thêm hoặc bớt consumer, group rebalance để chia lại. Hai group khác nhau thì độc lập: mỗi group đều nhận toàn bộ message, mỗi group có committed offset riêng.
Câu hỏi đào sâu
Muốn tăng tốc xử lý khi đã có 3 consumer cho 3 partition thì làm gì? Tăng số partition (chấp nhận thay đổi ánh xạ key, xem TRAP ở mục 8.8), hoặc tối ưu thời gian xử lý mỗi message (batch ghi DB, bỏ lời gọi đồng bộ chậm). Thêm consumer thứ 4 không giúp gì.
Câu hỏi (Cơ bản): At-most-once, at-least-once và exactly-once khác nhau thế nào?
Khác nhau ở thứ tự "commit offset" và "xử lý": commit trước thì có thể mất, xử lý trước thì có thể trùng. At-most-once: commit offset rồi xử lý, pod chết giữa chừng thì message bị bỏ qua. At-least-once: xử lý rồi mới commit, pod chết trước khi commit thì message được giao lại. Exactly-once của Kafka chỉ áp dụng khi cả đầu vào lẫn đầu ra đều là Kafka (transaction của Kafka gom việc ghi output và commit offset). Khi xử lý ghi vào DB hay gọi API, thực tế là at-least-once cộng consumer idempotent.
Câu hỏi (Hiểu sâu): Vì sao xóa cache trước khi commit lại nguy hiểm? Hãy trình bày theo thời gian.
Vì một request đọc chen vào giữa lúc xóa và lúc commit sẽ đọc được giá trị cũ đã commit và nạp nó ngược lại vào cache. Theo thời gian: t1 W cập nhật giá 150 chưa commit; t2 W xóa key; t3 R miss, đọc DB thấy 100 (bản đã commit); t4 R ghi 100 vào cache; t5 W commit. DB 150, cache 100 tới hết TTL. Cách đúng: commit rồi mới xóa (@TransactionalEventListener(AFTER_COMMIT) hoặc transactionAware() của RedisCacheManager), cộng TTL.
Câu hỏi đào sâu
Xóa sau commit có hết race không? Không. Vẫn còn race R đọc DB trước commit nhưng ghi cache sau khi W đã xóa (xem bảng ở mục 8.4). Xác suất nhỏ; delayed double delete và TTL xử lý phần còn lại.
Câu hỏi (Hiểu sâu): So sánh cache-aside, write-through và write-behind. Bạn chọn khi nào?
Cache-aside cho hầu hết dữ liệu đọc nhiều; write-through khi cần đọc ngay sau ghi thấy giá trị mới và có thư viện hỗ trợ; write-behind chỉ cho dữ liệu chấp nhận mất như bộ đếm.
| Tiêu chí | Cache-aside | Write-through | Write-behind |
|---|---|---|---|
| Ai ghi DB | App | Lớp cache, đồng bộ | Lớp cache, bất đồng bộ |
| Tốc độ ghi | Bình thường | Chậm hơn | Rất nhanh |
| Rủi ro | Cửa sổ lệch ngắn | Ghi nhiều dữ liệu không ai đọc | Mất dữ liệu chưa flush |
| Ví dụ | Chi tiết sản phẩm | Cấu hình tenant | Lượt xem bài viết |
Câu hỏi (Hiểu sâu): Vì sao INCR trên Redis an toàn khi nhiều request đồng thời, còn GET rồi SET từ Java thì không?
Vì một lệnh Redis chạy trọn vẹn trên thread thực thi lệnh, không có lệnh nào chen vào giữa, còn GET + SET là hai lệnh tách rời. Ví dụ counter đang 10, hai request cùng GET được 10, cùng tính 11, cùng SET 11: mất một lượt tăng. INCR gộp đọc, tăng, ghi thành một bước nguyên tử nên kết quả là 12. Khi logic phức tạp hơn một lệnh (kiểm tra rồi trừ, kiểm tra chủ khóa rồi xóa) thì gói vào Lua script để được cùng bảo đảm.
Bẫy thường gặp
Nói "Redis đơn luồng nên mọi thứ tự động an toàn". Chỉ từng lệnh (hoặc từng script) là nguyên tử; chuỗi lệnh gửi từ Java vẫn có thể bị client khác chen vào giữa. MULTI/EXEC gom lệnh nhưng không cho đọc giá trị giữa chừng để ra quyết định.
Câu hỏi (Hiểu sâu): Rebalance là gì, vì sao nó gây xử lý trùng và làm consumer lag tăng vọt?
Rebalance là lúc group chia lại partition cho các consumer; trong lúc đó tiêu thụ tạm dừng, và các message đã xử lý nhưng chưa commit offset sẽ bị giao lại. Nguyên nhân thường gặp: deploy (pod cũ tắt, pod mới vào), pod chết, hoặc xử lý một lô message lâu hơn max.poll.interval.ms nên bị coi là chết. Nếu cứ deploy là rebalance nhiều lần liên tiếp, lag tăng và message trùng nhiều.
- Giảm rebalance: dùng cooperative sticky assignor (chỉ di chuyển partition cần di chuyển), static membership (
group.instance.id) để pod khởi động lại trong thời gian ngắn không kích hoạt rebalance, giảmmax.poll.recordsnếu mỗi message xử lý lâu.
Câu hỏi (Hiểu sâu): Nối kiến thức: đặt @Cacheable và @Transactional(readOnly = true) trên cùng method, rồi gọi method đó từ một method khác cùng class. Điều gì xảy ra?
Cả hai annotation đều không có tác dụng, vì lời gọi nội bộ this.method() không đi qua proxy. Cache luôn miss (không có gì được lưu), method chạy trong transaction của method gọi nó (nếu có) chứ không phải transaction read-only riêng. Đây cùng một cơ chế với bẫy self-invocation của @Transactional ở mục 4.5. Cách sửa: tách method cache sang một bean khác, hoặc tiêm chính proxy của bean vào.
Câu hỏi đào sâu
Khi cả hai annotation cùng áp dụng qua proxy, cái nào chạy trước? Phụ thuộc thứ tự advisor (@EnableCaching(order = ...), @EnableTransactionManagement(order = ...)). Nếu cache interceptor bọc ngoài, cache hit sẽ không mở transaction nào, là điều ta muốn.
Câu hỏi (Hiểu sâu): Nối kiến thức: outbox đã đảm bảo "DB commit thì event được gửi", vì sao consumer vẫn phải idempotent? Nó giống gì với Idempotency-Key của REST?
Vì outbox chỉ bảo đảm at-least-once: relay có thể gửi lại một event đã gửi thành công nhưng chưa kịp đánh dấu. Consumer chống trùng bằng cách lưu eventId (chính là outbox.id) vào bảng có khóa chính, trong cùng transaction với thay đổi nghiệp vụ. Đây là cùng ý tưởng với Idempotency-Key ở mục 7.4: phía gửi gắn một id duy nhất cho mỗi ý định, phía nhận dùng unique constraint để lần thứ hai thành no-op. Khác biệt: REST cần trả lại response cũ cho client, còn consumer chỉ cần bỏ qua.
Câu hỏi (Hiểu sâu): Thiết kế nhỏ: rate limit 100 request/phút cho mỗi user bằng Redis.
Cách đơn giản nhất là fixed window: INCR một key theo user và phút hiện tại, lần đầu thì đặt TTL, vượt 100 thì trả 429.
key = rl:{userId}:{epochMinute} e.g. rl:42:28512345
count = INCR key -> 1, 2, 3, ...
if count == 1: EXPIRE key 60 (first request of this minute)
if count > 100: reply 429 Too Many RequestsINCRnguyên tử nên 200 request đồng thời đếm đúng 200. Nên góiINCRvàEXPIREtrong Lua script để không bị kẹt key không có TTL nếu app chết giữa hai lệnh.- Nhược điểm fixed window: user gửi 100 request lúc 10:00:59 và 100 request lúc 10:01:00, tức 200 request trong 2 giây. Cần mượt hơn thì dùng sliding window bằng Sorted Set (
ZADDtimestamp,ZREMRANGEBYSCOREbỏ phần quá 60 giây,ZCARDđể đếm) hoặc token bucket.
Câu hỏi (Thực chiến): 10:00 mở sale, key product:hot hết hạn, DB lên 100% CPU và API timeout hàng loạt. Bạn xử lý thế nào?
Đây là cache stampede: chữa cháy bằng cách nạp lại key bằng tay và chặn bớt traffic, rồi sửa gốc bằng cơ chế chỉ một request nạp lại.
- Chữa cháy: nạp lại key bằng tay (hoặc qua endpoint admin), bật rate limit hoặc circuit breaker trước DB để DB hồi lại; kill các query trùng lặp đang chạy.
- Xác nhận: xem metric cache miss cùng lúc với số query giống nhau trên DB (slow query log,
pg_stat_activity). - Sửa gốc:
@Cacheable(sync = true)hoặc khóaSET NXtheo key để một request nạp; refresh key nóng trước khi hết hạn; warm-up cache trước giờ sale; TTL có jitter.
Câu hỏi đào sâu
Khóa SET NX để nạp lại, các request không lấy được khóa làm gì? Chờ ngắn rồi đọc lại cache (spin vài lần, mỗi lần 50ms), hoặc trả giá trị cũ nếu còn giữ bản "stale" (một key phụ TTL dài hơn). Không nên để chúng rơi xuống DB.
Câu hỏi (Thực chiến): Consumer lag của topic payment-events tăng từ 0 lên 200.000 trong 30 phút. Bạn điều tra thế nào?
Xác định lag tăng đều mọi partition (consumer chậm chung) hay dồn vào một partition (message lỗi hoặc key lệch), rồi xử lý theo nguyên nhân.
kafka-consumer-groups.sh --describexem lag theo partition. Lag chỉ ở một partition vàCURRENT-OFFSETđứng yên: consumer kẹt ở một poison message đang retry vô hạn. Đẩy nó sang DLT và cấu hình lại error handler.- Lag đều ở mọi partition: consumer chậm. Xem thời gian xử lý mỗi message (DB chậm, gọi API đối tác chậm, lock), CPU, số lần rebalance trong log. Rebalance liên tục là dấu hiệu xử lý vượt
max.poll.interval.ms. - Lag dồn vào một partition nhưng offset vẫn chạy: một key quá nóng (ví dụ mọi event của một merchant lớn cùng key), cần đổi thiết kế key.
- Giải pháp ngắn hạn: tăng consumer nếu còn partition trống, tối ưu xử lý (batch insert, bỏ gọi đồng bộ). Dài hạn: tăng partition, alert khi lag vượt ngưỡng.
Câu hỏi (Thực chiến): Khách phản ánh bị cộng điểm thưởng hai lần cho một đơn. Bạn điều tra và sửa thế nào?
Gần như chắc chắn là message bị giao lại và consumer không idempotent; tìm bằng chứng trong log rồi thêm chống trùng theo eventId.
- Tra log theo
orderId: thấy cùngeventIdđược xử lý hai lần, cách nhau vài giây, ở hai pod khác nhau, trùng thời điểm deploy hoặc rebalance. - Sửa: bảng
processed_message(message_id PK)ghi trong cùng transaction với việc cộng điểm, hoặc unique constraint(order_id, reason)trên bảng lịch sử điểm. - Dọn dữ liệu: truy vấn tìm các đơn bị cộng hai lần, trừ lại có ghi chú, báo CSKH.
- Phòng ngừa: test tích hợp gửi cùng một message hai lần và kiểm tra kết quả chỉ một lần.
Bẫy thường gặp
Sửa bằng "kiểm tra đã có bản ghi điểm cho đơn này chưa rồi mới insert". Hai lần giao lại xử lý song song (hai pod trong lúc rebalance) vẫn cùng thấy "chưa có". Phải dựa vào unique constraint.
Câu hỏi (Thực chiến): Admin sửa giá sản phẩm nhưng trang web vẫn hiện giá cũ khoảng 10 phút. Bạn điều tra thế nào?
10 phút trùng TTL cache nên gần như chắc chắn key không bị xóa khi sửa, hoặc bị nạp lại giá cũ ngay sau khi xóa. Các nguyên nhân tôi kiểm tra lần lượt:
- Luồng sửa giá không đi qua method có
@CacheEvict(sửa qua batch job, service khác ghi thẳng DB, hoặc gọi nội bộthis.update()nên không qua proxy). - Key evict không khớp key cache (cache theo
#idkiểuLong, evict bằng#cmd.id()kiểuString). - Xóa trước commit, request đọc chen vào nạp lại giá cũ (xem bảng thời gian mục 8.4).
- Có thêm local cache hoặc CDN phía trước mà không ai xóa.
Kiểm tra nhanh: TTL product:<id> ngay sau khi sửa. Nếu TTL gần 600 nghĩa là key vừa được nạp lại (race), nếu TTL đang giảm dần từ lâu nghĩa là không có evict.
Câu hỏi (Thực chiến): Redis cluster sập hoàn toàn 5 phút. Hệ thống của bạn nên phản ứng thế nào?
Nên suy giảm chức năng (chậm hơn, một số tính năng tắt) chứ không sập theo; muốn vậy phải thiết kế từ trước.
- Timeout Redis ngắn (khoảng 50–100ms) để thread không bị treo chờ.
- Circuit breaker quanh Redis: lỗi nhiều thì bỏ qua cache luôn, không đợi timeout từng request.
- Không cho toàn bộ traffic đổ xuống DB: rate limit hoặc bulkhead cho đường đọc DB, local cache giữ bản gần nhất cho dữ liệu nóng.
- Các chức năng phụ thuộc Redis như khóa, rate limit, session: quyết định trước là fail-open (cho qua) hay fail-closed (từ chối). Rate limit thường fail-open, khóa chống trừ tiền trùng thì fail-closed hoặc dựa vào ràng buộc DB.
Câu hỏi (Đọc code): Đoạn code cập nhật sản phẩm dưới đây có lỗi gì về cache?
@Service
public class ProductService {
// ... fields and constructor omitted
@Transactional
public void updatePrice(Long id, BigDecimal price) {
redis.delete("product:" + id); // evict first
Product p = repo.findById(id).orElseThrow();
p.setPrice(price);
auditClient.send(id, price); // HTTP call, ~300ms
} // commit happens here
}Đáp án:
Cache bị xóa trước commit, và giữa lúc xóa với lúc commit còn một lời gọi HTTP khoảng 300ms, làm cửa sổ race rất rộng.
- Trong 300ms đó, mọi request đọc sản phẩm đều miss, đọc DB thấy giá cũ (chưa commit) và ghi giá cũ vào Redis. Sau commit, DB có giá mới nhưng Redis giữ giá cũ tới hết TTL.
- Lỗi phụ: gọi HTTP bên trong transaction giữ connection DB và row lock lâu (xem "Transaction dài và gọi API bên ngoài" ở mục 4.5).
- Sửa: bỏ lệnh xóa ở đầu method, xóa cache trong
@TransactionalEventListener(phase = AFTER_COMMIT); chuyển lời gọi audit ra sau commit hoặc qua outbox.
Câu hỏi (Đọc code): Consumer dưới đây chống trùng như thế nào và vẫn còn lỗi gì?
@Component
public class PointConsumer {
// ... fields and constructor omitted
@KafkaListener(topics = "order-paid", groupId = "loyalty")
public void on(OrderPaid e) {
if (processedRepo.existsById(e.eventId())) {
return; // already handled
}
pointService.addPoints(e.userId(), 100); // @Transactional inside
processedRepo.save(new Processed(e.eventId())); // separate transaction
}
}Đáp án:
Có hai lỗi: kiểm tra rồi mới ghi (check-then-act) và ghi "đã xử lý" ở transaction khác với cộng điểm.
- Race: trong lúc rebalance, hai pod có thể cùng nhận
E1, cùngexistsByIdthấy false, cùng cộng điểm. - Không nguyên tử:
addPointscommit xong, pod chết trướcsave, lần giao lại thấy chưa xử lý và cộng lần nữa. Ngược lại nếu đảo thứ tự,savethành công màaddPointslỗi thì message bị bỏ qua oan. - Sửa: đặt
@Transactionalở listener, dùngINSERT ... ON CONFLICT DO NOTHING(hoặc bắt lỗi trùng khóa chính) làm bước đầu tiên, rồi cộng điểm trong cùng transaction, như mẫu ở mục 8.10.
Câu hỏi (Đọc code): Giải phóng khóa Redis như dưới đây sai ở đâu?
@Service
public class ReportJob {
// ... fields and constructor omitted
public void release(String lockKey, String myId) {
String owner = redis.opsForValue().get(lockKey); // step 1
if (myId.equals(owner)) {
redis.delete(lockKey); // step 2
}
}
}Đáp án:
Kiểm tra chủ và xóa là hai lệnh riêng, nên giữa bước 1 và bước 2 khóa có thể hết hạn và bị pod khác lấy; bước 2 xóa nhầm khóa của pod đó.
- Theo thời gian: A
GETthấy khóa của mình; khóa hết TTL; BSET NXthành công; ADELxóa khóa của B; C lại lấy được khóa, giờ B và C cùng chạy. - Sửa: dùng Lua script "GET, so sánh, DEL" như mục 8.6 (Redis chạy trọn script không xen lệnh khác), hoặc dùng
RLock.unlock()của Redisson vốn đã làm việc đó.
Câu hỏi (Đọc code): Method đặt hàng dưới đây gửi event như thế nào, và sai ở đâu?
@Service
public class OrderService {
// ... fields and constructor omitted
@Transactional
public Long place(PlaceOrderCmd cmd) {
Order order = orderRepo.save(Order.from(cmd));
kafka.send("order-events", order.getId().toString(), OrderCreated.of(order));
inventoryRepo.reserve(cmd.sku(), cmd.qty()); // may throw OutOfStockException
return order.getId();
}
}Đáp án:
Đây là dual write: event được gửi lên Kafka trong khi transaction DB chưa commit và vẫn có thể rollback.
- Nếu
reservenémOutOfStockException, transaction rollback, đơn không tồn tại, nhưngOrderCreatedđã nằm trên Kafka; các service khác xử lý một đơn ma. - Ngược lại, nếu Kafka lỗi mạng mà
sendkhông được kiểm tra kết quả (nó bất đồng bộ), DB commit mà event mất. - Sửa: ghi event vào bảng
outboxtrong cùng transaction, relay gửi sau (mục 8.11). Nếu tạm chấp nhận rủi ro mất event, ít nhất gửi trong@TransactionalEventListener(AFTER_COMMIT)để không bao giờ gửi event cho đơn đã rollback.
Câu hỏi (Đọc code): Consumer dưới đây xử lý song song cho nhanh. Nó gây ra vấn đề gì?
@Component
public class ShipmentConsumer {
private final ExecutorService pool = Executors.newFixedThreadPool(16);
// ... other fields and constructor omitted
@KafkaListener(topics = "order-events", groupId = "shipment")
public void on(OrderEvent e) {
pool.submit(() -> shipmentService.handle(e)); // returns immediately
}
}Đáp án:
Listener trả về ngay nên offset được commit trước khi xử lý xong, và 16 thread phá thứ tự các event cùng một đơn.
- Mất message: offset đã commit, pod chết khi task còn trong hàng đợi của
poolthì các event đó không bao giờ được giao lại (thực chất thành at-most-once). - Sai thứ tự:
OrderCreatedvàOrderCancelledcủa cùng đơn có thể chạy trên hai thread khác nhau,Cancelledxử lý trướcCreated. - Lỗi bị nuốt: exception trong task không tới error handler của Spring Kafka nên không retry, không vào DLT.
- Sửa: xử lý đồng bộ trong listener và tăng song song bằng số partition cộng
concurrencycủa container; nếu bắt buộc song song trong một partition, chia thread theo hash của key và tự quản lý commit offset.
8.13 Tóm tắt trọng tâm
- Cache là bản sao gần và nhanh của dữ liệu gốc; chỉ cache dữ liệu đọc nhiều, chấp nhận cũ, và truy vấn gốc tốn kém, vì mọi cache đều đổi tính nhất quán lấy tốc độ.
- Luôn đặt TTL, vì mọi cơ chế xóa cache bằng code đều có lúc sót (race, service khác ghi DB, gọi nội bộ không qua proxy).
- Cache-aside là mặc định: đọc miss thì nạp từ DB, ghi thì cập nhật DB rồi xóa cache, vì xóa không bị lỗi đảo thứ tự như cập nhật.
- Xóa cache sau commit, vì xóa trước commit cho phép request đọc chen vào nạp lại giá trị cũ.
@Cacheablehoạt động qua proxy nên dính self-invocation như@Transactional;sync = truechỉ chống stampede trong một JVM.- Penetration chống bằng validate, cache rỗng TTL ngắn, Bloom filter; breakdown chống bằng chỉ cho một request nạp lại; avalanche chống bằng TTL có jitter, Redis HA và bảo vệ DB.
- Mỗi lệnh Redis là nguyên tử, còn chuỗi lệnh thì không; logic "kiểm tra rồi ghi" phải gói trong Lua script.
- Khóa Redis có thể hết hạn khi chủ còn đang chạy, nên với nghiệp vụ tiền bạc, chốt đúng đắn cuối cùng là ràng buộc DB hoặc fencing token.
- Kafka giữ thứ tự trong một partition; dùng key theo thực thể (
orderId) để các event của cùng thực thể vào cùng partition. - Mỗi partition chỉ thuộc một consumer trong group, nên số partition là trần song song; consumer thừa ngồi chơi.
- Theo dõi consumer lag theo từng partition: lag dồn một chỗ thường là poison message hoặc key nóng, lag đều là consumer chậm.
- Thực tế là at-least-once, vì không thứ tự nào giữa "xử lý" và "commit offset" tránh được cả mất lẫn trùng; consumer phải idempotent bằng unique constraint trong cùng transaction.
- Retry chỉ cho lỗi tạm thời và có giới hạn; lỗi vĩnh viễn vào DLQ ngay để không chặn cả partition.
- Không thể gửi Kafka "trong" transaction DB; Transactional Outbox ghi event cùng transaction rồi relay gửi, đổi lại event có thể gửi trùng.