qinghuan
← 回到知识探索

后端现场 / 动手实验

只是改个名称,为什么关联明细全没了?

在四份独立 SQLite 小库中比较 REPLACE、UPSERT 与 UPDATE:父记录编号一样,子记录和未提供的字段却可能完全不同。

展开本文目录
  1. 下载完整练习包
  2. 先写出允许改变的内容,再选择保存语句
  3. REPLACE 同一编号,仍然可能经历删除再插入
  4. 没有提供的备注,也不是自动沿用旧值
  5. 需要存在则更新时,把冲突分支写清楚
  6. 同一条 UPSERT,没有冲突时会真正新增
  7. 运行完整代码,同时检查父表与子表
  8. 让保存方法的名字对应真实的数据变化
  9. 记住这三点
  10. 别把例子用过头
  11. 自己试一小步
  12. 查原始资料
开始前,会这些就够了
  • 能读懂 INSERT、UPDATE 与主键约束。
  • 知道子表可以通过外键关联父表。
  • Python 3.10+,SQLite 3.24.0+;仅标准库,使用全新内存数据库。

你想把一个资料夹改名,保存后新名称出现了,编号也没变,可里面的明细全部消失。检查接口,它返回成功,影响行数是一。问题可能藏在“有就替换”的具体写法:SQLite 的 INSERT OR REPLACE 遇到主键冲突,并不等同于只修改原行。

本实验让父记录始终从编号一开始,关联一条子记录,并给父记录加一段应该保留的备注。四种保存路径各用一份全新内存库,对比完整前后状态,产出一张能看见关联与遗漏字段的变化表。

把例子带到自己的电脑上

下载完整练习包

需要 Python 3.10 或更新版本,无第三方依赖;制作时实测 Python 3.14.0;内置 SQLite 需 3.24.0 或更新版本。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。

下载 ZIP · 5 个文件
包里有什么
  • replace_versus_upsert.py:完整示例
  • README.md:环境、范围与独立练习
  • observed.stdout.txt:制作时的实际输出
  • verification.json:源码与产物验证记录
  • SHA256SUMS.txt:文件内容摘要

在这个文件夹打开 PowerShell,逐行运行

python --version
python -B replace_versus_upsert.py

解压到独立练习文件夹,在含源文件的目录打开终端运行。程序会在当前目录写入教学报告;先预测结果,再查看当次输出。四个独立的真实 SQLite 内存数据库,启用外键并核对级联删除与两种 UPSERT 分支;不代表其他数据库或线上业务。

找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。

先写出允许改变的内容,再选择保存语句

假设本次操作只允许把 old-name 改成 new-name。编号一应保留,备注 keep-note 应保留,子记录十仍应指向它。这三项都是验收要求;只查询名称是否更新,会把最关键的副作用藏起来。

父表有 id、name、note 三列,备注的默认值是 default-note。子表通过 parent_id 关联父表,并明确配置 ON DELETE CASCADE:父行被删除时,依赖它的子行也删除。每个案例都启用并核对外键检查,不比较开关不同造成的结果。

程序把四种方式放到相同初态,各自执行一次,再保存 before 与 after。每份内存库结束后关闭,下一组重新建库。这样前一组删掉的子行不会让后一组失去比较基础,也不会将连续操作误写成独立对照。

REPLACE 同一编号,仍然可能经历删除再插入

第一组执行 INSERT OR REPLACE,只提供编号一与新名称。编号一已经存在,触发主键冲突;SQLite 的 REPLACE 策略会先删除造成该冲突的既有行,再继续插入。它不是把这条语句自动改写成只更新 name 的 UPDATE。

最终父表里又有编号一,名称也正确,但 child 已经为空。旧父行删除时发生了我们明确配置的级联删除;后来再插入相同编号,不会把已经删除的子记录自动恢复。最终编号相同,无法独立证明中间没有发生删除。

这个结论依赖实际表的关联动作。若换成限制删除的规则,结果可能是失败,而不是删除子行。本篇没有宣称所有 REPLACE 都会删除所有关联数据;它展示的是一份具体且常见配置下,为什么改名接口会产生出乎意料的影响。

没有提供的备注,也不是自动沿用旧值

第一组父记录的备注从 keep-note 变成 default-note。原因与前面相连:执行的是一条新插入路径,语句没有提供 note,于是使用表中声明的默认值。把语句字段少理解成“只修改这几个字段”,在这里就会判断错误。

我们特意把旧备注设成与默认值不同。如果初始备注碰巧就等于默认值,即使经历同样替换,单看最终文字也不容易发现变化。设计验证样本时,应让需要保留的字段具有能识别的原值,而不是全部填空或默认内容。

