redis-zset分页查询去重组件
背景:
在工作中大量用到redis做数据存储,特别是zset结构非常适合做用户排行榜等包含权重的列表。假设一个场景:
- 实现一个用户排序列表
- 使用了zset结构存储这个列表,member元素为用户id,score分值为权重
- 分页查询:假设查询到第2页时,第1页数据发生变化,或者第1页有一条数据被顶到第二页,就可能产生重复数据
- 要解决分页数据重复的问题,一般有两种方案:
- 缓存快照,每个线程查询第一页时,缓存到快照,设定失效时间。保证这一次分页查询的生命周期内数据不会重复
- 已读缓存,每个线程查询第一页时,生成去重缓存(设定失效时间,下次请求第一页时也会失效),每返回一页数据时先根据已读缓存过滤一遍,再将新一页数据返回并添加到已读缓存。
- 下面针对已读缓存的方案,实现组件
引入依赖:
<!-- https://mvnrepository.com/artifact/org.springframework.data/spring-data-redis -->
<dependency>
<groupId>org.springframework.data</groupId>
<artifactId>spring-data-redis</artifactId>
<version>2.2.4.RELEASE</version>
</dependency>
实现代码:
接口:
package com.cache.service;
import org.springframework.data.redis.core.ZSetOperations;
import java.util.List;
import java.util.Set;
/**
* redis排名列表操作接口
*/
public interface RedisZSetRankingService {
/**
* 查询有序列表(过滤重复数据)
* @param key zset key
* @param id 唯一id(用户id、当前线程id)
* @param lastScore 上一页的最后一个score值
* @param pageSize 查询页大小
* @param sort 排序方式 1:升序;-1:降序
* @param blackSet 需要过滤的黑名单列表
* @return 返回结果
*/
List<ZSetOperations.TypedTuple<Object>> findRankList(String key, Object id, Double lastScore, long pageSize, long sort, Set<Long> blackSet);
/**
* 查询有序列表根据上一页最后一个元素(过滤重复数据)
* @param key zset key
* @param id 唯一id(用户id、当前线程id)
* @param lastMember 上一页最后第一个元素
* @param pageSize 查询页大小
* @param sort 排序方式 1:升序;-1:降序
* @param blackSet 需要过滤的黑名单列表
* @return 返回结果
*/
List<ZSetOperations.TypedTuple<Object>> findRankListByLastMember(String key, Object id, Object lastMember, long pageSize, long sort, Set<Long> blackSet);
}
实现类:
package com.cache.service.impl;
import com.vdpub.cache.config.RedisZSetRankConfig;
import com.vdpub.cache.service.RedisZSetRankingService;
import com.vdpub.common.util.CollectionUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ZSetOperations;
import org.springframework.util.Assert;
import java.text.MessageFormat;
import java.util.*;
import java.util.concurrent.TimeUnit;
public class RedisZSetRankingServiceImpl implements RedisZSetRankingService {
private final Logger logger = LoggerFactory.getLogger(getClass());
private final RedisTemplate<String, Object> redisTemplate;
private final RedisZSetRankConfig redisZSetRankConfig;
private final Map<String, RedisZSetRankConfig> configMap;
/**
* 去重key
*/
private static final String DEDUPLICATION_KEY = ":deduplication:{0}";
private static String getDeduplicationKey(String key, Object id) {
return MessageFormat.format(key + DEDUPLICATION_KEY, String.valueOf(id));
}
private RedisZSetRankConfig getConfig(String key) {
return Optional.ofNullable(configMap.get(key.replaceAll(":", ""))).orElse(redisZSetRankConfig);
}
public RedisZSetRankingServiceImpl(RedisTemplate<String, Object> redisTemplate, RedisZSetRankConfig redisZSetRankConfig) {
this.redisTemplate = redisTemplate;
this.redisZSetRankConfig = redisZSetRankConfig;
this.configMap = redisZSetRankConfig.getMap() != null ? redisZSetRankConfig.getMap() : new HashMap<>();
}
@Override
public List<ZSetOperations.TypedTuple<Object>> findRankList(String key, Object id, Double lastScore, long pageSize, long sort, Set<Long> blackSet) {
Assert.notNull(key, "param key is null");
Assert.notNull(id, "param id is null");
// 获取去重集合key
String deduplicationKey = getDeduplicationKey(key, id);
// 查询第一页删除去重集合
if (lastScore == null) {
redisTemplate.delete(deduplicationKey);
}
RedisZSetRankConfig config = getConfig(key);
int recursion = 0;
long offset = 0;
List<ZSetOperations.TypedTuple<Object>> result = new ArrayList<>();
while (result.size() < pageSize && recursion++ <= config.getRecursion()) {
Set<ZSetOperations.TypedTuple<Object>> objects;
if (sort < 0) {
objects = redisTemplate.opsForZSet().reverseRangeByScoreWithScores(key, Long.MIN_VALUE, lastScore != null ? lastScore : Long.MAX_VALUE, offset, pageSize);
} else {
objects = redisTemplate.opsForZSet().rangeByScoreWithScores(key, lastScore != null ? lastScore : Long.MIN_VALUE, Long.MAX_VALUE, offset, pageSize);
}
if (CollectionUtils.isEmpty(objects)) {
break;
}
// 第一页请求不需要过滤重复
// 过滤完将已读元素放到去重集合
for (ZSetOperations.TypedTuple<Object> o : objects) {
if(o.getValue() == null){
continue;
}
Long member = ((Number) o.getValue()).longValue();
if((lastScore == null || Boolean.FALSE.equals(redisTemplate.opsForSet().isMember(deduplicationKey, member))) && !blackSet.contains(member)){
redisTemplate.opsForSet().add(deduplicationKey, o.getValue());
result.add(o);
if (result.size() >= pageSize) {
break;
}
}
}
Optional<ZSetOperations.TypedTuple<Object>> first = objects.stream().skip(objects.size() - 1).findFirst();
if(!first.isPresent()){
break;
}
offset = lastScore != null && lastScore.equals(first.get().getScore()) ? objects.size() : 0;
lastScore = first.get().getScore();
if (recursion >= config.getRecursion()) {
logger.info("findRevRankList recursion over-limit key={}, id={}", key, id);
}
}
redisTemplate.expire(deduplicationKey, config.getDeduplicationExpire(), TimeUnit.SECONDS);
return result;
}
@Override
public List<ZSetOperations.TypedTuple<Object>> findRankListByLastMember(String key, Object id, Object lastMember, long pageSize, long sort, Set<Long> blackSet) {
Double lastScore = null;
if (lastMember != null) {
lastScore = redisTemplate.opsForZSet().score(key, lastMember);
if (lastScore == null) {
logger.warn("findRevRankListByLastMember lastMemberNotFound Key={}, lastMember={}", key, lastMember);
return new ArrayList<>();
}
}
return this.findRankList(key, id, lastScore, pageSize, sort, blackSet);
}
}
配置类:
package com.cache.config;
import java.util.Map;
public class RedisZSetRankConfig {
/**
* 默认配置:循环次数(特殊情况下查到一页数据全部为重复数据后,自动查询下一页的次数)
*/
private int recursion = 5;
/**
* 默认配置:去重缓存过期时间 单位:s
*/
private long deduplicationExpire = 7200;
/**
* 特殊配置:每个zset-key都可以单独配置,如果不配,使用默认
*/
private Map<String, RedisZSetRankConfig> map;
public long getDeduplicationExpire() {
return deduplicationExpire;
}
public void setDeduplicationExpire(long deduplicationExpire) {
this.deduplicationExpire = deduplicationExpire;
}
public int getRecursion() {
return recursion;
}
public void setRecursion(int recursion) {
this.recursion = recursion;
}
public Map<String, RedisZSetRankConfig> getMap() {
return map;
}
public void setMap(Map<String, RedisZSetRankConfig> map) {
this.map = map;
}
}
spring加载配置:
/**
将组件加载到spring容器
*/
@Bean("redisZSetRankConfig")
@ConfigurationProperties(prefix = "ranking.zset")
public RedisZSetRankConfig redisZSetRankConfig() {
return new RedisZSetRankConfig();
}
@Bean("redisZSetRankingService")
public RedisZSetRankingService RedisZSetRankingService(@Qualifier("redisTemplate") RedisTemplate<String, Object> redisTemplate, RedisZSetRankConfig redisZSetRankConfig){
return new RedisZSetRankingServiceImpl(redisTemplate, redisZSetRankConfig);
}
yaml配置:
ranking:
zset:
recursion: 5
deduplicationExpire: 7200
map:
zset_key1:
recursion: 10
deduplicationExpire: 1000
zset_key2:
recursion: 15
deduplicationExpire: 2000