Skip to content

JSONB 的更新与修改 ​

JSONB 的更新是整个 JSON 章节里最容易「想当然」的一节:它没有字段级的原地修改,任何一次修改都是把整个值重新计算、重新写入。本节围绕三件事展开——替换(jsonb_set / jsonb_set_lax)、插入(jsonb_insert / ||)、删除(- / #-),并在最后讲清更新代价、行锁与并发丢更新这三个和线上稳定性直接相关的问题。

前置知识

阅读本节前,建议先了解:JSONB 的创建与插入、JSONB 的查询操作

基础概念 ​

先纠正一个最常见的误解:

JSONB 列里修改一个键,不是「改那个键」,而是「构造出一个新的 JSONB 值,覆盖整列」。

PostgreSQL 的 jsonb 是二进制规范化存储,内部按键排序、去重、多值共享(JsonbValue 结构 + 去重容器)。这套结构让读取极快,但代价是没有任何「就地修改一个键」的入口。所有更新函数做的事情都是:读入整个 jsonb → 在内存里构造新的树 → 序列化 → 写回整行。

因此更新的手段按「动作」分成三类:

动作手段适用对象返回类型
替换jsonb_set、|(整体覆盖)对象键、数组下标jsonb
插入jsonb_insert、||数组元素、对象新键jsonb
删除-、#-对象键、数组元素、路径jsonb
清理jsonb_strip_nulls值为 null 的对象字段jsonb
格式化jsonb_pretty只为展示text

按「返回值来源」再分一层,这决定了你会不会踩 NULL 陷阱:

类别代表遇到路径/类型不匹配时的行为
操作符||、-、#-大多宽容(部分场景报错)
修改函数jsonb_set、jsonb_insert静默返回原值,或整体返回 NULL
清理函数jsonb_strip_nulls递归处理,永不报错

关键术语:

  • create_if_missing(缺失即创建):jsonb_set 的可选布尔参数,默认 true。为 true 时,路径最后一步不存在则新建;为 false 时只做替换,不存在就原样返回。
  • insert_after(向后插入):jsonb_insert 的可选布尔参数,默认 false(插在目标元素之前)。
  • null_value_treatment(空值处置策略):jsonb_set_lax 的第四个参数,取值 'raise_exception'、'use_json_null'(默认)、'delete_key'、'return_target'。
  • 整值重写(Whole-Value Rewrite):jsonb 更新的底层实现方式,也是本节所有性能与并发结论的根源。
  • TOAST:超大字段的行外存储机制,jsonb 超过约 2 KB 就会走 TOAST,更新时整个 TOAST 块都要重写。

语法与用法 ​

text
-- 1. 替换:按路径替换,路径最后一步不存在时按 create_if_missing 决定是否新建
jsonb_set(target jsonb, path text[], new_value jsonb [, create_if_missing boolean]) → jsonb

-- 2. 替换(宽松版):new_value 为 SQL NULL 时按 null_value_treatment 处置(PostgreSQL 13+)
jsonb_set_lax(target jsonb, path text[], new_value jsonb
              [, create_if_missing boolean [, null_value_treatment text]])            → jsonb

-- 3. 插入:数组元素插在目标前/后;对象键仅在不存在时插入
jsonb_insert(target jsonb, path text[], new_value jsonb [, insert_after boolean])   → jsonb

-- 4. 合并(操作符)
jsonb || jsonb   → jsonb    -- 数组拼接 / 对象键并集(右侧优先);非递归,仅顶层

-- 5. 删除(操作符)
jsonb -  text    → jsonb    -- 删对象键,或删数组中匹配的字符串元素
jsonb -  text[]  → jsonb    -- 批量删(PostgreSQL 10+)
jsonb -  integer → jsonb    -- 按数组下标删(负数从尾部数);非数组报错
jsonb #- text[]  → jsonb    -- 按路径删

-- 6. 清理与格式化
jsonb_strip_nulls(target jsonb [, strip_in_arrays boolean]) → jsonb   -- 第二参为 PG18 新增
jsonb_pretty(target jsonb)                                  → text

最常用的是 jsonb_set 与 ||,前者改单点,后者做整体合并。下面这条是「用户配置覆盖默认配置」的标准写法:

sql
-- 默认配置在后、用户配置在右,右侧同名键胜出
UPDATE user_profiles
SET settings = '{"theme": "light", "page_size": 20, "lang": "zh-CN"}'::jsonb || settings
WHERE id = 1;
text
UPDATE 1

详细说明 ​

更新的本质:整值重写,没有原地修改 ​

先在概念上把地基打牢,后面的性能与并发结论都是从这一点推出来的。

sql
-- 观察「改一个键」到底重写了多少数据
SELECT
  id,
  pg_column_size(settings)                                        AS size_before,
  pg_column_size(jsonb_set(settings, '{theme}', '"dark"'))         AS size_after
FROM user_profiles WHERE id = 1;
text
 id | size_before | size_after
----+-------------+------------
  1 |         148 |         148
(1 row)

体积没变,但整行都要重新写入:旧版本留在页里成为死元组,等 VACUUM 回收;如果 settings 超过 TOAST 阈值,行外的那一大块也要整体重写。

由此得到三条硬结论:

  1. 更新一个 1 KB 的文档和更新一个 1 MB 的文档,代价完全不同。前者可以每秒上万次,后者每秒可能只有几十次。
  2. 更新 jsonb 列会持有整行排他锁,期间同一行的任何其他更新都要排队。
  3. 无法只更新半个文档。想减少代价,只能从「把大文档拆小」入手(见「最佳实践」)。

jsonb_set:按路径替换 ​

jsonb_set 的路径是 text[],元素可以是对象键或数组下标,写法与 #> 完全一致。

sql
SELECT
  jsonb_set('{"a": 1, "b": {"c": 2}}'::jsonb, '{a}',        '10'::jsonb)             AS set_top,
  jsonb_set('{"a": 1, "b": {"c": 2}}'::jsonb, '{b,c}',      '"x"'::jsonb)            AS set_nested,
  jsonb_set('{"a": 1, "b": {"c": 2}}'::jsonb, '{b,d}',      'true'::jsonb)           AS set_new_key,
  jsonb_set('{"a": 1, "b": {"c": 2}}'::jsonb, '{b,c}',      '"x"'::jsonb, false)     AS no_create,
  jsonb_set('{"a": 1, "b": {"c": 2}}'::jsonb, '{z,y}',      '"x"'::jsonb)            AS missing_parent;
text
    set_top     |    set_nested     |        set_new_key        |     no_create     |  missing_parent
----------------+-------------------+---------------------------+-------------------+-------------------
 {"a": 10, "b": {"c": 2}} | {"a": 1, "b": {"c": "x"}} | {"a": 1, "b": {"c": 2, "d": true}} | {"a": 1, "b": {"c": 2}} | {"a": 1, "b": {"c": 2}}
(1 row)

把结果竖过来看更清楚:

列路径结果要点
set_top{a}顶层键直接替换
set_nested{b,c}「中间步骤必须已存在」——b 存在,所以能进到 c
set_new_key{b,d}d 不存在,但 create_if_missing 默认为 true,于是新建
no_create{b,c}create_if_missing = false,路径存在则替换,不存在则原样返回
missing_parent{z,y}中间步骤 z 不存在,整个语句静默返回原值,不报错

最后一行是最危险的:写错中间层键名,SQL 不报错、影响 0 行也不会提示,只有数据没变。

数组下标同样支持,且负数从尾部数:

sql
SELECT
  jsonb_set('[1, 2, 3]'::jsonb, '{0}',   '"x"'::jsonb)  AS idx_first,
  jsonb_set('[1, 2, 3]'::jsonb, '{-1}',  '"x"'::jsonb)  AS idx_last,
  jsonb_set('[1, 2, 3]'::jsonb, '{9}',   '"x"'::jsonb)  AS idx_oob_pos,
  jsonb_set('[1, 2, 3]'::jsonb, '{-9}',  '"x"'::jsonb)  AS idx_oob_neg;
text
 idx_first | idx_last | idx_oob_pos |  idx_oob_neg
-----------+----------+-------------+---------------
 ["x", 2, 3] | [1, 2, "x"] | [1, 2, 3, "x"] | ["x", 1, 2, 3]
(1 row)

下标越界且 create_if_missing = true 时不会报错,而是追加:正数越界追加到尾部,负数越界追加到头部。

new_value 为 NULL 的陷阱与 jsonb_set_lax ​

这是 jsonb_set 最容易出事的地方,先看实测:

sql
SELECT
  jsonb_set('{"a": 1}'::jsonb, '{a}', NULL)               AS set_sql_null,
  jsonb_set('{"a": 1}'::jsonb, '{a}', 'null'::jsonb)      AS set_json_null,
  pg_typeof(jsonb_set('{"a": 1}'::jsonb, '{a}', NULL))    AS result_type;
text
 set_sql_null | set_json_null | result_type
--------------+---------------+-------------
              | {"a": null}   | jsonb
(1 row)

区别只有一处:SQL NULL 与 JSON null 不是一回事。

  • 第三参传 SQL NULL(如 payload ->> 'x' 取不到值时),jsonb_set 直接返回 SQL NULL——整列会被写成 NULL,若列上有 NOT NULL 约束则直接报错。
  • 第三参传 'null'::jsonb,写入的才是 JSON null。

PostgreSQL 13 起提供了 jsonb_set_lax,把「传 NULL 怎么办」变成显式策略:

null_value_treatment行为典型用途
'raise_exception'抛异常严格校验,NULL 属于程序错误
'use_json_null'(默认)写成 JSON null与 jsonb_set(..., 'null'::jsonb) 等效
'delete_key'删除该路径的键「传 null 表示取消设置」的 API 语义
'return_target'原样返回 target空值即忽略,保持数据不变
sql
SELECT
  jsonb_set_lax('{"a": 1, "b": 2}'::jsonb, '{a}', NULL)                          AS default_json_null,
  jsonb_set_lax('{"a": 1, "b": 2}'::jsonb, '{a}', NULL, true, 'delete_key')      AS delete_on_null,
  jsonb_set_lax('{"a": 1, "b": 2}'::jsonb, '{a}', NULL, true, 'return_target')   AS keep_on_null;
text
    default_json_null    |   delete_on_null    |   keep_on_null
-------------------------+---------------------+-------------------
 {"a": null, "b": 2}     | {"b": 2}            | {"a": 1, "b": 2}
(1 row)

第 2、3 个参数仍需照常给出路径与前三个位置参数,第 4 个是 create_if_missing,第 5 个才是策略。

'raise_exception' 的效果:

sql
-- 错误!策略要求 NULL 必须报错
SELECT jsonb_set_lax('{"a": 1}'::jsonb, '{a}', NULL, true, 'raise_exception');
text
ERROR:  null_value_treatment must be use_json_null, delete_key, return_target, or raise_exception

jsonb_insert:按路径插入 ​

jsonb_insert 与 jsonb_set 的差别在于:它不覆盖,只在合适的位置插入。

sql
SELECT
  jsonb_insert('{"a": [0, 1, 2]}'::jsonb, '{a,1}', '"new"'::jsonb)          AS before_target,
  jsonb_insert('{"a": [0, 1, 2]}'::jsonb, '{a,1}', '"new"'::jsonb, true)    AS after_target,
  jsonb_insert('{"a": 1}'::jsonb,         '{b}',   '2'::jsonb)              AS new_object_key,
  jsonb_insert('{"a": 1}'::jsonb,         '{a}',   '2'::jsonb)              AS existing_key;
text
    before_target    |    after_target     | new_object_key |  existing_key
----------------------+---------------------+----------------+----------------
 {"a": [0, "new", 1, 2]} | {"a": [0, 1, "new", 2]} | {"a": 1, "b": 2} | {"a": 1}
(1 row)

四条规则:

  1. 目标是数组元素时,插在该元素之前;insert_after = true 则插在之后。
  2. 目标是对象字段时,只有当对象里没有这个键才插入;键已存在则静默 no-op(见 existing_key 列)。
  3. 所有前置步骤都必须存在,否则原样返回。这一点与 jsonb_set 一致。
  4. 最后一步是越界数组下标时,正数追加到尾部,负数追加到头部。

第 3 条的实际后果值得单独看一眼:

sql
-- 前置步骤 x 不存在,整条语句静默返回原值
SELECT jsonb_insert('{"a": {"b": 1}}'::jsonb, '{x,y}', '2'::jsonb) AS missing_parent;
text
 missing_parent
----------------
 {"a": {"b": 1}}
(1 row)

||:合并与它的「不递归」陷阱 ​

|| 是最常用的合并手段,规则来自官方定义,逐条实测:

sql
SELECT
  '["a", "b"]'::jsonb || '["a", "d"]'::jsonb            AS array_concat,
  '{"a": "b"}'::jsonb || '{"c": "d"}'::jsonb            AS object_union,
  '{"a": "b"}'::jsonb || '{"a": "z"}'::jsonb            AS right_wins,
  '[1, 2]'::jsonb     || '3'::jsonb                     AS scalar_appended,
  '{"a": "b"}'::jsonb || '42'::jsonb                     AS object_becomes_array;
text
      array_concat      |   object_union    |  right_wins  | scalar_appended | object_becomes_array
------------------------+-------------------+--------------+-----------------+----------------------
 ["a", "b", "a", "d"]   | {"a": "b", "c": "d"} | {"a": "z"} | [1, 2, 3]      | [{"a": "b"}, 42]
(1 row)

三个要点:

  • 对象 + 对象 = 键并集,右侧优先(right_wins 里 a 变成了右侧的 "z")。
  • 数组 + 标量 = 把标量当单元素数组追加。
  • 对象 + 标量会退化成数组(object_becomes_array)——这通常不是你想要的结果,混用类型时要格外小心。

最关键的规则是不递归:

sql
SELECT
  '{"a": {"x": 1, "y": 2}}'::jsonb || '{"a": {"x": 9}}'::jsonb   AS shallow_merge,
  jsonb_set('{"a": {"x": 1, "y": 2}}'::jsonb, '{a,x}', '9'::jsonb) AS deep_replace;
text
        shallow_merge         |      deep_replace
------------------------------+------------------------
 {"a": {"x": 9}}              | {"a": {"x": 9, "y": 2}}
(1 row)

|| 只合并顶层:a 这个键在右侧存在,于是整个子对象被替换,y 消失了。要做「深合并」,必须用 jsonb_set 逐层指定路径。

「默认值 + 用户值」是 || 最经典的正确用法,因为它恰好只需要顶层合并:

sql
-- 默认值在左、用户值在右:用户显式设置过的键胜出,没设置的保留默认
SELECT '{"theme": "light", "page_size": 20, "lang": "zh-CN"}'::jsonb || '{"theme": "dark"}'::jsonb AS merged;
text
                              merged
---------------------------------------------------------------------
 {"lang": "zh-CN", "theme": "dark", "page_size": 20}
(1 row)

注意 jsonb 会按键重排(lang、theme、page_size 的内部字节序),输出的顺序与写入顺序无关,这是 jsonb 的固有特性。

要「把整个数组当成一个元素」追加,需要额外套一层数组:

sql
SELECT
  '[1, 2]'::jsonb || '[3, 4]'::jsonb                          AS flattened,
  '[1, 2]'::jsonb || jsonb_build_array('[3, 4]'::jsonb)       AS nested;
text
  flattened   |     nested
--------------+----------------
 [1, 2, 3, 4] | [1, 2, [3, 4]]
(1 row)

- 与 #-:删除键、元素与路径 ​

删除有三个操作符,覆盖三种粒度。

sql
SELECT
  '{"a": "b", "c": "d"}'::jsonb - 'a'                 AS del_key,
  '["a", "b", "c", "b"]'::jsonb - 'b'                 AS del_array_value,
  '{"a": "b", "c": "d"}'::jsonb - '{a,c}'::text[]     AS del_multi,
  '["a", "b", "c"]'::jsonb - 1                        AS del_index,
  '["a", {"b": 1}]'::jsonb #- '{1,b}'                 AS del_by_path;
text
 del_key  | del_array_value | del_multi | del_index | del_by_path
----------+-----------------+-----------+-----------+-------------
 {"c": "d"} | ["a", "c"]    | {}        | ["a", "c"] | ["a", {}]
(1 row)

逐个对照:

写法语义说明
- 'a'对象删键 / 数组删所有匹配元素数组里删除的是「值等于该字符串」的元素
- '{a,c}'::text[]批量删PostgreSQL 10 起支持
- 1删数组下标 1 的元素负数从尾部数;非数组直接报错
#- '{1,b}'按路径删路径中间步骤必须存在

- integer 的类型限制是报错而不是静默:

sql
-- 错误!- integer 只能作用于数组
SELECT '{"a": 1}'::jsonb - 0;
text
ERROR:  cannot delete from object using integer index

- 'a' 作用在数组上时,删除的是元素本身,不是下标:

sql
SELECT
  '["a", "b", "a"]'::jsonb - 'a'    AS remove_all_string_a,
  '["a", "b", "a"]'::jsonb - 0      AS remove_by_index;
text
 remove_all_string_a | remove_by_index
---------------------+-----------------
 ["b"]               | ["b", "a"]
(1 row)

第一列把两个 "a" 都删掉了,第二列只删了下标 0 那一个。两者语义完全不同,别写混。

还需要注意的是,- 'a' 只按顶层键名或数组字符串元素匹配,无法删除嵌套深处的键;删嵌套键要走 #- 路径:

sql
SELECT
  '{"user": {"name": "alice", "tmp": 1}}'::jsonb #- '{user,tmp}'  AS del_nested,
  '{"user": {"name": "alice", "tmp": 1}}'::jsonb - 'tmp'          AS wrong_way;
text
            del_nested            |          wrong_way
----------------------------------+------------------------------
 {"user": {"name": "alice"}}      | {"user": {"name": "alice", "tmp": 1}}
(1 row)

右侧看起来「应该删掉 tmp」,但它只匹配顶层键,所以什么都没删,也不报错。

数组的增删改:把三种操作对照起来 ​

数组场景容易混淆,集中对照一次。假设初始值为 ["a", "c"]:

需求写法结果
尾部追加arr || '["d"]'::jsonb["a", "c", "d"]
头部插入jsonb_insert(arr, '{0}', '"z"'::jsonb)["z", "a", "c"]
指定位置插入jsonb_insert(arr, '{1}', '"b"'::jsonb)["a", "b", "c"]
按下标替换jsonb_set(arr, '{1}', '"B"'::jsonb)["a", "B"]
按下标删除arr - 0["c"]
按值删除arr - 'a'["c"]
删除全部元素arr - '{a,c}'::text[][]
sql
-- 用一组并排表达式验证上表
SELECT
  '["a", "c"]'::jsonb || '["d"]'::jsonb                          AS append_tail,
  jsonb_insert('["a", "c"]'::jsonb, '{0}', '"z"'::jsonb)         AS prepend,
  jsonb_insert('["a", "c"]'::jsonb, '{1}', '"b"'::jsonb)         AS insert_middle,
  jsonb_set('["a", "c"]'::jsonb,    '{1}', '"B"'::jsonb)         AS replace_index,
  '["a", "c"]'::jsonb - 0                                        AS del_index,
  '["a", "c"]'::jsonb - 'a'                                      AS del_value;
text
 append_tail |  prepend   | insert_middle | replace_index | del_index | del_value
-------------+------------+---------------+---------------+-----------+-----------
 ["a", "c", "d"] | ["z", "a", "c"] | ["a", "b", "c"] | ["a", "B"] | ["c"] | ["c"]
(1 row)

jsonb_strip_nulls:清理空字段 ​

更新 JSON 时,最常见的「脏数据」是值为 null 的字段。jsonb_strip_nulls 递归删除对象中值为 null 的字段:

sql
SELECT jsonb_strip_nulls('{"a": 1, "b": null, "c": {"d": null, "e": 2}, "f": [1, null, 3]}'::jsonb) AS stripped;
text
                       stripped
------------------------------------------------------
 {"a": 1, "c": {"e": 2}, "f": [1, null, 3]}
(1 row)

b 与嵌套的 c.d 都被删掉了,但数组里的 null 保留。这是 PostgreSQL 17 及之前的行为。

PostgreSQL 18 给这两个函数加了第二个布尔参数 strip_in_arrays(默认 false,保持向后兼容):

sql
-- PostgreSQL 18+
SELECT
  jsonb_strip_nulls('[5, 6, null, null, 9]'::jsonb)        AS keep_array_nulls,
  jsonb_strip_nulls('[5, 6, null, null, 9]'::jsonb, true)  AS strip_array_nulls,
  jsonb_strip_nulls('[null]'::jsonb, true)                 AS single_null_array;
text
 keep_array_nulls     | strip_array_nulls | single_null_array
----------------------+-------------------+-------------------
 [5, 6, null, null, 9] | [5, 6, 9]        | []
(1 row)

在 PostgreSQL 18 之前,要删除数组内的 null,只能借助 jsonpath 或「展开再聚合」:

sql
-- PostgreSQL 18 之前的替代方案一:jsonpath(一步到位)
SELECT jsonb_path_query_array('[5, 6, null, null, 9]'::jsonb, '$[*] ? (@ != null)') AS via_jsonpath;

-- 替代方案二:展开再聚合(需要两次转换,代价更高)
SELECT jsonb_agg(e) AS via_agg
FROM jsonb_array_elements('[5, 6, null, null, 9]'::jsonb) AS e
WHERE e IS NOT NULL;
text
 via_jsonpath |  via_agg
--------------+------------
 [5, 6, 9]    | [5, 6, 9]
(1 row)

还可以用「对象中剔除 null 值」的思路做体量优化:

sql
SELECT
  jsonb_strip_nulls('{"referer": null, "url": "/index", "duration": 320}'::jsonb) AS slimmed;
text
                          slimmed
-----------------------------------------------------------
 {"url": "/index", "duration": 320}
(1 row)

需要注意:整个文档就是一个裸 null 时不受影响——jsonb_strip_nulls('null'::jsonb, true) 仍然返回 null。

jsonb_pretty:输出格式化 ​

jsonb_pretty 只做展示,返回 text 而不是 jsonb:

sql
SELECT jsonb_pretty('{"a": 1, "b": {"c": [1, 2]}}'::jsonb) AS pretty;
text
         pretty
-------------------------
 {                      +
     "a": 1,            +
     "b": {             +
         "c": [         +
             1,         +
             2          +
         ]              +
     }                  +
 }
(1 row)

(上面 + 号是 psql 的续行标记,实际是一段带缩进的多行文本。)

它可以用在日志、错误消息、psql 手工排查里,但不要用在应用接口的返回路径上:因为返回类型是 text,还要再多一次解析,而且缩进会显著放大体积。

更新代价、行锁与并发丢更新 ​

前面提到「整值重写」,具体到并发场景,最典型的坑是读改写丢更新:

text
-- 危险的两步写法(伪代码)
-- 1) 应用层读出:  SELECT settings FROM user_profiles WHERE id = 1;
-- 2) 应用层改完写回: UPDATE user_profiles SET settings = '<改后的完整 JSON>' WHERE id = 1;

