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ềnullnế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.putmột field: chỉ update fieldemail, 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-1nghĩ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");
}
}
@Containerkhởi tạo Redis thật trong Docker cho mỗi test class.@DynamicPropertySourceghi đè host/port để Spring Boot kết nối đúng container.containsExactlykiể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ệnhLRANGEchậ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
expirecho 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.