Day 6:TableSchema 与数据库结构升级


昨天那些 SQL 都是在访问数据,今天换另一件事:那些数据表本身是怎么来的。

数据表有哪些字段、每列多长、哪几列要建索引,权威在 TableSchema 上,数据库里的物理结构是照它做出来的产物。这件事在这一层最难兑现:数据库里已经有数据,而那些数据不会因为定义改了就自动换一种形状。

本篇说明:

  1. TableSchema 为何要跟 FormSchema 分家
  2. 升级为何是「比对现况」而不是「产生脚本」
  3. 比对 / 计划 / 运行为什么要拆成三段
  4. 这次升级要花多久,怎么在动手之前算出来
  5. ALTER 还是重建,由谁决定
  6. 五个阶段各自一个 transaction,失败了怎么收尾
  7. 刻意不做的那几件事,以及它们共同的原则

一、TableSchema 为什么独立成一份定义

两份关心的东西不同:

FormSchema TableSchema
关心什么 业务语义:叫什么、什么类型、要不要必填、指向哪张表单 物理存储:占多长、精确到第几位、哪几列建索引、索引唯不唯一
谁下决定 懂业务的人 懂这个数据库的人

同一个「订单编号」,在案例订单的 ft_order.TableSchema.xml 里是这样:

<DbField FieldName="sys_id" Caption="Order No" DbType="String" Length="20" />

<DbTableIndex Name="uk_{0}" Unique="true">
  <IndexFields><IndexField FieldName="sys_id" /></IndexFields>
</DbTableIndex>

索引名称里的 {0} 是表名的位置,建出来是 uk_ft_order。Day 3 说 sys_id 的唯一性由框架担保,担保的位置就是这个唯一索引。

分成两份的实际回报:调索引、改精度、为了某支报表加一个复合索引,动的只有 TableSchema,FormSchema 完全不必碰,也不必找当初写那张表单的人。

昨天提过这一份不必从头写:框架可以从一份 FormSchema 直接生出对应的 TableSchema。生完之后两份各自维护,上面那些调整才有地方做。

这份定义里没有任何一家数据库的类型名

上面写的是 DbType="String" Length="20",不是 nvarchar(20);整份定义从头到尾都是这样。

开发者能写的类型是框架给的一组,常用的有字符串、长文字、短整数、整数、长整数、小数、金额、日期、日期时间、布尔、Guid、二进制、自动编号。翻成哪一种原生类型是产生语句那一刻才决定的,而各家差得比想象中多:

框架类型 SQL Server PostgreSQL MySQL Oracle SQLite
Guid uniqueidentifier uuid CHAR(36) RAW(16) UUID
Boolean bit boolean TINYINT(1) NUMBER(1) BOOLEAN
Currency decimal(19,4) numeric(19,4) DECIMAL(19,4) NUMBER(19,4) NUMERIC(19,4)

同一个 Guid 字段,有的是内置类型,有的存成文字,有的存成二进制。把这个决定收进机制,换到四件事:

  • 写定义的人不必先变成这个数据库的专家。挑原生类型很吃经验:金额用了浮点数、日期用字符串存、时间精度选得不够,都是资深与资浅最容易出现落差的地方,而且一旦落地就长在数据里。框架把选项收成一组讲语义的类型,错的那个根本不在清单上。
  • 全系统同一种字段一定长一样。不会这张表的 Guid 存成文字、那张存成二进制,等到跨表查询才发现接不起来。
  • 同一份定义能跑在多种数据库上。套装软件支持哪几家是明讲的,挑哪一个由客户决定:他现有的环境、会维护的人,还有商用数据库那笔要一直付的授权费。清单短就是少掉一整批客户。
  • 要改「某个框架类型对到什么原生类型」,改的是一张对应表,不是上千份定义。定义里若直接写原生类型,等于一写下去就拷贝了上千份。

比对也因此变单纯:从数据库读回来的字段先翻回框架类型,再跟定义对。定义说 Guid、MySQL 回报 CHAR(36),翻回来还是 Guid,两边一样就是没有变更。

这组类型刻意收得很窄,各家特有的东西不在里面,要用就得走定义以外的路。能表达的少一点,换到的是同一份定义在哪里都成立。

代价:两份要对得起来

昨天讲过 CRUD 语句照 FormSchema 的字段集合组出来,而建表与升级走 TableSchema。于是:

  • FormSchema 加了字段、TableSchema 忘了补 → 数据库里没有那一列,而保存语句偏偏会去写它,运行时才报错
  • 反过来多一列是无害的 → 会被建出来,但表单这条路不会碰它