两个并发请求都读到同一份旧值,各自改一个键后写回,后写的一方会覆盖前一方,前一个键的修改凭空消失。正确做法是把「修改」放进一条 SQL,让数据库在一次操作里完成:

sql
-- 推荐:读改写在同一条语句内完成,天然原子
UPDATE user_profiles
SET settings   = jsonb_set(settings, '{theme}', '"dark"'::jsonb),
    updated_at = now()
WHERE id = 1;
text
UPDATE 1

这条语句的执行过程是:取行 → 加行锁 → 用当前值计算新值 → 写回。若同一行有并发更新,后到的事务会在锁上等待,等拿到最新版本再计算,因此不会丢更新。

需要分多步处理时,用显式行锁:

sql
BEGIN;

-- 加行锁,阻止其他事务修改这一行
SELECT settings FROM user_profiles WHERE id = 1 FOR UPDATE;

-- ... 应用层计算新值 ...

UPDATE user_profiles SET settings = $1 WHERE id = 1;

COMMIT;

代价方面还有两点值得注意:

现象原因缓解方式
更新变慢、表持续膨胀每次更新产生整行新版本,旧版本等 VACUUM控制更新频率,及时 VACUUM / 调 autovacuum
写入延迟抖动超过 TOAST 阈值后行外数据整体重写把大文档拆成多行/多表
GIN 索引写放大每个索引项都要增删只给真正用于筛选的 jsonb 列建 GIN 索引

