Redis sorted set: leaderboard, hàng đợi ưu tiên và sliding window
1. Tổng quan
Sorted set (ZSET) là cấu trúc dữ liệu linh hoạt nhất của Redis. Mỗi phần tử (member) gắn với một điểm số (score), và Redis luôn giữ chúng theo thứ tự score. Bài này dùng ZSET để giải ba bài toán quen thuộc: bảng xếp hạng (leaderboard), hàng đợi ưu tiên/hẹn giờ và rate limiter kiểu sliding window. Tất cả viết bằng Java 21 với Spring Boot 3.x. Đọc xong, bạn biết chọn score thế nào cho từng bài toán và tránh được các lỗi race condition hay gặp.
2. Sorted set hoạt động thế nào
Một ZSET có các tính chất sau:
- Member là duy nhất.
ZADDmột member đã tồn tại chỉ cập nhật score của nó. - Score là số thực
double. Các member cùng score được sắp theo thứ tự từ điển (lexicographic) của member. - Thêm, xóa, cập nhật và lấy rank đều có độ phức tạp
O(log N). Lấy một khoảng phần tử tốnO(log N + M), với M là số phần tử trả về.
Bạn có thể thử nhanh bằng redis-cli:
ZADD scores 120 alice 95 bob 150 carol
ZREVRANGE scores 0 1 WITHSCORES
ZREVRANK scores bob
1) "carol"
2) "150"
3) "alice"
4) "120"
(integer) 2
ZREVRANGE trả về các phần tử theo score giảm dần. ZREVRANK cho biết vị trí của bob (đếm từ 0) trong thứ tự đó.
2.1. Cài đặt dự án
Thêm dependency Spring Data Redis. Hai dependency Testcontainers dùng cho phần test ở mục 6:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
Cấu hình kết nối:
spring.data.redis.host=localhost
spring.data.redis.port=6379
Spring Boot tự tạo bean StringRedisTemplate. Mọi thao tác ZSET đi qua opsForZSet().
3. Leaderboard
Bài toán: game cộng điểm cho người chơi, cần lấy top N và thứ hạng của một người bất kỳ. Ở đây score chính là điểm, member là ID người chơi.
public record PlayerScore(String player, double score) {}
@Service
public class LeaderboardService {
private static final String KEY = "leaderboard:game:2048";
private final ZSetOperations<String, String> zSet;
public LeaderboardService(StringRedisTemplate redis) {
this.zSet = redis.opsForZSet();
}
public double addPoints(String player, double points) {
return zSet.incrementScore(KEY, player, points);
}
public List<PlayerScore> top(int n) {
Set<ZSetOperations.TypedTuple<String>> tuples =
zSet.reverseRangeWithScores(KEY, 0, n - 1);
if (tuples == null) {
return List.of();
}
return tuples.stream()
.map(t -> new PlayerScore(t.getValue(), t.getScore()))
.toList();
}
public Optional<Long> rankOf(String player) {
Long rank = zSet.reverseRank(KEY, player);
return Optional.ofNullable(rank).map(r -> r + 1);
}
}
Giải thích từng phần:
incrementScoreứng với lệnhZINCRBY. Lệnh này atomic. Nếu member chưa tồn tại, Redis coi score ban đầu là 0. Bạn không cần đọc điểm cũ, cộng thêm rồi ghi lại, nên không có race condition khi nhiều request cùng cộng điểm cho một người.reverseRangeWithScores(KEY, 0, n - 1)ứng vớiZREVRANGE ... WITHSCORES. Chỉ số kết thúc là inclusive, vì vậy top 10 là0..9.reverseRanktrả vềnullnếu người chơi không có trong bảng. Ta cộng 1 để rank bắt đầu từ 1 cho thân thiện với người dùng.
Gọi thử:
leaderboard.addPoints("alice", 120);
leaderboard.addPoints("bob", 95);
leaderboard.addPoints("carol", 150);
leaderboard.addPoints("bob", 60);
System.out.println(leaderboard.top(3));
System.out.println(leaderboard.rankOf("alice"));
[PlayerScore[player=bob, score=155.0], PlayerScore[player=carol, score=150.0], PlayerScore[player=alice, score=120.0]]
Optional[3]
3.1. Xử lý trường hợp bằng điểm
Khi hai người bằng điểm, Redis sắp theo tên member. Với ZREVRANGE, thứ tự từ điển bị đảo ngược, nên zoe đứng trước anna. Kết quả này hiếm khi đúng với ý đồ nghiệp vụ. Nếu muốn ai đạt điểm trước thì xếp trên, bạn có thể mã hóa thời gian vào phần thập phân của score:
public double addPointsWithTieBreak(String player, long points) {
long now = System.currentTimeMillis();
double tieBreak = 1.0 - (now / 1e13);
double score = points + tieBreak;
zSet.add(KEY, player, score);
return score;
}
Người đạt điểm sớm hơn có now nhỏ hơn, nên tieBreak lớn hơn và được xếp trên. Khi hiển thị, bạn lấy Math.floor(score) để ra điểm thật.
Lưu ý: score là
double, chỉ biểu diễn chính xác số nguyên tới 2^53 (khoảng 9×10^15). Khi ghép nhiều giá trị vào một score, bạn cần tính trước độ lớn để không mất độ chính xác. Với cách trên,pointsnên dưới vài triệu. Ngoài ra, cách này không dùng được vớiincrementScore, vì mỗi lần cộng sẽ cộng dồn cả phần thập phân.
4. Hàng đợi ưu tiên và hàng đợi hẹn giờ
4.1. Priority queue
Lần này score là độ ưu tiên: số càng nhỏ thì càng ưu tiên. Để các job cùng mức ưu tiên vẫn ra theo thứ tự FIFO, ta ghép thêm timestamp vào score:
@Service
public class JobQueue {
private static final String KEY = "queue:email-jobs";
private final ZSetOperations<String, String> zSet;
public JobQueue(StringRedisTemplate redis) {
this.zSet = redis.opsForZSet();
}
public void enqueue(String jobId, int priority) {
if (priority < 0 || priority > 9) {
throw new IllegalArgumentException("priority must be 0..9");
}
double score = priority * 1e13 + System.currentTimeMillis();
zSet.add(KEY, jobId, score);
}
public Optional<String> poll() {
var tuple = zSet.popMin(KEY);
return Optional.ofNullable(tuple).map(ZSetOperations.TypedTuple::getValue);
}
public Optional<String> take(Duration timeout) {
var tuple = zSet.popMin(KEY, timeout.toSeconds(), TimeUnit.SECONDS);
return Optional.ofNullable(tuple).map(ZSetOperations.TypedTuple::getValue);
}
}
Các điểm chính:
priority * 1e13 + millischo giá trị lớn nhất khoảng 9.2×10^13, vẫn nằm xa dưới ngưỡng 2^53. Job priority 0 luôn ra trước job priority 1, kể cả khi được thêm vào sau.popMinứng vớiZPOPMIN. Lệnh này lấy và xóa phần tử trong cùng một bước atomic. Hai worker không bao giờ nhận trùng một job.popMin(key, timeout, unit)ứng vớiBZPOPMIN. Lệnh này chặn connection cho tới khi có job hoặc hết timeout, nên bạn tránh được kiểu polling liên tục tốn CPU.
Lưu ý:
BZPOPMINgiữ một connection trong suốt thời gian chờ. Với Lettuce mặc định dùng shared connection, lệnh blocking sẽ chặn cả các lệnh khác. Bạn nên bật connection pool (thêmcommons-pool2và cấu hìnhspring.data.redis.lettuce.pool.*) hoặc dùng một connection factory riêng cho worker.
4.2. Delayed queue
Muốn gửi email nhắc thanh toán sau 30 phút? Dùng score là thời điểm cần chạy (epoch millis). Worker định kỳ lấy các job có score nhỏ hơn hoặc bằng thời điểm hiện tại.
Cách viết dưới đây sai: gọi rangeByScore rồi mới remove. Hai worker có thể cùng đọc được một job trước khi kịp xóa. Ta gom hai bước vào một Lua script để Redis chạy atomic:
@Service
public class DelayedJobQueue {
private static final String KEY = "queue:delayed";
private static final RedisScript<String> POP_DUE = new DefaultRedisScript<>("""
local jobs = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', ARGV[1], 'LIMIT', 0, 1)
if #jobs == 0 then return false end
redis.call('ZREM', KEYS[1], jobs[1])
return jobs[1]
""", String.class);
private final StringRedisTemplate redis;
public DelayedJobQueue(StringRedisTemplate redis) {
this.redis = redis;
}
public void schedule(String jobId, Instant runAt) {
redis.opsForZSet().add(KEY, jobId, runAt.toEpochMilli());
}
public Optional<String> pollDue(Instant now) {
String job = redis.execute(POP_DUE, List.of(KEY), String.valueOf(now.toEpochMilli()));
return Optional.ofNullable(job);
}
}
Script lấy tối đa một job đã đến hạn rồi xóa nó. Trong Lua, return false được chuyển thành nil, nên phía Java nhận null. Worker có thể là một @Scheduled(fixedDelay = 500) gọi pollDue trong vòng lặp cho tới khi rỗng.
Lưu ý: Sau khi pop, job đã rời khỏi Redis. Nếu worker chết giữa chừng, job mất luôn. Khi cần đảm bảo at-least-once, bạn chuyển job sang một ZSET “processing” với score là deadline, rồi xóa khi xử lý xong. Nếu yêu cầu còn phức tạp hơn, hãy cân nhắc Redis Streams hoặc một message broker thật.
5. Rate limiter kiểu sliding window
Fixed window (đếm số request theo từng phút) có điểm yếu ở ranh giới. Ví dụ: 100 request lúc 00:59 và 100 request lúc 01:00 đều lọt qua, tức 200 request trong 2 giây. Sliding window khắc phục bằng cách lưu timestamp của từng request trong ZSET:
- Xóa các request cũ hơn
now - window(ZREMRANGEBYSCORE). - Đếm số request còn lại (
ZCARD). - Nếu chưa vượt giới hạn, thêm request hiện tại vào với score là
now.
Ba bước phải chạy atomic, nên ta lại dùng Lua:
@Service
public class SlidingWindowRateLimiter {
private static final RedisScript<Long> SCRIPT = new DefaultRedisScript<>("""
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
if redis.call('ZCARD', key) < limit then
redis.call('ZADD', key, now, ARGV[4])
redis.call('PEXPIRE', key, window)
return 1
end
return 0
""", Long.class);
private final StringRedisTemplate redis;
private final Clock clock;
public SlidingWindowRateLimiter(StringRedisTemplate redis, Clock clock) {
this.redis = redis;
this.clock = clock;
}
public boolean tryAcquire(String customerId, int limit, Duration window) {
Long allowed = redis.execute(SCRIPT,
List.of("rate:" + customerId),
String.valueOf(clock.millis()),
String.valueOf(window.toMillis()),
String.valueOf(limit),
UUID.randomUUID().toString());
return Long.valueOf(1L).equals(allowed);
}
}
Giải thích:
- Member là một UUID ngẫu nhiên. Nếu dùng timestamp làm member, hai request trong cùng một millisecond sẽ ghi đè nhau và bị đếm thành một.
PEXPIREgiúp Redis tự dọn key của khách hàng không còn hoạt động.- Bạn cần khai báo một bean
Clock(ví dụClock.systemUTC()). InjectClockgiúp test dễ hơn. Tuy vậy, nếu có nhiều instance ứng dụng, đồng hồ của chúng nên được đồng bộ, hoặc bạn lấy thời gian ngay trong script bằngredis.call('TIME').
Lưu ý: Mỗi request tốn một phần tử trong ZSET. Với giới hạn lớn, ví dụ 100.000 request/giờ cho mỗi khách, bộ nhớ tăng nhanh. Khi đó bạn nên dùng sliding window counter (ước lượng từ hai fixed window) hoặc token bucket.
Trong cluster mode, script chỉ được chạm vào các key cùng hash slot. Các script ở bài này mỗi lần chỉ dùng một key nên không gặp vấn đề.
6. Kiểm thử với Testcontainers
Test dưới đây chạy với Redis thật trong Docker. @ServiceConnection tự cấu hình host và port cho Spring.
@SpringBootTest
@Testcontainers
class SortedSetFeaturesTest {
@Container
@ServiceConnection(name = "redis")
static GenericContainer<?> redis =
new GenericContainer<>("redis:7-alpine").withExposedPorts(6379);
@Autowired LeaderboardService leaderboard;
@Autowired JobQueue jobQueue;
@Autowired SlidingWindowRateLimiter limiter;
@Autowired StringRedisTemplate template;
@BeforeEach
void flush() {
template.getConnectionFactory().getConnection().serverCommands().flushAll();
}
@Test
void leaderboardOrdersByScoreDescending() {
leaderboard.addPoints("alice", 120);
leaderboard.addPoints("bob", 95);
leaderboard.addPoints("bob", 60);
assertThat(leaderboard.top(2))
.extracting(PlayerScore::player)
.containsExactly("bob", "alice");
assertThat(leaderboard.rankOf("alice")).contains(2L);
assertThat(leaderboard.rankOf("nobody")).isEmpty();
}
@Test
void higherPriorityJobComesFirst() {
jobQueue.enqueue("newsletter", 5);
jobQueue.enqueue("password-reset", 0);
assertThat(jobQueue.poll()).contains("password-reset");
assertThat(jobQueue.poll()).contains("newsletter");
assertThat(jobQueue.poll()).isEmpty();
}
@Test
void rateLimiterRejectsAfterLimit() {
Duration window = Duration.ofSeconds(10);
assertThat(limiter.tryAcquire("c-42", 3, window)).isTrue();
assertThat(limiter.tryAcquire("c-42", 3, window)).isTrue();
assertThat(limiter.tryAcquire("c-42", 3, window)).isTrue();
assertThat(limiter.tryAcquire("c-42", 3, window)).isFalse();
assertThat(limiter.tryAcquire("c-99", 3, window)).isTrue();
}
}
Test cuối kiểm tra hai điều: request thứ tư của c-42 bị từ chối, và giới hạn được tính riêng cho từng khách hàng. Muốn test việc cửa sổ “trượt”, bạn inject một Clock có thể điều chỉnh (ví dụ một lớp MutableClock tự viết), tiến thời gian thêm 10 giây rồi gọi lại tryAcquire.
7. Khi nào không nên dùng sorted set
- Cần tính bền vững như message queue: ZSET không có acknowledgement, consumer group hay retry. Redis Streams, RabbitMQ hoặc Kafka phù hợp hơn.
- Một ZSET quá lớn: hàng chục triệu phần tử trong một key gây “big key”. Thao tác
DEL, migrate slot hay replication đều chậm. Bạn nên chia key theo mùa giải, theo vùng, hoặc theo ngày (leaderboard:2026-10). - Score cần chính xác tuyệt đối: ví dụ tiền tệ có nhiều chữ số thập phân.
doublecó sai số, nên bạn hãy lưu theo đơn vị nhỏ nhất (đồng, cent) dưới dạng số nguyên. - Đếm gần đúng là đủ: nếu chỉ cần biết “khoảng bao nhiêu người dùng duy nhất”, HyperLogLog tốn ít bộ nhớ hơn nhiều.
8. Kết luận
Sorted set giải được nhiều bài toán chỉ bằng cách chọn score phù hợp. Với leaderboard, score là điểm và bạn dùng ZINCRBY, ZREVRANGE, ZREVRANK. Với priority queue, score là độ ưu tiên ghép timestamp, kết hợp ZPOPMIN/BZPOPMIN. Với delayed queue, score là thời điểm cần chạy. Với rate limiter, score là thời điểm của từng request.
Có hai điều cần nhớ. Thứ nhất, mọi chuỗi thao tác “đọc rồi ghi” phải gói trong lệnh atomic hoặc Lua script. Thứ hai, luôn kiểm tra giới hạn độ chính xác của double khi ghép nhiều giá trị vào một score. Nắm được hai điều này, bạn có thể tự tin dùng ZSET trong hệ thống production.