文章目录
- [一、为什么用 ZSet 做排行榜?](#一、为什么用 ZSet 做排行榜?)
- [二、难点:ZSet 只有一个 score,如何实现"同分按时间排序"?](#二、难点:ZSet 只有一个 score,如何实现“同分按时间排序”?)
- [三、核心方案:复合 score(Composite Score)](#三、核心方案:复合 score(Composite Score))
- 四、完整示例(医疗场景:医生接诊排行榜)
- [五、Redis 命令实现](#五、Redis 命令实现)
- [六、Java 代码实现(可直接用)](#六、Java 代码实现(可直接用))
- 七、时间复杂度分析
- 八、方案优点总结
- 九、总结
在业务系统中,我们经常会遇到排行榜:
- 医生接诊量排行榜
- 护士工作量排行榜
- 科室服务质量排名
- 健康管理积分排行榜
这些排行榜通常有两个排序维度:
- 业务分数(例如接诊人数、评分、积分)从高到低排序
- 分数相同的人,按时间顺序排序(越早出现的排越前)
Redis 的 ZSet 天生适合做排行榜,但它只有一个 score 字段,
不支持"分数 + 时间"多维度排序。
本文介绍一个经过实践验证、业内常用的方案:
用复合 score(Composite Score)在 ZSet 中同时实现分数排序 + 时间排序。
一、为什么用 ZSet 做排行榜?
ZSet 是 Redis 唯一"自带排序"的结构
zset 的底层是"跳表 + hash",具备天然排序能力,复杂度:
| 操作 | 时间复杂度 |
|---|---|
插入 / 更新分数 (ZADD) |
O(logN) |
查某人排名 (ZRANK) |
O(logN) |
查前 N 名 (ZREVRANGE) |
O(logN + N) |
对于实时更新 + 高频读取的排行榜场景非常适合:
- 医生每接诊一个病人 → 分数变化 →
ZINCRBY - 页面的排行榜实时展示 →
ZREVRANGE
如果使用 MySQL 排序,每次都要 ORDER BY LIMIT,数据量一大系统就吃不消了。
二、难点:ZSet 只有一个 score,如何实现"同分按时间排序"?
ZSet 的规则很简单:
只按 score 排序(score 小在前),
score 相同再按 member 字典序排序。
我们真正想要的排序逻辑是:
- 业务分数高的排前面
- 分数相同时,时间越早的排前面
但 Redis 只给我们一个 score,所以必须把两个维度合成一个数字。
三、核心方案:复合 score(Composite Score)
公式如下:
finalScore = bizScore * FACTOR - timestamp
含义:
bizScore * FACTOR:把业务分数放到"高位",确保分数优先排序- timestamp:让时间越早(timestamp 越小)的最终 score 越大,在逆序(ZREVRANGE)时排前面
并且:
我们最后用
ZREVRANGE(从大到小)取排行榜。
为什么使用 "减 timestamp"?
假设我们的排序目标:
- 分数越高越靠前
- 同样分数中,时间越早越靠前
因为 ZSet 正序是从"小到大",而我们想要的是"从大到小",所以最终使用:
- finalScore 越大越靠前
- 时间越早 (
timestamp越小) → finalScore 越大(因为减得少)
逻辑完全正确。
四、完整示例(医疗场景:医生接诊排行榜)
假设我们做"医生接诊量排行榜",规则如下:
- 接诊人数多的医生排前
- 接诊人数相同时,"第一次接诊时间早"的医生排前
我们选择:
FACTOR = 1,000,000,000(10^9)
确保分数部分远大于时间部分。
示例数据
| 医生 | 接诊人数 | 首次接诊时间戳(秒) | finalScore 计算 |
|---|---|---|---|
| A | 45 | 1709100000 | 45×1e9 - 1709100000 = 43,290,900,000 |
| B | 45 | 1709103600 | 45×1e9 - 1709103600 = 43,290,896,400 |
| C | 43 | 1709107200 | 43×1e9 - 1709107200 = 41,290,892,800 |
排行榜结果(ZREVRANGE):
1. A(45 分,时间最早)
2. B(45 分,时间较晚)
3. C(43 分)
完全符合业务要求。
五、Redis 命令实现
加入排行榜(ZADD)
bash
# A 医生
ZADD doctor:rank 43290900000 doc_A
# B 医生
ZADD doctor:rank 43290896400 doc_B
# C 医生
ZADD doctor:rank 41290892800 doc_C
取前 10 名
bash
ZREVRANGE doctor:rank 0 9 WITHSCORES
更新分数
bash
ZINCRBY doctor:rank 1 doc_A
(P.S. 如果接诊人数变化,需要重新计算复合 score)
六、Java 代码实现(可直接用)
java
public class DoctorRankService {
private static final long FACTOR = 1_000_000_000L;
private final Jedis jedis;
public DoctorRankService(Jedis jedis) {
this.jedis = jedis;
}
public void updateDoctorScore(String doctorId, long bizScore, long timestamp) {
// 关键:分数 * 大倍数 - 时间戳
long finalScore = bizScore * FACTOR - timestamp;
jedis.zadd("doctor:rank", finalScore, doctorId);
}
public Set<String> topN(int n) {
return jedis.zrevrange("doctor:rank", 0, n - 1);
}
}
七、时间复杂度分析
| 操作 | ZSet(跳表) | 说明 |
|---|---|---|
| 更新分数(ZADD/ZINCRBY) | O(logN) | 适合实时更新 |
| 查前 N 名(ZREVRANGE) | O(logN + N) | 快 |
| 查名次(ZRANK) | O(logN) | 快 |
对比:
- 用 hash/list 排序 = O(N log N)(应用层排序)
- 用 MySQL 排序 = O(N log N)(大量读盘、索引开销)
结论:ZSet 速度优势巨大。
八、方案优点总结
1. 只用一个 ZSet 就能实现"分数 + 时间"排序
非常适合需要多维排序的业务。
2. 更新分数是 O(logN),取前 N 名很快
医生接诊量、护士工作量这类实时更新场景特别适合。
3. 可以承载高 QPS 查询
排行榜页面通常访问很频繁,ZSet 用作缓存非常高效。
4. 业务逻辑统一:所有排序规则全靠 finalScore 控制
既灵活又易扩展。
九、总结
在医疗系统中,"实时排行榜"是一个高频且关键的能力:
医生接诊榜、护士执行榜、健康管理积分榜、科室满意度榜都令人熟悉。
Redis 的 ZSet 提供了天然有序结构,非常适合这个场景。
但 ZSet 只有一个 score,我们需要把:
- 业务分数(分高的排前)
- 时间顺序(同分时间早的排前)
转化成一个数字,这就是复合 score:
finalScore = bizScore * FACTOR - timestamp