Welcome to MikeDaDev's Blog – notes on Java, backend & system design.

Explore
MikeDaDev's Blog
in java

redis pattern

Tung Bui Thanh (TungDaDev)14 mins read

Redis pattern.webp

Trong các hệ thống phân tán, microservices, Redis hay bị gán cho đúng một vai: làm cache. Nhưng đào sâu xuống tầng application và infrastructure thì Redis giống con dao đa năng hơn nhiều. Từ distributed lock, quản lý state cho tới chặn mấy "cơn bão" request đánh sập database, dùng Redis đúng pattern hay không chính là khác biệt giữa một hệ thống "chạy được" và một hệ thống chịu tải ngon lành.

Bài này gom lại các design pattern, chiến lược caching và mấy "cú ngã" hay gặp khi làm việc với Redis trong Java/Spring Boot.

# caching strategies

Cache và Database (Source of Truth) đồng bộ với nhau theo cách nào sẽ quyết định dữ liệu có nhất quán hay không.

# cache-aside (lazy loading)

Đây là pattern kinh điển và an toàn nhất. Ứng dụng tự lo việc đọc/ghi cache. Cache chỉ chứa dữ liệu mà user thật sự hỏi tới (lazy), nên đỡ tốn memory.

Lưu ý: khi dữ liệu thay đổi, nguyên tắc là xoá cache (invalidate) chứ đừng cập nhật lại cache. Cập nhật cache ngay lúc đó dễ dính race condition khi chạy multi-thread.

// Application quản lý cache manually
@Service
@RequiredArgsConstructor
public class ProductService {
   private final ProductRepository repo;
   private final RedisTemplate<String, Product> redis;
   private static final Duration TTL = Duration.ofMinutes(30);
 
   public Product getById(UUID id) {
       String key = "product:" + id;
       Product cached = redis.opsForValue().get(key);
       if (cached != null) return cached;
 
       Product product = repo.findById(id).orElseThrow();
       redis.opsForValue().set(key, product, TTL);
       return product;
   }
 
   public void update(UUID id, ProductUpdateDTO dto) {
       Product product = repo.findById(id).orElseThrow();
       product.apply(dto);
       repo.save(product);
       redis.delete("product:" + id); // invalidate
   }
}

Xoá cache cũng chưa kín hoàn toàn. Thử nghĩ: thread A đọc miss, lấy bản cũ từ DB; thread B update DB rồi xoá cache; xong A mới set bản cũ vào cache, và bản cũ nằm đó tới hết TTL. Cửa sổ này hẹp nên đa số hệ thống chấp nhận, TTL chính là lưới an toàn cuối cùng. Chỗ nào cần chặt hơn thì dùng "delayed double delete" (xoá thêm lần nữa sau vài trăm ms) hoặc xoá cache dựa trên CDC đọc từ binlog/WAL của DB. Ngoài ra nên xoá cache sau khi transaction commit: gọi delete bên trong @Transactional thì một request khác có thể đọc DB (chưa commit, vẫn bản cũ) và nạp lại cache ngay.

# write-through

Hợp với hệ thống read-heavy, muốn cache luôn có sẵn bản mới nhất ngay sau khi ghi. Ứng dụng ghi DB xong thì ghi luôn vào cache. Lưu ý Redis và DB không nằm chung một transaction: ghi DB xong mà ghi Redis lỗi thì cache vẫn giữ bản cũ, còn ghi Redis trước khi DB commit thì lỡ rollback là cache chứa dữ liệu chưa từng tồn tại. Vì vậy vẫn phải đặt TTL, và nên ghi cache sau khi commit.

// Write to cache và DB cùng lúc
public Product save(Product product) {
   Product saved = repo.save(product);
   redis.opsForValue().set("product:" + saved.getId(), saved, TTL);
   return saved;
}

# write-behind (async)

Dành cho hệ thống write-heavy. Request ghi thẳng vào Redis để trả lời user thật nhanh, sau đó bắn event đi (ví dụ qua Event Bus/Message Broker) để hệ thống lưu xuống Database theo kiểu bất đồng bộ.

// Write to cache immediately, persist to DB async
public void updateScore(UUID userId, int score) {
   redis.opsForValue().set("score:" + userId, score);
   publisher.publishEvent(new ScorePersistEvent(userId, score)); // async persist later
}

