目录

Life in Flow

知不知,尚矣;不知知,病矣。
不知不知,殆矣。

X

基于Redis的sorted set实现排行榜功能

业务需求

 排行榜功能是一个很普遍的需求。使用 Redis 中有序集合的特性来实现排行榜是又好又快的选择。 一般排行榜都是有实效性的,比如“用户积分榜”,游戏中活跃度排行榜,游戏装备排行榜等。

需求面临的问题

  • 数据库设计复杂。
  • 并发数较高。
  • 数据要求实时性高。

Redis 相关 API 概述

封装 Redis 工具类 RedisService

Service 层(RangingService 使用 Redis 工具类)

import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.ZSetOperations;
import org.springframework.stereotype.Service;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;

@Service
public class RangingService implements InitializingBean {

    private static final String RANKGNAME = "user_score";

    private static final String SALESCORE = "sale_score_rank:";

    @Autowired
    private RedisService redisService;

    @Autowired
    private UserMapper userMapper;

    @Autowired
    private ScoreFlowMapper scoreFlowMapper;

    @Autowired
    private UserScoreMapper userScoreMapper;

    public void rankAdd(String uid, Integer score) {
        redisService.zAdd(RANKGNAME, uid, score);
    }

    public void increSocre(String uid, Integer score) {

        redisService.incrementScore(RANKGNAME, uid, score);
    }

    public Long rankNum(String uid) {
        return redisService.zRank(RANKGNAME, uid);
    }

    public Long score(String uid) {
        Long score = redisService.zSetScore(RANKGNAME, uid).longValue();
        return score;
    }

    public Set<ZSetOperations.TypedTuple<Object>> rankWithScore(Integer start, Integer end) {
        return redisService.zRankWithScore(RANKGNAME, start, end);
    }

    public void rankSaleAdd() {
        UserScoreExample example = new UserScoreExample();
        example.setOrderByClause("id desc");
        List<UserScore> userScores = userScoreMapper.selectByExample(example);
        userScores.forEach(userScore -> {
            String key = userScore.getUserId() + ":" + userScore.getName();
            redisService.zAdd(SALESCORE, key, userScore.getUserScore());
        });
    }

    /**
     * 添加用户积分
     *
     * @param uid
     * @param score
     */
    public void increSaleSocre(String uid, Integer score) {
        User user = userMapper.find(uid);
        if (user == null) {
            return;
        }
        int uidInt = Integer.parseInt(uid);
        long socreLong = Long.parseLong(score + "");
        String name = user.getUserName();
        String key = uid + ":" + name;
        scoreFlowMapper.insertSelective(new ScoreFlow(socreLong, uidInt, name));
        userScoreMapper.insertSelective(new UserScore(uidInt, socreLong, name));
        redisService.incrementScore(SALESCORE, key, score);
    }

    public Map<String, Object> userRank(String uid, String name) {
        Map<String, Object> retMap = new LinkedHashMap<>();
        String key = uid + ":" + name;
        Integer rank = redisService.zRank(SALESCORE, key).intValue();
        Long score = redisService.zSetScore(SALESCORE, key).longValue();
        retMap.put("userId", uid);
        retMap.put("score", score);
        retMap.put("rank", rank);
        return retMap;
    }

    public List<Map<String, Object>> reverseZRankWithRank(long start, long end) {
        Set<ZSetOperations.TypedTuple<Object>> setObj = redisService.reverseZRankWithRank(SALESCORE, start, end);
        List<Map<String, Object>> mapList = setObj.stream().map(objectTypedTuple -> {
            Map<String, Object> map = new LinkedHashMap<>();
            map.put("userId", objectTypedTuple.getValue().toString().split(":")[0]);
            map.put("userName", objectTypedTuple.getValue().toString().split(":")[1]);
            map.put("score", objectTypedTuple.getScore());
            return map;
        }).collect(Collectors.toList());
        return mapList;
    }

    public List<Map<String, Object>> saleRankWithScore(Integer start, Integer end) {
        Set<ZSetOperations.TypedTuple<Object>> setObj = redisService.reverseZRankWithScore(SALESCORE, start, end);
        List<Map<String, Object>> mapList = setObj.stream().map(objectTypedTuple -> {
            Map<String, Object> map = new LinkedHashMap<>();
            map.put("userId", objectTypedTuple.getValue().toString().split(":")[0]);
            map.put("userName", objectTypedTuple.getValue().toString().split(":")[1]);
            map.put("score", objectTypedTuple.getScore());
            return map;
        }).collect(Collectors.toList());
        return mapList;
    }

//    @Override
//    public void run(ApplicationArguments args) throws Exception {
//        System.out.println("======enter run bean=======");
//        Thread.sleep(100000);
//        this.rankSaleAdd();
//    }

    @Override
    public void afterPropertiesSet() throws Exception {
        System.out.println("======enter init bean=======");
        this.rankSaleAdd();
    }
}

Controller 层

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.ZSetOperations;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestController;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;

@RestController
public class RankingController {

    @Autowired
    private RangingService rankingService;