默认值也不代表业务上正确。它只是建表时安排的填充值,可能不适合一次已有记录的更新。选择保存策略前,除了主键和必填字段,还应列出哪些列本次未提供,以及它们应保留、清空还是采用默认值。

需要存在则更新时,把冲突分支写清楚

第二组使用 INSERT 后的 ON CONFLICT(id) DO UPDATE,冲突目标明确为 id,更新列表只有 name=excluded.name。excluded.name 表示这次原本准备插入的新名称;发生冲突后,更新分支把它用于现有行。结果是名称变了,备注与子记录都保留。

第三组直接用 UPDATE 改已有行,得到相同的业务状态。这组对照提醒我们:如果操作只允许修改已存在对象,未必需要引入“没有就新增”的路径。选 UPDATE 还是 UPSERT,先由接口是否允许创建决定,而不是因为后者写法看起来更通用。

UPSERT 也不是“遇到任何错误都修好”。冲突目标针对唯一性约束,更新分支只执行写出来的修改;其他约束失败不会因此凭空变成成功。本例没有改父键,没有复杂触发器,也没有比较多条冲突分支,不能扩大成所有 UPSERT 都没有关联副作用。

同一条 UPSERT,没有冲突时会真正新增

第四组仍用相同 UPSERT 语句,但提供不存在的编号二。这次没有进入更新分支,而是多出一条父记录二,它的备注采用 default-note;原编号一的旧名称、旧备注和子记录十保持原样。

这说明只测“已存在”的那一半,不足以验收一个插入或更新接口。两条路径对遗漏字段可能使用不同依据:本例更新分支保留旧备注,新增分支使用默认备注。调用者应知道这两个结果都属于接口承诺。

四组的 foreign_key_check 都为空,包括第一组删掉子行的情况。没有孤儿记录,只证明关联约束没有被破坏;它不能证明用户希望保留的明细还在。约束检查和业务记录保留是不同的验收项,两者都要观察。

运行完整代码,同时检查父表与子表

将下方代码保存为 replace_versus_upsert.py,在同目录运行。程序只创建四份新的内存数据库,正常结束时关闭连接,不接受业务数据库路径。脚本旁的 replace-output 会生成 report.json 与 effects.csv,再次运行时更新同名教学产物。

本次在 Python 3.14.0、SQLite 3.50.4 实际通过。先预测四组的父行、备注与子行,再核对完整列表。replace_same_id 的子表为空且备注变为默认值;upsert_existing 与 update_existing 保留关联;upsert_new 同时保留原行并新增另一行。

脚本还在每次写入后立即执行 SELECT changes(),四组都得到一。这个计数只统计相应语句的直接变化,不把外键动作和 REPLACE 冲突处理的附带变化一并计算。因此返回一行,不能替代对相关表的结果检查。

展开收起代码 · 小实验python · 78 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

小实验 · python
"""SQLite replacement vs update: fresh in-memory fixtures, no real records."""
from contextlib import closing
from pathlib import Path
import csv
import json
import sqlite3
import sys


def connect():
    options = {"isolation_level": None}
    if sys.version_info >= (3, 12):
        options["autocommit"] = sqlite3.LEGACY_TRANSACTION_CONTROL
    db = sqlite3.connect(":memory:", **options)
    try:
        db.execute("PRAGMA foreign_keys = ON")
        assert db.execute("PRAGMA foreign_keys").fetchone() == (1,)
        db.execute("CREATE TABLE parent(id INTEGER PRIMARY KEY, name TEXT NOT NULL, note TEXT NOT NULL DEFAULT 'default-note')")
        db.execute("CREATE TABLE child(id INTEGER PRIMARY KEY, parent_id INTEGER NOT NULL REFERENCES parent(id) ON DELETE CASCADE)")
        db.execute("INSERT INTO parent VALUES (?, ?, ?)", (1, "old-name", "keep-note"))
        db.execute("INSERT INTO child VALUES (?, ?)", (10, 1))
        return db
    except BaseException:
        db.close()
        raise


def snapshot(db):
    return {"parents": [list(r) for r in db.execute("SELECT id, name, note FROM parent ORDER BY id")],
            "children": [list(r) for r in db.execute("SELECT id, parent_id FROM child ORDER BY id")],
            "foreign_key_violations": [list(r) for r in db.execute("PRAGMA foreign_key_check")]}