Đánh đổi của write-behind là độ bền: dữ liệu chỉ nằm trong Redis cho tới khi được ghi xuống DB, Redis mất dữ liệu (restart không bật AOF, failover) hay event bị rơi là mất luôn. publishEvent của Spring còn là event trong memory và chạy đồng bộ nếu listener không có @Async. Muốn chắc hơn thì đẩy vào hàng đợi bền (Kafka, Redis Streams) và cho consumer ghi DB có retry.

# spring cache annotations

Spring có sẵn bộ annotation giúp bớt hẳn boilerplate code. Có điều chúng chạy dựa trên AOP Proxy, nên nếu bạn gọi hàm nội bộ (internal method call) trong cùng một class thì annotation không có tác dụng.

# @Cacheable

Cache kết quả trả về. Dùng được SpEL (Spring Expression Language) để tạo key động và viết điều kiện cache phức tạp.

@Service
public class UserService {
 
   // Cache result — subsequent calls with same id skip method execution
   @Cacheable(value = "users", key = "#id")
   public User findById(UUID id) {
       return repo.findById(id).orElseThrow();
   }
 
   // Conditional caching — only cache non-null results
   @Cacheable(value = "users", key = "#email", unless = "#result == null")
   public User findByEmail(String email) {
       return repo.findByEmail(email).orElse(null);
   }
 
   // SpEL expression for complex keys
   // Key phải đủ các yếu tố ảnh hưởng kết quả: thiếu pageSize/sort là 2 request khác nhau dùng chung cache.
   // Lưu ý: PageImpl không có constructor mặc định, đọc lại bằng Jackson sẽ lỗi; nên cache một DTO/List riêng.
   @Cacheable(value = "search",
              key = "#criteria.keyword + ':' + #pageable.pageNumber + ':' + #pageable.pageSize + ':' + #pageable.sort")
   public Page<User> search(SearchCriteria criteria, Pageable pageable) {
       return repo.findAll(toSpec(criteria), pageable);
   }
}

# @CacheEvict

Xoá cache. Kết hợp với allEntries = true để xoá sạch cả cache khi cấu hình hệ thống thay đổi, khá tiện.

@CacheEvict(value = "users", key = "#id")
public void delete(UUID id) {
   repo.deleteById(id);
}
 
// Evict all entries in cache
@CacheEvict(value = "users", allEntries = true)
public void refreshAll() { }
 
// Evict before method execution
@CacheEvict(value = "users", key = "#user.id", beforeInvocation = true)
public void update(User user) {
   repo.save(user);
}

beforeInvocation = true xoá cache kể cả khi method ném exception, nhưng cũng mở ra cửa sổ: request khác đọc DB (bản cũ) và nạp lại cache trước khi save xong. Mặc định (false) xoá sau khi method chạy xong thì an toàn hơn cho trường hợp update.

# @CachePut

// Always execute method, update cache with result
@CachePut(value = "users", key = "#user.id")
public User save(User user) {
   return repo.save(user);
}

# @Caching (multiple operations)

Gom nhiều thao tác vào một chỗ (vừa update cache này, vừa xoá cache kia).

@Caching(
   put = @CachePut(value = "users", key = "#result.id"),
   evict = @CacheEvict(value = "userList", allEntries = true)
)
public User create(CreateUserDTO dto) {
   return repo.save(mapToEntity(dto));
}

# RedisTemplate configuration

Mặc định RedisTemplate của Spring Data Redis dùng serialization của JDK (StringRedisTemplate thì dùng String). Với microservices thì đây là thảm hoạ: dữ liệu lưu dạng byte, nhìn bằng mắt không đọc được, service viết bằng ngôn ngữ khác thì chịu không đọc nổi, còn giữa các service Java chỉ cần class đổi field hay khác serialVersionUID là InvalidClassException.

Nên làm: luôn cấu hình GenericJackson2JsonRedisSerializer.

@Configuration
public class RedisConfig {
 
   @Bean
   public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
       RedisTemplate<String, Object> template = new RedisTemplate<>();
       template.setConnectionFactory(factory);
 