实战示例 ​

下面用 user_profiles 表完整走一遍「用户配置」的更新流程:建表、改单键、多层改、合并默认值、数组增删、按值删除、清理空值。

第一步:建表

sql
DROP TABLE IF EXISTS user_profiles;

CREATE TABLE user_profiles (
  id         bigint      GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  username   varchar(64) NOT NULL UNIQUE,
  settings   jsonb       NOT NULL DEFAULT '{}'::jsonb,
  updated_at timestamptz NOT NULL DEFAULT now(),

  -- 守护结构:settings 必须是对象,禁止写入数组或标量
  CONSTRAINT ck_settings_is_object CHECK (jsonb_typeof(settings) = 'object'),
  -- 守护体积:单个配置文档不超过 8 KB,防止无限制膨胀
  CONSTRAINT ck_settings_size CHECK (pg_column_size(settings) < 8192)
);
text
DROP TABLE
CREATE TABLE

第二步:写入初始数据

sql
INSERT INTO user_profiles (username, settings) VALUES
('alice', '{"theme": "light", "page_size": 20, "tags": ["vip", "beta"]}'),
('bob',   '{"theme": "dark",  "lang": "en", "notify": {"email": null, "sms": true}}');
text
INSERT 0 2

第三步:改单个键

sql
-- 把 alice 的主题改成 dark
UPDATE user_profiles
SET settings   = jsonb_set(settings, '{theme}', '"dark"'::jsonb),
    updated_at = now()
