基于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,如下图所示: