Hot key và big key trong Redis: phát hiện và xử lý
1. Tổng quan
Redis xử lý lệnh trên một thread chính. Vì vậy, chỉ một key quá lớn (big key) hoặc một key bị truy cập quá dày (hot key) cũng có thể làm cả instance chậm theo. Trong bài này, bạn sẽ học cách phát hiện hai loại key này bằng công cụ có sẵn của Redis và bằng code Java. Sau đó, chúng ta xử lý chúng trong ứng dụng Spring Boot 3.x bằng các kỹ thuật tách key, xóa an toàn, local cache và nhân bản key.
2. Hot key và big key là gì?
Big key là key chiếm nhiều bộ nhớ hoặc chứa quá nhiều phần tử. Không có ngưỡng chuẩn, nhưng nhiều đội dùng các mốc sau:
- String lớn hơn khoảng 10 KB.
- Hash, List, Set, ZSet có hơn khoảng 5.000 phần tử, hoặc tổng dung lượng vượt vài MB.
Hot key là key nhận một tỉ lệ request lớn bất thường so với các key khác. Ví dụ: key chứa thông tin flash sale, trang chủ hoặc cấu hình dùng chung.
Hai loại key này gây ra những vấn đề sau:
- Chặn thread chính:
HGETALLtrên hash 1 triệu field, hoặcDELmột ZSet khổng lồ, có thể chặn Redis hàng trăm mili giây. Mọi client khác phải chờ. - Nghẽn băng thông mạng: đọc một value 2 MB với 5.000 request/giây tương đương khoảng 10 GB/giây.
- Mất cân bằng trong Redis Cluster: một key luôn nằm ở một slot, tức một node. Hot key hay big key đều dồn tải vào node đó, dù cluster có bao nhiêu node đi nữa.
- Replication và failover chậm: big key làm việc đồng bộ và nạp RDB tốn thời gian hơn.
3. Phát hiện big key
3.1. Dùng redis-cli --bigkeys và --memkeys
Cách nhanh nhất là dùng tùy chọn có sẵn của redis-cli:
redis-cli -h 127.0.0.1 -p 6379 --bigkeys -i 0.01
Lệnh này duyệt toàn bộ keyspace bằng SCAN, nên không chặn server như KEYS *. Tham số -i 0.01 nghỉ 10 ms sau mỗi 100 lệnh SCAN, giúp giảm áp lực lên server production. Kết quả trông như sau:
[00.00%] Biggest hash found so far '"cart:10023"' with 48211 fields
[12.35%] Biggest string found so far '"product:detail:88"' with 1048576 bytes
[45.10%] Biggest zset found so far '"leaderboard:global"' with 920144 members
-------- summary -------
Biggest hash found '"cart:10023"' has 48211 fields
Biggest string found '"product:detail:88"' has 1048576 bytes
Biggest zset found '"leaderboard:global"' has 920144 members
--bigkeys đo theo số phần tử. Nó chỉ báo key lớn nhất của mỗi kiểu dữ liệu. Nếu bạn quan tâm tới bộ nhớ thực tế, hãy dùng --memkeys:
redis-cli --memkeys --memkeys-samples 0
--memkeys-samples 0 yêu cầu Redis đo toàn bộ phần tử thay vì lấy mẫu. Cách này chính xác hơn nhưng chậm hơn.
3.2. Quét bằng Java với MEMORY USAGE
redis-cli chỉ trả về key lớn nhất của mỗi kiểu. Muốn liệt kê mọi key vượt ngưỡng, bạn có thể tự quét. Trước hết, thêm dependency:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
Tiếp theo, viết một component quét keyspace:
@Component
public class BigKeyScanner {
private final StringRedisTemplate redis;
public BigKeyScanner(StringRedisTemplate redis) {
this.redis = redis;
}
public Map<String, Long> findKeysLargerThan(long thresholdBytes) {
Map<String, Long> result = new LinkedHashMap<>();
ScanOptions options = ScanOptions.scanOptions().match("*").count(500).build();
try (Cursor<String> cursor = redis.scan(options)) {
while (cursor.hasNext()) {
String key = cursor.next();
Long bytes = redis.execute((RedisCallback<Long>) conn ->
(Long) conn.execute("MEMORY",
"USAGE".getBytes(), key.getBytes(), "SAMPLES".getBytes(), "0".getBytes()));
if (bytes != null && bytes > thresholdBytes) {
result.put(key, bytes);
}
}
}
return result;
}
}
Các điểm chính trong đoạn code:
redis.scan(options)duyệt keyspace theo lô 500 key. Thread chính không bị khóa lâu.Cursorphải được đóng, nên ta dùng try-with-resources.MEMORY USAGE key SAMPLES 0trả về số byte key chiếm, gồm cả overhead. Spring Data Redis không có API riêng cho lệnh này, nên ta gọi quaconn.execute.
Ví dụ khi gọi findKeysLargerThan(1_000_000):
{cart:10023=3145812, product:detail:88=1048648, leaderboard:global=89231560}
Lưu ý: Hãy chạy scanner trên replica thay vì master, và vào giờ thấp điểm. Một cách khác là phân tích file RDB offline bằng các công cụ đọc RDB. Cách này hoàn toàn không ảnh hưởng tới server đang chạy.
4. Phát hiện hot key
4.1. Dùng redis-cli --hotkeys
Redis chỉ theo dõi tần suất truy cập khi bạn bật chính sách eviction kiểu LFU:
# redis.conf
maxmemory-policy allkeys-lfu
Sau khi bật LFU, chạy:
redis-cli --hotkeys
[00.00%] Hot key '"config:homepage"' found so far with counter 255
[37.21%] Hot key '"product:stock:5001"' found so far with counter 201
-------- summary -------
hot key found with counter: 255 keyname: "config:homepage"
hot key found with counter: 201 keyname: "product:stock:5001"
Counter LFU là giá trị logarit, tối đa 255. Nó không phải số request thực tế. Bạn chỉ nên dùng nó để so sánh tương đối giữa các key.
Nếu đã nghi ngờ một key cụ thể, hãy kiểm tra trực tiếp:
redis-cli OBJECT FREQ config:homepage
(integer) 255
Lưu ý: Đừng dùng
MONITORtrên production để tìm hot key.MONITORgửi mọi lệnh về client và có thể làm throughput giảm hơn một nửa.
4.2. Đếm truy cập phía ứng dụng
Nếu không thể đổi maxmemory-policy, bạn có thể đếm ngay trong ứng dụng. Đoạn code dưới đây đếm số lần đọc mỗi key trong một cửa sổ thời gian:
@Component
public class HotKeyDetector {
private final ConcurrentHashMap<String, LongAdder> counters = new ConcurrentHashMap<>();
public void record(String key) {
counters.computeIfAbsent(key, k -> new LongAdder()).increment();
}
public List<Map.Entry<String, Long>> topAndReset(int n) {
Map<String, Long> snapshot = new HashMap<>();
counters.forEach((k, v) -> snapshot.put(k, v.sumThenReset()));
counters.clear();
return snapshot.entrySet().stream()
.sorted(Map.Entry.<String, Long>comparingByValue().reversed())
.limit(n)
.toList();
}
}
Bạn gọi record(key) mỗi khi đọc Redis. Sau đó, dùng một job @Scheduled gọi topAndReset(10) mỗi 10 giây, rồi ghi kết quả ra log hoặc metrics. LongAdder giảm tranh chấp khi nhiều thread cùng tăng một bộ đếm. Vì vậy, nó phù hợp hơn AtomicLong trong tình huống này.
5. Xử lý big key
5.1. Tách key (sharding)
Giả sử bạn lưu lượt xem của mọi sản phẩm vào một hash duy nhất là product:views. Hash này sẽ phình lên hàng triệu field. Cách khắc phục là chia nó thành nhiều hash nhỏ:
public final class ShardRouter {
private ShardRouter() {}
public static String shardKey(String baseKey, String field, int shards) {
int shard = Math.floorMod(field.hashCode(), shards);
return baseKey + ":" + shard;
}
}
@Service
public class ProductViewService {
private static final int SHARDS = 64;
private final StringRedisTemplate redis;
public ProductViewService(StringRedisTemplate redis) {
this.redis = redis;
}
public long increment(String productId) {
String key = ShardRouter.shardKey("product:views", productId, SHARDS);
return redis.opsForHash().increment(key, productId, 1);
}
public long get(String productId) {
String key = ShardRouter.shardKey("product:views", productId, SHARDS);
Object value = redis.opsForHash().get(key, productId);
return value == null ? 0 : Long.parseLong(value.toString());
}
}
Cách hoạt động:
Math.floorModluôn trả về số không âm, kể cả khihashCode()âm. Đây là chỗ nhiều người dùng%và bị lỗi chỉ số âm.- Sản phẩm
"P-1001"luôn rơi vào cùng một shard, ví dụproduct:views:17. Nhờ vậy, việc đọc và ghi luôn nhất quán. - Mỗi hash giờ chỉ còn khoảng 1/64 số field. Trong Redis Cluster, 64 key này nằm ở các slot khác nhau, nên tải được rải đều cho nhiều node.
Lưu ý: Đừng thêm hash tag kiểu
product:views:{all}:17nếu mục tiêu là phân tán tải. Hash tag ép mọi shard vào cùng một slot. Kích thước từng key giảm, nhưng tải vẫn dồn vào một node.
Với big key dạng String, ví dụ JSON chi tiết sản phẩm 1 MB, hãy tách phần ít đổi và phần hay đổi thành các key riêng. Bạn cũng có thể nén value, hoặc chỉ cache những trường thật sự cần.
5.2. Xóa big key an toàn
DEL giải phóng bộ nhớ một cách đồng bộ. Xóa một ZSet 1 triệu phần tử có thể chặn Redis hàng trăm mili giây. Hãy dùng UNLINK thay thế:
public void removeLeaderboard() {
redis.unlink("leaderboard:global");
}
UNLINK gỡ key khỏi keyspace ngay lập tức. Phần giải phóng bộ nhớ được đẩy sang một background thread. Bạn cũng nên bật lazy free trong cấu hình, để các thao tác xóa ngầm của Redis cũng không chặn thread chính:
# redis.conf (Redis 6+)
lazyfree-lazy-user-del yes
lazyfree-lazy-expire yes
lazyfree-lazy-eviction yes
lazyfree-lazy-server-del yes
Với lazyfree-lazy-user-del yes, ngay cả lệnh DEL cũng hoạt động như UNLINK. Thiết lập này bảo vệ bạn khỏi những đoạn code cũ vẫn gọi delete().
Nếu phải chạy trên Redis cũ, hoặc muốn kiểm soát tải chặt hơn, hãy xóa dần từng lô:
public void deleteHashGradually(String key) {
ScanOptions options = ScanOptions.scanOptions().count(500).build();
try (Cursor<Map.Entry<Object, Object>> cursor = redis.opsForHash().scan(key, options)) {
List<Object> batch = new ArrayList<>(500);
while (cursor.hasNext()) {
batch.add(cursor.next().getKey());
if (batch.size() == 500) {
redis.opsForHash().delete(key, batch.toArray());
batch.clear();
}
}
if (!batch.isEmpty()) {
redis.opsForHash().delete(key, batch.toArray());
}
}
redis.unlink(key);
}
Mỗi lần HDEL chỉ xóa 500 field, nên thời gian chặn của từng lệnh rất ngắn. Redis cho phép sửa hash trong lúc đang HSCAN. Các field chưa bị xóa vẫn được trả về. Lệnh unlink cuối cùng dọn những field còn sót.
6. Xử lý hot key
6.1. Local cache trước Redis
Cách hiệu quả nhất là để phần lớn request không chạm tới Redis. Bạn thêm Caffeine:
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
Sau đó, đặt một local cache ngắn hạn trước Redis:
@Service
public class HomepageConfigService {
private final StringRedisTemplate redis;
private final LoadingCache<String, String> localCache;
public HomepageConfigService(StringRedisTemplate redis) {
this.redis = redis;
this.localCache = Caffeine.newBuilder()
.maximumSize(1_000)
.expireAfterWrite(Duration.ofSeconds(2))
.build(key -> redis.opsForValue().get(key));
}
public String getConfig() {
return localCache.get("config:homepage");
}
}
Giả sử bạn có 20 instance ứng dụng. Mỗi instance chỉ gọi Redis tối đa khoảng một lần mỗi 2 giây cho key này, tức Redis nhận khoảng 10 request/giây, thay vì hàng chục nghìn. LoadingCache cũng gộp các lần load đồng thời cho cùng một key. Nhờ vậy, khi cache hết hạn sẽ không có hàng trăm thread cùng lao vào Redis.
Cái giá phải trả là dữ liệu có thể cũ tối đa 2 giây. Với cấu hình trang chủ hay danh mục sản phẩm, mức trễ này thường chấp nhận được.
6.2. Nhân bản key
Khi dữ liệu cần mới hơn, hoặc chạy trong Redis Cluster mà một node bị quá tải, bạn có thể ghi value ra nhiều bản sao và đọc ngẫu nhiên từ một bản:
@Service
public class FlashSaleService {
private static final int REPLICAS = 8;
private final StringRedisTemplate redis;
public FlashSaleService(StringRedisTemplate redis) {
this.redis = redis;
}
public void publish(String saleId, String json) {
for (int i = 0; i < REPLICAS; i++) {
redis.opsForValue().set("flashsale:" + saleId + ":r" + i, json, Duration.ofMinutes(10));
}
}
public String read(String saleId) {
int i = ThreadLocalRandom.current().nextInt(REPLICAS);
return redis.opsForValue().get("flashsale:" + saleId + ":r" + i);
}
}
8 key flashsale:42:r0 đến flashsale:42:r7 thường rơi vào các slot khác nhau. Vì vậy, tải đọc được chia cho nhiều node. Phương pháp này phù hợp với dữ liệu đọc nhiều, ghi ít.
Lưu ý: Thao tác ghi vào nhiều bản sao không phải atomic. Trong một khoảnh khắc ngắn, các bản sao có thể lệch nhau. Đừng dùng cách này cho dữ liệu đòi hỏi nhất quán tuyệt đối, như số dư tài khoản. Với bộ đếm tồn kho hot, hãy cân nhắc tách tồn kho thành nhiều “xô” (bucket) độc lập. Mỗi xô giữ một phần số lượng thay vì chứa bản sao của cùng một giá trị.
Ngoài hai cách trên, bạn có thể đọc từ replica. Với Lettuce, cấu hình ReadFrom.REPLICA_PREFERRED để chia tải đọc trên mô hình master–replica.
7. Kiểm thử logic tách key
Logic định tuyến shard là phần dễ sai nhất, nên hãy viết test cho nó:
class ShardRouterTest {
@Test
void sameFieldAlwaysMapsToSameShard() {
String first = ShardRouter.shardKey("product:views", "P-1001", 64);
String second = ShardRouter.shardKey("product:views", "P-1001", 64);
assertThat(first).isEqualTo(second).startsWith("product:views:");
}
@Test
void shardIndexIsNeverNegative() {
// "polygenelubricants" có hashCode() == Integer.MIN_VALUE
String key = ShardRouter.shardKey("product:views", "polygenelubricants", 64);
int index = Integer.parseInt(key.substring(key.lastIndexOf(':') + 1));
assertThat(index).isBetween(0, 63);
}
@Test
void fieldsAreSpreadAcrossShards() {
Map<String, Long> distribution = IntStream.range(0, 100_000)
.mapToObj(i -> ShardRouter.shardKey("product:views", "P-" + i, 64))
.collect(Collectors.groupingBy(k -> k, Collectors.counting()));
assertThat(distribution).hasSize(64);
assertThat(distribution.values()).allSatisfy(count ->
assertThat(count).isBetween(1_000L, 2_200L));
}
}
Test thứ hai kiểm tra trường hợp biên với Integer.MIN_VALUE. Nếu bạn dùng Math.abs(hash) % n, test này sẽ fail vì Math.abs(Integer.MIN_VALUE) vẫn là số âm. Test thứ ba xác nhận 100.000 field được phân bố tương đối đều: trung bình khoảng 1.560 field mỗi shard.
8. Lỗi hay gặp và khi nào không nên dùng
- Chạy
KEYS *để tìm big key: lệnh này chặn Redis cho tới khi duyệt xong. Luôn dùngSCAN. - Tách key nhưng vẫn cần đọc toàn bộ: nếu nghiệp vụ thường xuyên phải
HGETALLmọi field, việc tách chỉ biến 1 lệnh chậm thành 64 lệnh. Hãy xem lại nghiệp vụ trước, ví dụ phân trang hoặc tính toán trước kết quả tổng hợp. - Đổi số shard khi đang chạy: thay
SHARDStừ 64 sang 128 làm phần lớn field đổi vị trí. Bạn cần có kế hoạch migrate, hoặc chọn số shard đủ lớn ngay từ đầu. - Local cache cho dữ liệu thay đổi liên tục: tồn kho, giá đang đấu thầu… không hợp với TTL vài giây. Nếu vẫn muốn dùng, hãy kết hợp Pub/Sub hoặc client-side caching (
CLIENT TRACKING) của Redis 6+ để vô hiệu hóa cache. - Tối ưu quá sớm: một hash 2.000 field chưa phải vấn đề. Hãy đo bằng
--bigkeys,--hotkeysvà slowlog (SLOWLOG GET) trước khi quyết định.
9. Kết luận
Trong bài này, bạn đã thấy vì sao hot key và big key nguy hiểm với Redis vốn xử lý lệnh trên một thread. Để phát hiện chúng, bạn có thể dùng redis-cli --bigkeys, --memkeys, --hotkeys, MEMORY USAGE, OBJECT FREQ, hoặc tự đếm phía ứng dụng. Để xử lý big key, bạn tách key bằng Math.floorMod, xóa bằng UNLINK và bật lazy free. Để giảm tải cho hot key, bạn dùng local cache Caffeine hoặc nhân bản key. Hãy đưa việc quét định kỳ vào quy trình vận hành, vì phát hiện sớm luôn rẻ hơn xử lý khi Redis đã nghẽn.