    @ResponseBody
    @RequestMapping("/addScore")
    public String addRank(String uid, Integer score) {
        rankingService.rankAdd(uid, score);
        return "success";
    }

    @ResponseBody
    @RequestMapping("/increScore")
    public String increScore(String uid, Integer score) {
        rankingService.increSocre(uid, score);
        return "success";
    }

    @ResponseBody
    @RequestMapping("/rank")
    public Map<String, Long> rank(String uid) {
        Map<String, Long> map = new HashMap<>();
        map.put(uid, rankingService.rankNum(uid));
        return map;
    }

    @ResponseBody
    @RequestMapping("/score")
    public Long rankNum(String uid) {
        return rankingService.score(uid);
    }

    @ResponseBody
    @RequestMapping("/scoreByRange")
    public Set<ZSetOperations.TypedTuple<Object>> scoreByRange(Integer start, Integer end) {
        return rankingService.rankWithScore(start,end);
    }

    @ResponseBody
    @RequestMapping("/sale/increScore")
    public String increSaleScore(String uid, Integer score) {
        rankingService.increSaleSocre(uid, score);
        return "success";
    }

    @ResponseBody
    @RequestMapping("/sale/userScore")
    public Map<String,Object> userScore(String uid,String name) {
        return rankingService.userRank(uid,name);
    }

    @ResponseBody
    @RequestMapping("/sale/top")
    public List<Map<String,Object>> reverseZRankWithRank(long start,long end) {
        return rankingService.reverseZRankWithRank(start,end);
    }

    @ResponseBody
    @RequestMapping("/sale/scoreByRange")
    public List<Map<String,Object>> saleScoreByRange(Integer start, Integer end) {
        return rankingService.saleRankWithScore(start,end);
    }
}

MySQL 数据库表设计要点

数据类型设计要点

  • 更小的通常更好,控制字节长度。
  • 使用合适的数据类型: 如 tinyint 只占 8 个位,char(1024)与 varchar(1024)的对比,char 用于类似定长数据存储比 varchar 节省空间,如:uuid(32),可以用 char(32)。
  • 尽量避免 NULL 建议使用 NOT NULL DEFAULT。unsigned 代表必须为非负整数。
  • NULL 的列会让索引统计和值比较都更复杂。可为 NULL 的列会占据更多的磁盘空间,在 MySQL 中也需要更多复杂的处理程序。

索引设计要点

  • 选择唯一性索引:唯一性索引的值是唯一的,可以更快速的通过该索引来确定某条记录,保证物理上面唯一。
  • 为经常需要排序、分组和联合操作的字段建立索引 ,经常需要 ORDER BY、GROUP BY、DISTINCT 和 UNION 等操作的字段,排序操作会浪费很多时间。
  • 常作为查询条件的字段建立索引 如果某个字段经常用来做查询条件,那么该字段的查询速度会影响整个表的查询速度。
  • 数据少的地方不必建立索引。

SQL 优化

  • plain 查看执行计划(row 代表扫描行数:会影响 CPU 运行)。
  • 能够用 BETWEEN 的就不要用 IN 。
  • 能够用 DISTINCT 的就不用 GROUP BY。
  • 避免数据强转。
  • 学会采用 explain 查看执行计划。

建表语句
 需求有:查询 Top 排名(比如前 1000),查询用户在总排行榜的名次(比如:23040)。

  • score_flow:积分流水表。
  • user_score:拥挤积分总表。
