Redis: in-memory, single-threaded và các kiểu dữ liệu
Mình muốn học về Redis, Kafka, RabbitMQ. Học Redis trước nhé.
Không cần học sâu kiến trúc, chỉ cần các thứ cơ bản và cách trả lời phỏng vấn.
Ok. Ba công nghệ này hay đi chung trong Spring Boot microservices nhưng giải quyết bài toán khác nhau. Redis là chỗ lưu dữ liệu cần truy cập cực nhanh; Kafka và RabbitMQ là chỗ truyền message. Cả chương này dùng một project: shop-app.
1. Bài toán: shop-app bị DB kéo chậm
shop-app (Spring Boot, 3 instance) ── PostgreSQL
GET /products/100 → 100.000 lần/phút, data ít đổi
POST /login → session/token phải dùng chung cho 3 instance
POST /orders → mỗi user tối đa 10 request/phút
12:00 flash sale → 1 sản phẩm, 1 triệu người tranh
02:00 job tính báo cáo → chỉ 1 instance được chạy
Năm việc này có điểm chung: cần một chỗ dùng chung giữa các instance, đọc ghi rất nhanh, thao tác atomic. Đó là Redis.
2. Vấn đề nếu chỉ có DB
- Mỗi request đọc product đều query DB: DB thành nút cổ chai dù dữ liệu không đổi.
- Lưu counter rate limit vào bảng: mỗi request một lần UPDATE, lock row, chậm.
- Cache trong RAM của từng instance (HashMap): 3 instance có 3 bản khác nhau, xóa không đồng bộ.
3. Giải pháp: Redis là gì
Redis là in-memory data store dạng key-value. Dữ liệu nằm chủ yếu trên RAM nên đọc ghi rất nhanh. Value có nhiều kiểu: String, Hash, List, Set, Sorted Set, Stream.
Redis không chỉ là cache. Cache chỉ là use case phổ biến nhất:
Redis
├── Cache (product detail)
├── Session / token (login)
├── OTP (TTL 5 phút)
├── Rate limiting (INCR + EXPIRE)
├── Distributed lock (SET NX PX)
└── Counter / Ranking (INCR, ZSet)
Vì sao Redis nhanh?
- RAM: không đọc disk trên đường xử lý request.
- Data structure tối ưu: hash table, skiplist, listpack... phần lớn command O(1) hoặc O(log N).
- Thực thi command trên một main thread: không lock, không context switch giữa các command.
- I/O multiplexing (epoll): một thread theo dõi hàng chục nghìn connection.
Lưu ý cách nói: đừng nói gọn "Redis là single-thread". Chính xác hơn: Redis thực thi command tuần tự trên main thread; bản mới (6+) có thể dùng thêm thread cho network I/O và việc nền như persistence, giải phóng bộ nhớ.
Các kiểu dữ liệu và use case trong shop-app
| Type | Command tiêu biểu | Use case shop-app |
|---|---|---|
| String | SET, GET, INCR | Cache JSON product, OTP, counter rate limit |
| Hash | HSET, HGET, HINCRBY | Giỏ hàng: cart:42 → field productId, value số lượng |
| List | LPUSH, LRANGE, LTRIM | 10 sản phẩm user vừa xem |
| Set | SADD, SISMEMBER | Tập user đã mua flash sale (unique) |
| Sorted Set (ZSet) | ZINCRBY, ZREVRANGE | Top 10 sản phẩm bán chạy |
| Stream | XADD, XREADGROUP | Log sự kiện nhẹ (product-viewed) có consumer group |
4. Code từng bước
Bước 1: Dependency
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
Starter dùng client Lettuce (mặc định, thread-safe, một connection dùng chung).
Bước 2: application.yml
spring:
data:
redis:
host: ${REDIS_HOST:localhost}
port: 6379
password: ${REDIS_PASSWORD:}
timeout: 500ms
Bước 3: Code Java. Thử từng kiểu dữ liệu bằng StringRedisTemplate:
@Service
@RequiredArgsConstructor
public class RedisPlayground {
private final StringRedisTemplate redis;
public void demo() {
// String + TTL: OTP hết hạn sau 5 phút
redis.opsForValue().set("otp:user:42", "847291", Duration.ofMinutes(5));
// Hash: giỏ hàng
redis.opsForHash().increment("cart:42", "product:100", 2);
// List: 10 sản phẩm vừa xem
redis.opsForList().leftPush("recent:42", "100");
redis.opsForList().trim("recent:42", 0, 9);
// Set: user đã mua flash sale
redis.opsForSet().add("sale:12h:buyers", "42");
// ZSet: bảng xếp hạng bán chạy
redis.opsForZSet().incrementScore("top-selling", "product:100", 1);
Set<String> top10 = redis.opsForZSet().reverseRange("top-selling", 0, 9);
}
}
INCR atomic: rate limit 10 request/phút
public boolean allow(long userId) {
String key = "rate:order:" + userId + ":" + (System.currentTimeMillis() / 60_000);
Long count = redis.opsForValue().increment(key); // INCR, atomic
if (count != null && count == 1) {
redis.expire(key, Duration.ofSeconds(60)); // lần đầu thì gắn TTL
}
return count != null && count <= 10;
}
INCR và EXPIRE là hai command riêng. Nếu app chết giữa hai lệnh, key sống mãi. Muốn gộp thành một bước atomic, dùng Lua script:
private static final RedisScript<Long> RATE_LIMIT = RedisScript.of("""
local c = redis.call('INCR', KEYS[1])
if c == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]) end
return c
""", Long.class);
public boolean allowAtomic(long userId) {
String key = "rate:order:" + userId + ":" + (System.currentTimeMillis() / 60_000);
Long c = redis.execute(RATE_LIMIT, List.of(key), "60");
return c != null && c <= 10;
}
Cả script chạy trên main thread, không command nào khác chen vào giữa.
Bước 4: Test bằng redis-cli và curl
redis-cli SET product:100 '{"name":"iPhone"}' EX 600
redis-cli TTL product:100 # (integer) 597
redis-cli INCR rate:order:42:29123456 # 1, 2, 3...
for i in $(seq 1 12); do curl -s -o /dev/null -w "%{http_code}\n" -X POST localhost:8080/orders; done
# 200 x10, rồi 429 x2
5. Cách hoạt động
Client 1 ─┐
Client 2 ─┼─> [ I/O: epoll / io-threads ] ─> hàng đợi command
Client 3 ─┘ |
v
Main thread: chạy từng command
INCR rate:42 → 1
INCR rate:42 → 2 (không bao giờ chen nhau)
GET product:100
|
v
RAM (dict, skiplist...)
Vì chạy tuần tự, mỗi command là atomic. Mặt trái: một command chậm (KEYS *, HGETALL hash 1 triệu field, Lua chạy lâu) sẽ chặn tất cả client khác.
TTL: Redis xóa key hết hạn theo hai cách. Lazy: khi có ai đọc key thì kiểm tra. Active: định kỳ lấy mẫu ngẫu nhiên một số key có TTL rồi xóa key đã hết hạn.
6. So sánh
| Tiêu chí | HashMap trong JVM | Redis | PostgreSQL |
|---|---|---|---|
| Tốc độ | Nhanh nhất (không qua mạng) | ~sub-millisecond qua mạng | ms, có disk |
| Dùng chung nhiều instance | Không | Có | Có |
| TTL sẵn | Không (cần Caffeine) | Có | Không |
| Độ bền dữ liệu | Mất khi restart | Tùy cấu hình RDB/AOF | Cao (WAL) |
| Query phức tạp, join | Không | Không | Có |
7. Khi nào nên dùng / không nên dùng
- Nên: cache dữ liệu đọc nhiều, session/token dùng chung, counter, rate limit, leaderboard, lock ngắn hạn.
- Không nên: dữ liệu nghiệp vụ chính cần transaction, join, độ bền tuyệt đối (đơn hàng, số dư); dữ liệu lớn hơn RAM; value cực lớn (vài MB một key).
Redis chạy command trên một thread mà vẫn nhanh. Vậy nếu một command chậm thì các request khác phải chờ hết à?
Đúng rồi, đó chính là cái giá của mô hình này. Redis nhanh vì mỗi command rất ngắn (micro giây). Một command dài sẽ chặn hàng đợi:
Main thread
├─ KEYS product:* ... quét 5 triệu key, 2 giây
├─ GET session:abc (chờ)
└─ INCR rate:42 (chờ) → client timeout
Cách tránh trong shop-app:
- Không dùng
KEYS *trên production; dùngSCAN(duyệt từng đợt bằng cursor). - Tránh big key: hash/list hàng triệu phần tử. Chia nhỏ hoặc dùng
HSCAN. - Xóa key lớn bằng
UNLINK(giải phóng ở background thread) thay vìDEL. - Lua script phải ngắn; script chạy lâu chặn toàn bộ server.
- Theo dõi bằng
SLOWLOG GETvàredis-cli --bigkeys.
Mình tự viết GET counter, cộng 1 trong Java rồi SET lại thì khác gì INCR? Redis chạy tuần tự rồi mà?
Bạn hiểu gần đúng, chỉ cần chỉnh một điểm: Redis tuần tự từng command, không tuần tự cả đoạn code Java của bạn. Giữa GET và SET, instance khác có thể chen vào:
Instance A: GET rate:42 → 5
Instance B: GET rate:42 → 5
Instance A: SET rate:42 6
Instance B: SET rate:42 6 ← mất một lần đếm (lost update)
Đây là race condition read–modify–write, giống hệt bài 3.5 Race condition, chỉ là chuyển từ DB sang Redis. Ba cách đúng:
- Dùng command atomic có sẵn:
INCR,HINCRBY,SET NX,DECR. - Gộp nhiều bước thành Lua script.
WATCH+MULTI/EXEC(optimistic): nếu key bị đổi sau WATCH thì EXEC thất bại, phải retry. Ít dùng hơn Lua.
MULTI/EXEC không có rollback như DB: nó chỉ đảm bảo các command chạy liền nhau.
Phỏng vấn hỏi "Redis là gì, sao nhanh, dùng để làm gì" thì trả lời sao?
Q1. Redis là gì? Trong project em dùng Redis để làm gì?
🇻🇳 Tiếng Việt:
Redis là in-memory data store dạng key-value, hỗ trợ nhiều kiểu dữ liệu như String, Hash, List, Set, Sorted Set, Stream. Bên em dùng chủ yếu làm cache cho API product detail: đọc Redis trước, miss thì query DB rồi lưu lại với TTL. Ngoài ra dùng cho session, rate limiting bằng INCR và distributed lock cho job.
🇬🇧 English:
Redis is an in-memory key-value data store that supports data types such as strings, hashes, lists, sets, sorted sets and streams. In our project we mainly used it as a cache for the product detail API: read from Redis first, and on a miss load from the database and store it with a TTL. We also used it for sessions, rate limiting with INCR, and a distributed lock for scheduled jobs.
Q2. Vì sao Redis nhanh? Redis có phải single-thread không?
🇻🇳 Tiếng Việt:
Vì dữ liệu nằm trên RAM, data structure tối ưu, và command được thực thi tuần tự trên main thread nên không tốn lock hay context switch; kết hợp I/O multiplexing để phục vụ nhiều connection. Nói chính xác thì việc thực thi command là single-threaded, còn bản mới có thể dùng thêm thread cho network I/O và việc nền. Hệ quả là một command chậm như KEYS sẽ chặn mọi client khác.
🇬🇧 English:
Data lives in memory, the data structures are optimized, and commands are executed sequentially on a main thread, so there is no locking or context switching between commands; I/O multiplexing lets it serve many connections. Strictly speaking, command execution is single-threaded, while newer versions can use extra threads for network I/O and background work. The trade-off is that one slow command, such as KEYS, blocks every other client.
Q3. Kể các kiểu dữ liệu và use case.
🇻🇳 Tiếng Việt:
String cho cache, OTP, counter. Hash cho object như giỏ hàng. List cho danh sách có thứ tự như sản phẩm vừa xem. Set cho tập unique như user đã mua. Sorted Set cho ranking, leaderboard. Stream cho log sự kiện có consumer group.
🇬🇧 English:
Strings for cache entries, OTPs and counters. Hashes for objects such as a shopping cart. Lists for ordered data such as recently viewed products. Sets for unique members such as users who already bought. Sorted sets for rankings and leaderboards. Streams for an append-only event log with consumer groups.
Q4. Làm sao đảm bảo nhiều thao tác Redis là atomic?
🇻🇳 Tiếng Việt:
Mỗi command đơn đã atomic. Nhiều bước thì không được GET rồi SET ở Java vì instance khác chen vào được. Em dùng command atomic có sẵn như INCR, SET NX, hoặc gộp logic vào Lua script, script chạy trọn vẹn không bị chen ngang.
🇬🇧 English:
Each single command is atomic. For multiple steps I would not do GET then SET in Java, because another instance can interleave. I use built-in atomic commands such as INCR or SET NX, or put the logic into a Lua script, which runs to completion without interruption.
Cách nhớ ngắn gọn
- Redis = RAM + key-value + nhiều data type, không chỉ là cache.
- Nhanh vì RAM, data structure tốt, thực thi command tuần tự trên main thread.
- Tuần tự từng command, không tuần tự đoạn code của bạn: cần INCR / Lua.
- Không
KEYS *, không big key.
Tự kiểm tra
Nói "Redis là single-thread" có chính xác không?
Chưa. Thực thi command chạy tuần tự trên main thread; bản mới dùng thêm thread cho network I/O và việc nền (persistence, giải phóng bộ nhớ).
Vì sao KEYS * nguy hiểm trên production?
Nó quét toàn bộ key trên main thread, chặn mọi command khác. Dùng SCAN.
Use case hợp với Sorted Set?
Ranking/leaderboard, top sản phẩm bán chạy, hàng đợi theo thời gian (score = timestamp).
INCR rồi EXPIRE bằng hai lệnh riêng có rủi ro gì?
App chết giữa hai lệnh thì key không có TTL, sống mãi. Gộp bằng Lua script.
GET + SET trong Java có atomic không?
Không. Instance khác chen vào giữa gây lost update. Dùng INCR hoặc Lua.