← Back to Lab
// Backend2026.07.093 min read

SQLAlchemy + JSONB 的两个经典坑:缓存被覆盖与修改不落库

SQLAlchemy 的 JSONB 列「原地改不落库、整覆盖抹字段」两个经典坑,以及 MutableDict + 字段级 merge 的修复。

SQLAlchemy + JSONB 的两个经典坑:缓存被覆盖、修改不落库

适用场景:用 SQLAlchemy(或任何 ORM)把聚合缓存/配置存在 JSONB 列的项目。

一、两个线上怪象

  1. 缓存被覆盖:更新某一章的 AI 建议后,刷新页面发现同项目其他章的建议全没了
  2. 修改不生效:生成结果明明写回了,刷新页面又是旧的;直连 DB 查,字段压根没变。

两者都和「用 JSONB 列存一份聚合缓存」有关。

二、根因:JSONB 不检测"原地改"

SQLAlchemy 的 JSONB 把列值当成普通 Python 对象。如果你拿到已加载的 dict,原地改了它setattr同一个对象引用,ORM 认为"引用没变" → 不标记 dirtycommit() 不 flush。

# 错误写法 A:原地改 + 同对象 setattr → ORM 认为没变 → 不落库
cache = project.cache or {}        # 拿到已加载的同一 dict 对象
cache[key] = value                 # 原地改
await crud_update(db, project, cache=cache)   # 赋回同一对象 → 跳过 flush

而"整体覆盖"的写法会把别的 key 抹掉:

# 错误写法 B:整覆盖 → 其他 key 全没
await crud_update(db, project, cache={key: value})

三、根因级修复:MutableDict + 字段级 merge

让 ORM 感知原地改——用 MutableDict 包装 JSONB:

from sqlalchemy.ext.mutable import MutableDict
from sqlalchemy import JSONB

class Project(Base):
    cache = mapped_column(MutableDict.as_mutable(JSONB))

启用后,cache["x"] = 1 这类原地改会自动标 changed()commit 正常 flush,无需 alembic 迁移(仅 ORM 侧类型包装,列类型不变)。

写路径保留"构造新对象"作为双保险,并用 Postgres || 运算符做字段级合并而非整行覆盖:

def _merge_patch_jsonb_column(stmt, column, key, value):
    # UPDATE t SET column = column || '{"key": value}'::jsonb WHERE ...
    return stmt.values(**{column: text(f"{column} || :patch")})

四、复盘

  • 别用 update(...) 整覆盖 JSONB,会抹掉其他字段;
  • 要么 MutableDict(让 ORM 感知原地改),要么字段级 || merge(不碰其他 key);
  • 生产写路径两层防御都留着,互不冲突,任一即可保证落库。

JSONB + ORM 的"可变对象不脏"是经典坑,最佳实践是 MutableDict(或字段级 merge),而不是事后打补丁式地"记得换新对象"。