跳到主内容

从2.8s到12ms:千万级教育竞赛Redis排行榜优化实战 ​

问题背景:在线教育竞赛场景的排行榜痛点 ​

在K12刷题、职业资格竞赛等在线教育业务场景中,实时排行榜是核心互动功能:用户提交答题结果后,需要毫秒级更新排名,同时支持所有参与者实时查看全榜排名、个人附近排名,峰值场景下经常出现千万级用户同时参与活动、每秒上万次榜单查询的流量压力。

我们最初的实现方案直接基于MySQL排序:将用户得分、答题时长存入竞赛成绩表,前端请求排行榜时执行SELECT * FROM rank_table WHERE activity_id=1001 ORDER BY score DESC, use_time ASC LIMIT 0,100,在用户量不足10万时运行正常,但某次全国性刷题竞赛活动中,参与用户突破1200万,单表数据量破千万后,单条榜单查询耗时飙升到2.8s,峰值1.2万QPS直接把数据库IO打满,多次出现服务雪崩。后续我们尝试了定时异步排序、内存计算等多种方案都无法满足实时性要求,最终基于Redis ZSet落地的优化方案,把平均查询耗时压缩到12ms,完美扛住了峰值流量。

复盘当时的故障链路,问题并不只是“一条慢SQL”这么简单。MySQL在activity_id二级索引上虽然能快速过滤活动,但ORDER BY score DESC, use_time ASC需要回表取出大量记录再做排序,LIMIT 0,100无法消除前面的排序代价。实测在1200万记录、缓冲池命中率下降到72%时,榜单接口P99从300ms恶化到4.8s;应用线程被数据库等待占满后,Tomcat工作队列从200迅速增长到拒绝阈值,进而拖垮登录、报名、提交等相邻接口。换句话说,排行榜从一个“查询功能”变成了整个活动域的故障放大器。

从容量角度看,读多写多是这个场景的显著特征。用户交卷会触发成绩写入,而交卷后的几秒内,用户本人、同班同学、直播间观众都会反复刷新榜单;一次交卷往往带来5到20次读请求。当时峰值写入约1800 TPS,读峰值约1.2万 QPS,读写放大非常明显。若继续依赖数据库,只能通过增加索引、只读实例、查询缓存和限流硬扛,但这些手段无法解决“大范围实时排序”的根因。

方案对比:三类主流排行榜实现选型决策 ​

我们对比了行业内三类常见的排行榜实现方案,最终选定Redis ZSet作为核心实现:

  1. MySQL直接排序方案:优势是无需引入额外组件、开发成本低,缺点是千万级数据下全表排序会产生大量随机IO,单实例QPS上限不足50,延迟超过2s,完全无法满足高并发实时场景要求,仅适合10万用户以下的小型活动。
  2. Redis Hash+定时排序方案:优势是写入性能极高,所有用户得分存入Hash结构,后台定时任务全量排序后生成榜单快照,缺点是实时性差,最小延迟也在1分钟以上,不符合竞赛场景下“用户提交答题立刻更新排名”的产品要求。
  3. Redis ZSet有序集合方案:作为Redis原生数据结构,ZSet天生自带排序能力,成员的增删改查、排名查询、范围查询的时间复杂度均为O(logN),单实例可轻松支撑10万+写入QPS,延迟稳定在毫秒级,完全适配千万级用户的实时排行榜需求。

进一步对比三种方案的工程边界,可以更清楚地看到选型差异:

对比项MySQL直接排序Redis Hash + 定时排序Redis ZSet
实时更新理论实时,但查询代价高秒级到分钟级延迟毫秒级实时
写入能力受行锁、redo、索引维护限制很高,Hash写入接近O(1)很高,ZADD约O(logN)
TopN查询大数据量下需要filesort直接读快照,很快ZREVRANGE原生支持
个人排名需COUNT或复杂SQL快照内二次查找ZREVRANK直接支持
附近排名多次排序/子查询,成本高依赖快照索引结合RANK与RANGE完成
数据持久化强持久化取决于RDB/AOF与落库策略需配合MySQL做最终一致
适用规模10万以内百万级、延迟不敏感千万级实时榜单

这里需要强调:选择Redis ZSet并不意味着抛弃MySQL。生产实践中通常采用“MySQL作为权威存储,Redis作为实时排序加速层”的架构。交卷请求先落库或写消息队列,再更新ZSet;查询链路只访问Redis。这样既满足实时性,也保留了对账、回放、申诉和活动复盘所需的权威数据。

在更复杂的多机房场景,还需要评估Redis Cluster分片、主从切换和跨机房延迟。对于单个活动超过2000万成员的极端场景,可以按活动ID做分片,避免单个key落在一个热点slot上;但跨分片的全局TopN需要由应用层聚合,复杂度会上升。大多数单活动千万级用户场景下,一个设计合理的ZSet key已经足够,优先保证简单、可观测和可恢复,比过早做分片更重要。

