qinghuan
← 回到知识探索

后端现场 / 可下载小项目

新字段填满了,为什么还不能删掉旧字段?

用临时 SQLite 重放一次字段迁移:先兼容旧程序,再回填与切换,最后判断何时可以收掉旧结构。

展开本文目录
  1. 下载完整练习包
  2. 先写清楚三个版本各读什么、写什么
  3. 第一阶段:新增字段,给旧程序留出位置
  4. 反例:回填全部完成,旧程序又改了一次
  5. 第二阶段:先稳定写入规则,再按条件回填
  6. 让剩余工作可查询,关闭重开后继续完成
  7. 切换读取之后,先保留一段兼容窗口
  8. 第三阶段:删除旧列,会改变能回退到哪里
  9. 记住这三点
  10. 别把例子用过头
  11. 自己试一小步
  12. 查原始资料
开始前,会这些就够了
  • 能读懂 SELECT、UPDATE 与简单 ALTER TABLE。
  • 理解版本号随写入递增,了解旧程序和新程序可能同时存在。
  • 准备 Python 3.10+;其 sqlite3 模块连接的 SQLite 版本须至少为 3.35。

虚构用户表原来叫 full_name 的字段,现在希望改叫 display_name。直接改名之后,新代码能工作,仍在读旧名字的程序却立即报错。更隐蔽的做法是新增字段、把数据复制过去,然后马上切换读取:虽然没有空值,旧程序下一次写入仍可能让两份数据不一致。

本篇把一次字段变更拆成可检查的阶段。实验只在系统临时目录创建两份虚构 SQLite 数据库,退出时清理;没有真实账号,也没有部署服务器。最终产物是兼容性与阶段检查记录,帮助你理解升级顺序为什么也是正确性的一部分。

把例子带到自己的电脑上

下载完整练习包

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

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

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

python --version
python -B schema_migration.py

解压后在含示例文件的文件夹打开 PowerShell,按命令运行。先预测结果,再与当次输出对照。 真实临时 SQLite 数据库与受控执行顺序,不代表线上滚动发布或其他数据库的并发行为。

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

先写清楚三个版本各读什么、写什么

先把字段含义固定下来:本例只是同一份显示名字更换字段名,不拆分姓名,不改变编码,旧名字不允许为 NULL。旧版本读取并写入 full_name;过渡版本仍可读旧字段,但每次修改用同一条 UPDATE 同时写两列;最后版本只读写 display_name。每次名字修改还递增 version。

把两个字段放进同一条语句,可以避免本例在第一次写成功、第二次写失败后留下半套更新。这里不是跨数据库双写;若两份数据位于不同系统,需要另一套失败处理。也别把两个字段都存在,当成两个版本自然兼容:兼容取决于每个读取方和写入方实际使用什么。

第一阶段:新增字段,给旧程序留出位置

实验先保留旧列,再新增允许为空的 display_name。旧查询仍然能找到原字段,旧写入也还能运行。新列中的空值表示“这一行尚未迁移”,而不是合法的业务值。这个约定只适合本例;如果新字段本来就可以为空,需要另外设计完成标记,不能把两种空值混为一谈。

Danilo Sato 的 Parallel Change 将这种过程分为扩展、迁移、收缩:先容纳两种接口,再迁走使用者,最后移除旧接口。文中的接口范围比数据库列更广。本项目把该思路用于一个字段名,具体 SQL 和检查数据由本实验定义,不把模式名称当成数据库自动提供的功能。

反例:回填全部完成,旧程序又改了一次

第一份临时数据库故意走错顺序。程序先把三行旧名字复制到新字段,再让旧写入方只更新第一行的 full_name。此时 missing 是零,表示没有待填的空值;mismatch 却是一,表示有一行的两份名字不同。旧读取看到修改后的名字,新读取仍看到先前那份。