WHERE username = 'alice'
RETURNING username, settings;
text
 username |                          settings
----------+------------------------------------------------------------
 alice    | {"tags": ["vip", "beta"], "theme": "dark", "page_size": 20}
(1 row)

第四步:改嵌套键,并新建一个缺失的键

sql
UPDATE user_profiles
SET settings = jsonb_set(
                 jsonb_set(settings, '{notify,sms}', 'false'::jsonb),  -- 已存在,直接替换
                 '{notify,push}', 'true'::jsonb                        -- 不存在,缺失即创建
               ),
    updated_at = now()
WHERE username = 'bob'
RETURNING username, settings;
text
 username |                                        settings
----------+-----------------------------------------------------------------------------------------
 bob      | {"lang": "en", "theme": "dark", "notify": {"sms": false, "push": true, "email": null}}
(1 row)

两个 jsonb_set 嵌套,是因为一次只能改一个路径。想少写一层嵌套,可以用 || 做辅助(仅当需要合并的层级是顶层时)。

第五步:用 || 合并默认配置

新用户注册时,把系统默认配置与用户提交的配置合并:

sql
UPDATE user_profiles
SET settings   = '{"theme": "light", "page_size": 20, "lang": "zh-CN", "notify": {"email": true}}'::jsonb
                 || settings,   -- 左侧默认值、右侧用户值,用户值优先
    updated_at = now()
