一聚教程网:一个值得你收藏的教程网站

最新下载

热门教程

Redis实现分页与多条件模糊查询的组合方案

时间:2026-06-04 08:42:00 编辑:袖梨 来源:一聚教程网

  在常规业务中,分页与模糊查询十分普遍,通过数据库一条SQL即可轻松完成,但数据一旦落到Redis缓存层,由于缺乏类似SQL的查询语义,直接实现变得困难,需要探索替代方案。

  Redis是一种key-value内存数据库,提供GET/SET、List、Set、Hash、ZSet等指令,但不支持类似WHERE name LIKE '阿%' AND gender = '女' ORDER BY created_at DESC LIMIT 40, 20的复杂查询。要在Redis中同时实现分页与多条件模糊匹配,必须通过数据结构自行组合。

  通常会踩到这件事的业务场景有几类:

  1. 评论、动态、消息流这类时间线数据,热点都在缓存里
  2. 高并发写入的中间结果,先在Redis缓冲、稍后再持久化到数据库
  3. 实时统计类的看板,MySQL扛不住的查询被前移到缓存
  4. 某些数据本身就不落库,例如临时排行榜、活动期间的会话数据

  这些场景的共同特征是数据要么暂时不在数据库里,要么放在数据库里也来不及查。下面要解决的,就是在这种约束下,怎么把分页和多条件模糊查询拼起来。

二、分页:为什么首选 ZSet

  Redis里能支撑"取第几页、每页几条"的结构有两个:List和ZSet。看似都能做,但ZSet几乎在所有场景都更合适。

2.1 ZSet 的核心指令

  ZSet全称Sorted Set,即有序集合,每个元素同时绑定一个score,集合按score自动排序。和分页相关的指令主要有这几个:

指令作用
ZADD key score member写入元素并指定排序值
ZREVRANGE key start stop按score倒序取[start, stop]区间
ZRANGEBYSCORE key min max按score范围筛选
ZREM key member移除指定成员
ZCARD key返回集合元素总数,用于算total

  业务里常见的做法是把时间戳作为score,于是"最新发布"自然就是ZREVRANGE key 0 -1,分页就是ZREVRANGE key (page-1)*size page*size-1。这套语义和SQL里的ORDER BY created_at DESC LIMIT ?, ?几乎一一对应。

2.2 分页代码示例

  用Spring Data Redis的RedisTemplate实现一个朴素分页接口:

