Backend

99 Ngày Spring — Ngày 37: Redis

SSite Admin
2 tháng 09, 2026 8 phút đọc 1 lượt xem
99 Ngày Spring — Ngày 37: Redis

Ngày 36 ta đã làm cache chạy được với @Cacheable và Caffeine — nhanh, nhưng cache nằm trong tiến trình: restart là trắng, và chạy ba instance thì có ba cache lệch nhau. Hôm nay ta dời nó ra ngoài với Redis — một kho key-value trên RAM, dùng chung cho mọi instance, sống sót qua deploy. Điều đẹp nhất: nhờ lớp trừu tượng của Ngày 36, đổi Caffeine sang Redis gần như chỉ là vài dòng cấu hình.

Sketchnote Ngày 37: Redis với Spring Boot — cache dùng chung thay Caffeine trong tiến trình, cấu hình spring.cache.type redis và TTL, serializer JSON thay JDK, RedisTemplate cho counter và rate limit, session store và bốn cái bẫy

Vì sao đổi Caffeine lấy Redis

Caffeine của Ngày 36 không hề sai — nó vẫn là cache nhanh nhất có thể vì dữ liệu nằm ngay trong heap. Vấn đề chỉ xuất hiện khi ứng dụng nhân bản: mỗi instance một bản cache riêng, không ai biết ai vừa xóa gì, và mỗi lần deploy là toàn bộ cache bốc hơi cùng lúc.

# Ngày 36 cache nằm TRONG tiến trình (Caffeine): restart là mất sạch,
# và mỗi instance giữ một bản riêng — 3 pod là 3 cache lệch nhau.
# Redis chuyển cache ra NGOÀI: một kho dùng chung, sống sót qua restart.

# docker-compose.yml
services:
  redis:
    image: redis:7-alpine
    ports: ["6379:6379"]

# build.gradle / pom.xml
implementation 'org.springframework.boot:spring-boot-starter-data-redis'

# application.yml
spring:
  data:
    redis:
      host: localhost
      port: 6379
  cache:
    type: redis            # @Cacheable của Ngày 36 GIỜ ĐI THẲNG VÀO REDIS
    redis:
      time-to-live: 10m    # TTL mặc định cho mọi cache
      cache-null-values: false
  • Dùng chung: ba instance nhìn cùng một kho, nên xóa cache ở instance này thì instance kia thấy ngay — hết cảnh dữ liệu cũ lởn vởn ở một pod.

  • Sống sót restart: deploy phiên bản mới không kéo theo một cơn bão truy vấn xuống database vì cache rỗng.

  • Đổi bằng cấu hình: đúng lời hứa của cache abstraction — code @Cacheable viết hôm qua không phải sửa một chữ.

  • Cái giá phải trả: thêm một thành phần phải vận hành, và mỗi lần đọc cache giờ là một vòng qua mạng chứ không còn là một lần tra bộ nhớ.

Serializer — đổi JDK sang JSON ngay từ đầu

Đây là quyết định nhỏ mà nhiều đội trả giá muộn. Mặc định Spring cất giá trị bằng JDK serialization: nhị phân, không đọc được, và ràng chặt vào chữ ký class — thêm một field là mọi entry cũ hỏng.

// Mặc định Spring dùng JDK serialization — giá trị trong Redis thành chuỗi nhị
// phân không ai đọc được, và đổi một field trong class là hỏng toàn bộ cache cũ.
// Gần như luôn nên đổi sang JSON:
@Bean
RedisCacheConfiguration cacheConfiguration() {
    return RedisCacheConfiguration.defaultCacheConfig()
        .entryTtl(Duration.ofMinutes(10))
        .disableCachingNullValues()
        .serializeValuesWith(SerializationPair.fromSerializer(
                new GenericJackson2JsonRedisSerializer()));
}

// Vì sao JSON đáng đổi:
//   - đọc được bằng redis-cli, debug production không phải đoán
//   - thêm field mới KHÔNG làm nổ cache cũ (JDK serialization thì có)
//   - ngôn ngữ khác cũng đọc được cùng kho cache