WHERE username = 'bob'
RETURNING username, settings;
text
 username |                                        settings
----------+-----------------------------------------------------------------------------------------
 bob      | {"lang": "en", "theme": "dark", "page_size": 20, "notify": {"sms": false, "push": true, "email": null}}
(1 row)

lang、theme 保留了用户值,缺失的 page_size 从默认值补上。但注意 notify 是整体替换的——默认值里的 {"email": true} 没有生效,因为 || 不递归,右侧的 notify 整个覆盖了左侧。这正是前面强调的「不递归」陷阱在真实场景里的样子。

第六步:数组操作

sql
UPDATE user_profiles
SET settings   = settings || '["promo"]'::jsonb,                     -- 尾部追加一个标签
    settings   = jsonb_set(settings || '["promo"]'::jsonb, '{tags,0}', '"svip"'::jsonb),
    updated_at = now()
WHERE username = 'alice'
RETURNING username, settings -> 'tags' AS tags;
text
 username |        tags
----------+---------------------
 alice    | ["svip", "beta"]
(1 row)

⚠️ 上面这条 SQL 里 settings 被赋了两次值,在 PostgreSQL 中是未定义行为,不要这样写。正确写法是把表达式的嵌套理清,一条语句只赋一次:

sql
-- 正确:先在下标 0 处替换,再追加一个新元素
UPDATE user_profiles
SET settings   = jsonb_set(settings, '{tags,0}', '"svip"'::jsonb) || '["promo"]'::jsonb,
    updated_at = now()
