更多Spring文章,欢迎点击 一灰灰Blog-Spring专题
在一些游戏和活动中,当涉及到社交元素的时候,排行榜能够说是一个很常见的需求场景了,就咱们一般见到的排行榜而言,会提供如下基本功能java
上面能够说是一个排行榜须要实现的几个基本要素了,正好咱们刚讲到了redis这一节,本篇则开始实战,详细描述如何借助redis来实现一份全球排行榜git
<!-- more -->github
在进行方案设计以前,先模拟一个真实的应用场景,而后进行辅助设计与实现redis
之前一段时间特别🔥的跳一跳这个小游戏进行说明,假设咱们这个游戏用户遍及全球,所以咱们要设计一个全球的榜单,每一个玩家都会根据本身的战绩在排行榜中获取一个排名,咱们须要支持全球榜单的查询,本身排位的查询这两种最基本的查询场景;此外当个人分数比上一次的高时,我须要更新个人积分,从新得到个人排名;spring
此外也会有一些高级的统计,好比哪一个分段的人数最多,什么分段是瓶颈点,再根据地理位置计算平均分等等数组
本篇博文主要内容将放在排行榜的设计与实现上;至于高级的功能实现,后续有机会再说安全
由于排行榜的功能比较简单了,也不须要什么复杂的结构设计,也没有什么复杂的交互,所以咱们须要确认的无非就是数据结构 + 存储单元数据结构
存储单元并发
表示排行榜中每一位上应该持有的信息,一个最简单的以下app
// 用来代表具体的用户 long userId; // 用户在排行榜上的排名 long rank; // 用户的历史最高积分,也就是排行榜上的积分 long score;
数据结构
排行榜,通常而言都是连续的,借此咱们能够联想到一个合适的数据结构LinkedList,好处在于排名变更时,不须要数组的拷贝
上图演示,当一个用户积分改变时,须要向前遍历找到合适的位置,插入并获取新的排名, 在更新和插入时,相比较于ArrayList要好不少,但依然有如下几个缺陷
问题1:用户如何获取本身的排名?
使用LinkedList
在更新插入和删除的带来优点以外,在随机获取元素的支持会差一点,最差的状况就是从头至尾进行扫描
问题2:并发支持的问题?
当有多个用户同时更新score时,并发的更新排名问题就比较突出了,固然可使用jdk中相似写时拷贝数组的方案
上面是咱们本身来实现这个数据结构时,会遇到的一些问题,固然咱们的主题是借助redis来实现排行榜,下面则来看下,利用redis能够怎么简单的支持咱们的需求场景
这里主要使用的是redis的ZSET数据结构,带权重的集合,下面分析一下可能性
从zset的特性来看,咱们每一个用户的积分,丢到zset中,就是一个带权重的元素,并且是已经排好序的了,只须要获取元素对应的index,就是咱们预期的排名
再具体的实现以前,能够先查看一下redis中zset的相关方法和操做姿式:SpringBoot高级篇Redis之ZSet数据结构使用姿式
咱们主要是借助zset提供的一些方法来实现排行榜的需求,下面的具体方法设计中,也会有相关说明
首先准备好redis环境,spring项目搭建好,而后配置好redisTemplate
/** * Created by @author yihui in 15:05 18/11/8. */ public class DefaultSerializer implements RedisSerializer<Object> { private final Charset charset; public DefaultSerializer() { this(Charset.forName("UTF8")); } public DefaultSerializer(Charset charset) { Assert.notNull(charset, "Charset must not be null!"); this.charset = charset; } @Override public byte[] serialize(Object o) throws SerializationException { return o == null ? null : String.valueOf(o).getBytes(charset); } @Override public Object deserialize(byte[] bytes) throws SerializationException { return bytes == null ? null : new String(bytes, charset); } } @Configuration public class AutoConfig { @Bean(value = "selfRedisTemplate") public RedisTemplate<String, String> stringRedisTemplate(RedisConnectionFactory redisConnectionFactory) { StringRedisTemplate redis = new StringRedisTemplate(); redis.setConnectionFactory(redisConnectionFactory); // 设置redis的String/Value的默认序列化方式 DefaultSerializer stringRedisSerializer = new DefaultSerializer(); redis.setKeySerializer(stringRedisSerializer); redis.setValueSerializer(stringRedisSerializer); redis.setHashKeySerializer(stringRedisSerializer); redis.setHashValueSerializer(stringRedisSerializer); redis.afterPropertiesSet(); return redis; } }
上传用户积分,然而zset中有一点须要注意的是其排行是根据score进行升序排列,这个就和咱们实际的状况不太同样了;为了和实际状况一致,能够将score取反;另一个就是排行默认是从0开始的,这个与咱们的实际也不太同样,须要+1
/** * 更新用户积分,并获取最新的我的所在排行榜信息 * * @param userId * @param score * @return */ public RankDO updateRank(Long userId, Float score) { // 由于zset默认积分小的在前面,因此咱们对score进行取反,这样用户的积分越大,对应的score越小,排名越高 redisComponent.add(RANK_PREFIX, String.valueOf(userId), -score); Long rank = redisComponent.rank(RANK_PREFIX, String.valueOf(userId)); return new RankDO(rank + 1, score, userId); }
上面的实现,主要利用了zset的两个方法,一个是添加元素,一个是查询排名,对应的redis操做方法以下,
@Resource(name = "selfRedisTemplate") private StringRedisTemplate redisTemplate; /** * 添加一个元素, zset与set最大的区别就是每一个元素都有一个score,所以有个排序的辅助功能; zadd * * @param key * @param value * @param score */ public void add(String key, String value, double score) { redisTemplate.opsForZSet().add(key, value, score); } /** * 判断value在zset中的排名 zrank * * 积分小的在前面 * * @param key * @param value * @return */ public Long rank(String key, String value) { return redisTemplate.opsForZSet().rank(key, value); }
获取我的排行信息,主要就是两个一个是排名一个是积分;须要注意的是当用户没有积分时(即没有上榜时),须要额外处理
/** * 获取用户的排行榜位置 * * @param userId * @return */ public RankDO getRank(Long userId) { // 获取排行, 由于默认是0为开头,所以实际的排名须要+1 Long rank = redisComponent.rank(RANK_PREFIX, String.valueOf(userId)); if (rank == null) { // 没有排行时,直接返回一个默认的 return new RankDO(-1L, 0F, userId); } // 获取积分 Double score = redisComponent.score(RANK_PREFIX, String.valueOf(userId)); return new RankDO(rank + 1, Math.abs(score.floatValue()), userId); }
上面的封装中,除了使用前面的获取用户排名以外,还有获取用户积分
/** * 查询value对应的score zscore * * @param key * @param value * @return */ public Double score(String key, String value) { return redisTemplate.opsForZSet().score(key, value); }
有了前面的基础以后,这个就比较简单了,首先获取用户的我的排名,而后查询固定排名段的数据便可
private List<RankDO> buildRedisRankToBizDO(Set<ZSetOperations.TypedTuple<String>> result, long offset) { List<RankDO> rankList = new ArrayList<>(result.size()); long rank = offset; for (ZSetOperations.TypedTuple<String> sub : result) { rankList.add(new RankDO(rank++, Math.abs(sub.getScore().floatValue()), Long.parseLong(sub.getValue()))); } return rankList; } /** * 获取用户所在排行榜的位置,以及排行榜中其先后n个用户的排行信息 * * @param userId * @param n * @return */ public List<RankDO> getRankAroundUser(Long userId, int n) { // 首先是获取用户对应的排名 RankDO rank = getRank(userId); if (rank.getRank() <= 0) { // fixme 用户没有上榜时,不返回 return Collections.emptyList(); } // 由于实际的排名是从0开始的,因此查询周边排名时,须要将n-1 Set<ZSetOperations.TypedTuple<String>> result = redisComponent.rangeWithScore(RANK_PREFIX, Math.max(0, rank.getRank() - n - 1), rank.getRank() + n - 1); return buildRedisRankToBizDO(result, rank.getRank() - n); }
看下上面的实现,获取用户排名以后,就能够计算要查询的排名范围[Math.max(0, rank.getRank() - n - 1), rank.getRank() + n - 1]
其次须要注意的如何将返回的结果进行封装,上面写了个转换类,主要起始排行榜信息
上面的理解以后,这个就很简答了
/** * 获取前n名的排行榜数据 * * @param n * @return */ public List<RankDO> getTopNRanks(int n) { Set<ZSetOperations.TypedTuple<String>> result = redisComponent.rangeWithScore(RANK_PREFIX, 0, n - 1); return buildRedisRankToBizDO(result, 1); }
首先准备一个测试脚本,批量的插入一下积分,用于后续的查询更新使用
public class RankInitTest { private Random random; private RestTemplate restTemplate; @Before public void init() { random = new Random(); restTemplate = new RestTemplate(); } private int genUserId() { return random.nextInt(1024); } private double genScore() { return random.nextDouble() * 100; } @Test public void testInitRank() { for (int i = 0; i < 30; i++) { restTemplate.getForObject("http://localhost:8080/update?userId=" + genUserId() + "&score=" + genScore(), String.class); } } }
上面执行完毕以后,排行榜中应该就有三十条数据,接下来咱们开始逐个接口测试,首先获取top10排行
对应的rest接口以下
@RestController public class RankAction { @Autowired private RankListComponent rankListComponent; @GetMapping(path = "/topn") public List<RankDO> showTopN(int n) { return rankListComponent.getTopNRanks(n); } }
接下来咱们挑选第15名,获取对应的排行榜信息
@GetMapping(path = "/rank") public RankDO queryRank(long userId) { return rankListComponent.getRank(userId); }
首先咱们从redis中获取第15名的userId,而后再来查询
而后尝试修改下他的积分,改大一点,将score改为80分,则会排到第五名
@GetMapping(path = "/update") public RankDO updateScore(long userId, float score) { return rankListComponent.updateRank(userId, score); }
最后咱们查询下这个用户周边2个的排名信息
@GetMapping(path = "/around") public List<RankDO> around(long userId, int n) { return rankListComponent.getRankAroundUser(userId, n); }
上面利用redis的zset实现了排行榜的基本功能,主要借助下面三个方法
虽然实现了基本功能,可是问题仍是有很多的
一灰灰的我的博客,记录全部学习和工做中的博文,欢迎你们前去逛逛
尽信书则不如,以上内容,纯属一家之言,因我的能力有限,不免有疏漏和错误之处,如发现bug或者有更好的建议,欢迎批评指正,不吝感激
一灰灰blog
知识星球