RedisJSON 文档模型:JSONPath、路径更新与二级索引实战

RedisJSON 文档模型实战:JSON.SET/JSON.GET/JSON.DEL 基础操作、JSONPath 语法($ 根、.. 递归、[*] 通配、[start:end] 切片、?() 过滤、@ 当前节点)、JSON.TYPE/JSON.OBJKEYS/JSON.OBJLEN 探测、JSON.ARRAPPEND/ARRINSERT/ARRPOP/ARRTRIM 数组操作、JSON.NUMINCRBY/NUMBERS 数值与 JSON.STRAPPEND/STRLEN 字符串、JSON.TOGGLE、原子多路径更新与内存语义、与 RediSearch 二级索引配合、文档建模范式与反规范化权衡、性能与容量规划

在 Redis 里存一个对象,传统做法是 HSET user:1 name "张三" age 28 tags "a,b,c"——简单、省内存,但嵌套结构无处安放:订单里的商品列表、用户的多个地址、配置的多层树,只能序列化成 JSON 字符串塞进一个 field,读写都得整体反序列化,无法局部更新。

RedisJSON 正是为此而生:它把 JSON 文档作为一等公民存储,支持按路径读写任意深度的字段、原子更新数组元素、在文档字段上建二级索引。配合 RediSearch,一个 Redis 实例就能承担「文档数据库 + 搜索引擎」的角色。

本文从基础命令讲到 JSONPath 语法、数组与数值操作、二级索引、文档建模。


一、RedisJSON 的价值与代价

1.1 与 Hash 的对比

Hash 不支持嵌套结构(需序列化),局部更新只能到单层 field,数组操作需整体读写,数值运算只有仅支持整数的 HINCRBY,内存占用低(listpack 紧凑),类型全是字符串。RedisJSON 原生支持任意深度嵌套、任意路径级局部更新、数组原地追加与插入、支持浮点的 JSON.NUMINCRBY,能保留 number/bool/null 原始类型,但内存开销高(树结构加元数据)。

RedisJSON 的内存开销通常是等价 Hash 的 2~5 倍(取决于嵌套深度与字段数)。对纯扁平且字段固定的对象,Hash 依然是更经济的选择。

1.2 什么时候该用 RedisJSON

文档结构天然嵌套(订单含商品列表、用户含地址数组);需要按路径局部更新以避免读写放大;需要数组原地操作(点赞列表、购物车条目);需要保留原始类型(数字不变成字符串);计划用 RediSearch 在嵌套字段上建索引。

1.3 环境准备

# Redis Stack 已内置 RedisJSON
docker run -d --name redis-stack -p 6379:6379 redis/redis-stack-server:7.4.0-v0

redis-cli MODULE LIST | grep -i json
# 2) 1) "name" 2) "ReJSON" 3) "ver" 4) (integer) 20808

二、基础操作:SET / GET / DEL

2.1 JSON.SET

# 在根路径写入整个文档
redis-cli JSON.SET user:1 $ '{"name":"张三","age":28,"vip":true,"tags":["go","redis"],"address":{"city":"杭州","zip":"310000"}}'

# 只写某个字段(局部更新,其余字段不动)
redis-cli JSON.SET user:1 $.age 29
redis-cli JSON.SET user:1 $.address.city '"上海"'
redis-cli JSON.SET user:1 $.tags[0] '"golang"'

# NX:仅当路径不存在时写入;XX:仅当路径存在时写入
redis-cli JSON.SET user:1 $.level 3 NX      # OK(首次)
redis-cli JSON.SET user:1 $.level 9 NX      # (nil)(已存在,不覆盖)
redis-cli JSON.SET user:1 $.level 9 XX      # OK(存在,覆盖)

注意字符串值必须带引号且整体再套一层引号:'"上海"'。因为 JSON.SET 的参数本身就是 JSON 字面量,上海 不是合法 JSON,"上海" 才是。

2.2 JSON.GET

# 取整个文档(紧凑格式)
redis-cli JSON.GET user:1

# 取指定路径(结果是数组,因为一个路径可能匹配多个节点)
redis-cli JSON.GET user:1 $.name
# ["张三"]

# 多路径 + 缩进格式化
redis-cli JSON.GET user:1 $.name $.address.city INDENT "\t" NEWLINE "\n"