public List pageComments(String bizId, int page, int size) {    String key = "comments:" + bizId;    long start = (long) (page - 1) * size;    long end = start + size - 1;    Set 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 的优势

  很多人会说"用List也能分页",做的方式无非是LPUSH + LRANGE。但在生产环境里,List一旦遇到下面任何一项,就开始难受:

  1. 乱序写入:List只能按写入顺序排,业务里却经常出现"补录"、"修正时间戳"等需要重排序的情况
  2. 范围筛选:想"取过去24小时内的评论",List拿不到这个能力,ZSet一个ZRANGEBYSCORE就够
  3. 去重:ZSet的member是唯一的,配合业务主键能避免重复插入;List不去重,需要业务自己保证

  唯一一种List更合适的场景是:允许重复member、且不需要任何排序变更,比如纯粹的日志缓冲。除此之外,ZSet几乎都是更好的选择。

2.4 深翻页的隐藏代价

  ZSet的ZREVRANGE表面上是O(log N + M),但在N上千万、page翻到几千页时,依然会拖慢响应。生产里通常会做两件事:

  1. 限制最大可翻页深度,比如最多100页,剩下的引导用户用筛选条件而不是无脑翻页
  2. 改成游标式分页:把"上一页最后一条的score"传回客户端,下一次直接ZREVRANGEBYSCORE key (lastScore -inf LIMIT 0 size,避免offset越深越慢

  游标分页的代码示例:

public List pageByCursor(String bizId, Long lastScore, int size) {    String key = "comments:" + bizId;    double max = (lastScore == null) ? Double.POSITIVE_INFINITY : lastScore - 1;    Set set = redisTemplate.opsForZSet()            .reverseRangeByScore(key, Double.NEGATIVE_INFINITY, max, 0, size);    // ...}

三、多条件模糊查询:基于 Hash 与 HSCAN

  ZSet解决了分页,但解决不了"按条件筛选"。要在Redis内部完成模糊匹配,目前业界最常见的做法是借助Hash + HSCAN

3.1 思路:把条件字段编码进 field

  核心点是设计Hash的field命名规则,把所有可能参与模糊匹配的字段拼进去。例如用户数据,约定field形如:

:<姓名>:<性别>

  写入示例:

HSET user_index "1001:阿强:男" "{...用户详情JSON...}"HSET user_index "1002:阿琳:女" "{...用户详情JSON...}"HSET user_index "1003:张伟:男" "{...用户详情JSON...}"

  查询时利用HSCANMATCH模式:

# 所有女性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反复调用,直到cursor归零代表遍历结束。它比KEYS安全得多——不会阻塞Redis主线程。

3.2 为什么坚决不用KEYS

  新人很容易写出KEYS *:阿*:*这种代码。KEYS阻塞式的,会扫描全库,在生产环境上百万key的实例里,一次调用足以让整个Redis服务卡顿数秒,后果是所有读写请求一起阻塞,雪崩级的故障。所有线上代码都应当用SCAN系列指令(SCAN、HSCAN、SSCAN、ZSCAN)替代KEYSHGETALL式全量遍历。

3.3 模式匹配的局限

  HSCAN MATCH用的是glob风格通配符:*?[]。这意味着:

  1. 它能做前缀/后缀/包含匹配
  2. 它做不了真正的全文检索(分词、相关性排序、拼写纠错)
  3. 它做不了多字段交集,只能在field名里把字段拼起来用通配符近似实现

  如果业务方真正需要的是"在十万条描述里找语义相近的文本",那就别在原生Redis里硬刚,直接上RediSearch、Elasticsearch或者向量数据库。本文要谈的方案,针对的是中等规模(几万到几百万key)、字段维度可枚举的过滤性查询。

3.4 渐进式扫描的代码模板

public List scanByPattern(String hashKey, String pattern) {    List matched = new ArrayList<>();    ScanOptions options = ScanOptions.scanOptions()            .match(pattern)            .count(1000)            .build();    try (Cursor> cursor =                 redisTemplate.opsForHash().scan(hashKey, options)) {        while (cursor.hasNext()) {            Map.Entry entry = cursor.next();            matched.add(entry.getKey().toString());        }    }    return matched;}

  要注意COUNT只是提示,不是返回上限。Redis可能返回多于或少于这个值的元素,业务侧要做好"边扫边过滤"的心理预期。同时,扫描期间Hash内容可能发生变化,HSCAN提供的是"弱一致"语义——不会漏掉一直存在的field,但中途新增/删除的field可能返回也可能不返回,这点和MySQL的快照读完全不是一回事。

四、组合方案:把分页和模糊查询拼在一起

  单独看,ZSet解决分页、Hash + HSCAN解决条件过滤。问题来了——HSCAN的结果是无序的,单次也只是部分结果,直接拿它做"取第3页20条"完全行不通。怎么办?

4.1 总体思路

  业内比较成熟的做法可以归纳为四步:

  1. 数据写入:所有原始数据按"条件编码field"写到一张大Hash里
  2. 条件转匹配串:把用户传入的多条件请求,转成一个统一格式的匹配串,例如*:阿*:女
  3. 结果集索引:以匹配串本身作为ZSet的key,第一次查询时用HSCAN把所有命中field写进这个ZSet,并给ZSet设过期
  4. 分页读取:后续相同条件的请求直接走ZSet分页,不再扫描Hash

  整体流程画出来就是这样:

Redis实现分页+多条件模糊查询的组合方案

4.2 数据结构设计

  以一个用户检索场景为例:

# 原始数据,HASH 类型KEY    user_indexFIELD  :<姓名>:<性别>:<城市>VALUE  {"id":1001,"name":"阿强","gender":"男","city":"上海", ...}# 结果集索引,ZSET 类型KEY    user_index:query:*:阿*:*:上海MEMBER :<姓名>:<性别>:<城市>SCORE  排序字段(注册时间、id 等)

  ZSet的key由业务前缀+匹配串拼成,方便统一管理;member直接复用Hash的field,回查时HGET user_index 即可。

4.3 关键代码

public PageResult 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);                // 第一次查询,构建索引    } else {        redisTemplate.expire(zsetKey, Duration.ofMinutes(10));   // 命中则续期    }    long start = (long) (page - 1) * size;    long end = start + size - 1;    Set fields = redisTemplate.opsForZSet()            .reverseRange(zsetKey, start, end);    if (fields == null || fields.isEmpty()) {        return PageResult.empty();    }    List values = redisTemplate.opsForHash()            .multiGet("user_index", new ArrayList<>(fields));    List 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> cursor =                 redisTemplate.opsForHash().scan("user_index", options)) {        while (cursor.hasNext()) {            Map.Entry 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 这套方案带来的收益

  1. 第一次查询:付出一次HSCAN全量扫描的代价(O(N)),其余完全走ZSet,复杂度O(log N + M)
  2. 后续查询:所有翻页都是ZSet命中,HSCAN不再触发
  3. 统计total:ZCARD一次拿到符合条件总数,前端可以直接做分页器
  4. 支持排序:把任意可数值化的字段作为score即可,比如时间戳、热度、价格

五、生产环境必须考虑的工程问题

  简单跑通demo是一回事,把这套机制扛在线上是另一回事。下面这些点必须在落地前想清楚。

5.1 缓存膨胀:匹配串爆炸

  每一个独特的查询条件都会产生一个ZSet。前端筛选项一多,组合数能轻松上万:

*:阿*:*:上海*:阿*:*:北京*:阿*:女:上海*:阿*:男:上海... 共 N×M×K 组

  如果对每一种都建一个ZSet永久保留,Redis内存很快会被吃光。常见的几条治理思路:

  1. 必给TTL:每个查询索引ZSet都设置过期时间(比如5~30分钟),冷查询自动消失
  2. 命中续期:用户翻页时刷新TTL,热查询自动保活
  3. 限制匹配维度:约定允许的查询字段集合,把"完全自由组合"收敛成"有限可枚举"
  4. 匹配串归一化:相同语义的查询要生成同一个key,例如统一字段顺序、统一大小写、统一空缺位用*而不是空字符串

5.2 数据一致性:写入与索引怎么同步

  ZSet索引是基于"快照时刻的Hash内容"生成的。新数据写入Hash后,老的ZSet是不知道的——它依然会返回旧的分页结果。两条主流路线:

方案 A:双写

  写入Hash的同时,遍历当前活跃的查询ZSet,把新数据按glob规则补进去。

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 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());            }        }    }}

  这个方案保证了实时性,但写入开销随活跃索引数线性增长,而且要自己实现glob匹配,代码量不小。

