接口返回成功,主表记录也还在,但关联明细突然清空。遇到这种现象,先找到实际执行的 SQL:如果所谓“更新”用了 INSERT OR REPLACE,就要沿着删除路径排查。

本文用一个订单与明细模型复现问题,再把写入改成有明确冲突目标的 UPSERT。实验只使用内存数据库,不连接生产服务。

适用场景与现象

适用于 SQLite 本地业务库、桌面应用或同步程序:主表存在唯一业务键,明细通过外键关联,删除主表时按业务设计级联删除明细。同步程序希望“存在则更新,不存在则插入”。

典型现象是:同步前订单有明细,同步后订单名称更新成功、明细数量变为零,没有 SQL 异常。甚至重新写回相同的主键也不能避免这个问题。

这篇文章讨论的是冲突写入的删除语义。连接已开启外键校验,外键本身按设计工作。

原因:REPLACE 会删除发生唯一冲突的旧行

SQLite 的 REPLACE 在主键或唯一键冲突时,会先删除导致冲突的已有行,再继续写入。它不能等同于原地修改字段。这个删除步骤可能触发明细表的 ON DELETE CASCADE。详见 SQLite 冲突处理文档。

因此需要分别确认两个问题:写入是否替换了主表行;外键的删除动作是否清理了关联行。最终主键值相同,也不能证明中间没有发生删除。

用一个可重复实验把链路跑出来

将下面完整代码保存为 sqlite_replace_demo.py,使用 Python 3.10 以上运行;底层 SQLite 需至少 3.24.0。代码每次建立独立内存库,不修改磁盘文件。

import sqlite3
import unittest
from contextlib import closing


SCHEMA = """
CREATE TABLE orders (
    id INTEGER PRIMARY KEY,
    order_key TEXT NOT NULL UNIQUE,
    display_name TEXT NOT NULL
);
CREATE TABLE order_items (
    id INTEGER PRIMARY KEY,
    order_id INTEGER NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
    quantity INTEGER NOT NULL CHECK (quantity > 0)
);
CREATE INDEX order_items_order_id_idx ON order_items(order_id);
"""

UPSERT = """
INSERT INTO orders (order_key, display_name)
VALUES (?, ?)
ON CONFLICT (order_key) DO UPDATE
SET display_name = excluded.display_name
"""


def create_database() -> sqlite3.Connection:
    """创建独立实验库,先开启外键,再创建并填充数据。"""
    connection = sqlite3.connect(":memory:")
    connection.execute("PRAGMA foreign_keys = ON")
    if connection.execute("PRAGMA foreign_keys").fetchone() != (1,):
        connection.close()
        raise RuntimeError("实验连接未开启外键校验")
    connection.executescript(SCHEMA)
    with connection:
        connection.execute(
            "INSERT INTO orders VALUES (?, ?, ?)", (10, "order-a", "原名称")
        )
        connection.execute("INSERT INTO order_items VALUES (?, ?, ?)", (1, 10, 2))
    return connection


def read_state(connection: sqlite3.Connection) -> tuple[list[tuple], list[tuple]]:
    """同时读取主表身份和明细,避免只检查写入返回值。"""
    orders = connection.execute(
        "SELECT id, order_key, display_name FROM orders ORDER BY id"
    ).fetchall()
    items = connection.execute(
        "SELECT id, order_id, quantity FROM order_items ORDER BY id"
    ).fetchall()
    return orders, items


class ReplaceTests(unittest.TestCase):
    def test_replace_same_primary_key_deletes_items(self) -> None:
        with closing(create_database()) as connection:
            with connection:
                connection.execute(
                    "INSERT OR REPLACE INTO orders VALUES (?, ?, ?)",
                    (10, "order-a", "新名称"),
                )
            self.assertEqual(read_state(connection), ([(10, "order-a", "新名称")], []))
            self.assertEqual(
                connection.execute("PRAGMA foreign_key_check").fetchall(), []
            )

    def test_upsert_preserves_identity_and_items(self) -> None:
        with closing(create_database()) as connection:
            with connection:
                connection.execute(UPSERT, ("order-a", "新名称"))
            self.assertEqual(
                read_state(connection),
                ([(10, "order-a", "新名称")], [(1, 10, 2)]),
            )

    def test_upsert_inserts_new_order(self) -> None:
        with closing(create_database()) as connection:
            with connection:
                connection.execute(UPSERT, ("order-b", "新订单"))
            orders, items = read_state(connection)
            self.assertEqual(len(orders), 2)
            self.assertEqual(orders[0], (10, "order-a", "原名称"))
            self.assertEqual(orders[1][1:], ("order-b", "新订单"))
            self.assertEqual(items, [(1, 10, 2)])

    def test_repeat_upsert_keeps_one_order(self) -> None:
        with closing(create_database()) as connection:
            with connection:
                for _ in range(3):
                    connection.execute(UPSERT, ("order-a", "相同名称"))
            self.assertEqual(
                read_state(connection),
                ([(10, "order-a", "相同名称")], [(1, 10, 2)]),
            )

    def test_invalid_input_does_not_damage_existing_data(self) -> None:
        with closing(create_database()) as connection:
            before = read_state(connection)
            with self.assertRaises(sqlite3.IntegrityError):
                with connection:
                    connection.execute(UPSERT, ("order-a", None))
            self.assertEqual(read_state(connection), before)

    def test_rollback_restores_replaced_order_and_items(self) -> None:
        with closing(create_database()) as connection:
            before = read_state(connection)
            connection.execute("BEGIN")
            connection.execute(
                "INSERT OR REPLACE INTO orders VALUES (?, ?, ?)",
                (10, "order-a", "待回滚名称"),
            )
            self.assertEqual(read_state(connection)[1], [])
            connection.rollback()
            self.assertEqual(read_state(connection), before)


