在 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 从「键值存储」推进到「文档存储」,用原生的路径寻址与数组操作解决了嵌套数据的读写痛点。核心要点回顾:
- 按数据形状选存储:扁平对象用 Hash,嵌套文档用 JSON,二者可在同一实例共存
- 路径更新优于整体覆盖:
JSON.SET $.field是 O(深度),JSON.SET $是 O(文档大小),差异巨大 - 数组操作是杀手锏:
ARRAPPEND/ARRINSERT/ARRPOP原地操作,无需读出整个数组 - NUMINCRBY 支持浮点:这是 Hash 的
HINCRBY做不到的,适合价格与评分累加 - 多路径批量更新原子生效:
$.items[*].checked一次改所有元素,是极高效的批量打标手段 - 检索靠 RediSearch:RedisJSON 只解决存储,按字段查询必须建
ON JSON索引 - 控制文档大小:100KB 是心理红线,无限增长的数组一律外置
RedisJSON 的真正价值不在于「能存 JSON」——把 JSON 序列化成字符串也能存。它的价值在于路径级的原子操作:当你要修改一个深层嵌套字段、往数组里追加一个元素、给一批子文档批量打标时,传统方案需要「读出-反序列化-修改-序列化-写回」,而 RedisJSON 只需要一条命令。这个差别在高并发下就是数量级的性能鸿沟。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。