基于Redis的sorted set实现排行榜功能
业务需求
排行榜功能是一个很普遍的需求。使用 Redis 中有序集合的特性来实现排行榜是又好又快的选择。 一般排行榜都是有实效性的,比如“用户积分榜”,游戏中活跃度排行榜,游戏装备排行榜等。
需求面临的问题
- 数据库设计复杂。
- 并发数较高。
- 数据要求实时性高。
Redis 相关 API 概述
封装 Redis 工具类 RedisService
Service 层(RangingService 使用 Redis 工具类)
1import org.springframework.beans.factory.InitializingBean;
2import org.springframework.beans.factory.annotation.Autowired;
3import org.springframework.data.redis.core.ZSetOperations;
4import org.springframework.stereotype.Service;
5import java.util.LinkedHashMap;
6import java.util.List;
7import java.util.Map;
8import java.util.Set;
9import java.util.stream.Collectors;
10
11@Service
12public class RangingService implements InitializingBean {
13
14 private static final String RANKGNAME = "user_score";
15
16 private static final String SALESCORE = "sale_score_rank:";
17
18 @Autowired
19 private RedisService redisService;
20
21 @Autowired
22 private UserMapper userMapper;
23
24 @Autowired
25 private ScoreFlowMapper scoreFlowMapper;
26
27 @Autowired
28 private UserScoreMapper userScoreMapper;
29
30 public void rankAdd(String uid, Integer score) {
31 redisService.zAdd(RANKGNAME, uid, score);
32 }
33
34 public void increSocre(String uid, Integer score) {
35
36 redisService.incrementScore(RANKGNAME, uid, score);
37 }
38
39 public Long rankNum(String uid) {
40 return redisService.zRank(RANKGNAME, uid);
41 }
42
43 public Long score(String uid) {
44 Long score = redisService.zSetScore(RANKGNAME, uid).longValue();
45 return score;
46 }
47
48 public Set<ZSetOperations.TypedTuple<Object>> rankWithScore(Integer start, Integer end) {
49 return redisService.zRankWithScore(RANKGNAME, start, end);
50 }
51
52 public void rankSaleAdd() {
53 UserScoreExample example = new UserScoreExample();
54 example.setOrderByClause("id desc");
55 List<UserScore> userScores = userScoreMapper.selectByExample(example);
56 userScores.forEach(userScore -> {
57 String key = userScore.getUserId() + ":" + userScore.getName();
58 redisService.zAdd(SALESCORE, key, userScore.getUserScore());
59 });
60 }
61
62 /**
63 * 添加用户积分
64 *
65 * @param uid
66 * @param score
67 */
68 public void increSaleSocre(String uid, Integer score) {
69 User user = userMapper.find(uid);
70 if (user == null) {
71 return;
72 }
73 int uidInt = Integer.parseInt(uid);
74 long socreLong = Long.parseLong(score + "");
75 String name = user.getUserName();
76 String key = uid + ":" + name;
77 scoreFlowMapper.insertSelective(new ScoreFlow(socreLong, uidInt, name));
78 userScoreMapper.insertSelective(new UserScore(uidInt, socreLong, name));
79 redisService.incrementScore(SALESCORE, key, score);
80 }
81
82 public Map<String, Object> userRank(String uid, String name) {
83 Map<String, Object> retMap = new LinkedHashMap<>();
84 String key = uid + ":" + name;
85 Integer rank = redisService.zRank(SALESCORE, key).intValue();
86 Long score = redisService.zSetScore(SALESCORE, key).longValue();
87 retMap.put("userId", uid);
88 retMap.put("score", score);
89 retMap.put("rank", rank);
90 return retMap;
91 }
92
93 public List<Map<String, Object>> reverseZRankWithRank(long start, long end) {
94 Set<ZSetOperations.TypedTuple<Object>> setObj = redisService.reverseZRankWithRank(SALESCORE, start, end);
95 List<Map<String, Object>> mapList = setObj.stream().map(objectTypedTuple -> {
96 Map<String, Object> map = new LinkedHashMap<>();
97 map.put("userId", objectTypedTuple.getValue().toString().split(":")[0]);
98 map.put("userName", objectTypedTuple.getValue().toString().split(":")[1]);
99 map.put("score", objectTypedTuple.getScore());
100 return map;
101 }).collect(Collectors.toList());
102 return mapList;
103 }
104
105 public List<Map<String, Object>> saleRankWithScore(Integer start, Integer end) {
106 Set<ZSetOperations.TypedTuple<Object>> setObj = redisService.reverseZRankWithScore(SALESCORE, start, end);
107 List<Map<String, Object>> mapList = setObj.stream().map(objectTypedTuple -> {
108 Map<String, Object> map = new LinkedHashMap<>();
109 map.put("userId", objectTypedTuple.getValue().toString().split(":")[0]);
110 map.put("userName", objectTypedTuple.getValue().toString().split(":")[1]);
111 map.put("score", objectTypedTuple.getScore());
112 return map;
113 }).collect(Collectors.toList());
114 return mapList;
115 }
116
117// @Override
118// public void run(ApplicationArguments args) throws Exception {
119// System.out.println("======enter run bean=======");
120// Thread.sleep(100000);
121// this.rankSaleAdd();
122// }
123
124 @Override
125 public void afterPropertiesSet() throws Exception {
126 System.out.println("======enter init bean=======");
127 this.rankSaleAdd();
128 }
129}
Controller 层
1import org.springframework.beans.factory.annotation.Autowired;
2import org.springframework.data.redis.core.ZSetOperations;
3import org.springframework.web.bind.annotation.RequestMapping;
4import org.springframework.web.bind.annotation.ResponseBody;
5import org.springframework.web.bind.annotation.RestController;
6import java.util.HashMap;
7import java.util.List;
8import java.util.Map;
9import java.util.Set;
10
11@RestController
12public class RankingController {
13
14 @Autowired
15 private RangingService rankingService;
16
17 @ResponseBody
18 @RequestMapping("/addScore")
19 public String addRank(String uid, Integer score) {
20 rankingService.rankAdd(uid, score);
21 return "success";
22 }
23
24 @ResponseBody
25 @RequestMapping("/increScore")
26 public String increScore(String uid, Integer score) {
27 rankingService.increSocre(uid, score);
28 return "success";
29 }
30
31 @ResponseBody
32 @RequestMapping("/rank")
33 public Map<String, Long> rank(String uid) {
34 Map<String, Long> map = new HashMap<>();
35 map.put(uid, rankingService.rankNum(uid));
36 return map;
37 }
38
39 @ResponseBody
40 @RequestMapping("/score")
41 public Long rankNum(String uid) {
42 return rankingService.score(uid);
43 }
44
45 @ResponseBody
46 @RequestMapping("/scoreByRange")
47 public Set<ZSetOperations.TypedTuple<Object>> scoreByRange(Integer start, Integer end) {
48 return rankingService.rankWithScore(start,end);
49 }
50
51 @ResponseBody
52 @RequestMapping("/sale/increScore")
53 public String increSaleScore(String uid, Integer score) {
54 rankingService.increSaleSocre(uid, score);
55 return "success";
56 }
57
58 @ResponseBody
59 @RequestMapping("/sale/userScore")
60 public Map<String,Object> userScore(String uid,String name) {
61 return rankingService.userRank(uid,name);
62 }
63
64 @ResponseBody
65 @RequestMapping("/sale/top")
66 public List<Map<String,Object>> reverseZRankWithRank(long start,long end) {
67 return rankingService.reverseZRankWithRank(start,end);
68 }
69
70 @ResponseBody
71 @RequestMapping("/sale/scoreByRange")
72 public List<Map<String,Object>> saleScoreByRange(Integer start, Integer end) {
73 return rankingService.saleRankWithScore(start,end);
74 }
75}
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:拥挤积分总表。
1CREATE TABLE `score_flow` (
2 `id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键id',
3 `score` bigint(19) unsigned NOT NULL COMMENT '用户积分流水',
4 `user_id` int(11) unsigned NOT NULL COMMENT '用户主键id',
5 `user_name` varchar(30) NOT NULL DEFAULT '' COMMENT '用户姓名',
6 PRIMARY KEY (`id`),
7 KEY `idx_userid` (`user_id`)
8) ENGINE=InnoDB AUTO_INCREMENT=13 DEFAULT CHARSET=utf8mb4;
9
10CREATE TABLE `sys_user` (
11 `id` int(11) unsigned NOT NULL AUTO_INCREMENT,
12 `user_name` varchar(11) CHARACTER SET utf8mb4 DEFAULT NULL COMMENT '用户名',
13 `image` varchar(11) CHARACTER SET utf8mb4 DEFAULT NULL COMMENT '用户头像',
14 PRIMARY KEY (`id`)
15) ENGINE=InnoDB AUTO_INCREMENT=11 DEFAULT CHARSET=utf8;
16
17CREATE TABLE `user_score` (
18 `id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
19 `user_id` int(11) unsigned NOT NULL COMMENT '用户ID',
20 `user_score` bigint(19) unsigned NOT NULL COMMENT '用户积分',
21 `name` varchar(30) NOT NULL DEFAULT '' COMMENT '用户姓名',
22 PRIMARY KEY (`id`)
23) ENGINE=InnoDB AUTO_INCREMENT=9 DEFAULT CHARSET=utf8;
生成 XML 和 Mapper 类
引入 Maven 依赖
1<dependency>
2 <groupId>org.mybatis.generator</groupId>
3 <artifactId>mybatis-generator-core</artifactId>
4 <scope>test</scope>
5 <version>1.3.2</version>
6 <optional>true</optional>
7 </dependency>
8 <dependency>
9 <groupId>commons-io</groupId>
10 <artifactId>commons-io</artifactId>
11 <version>2.5</version>
12 </dependency>
添加
generatorConfig.xml、AddLimitOffsetPlugin、Generator
排行榜三大接口概念梳理
添加用户积分
1http://192.168.31.230:8080/sale/increScore?uid=1&score=11
2http://192.168.31.230:8080/sale/increScore?uid=2&score=12
3http://192.168.31.230:8080/sale/increScore?uid=3&score=13
4http://192.168.31.230:8080/sale/increScore?uid=4&score=14
5http://192.168.31.230:8080/sale/increScore?uid=5&score=15
6success
根据用户 ID 获取排行
1// http://192.168.31.230:8080/sale/userScore?uid=1&name=soulboy
2{
3
4 "userId": "1",
5 "score": 11,
6 "rank": 0
7}
8
9// http://192.168.31.230:8080/sale/userScore?uid=2&name=test2
10{
11 "userId": "2",
12 "score" 12,
13 "rank": 1
14}
15
16// http://192.168.31.230:8080/sale/userScore?uid=3&name=test3
17{
18 "userId": "3",
19 "score": 13,
20 "rank": 2
21}
22
23// http://192.168.31.230:8080/sale/userScore?uid=4&name=test4
24{
25 "userId": "4",
26 "score": 14,
27 "rank": 3
28}
29
30// http://192.168.31.230:8080/sale/userScore?uid=5&name=test5
31{
32 "userId": "5",
33 "score": 15,
34 "rank": 4
35}
获取 top N 排行
1// 获取排行榜前三的用户
2// http://192.168.31.230:8080/sale/top?start=0&end=2
3// 显示整个排行榜
4// http://192.168.31.230:8080/sale/top?start=0&end=-1
5[
6 {
7 "userId": "5",
8 "userName": "test5",
9 "score": 15.0
10 },
11 {
12 "userId": "4",
13 "userName": "test4",
14 "score": 14.0
15 },
16 {
17 "userId": "3",
18 "userName": "test3",
19 "score": 13.0
20 },
21 {
22 "userId": "2",
23 "userName": "test2",
24 "score": 12.0
25 },
26 {
27 "userId": "1",
28 "userName": "soulboy",
29 "score": 11.0
30 }
31]
32
33// 显示分数11~13之间的用户排名
34// http://192.168.31.230:8080/sale/scoreByRange?start=11&end=13
35[
36 {
37 "userId": "3",
38 "userName": "test3",
39 "score": 13.0
40 },
41 {
42 "userId": "2",
43 "userName": "test2",
44 "score": 12.0
45 },
46 {
47 "userId": "1",
48 "userName": "soulboy",
49 "score": 11.0
50 }
51]
缓存预热
Redis 有可能会发生数据丢失,为了防止数据丢失,在添加用户积分的同时会把数据插入数据库。以便将数据库中数据同步到 Redis 中。可以在 SpringBoot 项目每次初始化加载的时候进行数据同步。
场景
将一千万用户 load 到 Redis 缓存,用户请求进入命中缓存,如果未命中再进行数据查询。
SpringBoot 的中可采用以下两种方式完成缓存预热
初始化完成再放入请求,推荐使用 InitializingBean 进行缓存预热
- 采用实现 SpringBoot ApplicationRunner 该方法仅在启动类的 SpringApplication.run(…)完成之前调用。(在数据预热完成之前,用户的请求可以进来)
1public class RangingService implements ApplicationRunner {
2 public void rankSaleAdd() {
3 UserScoreExample example = new UserScoreExample();
4 example.setOrderByClause("id desc");
5 List<UserScore> userScores = userScoreMapper.selectByExample(example);
6 userScores.forEach(userScore -> {
7 String key = userScore.getUserId() + ":" + userScore.getName();
8 redisService.zAdd(SALESCORE, key, userScore.getUserScore());
9 });
10
11 @Override
12 public void run(ApplicationArguments args) throws Exception {
13 System.out.println("======enter run bean=======");
14 this.rankSaleAdd();
15 }
16}
- 采用实现 InitializingBean(在数据预热完成之前,用户的请求无法进来)
InitializingBean 接口为 bean 提供了初始化方法的方式,它只包括 afterPropertiesSet()方法。 在 Spring 初始化 bean 的时候,如果 bean 实现了 InitializingBean 接口, 在对象的所有属性被初始化后之后才会调用 afterPropertiesSet()方法。
1public class RangingService implements InitializingBean {
2
3 public void rankSaleAdd() {
4 UserScoreExample example = new UserScoreExample();
5 example.setOrderByClause("id desc");
6 List<UserScore> userScores = userScoreMapper.selectByExample(example);
7 userScores.forEach(userScore -> {
8 String key = userScore.getUserId() + ":" + userScore.getName();
9 redisService.zAdd(SALESCORE, key, userScore.getUserScore());
10 });
11
12 @Override
13 public void afterPropertiesSet() throws Exception {
14 System.out.println("======enter init bean=======");
15 this.rankSaleAdd();
16 }
17}
测试缓存预热是否生效
停止 SpringBoot 项目,删除 Redis 中所有缓存,启动 SpringBoot 项目,使用 Redis Desktop Manager 查看 Redis,如下图所示: