开发 / 2026-03-22

我花了一整晚 Debug 一个 SQLite 的"默认行为"

Akanyi Akanyi
61 5 min read

记一次从 SQLAlchemy UOW 怀疑人生,到发现 SQLite 外键根本没开的荒诞经历。

症状

事情是这样的。我的项目 CommandLab 有一个函数上传接口,用户可以上传 .mcd 命令脚本文件到私有库。第一次上传没问题,但是当用户更新同一个函数的时候,后端直接炸了:

sqlite3.IntegrityError: UNIQUE constraint failed: metadata.function_id, metadata.key
[SQL: INSERT INTO metadata (function_id, "key", value) VALUES (?, ?, ?)]
[parameters: (8, 'name', 'A function name')]

metadata 表对 (function_id, key)有唯一约束。报错说 function_id=8, key='name' 已经存在了,你不能再插一行。

看起来很简单对吧?旧的 metadata 没删干净呗。

但事情远没有这么简单。

第一轮:怀疑 SQLAlchemy 的 Unit of Work

我的第一反应是:SQLAlchemy 的 UOW(Unit of Work)在 flush/commit 的时候执行 SQL 的顺序有问题。 也许它先 INSERT 了新的 metadata,然后才去 DELETE 旧的?

于是我尝试了:

# 方案1:用 raw SQL DELETE 强制先删
await session.execute(delete(FunctionMetadata).where(...))
await session.flush()
# 然后再 INSERT 新的

还是炸。

# 方案2:用 ORM 集合替换
existing_private.metadata_items.clear()
await session.flush()
existing_private.metadata_items = [FunctionMetadata(...), ...]

还是炸。

# 方案3:完全不删不增,原地修改 value
current_meta_map = {m.key: m for m in existing_private.metadata_items}
for key, value in metadata.items():
    if key in current_meta_map:
        current_meta_map[key].value = value  # 只改 value
    else:
        session.add(FunctionMetadata(...))

依!然!炸!

我开始怀疑人生。明明是 In-Place 修改,压根没有 DELETE 和 INSERT,为什么还是 UNIQUE constraint failed

第二轮:发现 session.refresh() 在异步环境下的诡异行为

在第三个方案里,我用了 await session.refresh(existing_private, ["metadata_items"]) 来加载关联集合。

但在 async + aiosqlite 环境下,refresh之后访问 existing_private.metadata_items,它没有报错,也没有触发 lazy load,而是静悄悄地返回了一个空列表

所以 current_meta_map 是空的 → 所有 key 都走了 session.add(新对象) → 和数据库已有的行撞车 → 炸

我改成了显式 select() 查询:

stmt = select(FunctionMetadata).where(FunctionMetadata.function_id == existing_private.id)
existing_metas = (await session.execute(stmt)).scalars().all()

本地测试通过了!我信心满满地部署到生产环境。

结果还是炸。

第三轮:把生产数据库拷下来

这时候我终于做了一件早就该做的事——把生产环境的 commandlab.db 拷到本地来看。

-- 查 function_id=8 的 metadata
SELECT * FROM metadata WHERE function_id = 8;
-- 结果:5 行,name/note/tags/uuid/version,每个 key 只有一行
-- 数据很干净啊?

-- 那 function_id=8 的函数本体呢?
SELECT * FROM functions WHERE id = 8;
-- 结果:
-- (空)

等等,什么??

SELECT MAX(id) FROM functions;
-- 7

SELECT id, function_id, key FROM metadata 
WHERE function_id NOT IN (SELECT id FROM functions);
-- 34  8  name
-- 37  8  note
-- 36  8  tags
-- 38  8  uuid
-- 35  8  version

function_id=8 的函数已经被删了,但它的 metadata 还活着。

这就是所谓的"孤儿数据"。当新函数被创建时,SQLite 重用了 id=8(因为没用 AUTOINCREMENT),然后插入 metadata 的时候就和这些孤儿撞上了。

但是,我明明写了 CASCADE 啊??

class FunctionMetadata(Base):
    function_id = mapped_column(
        Integer, ForeignKey("functions.id", ondelete="CASCADE"), nullable=False
    )

白纸黑字,ondelete="CASCADE"。删除 function 的时候,metadata 应该自动跟着删啊?

我又去看了 session.py:

async def init_database():
    _engine = create_async_engine(f"sqlite+aiosqlite:///{DB_PATH}")
    async with _engine.begin() as conn:
        await conn.execute(text("PRAGMA foreign_keys = ON"))  # ← 你看,写了!
        await conn.run_sync(Base.metadata.create_all)

写了 PRAGMA foreign_keys = ON 啊,没毛病啊?

有毛病。

SQLite 的惊天大坑:PRAGMA 是连接级别的

在 SQLite 中,PRAGMA foreign_keys = ON 只对当前连接生效

上面的代码在 init_database() 里用 engine.begin() 拿了一个连接,设置了 PRAGMA,创建了表,然后这个连接就还回连接池了

之后所有 API 请求拿到的都是新的连接,它们的 foreign_keys 依然是 OFF

也就是说:

从项目第一天起,你的 ondelete="CASCADE" 就从来没有生效过。

每次删除 function 的时候,metadata 行都安安静静地留在那里,变成一颗颗定时炸弹,等着某天 SQLite 重用了那个 ID,然后……炸

修复

修复本身反而是最简单的部分:

from sqlalchemy import event

@event.listens_for(_engine.sync_engine, "connect")
def _set_sqlite_pragma(dbapi_conn, connection_record):
    cursor = dbapi_conn.cursor()
    cursor.execute("PRAGMA foreign_keys=ON")
    cursor.close()

用 SQLAlchemy 的 event.listens_for("connect") 钩子,在每个新连接建立时都执行 PRAGMA。这是 SQLAlchemy 官方文档 推荐的做法。

然后清理孤儿数据:

DELETE FROM metadata WHERE function_id NOT IN (SELECT id FROM functions);
-- 5 rows deleted

搞定。

回顾

我以为的问题 实际的问题
SQLAlchemy UOW 执行顺序不对 和 UOW 毫无关系
session.refresh() 异步环境有 bug 确实有坑,但不是根因
ORM 集合操作不可靠 集合本身没问题
需要升级 ORM 版本 不需要
SQLite 外键没开 就是这个

一行 PRAGMA,五条孤儿,一整晚的 debug。

教训

  1. SQLite 的外键约束默认是关闭的。 这不是 bug,这是"feature"。每个新连接都需要手动开启。
  2. PRAGMA 是连接级别的,不是数据库级别的。 在连接池环境下,你必须用 event listener 来确保每个连接都正确配置。
  3. 当 ORM 层面的所有尝试都失败时,去看看数据库本身。 我在 SQLAlchemy 的 UOW 逻辑上浪费了大量时间,而真正的答案一条 SELECT 就能发现。

下次再遇到 SQLite + ondelete="CASCADE" 不生效的时候,先查 PRAGMA foreign_keys

别问我怎么知道的。

:wq

评论区

发表评论

暂无评论。来抢沙发吧!