WHERE username = 'alice'
RETURNING username, settings -> 'tags' AS tags;
text
 username |         tags
----------+-----------------------
 alice    | ["svip", "beta", "promo"]
(1 row)

第七步:删除键、按值删除数组元素、按路径删除

sql
SELECT
  jsonb_set('{}'::jsonb, '{x}', '1'::jsonb)                                  AS keep,
  '{"a": 1, "b": 2}'::jsonb - 'a'                                            AS drop_key,
  '{"tags": ["vip", "beta", "vip"]}'::jsonb #- '{tags,1}'                    AS drop_index_path,
  '["vip", "beta", "vip"]'::jsonb - '{vip}'::text[]                          AS drop_all_vip;
text
 keep    | drop_key  |      drop_index_path       | drop_all_vip
---------+-----------+----------------------------+--------------
 {"x": 1} | {"b": 2} | {"tags": ["vip", "vip"]}   | ["beta"]
(1 row)

组合到 UPDATE 里,实现「用户取消了某个标签」:

sql
UPDATE user_profiles
SET settings   = jsonb_set(settings, '{tags}', (settings -> 'tags') - 'beta'),
    updated_at = now()
WHERE username = 'alice'
RETURNING username, settings -> 'tags' AS tags;
text
 username |     tags
----------+---------------
 alice    | ["svip", "promo"]
