1. 项目背景
1.1 业务场景
运营团队规划了两项活动需求:
第一,618 积分冲榜。用户在活动期间下单、评价、分享都能获得积分,App 首页实时展示"积分榜 Top 100"和"我的排名"。运营还要求每天 0 点自动归档昨日榜单,生成"日冠军"名单,同时总榜继续累加。产品经理强调:相同积分必须按并列名次处理,不能因为 Redis 默认排序让同分用户分出先后。
第二,订单 30 分钟未支付自动关闭。用户创建订单后 30 分钟内如果未完成支付,系统需要自动关闭订单、释放冻结的库存,并退还优惠券。这个需求如果用数据库定时任务实现——每分钟执行 UPDATE orders SET status='CLOSED' WHERE status='WAIT_PAY' AND created_at < NOW() - INTERVAL 30 MINUTE——会遇到两个问题:扫描间隔太短会影响数据库性能,间隔太长会导致订单关闭不及时。而且在秒杀场景下,同一秒可能有上千订单到期,数据库行锁竞争会很激烈。
这两个需求看似不相关,但本质都是"按某个数值排序并快速取出 Top N"——排行榜排的是积分,延时任务排的是执行时间戳。Redis ZSet 恰好是解决这类问题的标准答案。
1.2 痛点
| 需求 | 数据库方案的表现 | ZSet 方案的优势 |
|---|---|---|
| 积分榜 Top 100 | SELECT uid, score FROM rank ORDER BY score DESC LIMIT 100 → 高峰期每次积分变化都触发排序,大表全表扫描或索引回表 | ZREVRANGE key 0 99 WITHSCORES → O(log N+100),不依赖全表扫描 |
| 查我的排名 | SELECT COUNT(*) FROM rank WHERE score > myScore → O(N) 统计,慢查询 | ZREVRANK key uid → O(log N) |
| 积分更新 | UPDATE rank SET score = score + ? WHERE uid = ? → 行锁,热点行竞争 | ZINCRBY key 100 uid → O(log N) 原子操作 |
| 订单超时扫描 | SELECT * FROM orders WHERE status='WAIT_PAY' AND expire_time < NOW() → 全表扫描或索引 + 定时轮询 | ZRANGEBYSCORE key -inf NOW LIMIT 0 50 → O(log N + 50),按需取 |
| 并发抢占 | 多个消费实例同时 UPDATE → 行锁竞争 | ZREM + 应用层抢占 → 只有删除成功的才处理 |
| 历史榜单 | 需要额外表来归档,查询需要 JOIN | 每个日期一个 ZSet key,各自独立 TTL |
1.3 本章目标
掌握 Redis ZSet 的三种实战模式:
- 积分排行榜:
ZADD/ZINCRBY/ZREVRANGE/ZREVRANK+ 并列名次设计。 - 延时任务队列:用 ZSet 的 score 存执行时间戳,
ZRANGEBYSCORE+ZREM实现消费抢占。 - 时间线/热榜:score 设计从简单积分到复合分值(时间加权)。
同时理解三个关键边界:
- ZSet 的 score 是双精度浮点数——不适合拼接复杂信息。
- 深分页(
ZREVRANGE key 10000 10020)虽然比数据库好,但仍有 O(log N + M) 的成本。 - 延时任务的消费者抢占机制——
ZREM返回 1 的才处理,但仍需数据库幂等兜底。
2. 项目设计
2.1 第一幕:score 怎么设计?
小胖:"排行榜不就是按积分排吗?积分高的站上面。ZADD rank:618 uid score,好了。"
小白:"那相同积分的人怎么排?Redis 默认同分按 member 字典序排——如果两个用户积分都是 1680,名字排序靠前的就永远在前面。但这不合理吧?并列金牌应该是两个人并列第一,下一个是第三名。"
大师:"score 设计是 ZSet 的第一课。先说我画的几种常见 score 模式:"
模式 1:单一分值(积分榜)
member: 用户ID
score: 积分
→ 简单直接,适合"同分同排名"的竞技榜
模式 2:时间戳(延时任务)
member: 订单ID / 任务ID
score: 执行时间戳(Unix 秒)
→ 越早到期的 score 越小,ZRANGEBYSCORE 按时间扫
模式 3:复合分值(热榜)
score = 基础分 + 时间加权
→ 例如: score = viewCount * 0.6 + likeCount * 0.4 + (now - postTime) * -0.1
→ 老内容逐渐降权,新内容自然上升
模式 4:时间倒序(时间线)
member: 动态ID
score: 发布时间戳
→ ZREVRANGE 返回最新发布的动态
小胖:"那并列名次怎么办?用 ZREVRANK + 1 肯定不对——因为这会给同分的人排不同的名次。"
大师:"正确做法不是直接拿 ZREVRANK,而是统计'有多少人的分数严格大于我':"
# 我的分数
ZSCORE rank:618 1001
# → "1680"
# 分数严格大于 1680 的人数
ZCOUNT rank:618 (1680 +inf
# → 2 (表示有 2 个人分数 > 1680)
# 我的名次 = 大于我的人数 + 1 = 3 (并列第 3)
技术映射:ZREVRANK 是 Redis 内部的排名(从 0 开始,同分按字典序),不等于业务的并列排名。业务并列排名 = 分数严格大于我的人数 + 1。
2.2 第二幕:延时任务——ZSet 不是 RabbitMQ
小胖:"订单关闭这事,不就是把订单 ID 放进队列,等 30 分钟再处理吗?List 不能做?"
小白:"List 的问题是——你没法'等到某个时间点才消费'。List 是先进先出,不是'到期再出'。如果你用 List,必须靠消费者轮询 + sleep,粗粒度不可靠。"
大师:"ZSet 做延时任务的核心是把'处理时间'编码为 score。消费者的逻辑非常简单:"
消费者的核心循环:
while true:
now = currentTimestamp()
tasks = ZRANGEBYSCORE delay:order:close -inf now LIMIT 0 50
for task in tasks:
if ZREM delay:order:close task == 1: ← 抢占成功
processTask(task)
else:
continue ← 被其他消费者抢了
sleep(1) ← 没有到期任务时休眠 1 秒
小胖:"那如果消费者 ZREM 成功后宕机了呢?订单不就没人关了?"
大师:"这就要靠数据库兜底了。ZREM 只保证'Redis 中的任务被移除且只有一个消费者拿到',但消费者拿到后能不能成功处理,是另一个问题。所以处理逻辑必须是幂等的:"
-- 幂等关闭订单(只有 WAIT_PAY 状态的才会被关闭)
UPDATE orders
SET status = 'CLOSED', closed_at = NOW()
WHERE id = ? AND status = 'WAIT_PAY';
大师:"而且还需要一个对账任务——每小时扫描一次数据库,找出所有超过 30 分钟仍处于 WAIT_PAY 状态的订单,用 ZADD 重新放回 ZSet。这样即使消费者崩溃了,最多 1 小时后订单也会被重新处理。"
技术映射:ZSet 延时任务 = 调度层(ZSet 负责"到点了发现任务") + 执行层(应用负责"正确地执行任务") + 兜底层(对账任务负责"发现漏网之鱼")。
2.3 第三幕:历史榜单和 key 生命周期
小胖:"日榜要每天归档,周榜每周归档,总榜不归档。这么多榜单,Redis key 不会爆炸吗?"
大师:"按日期分 key 是最清晰的设计:"
日榜: rank:618:daily:20260718 → EXPIRE 30 天后自动删除
周榜: rank:618:weekly:2026W29 → EXPIRE 90 天后自动删除
总榜: rank:618:total → 不设过期,长期保留
每次积分变动同时更新:
ZINCRBY rank:618:daily:20260718 10 uid
ZINCRBY rank:618:weekly:2026W29 10 uid
ZINCRBY rank:618:total 10 uid
小白:"三个 ZSet 三倍内存,会不会太浪费?"
大师:"单个 ZSet 的内存开销并不大——1 万个 member 的 ZSet 大约占用 200-400KB。而且日榜设了 30 天 TTL,到时会自动清理。相比把多个日期塞进一个 ZSet(用 member 拼接日期),分开 key 的查询性能更好、清理也更简单。"
技术映射:按时间维度拆分 key 是 Redis 的最佳实践。"大而全"的 key 查询复杂且难以清理。
2.4 第四幕:热榜——score 可以随时间变化
小胖:"热榜是什么?和积分榜不一样吗?"
大师:"积分榜是纯累加——分越来越高。热榜是动态的——老内容即使之前很火,时间久了也应该降权。这要求 score 不是一成不变的:"
热榜 score 的组成:
score = 热度分 - 时间衰减
热度分 = viewCount * 1 + likeCount * 3 + commentCount * 5
时间衰减 = (当前时间 - 发布时间) * 衰减系数
发布时间越早,衰减越大,score 越低
新发布的内容即使热度分不高,衰减小,score 可能高于老内容
大师:热榜的 score 需要定期刷新(比如每 5 分钟重算一次)。但这会带来一个问题——如果全量重算所有内容的 score,在 100 万条数据时非常重。实际做法是:只重算最近 N 天发布的内容 + 前 1000 名热度内容;其他内容可以降低刷新频率。
3. 项目实战
3.1 环境准备
docker exec -it redis-lab redis-cli -a redis123
3.2 实战一:积分排行榜
步骤 1:初始化榜单
# ZADD:key=榜单名,score=积分,member=用户ID
127.0.0.1:6379> ZADD rank:618:daily:20260718 1200 1001 980 1002 1680 1003 1680 1004 750 1005
(integer) 5
# 查看 Top 5(按积分从高到低)
127.0.0.1:6379> ZREVRANGE rank:618:daily:20260718 0 4 WITHSCORES
1) "1003"
2) "1680"
3) "1004"
4) "1680"
5) "1001"
6) "1200"
7) "1002"
8) "980"
9) "1005"
10) "750"
# 注意:1003 和 1004 同分 1680,Redis 按 member 字典序排(1003 < 1004)
步骤 2:积分增减和排名查询
# 用户 1002 完成任务,加 300 分
127.0.0.1:6379> ZINCRBY rank:618:daily:20260718 300 1002
"1280"
# 查询用户 1002 的当前分数
127.0.0.1:6379> ZSCORE rank:618:daily:20260718 1002
"1280"
# 查询排名(Redis 内部排名,从 0 开始,高分在前用 ZREVRANK)
127.0.0.1:6379> ZREVRANK rank:618:daily:20260718 1002
(integer) 2 # 排名第 3(0-based → 展示为 3)
# 查询低分到高分的排名(用 ZRANK)
127.0.0.1:6379> ZRANK rank:618:daily:20260718 1005
(integer) 0 # 1005 分数 750,最低,排第 1(从低到高)
# 查看用户附近排名(上下各 2 名)
127.0.0.1:6379> ZREVRANK rank:618:daily:20260718 1001
(integer) 1 # 我排第 2
127.0.0.1:6379> ZREVRANGE rank:618:daily:20260718 0 3 WITHSCORES
# 返回 0~3 名,包含了我的前一名和后两名
步骤 3:并列名次处理(关键)
# 1003 和 1004 都是 1680 分 → 应该并列第一
# 错误做法:
127.0.0.1:6379> ZREVRANK rank:618:daily:20260718 1003
(integer) 0 # 排名 0 → 直接 +1 = 第 1 名
127.0.0.1:6379> ZREVRANK rank:618:daily:20260718 1004
(integer) 1 # 排名 1 → 直接 +1 = 第 2 名 ← 错误!应该是并列第 1
# 正确做法:统计分数严格大于我的人数
127.0.0.1:6379> ZSCORE rank:618:daily:20260718 1003
"1680"
127.0.0.1:6379> ZCOUNT rank:618:daily:20260718 (1680 +inf
(integer) 0 # 0 个人分数 > 1680 → 排名第 1
127.0.0.1:6379> ZSCORE rank:618:daily:20260718 1001
"1200"
127.0.0.1:6379> ZCOUNT rank:618:daily:20260718 (1200 +inf
(integer) 2 # 2 个人分数 > 1200(1003 和 1004) → 排名第 3
Java 并列排名实现:
public class RankService {
private static final String RANK_KEY_PREFIX = "rank:618:daily:";
// 获取用户积分
public double getScore(String date, String uid) {
Double score = redis.zScore(RANK_KEY_PREFIX + date, uid);
return score == null ? 0 : score;
}
// 增加积分
public double addScore(String date, String uid, double delta) {
return redis.zIncrBy(RANK_KEY_PREFIX + date, delta, uid);
}
// 获取 Top N
public List<RankItem> getTopN(String date, int n) {
Set<ZSetOperations.TypedTuple<String>> topN =
redis.zRevRangeWithScores(RANK_KEY_PREFIX + date, 0, n - 1);
return topN.stream()
.map(t -> new RankItem(t.getValue(), t.getScore()))
.collect(Collectors.toList());
}
// 获取用户的业务排名(并列处理)
public long getUserRank(String date, String uid) {
String key = RANK_KEY_PREFIX + date;
Double score = redis.zScore(key, uid);
if (score == null) {
return -1; // 未上榜
}
// 统计分数严格大于该用户的 count
Long higherCount = redis.zCount(key, score + 1, Double.POSITIVE_INFINITY);
// 名次 = 高于我的 count + 1
return (higherCount == null ? 0 : higherCount) + 1;
}
// 获取用户附近排名(上下各 m 名)
public List<RankItem> getUserNearby(String date, String uid, int m) {
String key = RANK_KEY_PREFIX + date;
Long myRank = redis.zRevRank(key, uid); // Redis 内部排名
if (myRank == null) return Collections.emptyList();
long start = Math.max(0, myRank - m);
long end = myRank + m;
Set<ZSetOperations.TypedTuple<String>> nearby =
redis.zRevRangeWithScores(key, start, end);
return nearby.stream()
.map(t -> new RankItem(t.getValue(), t.getScore()))
.collect(Collectors.toList());
}
}
3.3 实战二:延时任务(订单超时关闭)
步骤 1:写入延时任务
# 订单 9001 期望在时间戳 1714470000 关闭
# 订单 9002 期望在时间戳 1714470060 关闭
127.0.0.1:6379> ZADD delay:order:close 1714470000 9001
(integer) 1
127.0.0.1:6379> ZADD delay:order:close 1714470060 9002
(integer) 1
# 查看所有延时任务(按到期时间排序)
127.0.0.1:6379> ZRANGE delay:order:close 0 -1 WITHSCORES
1) "9001"
2) "1714470000"
3) "9002"
4) "1714470060"
# 查看还有多少秒到期
127.0.0.1:6379> ZSCORE delay:order:close 9001
"1714470000"
步骤 2:消费者扫描并抢占任务
# 假设当前时间戳为 1714470000
# 第一步:扫描已到期的任务
127.0.0.1:6379> ZRANGEBYSCORE delay:order:close -inf 1714470000 LIMIT 0 50
1) "9001" # 只返回已到期的
# 第二步:用 ZREM 抢占(返回 1 表示抢占成功)
127.0.0.1:6379> ZREM delay:order:close 9001
(integer) 1 # 抢占成功!
# 如果另一个消费者也尝试 ZREM 同一个订单
127.0.0.1:6379> ZREM delay:order:close 9001
(integer) 0 # 抢占失败——已被别人处理或不存在
步骤 3:Java 延时任务消费者
public class DelayedTaskConsumer implements Runnable {
private static final String DELAY_KEY = "delay:order:close";
private static final int BATCH_SIZE = 50;
private static final int POLL_INTERVAL_MS = 1000;
private final AtomicBoolean running = new AtomicBoolean(true);
@Override
public void run() {
while (running.get()) {
try {
long now = System.currentTimeMillis() / 1000;
// 1. 扫描已到期的任务
Set<String> expiredIds = redis.zRangeByScore(
DELAY_KEY, 0, now, 0, BATCH_SIZE);
if (expiredIds == null || expiredIds.isEmpty()) {
Thread.sleep(POLL_INTERVAL_MS);
continue;
}
// 2. 逐个抢占
for (String orderId : expiredIds) {
Long removed = redis.zRem(DELAY_KEY, orderId);
if (removed != null && removed == 1) {
// 抢占成功,处理任务
processDelayedTask(orderId);
}
// removed == 0 → 被其他消费者抢了,跳过
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
} catch (Exception e) {
log.error("延时任务处理异常", e);
safeSleep(2000);
}
}
}
private void processDelayedTask(String orderId) {
// 幂等关闭订单——只有 WAIT_PAY 状态才会被关闭
int affected = jdbcTemplate.update(
"UPDATE orders SET status = 'CLOSED', closed_at = NOW() " +
"WHERE id = ? AND status = 'WAIT_PAY'",
orderId
);
if (affected > 0) {
log.info("订单 {} 超时自动关闭成功", orderId);
// 释放库存、退还优惠券等后续操作...
} else {
log.info("订单 {} 已非待支付状态,跳过关闭", orderId);
}
}
}
步骤 4:对账任务(兜底)
@Scheduled(fixedDelay = 3600_000) // 每小时执行一次
public void reconcile() {
// 从数据库找出超时未关闭的订单
List<String> staleOrders = jdbcTemplate.queryForList(
"SELECT id FROM orders " +
"WHERE status = 'WAIT_PAY' " +
"AND created_at < NOW() - INTERVAL 30 MINUTE",
String.class
);
for (String orderId : staleOrders) {
// 问:这个订单还在 ZSet 里吗?
Double score = redis.zScore(DELAY_KEY, orderId);
if (score == null) {
// 不在 ZSet 里 → 可能被消费者取走了但处理失败
// 重新放入 ZSet,score 设为现在(立即触发)
redis.zAdd(DELAY_KEY, System.currentTimeMillis() / 1000.0, orderId);
log.warn("对账发现遗漏订单 {}, 重新加入延时队列", orderId);
}
// 如果在 ZSet 里 → score 如果已经小于 now,说明消费者还没来得及扫到
// score 如果还在未来,说明还没到期,正常
}
}
3.4 实战三:复合分值热榜
# 热榜 score 可以多维合成
# rank:hot -> score = viewCount * 1 + likeCount * 3 - timeDecay
# 初始化热榜数据
127.0.0.1:6379> ZADD rank:hot:20260718 850 post:1001 920 post:1002 780 post:1003
# 每次浏览增加 1 分
127.0.0.1:6379> ZINCRBY rank:hot:20260718 1 post:1001
"851"
# 每次点赞增加 3 分
127.0.0.1:6379> ZINCRBY rank:hot:20260718 3 post:1001
"854"
# 定期做时间衰减(比如每 30 分钟执行一次)
# 从 ZSet 取出所有 member 和 score,减去时间衰减后写回
# (实际项目中,往往是懒计算——读取时根据发布时间和当前时间算衰减,
# 而不是频繁全量更新 score)
时间衰减的懒计算模式(推荐):
public class HotRankService {
// 不实时更新 score 做衰减,而是读取时动态计算
public List<HotPost> getHotPosts(String date, int limit) {
// 1. 从 ZSet 拿原始热度分 Top N*2(留余量)
Set<ZSetOperations.TypedTuple<String>> candidates =
redis.zRevRangeWithScores("rank:hot:raw:" + date, 0, limit * 2 - 1);
List<HotPost> result = new ArrayList<>();
long now = System.currentTimeMillis() / 1000;
for (ZSetOperations.TypedTuple<String> t : candidates) {
String postId = t.getValue();
double rawScore = t.getScore();
// 2. 查帖子发布时间
Long postTime = getPostPublishTime(postId);
// 3. 计算衰减后的真实分数
long ageSeconds = now - postTime;
double decay = ageSeconds / 3600.0 * 0.5; // 每小时降 0.5 分
double finalScore = rawScore - decay;
result.add(new HotPost(postId, finalScore));
}
// 4. 按最终分数重新排序
result.sort((a, b) -> Double.compare(b.score, a.score));
return result.subList(0, Math.min(limit, result.size()));
}
}
3.5 实战四:多榜单联动(日榜+周榜+总榜)
# 一笔积分写入同时更新三个榜单
127.0.0.1:6379> ZINCRBY rank:618:daily:20260718 100 1001
"1300"
127.0.0.1:6379> ZINCRBY rank:618:weekly:2026W29 100 1001
"1300"
127.0.0.1:6379> ZINCRBY rank:618:total 100 1001
"1300"
# 日榜设置 30 天过期
127.0.0.1:6379> EXPIRE rank:618:daily:20260718 2592000
(integer) 1
# 周榜设置 90 天过期
127.0.0.1:6379> EXPIRE rank:618:weekly:2026W29 7776000
(integer) 1
# 总榜不设置过期
127.0.0.1:6379> TTL rank:618:total
(integer) -1 # 永不过期
3.6 实战五:ZSet 范围操作
# 查询分数在 1000-2000 之间的用户
127.0.0.1:6379> ZRANGEBYSCORE rank:618:daily:20260718 1000 2000 WITHSCORES
1) "1001" 2) "1200"
2) "1002" 3) "1280"
3) "1003" 4) "1680"
4) "1004" 5) "1680"
# 按排名删除末位(保留前 1000 名,删除 1001 名之后)
127.0.0.1:6379> ZREMRANGEBYRANK rank:618:daily:20260718 0 -1001
# 删除 750 分以下的用户
127.0.0.1:6379> ZREMRANGEBYSCORE rank:618:daily:20260718 -inf 750
# 统计分数在某范围的人数
127.0.0.1:6379> ZCOUNT rank:618:daily:20260718 1000 2000
(integer) 3
# 获取排行榜总人数
127.0.0.1:6379> ZCARD rank:618:daily:20260718
(integer) 5
3.7 实战六:观察 ZSet 编码和内存
# 小 ZSet 用 listpack 编码(Redis 7.0+)
127.0.0.1:6379> OBJECT ENCODING rank:618:daily:20260718
"listpack"
# 查看内存占用
127.0.0.1:6379> MEMORY USAGE rank:618:daily:20260718
(integer) 312
# 当成员数量超过阈值,编码自动切换为 skiplist(跳表+哈希表)
# 跳表负责排序,哈希表负责 O(1) 查找 member 的 score
4. 项目总结
4.1 核心命令速查
| 命令 | 用途 | 复杂度 | 注意事项 |
|---|---|---|---|
ZADD key score member | 添加/更新成员 | O(log N) | 可一次加多个;member 已存在则更新 score |
ZINCRBY key delta member | 原子自增 score | O(log N) | member 不存在时先加再增 |
ZSCORE key member | 获取成员分数 | O(1) | member 不存在返回 nil |
ZRANK key member | 排名(低→高,从 0 开始) | O(log N) | |
ZREVRANK key member | 排名(高→低,从 0 开始) | O(log N) | 等同分不同 member 排名不同 |
ZRANGE key start end | 按排名范围取 | O(log N+M) | M 是返回数量 |
ZREVRANGE key start end | 倒序按排名取 | O(log N+M) | 带 WITHSCORES 返回分数 |
ZRANGEBYSCORE key min max | 按分数范围取 | O(log N+M) | (1000 表示 >1000;LIMIT offset count 分页 |
ZCOUNT key min max | 统计分数范围 count | O(log N) | 等同分名次的关键命令 |
ZREM key member | 删除成员 | O(log N) | 延时任务抢占的核心 |
ZREMRANGEBYRANK key start end | 按排名删除 | O(log N+M) | 保留前 N 名 |
ZREMRANGEBYSCORE key min max | 按分数删除 | O(log N+M) | |
ZCARD key | 获取成员总数 | O(1) | |
ZINTERSTORE dst N k1 k2 | 交集存为新 ZSet | O(N*K)+O(M log M) | 可指定聚合方式(SUM/MIN/MAX) |
ZUNIONSTORE dst N k1 k2 | 并集存为新 ZSet | O(N)+O(M log M) | 同上 |
4.2 优点与缺点
| 维度 | 详细说明 |
|---|---|
| 优点1:天然排序 | 插入时就维护好顺序,读取时直接按 index 范围取 |
| 优点2:排名查询快 | ZRANK/ZREVRANK 都是 O(log N),远胜数据库的 COUNT |
| 优点3:原子自增 | ZINCRBY 不需要读改写,高并发下安全 |
| 优点4:延时任务轻量 | 无需额外中间件就能实现"按时间触发"的调度 |
| 优点5:范围操作丰富 | 按排名、按 score、按字典序,灵活组合 |
| 缺点1:score 精度限制 | 双精度浮点数,拼接复合信息需谨慎 |
| 缺点2:深分页有开销 | ZREVRANGE key 100000 100020 仍需遍历前面 100000 个节点 |
| 缺点3:不存复杂属性 | member 通常只是 ID,需要额外查其他数据源 |
| 缺点4:全量更新重 | 热榜的时间衰减如果全量重算,大数据量时成本高 |
4.3 适用与不适用场景
适用场景:
- 各类排行榜——积分榜、战力榜、消费榜、贡献榜,支持日/周/月/总榜。
- 延时任务——订单超时关闭、优惠券到期提醒、定时推送。
- 时间线——按发布时间排序的动态流、消息列表。
- 热榜——浏览+点赞+评论复合分数,支持时间衰减。
- 优先级队列——按优先级 score 排序的任务队列,
ZPOPMAX取最高优先级。 - 滑动窗口限流——member 是请求 ID/timestamp,score 是时间戳,
ZREMRANGEBYSCORE清理过期窗口。
不适用场景:
- 需要消息确认和重投的可靠队列——应使用 Stream(第 20 章)。
- 只需"有没有"而不需排序的关系——应使用 Set(第 6 章)。
- 大量成员需要按复杂属性过滤——ZSet 只有 score 一个排序维度。
- member 非常大且数量极多——ZSet 的内部跳表需要额外的指针开销。
4.4 常见踩坑经验
| 案例 | 表象 | 根因 | 解法 |
|---|---|---|---|
| 并列名次计算错误 | 用户投诉"我和朋友都是 1680 分,他显示第 1 名我显示第 2 名" | 用 ZREVRANK + 1 直接当名次,同分被字典序分开 | 改用 ZSCORE + ZCOUNT 统计严格大于自己的人数 + 1 |
| 延时任务被重复处理 | 用户收到两条"订单已关闭"的短信 | 两个消费者同时 ZRANGEBYSCORE 拿到同批任务,都用 ZREM 抢占但 DB 更新没加状态条件 | ZREM 判断返回值;DB UPDATE 加 WHERE status='WAIT_PAY' 幂等 |
| 热榜全量重算打挂 Redis | 每 5 分钟热榜全量重算,Redis CPU 飙到 95% | 100 万条 member 全量 ZADD 更新 score | 只更新近 3 天数据 + 前 1000 名;其他数据用懒衰减 |
| 深分页慢查询 | 排行榜"第 1000 页"加载需要 2 秒 | ZREVRANGE key 10000 10020 的 O(log N + 10000) 成本 | 限制最大翻页深度(如前 100 页);提供"跳转到我的排名附近" |
| score 浮点精度问题 | 复合 score(时间戳+积分)排序不准 | 把时间戳(10 位)+ 积分拼接进双精度浮点数,超出 53 位有效精度 | 复合 score 尽量保持在一个数量级内;或用二级排序 |
4.5 思考题
-
设计题:一个直播平台的"小时榜"需要统计最近 60 分钟的礼物收入,每分钟刷新一次。如果用一个 ZSet,你需要定期清理 60 分钟前的数据。请设计具体的实现方案——key 设计、score 设计、清理策略,以及如何在清理期间保持榜单正确性。
-
优化题:延时任务队列(
delay:order:close)中堆积了 100 万个待关闭订单。消费者每次ZRANGEBYSCORE -inf now LIMIT 0 50,但发现每次扫描都返回空(因为 score 最小的订单还没到期)。如何优化避免"空转扫描"浪费 CPU?
(答案提示将在下一章末尾给出。)
上一章思考题答案提示(第6章):
第1题(多维推荐):不同维度的关联度量化:共同关注数用
SINTERCARD直接获取数量;共同群聊用SINTER groups:A groups:B后用SCARD;通讯录匹配在应用层做。最终推荐分数 = 各维度加权求和(如共同关注×5 + 共同群聊×3 + 通讯录×10)。TopN 推荐:离线任务对所有候选计算分数后写入ZSet(recommend:scores:{uid}),member=候选 userId,score=推荐分数,前端读ZREVRANGE即可。第2题(取关后仍看到动态):3 种可能:① 动态内容在取关前已被推送到时间线缓存里(如用户下拉刷新前已经加载了这批数据)——前端刷新后即消失;② 关注/取关的双写不一致——followings 集合已删除但 followers 或关系链缓存未更新——检查反向索引和缓存 key;③ 推送服务使用了独立的关注缓存(本地内存),取关时未通知其失效——检查是否有缓存刷新机制。