SQLAlchemy 更新 JSONB 后未传字段丢失:UPDATE 不会自动深度合并
局部请求被当成整个 JSONB 值保存时,区分整列替换、路径更新与浅层连接,并为应用读改写保留行锁或版本冲突处理。
应用通过 SQLAlchemy 更新 PostgreSQL 的 JSONB 列,只提交一个嵌套字段,保存后同一对象里的其他字段却不见了。即使请求模型已经排除未提供的字段,UPDATE ... SET form = :payload 仍然是在替换 form 整列的值,不会自动把请求递归合并到已有 JSON。
先确认需求是替换整份配置,还是修改其中一个路径。两种接口不能共用一套“只传需要改的字段”解释,否则应用会把局部对象当成完整文档保存。以下针对普通 PostgreSQL JSONB 列,以及 SQLAlchemy 2.0 的直接 UPDATE 或 ORM 更新路径。
查看传给数据库的是整个值还是路径操作
例如,数据库里原本保存:
{
"attributes": {
"attribute_1": "old",
"attribute_2": "keep"
},
"action": "ACTIVE"
}
请求只带 {"attributes":{"attribute_1":"new"}}。把它直接赋给 form,保存结果就只剩这份对象;没有被传入的内容不会因为“客户端没有要求删除”而自动保留。
在受控环境检查实际 SQL 的结构和更新参数,确认是不是给整列赋值。不要在公开日志里输出业务配置或凭据。Pydantic 的排除规则只决定发送哪些字段,不能让数据库里的一个 JSONB 值自动获得 PATCH 语义。
同样要区分另一类问题:原地修改 ORM 对象的嵌套字典,提交后数据库根本没变。这可能涉及变更跟踪;而直接 UPDATE 已经发出、结果被替换,是更新语义问题。MutableDict 不会让直接 UPDATE 自动深度合并,而且默认也不递归跟踪字典内部的所有可变值。
固定路径可以在数据库里修改
只更新已知路径时,可以使用 PostgreSQL 的 jsonb_set。下面是说明结构的 SQL 示例,假设 tasks.form 是非空 JSON 对象,attributes 已存在且是对象,编号 42 是确认过的目标行:
UPDATE tasks
SET form = jsonb_set(
form,
'{attributes,attribute_1}',
'"new"'::jsonb,
true
)
WHERE id = 42
RETURNING form;
新值是 JSON 字符串,因此示例写成包含双引号的 JSONB 值。数字、布尔值和对象使用各自的合法 JSON 表示,不能把所有输入都拼成未经转义的字符串。应用中使用绑定参数,不要直接把用户提供的路径或内容拼进 SQL。
最后一个参数允许创建缺失的末级成员,但路径的前置层级必须存在;attributes 根本不存在时,这条写法不会自动创建整条嵌套结构。列为 SQL NULL、父节点类型不符或需要处理数组时,也要单独定义策略。检查返回结果,不能只因执行没有报错就报告修改成功。
SQLAlchemy 可以表达相应的数据库函数,但应按实际驱动给路径传 text[]、给新值传 JSONB,核对生成 SQL 的参数类型。不能省掉必需参数或把普通字符串、布尔值误传到函数的其他位置,再用重装驱动解决函数签名不匹配。
路径更新让数据库处理当前行的 JSON 值,避免应用先取出整份旧文档再覆盖回去。不过,同一路径的并发业务操作仍需要冲突策略;金额、计数或有版本约束的更新不能仅靠最后一次赋值解决。
JSONB 的连接操作不是递归合并
把 form || patch 当作深度合并也会丢字段。JSONB 对象连接在顶层合并键;遇到重复键,采用右侧的值,不会继续递归合并这个键内部的对象。
如果右侧只给了一个新的 attributes 对象,它会替换左侧整个 attributes,旧的 attribute_2 仍会丢失。其他顶层键可能还在,所以结果看起来像“只坏了部分字段”。这与 jsonb_set 修改一条路径是不同语义。
任意层级的补丁还需要明确数组是替换还是追加、JSON null 是保留还是删除、空对象代表什么。先制定业务规则,再选择经过审查的数据库表达式或合并实现,不要直接把一段递归代码当成所有 JSON 更新的通用答案。
必须在应用合并,先保护读改写过程
应用可以读取旧 JSON,按自己的规则合并,再保存完整的新值,但普通 SELECT 不会锁住这行。两个请求读到同一份旧文档后各自保存,后提交者可能覆盖另一方已经写入的内容。
若采用行锁,读取、合并和写回应位于同一事务内,使用目标行的 SELECT FOR UPDATE;SQLAlchemy 对应 with_for_update。锁会影响其他写入,事务应尽量短,并由应用处理超时、回滚与重试。不要在读完以后才开启事务,也无需通过无意义 UPDATE 代替正常的锁定读取。
另一种方案是使用版本字段做乐观并发检查:只有记录版本仍符合预期才更新,冲突时重新读取并按业务规则处理。版本校验必须覆盖这份文档的实际写入入口,不能只在其中一个接口里检查。
修改前备份相关数据和原更新逻辑,在隔离副本覆盖未传字段保留、明确删除、缺失父路径、数组及并发场景。验收时对比完整 JSON 和预期变化,而不仅是被改的一个字段。
若旧接口已经覆盖了其他内容,修复代码不会找回丢失数据。应依据保存的历史、备份或业务记录恢复,避免把当前残缺对象当成正确基线继续合并;应用回退也不撤销已经提交的更新。