实操步骤与代码示例:ZSet排行榜落地全流程 ​

1. 核心数据结构设计 ​

针对教育竞赛场景“同分情况下答题用时越短排名越靠前”的特殊规则,我们没有直接把得分作为ZSet的score值,而是做了位运算封装:最终score = 用户得分 * 1000000 + (最大允许答题时长 - 用户实际答题秒数),这样既保证得分高的用户排在前面,同分场景下答题用时少的用户自动排在前面,完全匹配竞赛业务规则。

这个公式的本质是把两个排序维度编码进一个可比较的整数。ZSet只有一个score字段,默认按score升序排列;如果业务希望高分在前,可以统一使用ZREVRANK、ZREVRANGE读取。编码时要保证时间补偿值不会侵占分数区间,因此乘数1000000必须大于最大时间补偿值。例如最大答题时长为7200秒,补偿值范围是0到7200,选择1000000作为进位单位后,分数每增加1分,编码值至少增加1000000,时间差异不可能造成跨分数反超。

在Java中可以按如下方式封装分数计算,避免各业务方重复手写公式:

java
public final class RankScoreCodec {
    private static final long SCORE_SCALE = 1_000_000L;

    private RankScoreCodec() {}

    public static double encode(long userScore, int useTimeSeconds, int maxTimeSeconds) {
        if (userScore < 0) {
            throw new IllegalArgumentException("score must be non-negative");
        }
        if (useTimeSeconds < 0 || useTimeSeconds > maxTimeSeconds) {
            throw new IllegalArgumentException("invalid use time");
        }
        long timeBonus = maxTimeSeconds - useTimeSeconds;
        return userScore * SCORE_SCALE + timeBonus;
    }

    public static long[] decode(double encoded) {
        long value = (long) encoded;
        long userScore = value / SCORE_SCALE;
        long timeBonus = value % SCORE_SCALE;
        return new long[]{userScore, timeBonus};
    }
}

如果活动还存在“交卷时间越早越靠前”的第三排序维度,可以继续扩展低位区间,例如score * 1_000_000_000 + timeBonus * 10000 + submitOrderBonus。但不要无限堆叠字段,必须先确认Redis score的精度范围。Redis score使用双精度浮点数,安全整数上限约为2^53;在设计编码时应打印最大可能值并进行越界校验,避免高位分数在极端情况下出现精度丢失。

Key设计也建议规范化,便于按活动维度统计、过期和迁移:

  • 正式榜单:activity:{activityId}:rank:zset
  • 灰度榜单:activity:{activityId}:rank:gray
  • 去重标记:activity:{activityId}:submit:set
  • 活动元数据:activity:{activityId}:meta:hash

成员ID建议使用稳定的用户唯一标识,如u:{userId},不要直接使用可能变化的手机号、昵称或班级ID。ZSet的member必须唯一,重复ZADD同一成员会更新score而不是新增一条记录,这正好满足“一个用户在一个活动中只有一条有效成绩”的规则。

2. 核心命令演示 ​

所有操作均为Redis原生命令,可直接运行:

redis
# 初始化竞赛排行榜ZSet,key命名规则:activity:{活动ID}:rank
ZADD activity:2024061:rank 89000256

上面的命令只给出了score,还缺少member;实际写入时应使用“分数 用户ID”成对出现。下面给出一套完整可执行的命令,模拟三个用户交卷。假设满分100分,最大答题时长3600秒,用户1001得89分、用时1744秒,则时间补偿为3600 - 1744 = 1856,编码值为89 * 1000000 + 1856 = 89001856。

redis
# 写入用户成绩:89分/1744秒,92分/2100秒,89分/1600秒
ZADD activity:2024061:rank 89001856 u:1001
ZADD activity:2024061:rank 92001500 u:1002
ZADD activity:2024061:rank 89002000 u:1003

# 查询Top10,高分在前;WITHSCORES可选,用于返回编码后的分数
ZREVRANGE activity:2024061:rank 0 9 WITHSCORES

# 查询用户个人排名,Redis排名从0开始,业务展示通常需要加1
ZREVRANK activity:2024061:rank u:1001

# 查询用户分数
ZSCORE activity:2024061:rank u:1001

# 查询榜单总人数
ZCARD activity:2024061:rank

查询“个人附近排名”时,先取个人排名,再计算窗口边界。例如展示当前用户前后各5名:

redis
# 假设返回 rank=98,表示第99名
ZREVRANK activity:2024061:rank u:1001

# 取第94名到第104名,共11人
ZREVRANGE activity:2024061:rank 93 103 WITHSCORES

生产代码中不要让前端传入排名窗口后直接拼接命令,建议由后端根据当前登录用户的userId调用ZREVRANK,再计算起止位置,避免越权查询和非法offset。Java侧可使用Spring Data Redis封装:

java
public List<RankItemVO> queryNearbyRank(String activityId, Long userId, int radius) {
    String key = "activity:" + activityId + ":rank:zset";
    Long rank = redisTemplate.opsForZSet().reverseRank(key, "u:" + userId);
    if (rank == null) {
        return Collections.emptyList();
    }

    long start = Math.max(0, rank - radius);
    long end = rank + radius;

    Set<ZSetOperations.TypedTuple<Object>> tuples =
            redisTemplate.opsForZSet().reverseRangeWithScores(key, start, end);

    if (tuples == null) {
        return Collections.emptyList();
    }

    List<RankItemVO> result = new ArrayList<>(tuples.size());
    long displayRank = start + 1;
    for (ZSetOperations.TypedTuple<Object> tuple : tuples) {
        long encoded = tuple.getScore().longValue();
        long[] scoreAndBonus = RankScoreCodec.decode(encoded);
        result.add(new RankItemVO(
                tuple.getValue().toString(),
                displayRank++,
                scoreAndBonus[0],
                scoreAndBonus[1]
        ));
    }
    return result;
}

更新成绩时要重点处理重复交卷。若业务规则允许用户多次答题并取最好成绩,可以用Lua脚本做“条件更新”,只有新编码分数更高时才覆盖旧值,避免请求乱序导致高分被低分回滚:

lua
local key = KEYS[1]
local member = ARGV[1]
local newScore = tonumber(ARGV[2])

local oldScore = redis.call('ZSCORE', key, member)
if oldScore == false or tonumber(newScore) > tonumber(oldScore) then
    return redis.call('ZADD', key, newScore, member)
else
    return 0
end

Spring中加载并执行该脚本:

java
DefaultRedisScript<Long> script = new DefaultRedisScript<>();
script.setLocation(new ClassPathResource("lua/zadd_best_score.lua"));
script.setResultType(Long.class);

redisTemplate.execute(
        script,
        Collections.singletonList("activity:2024061:rank:zset"),
        "u:1001",
        String.valueOf(RankScoreCodec.encode(89, 1744, 3600))
);

缓存与数据一致性方面,建议采用以下落地策略:活动开始前通过离线任务预热ZSet;活动进行中交卷请求写MySQL成功后发送消息,由消费者更新Redis;消费失败进入重试队列和死信队列;活动结束后冻结榜单,并以MySQL离线计算结果做最终对账。若更新Redis先于MySQL提交成功,而数据库事务随后回滚,就会出现“Redis有名次、权威库无成绩”的短暂脏数据,因此顺序上优先保证数据库事务成功。

避坑清单如下:

  1. 不要用浮点数直接表达毫秒差值:score参与排序,精度问题可能导致同分用户顺序异常,优先使用整数编码。
  2. 不要忽略排名从0开始:ZRANK、ZREVRANK返回值从0开始,展示给用户时必须加1。
  3. 不要在大key上随意调试ZRANGE 0 -1 WITHSCORES:这会返回全量成员,可能阻塞Redis并打满网络带宽。
  4. 不要只依赖Redis持久化:AOF重写、主从切换和误删key都可能造成数据问题,MySQL或消息日志必须能重建榜单。
  5. 不要无TTL堆积历史活动key:活动结束并归档后设置过期时间,降低内存成本。
  6. 不要让热key完全落在单连接上:客户端使用连接池,并对个人排名、TopN接口增加本地短缓存。
  7. 不要在前端展示编码score:返回给前端前必须解码为真实分数、用时或时间奖励。
  8. 不要忽略主从延迟:强一致读取刚提交的个人排名时,可读主库;普通榜单浏览可读从库。

上线前建议做三类压测。第一类是写入压测,验证ZADD在千万级key上的P99延迟;第二类是读压测,混合ZREVRANGE、ZREVRANK和ZSCORE;第三类是故障演练,模拟主节点切换、消费者堆积和Redis key重建。我们在压测环境使用1200万成员、32字节member、8字节score,单key内存约150MB到180MB;在4C8G的Redis实例上,混合2万QPS读取与2000 TPS写入时,TopN平均耗时约2ms到5ms,个人附近排名约8ms到15ms,CPU峰值约62%。线上因为还有反序列化、网络跳转和用户信息批量查询,端到端平均耗时稳定在12ms左右,P99约35ms,相比原MySQL方案2.8s的平均耗时下降超过99%。

活动期间还应建立监控指标:Redis命令P99、慢查询、主从延迟、key内存、消费者延迟、榜单重建进度、MySQL与Redis人数差异。尤其要监控ZADD失败、Lua脚本超时和空排名比例。若空排名在交卷后短时间内持续升高,通常说明消息消费堆积或数据库事务提交后Redis更新失败,需要及时告警并触发补偿任务。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。