运行时没有人替这两份定义对账,靠的是改动的顺序:字段先进 FormSchema,TableSchema 跟着补上物理字段。Day 4 讲那个顺序时看起来像一句习惯,它是这个代价的操作版本。(构建期有一道关卡专门看这件事,Day 9 会谈。)


二、升级不是产生脚本,是比对现况

一般做法是 migration:改完模型、工具产生脚本、进版本控制、按顺序运行,数据库另外记着跑到哪一版。

这套框架没有脚本,也没有版本号。每次升级都是当场问数据库「你现在长什么样」,拿它跟定义比对,算出差异,然后运行。

差别显现在三处:

  • 不需要知道这个数据库上一次停在哪一版
  • 不需要保证每一版的脚本都照顺序跑过
  • 有人手动改过数据库也不会让后面全部对不上,因为下一次比对看到的就是改过之后的样子

换来一句很短的成功契约:

升级成功返回之后,数据库至少要有定义里的每一个字段,而且类型、长度、允不允许 NULL 与定义一致。

重跑安全是同一件事的结果:跑第二次时比对到的现况已经是升级后的样子,算出来的差异是空的。这个性质在谈失败处理时还要再用一次。

代价三个:

  1. 比对得向数据库问它自己的结构,各家问法不同,这一段每个数据库各写一份
  2. 「删掉一个字段」表达不出来,定义里没有的字段只会被忽略(第七节回来讲)
  3. 没有脚本,就没有一份写着「这次部署会对数据库做什么」的文件可以审。migration 那条路上那份脚本会出现在变更清单里,资深的人扫一眼就知道动到什么。这里的变更清单只有定义文件差异,「一个字段从 50 改成 100」读得出来,但「这会不会触发重建」得另外问

第三个代价有解,就是第四节那个先算不运行的用法,只是它得由部署流程主动去做,不会自己浮到眼前。


三、比对、计划、运行,切成三段

段 产出 碰数据库吗
比对 结构化差异:哪些字段要添加、哪些字段的定义变了、哪一列要改名、哪些索引要建要删。里面没有任何 SQL 读
计划 这次要怎么做:模式、分几个阶段、每阶段哪几句 SQL、有没有警告 否
运行 实际跑 写

拆三段最主要的理由不是分层好看,是中间那一段的产出要给人看。

三段都是应用主动调用的。正式环境由部署工具在更版时把 DbCategorySettings 登录过的表逐一跑过一次,入口是 TableSchemaBuilder,案例那一段在 NorthwindSchemaSeeder.cs 里:

var builder = new TableSchemaBuilder(category.Id, defineAccess, connectionManager);
foreach (var table in category.Tables)
    builder.Execute(category.Id, table.TableName);

那一句 Execute 就是三段接起来跑。要先算不运行就把它拆开,这一步叫干跑:CompareToDiff 拿差异、TableUpgradeOrchestrator.Plan 拿计划,看完再决定要不要往下走。


四、先跑一次计划,就知道这次升级要花多久

计划算出来的模式有四种:

模式 影响 该做什么
没有变更 无 不必动作
建表 新表,没有数据 直接跑
改表 秒级,最多单一字段短暂锁住 一般时段就能跑
重建 整表搬数据,期间整张表锁住 排维护窗口,并先估搬移时间

重建的做法是建临时表、整批搬数据、丢掉旧表、把临时表改名,一张千万笔的表跑下来,数十分钟到数小时都有可能。所以部署前该先看的不是 SQL,是模式:先做一次干跑,看模式是不是重建,是的话再把 SQL 印出来人工审。

那份计划除了模式还带着警告清单,例如哪些字段被缩小,所以要看的有两样:模式决定要不要停机,警告决定要不要先去确认数据。这一题原本靠经验,答案是「问一下那位比较资深的」;现在是一个返回值,写得进部署脚本。


五、ALTER 还是重建,框架自己决定

框架把字段类型分成几个粗一点的家族(字符串、数值、布尔、日期时间、Guid、二进制,加上自动编号自成一类),对照规则很短:

这一类变更 走哪条路
加字段、加索引、删索引 ALTER
同一家族内的变化(字符串放长、整数换长整数) ALTER
允不允许 NULL、默认值 ALTER
跨家族(字符串换数值、任何类型换布尔) 重建
自动编号的开关 重建

然后逐笔检查:全部能 ALTER 就走 ALTER;只要有一笔得重建,整张表就重建;有一笔是这个数据库做不到的,直接中止报错,不会做一半。这件事刻意不让人选,因为它有唯一答案,而答案由变更内容决定。开成选项只是把算得出来的东西交给人猜,猜错赔上的是数据。