# 取多个键(MGET 风格)
redis-cli JSON.MGET user:1 user:2 $.name
# 1) "[\"张三\"]"  2) (nil)

JSON.GET 返回的路径结果总是数组,而 JSON.SET 不需要。这是最易混淆的一点。

2.3 JSON.DEL 与探测命令

redis-cli JSON.DEL user:1 $.tags[1]
# (integer) 1        -- 返回删除的节点数

redis-cli JSON.TYPE user:1 $.age
# "integer"
redis-cli JSON.OBJLEN user:1 $
# (integer) 5
redis-cli JSON.TOGGLE user:1 $.vip
# false

JSON.TYPE 返回节点类型(object、array、string、integer、number、boolean、null),JSON.OBJKEYS 列出对象键,JSON.OBJLEN 返回字段数,JSON.ARRLEN 返回数组长度,JSON.STRLEN 返回字符串长度,JSON.TOGGLE 对布尔取反。


三、JSONPath 语法详解

3.1 路径基础

RedisJSON 支持两种路径语法:增强语法(推荐)以 $ 开头(如 $.a.b[0]),功能完整;旧版语法以 . 开头,功能受限。除非兼容老代码,一律使用 $ 开头的增强语法——RediSearch 的 ON JSON 索引也要求 $. 路径。

3.2 核心语法元素

$ 是根节点;.name 取子字段($.address.city);['name'] 用于含特殊字符的字段名;.. 是递归下降($..price);[*] 匹配数组全部元素;[0] 与 [-1] 按索引取值(负数从尾算起);[0,2] 取多个索引;[1:3] 是左闭右开的切片;[?(@.qty>2)] 是过滤表达式,其中 @ 代表当前节点。

3.3 递归与通配

取所有层级的 price 用 JSON.GET order:1 '$..price'(返回 [99.5, 199.0]),取商品数组里所有商品的名称用 JSON.GET order:1 '$.items[*].name'(返回 ["键盘","鼠标"])。

.. 递归下降会遍历整棵文档树,大文档上开销显著。能用精确路径就不用递归。

3.4 过滤表达式

JSON.GET order:1 '$.items[?(@.qty>2)]' 取出数量大于 2 的商品;$.items[?(@.qty>1 && @.price<200)] 是组合条件;$.items[?(@.category=="electronics")] 按字符串匹配。

过滤支持 ==、!=、<、<=、>、>=、&&、||、=~(正则)等运算符。

3.5 路径匹配多个节点时的行为

# 多匹配时,读操作返回数组;写操作作用于全部匹配节点
redis-cli JSON.SET order:1 '$.items[*].checked' true
# OK  -- 所有商品的 checked 都被设为 true

redis-cli JSON.GET order:1 '$.items[*].checked'
# [true,true]

这是 RedisJSON 最强大的特性之一:一次命令批量更新多个节点,且保证原子性。批量打标、批量改状态极其高效。


四、数组操作

4.1 追加与插入

# 在数组末尾追加(可一次追加多个)
redis-cli JSON.ARRAPPEND user:1 $.tags '"rust"' '"python"'
# (integer) 4      -- 追加后的数组长度

# 在指定位置插入(index 为插入点,支持负数)
redis-cli JSON.ARRINSERT user:1 $.tags 1 '"java"'
# (integer) 5

# 在数组头部插入
redis-cli JSON.ARRINSERT user:1 $.tags 0 '"c"'

4.2 弹出与裁剪

redis-cli JSON.ARRPOP user:1 $.tags       # 从尾部弹出(默认 -1)
# "python"
redis-cli JSON.ARRPOP user:1 $.tags 0     # 从头部弹出
# "c"
redis-cli JSON.ARRTRIM user:1 $.tags 1 3  # 只保留 [1,3] 区间
# (integer) 3

4.3 数组查询

redis-cli JSON.ARRLEN user:1 $.tags
# (integer) 3
redis-cli JSON.GET user:1 '$.tags[0]'
# ["golang"]
redis-cli JSON.GET user:1 '$.tags'
# [["golang","java","rust"]]

4.4 数组命令速查

JSON.ARRAPPEND key path v... 尾部追加并返回新长度;JSON.ARRINSERT key path idx v... 指定位置插入;JSON.ARRPOP key [path [idx]] 弹出元素并返回被弹出的值;JSON.ARRTRIM key path start stop 裁剪;JSON.ARRLEN key [path] 返回长度;JSON.ARRINDEX key path v [start stop] 查找索引,未命中返回 -1。

