关注

停止用ZSET做“玩具”!你的实时榜单,离生产环境还差十个“战场”

那是一个足以载入公司P0级故障史册的夜晚。

我们承办的《xxx》游戏的全球总决赛,奖金池高达千万美金。当比赛进入最后白热化的10秒,主播声嘶力竭地吼着双方选手的操作,屏幕上方的实时积分榜,却像被时间凝固了一样,纹丝不动。

几秒后,榜单数字一阵狂跳,排名瞬间颠倒,但旋即又卡住。聊天频道被“黑幕”、“BUG”、“退钱”的弹幕淹没。赛后,赞助商的律师函和愤怒玩家的投诉邮件,塞满了公司的每一个邮箱。

我被紧急调来复盘这个“看上去很简单”的实时榜单。不查不知道,这个用ZSET构建的系统,表面光鲜,内里却埋着三颗随时会引爆的“地雷”。

前言:Redis的ZSET使用指南

ZSET的核心构造:
每个ZSET都像一张积分表,由两部分组成:

  • Member (成员):唯一的、不重复的字符串。可以理解为“选手ID”。
  • Score (分数):与每个member绑定的一个浮点数。可以理解为“选手积分”。

ZSET的魔法在于,它会自动根据scoremember进行排序,并且所有操作都快如闪电。

ZSET必备的五个核心命令

假设我们有一个游戏积分榜,Key为rank:game:points

  1. ZADD:选手入榜 / 更新分数

    • ZADD rank:game:points 1000 user:101
    • 作用:将user:101的分数设置为1000。如果user:101已存在,则更新其分数;如果不存在,则将其加入榜单。这是个“有则更新,无则创建”的方便命令。
  2. ZINCRBY:上分!(或扣分)

    • ZINCRBY rank:game:points 50 user:101
    • 作用:给user:101的当前分数增加50分。这是实时榜单最常用的命令。要扣分,给个负数就行:ZINCRBY rank:game:points -10 user:101
  3. ZREVRANGE:查看榜单 (从高到低)

    • ZREVRANGE rank:game:points 0 9 WITHSCORES
    • 作用:REV代表REVERSE(反向),即从高分到低分。0 9表示取排名第1到第10的用户(索引从0开始)。WITHSCORES会同时返回分数。
  4. ZREVRANK:查询我的排名

    • ZREVRANK rank:game:points user:101
    • 作用:查询user:101在榜单中的排名(从高到低)。返回的是一个从0开始的索引,所以你的真实排名是rank + 1
  5. 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)

他们的加分逻辑,分为两步:

  1. 调用ZINCRBY给玩家加分。
  2. 调用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动态合成。这更复杂,但能更精准地反映“当下”的热度。

  1. 数据写入(增量、分散):用户的行为(如点击、购买)只更新当前小时的ZSET。这意味着写入操作极其轻量,永远不会操作一个庞大的集合,性能和并发能力极强。
  2. 数据合成(批量、加权):我们不直接读取任何单一的小时榜。相反,我们启动一个后台定时任务(比如每分钟一次),它会抓取过去24小时的所有小时榜,像“炼金术”一样,按照“越近权重越高”的原则,将它们加权合并 (ZUNIONSTORE) 成一个最终的、供线上用户读取的“实时总榜”。
  3. 数据读取(快速、集中):用户的所有读请求,都只访问那个由后台任务预先合成好的“实时总榜”。由于计算过程已经提前完成,用户的读取操作就是一次简单的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);
    }
}

实时榜单的架构铁律

  1. 骨肉分离,ID为王:ZSET的member必须使用稳定、唯一的字符串ID。血肉(详细信息)请存放在Hash中。这是避免序列化灾难的唯一法则。
  2. 原子性是生命线:任何“读-改-写”或“写-读”的多步操作,都必须封装在Lua脚本中,通过execute提交。否则,你看到的排名永远是“薛定谔的排名”。
  3. 为榜单注入新陈代谢:没有时间衰减机制的“热榜”,终将变成“死榜”。用ZINTERSTOREZUNIONSTORE,让你的榜单反映“当下”,而非“历史”。
  4. 读写分离的一致性陷阱:当使用主从复制时,要警惕“读自己刚写的数据”时的主从延迟。“写后粘主”或用Lua合并读写是跨越这个陷阱的两种经典姿态。
  5. 为失控设计:反作弊、数据冲销(ZINCRBY负分)、黑名单,这些不是“附加功能”,而是在真实、混乱的商业环境中,保证你榜单不被“玩坏”的“安全阀”。

在批处理的世界里,你有充足的时间去追求最终的正确。但在实时的世界里,你只有微秒级的时间,去保证每一个瞬间的精准。

转载自CSDN-专业IT技术社区

版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。

原文链接:https://blog.csdn.net/qq_45740561/article/details/151288727

评论

赞0

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--