家族切得粗是刻意保守:把该重建的判成可以 ALTER,运行到一半才发现不行,比多花一次重建的时间严重得多。各家能力的差异也一起吃掉,最极端的是 SQLite:它的 ALTER TABLE 只能加字段、改名与删字段,类型、允不允许 NULL、默认值只要动一个就得重建整张表。同一份定义、同一次变更,在 SQL Server 上是秒级 ALTER,在 SQLite 上是重建,而调用端写的程序一模一样。


六、五个阶段,各自一个 transaction

走 ALTER 时 SQL 不是一口气送出去,而是切成有序阶段,每阶段自己一个 transaction:

  1. 先删掉那些即将被改定义的索引
  2. 改既有字段(名字、类型、长度、允不允许 NULL)
  3. 加新字段
  4. 建新索引,并把第一步删掉的重新建回来
  5. 同步字段说明

这个顺序没有别的排法:索引压在字段上的时候那一列改不动,所以得先让路,改完再建回去。

某阶段失败 → 该阶段回滚、后面不跑、异常往上抛,而前面已提交的阶段不会被回滚。听起来很危险,那为什么不整包一个 transaction?因为建表与改表这类语句,整体回滚在多数数据库上并不可靠,有些连 transaction 都不进去,绑成一个换来的是一句看起来安全、实际上不成立的承诺。

所以框架不做那个承诺:允许停在中间,靠重跑收尾。第二节留的那个性质在这里用上,修掉失败原因再跑一次,已完成的阶段会被算成没有变更而自动跳过。能重跑,比能回滚实际:一个能安全重跑的流程允许你在半夜三点修掉问题再按一次;宣称能整体回滚、实际上却部分生效的那种,只会让你不知道现在停在哪里。


七、刻意不做的那几件事

一套自动升级的机制,能力边界比能力本身重要,因为它是自动的,越界的那一刀不会有人喊停。

  • 不删字段。定义里没有、数据库里有的字段一律保留。理由是这一列未必是垃圾(可能是别的系统加的,可能是还没收进定义的扩展字段),而删除做完就回不去。
  • 不建外键,也不碰触发器与视图。业务规则不放进数据库,是同一个决定贯彻到底。
  • 缩小字段默认拒绝。除非明确打开那个选项,打开之后照做但会记进警告。理由是静默截断是最糟的失败方式:它不报错,只是把数据切掉一截,发现时通常已经是几个月后有人问「为什么这笔地址少了半行」。
  • 字段改名要明讲。框架不会猜「这大概是改名吧」,要改名就在定义上标出旧名。而且只保证单次,跨好几版累积的连续改名不支持。

不删字段更简单的说法:数据库可以比定义多,不能比定义少。「定义是唯一真相」在这一层的意思很精确:它保证定义里声明的东西一定在数据库里,不保证数据库里没有别的东西。想反过来要求「数据库必须刚好等于定义」,代价是框架得有权删掉它不认得的字段,而那个权力没有人会想给它。

不建外键带来一个顺便的好处:Day 3 那个部门与员工互相指向的环,在建表这一侧连问题都不构成。没有外键就没有先后顺序,一张一张建就好。有外键约束的世界得先想清楚谁先建谁后建,这里不必。

这几条放在一起是同一个原则:自动化可以做加法,减法留给人。加一个字段做错了补一次就好;删一个字段做错了,数据已经没了。


回到 Northwind

案例启动时会把数据表建起来,跑的就是第三节那个循环,把登录的每张表逐一比对升级。

所以加一张表的时候这段程序不用改。Day 5 数的六处里有两处在这里(添加 TableSchema、在 DbCategorySettings 加一行),建表就会发生。

第一次跑是全新建表,之后每次都是比对,没有变更就什么都不做。开发过程中改了一个字段长度、加了一个索引,下次启动就跟上,中间没有人手写过一句建表或改表的语句。


真相的边界

回到开头那句话:权威在定义上,数据库里的结构是产物。它说的不只是「框架会帮你产生建表语句」,产生语句是简单的部分。真正的内容是这几件加起来:结构只描述一次,描述的时候连哪一家数据库都不提;想知道现在该长什么样,读定义就够了;两边对不上的时候,被改的是数据库那一边。

这一层还多一件前面几层没有的事:这里有数据,所以真相这个词得有边界。数据库可以比定义多、不能比定义少。在别的层,定义是唯一真相说的是把重复收干净;在这一层,说的是知道自己不该碰哪些东西。

明天回到界面那一侧:同一份定义怎么长出四个前端的界面。


本文为 2026 iThome 铁人赛参赛文章,原文为繁体中文。

iThome 原文 | HackMD 版

系列完整目录:ERP 架构师笔记:定义驱动的框架设计

文章摘自:https://www.cnblogs.com/jeff-tsai/p/23511369/day-06-schema-single-truth