Redis 实现简单排行榜功能 | 实战案例

一、业务场景

口算小程序,用户完成口算并获得满分,根据耗时长短进行rank排名,耗时越短,排名越高。主要有以下功能:

1. 用户数据Mysql与Redis同步:使用一个redis hash用来保存用户基本信息,fielduserIdvalue为用户基础数据(本案例为昵称);用户修改昵称时,同步更新hash中对应userIdnickname

2. 保存记录:口算完成后,根据当前口算结果(得分、耗时)更新对应难度的排行榜数据。用户获得满分时,并且耗时大于三秒(可以添加更专业的防脚本措施),则将userId作为field,耗时作为score,添加进排行榜zset中,并且剔除多余排名(本场景zset最大支持50名),这几步使用lua脚本保证原子性;

3. 查看排行榜:按照耗时升序排序,范围查找前20名用户,查询上述用户hash,将userId转化为nickname返回给前端。

二、数据库、数据结构

对重点涉及的Mysql表以及Redis数据结果进行说明

Mysql:

用户基础数据表

drop table if exists arithmetic_user;
create table arithmetic_user (
id bigint primary key auto_increment,
username varchar(255) comment '用户名称',
password varchar(255) comment '密码',
nickname varchar(40) default '匿名用户' comment '昵称',
integral float default 0 comment '积分',
total_integral float default 0 comment '总积分',

create_time datetime default now() comment '创建时间',
deleted tinyint default 0
);

Redis:

排行榜有序集合zsetfield存放用户Id,score存放答题总耗时,根据耗时升序排序;

public static final String ARITHMETIC_RANK = "arithmetic:rank";

// 使用difficuty参数,可以对不同难度模式分别进行排行数据统计
public static String getArithmeticRank(int difficulty) {
    return ARITHMETIC_RANK + ":" + difficulty;
}

用户基础数据hashfield存放用户Id,value存放nickname昵称。

三、代码示例

为方便理解,非核心代码将进行黑盒处理

1、Mysql与Redis数据同步

首次同步:项目启动时,将数据库中的用户数据同步到Redis hash中,供后续查看排行榜使用(ps:用户量大时,可以分批次进行同步操作):

@PostConstruct
public void initUserInfoCache() {
    List<ToolArithmeticUserEntity> list = this.list();
    String key = RedisKeys.ARITHMETIC_USER_INFO;
    if (Boolean.TRUE.equals(redisTemplate.hasKey(key))) {
        redisTemplate.delete(key);
    }
    // 批量添加hmset
    redisTemplate.opsForHash().putAll(
            key,
            list.stream().collect(
                    Collectors.toMap(
                            item -> String.valueOf(item.getId()),
                            ToolArithmeticUserEntity::getNickname
                    )
            )
    );
}

新增用户:用户首次进入时,将数据保存至数据库中,并同步保存至Redis

@Transactional
@Override
public ArithmeticUserEntity getUserInfo() {
    // 查询用户
    ArithmeticUserEntity one = getUserInfo();
    // 如果没查询到,则基于ip新增当前用户至数据库中
    if (one == null) {
        ToolArithmeticUserEntity entity = new ToolArithmeticUserEntity();
        // 设置默认用户信息
        entity.setInfo();
        boolean b = this.save(entity);
        // 用户数据同步添加至Redis中
        if (b) {
            String userId = String.valueOf(entity.getId());
            redisTemplate.opsForHash().put(RedisKeys.ARITHMETIC_USER_INFO, userId, entity.getNickname());
        }
        
        return entity;
    }
    return this.getById(one.getUserId());
}

编辑昵称:当用户修改昵称时,同样需要同步修改Redis hash中的nickname(是否会存在数据不一致问题?)