因此,检查“新字段全部非空”不够。切换读取前,还要确认旧写入方已经退出,并核对新旧值是否满足本次迁移定义。本例切换门同时检查旧写入状态、空值数和差异数。旧写入是否退出由脚本中的明确阶段变量表示,它不是探测真实进程的办法;生产环境需要另有版本与流量证据。

第二阶段:先稳定写入规则,再按条件回填

第二份数据库用两个连接排列动作。回填先读到旧名字和版本号,随后旧写入修改同一行。回填提交时要求 id 相同、version 仍等于读取值,而且 display_name 仍为空。由于版本已变化,这次更新影响零行,旧快照不会被写成新字段;下一批应重新读取最新数据。

脚本也安排过渡写入先更新另一行,再尝试提交该行旧快照。过渡写入已把两列填为新名字,旧回填依然影响零行。零行不是成功复制了一行,也不一定是 SQL 错误;它可能表示条件已经不成立。处理方式是重新检查剩余工作,不能忽略影响行数后仍宣布全部完成。

只有在旧写入方退出、后续写入都遵守双列规则之后,本例才完成剩余回填并允许新读取切换。两个连接只是控制交错顺序,没有并行压力测试。它证明所列顺序中的条件更新有效,不证明任何数据库上的锁等待、性能或整套发布流程。

让剩余工作可查询,关闭重开后继续完成

每批只取仍为空的记录,按编号排序并限制条数。本例故意每批取一行,完成一批后关闭全部连接,再打开同一个临时文件。重新查询仍能看到一行未完成,继续一批便完成;完成后再跑一批,更新数应为零。进度来自数据库状态,不能只记在进程内计数器里。

下载练习包,在 schema_migration.py 所在目录执行下方命令。输出包含错误路径的差异数、旧快照影响行数、关闭重开前后的空值数,以及最终字段清单。程序逐项断言后才打印 PASS,并检查临时目录已清理。这是正常关闭后的续跑验证,没有模拟突然断电、损坏文件或回填进程崩溃。

小实验 · text
python --version
python schema_migration.py

怎样运行:下载并解压本篇练习包,在 schema_migration.py 所在目录执行。Python 3.10+,SQLite 3.35+;脚本会检查 SQLite 版本。只创建并清理系统临时目录中的虚构数据库。 2026-10-13 已用 3.14.0 执行这份示例并通过自带断言;下方为当次输出。真实临时 SQLite 数据库与受控执行顺序,不代表线上滚动发布或其他数据库的并发行为。

运行后对照结果
{
  "scope": "temporary SQLite only; controlled interleaving, not a rolling deployment",
  "sqlite_version": "3.50.4",
  "unsafe_cutover": {
    "missing": 0,
    "mismatch": 1,
    "old_read": "Alpha-after-backfill",
    "new_read": "Alpha"
  },
  "version_guard_before_retirement": "stale copy skipped; new column still NULL",
  "during_coexistence": "blocked: old writers active",
  "stale_backfill": {
    "affected_rows": 0,
    "kept": "Beta-newer-write"
  },
  "before_reopen": {
    "missing": 1,
    "mismatch": 0
  },
  "after_reopen": {
    "missing": 1,
    "mismatch": 0
  },
  "cutover": {
    "gate": "ready",
    "missing": 0,
    "mismatch": 0
  },
  "before_contract_old_reader": "Alpha-during-rollback-window",
  "old_application_after_contract": "fails: old column is absent",
  "new_reader_after_contract": "Alpha-after-contract",
  "final_columns": [
    "id",
    "version",
    "display_name"
  ],
  "temporary_directory_removed": true
}
PASS: compatibility, late old write, guarded backfill, resume and rollback boundary

切换读取之后,先保留一段兼容窗口

新读取启用后,过渡写入仍同步更新两列。实验在这个阶段再次改名,断言旧读取和新读取相等。这支持“旧读取代码目前还可用”,却不能扩大成“整个旧版本随时都能安全回来”:旧版本若恢复只写一列,又会让已经切换的新读取变旧。回滚计划必须同时说明读写路径。