       // Key chuẩn hóa bằng String để tối ưu indexing trong Redis
       template.setKeySerializer(new StringRedisSerializer());
 
       // Value chuẩn hóa bằng JSON (Human-readable, cross-language)
       ObjectMapper mapper = new ObjectMapper();
       mapper.registerModule(new JavaTimeModule());
       // Chỉ cho phép deserialize các class mình tin. Validator mặc định của ObjectMapper
       // cho mọi class đi qua, ai ghi được vào Redis là có thể nhét gadget class vào "@class".
       PolymorphicTypeValidator ptv = BasicPolymorphicTypeValidator.builder()
           .allowIfSubType("com.example.")
           .allowIfSubType("java.util.")
           .allowIfSubType("java.time.")
           .build();
       // NON_FINAL không gắn "@class" cho class final (kể cả record) → đọc ra LinkedHashMap.
       // Cache record thì dùng Jackson2JsonRedisSerializer<>(mapper, MyRecord.class) cho cache đó.
       mapper.activateDefaultTyping(ptv, ObjectMapper.DefaultTyping.NON_FINAL);
 
       GenericJackson2JsonRedisSerializer jsonSerializer =
           new GenericJackson2JsonRedisSerializer(mapper);
 
       template.setValueSerializer(jsonSerializer);
       template.afterPropertiesSet();
 
       return template;
   }
}

# ttl strategies

// Per-cache TTL via CacheManager
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory factory) {
   RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig()
       .entryTtl(Duration.ofMinutes(10))
       .serializeValuesWith(SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()));
 
   Map<String, RedisCacheConfiguration> cacheConfigs = Map.of(
       "users", defaultConfig.entryTtl(Duration.ofHours(1)),
       "sessions", defaultConfig.entryTtl(Duration.ofMinutes(30)),
       "lookups", defaultConfig.entryTtl(Duration.ofHours(24))
   );
 
   return RedisCacheManager.builder(factory)
       .cacheDefaults(defaultConfig)
       .withInitialCacheConfigurations(cacheConfigs)
       .build();
}

# distributed lock

Khi hệ thống scale ngang (horizontal scaling) ra nhiều node, @Scheduled kiểu thường hay khoá thread (như synchronized) đều vô dụng. Muốn một task (ví dụ batch job chốt đơn cuối ngày) chỉ chạy trên đúng một instance thì phải dùng Distributed Lock.

# shedlock (scheduled tasks)

Shedlock là thư viện làm ra để giải đúng bài toán này, dùng khá gọn. Ngoài annotation trên method, cần @EnableSchedulerLock(defaultLockAtMostFor = "...") và một bean LockProvider (với Redis là RedisLockProvider từ module shedlock-provider-redis-spring).

// Prevent concurrent execution of scheduled tasks across instances
@Scheduled(cron = "0 0 * * * *")
@SchedulerLock(name = "hourlyReport", lockAtMostFor = "50m", lockAtLeastFor = "5m")
public void generateHourlyReport() {
   // Only ONE instance executes this across cluster
}

# manual distributed lock - lua script

Muốn lock an toàn thì bước kiểm tra lock và nhả lock phải là một thao tác nguyên tử (Atomic). Bắt buộc dùng Lua Script, để node A không lỡ tay nhả lock của node B.

@Component
@RequiredArgsConstructor
public class RedisLock {
   private final StringRedisTemplate redis;
 
   public boolean tryLock(String key, String owner, Duration ttl) {
       Boolean acquired = redis.opsForValue()
           .setIfAbsent("lock:" + key, owner, ttl);
       return Boolean.TRUE.equals(acquired);
   }
 
   public void unlock(String key, String owner) {
       // Lua script: atomic check-and-delete (only owner can unlock)
       String script = """
           if redis.call('get', KEYS[1]) == ARGV[1] then
               return redis.call('del', KEYS[1])
           else
               return 0
           end
           """;
       redis.execute(new DefaultRedisScript<>(script, Long.class),
           List.of("lock:" + key), owner);
   }
}
 
// Usage
public void processExclusive(String resourceId) {
   String owner = UUID.randomUUID().toString();
   if (!redisLock.tryLock(resourceId, owner, Duration.ofSeconds(30))) {
       throw new ConflictException("Resource locked");
   }
   try {
       doWork(resourceId);
   } finally {
       redisLock.unlock(resourceId, owner);
   }
}