@Override
public void editNickName(String nickname) {
    LambdaQueryWrapper<ArithmeticUserEntity> queryWrapper = new LambdaQueryWrapper<>();
    queryWrapper.eq(ArithmeticUserEntity::getNickname, nickname);
    if (this.count(queryWrapper) > 0) {
        throw new GlobalException(10000, "该昵称已被使用");
    }
    ToolArithmeticUserEntity byId = this.getUserInfo();
    byId.setNickname(nickname);
    boolean b = this.updateById(byId);
    if (b) {
        String userId = String.valueOf(byId.getId());
        redisTemplate.opsForHash().put(RedisKeys.ARITHMETIC_USER_INFO, userId, nickname);
    }
}

2、保存记录,更新排行榜

@Override
public void saveRecord(ArithmeticRecordDTO entity) {
    // 获取用户信息
    // 保存当前答题记录至记录表
    // 更新积分等
    // 只有满分才参与排行,并且耗时大于3秒(或更加详细的防刷机制)
    if (Objects.equals(entity.getRightCount(), entity.getQuestionCount()) && entity.getTime() > 3) {
        int difficulty = entity.getDifficulty();
        String key = RedisKeys.getArithmeticRank(difficulty);
        float time = entity.getTime();
        // 当耗时比原有的score小时,才更新。使用lua脚本封装成原子操作
        String script = getLuaScript();
        int total = 50;
        redisTemplate.execute((RedisCallback<Object>) connection ->
                connection.eval(script.getBytes(), ReturnType.INTEGER,
                        1, key.getBytes(), userId.toString().getBytes(),
                        String.valueOf(time).getBytes(), String.valueOf(total).getBytes()));
    }
}

// lua语句
private String getLuaScript() {
    return "local key = KEYS[1]\n" +
            "local userId = ARGV[1]\n" +
            "local time = tonumber(ARGV[2])\n" +
            "local total = tonumber(ARGV[3])\n" +
            "local score = redis.call('ZSCORE', key, userId)\n" +
            "if not score then\n" +
            "    redis.call('ZADD', key, time, userId)\n" +
            "elseif tonumber(score) > time then\n" +
            "    redis.call('ZADD', key, time, userId)\n" +
            "end\n" +
            "local size = redis.call('ZCARD', key)\n" +
            "if size ~= false and size > total then\n" +
            "    redis.call('ZREMRANGEBYRANK', key, total, size)\n" +
            "end\n" +
            "return 0";
}

3、查询排行榜

@Override
public List<ArithmeticRankVO> getRankings(int difficulty) {
    return getRankings(difficulty, 0, 20);
}
/**
 * @param difficulty 题目难度
 * @param start 最高名次(根据耗时升序排名)
 * @param end 最低名次
 * @return field、score
 */
private List<ArithmeticRankVO> getRankings(int difficulty, int start, int end) {
    String key = RedisKeys.getArithmeticRank(difficulty);
    if (Boolean.TRUE.equals(redisTemplate.hasKey(key))) {
        Set<ZSetOperations.TypedTuple<Object>> typedTuples = redisTemplate.opsForZSet().rangeWithScores(key, start, end);
        if (typedTuples != null) {
            // 先从zset中查询出userId
            List<ArithmeticRankVO> list = typedTuples.stream().map(tuple -> {
                ArithmeticRankVO vo = new ArithmeticRankVO();
                vo.setUserId(Objects.requireNonNull(tuple.getValue()).toString());
                vo.setTime(Objects.requireNonNull(tuple.getScore()).floatValue());
                return vo;
            }).collect(Collectors.toList());
            // 获取nickname
            String infoKey = RedisKeys.ARITHMETIC_USER_INFO;
            // 再使用userId去hash中查出nickname
            List<Object> nicknameList = redisTemplate.opsForHash().multiGet(infoKey, list.stream().map(ArithmeticRankVO::getUserId).
            for (int i = 0; i < list.size() && i < nicknameList.size(); i++) {
                list.get(i).setNickname(nicknameList.get(i) == null ? "账号昵称异常" : nicknameList.get(i).toString());
            }
            return list;
        }
    }
    return new ArrayList<>();
}

四、效果

演示地址(菜鸡小站,大佬手下留情):

口算排行榜示例地址

  • 12
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值