清理旧列之前,要确认旧读取、旧写入、脚本与报表等使用者都已经迁走,并说明兼容窗口何时结束。本文没有替你决定等待多少分钟;时间本身不能证明没有旧使用者。观察哪些版本仍在使用字段、有没有遗漏调用,以及如何阻止旧写入恢复,才是需要交付的依据。

第三阶段:删除旧列,会改变能回退到哪里

最后,实验在自动提交模式执行 DROP COLUMN full_name,再用新列读写。随后故意执行旧查询,它因为旧字段不存在而失败。把应用代码切回旧版,不会撤销已经提交的数据库结构变化。要恢复旧接口,需要另行设计恢复结构与数据的迁移,不能把“回滚应用”当成这一步已经完成。

这不表示 SQLite 的所有结构修改都无法放进事务,也不表示任何数据都不可能重建;本例展示的是已经提交后的边界。SQLite 对删除列还有限制,例如相关约束和引用可能阻止操作。这里只删除没有额外索引、外键或视图依赖的教学字段,没有覆盖生产表改造的完整检查。

记住这三点

  • 扩展结构、迁移读写方、收缩旧结构要分别设检查条件。
  • 没有空值不代表两列一致;回填完成也不代表旧写入已退出。
  • 回滚应用与恢复已提交的数据库结构,是两项不同的工作。

别把例子用过头

  • 仅检查新列非空,就在旧写入仍存在时切换读取。
  • 回填读到旧快照后无条件覆盖新值,或者忽略零行更新。
  • 保留旧列便声称旧版可完整回滚,却没有约束旧版写入。

自己试一小步

如果迁移后允许 display_name 为 NULL,怎样重新定义剩余工作与切换条件?

  1. 另建一份虚构表,包含待处理、已迁移且值为空、已迁移且有值三种状态,先写下可区分它们的设计。
  2. 自行加入一次“回填读取后,业务又修改”的动作和一次关闭重开,检查旧快照与进度恢复。
  3. 画出旧版、过渡版、新版的读写顺序,并指定旧写入意外恢复时应阻止哪个阶段。

做到这些,就算完成

  • 提交自己的结构、输入、阶段记录和断言,没有直接照抄正文的非空完成条件。
  • 能说明零行更新是哪些条件变化导致的,并证明合法空值不会永远被当成待迁移。
  • 只使用临时虚构数据,分别标记已经实跑的检查与尚未验证的发布假设。

查原始资料

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

  • Danilo Sato:Parallel Change (新标签页)↗Danilo Sato / martinfowler.com · 英文

    阅读 Expand、Migrate、Contract 三阶段,以及所有调用方迁走后再移除旧接口的说明;作者为 Danilo Sato。本篇字段案例为原创练习。

    公开的作者或官方原始资料;本文只采用列出的阅读范围。

  • SQLite:ALTER TABLE (新标签页)↗SQLite · 英文

    阅读第 4 节 ADD COLUMN 与第 5 节 DROP COLUMN:新增列、删除列及相关引用限制;只使用 SQLite 3.35 起已有的 DROP COLUMN,不采用新版本其他语法。

    公开的作者或官方原始资料;本文只采用列出的阅读范围。

  • SQLite:UPDATE (新标签页)↗SQLite · 英文

    阅读第 2 节 Details:WHERE 只更新条件为真的行,零行匹配不构成错误,同一 SET 中列值表达式的求值规则;支持本例条件回填和同语句双列写入。

    公开的作者或官方原始资料;本文只采用列出的阅读范围。

  • SQLite:事务与自动提交 (新标签页)↗SQLite · 英文

    阅读第 2 节与第 2.3 节:自动启动事务、隐式事务结束与提交;用于说明脚本未保持事务打开时,已提交删除列不因应用版本回退而撤销。

    公开的作者或官方原始资料;本文只采用列出的阅读范围。