Redis cơ bản: kiểu dữ liệu và khi nào dùng

1. Tổng quan

Redis là in-memory data store, thường bị hiểu nhầm là “chỉ dùng để cache”. Thực ra Redis cung cấp nhiều kiểu dữ liệu (data structure) khác nhau, mỗi kiểu phù hợp với một nhóm bài toán riêng.

Bài này giúp bạn nắm được 5 kiểu dữ liệu phổ biến nhất của Redis, cách dùng qua Spring Data Redis, và quan trọng hơn: khi nào nên chọn kiểu nào. Đọc xong, bạn sẽ biết tránh lỗi kinh điển là “dùng String cho mọi thứ” rồi tự làm khổ mình khi update dữ liệu.

2. Chuẩn bị môi trường

Thêm dependency Spring Data Redis vào project:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

Khai báo connection trong application.yml:

spring:
  data:
    redis:
      host: localhost
      port: 6379

Chạy Redis bằng Docker cho nhanh:

docker run -d --name redis-dev -p 6379:6379 redis:7-alpine

Các ví dụ dưới đây dùng RedisTemplate<String, String> do Spring Boot auto-config sẵn, bạn không cần khai báo bean thủ công.

3. String: kiểu đơn giản nhất

String là kiểu cơ bản, lưu một value gắn với một key. Value có thể là text, số, hoặc JSON serialize.

@Service
public class SessionTokenService {

    private final RedisTemplate<String, String> redisTemplate;

    public SessionTokenService(RedisTemplate<String, String> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    public void saveToken(String userId, String token) {
        redisTemplate.opsForValue()
            .set("session:" + userId, token, Duration.ofMinutes(30));
    }

    public String getToken(String userId) {
        return redisTemplate.opsForValue().get("session:" + userId);
    }
}
  • set(key, value, ttl): lưu token kèm thời gian sống 30 phút, Redis tự xóa khi hết hạn.
  • get(key): trả về null nếu key không tồn tại hoặc đã expire.

Output khi gọi getToken với token đã hết hạn:

null

Khi nào dùng: session token, cache một object đã serialize sẵn (JSON), counter đơn giản (INCR), feature flag. String cũng hỗ trợ atomic increment:

Long visits = redisTemplate.opsForValue().increment("page:views:home");

Lưu ý: đừng lưu một object lớn rồi serialize/deserialize toàn bộ chỉ để update 1 field — đó là dấu hiệu bạn nên chuyển sang Hash.

4. Hash: object có nhiều field

Hash giống một Map<String, String> bên trong một key, cho phép update từng field riêng lẻ mà không cần đọc/ghi toàn bộ object.

@Service
public class UserProfileCache {

    private final RedisTemplate<String, String> redisTemplate;

    public UserProfileCache(RedisTemplate<String, String> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    public void saveProfile(String userId, Map<String, String> fields) {
        redisTemplate.opsForHash().putAll("user:" + userId, fields);
    }

    public void updateEmail(String userId, String newEmail) {
        redisTemplate.opsForHash().put("user:" + userId, "email", newEmail);
    }

    public Map<Object, Object> getProfile(String userId) {
        return redisTemplate.opsForHash().entries("user:" + userId);
    }
}
  • putAll: ghi nhiều field cùng lúc, tương tự HSET.
  • put một field: chỉ update field email, các field khác (name, age…) không bị ảnh hưởng.
  • entries: lấy toàn bộ field-value của hash.

So sánh với String: nếu dùng String + JSON, mỗi lần đổi email bạn phải GET toàn bộ JSON, parse, sửa, serialize lại, rồi SET. Với Hash, chỉ một lệnh HSET.

Khi nào dùng: object có nhiều field hay update độc lập — user profile, product detail, config theo tenant.

5. List: hàng đợi và lịch sử theo thứ tự

List là danh sách có thứ tự, hỗ trợ push/pop ở cả hai đầu. Phù hợp cho queue hoặc lưu N item gần nhất.

@Service
public class RecentActivityService {

    private final RedisTemplate<String, String> redisTemplate;

    public RecentActivityService(RedisTemplate<String, String> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    public void logActivity(String userId, String activity) {
        String key = "activity:" + userId;
        redisTemplate.opsForList().leftPush(key, activity);
        redisTemplate.opsForList().trim(key, 0, 9); // giữ 10 item gần nhất
    }

    public List<String> getRecentActivities(String userId) {
        return redisTemplate.opsForList().range("activity:" + userId, 0, -1);
    }
}
  • leftPush: thêm activity mới vào đầu danh sách.
  • trim(key, 0, 9): cắt list chỉ giữ 10 phần tử đầu, tránh list phình to vô hạn.
  • range(0, -1): lấy toàn bộ phần tử, index -1 nghĩa là phần tử cuối.

Khi nào dùng: timeline hoạt động gần nhất, simple job queue (kết hợp BLPOP để consumer chờ), undo stack.

Lưu ý: List không phù hợp nếu bạn cần tìm kiếm theo value hoặc truy cập ngẫu nhiên nhiều — độ phức tạp truy cập theo index là O(n) với list dài.

6. Set: tập hợp không trùng, không thứ tự

Set lưu các giá trị duy nhất, hỗ trợ phép toán tập hợp (union, intersection, difference) rất nhanh.

@Service
public class TagService {

    private final RedisTemplate<String, String> redisTemplate;

