上个月做了一次营销活动,活动大概是分成两个队,用户可以随意加入战队,通过不断做任务来提高自己的战力值,提升自己战力值的同时也会提升所在队伍的战力值,在不断PK的过程中,队伍和具体用户的战力值及排名信息是在不断变化的,承蒙组织厚爱,把这个光荣的任务交给了我,经过对比,我最终选择了redis的zset来做我们本次排名机制的技术方案。
但是我们的活动忽略了一个很重要的东西,就是在相同的战力值下,如何确定二级排名,由于产品意识到这个问题较晚,最终没有实现,一是项目已经提测,无法承担改动的风险,二是时间真的不够。虽然现在活动结束了,但是这个问题一直摆在我心里,作为一个举一反三,勤思好学的程序员,我绝对要把这个问题解决掉,哈哈。
网上百度方案,发现大多数的方案为:通过分数和时间戳来组合成一个分数存到redis,随机选择一个方案的具体实现如下:
我想说:这个方案的确可行,但是存在缺陷:
1、战力值相同的情况下,我如果要改成最晚达到战力值的排名在前,怎么改?
2、如果我有三级排名,需要用到三个字段来排名,怎么办?
经过我痛定思痛,我想了一套可以完美解决此类问题的方案:一级的战力值排名依然放在redis,二级(甚至更多级)的排名信息,我通过一个另外的方式存储在其他地方。
直接上代码:
package chen.huai.jie.springboot.ranking.service;
import chen.huai.jie.springboot.ranking.model.UserRanking;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ZSetOperations;
import org.springframework.stereotype.Service;
import org.springframework.util.CollectionUtils;
import java.util.*;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors;
/**
* @author chenhuaijie
*/
@Service
public class RankingService {
private String key = "ranking";
private String updateTimeKey = "updateTime";
@Autowired
private StringRedisTemplate stringRedisTemplate;
private void putUpdateTime(String userId, Long updateTime) {
stringRedisTemplate.opsForHash().put(updateTimeKey, userId, updateTime);
}
private Long getUpdateTime(String userId) {
return (Long) stringRedisTemplate.opsForHash().get(updateTimeKey, userId);
}
/**
* 给用户增加分数
*
* @param userId
* @param score
* @return
*/
public Double increaseUserScore(String userId, Double score) {
putUpdateTime(userId, System.currentTimeMillis());
return stringRedisTemplate.opsForZSet().incrementScore(key, userId, score);
}
/**
* 获取排名
* 根据分分数倒序排列,时间升序排列(越早达到这个分数的越排名越靠前)
* 说明:大的排序在Redis里做,只是分数相同的情况下,需要在内存里再进行一次排序
*
* @return
*/
public List<UserRanking> getRankings() {
Long size = stringRedisTemplate.opsForZSet().size(key);
Set<ZSetOperations.TypedTuple<String>> typedTuples = Objects.requireNonNull(stringRedisTemplate.opsForZSet().reverseRangeWithScores(key, 0, size));
if (CollectionUtils.isEmpty(typedTuples)) {
return new ArrayList<>(0);
}
return getRankings(typedTuples, size);
}
/**
* 获取排名
* 根据分分数倒序排列,时间升序排列(越早达到这个分数的越排名越靠前)
* 说明:大的排序在Redis里做,只是分数相同的情况下,需要在内存里再进行一次排序
*
* @return
*/
public List<UserRanking> getRankings(Long limit) {
Long size = stringRedisTemplate.opsForZSet().size(key);
if (limit > size) {
limit = size;
}
Set<ZSetOperations.TypedTuple<String>> typedTuples = stringRedisTemplate.opsForZSet().reverseRangeWithScores(key, 0, limit - 1);
// 获取第一名和最后一名的分数
Double maxScore = new ArrayList<>(typedTuples).get(0).getScore();
Double score = new ArrayList<>(typedTuples).get(typedTuples.size() - 1).getScore();
if (stringRedisTemplate.opsForZSet().count(key, score, score) > 1) {
typedTuples = stringRedisTemplate.opsForZSet().reverseRangeByScoreWithScores(key, score, maxScore);
}
return getRankings(typedTuples, limit);
}
private List<UserRanking> getRankings(Set<ZSetOperations.TypedTuple<String>> typedTuples, Long limit) {
AtomicInteger atomicInteger = new AtomicInteger(0);
List<UserRanking> userRankings = typedTuples.stream().map(x ->
UserRanking.builder()
.userId(x.getValue())
.score(x.getScore())
.updateTime(getUpdateTime(x.getValue()))
.build()
).collect(Collectors.toList());
if (userRankings.stream().map(UserRanking::getScore).distinct().count() < typedTuples.size()) {
// 有排名重复的情况下,内存做二次排名
userRankings = userRankings.stream()
.sorted(Comparator
// 按照分数倒序排列,分数为空的排在最后
.comparing(UserRanking::getScore, Comparator.nullsLast(Double::compareTo)).reversed()
// 接着按照时间正序排列,时间为空的排在最后
.thenComparing(UserRanking::getUpdateTime, Comparator.nullsLast(Long::compareTo)))
.limit(limit)
.collect(Collectors.toList());
}
userRankings.forEach(x -> x.setRanking(atomicInteger.incrementAndGet()));
return userRankings;
}
/**
* 获取某个用户的排名
*
* @param userId
* @return
*/
public UserRanking getUserRanking(String userId) {
Set<ZSetOperations.TypedTuple<String>> typedTuples = stringRedisTemplate.opsForZSet().reverseRangeWithScores(key, 0, 0);
Double score = stringRedisTemplate.opsForZSet().score(key, userId);
Double maxScore = new ArrayList<>(typedTuples).get(0).getScore();
Long countOfScore = stringRedisTemplate.opsForZSet().count(key, score, score);
if (countOfScore == 1) {
// 本人排名没有重复的情况
Long ranking = stringRedisTemplate.opsForZSet().rank(key, score);
return UserRanking.builder()
.ranking(ranking.intValue() + 1)
.userId(userId)
.score(score)
.updateTime(getUpdateTime(userId))
.build();
} else {
// 本人排名有重复的情况
Long countBetweenScores = stringRedisTemplate.opsForZSet().count(key, score, maxScore);
typedTuples = stringRedisTemplate.opsForZSet().reverseRangeByScoreWithScores(key, score, score);
List<UserRanking> userRankings = getRankings(typedTuples, (long) typedTuples.size());
UserRanking userRanking = userRankings.stream().filter(x -> x.getUserId().equals(userId)).findAny().orElse(null);
if (userRanking == null) {
return null;
}
// 重新计算排名信息
userRanking.setRanking((countBetweenScores.intValue() - countOfScore.intValue() + userRanking.getRanking()));
return userRanking;
}
}
}
我一共实现了四个方法:
给用户增加战力值
获取全部用户排名
获取指定数量用户排名
获取某个用户的排名
相比上面引入图例其他的实现方式,我这个的方案的优缺点总结如下:
缺点:
计算逻辑稍微复杂(但是还是很清晰的);
同事每次计算需要访问多次redis(这个并不影响性能);
不能再redis里直观地看到排名信息;
优点:
redis存储的分数数据保留了原始值,避免了跟时间戳混在一起;
后续需求变更改动少,如果需要增加二级以上排序,我这个修改逻辑还可以实现,用上面的方案根本无法实现。
总结:
记录一下日常开发的趣事,老铁给我一个三连吧。