if __name__ == "__main__":
    print(f"SQLite 运行库版本:{sqlite3.sqlite_version}")
    unittest.main(verbosity=2)

执行命令:

python .\sqlite_replace_demo.py

第一项测试确认故障确实发生:显式写回主键 10,订单仍为 10,明细却为空。第二项测试验证修复后订单身份及明细同时保留。剩余测试检查新增、重复同步、无效输入,以及未提交时的回滚恢复。

注意第一项测试中的 foreign_key_check 返回空列表:级联删除后没有孤儿行,数据库仍满足外键约束。这项检查不能证明业务明细没有丢失,必须另外核对业务数量或具体记录。

现场排查:先核对 SQL,再核对约束

在实际发生写入的连接上执行以下只读诊断语句;表名按真实结构调整:

SELECT sqlite_version();
PRAGMA foreign_keys;
PRAGMA foreign_key_list(order_items);
PRAGMA index_list(orders);
SELECT type, name, sql
FROM sqlite_schema
WHERE tbl_name IN ('orders', 'order_items')
ORDER BY type, name;
SELECT id, order_key, display_name FROM orders WHERE order_key = 'order-a';
SELECT id, order_id, quantity FROM order_items WHERE order_id = 10;
PRAGMA foreign_key_check;

重点看 foreign_keys 是否为 1,以及 foreign_key_list 的 on_delete 是否为 CASCADE。同时检查表和触发器定义,确认是否还有其他删除路径。SQLite 外键动作的语义见 外键文档。

从应用仓储方法、ORM 生成 SQL 或已有的脱敏 SQL 记录中定位 REPLACE、INSERT OR REPLACE;不要只依据方法名中的 save、update 判断执行语义。现场日志不要打印原始订单内容或敏感参数。

修复:明确冲突键,只更新允许覆盖的字段

示例 UPSERT 以 order_key 为业务身份,冲突时只更新 display_name。excluded.display_name 表示本次待写入的新值,原有 id 保留,因此这个更新不会走父行删除路径。语法及约束范围见 SQLite UPSERT 文档。

迁移前核对以下条件:

  • order_key 确实有唯一约束。如果业务身份由租户和订单号共同组成,应建立对应联合唯一约束,并让冲突目标完全匹配。
  • 不把主键、创建时间等字段顺手放入更新列表。实际业务中的状态字段还需要独立的状态流转校验。
  • 保留参数绑定;不要把外部输入拼接进 SQL。接口层还应按业务限制字段类型、长度和权限。
  • 其他唯一约束、非空约束及外键错误仍需处理。UPSERT 不能当作通用的“忽略所有错误”。

如果业务明确要求“必须已有记录才能修改”,参数化 UPDATE ... WHERE ... 更符合意图,再依据受影响行数处理不存在的订单。不要先查有没有、再在没有事务保障的情况下决定插入或更新。

已丢失数据怎样处理

事务尚未提交时,可以像第六项测试一样回滚,主表变化与级联删除一起撤销。提交后再调用 rollback() 不能恢复之前的明细。

已经提交的事故应先停止触发问题的同步入口,保留现场并制作可恢复备份,再从经过校验的备份或权威上游恢复明细。恢复时核对订单身份、明细数量和业务汇总,并执行外键检查;先在副本演练,避免重复导入。不要凭主表金额推造明细。

发布修复时先在数据库副本运行回归,再用少量真实结构的数据观察。代码回退如果重新启用 REPLACE,会重新引入删除风险;回退期间应暂停相关写入入口,而不是继续使用旧逻辑同步。

防止复发与验证边界

代码评审中检查主表写入是否出现替换语句;回归断言同时检查主键、关联行和业务字段,而不是只判断“调用未抛异常”。保留正常删除订单时的级联设计,不要通过关闭外键掩盖错误写法。

本文在 Python 3.14.6、SQLite 3.50.4 上执行上述六项测试,使用独立内存数据库。它验证了 SQL 语义及事务内回滚,没有验证生产 ORM、磁盘恢复、并发同步或其他数据库的同名语法。接入真实项目后,还应覆盖该项目的触发器、其他唯一键及实际事务边界。

问题的关键是写入语句表达的业务动作:保持订单身份的字段更新,应采用明确的更新语义,并把明细保留作为回归条件。