redis-zset 페이징 쿼리 중복 제거 구성 요소
배경:
Redis는 업무 중 데이터 저장 용도로 널리 사용되며, 특히 zset 구조는 사용자 리더보드와 같이 가중치가 포함된 목록에 매우 적합합니다. 시나리오를 가정해 보겠습니다.
- 정렬된 사용자 목록 구현
- zset 구조는 이 목록을 저장하는 데 사용되며 멤버 요소는 사용자 ID이고 점수는 가중치입니다.
- 페이징 쿼리: 쿼리가 두 번째 페이지에 도달하면 첫 번째 페이지의 데이터가 변경되거나 첫 번째 페이지의 데이터 일부가 두 번째 페이지로 푸시되어 중복 데이터가 발생할 수 있다고 가정합니다.
- 페이징 데이터 중복 문제를 해결하려면 일반적으로 두 가지 솔루션이 있습니다.
- 캐시 스냅샷: 각 스레드가 첫 번째 페이지를 쿼리할 때 스냅샷을 캐시하고 만료 시간을 설정합니다. 이 페이징 쿼리의 수명 주기 동안 데이터가 반복되지 않는지 확인하세요.
- 읽기 캐시, 각 스레드가 첫 번째 페이지를 쿼리하면 중복 제거 캐시가 생성되고(만료 시간을 설정하면 다음에 첫 번째 페이지가 요청될 때도 유효하지 않음) 데이터 페이지가 반환될 때마다 먼저 읽기 캐시에 따라 필터링한 다음 반환하고 읽기 캐시에 새 데이터 페이지를 추가합니다.
- 다음은 읽기 캐시 솔루션을 위한 구성 요소를 구현합니다.
종속성 가져오기:
<!-- 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容器
*/
@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