那是一个足以载入公司P0级故障史册的夜晚。
我们承办的《xxx》游戏的全球总决赛,奖金池高达千万美金。当比赛进入最后白热化的10秒,主播声嘶力竭地吼着双方选手的操作,屏幕上方的实时积分榜,却像被时间凝固了一样,纹丝不动。
几秒后,榜单数字一阵狂跳,排名瞬间颠倒,但旋即又卡住。聊天频道被“黑幕”、“BUG”、“退钱”的弹幕淹没。赛后,赞助商的律师函和愤怒玩家的投诉邮件,塞满了公司的每一个邮箱。
我被紧急调来复盘这个“看上去很简单”的实时榜单。不查不知道,这个用ZSET构建的系统,表面光鲜,内里却埋着三颗随时会引爆的“地雷”。
前言:Redis的ZSET使用指南
ZSET的核心构造:
每个ZSET都像一张积分表,由两部分组成:
- Member (成员):唯一的、不重复的字符串。可以理解为“选手ID”。
- Score (分数):与每个
member
绑定的一个浮点数。可以理解为“选手积分”。
ZSET的魔法在于,它会自动根据score
对member
进行排序,并且所有操作都快如闪电。
ZSET必备的五个核心命令
假设我们有一个游戏积分榜,Key为rank:game:points
。
-
ZADD
:选手入榜 / 更新分数ZADD rank:game:points 1000 user:101
- 作用:将
user:101
的分数设置为1000。如果user:101
已存在,则更新其分数;如果不存在,则将其加入榜单。这是个“有则更新,无则创建”的方便命令。
-
ZINCRBY
:上分!(或扣分)ZINCRBY rank:game:points 50 user:101
- 作用:给
user:101
的当前分数增加50分。这是实时榜单最常用的命令。要扣分,给个负数就行:ZINCRBY rank:game:points -10 user:101
。
-
ZREVRANGE
:查看榜单 (从高到低)ZREVRANGE rank:game:points 0 9 WITHSCORES
- 作用:
REV
代表REVERSE
(反向),即从高分到低分。0 9
表示取排名第1到第10的用户(索引从0开始)。WITHSCORES
会同时返回分数。
-
ZREVRANK
:查询我的排名ZREVRANK rank:game:points user:101
- 作用:查询
user:101
在榜单中的排名(从高到低)。返回的是一个从0开始的索引,所以你的真实排名是rank + 1
。
-
ZSCORE
:查询我的分数ZSCORE rank:game:points user:101
- 作用:查询
user:101
的当前分数。
Java实弹演练
@Component
public class ZSetBasicTraining {
@Autowired
private StringRedisTemplate stringRedisTemplate;
public void practice() {
String key = "rank:game:points";
ZSetOperations<String, String> zSetOps = stringRedisTemplate.opsForZSet();
// 1. ZADD: 玩家 "user:101" 和 "user:102" 入榜
zSetOps.add(key, "user:101", 1000);
zSetOps.add(key, "user:102", 1500);
// 2. ZINCRBY: "user:101" 上分50
zSetOps.incrementScore(key, "user:101", 50); // 现在是1050分
// 3. ZREVRANGE: 查看Top 10
Set<ZSetOperations.TypedTuple<String>> top10 = zSetOps.reverseRangeWithScores(key, 0, 9);
System.out.println("--- Top 10 ---");
top10.forEach(tuple -> System.out.println(
"Player: " + tuple.getValue() + ", Score: " + tuple.getScore()));
// 4. ZREVRANK & ZSCORE: 查看 "user:101" 的情况
Long rank = zSetOps.reverseRank(key, "user:101"); // 排名索引 (从0开始)
Double score = zSetOps.score(key, "user:101");
System.out.println("\n--- Player user:101's Status ---");
System.out.println("Score: " + score);
System.out.println("Rank: " + (rank != null ? rank + 1 : "Not in rank"));
}
}
好了,新兵,你已经掌握了基础的射击技巧。现在,收起你的训练手册,欢迎来到真实的战场。在这里,每一个你刚学会的命令,都可能因为一个微小的疏忽,变成射向你自己的子弹。
序列化地雷 —— 没改代码,榜单怎么全乱了?
出事前一周,有个新同学为了优化显示,给榜单的UserPointDTO
类加了个avatarUrl
字段,一次看似无害的迭代。但就是这个改动,成了压垮骆驼的第一根稻草。
他们最初的代码,图省事,直接把UserPointDTO
对象序列化后,当作ZSET的member
。
// ZSET<String, UserPointDTO> operations;
UserPointDTO user = new UserPointDTO(101L, "PlayerOne", "138...");
operations.add("rank:game:points", user, 1000);
这在Java容器里没问题,但在Redis里,member
是否相等,取决于序列化后的二进制字节流。增加一个字段,哪怕只是调整一下字段顺序,都会导致序列化结果不同。
Redis一看:“哦,一个新的二进制串,这肯定是个新玩家。” 于是,同一个userId=101的选手,在榜单里出现了两个甚至多个分身,旧的“幽灵”数据永远无法被更新。榜单,从那一刻起,已经是一个被污染的“化粪池”。
【铁律】ZSET的member必须是稳定的、业务无关的字符串ID!
// 正确的做法
String key = "rank:game:points";
String member = "user:101"; // 用 "业务:ID" 格式的字符串
Double score = 1000.0;
stringRedisTemplate.opsForZSet().add(key, member, score);
// 用户的详细信息,存入Hash
Map<String, String> userDetails = new HashMap<>();
userDetails.put("username", "PlayerOne");
// ...
redisTemplate.opsForHash().putAll("user:obj:101", userDetails);
member
是骨架,必须稳定不变;Hash
是血肉,可以随时更新。骨肉分离,才能保证百毒不侵。
竞态条件的幻觉 —— 我刚加了分,排名为什么没变?
决赛当晚的榜单卡顿和错乱,源于一个更隐蔽的杀手:竞态条件(Race Condition)。
他们的加分逻辑,分为两步:
- 调用
ZINCRBY
给玩家加分。 - 调用
ZREVRANK
查询玩家的最新排名,然后推送给前端。
// 这是一个原子性灾难
public Map<String, Object> incrementScore(String userId, double delta) {
String member = "user:" + userId;
// 第1步:加分
Double newScore = redis.opsForZSet().incrementScore("rank:game:points", member, delta);
// 在这微秒级的间隙,服务器可能处理了成百上千个其他请求...
// 第2步:查排名
Long rank = redis.opsForZSet().reverseRank("rank:game:points", member);
Map<String, Object> result = new HashMap<>();
result.put("score", newScore);
result.put("rank", rank != null ? rank + 1 : null);
return result; // 这个rank很可能已经不是newScore对应的rank
}
在高并发下,这两步操作之间,可能插入了任意数量的其他玩家的加分操作。你刚为A加完分,还没来得及查排名,B、C、D的分数已经改变了整个榜单的顺序。你查到的,是一个“瞬间过时”的排名。这就是决赛当晚,榜单排名“反复横跳”的根源。
用Lua原子操作,终结一切争议
我们必须将“加分”和“查排名”这两步,合并成一个不可分割的原子操作。这,是Lua脚本的战场。
@PostMapping("/rank/incr")
public JsonData incr(@RequestParam String userId, @RequestParam long delta) {
String lua =
// "local"是Lua的关键字,KEYS和ARGV是Redis传入的参数
"local key=KEYS[1]; local member=ARGV[1]; local delta=tonumber(ARGV[2]); " +
// 1. ZINCRBY, 返回最新分数
"local new_score = redis.call('ZINCRBY', key, delta, member); " +
// 2. ZREVRANK, 返回最新排名 (0-based)
"local new_rank = redis.call('ZREVRANK', key, member); " +
// 3. 将两个结果打包返回
"return {new_score, new_rank};";
List<Object> results = stringRedisTemplate.execute(
new DefaultRedisScript<>(lua, List.class),
Collections.singletonList("rank:game:points"), // KEYS[1]
"user:" + userId, String.valueOf(delta)); // ARGV[1], ARGV[2]
// ... 解析results并返回 ...
}
这份Lua脚本,会被Redis作为一个单线程、不可中断的事务来执行。它保证了我们拿到的分数和排名,是同一个数据版本下的快照,绝对精准。stringRedisTemplate.execute
,就是我们向Redis提交这份“原子作战计划”的唯一入口。
“数据幽灵”的诅咒 —— 为什么霸榜的总是那些过气网红?
游戏榜单修复后,我们用同样的架构去做了“视频热度榜”。一个月后,产品经理又来了:“为什么榜一永远是上个月那个爆款视频?用户都审美疲劳了!”
我们犯了第三个错误:把“历史功绩”等同于“当前热度”。ZSET的分数只会累加,一个曾经的王者,会像“数据幽灵”一样,凭借其巨大的历史分数,永远压制所有新内容的崛起。
一个没有新陈代谢的榜单,就是一潭死水。
引入“时间衰减”,让榜单“活”起来
我们必须为热度引入“时间”的维度,让旧的热度随着时间流逝而衰减。
周期性全量缩放(简单粗暴)
定时任务,每小时给所有视频的热度分值乘以一个衰减因子(比如0.9)。
// ZINTERSTORE能对ZSET做运算,这里利用它实现全体成员分数乘以0.9
stringRedisTemplate.opsForZSet().intersectAndStore(
"rank:video:hot:rt", // 目标Key
Collections.singleton("rank:video:hot:rt"), // 源Key
"rank:video:hot:rt", // 结果覆盖回自己
RedisZSetCommands.Aggregate.SUM,
RedisZSetCommands.Weights.of(0.9) // 权重,即衰减因子
);
滑动窗口合成(更精细)
维护最近24小时、每小时一个ZSET。实时榜单由这24个ZSET,按不同权重(越近权重越高)ZUNIONSTORE
动态合成。这更复杂,但能更精准地反映“当下”的热度。
- 数据写入(增量、分散):用户的行为(如点击、购买)只更新当前小时的ZSET。这意味着写入操作极其轻量,永远不会操作一个庞大的集合,性能和并发能力极强。
- 数据合成(批量、加权):我们不直接读取任何单一的小时榜。相反,我们启动一个后台定时任务(比如每分钟一次),它会抓取过去24小时的所有小时榜,像“炼金术”一样,按照“越近权重越高”的原则,将它们加权合并 (
ZUNIONSTORE
) 成一个最终的、供线上用户读取的“实时总榜”。 - 数据读取(快速、集中):用户的所有读请求,都只访问那个由后台任务预先合成好的“实时总榜”。由于计算过程已经提前完成,用户的读取操作就是一次简单的
ZREVRANGE
,速度快如闪电。
这个设计的精髓在于读写分离和计算预热,将昂贵的合并计算从用户请求路径中剥离,转移到后台异步执行。
1.Key设计
- 小时增量榜 (Write-Only):
rank:video:hot:hour:{yyyy-MM-dd-HH}
- 实时合成总榜 (Read-Only for users):
rank:video:hot:realtime
2.核心服务层代码
这是实现滑动窗口模型的核心逻辑。
import com.google.common.collect.Lists;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ZSetOperations;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.data.redis.connection.RedisZSetCommands.Aggregate;
import org.springframework.data.redis.connection.RedisZSetCommands.Weights;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
@Service
public class HotRankService {
@Autowired
private StringRedisTemplate stringRedisTemplate;
private static final String HOUR_RANK_PREFIX = "rank:video:hot:hour:";
private static final String REALTIME_RANK_KEY = "rank:video:hot:realtime";
private static final DateTimeFormatter HOUR_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd-HH");
/**
* 写入路径:为某个视频在当前小时的榜单上增加分数
* 这个操作非常轻量,只会落在当前小时的ZSET上
*
* @param videoId 视频ID
* @param scoreDelta 分数增量
*/
public void incrementScore(String videoId, double scoreDelta) {
String currentHourKey = getHourlyKey(LocalDateTime.now());
ZSetOperations<String, String> zSetOps = stringRedisTemplate.opsForZSet();
// 对当前小时的榜单执行ZINCRBY
zSetOps.incrementScore(currentHourKey, videoId, scoreDelta);
// 首次写入时,为该小时榜设置一个稍长于24小时的过期时间,让其能自动清理
if (zSetOps.size(currentHourKey) == 1) {
stringRedisTemplate.expire(currentHourKey, 25, TimeUnit.HOURS);
}
}
/**
* 核心计算:合成实时榜单
* 这个方法应该由后台定时任务调用,而不是用户请求直接触发
*/
public void synthesizeRealtimeRank() {
// 1. 获取过去24小时的所有小时榜Key
List<String> hourlyKeys = getRecentHourlyKeys(24);
// 2. 定义权重:越近的小时,权重越高。这里使用一个简单的线性衰减模型。
// 例如:当前小时权重为1.0, 1小时前为0.95, 2小时前为0.9...
List<Double> weights = IntStream.range(0, hourlyKeys.size())
.mapToDouble(i -> 1.0 - (double) i * 0.04) // 简单的线性衰减
.boxed()
.collect(Collectors.toList());
// Guava反转列表,因为我们的hourlyKeys是从近到远,权重也应该是从高到低
Collections.reverse(weights);
// 3. 执行ZUNIONSTORE进行加权合并
// ZUNIONSTORE destination numkeys key [key ...] [WEIGHTS weight [weight ...]]
ZSetOperations<String, String> zSetOps = stringRedisTemplate.opsForZSet();
// 为了保证原子性,我们先合成到一个临时Key
String tempRealtimeKey = REALTIME_RANK_KEY + ":temp";
zSetOps.unionAndStore(
hourlyKeys.get(0), // 第一个Key
hourlyKeys.subList(1, hourlyKeys.size()), // 其他Keys
tempRealtimeKey, // 目标Key
Aggregate.SUM, // 聚合方式为SUM
Weights.of(weights.stream().mapToDouble(d -> d).toArray()) // 权重
);
// 4. 原子地将临时Key重命名为正式的实时榜单Key,实现无缝切换
stringRedisTemplate.rename(tempRealtimeKey, REALTIME_RANK_KEY);
// 5. 为实时榜单设置一个较短的TTL,比如5分钟。
// 这样即使用于合成的定时任务挂了,榜单也只会在5分钟后消失,而不是永远展示一个旧的榜单。
stringRedisTemplate.expire(REALTIME_RANK_KEY, 5, TimeUnit.MINUTES);
}
/**
* 读取路径:从预先合成好的实时总榜中获取Top N
*
* @param topN 需要获取的排名数量
* @return 带有分数和排名的列表
*/
public Set<ZSetOperations.TypedTuple<String>> getTopN(int topN) {
return stringRedisTemplate.opsForZSet().reverseRangeWithScores(REALTIME_RANK_KEY, 0, topN - 1);
}
private String getHourlyKey(LocalDateTime dateTime) {
return HOUR_RANK_PREFIX + dateTime.format(HOUR_FORMATTER);
}
private List<String> getRecentHourlyKeys(int hours) {
LocalDateTime now = LocalDateTime.now();
return IntStream.range(0, hours)
.mapToObj(i -> getHourlyKey(now.minusHours(i)))
.collect(Collectors.toList());
}
}
3.后台调度器
创建一个调度器,按固定频率(例如,每分钟)调用synthesizeRealtimeRank
方法。
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@Component
public class RankScheduler {
@Autowired
private HotRankService hotRankService;
/**
* 每分钟执行一次,合成最新的实时榜单
* cron表达式 "0 * * * * ?" 表示每分钟的第0秒触发
*/
@Scheduled(cron = "0 * * * * ?")
public void scheduleSynthesizeRank() {
// 在这里可以加入分布式锁,确保多实例下只有一个节点执行合成任务
// tryLock(...)
try {
hotRankService.synthesizeRealtimeRank();
} finally {
// unlock()
}
}
}
注意:在生产环境的多实例部署中,这个定时任务必须加上分布式锁(如Redisson),确保同一时间只有一个实例在执行榜单合成,避免资源浪费和冲突。
4.API控制器
前端接口非常简单,它只和HotRankService
交互。
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.ZSetOperations;
import org.springframework.web.bind.annotation.*;
import java.util.Set;
@RestController
@RequestMapping("/api/rank")
public class RankController {
@Autowired
private HotRankService hotRankService;
/**
* 供前端读取实时榜单Top100
*/
@GetMapping("/hot")
public Set<ZSetOperations.TypedTuple<String>> getHotRank() {
return hotRankService.getTopN(100);
}
/**
* 供业务方上报分数(例如,视频被播放了一次)
*/
@PostMapping("/heat")
public void addHeat(@RequestParam String videoId, @RequestParam(defaultValue = "1.0") double score) {
hotRankService.incrementScore(videoId, score);
}
}
实时榜单的架构铁律
- 骨肉分离,ID为王:ZSET的
member
必须使用稳定、唯一的字符串ID。血肉(详细信息)请存放在Hash中。这是避免序列化灾难的唯一法则。 - 原子性是生命线:任何“读-改-写”或“写-读”的多步操作,都必须封装在Lua脚本中,通过
execute
提交。否则,你看到的排名永远是“薛定谔的排名”。 - 为榜单注入新陈代谢:没有时间衰减机制的“热榜”,终将变成“死榜”。用
ZINTERSTORE
或ZUNIONSTORE
,让你的榜单反映“当下”,而非“历史”。 - 读写分离的一致性陷阱:当使用主从复制时,要警惕“读自己刚写的数据”时的主从延迟。“写后粘主”或用Lua合并读写是跨越这个陷阱的两种经典姿态。
- 为失控设计:反作弊、数据冲销(
ZINCRBY
负分)、黑名单,这些不是“附加功能”,而是在真实、混乱的商业环境中,保证你榜单不被“玩坏”的“安全阀”。
在批处理的世界里,你有充足的时间去追求最终的正确。但在实时的世界里,你只有微秒级的时间,去保证每一个瞬间的精准。
转载自CSDN-专业IT技术社区
版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
原文链接:https://blog.csdn.net/qq_45740561/article/details/151288727