JSON.ARRAPPEND 与 JSON.ARRINSERT 是原地操作,不需要读出整个数组再写回,这是 RedisJSON 相对「序列化字符串」方案最大的性能优势。


五、数值与字符串原子操作

5.1 JSON.NUMINCRBY:浮点原子自增

redis-cli JSON.SET stat:1 $.score 10.5
redis-cli JSON.NUMINCRBY stat:1 $.score 2.5
# "13"          -- 返回新值(字符串形式的数字)

redis-cli JSON.NUMINCRBY stat:1 $.score -3.5
# "9.5"

这是 Hash 的 HINCRBY 做不到的:HINCRBY 只支持整数,而 NUMINCRBY 支持浮点。价格累加、评分计算、指标累加都依赖它。

5.2 JSON.NUMMULTBY 与多路径批量

redis-cli JSON.SET stat:1 $.score 10
redis-cli JSON.NUMMULTBY stat:1 $.score 1.5
# "15"

# 对多个路径批量自增(一次命令,原子)
redis-cli JSON.SET stat:1 $ '{"a":1,"b":2}'
redis-cli JSON.NUMINCRBY stat:1 '$.*' 10
# "{\"a\":11,\"b\":12}"

5.3 字符串操作

redis-cli JSON.SET msg:1 $.text '"hello"'
redis-cli JSON.STRAPPEND msg:1 $.text '" world"'
# (integer) 11      -- 新长度
redis-cli JSON.STRLEN msg:1 $.text
# (integer) 11

JSON.NUMINCRBY 与 JSON.NUMMULTBY 支持浮点,JSON.STRAPPEND 与 JSON.STRLEN 处理字符串,JSON.TOGGLE 对布尔取反。所有操作都是单命令原子;若需要「读-判断-写」的组合,用 Lua 脚本包裹。


六、与 RediSearch 的二级索引

6.1 为什么需要二级索引

RedisJSON 本身只能按键访问:JSON.GET user:1 很快,但「找出所有 vip 为 true 的用户」无从下手——总不能遍历所有 Key。RediSearch 的 ON JSON 索引把文档字段映射成倒排索引,从而支持按字段检索。

6.2 建立 JSON 索引

redis-cli JSON.SET product:1001 $ '{"name":"iPhone 15 Pro","brand":"Apple","price":8999,"spec":{"weight":187},"tags":["5g","flagship"]}'

# 建索引:JSONPath + AS 别名
redis-cli FT.CREATE idx:product ON JSON PREFIX 1 product: SCHEMA \
  '$.name' AS name TEXT WEIGHT 5.0 SORTABLE \
  '$.brand' AS brand TAG \
  '$.price' AS price NUMERIC SORTABLE \
  '$.spec.weight' AS weight NUMERIC \
  '$.tags[*]' AS tags TAG

注意数组字段用 $.tags[*],RediSearch 会把每个元素都建成 TAG。这是 JSON 索引相对 Hash 的优势:数组天然支持多值 TAG,无需 SEPARATOR 手工拼接。

6.3 查询

redis-cli FT.SEARCH idx:product "@brand:{Apple}"
redis-cli FT.SEARCH idx:product "@price:[5000 10000] @weight:[0 200]" \
  SORTBY price ASC RETURN 3 name price weight
redis-cli FT.SEARCH idx:product "@tags:{5g} @tags:{flagship}"

6.4 索引与数据的一致性

JSON.SET 修改被索引字段时索引自动更新,修改未索引字段则索引不变、无额外开销;JSON.DEL 删除键时索引条目自动移除;通过 RDB 恢复时索引随之恢复。用 FT.INFO 的 num_docs 与 --scan --pattern 'product:*' | wc -l 对比可校验一致性——若不一致,通常是 PREFIX 写错,或 indexing 尚未完成。

6.5 与 Hash 索引的差异

Hash 索引直接写 field 名,数组多值需 SEPARATOR 拼接,不支持嵌套字段,类型全为字符串;JSON 索引用 JSONPath 加 AS 别名,原生支持 [*] 多值与任意深度嵌套,保留原始类型,但内存占用更高。


七、文档建模实践