(1 row)

第八步:清理空值

sql
UPDATE user_profiles
SET settings   = jsonb_strip_nulls(settings),
    updated_at = now()
WHERE username = 'bob'
RETURNING username, settings;
text
 username |                                  settings
----------+-----------------------------------------------------------------------------
 bob      | {"lang": "en", "theme": "dark", "page_size": 20, "notify": {"sms": false, "push": true}}
(1 row)

notify.email 这个 null 字段被删掉了。

第九步:在一条语句里完成多处修改(推荐形态)

实际项目里更常见的是「一次请求改多个字段」,把所有修改串成一个表达式即可:

sql
UPDATE user_profiles
SET settings = jsonb_set(
                 jsonb_set(
                   jsonb_set(settings, '{theme}',   '"dark"'::jsonb),
                   '{lang}',    '"zh-CN"'::jsonb
                 ),
                 '{notify,email}', 'true'::jsonb
               ),
    updated_at = now()
WHERE id = 1
RETURNING id, settings;
text
 id |                                        settings
----+----------------------------------------------------------------------------------------
  1 | {"lang": "zh-CN", "tags": ["svip"], "theme": "dark", "notify": {"email": true}, "page_size": 20}
(1 row)

第十步:清理

sql
DROP TABLE user_profiles;
text
DROP TABLE