// Vẫn giữ nguyên tắc Ngày 36: cache DTO/record BẤT BIẾN, đừng cache entity JPA
// (entity kéo theo proxy lazy — serialize ra là gặp LazyInitializationException).
  • Đổi sang GenericJackson2JsonRedisSerializer để giá trị đọc được bằng redis-cli — debug production không còn là trò đoán mò.

  • Vẫn giữ nguyên kỷ luật của Ngày 36: cache record/DTO bất biến, tuyệt đối không cache entity JPA.

  • Đặt TTL ngay trong cấu hình, và tắt cache giá trị null nếu bạn không cố ý dùng nó để chặn cache penetration.

RedisTemplate — Redis không chỉ để cache

Cache chỉ là một cách dùng Redis. Bản chất nó là kho cấu trúc dữ liệu trên RAM với các thao tác nguyên tử — thứ giải quyết gọn ghẽ mấy bài toán mà làm bằng database thì vừa chậm vừa rắc rối.

// @Cacheable lo phần cache. Còn khi cần Redis như một CẤU TRÚC DỮ LIỆU
// (đếm, khóa, hàng đợi, xếp hạng) thì dùng thẳng template:
private final StringRedisTemplate redis;

// Đếm lượt xem — nguyên tử, không cần transaction:
redis.opsForValue().increment("post:" + id + ":views");

// Rate limit đơn giản: 100 request mỗi phút cho một IP
String key = "rate:" + ip;
Long dem = redis.opsForValue().increment(key);
if (dem == 1) redis.expire(key, Duration.ofMinutes(1));   // đặt hạn ở lần đầu
if (dem > 100) throw new TooManyRequestsException();

// Các họ thao tác hay dùng:
//   opsForValue()  chuỗi & số     — cache, đếm, cờ
//   opsForHash()   bảng băm       — lưu object theo field, sửa từng field
//   opsForList()   danh sách      — hàng đợi đơn giản
//   opsForSet()    tập hợp        — khử trùng, "ai đang online"
//   opsForZSet()   tập có điểm    — bảng xếp hạng, top-K

// Quy ước ĐẶT TÊN KEY quan trọng hơn ta tưởng: "sanpham:42:chitiet".
// Redis là một không gian key PHẲNG dùng chung — không có tên miền thì
// hai service ghi đè nhau trong im lặng.
  • incrementnguyên tử: đếm lượt xem hay đếm request không cần khóa, không cần transaction, và không mất số khi nhiều luồng cùng tăng.

  • Chọn đúng họ thao tác đáng giá: opsForZSet cho bảng xếp hạng, opsForHash khi chỉ muốn sửa một field thay vì ghi đè cả object.

  • Quy ước tên key là thứ phải chốt sớm — Redis chỉ có một không gian key phẳng dùng chung cho cả hệ thống.

Session store và bốn cái bẫy

Công dụng thứ hai hay gặp nhất là session dùng chung. Đây là chỗ Redis tỏa sáng nhất: chỉ thêm một dependency và một dòng cấu hình, ứng dụng đang chạy một máy bỗng chạy được nhiều máy mà không đá ai ra ngoài.

// Session store: bài toán kinh điển khi chạy nhiều instance.
// Session mặc định nằm trong RAM của từng server  user đăng nhập  pod A,
// request kế tiếp rơi vào pod B  mất session. Redis giải quyết trong 2 bước:
implementation 'org.springframework.session:spring-session-data-redis'

spring:
  session:
    store-type: redis
    timeout: 30m
// Xong. Không sửa một dòng controller nào  session đi ra Redis, mọi
// instance đọc chung,  deploy phiên bản mới không đá ai ra khỏi hệ thống.

