Redis 通常被当作缓存或简单的键值存储使用,但真实业务中经常需要根据非主键字段筛选数据。例如用户表里需要查询“城市等于北京且年龄大于18的用户”,如果只用原始键值模型,要么为每种查询提前把结果缓存好,要么用 SCAN 遍历所有键再逐个判断,后者在数据量大时性能不可接受。模拟全局二级索引的核心思想,就是把关系型数据库的索引机制搬到 Redis 上:主数据保存在哈希中,每个需要检索的字段分别建立索引结构,通过索引先定位到一批候选键,再回查主数据。

用哈希保存主数据,用集合维护等值索引
先来看主数据的组织方式。在 Redis 中,一个实体通常对应一个哈希键,例如用户 ID 为 1 的对象存储在 user:1 中,字段包括 name、age、city 等。哈希结构可以单独读写某个字段,节省网络开销,也方便后续维护索引时获取旧值。
为某个字段建立等值索引,就是创建一组集合。以城市字段为例,可以维护 idx:user:city:Beijing、idx:user:city:Shanghai 这样的集合,里面存放所有该城市用户的实体键。写入用户时,先 HSET user:1 city Beijing,再 SADD idx:user:city:Beijing user:1。删除或修改城市时,需要先 SREM 旧索引,再 SADD 新索引。这样,查询所有北京用户只需要 SMEMBERS idx:user:city:Beijing 或与其他条件集合做交集。
集合非常适合等值查询,因为它能快速判断某个键是否存在,也支持集合运算。但它不保存字段值的顺序,也不支持按数值范围直接过滤。因此对于年龄这类需要比较大小的字段,集合就不够用了,需要引入有序集合。
# 写入主数据 HSET user:1 name "Alice" age 25 city "Beijing" # 建立城市索引 SADD idx:user:city:Beijing user:1 # 查询北京用户 SMEMBERS idx:user:city:Beijing
集合运算与有序集合实现组合查询
真实查询往往包含多个条件。Redis 提供了 SINTER、SUNION、SDIFF 等集合运算命令,可以很方便地实现 AND、OR、NOT 逻辑。比如查询“城市为北京且状态为活跃”,可以先分别获取 idx:user:city:Beijing 和 idx:user:status:active,再执行 SINTER 得到同时满足两个条件的实体键集合。多个条件叠加时,可以继续对结果做交集。
对于年龄这种范围条件,可以用有序集合来模拟索引。创建 idx:user:age,成员是实体键,分数是对应的年龄值。写入时 ZADD idx:user:age 25 user:1。查询年龄大于 18 的用户时,通过 ZRANGEBYSCORE idx:user:age (18 +inf 获取候选键列表。如果想同时满足城市等值条件,可以先用有序集合查出年龄满足的用户集合,再与城市索引集合做 SINTER。这里要注意集合运算要求操作数都是集合,而有序集合不能直接与普通集合做 SINTER,需要先把有序集合的结果转成普通集合,或者使用 ZRANGE 获取成员后再与普通集合交互。一种做法是在客户端拿到年龄候选键列表,再与城市索引做交集;也可以使用临时集合存储有序集合结果。
另外,Redis 的 ZINTERSTORE 和 ZUNIONSTORE 可以在服务端对有序集合做交集/并集并保留分数,适合需要排序的场景。例如将年龄有序集合和注册时间有序集合合并并保留权重,但需要提前把每个成员在两个有序集合中都存在,否则会丢失数据,因此使用时要注意。
# 建立年龄有序集合索引 ZADD idx:user:age 25 user:1 ZADD idx:user:age 19 user:2 # 查询年龄大于18的用户 ZRANGEBYSCORE idx:user:age (18 +inf # 查询年龄大于18且城市为北京(先取年龄候选,再与城市索引交集) # 可在客户端获取 ZRANGEBYSCORE 结果后,再与城市集合做 SINTER SINTER idx:user:city:Beijing tmp_age_candidates
使用 Lua 脚本保证索引更新原子性
写入数据时,更新主数据和多个索引必须保持原子性。如果先更新主数据,再更新索引,中途发生错误或 Redis 崩溃,就会出现主数据与索引不一致。例如城市从北京改成上海,如果只执行了 HSET 和 SADD idx:user:city:Shanghai,却漏掉了 SREM idx:user:city:Beijing,用户会同时出现在两个城市索引中,查询结果就会出错。
Redis 事务可以保证命令顺序执行,但无法根据旧值动态决定需要删除哪个旧索引,因为 MULTI/EXEC 不支持在事务中间读取值后再决定后续命令。Lua 脚本则解决了这个问题,脚本整个执行过程是原子的,并且可以在脚本内部通过 redis.call 读取旧字段值,再执行删除旧索引、写入新索引的操作。下面这段脚本演示了修改城市字段时的完整流程:先读取旧城市,如果存在则从旧索引中移除实体键,然后写入新城市,并加入新索引。这样即使脚本执行失败,也不会产生部分更新。
-- KEYS[1] 是实体键,例如 user:1
-- ARGV[1] 是新城市值
local old_city = redis.call('HGET', KEYS[1], 'city')
if old_city then
redis.call('SREM', 'idx:user:city:' .. old_city, KEYS[1])
end
redis.call('HSET', KEYS[1], 'city', ARGV[1])
redis.call('SADD', 'idx:user:city:' .. ARGV[1], KEYS[1])
return 1
如果涉及多个字段的索引,也可以把多个字段的更新写在一个 Lua 脚本里。例如同时修改年龄和城市,需要分别读取旧值、删除旧索引、写入新值、添加新索引。这样的脚本会稍长,但能够保证所有索引与主数据同步。对于删除操作,同样应该在脚本中从所有相关索引里移除该实体键。
全局索引的演化与 RediSearch 的取舍
当业务查询模式增多,自建索引的维护成本会快速上升。每个需要过滤或排序的字段都要额外维护一套集合或有序集合,写入放大明显;组合查询需要精心设计索引结构,否则客户端逻辑会变得复杂。此外,字符串字段的模糊匹配、多词全文搜索等需求,用原生集合几乎无法实现。
Redis 官方模块 RediSearch 提供了完整的二级索引与全文检索能力。通过 FT.CREATE 可以声明哈希前缀、字段类型(TEXT、NUMERIC、TAG 等),之后使用 FT.SEARCH 执行类似 SQL 的查询,例如 FT.SEARCH idx:user "@city:{Beijing} @age:[18 +inf]"。RediSearch 底层维护了倒排索引和数值范围树,查询效率远高于自己用集合模拟,而且支持排序、分页、聚合等高级功能。
# RediSearch 创建索引
FT.CREATE idx:user ON HASH PREFIX 1 user: SCHEMA name TEXT age NUMERIC city TAG
# RediSearch 组合查询
FT.SEARCH idx:user "@city:{Beijing} @age:[18 +inf]"
是否引入 RediSearch,取决于业务规模和对模块部署的接受度。自建索引轻量、无额外依赖,适合字段少、查询模式固定的场景;当索引字段超过三四个、需要全文搜索或复杂排序时,使用 RediSearch 可以大幅降低代码复杂度。理解全局二级索引模拟的原理,有助于更合理地评估两种方案。