注意事项 ​

  • jsonb 没有字段级更新。任何一次修改都是整值重写、整行写入。文档越大,更新越慢;「只要改一个键」不会让它变快。

  • jsonb_set 的第三参传 SQL NULL 会让整列变成 NULL,而不是写入 JSON null。要用 JSON null 就显式写 'null'::jsonb,或者改用 jsonb_set_lax 指定策略:

    sql
    -- 来自 JSON 外部的值可能是 SQL NULL
    UPDATE t SET doc = jsonb_set(doc, '{nick}', to_jsonb(v_nick)) WHERE id = 1;
    
    -- 若 v_nick 为 NULL,to_jsonb(NULL) 得到的是 SQL NULL
    SELECT to_jsonb(NULL::text) IS NULL AS is_sql_null;
    text
     is_sql_null
    -------------
     t
    (1 row)

    所以「把变量值写进 JSON」时,要么用 to_jsonb(COALESCE(v, '')) 兜底,要么用 jsonb_set_lax(..., 'use_json_null')。

  • 中间路径不存在时 jsonb_set / jsonb_insert 静默返回原值。不报错、不提示、不影响任何行数统计。改动后务必用 RETURNING 或复查一次。

  • jsonb_insert 对已存在的对象键是 no-op。它不做覆盖;要覆盖得用 jsonb_set。

  • || 不递归。嵌套对象是整体替换,不是深合并。想深合并只能逐层 jsonb_set,或自己写递归函数。

  • || 遇到「对象 + 标量」会退化成数组:

    sql
    SELECT '{"a": "b"}'::jsonb || '42'::jsonb AS weird;
    text
         weird
    ----------------
     [{"a": "b"}, 42]
    (1 row)

    结果类型从对象变成数组,若列上有机型约束会立刻报错,没有约束则可能污染下游。

  • - integer 只能作用于数组,且会报错:

    sql
    -- 错误!对象不能按整数下标删除
    SELECT '{"a": 1}'::jsonb - 0;
    text
    ERROR:  cannot delete from object using integer index
  • - 'x' 只匹配顶层。删嵌套键要用 #- 路径;写在数组上是「按值删除所有匹配元素」,不是按下标。

  • 不要在同一条 UPDATE 的 SET 列表里对同一列赋值两次(如 SET settings = ..., settings = ...)。PostgreSQL 不保证取哪个表达式的结果,属于未定义行为。要把多个修改串成一个嵌套表达式。

  • jsonb_strip_nulls 不影响裸 null。整个文档是 null 时原样返回;strip_in_arrays 参数(PostgreSQL 18+)也只管数组元素,不管「文档本身是 null」。

  • jsonb_pretty 返回 text。它不能参与 jsonb 运算,写进 jsonb 列需要再 ::jsonb 转一次,且缩进会放大体积,不适合做持久化。

  • 读改写会丢更新。跨语句的「读出来改完写回」在并发下一定出问题,必须把修改压进一条 SQL,或用 SELECT ... FOR UPDATE 加行锁。

  • 更新 jsonb 列会同步维护它上面的 GIN 索引,写入代价随索引数量放大。索引不是免费的,只为真实筛选条件建。

最佳实践 ​

  • 把修改压成一条 UPDATE 表达式,永远不要在应用层「读—改—写」:

    sql
    UPDATE user_profiles
    SET settings   = jsonb_set(settings, '{theme}', '"dark"'::jsonb),
        updated_at = now()
    WHERE id = 1;
  • 「默认值 + 用户值」用 || 做顶层合并,顺序固定为「默认在左、用户在右」:

    sql
    SET settings = '{"theme": "light", "page_size": 20}'::jsonb || $1::jsonb

    但要清楚它只合并顶层;嵌套结构的默认值需要单独用 jsonb_set 补齐。

  • 需要「传 NULL 表示取消设置」的 API 语义时用 jsonb_set_lax(..., 'delete_key'),让数据库承担这个约定,避免在应用层写 if/else 分支。

  • 多字段修改用嵌套 jsonb_set 或 jsonb_build_object 构造整体新值。前者适合「保留原有其他键」,后者适合「结构由服务端完整决定」。

  • 更新后立刻在 WHERE 中确认命中行数,必要时用 RETURNING 回读校验:

    sql
    UPDATE t SET doc = jsonb_set(doc, '{a}', '1'::jsonb) WHERE id = 1 RETURNING doc;
  • 高频更新的字段抽成普通列。计数器、状态机、updated_at 这类字段放在 jsonb 里会引发整值重写;把它们提升为独立列,jsonb 只放「整体读取、整份替换」的数据。

  • 超大文档拆行/拆表。与其维护一个 500 KB 的 settings,不如按业务域拆成多行(一行一个配置域),每次只更新对应行。这直接把整值重写的影响面缩小了一个数量级。

  • 给 jsonb 列加 jsonb_typeof 检查约束,避免 || 的「对象变数组」之类意外把数据结构写坏:

    sql
    CONSTRAINT ck_settings_is_object CHECK (jsonb_typeof(settings) = 'object')
  • 用 pg_column_size 监控文档体积,在膨胀到 TOAST 之前就做拆分决策:

    sql
    SELECT id, pg_column_size(settings) AS bytes
    FROM user_profiles ORDER BY bytes DESC LIMIT 10;
  • 并发敏感场景加乐观锁。用 updated_at 或独立版本列做条件更新,冲突时重试:

    sql
    UPDATE user_profiles
    SET settings = jsonb_set(settings, '{theme}', '"dark"'::jsonb), updated_at = now()
    WHERE id = 1 AND updated_at = $expected_ts;
    -- 影响 0 行表示版本已变,交由应用层重试
  • 更新语句先在只读副本或测试库跑一次 EXPLAIN (ANALYZE, BUFFERS),确认没有意外的顺序扫描或索引维护开销。

下一节

继续学习:JSONB 的索引优化

参考链接 ​