def main():
    assert sqlite3.sqlite_version_info >= (3, 24, 0), "UPSERT requires SQLite 3.24.0+"
    upsert = "INSERT INTO parent(id, name) VALUES (?, ?) ON CONFLICT(id) DO UPDATE SET name=excluded.name"
    cases = [
        ("replace_same_id", "INSERT OR REPLACE INTO parent(id, name) VALUES (?, ?)", (1, "new-name"),
         [[1, "new-name", "default-note"]], []),
        ("upsert_existing", upsert, (1, "new-name"), [[1, "new-name", "keep-note"]], [[10, 1]]),
        ("update_existing", "UPDATE parent SET name=? WHERE id=?", ("new-name", 1),
         [[1, "new-name", "keep-note"]], [[10, 1]]),
        ("upsert_new", upsert, (2, "new-name"),
         [[1, "old-name", "keep-note"], [2, "new-name", "default-note"]], [[10, 1]]),
    ]
    records = []
    for label, statement, parameters, expected_parents, expected_children in cases:
        with closing(connect()) as db:
            before = snapshot(db)
            db.execute(statement, parameters)
            direct_changes = db.execute("SELECT changes()").fetchone()[0]
            after = snapshot(db)
            assert direct_changes == 1
            assert after["parents"] == expected_parents, (label, after)
            assert after["children"] == expected_children, (label, after)
            assert after["foreign_key_violations"] == []
            assert db.execute("PRAGMA foreign_keys").fetchone() == (1,)
            records.append({"case": label, "sql": statement, "parameters": parameters,
                            "direct_changes": direct_changes, "before": before, "after": after})
    report = {"python": sys.version.split()[0], "sqlite": sqlite3.sqlite_version,
              "scope": "four isolated real SQLite in-memory databases, foreign_keys ON throughout; no concurrency or user-defined triggers",
              "cases": records}
    out = Path(__file__).resolve().parent / "replace-output"
    out.mkdir(exist_ok=True)
    (out / "report.json").write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
    with (out / "effects.csv").open("w", encoding="utf-8-sig", newline="") as file:
        writer = csv.DictWriter(file, fieldnames=["case", "direct_changes", "parents", "children", "violations"])
        writer.writeheader()
        for row in records:
            writer.writerow({"case": row["case"], "direct_changes": row["direct_changes"],
                "parents": json.dumps(row["after"]["parents"]), "children": json.dumps(row["after"]["children"]),
                "violations": json.dumps(row["after"]["foreign_key_violations"])})
    print(json.dumps(report, indent=2))
    print("PASS: same-ID replacement, cascading children, omitted defaults, upsert branches and direct-change counts")


if __name__ == "__main__":
    main()

怎样运行:python -B replace_versus_upsert.py(Python 3.10+,仅标准库;在解压后的脚本目录执行。) 2026-10-15 已用 Python 3.14.0 执行这份代码并通过自带检查;下方是当次输出。四个独立的真实 SQLite 内存数据库,启用外键并核对级联删除与两种 UPSERT 分支;不代表其他数据库或线上业务。

运行后对照结果
{
  "python": "3.14.0",
  "sqlite": "3.50.4",
  "scope": "four isolated real SQLite in-memory databases, foreign_keys ON throughout; no concurrency or user-defined triggers",
  "cases": [
    {
      "case": "replace_same_id",
      "sql": "INSERT OR REPLACE INTO parent(id, name) VALUES (?, ?)",
      "parameters": [
        1,
        "new-name"
      ],
      "direct_changes": 1,
      "before": {
        "parents": [
          [
            1,
            "old-name",
            "keep-note"
          ]
        ],
        "children": [
          [
            10,
            1
          ]
        ],
        "foreign_key_violations": []
      },
      "after": {
        "parents": [
          [
            1,
            "new-name",
            "default-note"
          ]
        ],
        "children": [],
        "foreign_key_violations": []
      }
    },
    {
      "case": "upsert_existing",
      "sql": "INSERT INTO parent(id, name) VALUES (?, ?) ON CONFLICT(id) DO UPDATE SET name=excluded.name",
      "parameters": [
        1,
        "new-name"
      ],
      "direct_changes": 1,
      "before": {
        "parents": [
          [
            1,
            "old-name",
            "keep-note"
          ]
        ],
        "children": [
          [
            10,
            1
          ]
        ],
        "foreign_key_violations": []
      },
      "after": {
        "parents": [
          [
            1,
            "new-name",
            "keep-note"
          ]
        ],
        "children": [
          [
            10,
            1
          ]
        ],
        "foreign_key_violations": []
      }
    },
    {
      "case": "update_existing",
      "sql": "UPDATE parent SET name=? WHERE id=?",
      "parameters": [
        "new-name",
        1
      ],
      "direct_changes": 1,
      "before": {
        "parents": [
          [
            1,
            "old-name",
            "keep-note"
          ]
        ],
        "children": [
          [
            10,
            1
          ]
        ],
        "foreign_key_violations": []
      },
      "after": {
        "parents": [
          [
            1,
            "new-name",
            "keep-note"
          ]
        ],
        "children": [
          [
            10,
            1
          ]
        ],
        "foreign_key_violations": []
      }
    },
    {
      "case": "upsert_new",
      "sql": "INSERT INTO parent(id, name) VALUES (?, ?) ON CONFLICT(id) DO UPDATE SET name=excluded.name",
      "parameters": [
        2,
        "new-name"
      ],
      "direct_changes": 1,
      "before": {
        "parents": [
          [
            1,
            "old-name",
            "keep-note"
          ]
        ],
        "children": [
          [
            10,
            1
          ]
        ],
        "foreign_key_violations": []
      },
      "after": {
        "parents": [
          [
            1,
            "old-name",
            "keep-note"
          ],
          [
            2,
            "new-name",
            "default-note"
          ]
        ],
        "children": [
          [
            10,
            1
          ]
        ],
        "foreign_key_violations": []
      }
    }
  ]
}
PASS: same-ID replacement, cascading children, omitted defaults, upsert branches and direct-change counts