7.1 三种建模范式

内嵌把子文档直接嵌套在父文档中,一次读取、原子更新,但文档膨胀、部分更新慢;引用只存子文档 ID,文档小、复用度高,但需多次读取;混合把热字段内嵌、冷字段引用,是二者的折中,建模更复杂。

# 内嵌:订单含商品明细(读多写少,明细不独立更新)
redis-cli JSON.SET order:1 $ '{"id":"1","user":1001,"items":[{"sku":"A1","name":"键盘","qty":1,"price":99.5}],"status":"paid"}'

# 引用:订单只存商品 ID,商品详情独立存储
redis-cli JSON.SET order:2 $ '{"id":"2","user":1001,"items":[{"sku":"A1","qty":1}],"status":"paid"}'
redis-cli JSON.SET sku:A1 $ '{"name":"键盘","price":99.5,"stock":500}'

7.2 建模决策要点

文档大小控制在 100KB 以内,超过后单次读写与索引开销都显著上升;不无限增长的数组(评论、日志类数据用 List/Stream,不要塞进 JSON 数组);高频更新的字段单独放;层级不超过 4~5 层;区分热冷字段,热字段留在 JSON,冷字段归档到其他存储。

7.3 反模式清单

用 JSON 存超大数组会让每次 ARRAPPEND 都可能触发扩容拷贝,应改用 List、Stream 或 ZSet;每次读整个文档再改一个字段造成读写放大,应用 JSON.SET $.path 局部更新;把 JSON 当关系表用会陷入无法 JOIN 的困境,应评估是否该用关系库;用 JSON 存二进制会因 Base64 膨胀 33%,应用 String 加压缩;所有字段都建索引会让内存暴涨,只索引检索与排序字段。

7.4 与 Hash 的混合使用

# 扁平高频字段用 Hash(省内存、HINCRBY 高效)
redis-cli HSET counter:user:1001 login_count 42 last_login 1696000000

# 复杂结构用 JSON
redis-cli JSON.SET profile:user:1001 $ '{"prefs":{"theme":"dark","lang":"zh"},"devices":[{"os":"ios"}]}'

同一个 Redis 实例里 Hash 与 JSON 可以共存,甚至可以用 RediSearch 建两个索引后统一查询。按数据的形状选存储,而不是强行统一。


八、性能、内存与运维

8.1 内存构成

JSON.DEBUG MEMORY user:1 只统计 JSON 值的部分,MEMORY USAGE user:1 含键名与对象头,做容量规划时以后者为准。内存由文档树结构(每个节点有类型标记与指针)、完整存储的字段名、数组元素节点以及 Redis 对象头与过期时间组成。

8.2 性能特征

JSON.GET 与 JSON.SET 走单路径都是 O(路径深度),极快且不重写整文档;递归 .. 查询是 O(文档节点数),大文档慎用;JSON.SET $ 是 O(文档大小),会整体替换;JSON.ARRAPPEND 均摊 O(1) 但可能触发数组扩容;JSON.ARRINSERT 在头部是 O(n);索引更新是 O(被索引字段数),每次写入都会触发。

JSON.SET $ 整体替换文档会重建整棵树,是性能最差的操作。能用路径更新就不要整体覆盖。

8.3 大文档的拆分策略

# 反例:单文档 5MB,含 10 万条评论
redis-cli JSON.SET post:1 $ '{"title":"...","comments":[ ... 10万条 ... ]}'

# 正例:正文用 JSON,评论用 List/Stream
redis-cli JSON.SET post:1 $ '{"title":"...","summary":"...","comment_count":100000}'
redis-cli LPUSH comments:post:1 '{"user":1,"text":"..."}'

8.4 持久化、复制与集群

JSON 以二进制格式存入 RDB,恢复后类型完整保留;AOF 记录写命令,重放后结果一致;主从复制通过写命令传播,副本独立构建文档树;集群模式下文档整体落在单个槽,不可跨槽分片。这意味着超大文档无法通过分片缓解,只能靠拆分建模。

8.5 监控指标

关注 INFO memory 的 used_memory_human 与 mem_fragmentation_ratio,用 JSON.DEBUG MEMORY 排查单文档,用 SLOWLOG GET 10 检查 JSON.GET/JSON.SET 是否变慢,并监控单文档大小分布以识别异常大文档;索引内存(FT.INFO 的 inverted_sz_mb)应纳入容量看板,同时定期巡检 .. 递归路径的使用。


