Redis實現(xiàn)分頁+多條件模糊查詢的組合方案
一、問題背景:為什么 Redis 單獨搞不定這件事
在大多數(shù)業(yè)務(wù)里,分頁和模糊查詢本身是再普通不過的需求。挑出第 3 頁、每頁 20 條、姓"阿"的女性用戶——放在 MySQL、Oracle 里,一行 SQL 就能解決。但當數(shù)據(jù)先落在 Redis 這種緩存層、甚至完全只存在于 Redis 里時,事情就沒那么簡單。
Redis 是一個 key-value 形態(tài)的內(nèi)存數(shù)據(jù)庫,原生指令圍繞 GET/SET、List、Set、Hash、ZSet 展開,并沒有提供類似 WHERE name LIKE '阿%' AND gender = '女' ORDER BY created_at DESC LIMIT 40, 20 的語義。要在 Redis 里同時滿足"分頁"和"多條件模糊匹配",必須自己用數(shù)據(jù)結(jié)構(gòu)拼裝。
通常會踩到這件事的業(yè)務(wù)場景有幾類:
- 評論、動態(tài)、消息流這類時間線數(shù)據(jù),熱點都在緩存里
- 高并發(fā)寫入的中間結(jié)果,先在 Redis 緩沖、稍后再持久化到數(shù)據(jù)庫
- 實時統(tǒng)計類的看板,MySQL 扛不住的查詢被前移到緩存
- 某些數(shù)據(jù)本身就不落庫,例如臨時排行榜、活動期間的會話數(shù)據(jù)
這些場景的共同特征是:數(shù)據(jù)要么暫時不在數(shù)據(jù)庫里,要么放在數(shù)據(jù)庫里也來不及查。下面要解決的,就是在這種約束下,怎么把分頁和多條件模糊查詢拼起來。
二、分頁:為什么首選 ZSet
Redis 里能支撐"取第幾頁、每頁幾條"的結(jié)構(gòu)有兩個:List 和 ZSet。看似都能做,但 ZSet 幾乎在所有場景都更合適。
2.1 ZSet 的核心指令
ZSet 全稱 Sorted Set,即有序集合,每個元素同時綁定一個 score,集合按 score 自動排序。和分頁相關(guān)的指令主要有這幾個:
| 指令 | 作用 |
|---|---|
ZADD key score member | 寫入元素并指定排序值 |
ZREVRANGE key start stop | 按 score 倒序取 [start, stop] 區(qū)間 |
ZRANGEBYSCORE key min max | 按 score 范圍篩選 |
ZREM key member | 移除指定成員 |
ZCARD key | 返回集合元素總數(shù),用于算 total |
業(yè)務(wù)里常見的做法是把時間戳作為 score,于是"最新發(fā)布"自然就是 ZREVRANGE key 0 -1,分頁就是 ZREVRANGE key (page-1)*size page*size-1。這套語義和 SQL 里的 ORDER BY created_at DESC LIMIT ?, ? 幾乎一一對應(yīng)。
2.2 分頁代碼示例
用 Spring Data Redis 的 RedisTemplate 實現(xiàn)一個樸素分頁接口:
public List<Comment> pageComments(String bizId, int page, int size) {
String key = "comments:" + bizId;
long start = (long) (page - 1) * size;
long end = start + size - 1;
Set<String> jsonSet = redisTemplate.opsForZSet()
.reverseRange(key, start, end);
if (jsonSet == null || jsonSet.isEmpty()) {
return Collections.emptyList();
}
return jsonSet.stream()
.map(json -> JSON.parseObject(json, Comment.class))
.collect(Collectors.toList());
}
public void addComment(String bizId, Comment c) {
String key = "comments:" + bizId;
redisTemplate.opsForZSet()
.add(key, JSON.toJSONString(c), c.getCreateTime().getTime());
}2.3 ZSet 相比 List 的優(yōu)勢
很多人會說"用 List 也能分頁",做的方式無非是 LPUSH + LRANGE。但在生產(chǎn)環(huán)境里,List 一旦遇到下面任何一項,就開始難受:
- 亂序?qū)懭?/strong>:List 只能按寫入順序排,業(yè)務(wù)里卻經(jīng)常出現(xiàn)"補錄"、"修正時間戳"等需要重排序的情況
- 范圍篩選:想"取過去 24 小時內(nèi)的評論",List 拿不到這個能力,ZSet 一個
ZRANGEBYSCORE就夠 - 去重:ZSet 的 member 是唯一的,配合業(yè)務(wù)主鍵能避免重復(fù)插入;List 不去重,需要業(yè)務(wù)自己保證
唯一一種 List 更合適的場景是:允許重復(fù) member、且不需要任何排序變更,比如純粹的日志緩沖。除此之外,ZSet 幾乎都是更好的選擇。
2.4 深翻頁的隱藏代價
ZSet 的 ZREVRANGE 表面上是 O(log N + M),但在 N 上千萬、page 翻到幾千頁時,依然會拖慢響應(yīng)。生產(chǎn)里通常會做兩件事:
- 限制最大可翻頁深度,比如最多 100 頁,剩下的引導(dǎo)用戶用篩選條件而不是無腦翻頁
- 改成游標式分頁:把"上一頁最后一條的 score"傳回客戶端,下一次直接
ZREVRANGEBYSCORE key (lastScore -inf LIMIT 0 size,避免 offset 越深越慢
游標分頁的代碼示例:
public List<Comment> pageByCursor(String bizId, Long lastScore, int size) {
String key = "comments:" + bizId;
double max = (lastScore == null) ? Double.POSITIVE_INFINITY : lastScore - 1;
Set<String> set = redisTemplate.opsForZSet()
.reverseRangeByScore(key, Double.NEGATIVE_INFINITY, max, 0, size);
// ...
}三、多條件模糊查詢:基于 Hash 與 HSCAN
ZSet 解決了分頁,但解決不了"按條件篩選"。要在 Redis 內(nèi)部完成模糊匹配,目前業(yè)界最常見的做法是借助 Hash + HSCAN。
3.1 思路:把條件字段編碼進 field
核心點是設(shè)計 Hash 的 field 命名規(guī)則,把所有可能參與模糊匹配的字段拼進去。例如用戶數(shù)據(jù),約定 field 形如:
<id>:<姓名>:<性別>
寫入示例:
HSET user_index "1001:阿強:男" "{...用戶詳情JSON...}"
HSET user_index "1002:阿琳:女" "{...用戶詳情JSON...}"
HSET user_index "1003:張偉:男" "{...用戶詳情JSON...}"
查詢時利用 HSCAN 的 MATCH 模式:
# 所有女性 HSCAN user_index 0 MATCH *:*:女 COUNT 1000 # 姓阿的全部 HSCAN user_index 0 MATCH *:阿*:* COUNT 1000 # id 前綴 100 的男性 HSCAN user_index 0 MATCH 100*:*:男 COUNT 1000
HSCAN 是漸進式掃描,單次只返回一小部分,配合返回的 cursor 反復(fù)調(diào)用,直到 cursor 歸零代表遍歷結(jié)束。它比 KEYS 安全得多——不會阻塞 Redis 主線程。
3.2 為什么堅決不用KEYS
新人很容易寫出 KEYS *:阿*:* 這種代碼。KEYS 是 阻塞式 的,會掃描全庫,在生產(chǎn)環(huán)境上百萬 key 的實例里,一次調(diào)用足以讓整個 Redis 服務(wù)卡頓數(shù)秒,后果是所有讀寫請求一起阻塞,雪崩級的故障。所有線上代碼都應(yīng)當用 SCAN 系列指令(SCAN、HSCAN、SSCAN、ZSCAN)替代 KEYS 與 HGETALL 式全量遍歷。
3.3 模式匹配的局限
HSCAN MATCH 用的是 glob 風格通配符:*、?、[]。這意味著:
- 它能做 前綴 / 后綴 / 包含 匹配
- 它做不了 真正的全文檢索(分詞、相關(guān)性排序、拼寫糾錯)
- 它做不了 多字段交集,只能在 field 名里把字段拼起來用通配符近似實現(xiàn)
如果業(yè)務(wù)方真正需要的是"在十萬條描述里找語義相近的文本",那就別在原生 Redis 里硬剛,直接上 RediSearch、Elasticsearch 或者向量數(shù)據(jù)庫。本文要談的方案,針對的是中等規(guī)模(幾萬到幾百萬 key)、字段維度可枚舉的過濾性查詢。
3.4 漸進式掃描的代碼模板
public List<String> scanByPattern(String hashKey, String pattern) {
List<String> matched = new ArrayList<>();
ScanOptions options = ScanOptions.scanOptions()
.match(pattern)
.count(1000)
.build();
try (Cursor<Map.Entry<Object, Object>> cursor =
redisTemplate.opsForHash().scan(hashKey, options)) {
while (cursor.hasNext()) {
Map.Entry<Object, Object> entry = cursor.next();
matched.add(entry.getKey().toString());
}
}
return matched;
}要注意 COUNT 只是 提示,不是返回上限。Redis 可能返回多于或少于這個值的元素,業(yè)務(wù)側(cè)要做好"邊掃邊過濾"的心理預(yù)期。同時,掃描期間 Hash 內(nèi)容可能發(fā)生變化,HSCAN 提供的是"弱一致"語義——不會漏掉一直存在的 field,但中途新增 / 刪除的 field 可能返回也可能不返回,這點和 MySQL 的快照讀完全不是一回事。
四、組合方案:把分頁和模糊查詢拼在一起
單獨看,ZSet 解決分頁、Hash + HSCAN 解決條件過濾。問題來了——HSCAN 的結(jié)果是 無序的,單次也只是部分結(jié)果,直接拿它做"取第 3 頁 20 條"完全行不通。怎么辦?
4.1 總體思路
業(yè)內(nèi)比較成熟的做法可以歸納為四步:
- 數(shù)據(jù)寫入:所有原始數(shù)據(jù)按"條件編碼 field"寫到一張大 Hash 里
- 條件轉(zhuǎn)匹配串:把用戶傳入的多條件請求,轉(zhuǎn)成一個統(tǒng)一格式的匹配串,例如
*:阿*:女 - 結(jié)果集索引:以匹配串本身作為 ZSet 的 key,第一次查詢時用 HSCAN 把所有命中 field 寫進這個 ZSet,并給 ZSet 設(shè)過期
- 分頁讀取:后續(xù)相同條件的請求直接走 ZSet 分頁,不再掃描 Hash
整體流程畫出來就是這樣:

4.2 數(shù)據(jù)結(jié)構(gòu)設(shè)計
以一個用戶檢索場景為例:
# 原始數(shù)據(jù),HASH 類型
KEY user_index
FIELD <id>:<姓名>:<性別>:<城市>
VALUE {"id":1001,"name":"阿強","gender":"男","city":"上海", ...}
# 結(jié)果集索引,ZSET 類型
KEY user_index:query:*:阿*:*:上海
MEMBER <id>:<姓名>:<性別>:<城市>
SCORE 排序字段(注冊時間、id 等)
ZSet 的 key 由業(yè)務(wù)前綴 + 匹配串拼成,方便統(tǒng)一管理;member 直接復(fù)用 Hash 的 field,回查時 HGET user_index <field> 即可。
4.3 關(guān)鍵代碼
public PageResult<User> queryUsers(UserQuery q, int page, int size) {
String pattern = buildPattern(q); // 例如 "*:阿*:*:上海"
String zsetKey = "user_index:query:" + pattern;
Boolean exists = redisTemplate.hasKey(zsetKey);
if (Boolean.FALSE.equals(exists)) {
rebuildIndex(zsetKey, pattern); // 第一次查詢,構(gòu)建索引
} else {
redisTemplate.expire(zsetKey, Duration.ofMinutes(10)); // 命中則續(xù)期
}
long start = (long) (page - 1) * size;
long end = start + size - 1;
Set<String> fields = redisTemplate.opsForZSet()
.reverseRange(zsetKey, start, end);
if (fields == null || fields.isEmpty()) {
return PageResult.empty();
}
List<Object> values = redisTemplate.opsForHash()
.multiGet("user_index", new ArrayList<>(fields));
List<User> users = values.stream()
.filter(Objects::nonNull)
.map(v -> JSON.parseObject(v.toString(), User.class))
.collect(Collectors.toList());
Long total = redisTemplate.opsForZSet().zCard(zsetKey);
return new PageResult<>(users, total);
}
private void rebuildIndex(String zsetKey, String pattern) {
ScanOptions options = ScanOptions.scanOptions()
.match(pattern).count(1000).build();
try (Cursor<Map.Entry<Object, Object>> cursor =
redisTemplate.opsForHash().scan("user_index", options)) {
while (cursor.hasNext()) {
Map.Entry<Object, Object> entry = cursor.next();
String field = entry.getKey().toString();
double score = extractScore(field); // 解析 field 拿排序值
redisTemplate.opsForZSet().add(zsetKey, field, score);
}
}
redisTemplate.expire(zsetKey, Duration.ofMinutes(10));
}4.4 這套方案帶來的收益
- 第一次查詢:付出一次 HSCAN 全量掃描的代價(O(N)),其余完全走 ZSet,復(fù)雜度 O(log N + M)
- 后續(xù)查詢:所有翻頁都是 ZSet 命中,HSCAN 不再觸發(fā)
- 統(tǒng)計 total:
ZCARD一次拿到符合條件總數(shù),前端可以直接做分頁器 - 支持排序:把任意可數(shù)值化的字段作為 score 即可,比如時間戳、熱度、價格
五、生產(chǎn)環(huán)境必須考慮的工程問題
簡單跑通 demo 是一回事,把這套機制扛在線上是另一回事。下面這些點必須在落地前想清楚。
5.1 緩存膨脹:匹配串爆炸
每一個獨特的查詢條件都會產(chǎn)生一個 ZSet。前端篩選項一多,組合數(shù)能輕松上萬:
*:阿*:*:上海 *:阿*:*:北京 *:阿*:女:上海 *:阿*:男:上海 ... 共 N×M×K 組
如果對每一種都建一個 ZSet 永久保留,Redis 內(nèi)存很快會被吃光。常見的幾條治理思路:
- 必給 TTL:每個查詢索引 ZSet 都設(shè)置過期時間(比如 5~30 分鐘),冷查詢自動消失
- 命中續(xù)期:用戶翻頁時刷新 TTL,熱查詢自動保活
- 限制匹配維度:約定允許的查詢字段集合,把"完全自由組合"收斂成"有限可枚舉"
- 匹配串歸一化:相同語義的查詢要生成同一個 key,例如統(tǒng)一字段順序、統(tǒng)一大小寫、統(tǒng)一空缺位用
*而不是空字符串
5.2 數(shù)據(jù)一致性:寫入與索引怎么同步
ZSet 索引是基于"快照時刻的 Hash 內(nèi)容"生成的。新數(shù)據(jù)寫入 Hash 后,老的 ZSet 是不知道的——它依然會返回舊的分頁結(jié)果。兩條主流路線:
方案 A:雙寫
寫入 Hash 的同時,遍歷當前活躍的查詢 ZSet,把新數(shù)據(jù)按 glob 規(guī)則補進去。
public void addUser(User u) {
String field = buildField(u);
redisTemplate.opsForHash()
.put("user_index", field, JSON.toJSONString(u));
// 找到所有可能匹配該用戶的活躍索引,補一條
ScanOptions opts = ScanOptions.scanOptions()
.match("user_index:query:*").count(500).build();
try (Cursor<byte[]> cursor =
redisTemplate.executeWithStickyConnection(
c -> c.scan(opts))) {
while (cursor.hasNext()) {
String key = new String(cursor.next());
String pattern = key.substring("user_index:query:".length());
if (matchGlob(field, pattern)) {
redisTemplate.opsForZSet().add(key, field, u.getCreateTime());
}
}
}
}這個方案保證了實時性,但寫入開銷隨活躍索引數(shù)線性增長,而且要自己實現(xiàn) glob 匹配,代碼量不小。
方案 B:定時重建 / 惰性失效
不去維護增量,索引到期自動清理;下次查詢命中時再用 HSCAN 重建一次。實現(xiàn)簡單,開銷小,代價是用戶可能在 TTL 內(nèi)看不到新數(shù)據(jù)。
實戰(zhàn)經(jīng)驗上,評論、商品篩選這一類不要求強實時的列表,惰性失效就夠;聊天列表、實時排行榜這類核心寫入面,需要雙寫。兩種方案也可以結(jié)合:高優(yōu)先級字段做雙寫,長尾查詢做惰性。
5.3 大 Key 風險
把全量數(shù)據(jù)塞進一張 Hash,單 key 體積可能上 GB。一旦 Redis 觸發(fā) RDB 持久化或者主從同步,單個大 key 的序列化會顯著卡 IO,嚴重時還會讓從庫追不上主庫,觸發(fā)全量重同步。規(guī)避手段:
- 分桶:按 id 哈希到 N 張 Hash,例如
user_index:0~user_index:15,HSCAN 時并發(fā)掃各桶 - 限制 field 數(shù)量:每張 Hash 不超過百萬級 field
- 不要盲目調(diào)大 hash-max-ziplist-entries:默認上限是有原因的,超過后內(nèi)存與 CPU 都會跳變到更糟的狀態(tài)
5.4 掃描期間的雪崩
高并發(fā)場景下,如果某個熱門查詢的 ZSet 同時過期,可能瞬間有幾十個請求一起觸發(fā) HSCAN,把 Redis CPU 打滿。
應(yīng)對手段:
- 互斥重建:用
SET NX+ 短 TTL 搶鎖,只允許一個線程觸發(fā)重建,其他線程短暫等待或降級返回上一次結(jié)果 - 邏輯過期:ZSet 本體不設(shè) TTL,而是把過期時間存進 ZSet 自身的一個 member 或附帶的 String key,到期由后臺線程異步刷新
- 預(yù)熱:對已知熱門組合(如默認排序、默認篩選)在系統(tǒng)啟動或后臺任務(wù)里主動構(gòu)建索引
互斥重建的最小實現(xiàn):
private void rebuildWithLock(String zsetKey, String pattern) {
String lockKey = zsetKey + ":lock";
Boolean got = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", Duration.ofSeconds(30));
if (Boolean.TRUE.equals(got)) {
try {
rebuildIndex(zsetKey, pattern);
} finally {
redisTemplate.delete(lockKey);
}
} else {
// 沒搶到鎖:短暫等待讓其他線程把索引建好,再走讀路徑
sleepBackoff();
}
}5.5 排序與穩(wěn)定性
ZSet 按 score 排序,score 相同時按 member 字典序排。如果業(yè)務(wù)的 score 是毫秒時間戳,同一毫秒插入兩條記錄會出現(xiàn)順序不穩(wěn)定??梢?
- 復(fù)合 score:
score = timestamp * 1000 + sequence,把次序揉進 score - 避免相等:分布式 ID 自帶單調(diào)性,本身就能當 score
六、什么時候不該用這套方案
工程上沒有銀彈。下面幾種情況,直接放棄 Hash + HSCAN,換更專業(yè)的工具:
- 真·全文檢索:要分詞、要相關(guān)性打分、要拼寫糾錯——上 RediSearch / Elasticsearch
- 高維聚合:要
GROUP BY出銷量榜、要做時間窗口聚合——上 ClickHouse / Druid - 強一致 + 復(fù)雜事務(wù):還是回 MySQL / PostgreSQL,加合理的索引
- 超大數(shù)據(jù)量(億級及以上):單機 Redis 扛不動這種規(guī)模的 HSCAN 全表掃描,要么分片、要么轉(zhuǎn)專業(yè)搜索引擎
判斷標準其實只有一個:業(yè)務(wù)真正需要的查詢語義,Redis 用通配符 + 集合能不能近似表達。能,就用這套方案;不能,就別勉強。
七、與 RediSearch 的對照
Redis 官方近幾年大力推 RediSearch(現(xiàn)在叫 Redis Query Engine),它能在 Redis 上原生支持二級索引、全文檢索、向量檢索、范圍聚合。功能上比手撕 Hash + HSCAN 強一個數(shù)量級:
| 維度 | 手撕方案(Hash + ZSet) | RediSearch |
|---|---|---|
| 部署門檻 | 原生 Redis 即可 | 需要加載模塊 |
| 多字段過濾 | 通配符近似 | 原生 AND / OR / NOT |
| 全文檢索 | 不支持 | 支持,含分詞、模糊、相關(guān)性 |
| 排序與分頁 | 自行維護 ZSet | 內(nèi)置 SORTBY、LIMIT |
| 內(nèi)存開銷 | 索引 ZSet 可控但易膨脹 | 二級索引常駐,開銷可觀 |
| 改造成本 | 應(yīng)用層全量自研 | 客戶端切到 FT.* 指令 |
如果團隊能控制 Redis 部署形態(tài)(自建,或云廠商提供模塊支持),直接用 RediSearch 會更穩(wěn)。但仍有大量場景受限于"只能用原生 Redis"——某些云上的標準版實例、共享托管、合規(guī)限制等等——這時本文的方案就有了用武之地。
八、把方案串成一張工程圖
最后用一張相對完整的架構(gòu)圖收尾,便于落地時對照:


按這張圖把讀寫兩條鏈路都實現(xiàn)一遍,再補上 TTL、互斥鎖、監(jiān)控埋點,就能在中等規(guī)模業(yè)務(wù)里穩(wěn)定跑起來。
九、結(jié)語
回到最初的問題——Redis 單純靠內(nèi)置指令做不到"分頁 + 多條件模糊查詢"。但當把 Hash 當作主存儲、HSCAN 當作過濾器、ZSet 當作結(jié)果集緩存 這三件事拼起來,再疊上 TTL、雙寫或惰性失效、互斥重建等若干工程手段,就能在原生 Redis 上湊出一套足夠?qū)嵱玫姆桨浮?/p>
它不是最優(yōu)雅的,也不是性能上限——RediSearch、Elasticsearch、向量數(shù)據(jù)庫都會比它強。它的價值在于:不依賴任何額外模塊,只用 Redis 原生能力,就能服務(wù)相當一部分中等規(guī)模的業(yè)務(wù)查詢場景。在受限環(huán)境下,這種"用基礎(chǔ)原語拼接出復(fù)雜語義"的能力,恰恰是后端工程師區(qū)別于 API 調(diào)用員的關(guān)鍵。
理解了思路之后,落地時只需要回答三個問題:
- 我的條件字段能否枚舉?能枚舉,就能編碼進 field。
- 我的數(shù)據(jù)規(guī)模能否承受全量 HSCAN?能承受,方案就成立。
- 我的業(yè)務(wù)能否容忍 TTL 級別的延遲?能容忍,惰性失效就夠用;不能容忍,就上雙寫。
這三個問題問清楚,剩下的就只是寫代碼。
以上就是Redis實現(xiàn)分頁+多條件模糊查詢的組合方案的詳細內(nèi)容,更多關(guān)于Redis分頁與多條件模糊查詢的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
Mac中Redis服務(wù)啟動時錯誤信息:NOAUTH Authentication required
這篇文章主要介紹了Mac中使用Redis服務(wù)啟動時錯誤信息:"NOAUTH Authentication required"問題,具有很好的參考價值,希望對大家有所幫助,如有錯誤或未考慮完全的地方,望不吝賜教2023-08-08
關(guān)于redis可視化工具讀取數(shù)據(jù)亂碼問題
大家來聊一聊在日常操作redis時用的是什么工具,redis提供的一些命令你都了解了嗎,今天通過本文給大家介紹redis可視化工具讀取數(shù)據(jù)亂碼問題,感興趣的朋友跟隨小編一起看看吧2021-07-07
Redis使用LocalStorage的實現(xiàn)示例
本文介紹了如何在 NestJS 項目中參考 Redis 緩存接口封裝 LocalStorage,提供了 CacheUtil 類的詳細實現(xiàn)及使用示例,方便前端同學向全棧發(fā)展2026-04-04