方案 B:定时重建/惰性失效

  不去维护增量,索引到期自动清理;下次查询命中时再用HSCAN重建一次。实现简单,开销小,代价是用户可能在TTL内看不到新数据。

  实战经验上,评论、商品筛选这一类不要求强实时的列表,惰性失效就够;聊天列表、实时排行榜这类核心写入面,需要双写。两种方案也可以结合:高优先级字段做双写,长尾查询做惰性。

5.3 大 Key 风险

  把全量数据塞进一张Hash,单key体积可能上GB。一旦Redis触发RDB持久化或者主从同步,单个大key的序列化会显著卡IO,严重时还会让从库追不上主库,触发全量重同步。规避手段:

  1. 分桶:按id哈希到N张Hash,例如user_index:0 ~ user_index:15,HSCAN时并发扫各桶
  2. 限制field数量:每张Hash不超过百万级field
  3. 不要盲目调大hash-max-ziplist-entries:默认上限是有原因的,超过后内存与CPU都会跳变到更糟的状态

5.4 扫描期间的雪崩

  高并发场景下,如果某个热门查询的ZSet同时过期,可能瞬间有几十个请求一起触发HSCAN,把Redis CPU打满。

  应对手段:

  1. 互斥重建:用SET NX + 短TTL抢锁,只允许一个线程触发重建,其他线程短暂等待或降级返回上一次结果
  2. 逻辑过期:ZSet本体不设TTL,而是把过期时间存进ZSet自身的一个member或附带的String key,到期由后台线程异步刷新
  3. 预热:对已知热门组合(如默认排序、默认筛选)在系统启动或后台任务里主动构建索引

  互斥重建的最小实现:

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 排序与稳定性

  ZSet按score排序,score相同时按member字典序排。如果业务的score是毫秒时间戳,同一毫秒插入两条记录会出现顺序不稳定。可以:

  1. 复合score:score = timestamp * 1000 + sequence,把次序揉进score
  2. 避免相等:分布式ID自带单调性,本身就能当score

