1. 為什么需要改進
當 ZSet 中存在相同分數 (score) 的元素時,單純使用分數作為偏移會導致數據漏查或重復。例如:
- 多條記錄具有相同時間戳(作為分數)
- 分頁查詢時可能跳過相同分數的元素
- 或重復查詢相同分數的元素
改進方案:結合最后分數 (lastScore)?和相同分數內的偏移量 (offset)?實現精確滾動。
2. 實現代碼
2.1 依賴配置
確保?pom.xml
?包含 Redis 依賴:
xml
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
2.2 服務實現類
使用StringRedisTemplate的高級ZSet滾動查詢
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ZSetOperations;
import org.springframework.stereotype.Service;
import java.util.*;@Service
public class ZSetScrollService {private final StringRedisTemplate stringRedisTemplate;// ZSet鍵名(可根據業務調整)private static final String ZSET_KEY = "articles:zset";public ZSetScrollService(StringRedisTemplate stringRedisTemplate) {this.stringRedisTemplate = stringRedisTemplate;}/*** 高級滾動查詢(處理相同分數場景)* @param lastScore 上一次查詢的最后分數,首次查詢傳0* @param offset 相同分數內的偏移量,首次查詢傳0* @param pageSize 每頁大小* @return 包含數據、最后分數、偏移量和是否有更多數據的Map*/public Map<String, Object> scrollQuery(double lastScore, int offset, int pageSize) {List<String> result = new ArrayList<>();double newLastScore = lastScore;int newOffset = 0;boolean hasMore = false;// 1. 處理上一次最后分數的剩余元素(如果有偏移)if (offset > 0) {// 獲取與lastScore相同分數的所有元素Set<ZSetOperations.TypedTuple<String>> sameScoreTuples = stringRedisTemplate.opsForZSet().rangeByScoreWithScores(ZSET_KEY, lastScore, lastScore);if (sameScoreTuples != null && !sameScoreTuples.isEmpty()) {List<ZSetOperations.TypedTuple<String>> sameScoreList = new ArrayList<>(sameScoreTuples);int remaining = sameScoreList.size() - offset;// 相同分數下還有剩余元素if (remaining > 0) {int take = Math.min(remaining, pageSize);// 從偏移位置開始取元素for (int i = offset; i < offset + take; i++) {result.add(sameScoreList.get(i).getValue());}newOffset = offset + take;// 如果已取完當前分數的所有元素,重置偏移if (newOffset >= sameScoreList.size()) {newOffset = 0;} else {newLastScore = lastScore;}// 如果取的元素小于pageSize,繼續從更高分數取if (take < pageSize) {pageSize -= take;} else {// 已取夠一頁,判斷是否有更多數據hasMore = checkHasMore(newLastScore, newOffset);return createResponse(result, newLastScore, newOffset, hasMore);}} else {newOffset = 0; // 相同分數下已無元素,重置偏移}} else {newOffset = 0; // 該分數已無元素,重置偏移}}// 2. 查詢更高分數的元素if (pageSize > 0) {// 查詢大于lastScore的元素,多查一個用于判斷是否有下一頁Set<ZSetOperations.TypedTuple<String>> higherTuples = stringRedisTemplate.opsForZSet().rangeByScoreWithScores(ZSET_KEY, lastScore + 1, Double.MAX_VALUE, 0, pageSize + 1);if (higherTuples != null && !higherTuples.isEmpty()) {List<ZSetOperations.TypedTuple<String>> higherList = new ArrayList<>(higherTuples);hasMore = higherList.size() > pageSize;// 確定需要取的元素數量int take = hasMore ? pageSize : higherList.size();// 提取元素for (int i = 0; i < take; i++) {ZSetOperations.TypedTuple<String> tuple = higherList.get(i);result.add(tuple.getValue());newLastScore = tuple.getScore();}// 處理最后一個分數的偏移if (take > 0) {ZSetOperations.TypedTuple<String> lastTuple = higherList.get(take - 1);double currentScore = lastTuple.getScore();// 計算當前分數下的總元素數long totalSameScore = stringRedisTemplate.opsForZSet().count(ZSET_KEY, currentScore, currentScore);// 計算當前分數下已返回的元素數long currentScoreReturned = 0;for (int i = 0; i < take; i++) {if (higherList.get(i).getScore().equals(currentScore)) {currentScoreReturned++;}}// 設置相同分數內的偏移newOffset = (currentScoreReturned < totalSameScore) ? (int) currentScoreReturned : 0;}}}// 最終判斷是否有更多數據if (!hasMore) {hasMore = checkHasMore(newLastScore, newOffset);}return createResponse(result, newLastScore, newOffset, hasMore);}/*** 檢查是否有更多數據*/private boolean checkHasMore(double lastScore, int offset) {// 1. 檢查當前分數下是否還有未返回的元素if (offset > 0) {Long sameScoreCount = stringRedisTemplate.opsForZSet().count(ZSET_KEY, lastScore, lastScore);if (sameScoreCount != null && sameScoreCount > offset) {return true;}}// 2. 檢查是否存在更高分數的元素Long higherCount = stringRedisTemplate.opsForZSet().count(ZSET_KEY, lastScore + 1, Double.MAX_VALUE);return higherCount != null && higherCount > 0;}/*** 創建響應結果*/private Map<String, Object> createResponse(List<String> data, double lastScore, int offset, boolean hasMore) {Map<String, Object> response = new HashMap<>(4);response.put("data", data);response.put("lastScore", lastScore);response.put("offset", offset);response.put("hasMore", hasMore);return response;}// ------------------------- 輔助方法 -------------------------/*** 向ZSet添加元素* @param member 元素值(通常為JSON字符串)* @param score 排序分數(如時間戳)*/public Boolean add(String member, double score) {return stringRedisTemplate.opsForZSet().add(ZSET_KEY, member, score);}/*** 獲取元素的分數*/public Double getScore(String member) {return stringRedisTemplate.opsForZSet().score(ZSET_KEY, member);}/*** 刪除元素*/public Long remove(String... members) {return stringRedisTemplate.opsForZSet().remove(ZSET_KEY, members);}/*** 統計分數范圍內的元素數量*/public Long count(double min, double max) {return stringRedisTemplate.opsForZSet().count(ZSET_KEY, min, max);}
}
創建時間:10:10
2.3 控制器使用示例
java
運行
@RestController
@RequestMapping("/api/zset")
public class ZSetController {private final ZSetScrollService scrollService;private final ObjectMapper objectMapper; // 用于JSON序列化public ZSetController(ZSetScrollService scrollService, ObjectMapper objectMapper) {this.scrollService = scrollService;this.objectMapper = objectMapper;}// 添加元素示例(對象轉JSON字符串)@PostMapping("/add")public ResponseEntity<?> addElement(@RequestBody Article article) throws JsonProcessingException {// 將對象轉為JSON字符串存儲String jsonStr = objectMapper.writeValueAsString(article);// 使用時間戳作為分數(確保新元素排在前面可使用負的時間戳)double score = System.currentTimeMillis();scrollService.add(jsonStr, score);return ResponseEntity.ok("添加成功");}// 滾動查詢示例@GetMapping("/scroll")public ResponseEntity<?> scroll(@RequestParam(defaultValue = "0") double lastScore,@RequestParam(defaultValue = "0") int offset,@RequestParam(defaultValue = "10") int pageSize) throws JsonProcessingException {Map<String, Object> result = scrollService.scrollQuery(lastScore, offset, pageSize);// 將JSON字符串轉為對象(可選)if (result.containsKey("data")) {List<String> jsonList = (List<String>) result.get("data");List<Article> articles = jsonList.stream().map(json -> {try {return objectMapper.readValue(json, Article.class);} catch (JsonProcessingException e) {throw new RuntimeException(e);}}).collect(Collectors.toList());result.put("data", articles);}return ResponseEntity.ok(result);}
}
3. 核心原理說明
雙重定位機制:
lastScore
:記錄上一次查詢的最后一個元素的分數offset
:記錄在該分數下已經返回的元素數量,解決相同分數問題
查詢流程:
- 先處理上一次最后分數中未返回的元素(基于 offset)
- 再查詢更高分數的元素,直到滿足分頁大小
- 自動計算下一次查詢所需的 lastScore 和 offset
關鍵 Redis 命令:
ZRANGEBYSCORE key min max [LIMIT offset count]
:按分數范圍查詢ZCOUNT key min max
:統計分數范圍內的元素數量ZSCORE key member
:獲取元素的分數
4. 使用場景
- 社交媒體時間線(按發布時間排序,可能有相同時間)
- 實時排行榜(按分數排序,可能有相同分數)
- 日志記錄查詢(按時間戳排序)
- 大數據量有序列表的滾動加載
這種實現既保留了 Redis ZSet 的高性能,又解決了相同分數元素的查詢問題,是處理動態有序數據滾動加載的理想方案。