Bài 35 đo cache trong bộ nhớ — như cuốn sổ tay riêng trên bàn mỗi người, với tới là đọc được ngay. Bài này đo cái giá và cái lợi của việc đưa nó ra một tấm bảng chung ngoài hành lang: bạn phải đi ra đó một chuyến, nhưng đổi lại cả ba container đều đọc cùng một thứ.
Cache qua Redis
spring:
data:
redis:
host: localhost
port: 6379
cache:
type: redis
redis:
time-to-live: 60s
Mã không đổi một dòng — vẫn @Cacheable như bài 35.
lần 1: 117,0 ms | lần 2: 2,09 ms | chạy thật = 1
khoá trong Redis: [sp::a]
TTL còn lại: 60 giây
Cache hoạt động, khoá được đặt tên sp::a theo quy ước tênCache::khoá, và TTL được áp dụng.
Cái giá: 2,09 so với 0,31
Bài 35 đo cache trong bộ nhớ trúng sau 0,31 ms. Redis trúng sau 2,09 ms — chậm hơn gần bảy lần.
Khác biệt đó là một lượt đi về mạng cộng việc tuần tự hoá — chính là quãng đường đi ra hành lang và quay lại bàn. Nó không lớn theo giá trị tuyệt đối, nhưng nó có thật, và nó đổi lại ba thứ:
Chung cho mọi bản sao. Ba container, một cache. Bài 35 chỉ ra cái bẫy của cache trong bộ nhớ: xoá cache ở một container không xoá ở hai cái kia, và người dùng thấy dữ liệu cũ tuỳ vào việc họ rơi vào container nào. Ba cuốn sổ riêng thì mỗi cuốn ghi một kiểu; tấm bảng chung thì chỉ có một bản.
Sống qua khởi động lại. Triển khai phiên bản mới không làm cache trống rỗng — mà cache trống rỗng lúc vừa triển khai chính là lúc tải cao nhất. Tấm bảng hành lang vẫn còn đó qua đêm, không ai xoá khi bạn về.
Không chiếm heap của ứng dụng. Cache 2 GB nằm ngoài JVM, nên nó không ảnh hưởng tới GC — nó không chất đống trên mặt bàn của bạn.
Kết luận thực dụng: dữ liệu nhỏ, đọc rất nhiều, chấp nhận được sai lệch giữa các bản sao → Caffeine. Còn lại → Redis. Và với ứng dụng chỉ chạy một bản sao, Caffeine gần như luôn đúng.
Độ trễ mỗi thao tác
SET từng cái x1000: 326,8 ms (0,327 ms/thao tác)
pipeline x1000: 53,9 ms
0,327 ms một thao tác nghe rất nhanh. Nhưng nếu một request gọi Redis mười lần, đó là 3,3 ms — và bạn vừa thêm một phần đáng kể vào thời gian phản hồi để "làm cho nhanh hơn".
Pipeline gộp 1000 lệnh thành một lượt gửi: nhanh hơn sáu lần. Thay vì chạy ra hành lang một nghìn lần, bạn viết cả nghìn dòng trong một chuyến. Cùng nguyên tắc với gộp lô ở bài 32 — chi phí nằm ở lượt đi về, không ở công việc.
redisTemplate.executePipelined((RedisCallback<Object>) c -> {
for (var x : danhSach) c.stringCommands().set(khoa(x), giaTri(x));
return null;
});
Nguyên tắc chung: đếm số lượt đi về, đừng đếm số thao tác.
Khoá phân tán
Boolean duoc = redis.opsForValue()
.setIfAbsent("khoa:viec", "toi", Duration.ofSeconds(10));
5 luồng cùng xin khoá -> 1 luồng lấy được
SETNX là nguyên tử, nên đúng một người thắng — như viết "tôi đang dùng phòng X" lên bảng, chỉ một người kịp ghi. Đây là cách chạy một công việc định kỳ khi có nhiều bản sao ứng dụng — không có nó, ba container cùng gửi báo cáo hằng ngày ba lần.
Thời gian sống là bắt buộc. Không có nó, một tiến trình chết trong lúc giữ khoá sẽ khoá vĩnh viễn.
Và ba giới hạn phải biết:
Khoá có thể hết hạn khi việc chưa xong. Bạn đặt 10 giây, việc chạy 12 giây — người thứ hai vào lúc giây thứ 10 và hai người cùng chạy. Đặt hạn dài hơn nhiều thời gian tối đa dự kiến, hoặc gia hạn định kỳ.
Xoá khoá phải kiểm chủ sở hữu. Xoá bằng DEL đơn thuần có thể xoá khoá của người khác nếu khoá của bạn đã hết hạn. Dùng script Lua so giá trị rồi mới xoá.
Redis một nút không đảm bảo được gì khi có sự cố. Redisson cài đặt sẵn thuật toán Redlock và xử lý cả gia hạn lẫn kiểm chủ sở hữu — dùng nó thay vì tự viết, trừ khi bài toán chịu được việc thỉnh thoảng chạy hai lần.
Và câu hỏi nên đặt trước: có thật sự cần khoá phân tán không? Một cột trạng thái trong CSDL với UPDATE ... WHERE có điều kiện (bài 28) thường đủ, và nó không thêm dịch vụ nào để hỏng.
Bộ đếm nguyên tử
1000 luồng tăng -> giá trị = 1000
redis.opsForValue().increment("dem:luot");
INCR là nguyên tử ở phía Redis, nên không cần khoá gì cả — một vạch đếm trên bảng mà ai cũng cộng thêm được mà không giẫm lên nhau. Đây là công cụ đúng cho đếm lượt xem, giới hạn tần suất, đếm số lần thử đăng nhập sai.
Giới hạn tần suất bằng cửa sổ trượt, đủ tốt cho phần lớn trường hợp:
Long n = redis.opsForValue().increment("gh:" + ip);
if (n == 1) redis.expire("gh:" + ip, Duration.ofMinutes(1));
if (n > 60) throw new QuaNhieuYeuCau();
Và nhớ bài học ở CLAUDE.md của blog này: đừng chặn cứng theo IP. Văn phòng, trường học và mạng di động dùng chung một IP; luôn dùng dạng đếm lượt trong cửa sổ như trên.
Session chung
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
Một phụ thuộc, và session HttpSession chuyển sang Redis. Người dùng đăng nhập ở container A vẫn đăng nhập khi request tiếp theo rơi vào container B, và triển khai phiên bản mới không đá ai ra.
Đây là lý do phổ biến nhất để đưa Redis vào một ứng dụng Spring, và nó đáng hơn cache.
Lựa chọn khác là token JWT không trạng thái — không cần Redis, nhưng bạn mất khả năng thu hồi phiên ngay lập tức. Chọn theo việc bạn cần "đăng xuất mọi thiết bị" tới mức nào.
Bốn thứ cần cấu hình
Tuần tự hoá. Mặc định của RedisTemplate là Java serialization — bài 59 sê-ri Java đã cho thấy nó vừa chậm vừa nguy hiểm, và dữ liệu trong Redis không đọc được bằng mắt. Đổi sang JSON:
template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
Phép chờ. Cùng bài học với bài 20: Redis treo mà không có phép chờ là ứng dụng của bạn treo theo.
Hết hạn cho mọi khoá. Redis không tự dọn. Khoá không có TTL sống mãi tới khi hết bộ nhớ.
Chính sách khi đầy bộ nhớ. maxmemory-policy allkeys-lru cho cache; mặc định noeviction sẽ từ chối mọi lệnh ghi khi đầy — đúng cho hàng đợi, sai cho cache.
Redis hỏng thì sao
Câu hỏi phải trả lời trước khi đưa nó vào: ứng dụng còn chạy được không khi Redis chết?
Với cache, câu trả lời nên là có — cache lỗi thì đi thẳng xuống nguồn dữ liệu:
@Bean CacheErrorHandler xuLyLoiCache() {
return new SimpleCacheErrorHandler() {
@Override public void handleCacheGetError(RuntimeException e, Cache c, Object k) {
log.warn("cache loi, di thang xuong nguon", e); // KHÔNG ném lại
}
};
}
Không có nó, Redis chết là mọi endpoint có @Cacheable cùng chết — và bạn vừa biến một tầng tăng tốc thành một điểm hỏng đơn lẻ.
Với session thì không tránh được: Redis chết là mọi người bị đăng xuất. Đó là lý do session Redis cần bản sao dự phòng, còn cache thì không.
Nếu chỉ soi một thứ sau bài này, soi xem tấm bảng của bạn đang chứa gì và có khoá nào sống mãi không, trong ba mươi giây:
redis-cli --scan --pattern '*' | head -20
redis-cli --scan --pattern '*' | wc -l
redis-cli info memory | grep used_memory_human
Rồi kiểm TTL của vài khoá:
redis-cli ttl <khoa>
Trả về -1 nghĩa là khoá đó không bao giờ hết hạn. Nhiều khoá như vậy là bộ nhớ Redis của bạn chỉ có một chiều đi lên.
Mẫu số chung
Khoảnh khắc bạn chạy hơn một bản sao, mọi trạng thái giữ bên trong một tiến trình — cache cục bộ, khoá trong tiến trình, session dính — đều nhanh nhưng cô lập: nó lệch nhau giữa các nút, bay mất khi khởi động lại, và biến "bạn rơi vào bản sao nào" thành một câu hỏi về tính đúng. Dời trạng thái đó ra một kho chung với tới qua mạng (Redis, memcached, một CSDL, ZooKeeper/etcd cho khoá) tốn một lượt đi về nhưng khiến mọi nút đồng ý với nhau và sống qua khởi động lại — đúng cái đánh đổi đằng sau thiết kế ứng dụng không trạng thái (Twelve-Factor: tiến trình không ôm trạng thái, gửi nó ra một dịch vụ chung để bản sao nào cũng phục vụ được request nào). Và một phụ thuộc chung là một kiểu hỏng mới: quyết trước xem ứng dụng có sống nổi khi nó chết không — cache nên rơi thẳng xuống nguồn, còn session thì thành phụ thuộc cứng cần bản sao dự phòng.
Điều thứ hai, ngắn mà đắt: với bất cứ thứ gì đi qua mạng, độ trễ bị chi phối bởi số lượt đi về, không phải việc làm trong mỗi lượt — nên hãy gộp lô: pipeline của Redis, multi-get, gộp lô JDBC ở bài 32, keep-alive của HTTP, xử lý N+1. 0,327 ms một lượt nghe như miễn phí cho tới khi một request gọi mười lần. Bản năng cần có: đếm lượt đi về, đừng đếm thao tác — và đừng bao giờ thêm một tầng "cho nhanh hơn" mà bản thân nó lại đi một vòng mạng cho mỗi phần tử.
Ngày mai: chốt chặng dữ liệu — mười sáu bài, và bản đồ quyết định rút ra từ chúng.