六、什么时候不该用这套方案

  工程上没有银弹。下面几种情况,直接放弃Hash + HSCAN,换更专业的工具:

  1. 真·全文检索:要分词、要相关性打分、要拼写纠错——上RediSearch / Elasticsearch
  2. 高维聚合:要GROUP BY出销量榜、要做时间窗口聚合——上ClickHouse / Druid
  3. 强一致+复杂事务:还是回MySQL / PostgreSQL,加合理的索引
  4. 超大数据量(亿级及以上):单机Redis扛不动这种规模的HSCAN全表扫描,要么分片、要么转专业搜索引擎

  判断标准其实只有一个:业务真正需要的查询语义,Redis用通配符+集合能不能近似表达。能,就用这套方案;不能,就别勉强。

七、与 RediSearch 的对照

  Redis官方近几年大力推RediSearch(现在叫Redis Query Engine),它能在Redis上原生支持二级索引、全文检索、向量检索、范围聚合。功能上比手撕Hash + HSCAN强一个数量级:

维度手撕方案(Hash + ZSet)RediSearch
部署门槛原生Redis即可需要加载模块
多字段过滤通配符近似原生AND / OR / NOT
全文检索不支持支持,含分词、模糊、相关性
排序与分页自行维护ZSet内置SORTBY、LIMIT
内存开销索引ZSet可控但易膨胀二级索引常驻,开销可观
改造成本应用层全量自研客户端切到FT.*指令

  如果团队能控制Redis部署形态(自建,或云厂商提供模块支持),直接用RediSearch会更稳。但仍有大量场景受限于"只能用原生Redis"——某些云上的标准版实例、共享托管、合规限制等等——这时本文的方案就有了用武之地。

八、把方案串成一张工程图

  最后用一张相对完整的架构图收尾,便于落地时对照:

Redis实现分页+多条件模糊查询的组合方案

Redis实现分页+多条件模糊查询的组合方案

  按这张图把读写两条链路都实现一遍,再补上TTL、互斥锁、监控埋点,就能在中等规模业务里稳定跑起来。

九、结语

  回到最初的问题——Redis单纯靠内置指令做不到"分页+多条件模糊查询"。但当把Hash当作主存储、HSCAN当作过滤器、ZSet当作结果集缓存这三件事拼起来,再叠上TTL、双写或惰性失效、互斥重建等若干工程手段,就能在原生Redis上凑出一套足够实用的方案。

  它不是最优雅的,也不是性能上限——RediSearch、Elasticsearch、向量数据库都会比它强。它的价值在于:不依赖任何额外模块,只用Redis原生能力,就能服务相当一部分中等规模的业务查询场景。在受限环境下,这种"用基础原语拼接出复杂语义"的能力,恰恰是后端工程师区别于API调用员的关键。

  理解了思路之后,落地时只需要回答三个问题:

  1. 我的条件字段能否枚举?能枚举,就能编码进field。
  2. 我的数据规模能否承受全量HSCAN?能承受,方案就成立。
  3. 我的业务能否容忍TTL级别的延迟?能容忍,惰性失效就够用;不能容忍,就上双写。

  这三个问题问清楚,剩下的就只是写代码。

  以上方案展示了如何利用Redis的Hash、HSCAN与ZSet原语,在受限环境中实现分页与多条件模糊查询的组合,为中等规模业务提供了实用且无需依赖额外模块的解决思路。