Khi xây dựng hệ thống có tính tương tác cao như trò chơi, nền tảng cộng đồng hay ứng dụng đánh giá, bảng xếp hạng thường là một thành phần then chốt. Tuy nhiên, việc triển khai hiệu quả không chỉ dừng ở việc "sắp xếp theo điểm số", mà còn phụ thuộc vào quy mô dữ liệu, yêu cầu về độ trễ, khả năng mở rộng và chi phí vận hành.
Phương án 1: Truy vấn SQL trực tiếp với ORDER BY
Dành cho hệ thống nhỏ, dữ liệu dưới 10.000 bản ghi và không yêu cầu cập nhật tức thì.
public List<PlayerRecord> fetchTopPlayers(int limit) {
String query = "SELECT player_id, total_points FROM players ORDER BY total_points DESC LIMIT ?";
return jdbcTemplate.query(query, new PlayerRowMapper(), limit);
}
Ưu điểm: dễ viết, dễ kiểm thử, không cần phụ thuộc thành phần bên ngoài.
Nhược điểm: độ phức tạp truy vấn tăng tuyến tính theo kích thước bảng; dễ gây contention khi có hàng chục request đồng thời.
Phương án 2: Tiền xử lý định kỳ + lưu vào bộ nhớ đệm phân tán
Phù hợp khi dữ liệu thay đổi chậm (ví dụ: điểm thưởng được cộng theo ngày), nhưng lượt đọc rất cao.
@Scheduled(cron = "0 0/5 * * * ?") // Cập nhật mỗi 5 phút
public void refreshLeaderboardCache() {
List<PlayerRecord> topList = playerRepository.findTopRanked(5000);
redisTemplate.opsForValue().set("lb:weekly:active", topList, Duration.ofMinutes(10));
}
public List<PlayerRecord> getActiveLeaderboard() {
return Optional.ofNullable(
(List<PlayerRecord>) redisTemplate.opsForValue().get("lb:weekly:active")
).orElse(Collections.emptyList());
}
Ưu điểm: giảm tải hoàn toàn cho cơ sở dữ liệu chính; thời gian phản hồi ổn định dưới 5ms.
Nhược điểm: độ trễ cố định; khó hỗ trợ phân trang động hoặc tìm vị trí cụ thể của người dùng.
Phương án 3: Sử dụng ZSET trong Redis với logic phân vùng
Lựa chọn tiêu chuẩn cho hệ thống vừa và lớn — hỗ trợ cập nhật tức thì, truy vấn nhanh và lấy thứ hạng theo ID người dùng.
public class LeaderboardService {
private static final String KEY_PREFIX = "lb:season:2024q3";
public void updateScore(String playerId, double delta) {
String key = KEY_PREFIX + ":scores";
redisTemplate.opsForZSet().incrementScore(key, playerId, delta);
}
public List<String> getTopPlayers(int count) {
return redisTemplate.opsForZSet().reverseRange(KEY_PREFIX + ":scores", 0, count - 1);
}
public long getPlayerRank(String playerId) {
Long rank = redisTemplate.opsForZSet().reverseRank(KEY_PREFIX + ":scores", playerId);
return rank == null ? -1 : rank + 1;
}
}
Ưu điểm: độ phức tạp log(N); hỗ trợ cả top-N và rank-by-ID; dễ tích hợp với các chiến lược TTL.
Nhược điểm: giới hạn dung lượng RAM trên một node; cần giám sát bộ nhớ để tránh OOM.
Phương án 4: Phân mảnh ngang bằng Redis Cluster và hash-based routing
Dành cho hệ thống có hơn 50 triệu người chơi, với tần suất cập nhật điểm mỗi giây lên đến hàng chục nghìn lần.
public class ShardedLeaderboard {
private final RedissonClient redisson;
public void recordScore(String playerId, double points) {
String shardKey = "lb:shard:" + calculateShardId(playerId);
RScoredSortedSet<String> zset = redisson.getScoredSortedSet(shardKey);
zset.add(points, playerId);
}
private int calculateShardId(String id) {
return Math.abs(id.hashCode()) % 32; // 32 phân vùng
}
public List<String> getGlobalTop(int n) {
// Gộp kết quả từ tất cả các shard rồi sắp xếp lại tại ứng dụng
return IntStream.range(0, 32)
.parallel()
.mapToObj(i -> {
String key = "lb:shard:" + i;
return redisson.getScoredSortedSet(key).reverseRange(0, 99);
})
.flatMap(Collection::stream)
.distinct()
.limit(n)
.collect(Collectors.toList());
}
}
Ưu điểm: khả năng mở rộng tuyến tính; chịu tải cao nhờ phân tán.
Nhược điểm: không hỗ trợ atomic global ranking; cần xử lý thủ công khi tổng hợp dữ liệu từ nhiều phân vùng.
Phương án 5: Tiền tính toán theo batch + cache đa cấp (local + Redis)
Phù hợp với bảng xếp hạng theo tuần/tháng, nơi người dùng chủ yếu tra cứu thứ hạng cá nhân chứ không xem top chung.
@Scheduled(fixedDelay = 3600000) // Mỗi giờ
public void rebuildPersonalRanks() {
Map<String, Integer> rankMap = computeAllRanks();
// Lưu vào Redis dạng hash để tiết kiệm bộ nhớ
redisTemplate.opsForHash().putAll("lb:personal:ranks", rankMap);
// Đồng bộ vào local cache (Caffeine)
rankMap.forEach(localRankCache::put);
}
public int lookupRank(String playerId) {
return localRankCache.getIfPresent(playerId)
.or(() -> Optional.ofNullable(
(Integer) redisTemplate.opsForHash().get("lb:personal:ranks", playerId)
))
.orElseGet(() -> fallbackToDatabase(playerId));
}
Ưu điểm: thời gian truy vấn trung bình < 0.1ms; giảm 95% traffic tới Redis.
Nhược điểm: không phù hợp nếu thứ hạng thay đổi liên tục từng giây.
Phương án 6: Xử lý luồng thời gian thực với Flink hoặc Kafka Streams
Dành riêng cho các nền tảng xã hội hoặc game có cơ chế "điểm sống" (live scoring), ví dụ: đấu trường PvP, sự kiện live-streaming.
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.setParallelism(8);
DataStream<ScoreEvent> events = env
.addSource(new KafkaSourceBuilder().build())
.keyBy(ScoreEvent::getPlayerId);
DataStream<RankedPlayer> rankedStream = events
.window(TumblingEventTimeWindows.of(Time.minutes(1)))
.aggregate(new ScoreAggregator(), new RankAssigner());
rankedStream
.keyBy(RankedPlayer::getSeasonId)
.process(new TopNProcessor(100))
.addSink(new RedisSink<>("lb:live:current"));
Ưu điểm: độ trễ end-to-end dưới 2 giây; xử lý được biến động dữ liệu theo thời gian thực.
Nhược điểm: yêu cầu kiến trúc DevOps chuyên biệt; chi phí vận hành cao hơn 3–5 lần so với giải pháp dựa trên Redis.
| Phương án | Khả năng xử lý | Độ trễ tối đa | Mức độ phức tạp | Gợi ý triển khai |
|---|---|---|---|---|
| SQL đơn giản | < 10K bản ghi | > 500ms | Thấp | MVP, demo nội bộ |
| Tiền xử lý + Redis | 100K – 1M | 5–300 giây | Trung bình | Ứng dụng SaaS cỡ vừa |
| ZSET trong Redis | 1M – 50M | < 50ms | Trung bình | Hệ thống game, community platform |
| Phân mảnh Redis | > 50M | < 100ms | Cao | Nền tảng toàn cầu, multi-region |
| Cache đa cấp + batch | 10M – 100M | 1–60 phút | Cao | Bảng xếp hạng theo mùa, theo tháng |
| Xử lý luồng thời gian thực | Vô hạn | < 2 giây | Rất cao | Live event, real-time competition |
Khi lựa chọn phương án, hãy ưu tiên đánh giá khách quan dựa trên ba yếu tố then chốt: (1) tần suất cập nhật điểm, (2) phân bố truy vấn (top-N vs. rank-by-ID), và (3) ngân sách vận hành. Một thiết kế tốt không nhất thiết phải phức tạp — mà phải đáp ứng đúng nhu cầu nghiệp vụ với chi phí kỹ thuật tối ưu.