Lock này có TTL 30 giây. Nếu doWork chạy lâu hơn (GC pause dài, DB chậm), lock tự hết hạn, node khác lấy được lock và hai node cùng làm. Script Lua chỉ chặn được chuyện nhả nhầm lock của người khác, không chặn được chuyện này. Hướng xử lý: gia hạn lock định kỳ khi còn đang làm (Redisson có sẵn "watchdog" làm việc này), và với tài nguyên quan trọng thì dùng fencing token (số tăng dần đi kèm lock, phía DB từ chối ghi có token cũ hơn). Bài "How to do distributed locking" của Martin Kleppmann phân tích kỹ chỗ này, kể cả vì sao Redlock cũng không giải quyết được.

# cache stampede prevention

Chuyện gì xảy ra khi một key quan trọng (ví dụ dữ liệu Flash Sale) hết TTL? Ngay mili-giây đó, cả ngàn request cùng "cache miss" và lao thẳng vào Database. Database quá tải, connection pool cạn, hệ thống sập.

# giải pháp: mutex lock

Chỉ cho đúng 1 thread xuống DB lấy dữ liệu và đổ lại vào cache. Mấy thread còn lại phải chờ hoặc retry.

// 1. Lock-based (only one thread refreshes)
public Product getWithLock(UUID id) throws InterruptedException {
   String key = "product:" + id;
   for (int attempt = 0; attempt < 20; attempt++) {  // giới hạn số lần thử, không đệ quy vô hạn
       Product cached = redis.opsForValue().get(key);
       if (cached != null) return cached;
 
       String owner = UUID.randomUUID().toString();  // mỗi lần lấy lock một owner riêng
       if (redisLock.tryLock(key, owner, Duration.ofSeconds(5))) {  // tryLock tự thêm tiền tố "lock:"
           try {
               // Double-check locking (Cực kỳ quan trọng)
               cached = redis.opsForValue().get(key);
               if (cached != null) return cached;
 
               Product product = repo.findById(id).orElseThrow();
               redis.opsForValue().set(key, product, Duration.ofMinutes(30));
               return product;
           } finally {
               redisLock.unlock(key, owner);
           }
       }
       // Others wait briefly then retry
       Thread.sleep(50);
   }
   return repo.findById(id).orElseThrow();  // chờ quá lâu thì đọc thẳng DB (hoặc trả lỗi 503)
}

Ba chi tiết trong đoạn trên rất hay bị viết sai. Dùng owner cố định (kiểu "refresh") thì node nào cũng nhả được lock của node khác, script Lua mất tác dụng. Thread.sleep ném InterruptedException (checked) nên method phải khai báo hoặc tự xử lý. Còn retry bằng đệ quy không giới hạn thì lúc lock holder chết, các thread chờ cứ đệ quy mãi tới tràn stack.

# giải pháp: probabilistic early expiration (logic refresh bất đồng bộ)

Gắn thêm một cờ "sắp hết hạn" vào value trong cache. Thread nào đọc thấy dữ liệu sắp hết hạn thì vẫn trả dữ liệu cũ cho user, đồng thời âm thầm chạy một tác vụ async để làm mới cache.

Chữ "probabilistic" đến từ thuật toán XFetch (Vattani và cộng sự, VLDB 2015): lưu kèm value thời điểm hết hạn expiry và thời gian tính lại delta, mỗi lần đọc thì refresh nếu now - delta * beta * ln(random()) >= expiry (với random() trong khoảng (0, 1], beta mặc định 1). Vì ln(random()) âm, càng gần hạn thì xác suất refresh càng cao, và tính lại càng lâu (delta lớn) thì refresh càng sớm. Mỗi request tự "tung xúc xắc" nên thường chỉ một vài request refresh trước hạn, không phải cả nghìn cái cùng lúc.

public Product getWithEarlyRefresh(UUID id) {
   String key = "product:" + id;
   CachedValue<Product> cached = redis.opsForValue().get(key);
   if (cached != null) {
       // Refresh proactively before actual expiry (random window)
       if (cached.shouldRefresh()) {
           CompletableFuture.runAsync(() -> refreshCache(id));
       }
       return cached.getValue();
   }
   return refreshCache(id);
}

