Bài 35 đo cache trong bộ nhớ. Bài này đo cái giá và cái lợi của việc đưa nó ra ngoài.

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á. 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.

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.

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.

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. 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. Đâ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ả. Đâ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.

Thử 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.

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.