← Back to Lab
// Backend2026.07.093 min read
SQLAlchemy + JSONB 的两个经典坑:缓存被覆盖与修改不落库
SQLAlchemy 的 JSONB 列「原地改不落库、整覆盖抹字段」两个经典坑,以及 MutableDict + 字段级 merge 的修复。
SQLAlchemy + JSONB 的两个经典坑:缓存被覆盖、修改不落库
适用场景:用 SQLAlchemy(或任何 ORM)把聚合缓存/配置存在 JSONB 列的项目。
一、两个线上怪象
- 缓存被覆盖:更新某一章的 AI 建议后,刷新页面发现同项目其他章的建议全没了。
- 修改不生效:生成结果明明写回了,刷新页面又是旧的;直连 DB 查,字段压根没变。
两者都和「用 JSONB 列存一份聚合缓存」有关。
二、根因:JSONB 不检测"原地改"
SQLAlchemy 的 JSONB 把列值当成普通 Python 对象。如果你拿到已加载的 dict,原地改了它再 setattr 回同一个对象引用,ORM 认为"引用没变" → 不标记 dirty → commit() 不 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),而不是事后打补丁式地"记得换新对象"。