# data structures

# hash (object fields)

// Store object as hash — partial updates without full serialization
HashOperations<String, String, String> hashOps = redis.opsForHash();
 
// Set fields
hashOps.put("user:123", "name", "John");
hashOps.put("user:123", "email", "john@example.com");
hashOps.putAll("user:123", Map.of("name", "John", "status", "active"));
 
// Get single field
String name = hashOps.get("user:123", "name");
 
// Increment numeric field
hashOps.increment("user:123", "loginCount", 1);

# sorted set (ranking/leaderboard)

ZSetOperations<String, String> zOps = redis.opsForZSet();
 
// Add with score
zOps.add("leaderboard", "player1", 1500.0);
zOps.add("leaderboard", "player2", 2100.0);
 
// Top 10
Set<String> top10 = zOps.reverseRange("leaderboard", 0, 9);
 
// Rank of specific member
Long rank = zOps.reverseRank("leaderboard", "player1");

# list (queue)

ListOperations<String, String> listOps = redis.opsForList();
 
// Producer
listOps.rightPush("queue:tasks", taskJson);
 
// Consumer (blocking pop)
String task = listOps.leftPop("queue:tasks", Duration.ofSeconds(5));

Queue bằng List rất đơn giản nhưng consumer pop xong mà chết giữa chừng là task mất. Cần "at-least-once" thì dùng LMOVE/BLMOVE chuyển task sang list "processing" rồi xoá khi làm xong, hoặc dùng Redis Streams với consumer group và XACK.

# pub/sub

// Publisher
redis.convertAndSend("channel:notifications", notification);
 
// Subscriber
@Component
public class NotificationSubscriber implements MessageListener {
   @Override
   public void onMessage(Message message, byte[] pattern) {
       String body = new String(message.getBody(), StandardCharsets.UTF_8);
       processNotification(body);
   }
}
 
// Config
@Bean
public RedisMessageListenerContainer listenerContainer(RedisConnectionFactory factory,
                                                      NotificationSubscriber subscriber) {
   RedisMessageListenerContainer container = new RedisMessageListenerContainer();
   container.setConnectionFactory(factory);
   container.addMessageListener(subscriber, new PatternTopic("channel:*"));
   return container;
}

Pub/Sub của Redis là "fire and forget": subscriber nào đang offline lúc publish thì không bao giờ nhận được message đó, không có lưu lại hay retry. Hợp cho thông báo realtime, invalidate local cache; còn việc gì không được phép mất thì dùng Redis Streams hoặc message broker.

# common pitfalls

PitfallProblemFix
No TTLMemory grows foreverAlways set expiration
JDK serializationNot readable, version-sensitiveUse JSON serializer
Large valuesSlow network, memory pressureSplit or compress
Hot keysSingle shard overloadedAdd random suffix, local cache
Cache + DB inconsistencyStale reads after updateDelete cache on write (not update)
Connection pool exhaustionBlocked threadsTune pool size, add timeout
Missing null cachingCache miss → repeated DB queriesCache null with short TTL

# configuration (application.yml)

spring:
  data:
    redis:
      host: localhost
      port: 6379
      password: ${REDIS_PASSWORD:}
      timeout: 2000ms
      lettuce:
        pool:
          max-active: 16
          max-idle: 8
          min-idle: 2
          max-wait: 1000ms
  cache:
    type: redis
    redis:
      time-to-live: 600000 # 10 minutes (ms)
      cache-null-values: true

Cấu hình lettuce.pool chỉ có tác dụng khi có org.apache.commons:commons-pool2 trên classpath. Lettuce vốn dùng chung một connection thread-safe cho mọi thread, nên pool thường chỉ cần khi dùng lệnh blocking (BLPOP) hoặc transaction (MULTI/EXEC).

Chỉ là những ghi chép cá nhân với hy vọng mang lại chút giá trị. Nếu thấy hữu ích, đừng ngại chia sẻ cho bạn bè & đồng nghiệp nhé!

Happy coding 😎 👍🏻 🚀 🔥.