// BỐN CÁI BẪY của Redis, gặp sớm hơn ta tưởng:
// 1. Redis chết thì sao? Mặc định lỗi cache LAN RA thành lỗi request.
//    Khai báo CacheErrorHandler để bỏ qua lỗi cache  đi thẳng xuống DB.
// 2. Mọi thứ giờ đi qua MẠNG: Caffeine ~100 nanô giây, Redis ~1 mili giây.
//    Vẫn nhanh hơn DB nhiều, nhưng vòng lặp gọi cache 1000 lần thì thấy ngay.
// 3. TTL  BẮT BUỘC, không phải tùy chọn  key không hạn dùng sẽ  lại
//    mãi mãi  ăn dần RAM cho tới khi Redis bắt đầu đuổi key bừa bãi.
// 4. Cache hai tầng nếu cần: Caffeine (L1, trong tiến trình) + Redis (L2,
//    dùng chung)  nhanh nhất, đổi lại L1 của các instance  thể lệch nhau.
  • Bẫy đau nhất là bẫy số một: Redis chết kéo cả ứng dụng chết theo. Cache là thứ để tăng tốc, hỏng thì phải rơi về database chứ không được biến thành lỗi 500 — hãy khai báo CacheErrorHandler ngay từ đầu.

  • Nhớ đổi đơn vị trong đầu: Caffeine tính bằng nanô giây, Redis tính bằng mili giây. Vẫn rẻ hơn DB rất nhiều, nhưng gọi trong vòng lặp thì vẫn đắt.

  • Key không TTL là rò rỉ bộ nhớ chạy chậm — đặt hạn dùng cho mọi key, kể cả key bạn nghĩ là tạm thời.

  • Cần cực nhanh thì làm hai tầng: Caffeine ở trong, Redis ở ngoài — nhưng phải chấp nhận L1 giữa các instance có thể lệch nhau trong vài giây.

Bài tập nhỏ

  • Bật Redis bằng docker compose, đổi spring.cache.type từ caffeine sang redis và xác nhận @Cacheable của Ngày 36 chạy y nguyên, không sửa code.

  • Mở redis-cli chạy KEYS *TTL <key> để nhìn tận mắt cache của mình — rồi đổi serializer sang JSON và xem giá trị từ nhị phân thành đọc được.

  • Chạy hai instance trên hai cổng khác nhau, sửa dữ liệu ở instance A và xác nhận instance B không còn trả bản cũ.

  • Viết rate limit 10 request mỗi phút bằng increment + expire, rồi bắn 15 request để thấy ngưỡng chặn.

  • Tắt hẳn container Redis trong lúc ứng dụng đang chạy: quan sát request lỗi, rồi thêm CacheErrorHandler và xác nhận ứng dụng rơi êm về database.

Kết lại

Bốn ý gói lại hôm nay: Redis biến cache từ riêng từng tiến trình thành dùng chung và sống sót restart, và nhờ lớp trừu tượng Ngày 36 thì đổi sang nó gần như chỉ là cấu hình; đổi serializer sang JSON ngay từ đầu để cache đọc được và không vỡ khi class đổi; RedisTemplate mở ra cả họ bài toán ngoài cache — counter, rate limit, xếp hạng — nhờ thao tác nguyên tử; và session store chỉ tốn một dependency nhưng phải nhớ TTL cho mọi key cùng đường lui khi Redis chết. Ngày 38 ta rời chuyện tốc độ để xử lý một bài toán cấu hình thật: nhiều datasource trong một ứng dụng — hai database, hai transaction manager, và @Primary. Hẹn gặp lại!

S

Site Admin

Engineer and writer. Building things with TypeScript and distributed systems.

Bình luận (0)

Bạn cần đăng nhập bằng Google để bình luận.

Hãy là người bình luận đầu tiên.

Bài viết liên quan

99 Ngày Java — Ngày 37: Iterator & duyệt collection

for-each chỉ là đường cú pháp của Iterator: hợp đồng Iterable một method, cơ chế fail-fast với modCount sinh ra ConcurrentModificationException — và hai cách xóa đúng với removeIf và it.remove().

2 thg 9, 20267 phút0
99 Ngày Java — Ngày 36: Queue, Deque & Stack

FIFO xếp hàng và LIFO chồng đĩa, hai bộ method offer/poll/peek, ArrayDeque mảng vòng một mình hai vai (và bản án cho class Stack) — cùng PriorityQueue trên binary heap với bài toán top-K.

1 thg 9, 20269 phút6
99 Ngày Spring — Ngày 36: Caching với @Cacheable

Tốc độ lớn nhất là không truy vấn: cache abstraction với @Cacheable, key SpEL và hai luật sống còn, bộ đôi @CacheEvict/@CachePut giữ cache thật, TTL với Caffeine — và bốn cái bẫy caching.

1 thg 9, 202610 phút4