背景

最近在做一个游戏中台,里面有个排行榜需求是这个项目最重要的需求,其排序维度不只一个分数,可能包含 得分,次数,首胜等条件,且得分数会比较大,最好能实时更新榜单数据,还要包含日榜,周榜,甚至月榜。

为此做了专门设计。

最近工作强度太高了。

easyswoole redis 日排行 周排行 redis做实时排名_缓存

方案分析

数据库分组统计+代码排名+缓存

说起实时排行榜,最先能想到的方案就是这个,该方案无需额外依赖其他组件,只以日榜的维度来看,该方案能支撑的流水数最多也就百万级别,超过这个级别很可能对数据库造成严重的计算压力,甚至影响其他业务sql的执行。
且实时性不高,高的话数据库压力就非常大。

Redis的ScoreSortedSet统计+增量累加+redis记录水位

对于简单排序,或者可以快速将多维度转化为score的数据模型,可以直接使用redis的 Redis 有序集合(sorted set),将排序条件转换为score,直接使用redis实现实时排序.
当只需要按照2个纬度 得分(points)降序 -> 次数(times)降序 排序时,利用代码直接计算出得分 2333 * 100 + 66 = 233366,接着保存到redis

创建

举栗: 单条/一批 流水记录汇总后的模型

{
"user_id": 555,
"points": 2333,
"times": 66,
"first_time": 1600000000
}
# 添加新成员到 rank组 成员555 分数为233366
ZADD rank 233366 555

增量追加

后续增量流水通过同样的逻辑将得分追加到redis的相同分组中
举栗: 追加的单条流水记录模型

{
"user_id": 555,
"points": 66,
"times": 1,
"first_time": 1610000000
}
# 追加分数到 rank组 成员555 分数为6601
ZINCRBY rank 6601 555

而如何记录增加可以简单的用redis保存上次统计的最大id作为下次扫描的起始id.

获取单个成员排名

获取单个成员的排名时:

# 获取 成员555 的排名
ZRANK rank 555

获取Top列表

获取单个成员的排名时:

# 获取 排名1-100 的成员及分数
ZRANGE rank 0 99 WITHSCORES

缺点

该方案对于多条件排序存有很大问题,首先是zset保存分数使用的是double类型,精度只有16-17位长度,当保存的分值很大,或者参与排序的条件较多时,不适用,或者需要借助数据库及代码做额外工作.
以上面的数据模型为例,当points = 1000000,times = 10000000,再加 first_time 降序时,就不可用了.

代码统计+增量累加+数据库存储+redis记录水位

该方案大体上是redis的zset的数据库版本实现,主要区别在于score那块,我们可以直接使用 mysql的decimal和javaBigDecimal来实现超长的精度运算,从而实现得分的实时累加.
然后借助在数据库排序+缓存,实现topN的展示及排序数据的解析.

将分数解析为原本数值,比如 62700101.0058365000分数,经过反序列化,得到用户的原始数据为

{
	"user_id": 555,
	"points": 627,
	"times": 101,
	"first_time": 1610000000
}
// 认为用户最大次数不超过100000次,只要保证在统计周期内不存在超过该值的情况,即可
    final static int TIMES_BASE = 100000;

    final static Function<RankDto, BigDecimal> COMMON_RANKING_STRATEGY = (v) -> {
        // 排名策略
        BigDecimal points = BigDecimal.valueOf(v.getPoints()).multiply(BigDecimal.valueOf(TIMES_BASE));
        BigDecimal times = BigDecimal.valueOf(v.getTimes());
        Long firstWinTime = v.getFirstWinTime();
        // 反转时间戳 一个月最大的秒数是260000不到
        long reverseTimeScore = LocalDateTime.now().toEpochSecond(ZoneOffset.ofHours(8)) - firstWinTime;
        return points.add(times).add(BigDecimal.valueOf(reverseTimeScore).movePointLeft(7));
    };
    
    final static Function<BigDecimal, RankDto> PARSE_GAME_MINI_RANK_ITEM_DTO = (v) -> {
        // 通过排名分数倒推信息
        double times = v.doubleValue() % TIMES_BASE;
        BigDecimal points = v.divide(BigDecimal.valueOf(TIMES_BASE), RoundingMode.DOWN);
        int reverseTimeScore = v.movePointRight(7).remainder(BigDecimal.valueOf(1000000)).intValue();
        RankDto rankItemDto = new RankDto();
        rankItemDto.setPoints(points.intValue());
        rankItemDto.setTimes((int) times);
        rankItemDto.setFirstWinTime((long) reverseTimeScore);
        return rankItemDto;
    };