    public TagService(RedisTemplate<String, String> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    public void addTags(String articleId, String... tags) {
        redisTemplate.opsForSet().add("article:tags:" + articleId, tags);
    }

    public boolean hasTag(String articleId, String tag) {
        return redisTemplate.opsForSet()
            .isMember("article:tags:" + articleId, tag);
    }

    public Set<String> commonTags(String articleId1, String articleId2) {
        return redisTemplate.opsForSet()
            .intersect("article:tags:" + articleId1, "article:tags:" + articleId2);
    }
}
  • add: thêm nhiều tag, Redis tự loại bỏ trùng lặp.
  • isMember: kiểm tra tồn tại với độ phức tạp O(1), nhanh hơn nhiều so với duyệt List.
  • intersect: tìm tag chung giữa hai bài viết — dùng để gợi ý bài liên quan.

Khi nào dùng: tag/category, danh sách unique visitor theo ngày (SADD visitors:2026-10-05 userId), kiểm tra membership nhanh (ví dụ: user đã đăng ký sự kiện chưa).

7. Sorted Set: xếp hạng theo điểm số

Sorted Set (ZSet) giống Set nhưng mỗi member có một score để sắp xếp. Đây là kiểu mạnh nhất cho bài toán leaderboard.

@Service
public class LeaderboardService {

    private final RedisTemplate<String, String> redisTemplate;

    public LeaderboardService(RedisTemplate<String, String> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    public void addScore(String playerId, double score) {
        redisTemplate.opsForZSet().add("leaderboard:weekly", playerId, score);
    }

    public Long getRank(String playerId) {
        // rank 0-based, null nếu không tồn tại
        return redisTemplate.opsForZSet().reverseRank("leaderboard:weekly", playerId);
    }

    public Set<String> getTop10() {
        return redisTemplate.opsForZSet()
            .reverseRange("leaderboard:weekly", 0, 9);
    }
}
  • add(key, member, score): thêm hoặc cập nhật điểm của player.
  • reverseRank: lấy hạng theo thứ tự điểm giảm dần (hạng cao nhất là điểm cao nhất).
  • reverseRange(0, 9): lấy top 10, Redis tự sắp xếp sẵn, không cần sort ở application.

Output ví dụ khi gọi getTop10():

["playerA", "playerC", "playerB"]

Khi nào dùng: bảng xếp hạng, priority queue (score = priority), rate limiting theo time window (score = timestamp).

8. Kiểm tra hành vi với JUnit 5 và Testcontainers

Để test logic liên quan Redis mà không ảnh hưởng dữ liệu thật, dùng Testcontainers.

<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <scope>test</scope>
</dependency>
@SpringBootTest
@Testcontainers
class LeaderboardServiceTest {

    @Container
    static GenericContainer<?> redis = new GenericContainer<>("redis:7-alpine")
        .withExposedPorts(6379);

    @DynamicPropertySource
    static void redisProps(DynamicPropertyRegistry registry) {
        registry.add("spring.data.redis.host", redis::getHost);
        registry.add("spring.data.redis.port", () -> redis.getMappedPort(6379));
    }

    @Autowired
    private LeaderboardService leaderboardService;

    @Test
    void shouldReturnTopPlayersInOrder() {
        leaderboardService.addScore("playerA", 100);
        leaderboardService.addScore("playerB", 50);
        leaderboardService.addScore("playerC", 80);

        Set<String> top = leaderboardService.getTop10();

        assertThat(top).containsExactly("playerA", "playerC", "playerB");
    }
}
  • @Container khởi tạo Redis thật trong Docker cho mỗi test class.
  • @DynamicPropertySource ghi đè host/port để Spring Boot kết nối đúng container.
  • containsExactly kiểm tra thứ tự chính xác — Sorted Set đảm bảo kết quả luôn sắp theo score giảm dần.

9. Bảng so sánh nhanh

Kiểu Cấu trúc Dùng khi
String key → value cache object, counter, session token
Hash key → field-value map object nhiều field, update từng phần
List key → danh sách có thứ tự queue, recent items, timeline
Set key → tập hợp không trùng tag, membership check, phép toán tập hợp
Sorted Set key → member có score leaderboard, rate limiting, priority queue

10. Lỗi hay gặp

  • Dùng String cho dữ liệu hay update từng phần: dẫn đến race condition khi nhiều thread cùng đọc-sửa-ghi JSON. Chuyển sang Hash để update atomic từng field.
  • List không trim: list phình to vô hạn, tốn RAM, lệnh LRANGE chậm dần theo thời gian.
  • Quên set TTL: Redis mặc định không tự xóa key, dữ liệu rác tích lũy mãi. Luôn cân nhắc expire cho dữ liệu tạm thời như session, cache.
  • Dùng Set cho dữ liệu cần thứ tự: Set không đảm bảo thứ tự lặp (iteration order), nếu cần sort hãy dùng Sorted Set.

Lưu ý: Redis là in-memory — mọi dữ liệu nằm trong RAM. Đừng lưu dữ liệu lớn, dài hạn, hoặc dữ liệu cần ACID transaction phức tạp (ví dụ: số dư tài khoản ngân hàng) vào Redis như nguồn sự thật (source of truth) duy nhất.

11. Kết luận

Redis không phải “cái cache to”, mà là bộ công cụ data structure linh hoạt. Chọn đúng kiểu dữ liệu — String cho giá trị đơn, Hash cho object nhiều field, List cho hàng đợi, Set cho membership, Sorted Set cho xếp hạng — giúp code gọn hơn, ít lệnh hơn, và tận dụng được sức mạnh atomic operation của Redis thay vì tự xử lý logic đó ở tầng application.

rediscachingspring-bootnosqlperformance