九、生产实践与选型

9.1 迁移方案:Hash 到 JSON

双写过渡是标准做法:读走旧 Hash,写同时更新 Hash 与 JSON,再按 uid 取模灰度分流读流量。

def update_user(uid, field, value):
    r.hset(f"user:{uid}", field, value)                  # 旧路径
    r.json().set(f"user:{uid}:v2", f"$.{field}", value)  # 新路径

def read_user(uid, field):
    if uid % 100 < 10:                                    # 10% 走新路径
        return r.json().get(f"user:{uid}:v2", f"$.{field}")
    return r.hget(f"user:{uid}", field)

9.2 常见问题排查

现象原因对策
new objects must be created at the root对不存在的路径做非根写先创建父对象
Path '$.a' does not exist路径不存在用 NX 或先初始化
WRONGTYPE键是 Hash 却用 JSON 命令检查键的类型
JSON.GET 返回嵌套数组路径匹配多节点用 [0] 取首个
索引查不到数据字段未建索引或 PREFIX 不符FT.INFO 核对
内存超预期嵌套过深或字段名过长JSON.DEBUG MEMORY 定位

9.3 客户端支持

Python 的 redis-py 用 r.json().set(key, '$', obj),Java 的 Jedis/Lettuce 用 jedis.jsonSet(key, Path.ROOT_PATH, obj),Go 的 go-redis 用 client.JSONSet(ctx, key, "$", obj),Node.js 的 node-redis 用 client.json.set(key, '$', obj)。

from redis import Redis
r = Redis(decode_responses=True)
r.json().set("user:1", "$", {"name": "张三", "age": 28})
r.json().numincrby("user:1", "$.age", 1)
r.json().arrappend("user:1", "$.tags", "go", "redis")
print(r.json().get("user:1", "$.name"))   # ['张三']

客户端封装会替你处理「字符串加引号」这类细节,强烈建议用 SDK 而非裸命令拼接,尤其是在需要转义用户输入时。

9.4 选型检查清单

  • 数据是否天然嵌套?扁平的用 Hash 更省内存
  • 是否需要局部更新?整体读写用 String 存 JSON 也行
  • 是否需要数组原地操作?是则 JSON 优势明显
  • 单文档是否小于 100KB?超了要拆
  • 是否需要按字段检索?需要则配 RediSearch
  • 是否接受 2~5 倍内存开销?集群下文档是否能落在单槽?

结语

RedisJSON 把 Redis 从「键值存储」推进到「文档存储」,用原生的路径寻址与数组操作解决了嵌套数据的读写痛点。核心要点回顾:

  1. 按数据形状选存储:扁平对象用 Hash,嵌套文档用 JSON,二者可在同一实例共存
  2. 路径更新优于整体覆盖:JSON.SET $.field 是 O(深度),JSON.SET $ 是 O(文档大小),差异巨大
  3. 数组操作是杀手锏:ARRAPPEND/ARRINSERT/ARRPOP 原地操作,无需读出整个数组
  4. NUMINCRBY 支持浮点:这是 Hash 的 HINCRBY 做不到的,适合价格与评分累加
  5. 多路径批量更新原子生效:$.items[*].checked 一次改所有元素,是极高效的批量打标手段
  6. 检索靠 RediSearch:RedisJSON 只解决存储,按字段查询必须建 ON JSON 索引
  7. 控制文档大小:100KB 是心理红线,无限增长的数组一律外置

RedisJSON 的真正价值不在于「能存 JSON」——把 JSON 序列化成字符串也能存。它的价值在于路径级的原子操作:当你要修改一个深层嵌套字段、往数组里追加一个元素、给一批子文档批量打标时,传统方案需要「读出-反序列化-修改-序列化-写回」,而 RedisJSON 只需要一条命令。这个差别在高并发下就是数量级的性能鸿沟。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「redis」更多文章

  1. Redis 线上排障与延迟诊断:SLOWLOG、LATENCY 与阻塞命令全流程
  2. 云托管 Redis 选型与运维:ElastiCache、MemoryDB、Redis Cloud 与 Upstash 对比
  3. RedisTimeSeries 时序数据实战:降采样、压缩与监控告警