CREATE TABLE `score_flow` (
  `id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键id',
  `score` bigint(19) unsigned NOT NULL COMMENT '用户积分流水',
  `user_id` int(11) unsigned NOT NULL COMMENT '用户主键id',
  `user_name` varchar(30) NOT NULL DEFAULT '' COMMENT '用户姓名',
  PRIMARY KEY (`id`),
  KEY `idx_userid` (`user_id`)
) ENGINE=InnoDB AUTO_INCREMENT=13 DEFAULT CHARSET=utf8mb4;

CREATE TABLE `sys_user` (
  `id` int(11) unsigned NOT NULL AUTO_INCREMENT,
  `user_name` varchar(11) CHARACTER SET utf8mb4 DEFAULT NULL COMMENT '用户名',
  `image` varchar(11) CHARACTER SET utf8mb4 DEFAULT NULL COMMENT '用户头像',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=11 DEFAULT CHARSET=utf8;

CREATE TABLE `user_score` (
  `id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
  `user_id` int(11) unsigned NOT NULL COMMENT '用户ID',
  `user_score` bigint(19) unsigned NOT NULL COMMENT '用户积分',
  `name` varchar(30) NOT NULL DEFAULT '' COMMENT '用户姓名',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=9 DEFAULT CHARSET=utf8;

生成 XML 和 Mapper 类

引入 Maven 依赖

<dependency>
			<groupId>org.mybatis.generator</groupId>
			<artifactId>mybatis-generator-core</artifactId>
			<scope>test</scope>
			<version>1.3.2</version>
			<optional>true</optional>
		</dependency>
		<dependency>
			<groupId>commons-io</groupId>
			<artifactId>commons-io</artifactId>
			<version>2.5</version>
		</dependency>

添加
 generatorConfig.xml、AddLimitOffsetPlugin、Generator

排行榜三大接口概念梳理

添加用户积分

http://192.168.31.230:8080/sale/increScore?uid=1&score=11
http://192.168.31.230:8080/sale/increScore?uid=2&score=12
http://192.168.31.230:8080/sale/increScore?uid=3&score=13
http://192.168.31.230:8080/sale/increScore?uid=4&score=14
http://192.168.31.230:8080/sale/increScore?uid=5&score=15
success

根据用户 ID 获取排行

// http://192.168.31.230:8080/sale/userScore?uid=1&name=soulboy
{

  "userId": "1",
  "score": 11,
  "rank": 0
}

// http://192.168.31.230:8080/sale/userScore?uid=2&name=test2
{
  "userId": "2",
  "score" 12,
  "rank": 1
}

// http://192.168.31.230:8080/sale/userScore?uid=3&name=test3
{
  "userId": "3",
  "score": 13,
  "rank": 2
}

// http://192.168.31.230:8080/sale/userScore?uid=4&name=test4
{
  "userId": "4",
  "score": 14,
  "rank": 3
}

// http://192.168.31.230:8080/sale/userScore?uid=5&name=test5
{
  "userId": "5",
  "score": 15,
  "rank": 4
}

获取 top N 排行

// 获取排行榜前三的用户
// http://192.168.31.230:8080/sale/top?start=0&end=2
// 显示整个排行榜
// http://192.168.31.230:8080/sale/top?start=0&end=-1
[
  {
    "userId": "5",
    "userName": "test5",
    "score": 15.0
  },
  {
    "userId": "4",
    "userName": "test4",
    "score": 14.0
  },
  {
    "userId": "3",
    "userName": "test3",
    "score": 13.0
  },
  {
    "userId": "2",
    "userName": "test2",
    "score": 12.0
  },
  {
    "userId": "1",
    "userName": "soulboy",
    "score": 11.0
  }
]

// 显示分数11~13之间的用户排名
// http://192.168.31.230:8080/sale/scoreByRange?start=11&end=13
[
  {
    "userId": "3",
    "userName": "test3",
    "score": 13.0
  },
  {
    "userId": "2",
    "userName": "test2",
    "score": 12.0
  },
  {
    "userId": "1",
    "userName": "soulboy",
    "score": 11.0
  }
]

缓存预热

 Redis 有可能会发生数据丢失,为了防止数据丢失,在添加用户积分的同时会把数据插入数据库。以便将数据库中数据同步到 Redis 中。可以在 SpringBoot 项目每次初始化加载的时候进行数据同步。

场景
 将一千万用户 load 到 Redis 缓存,用户请求进入命中缓存,如果未命中再进行数据查询。

SpringBoot 的中可采用以下两种方式完成缓存预热
 初始化完成再放入请求,推荐使用 InitializingBean 进行缓存预热

  • 采用实现 SpringBoot ApplicationRunner 该方法仅在启动类的 SpringApplication.run(…)完成之前调用。(在数据预热完成之前,用户的请求可以进来)
public class RangingService implements ApplicationRunner {
    public void rankSaleAdd() {
        UserScoreExample example = new UserScoreExample();
        example.setOrderByClause("id desc");
        List<UserScore> userScores = userScoreMapper.selectByExample(example);
        userScores.forEach(userScore -> {
            String key = userScore.getUserId() + ":" + userScore.getName();
            redisService.zAdd(SALESCORE, key, userScore.getUserScore());
        });

    @Override
    public void run(ApplicationArguments args) throws Exception {
	System.out.println("======enter run bean=======");
        this.rankSaleAdd();
    }
}
  • 采用实现 InitializingBean(在数据预热完成之前,用户的请求无法进来)
     InitializingBean 接口为 bean 提供了初始化方法的方式,它只包括 afterPropertiesSet()方法。 ​ 在 Spring 初始化 bean 的时候,如果 bean 实现了 InitializingBean 接口, ​ 在对象的所有属性被初始化后之后才会调用 afterPropertiesSet()方法。
public class RangingService implements InitializingBean {

    public void rankSaleAdd() {
        UserScoreExample example = new UserScoreExample();
        example.setOrderByClause("id desc");
        List<UserScore> userScores = userScoreMapper.selectByExample(example);
        userScores.forEach(userScore -> {
            String key = userScore.getUserId() + ":" + userScore.getName();
            redisService.zAdd(SALESCORE, key, userScore.getUserScore());
        });

    @Override
    public void afterPropertiesSet() throws Exception {
        System.out.println("======enter init bean=======");
        this.rankSaleAdd();
    }
}

测试缓存预热是否生效
 停止 SpringBoot 项目,删除 Redis 中所有缓存,启动 SpringBoot 项目,使用 Redis Desktop Manager 查看 Redis,如下图所示:
缓存预热


作者:Soulboy