让保存方法的名字对应真实的数据变化

回到应用时,找到最终送给数据库的 SQL,而不是只看方法叫 save、replace 还是 update。ORM 与数据库方言可能选择不同语句;先确认实际版本、表约束和关联动作,再给方法行为下结论。本文只实际执行 SQLite,没有运行任何 ORM 或其他数据库。

验证应保存输入、SQL、父行与子行的前后快照,再写出与本次业务意图是否一致。若业务确实需要完全替换记录,应主动设计相关数据的保留或删除规则;不能靠把错误隐藏成成功,或临时关闭外键,让不清楚的操作继续。

本实验是顺序的单语句对照,没有并发竞争、进程崩溃或性能计时,也没有安装自定义触发器。它足以说明这四条路径在当前环境留下了什么,不能证明所有替换操作的审计钩子、触发顺序或故障持久性。带着这些边界,下一步检查才有明确目标。

记住这三点

  • SQLite REPLACE 遇到唯一性冲突会删除冲突旧行,不能用它代替所有原地更新。
  • 相同最终主键、空外键审计和 changes() 等于一,都不能独立证明关联明细被保留。
  • UPSERT 的新增分支与更新分支应分别验收,遗漏字段也必须有明确政策。

别把例子用过头

  • 只检查新名称与编号,没检查备注和子表。
  • 把 INSERT 中未提供的字段理解成保留旧值。
  • 以为外键审计通过就证明业务数据没有被意外删掉。

自己试一小步

为一次资料修改设计一组对照,怎样证明允许变化的列变了,而其他内容与关联都保留?

  1. 自拟至少两个父记录、三条子记录和一个额外带默认值的字段,不沿用本文编号与文字。
  2. 明确本次接口是否允许新增,先写出已存在与不存在两类输入的业务预期。
  3. 在全新内存库比较替换、明确更新及冲突更新,保留完整前后快照。
  4. 对照直接变化数、外键审计与实际子记录列表,解释三者分别能证明什么。

做到这些,就算完成

  • 没有只提交 PASS 或影响行数。
  • 遗漏字段初值与默认值不同,能够观察是否保留。
  • 分开核对插入分支与更新分支。
  • 不关闭约束掩盖差异,不访问真实业务数据。

查原始资料

想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。

  • SQLite:ON CONFLICT 中的 REPLACE (新标签页)↗SQLite 官方文档 · 英文

    核读 REPLACE 条目及其适用范围:唯一性冲突时删除既有冲突行;并核对不要将其与 INSERT 后的 UPSERT 混为同一机制。

    2026-10-15 实际核读所列范围;未测试自定义删除触发器、更新钩子或其他冲突算法。

  • SQLite:UPSERT 冲突目标与 excluded (新标签页)↗SQLite 官方文档 · 英文

    核读第 2、3、4 节:唯一性冲突目标、DO UPDATE、excluded 值、约束限制与 SQLite 3.24.0 起的支持范围。

    2026-10-15 实际核读所列范围;本例只有一个主键冲突目标与一个更新列,未测试多冲突分支或虚拟表。

  • SQLite:外键删除与更新动作 (新标签页)↗SQLite 官方文档 · 英文

    核读 4.3 节 ON DELETE CASCADE,支持删除父行时删除相关子行;同时保留不同关联动作会给出不同结果的范围。

    2026-10-15 实际核读所列范围;实跑始终开启外键检查,没有将级联行为推广到默认 NO ACTION。

  • SQLite:直接修改行数的计数范围 (新标签页)↗SQLite 官方文档 · 英文

    核读直接修改计数排除触发器、外键动作与 REPLACE 附带变化;并核对 changes() SQL 函数包装遵循同样规则。

    2026-10-15 实际核读所列范围;实际调用 SELECT changes();四组完整表状态由另外的查询检查。