

昨天那些 SQL 都是在访问数据,今天换另一件事:那些数据表本身是怎么来的。
数据表有哪些字段、每列多长、哪几列要建索引,权威在 TableSchema 上,数据库里的物理结构是照它做出来的产物。这件事在这一层最难兑现:数据库里已经有数据,而那些数据不会因为定义改了就自动换一种形状。
本篇说明:
TableSchema为何要跟FormSchema分家- 升级为何是「比对现况」而不是「产生脚本」
- 比对 / 计划 / 运行为什么要拆成三段
- 这次升级要花多久,怎么在动手之前算出来
- ALTER 还是重建,由谁决定
- 五个阶段各自一个 transaction,失败了怎么收尾
- 刻意不做的那几件事,以及它们共同的原则
一、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 与定义一致。
重跑安全是同一件事的结果:跑第二次时比对到的现况已经是升级后的样子,算出来的差异是空的。这个性质在谈失败处理时还要再用一次。
代价三个:
- 比对得向数据库问它自己的结构,各家问法不同,这一段每个数据库各写一份
- 「删掉一个字段」表达不出来,定义里没有的字段只会被忽略(第七节回来讲)
- 没有脚本,就没有一份写着「这次部署会对数据库做什么」的文件可以审。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:
- 先删掉那些即将被改定义的索引
- 改既有字段(名字、类型、长度、允不允许 NULL)
- 加新字段
- 建新索引,并把第一步删掉的重新建回来
- 同步字段说明
这个顺序没有别的排法:索引压在字段上的时候那一列改不动,所以得先让路,改完再建回去。
某阶段失败 → 该阶段回滚、后面不跑、异常往上抛,而前面已提交的阶段不会被回滚。听起来很危险,那为什么不整包一个 transaction?因为建表与改表这类语句,整体回滚在多数数据库上并不可靠,有些连 transaction 都不进去,绑成一个换来的是一句看起来安全、实际上不成立的承诺。
所以框架不做那个承诺:允许停在中间,靠重跑收尾。第二节留的那个性质在这里用上,修掉失败原因再跑一次,已完成的阶段会被算成没有变更而自动跳过。能重跑,比能回滚实际:一个能安全重跑的流程允许你在半夜三点修掉问题再按一次;宣称能整体回滚、实际上却部分生效的那种,只会让你不知道现在停在哪里。
七、刻意不做的那几件事
一套自动升级的机制,能力边界比能力本身重要,因为它是自动的,越界的那一刀不会有人喊停。
- 不删字段。定义里没有、数据库里有的字段一律保留。理由是这一列未必是垃圾(可能是别的系统加的,可能是还没收进定义的扩展字段),而删除做完就回不去。
- 不建外键,也不碰触发器与视图。业务规则不放进数据库,是同一个决定贯彻到底。
- 缩小字段默认拒绝。除非明确打开那个选项,打开之后照做但会记进警告。理由是静默截断是最糟的失败方式:它不报错,只是把数据切掉一截,发现时通常已经是几个月后有人问「为什么这笔地址少了半行」。
- 字段改名要明讲。框架不会猜「这大概是改名吧」,要改名就在定义上标出旧名。而且只保证单次,跨好几版累积的连续改名不支持。
不删字段更简单的说法:数据库可以比定义多,不能比定义少。「定义是唯一真相」在这一层的意思很精确:它保证定义里声明的东西一定在数据库里,不保证数据库里没有别的东西。想反过来要求「数据库必须刚好等于定义」,代价是框架得有权删掉它不认得的字段,而那个权力没有人会想给它。
不建外键带来一个顺便的好处:Day 3 那个部门与员工互相指向的环,在建表这一侧连问题都不构成。没有外键就没有先后顺序,一张一张建就好。有外键约束的世界得先想清楚谁先建谁后建,这里不必。
这几条放在一起是同一个原则:自动化可以做加法,减法留给人。加一个字段做错了补一次就好;删一个字段做错了,数据已经没了。
回到 Northwind
案例启动时会把数据表建起来,跑的就是第三节那个循环,把登录的每张表逐一比对升级。
所以加一张表的时候这段程序不用改。Day 5 数的六处里有两处在这里(添加 TableSchema、在 DbCategorySettings 加一行),建表就会发生。
第一次跑是全新建表,之后每次都是比对,没有变更就什么都不做。开发过程中改了一个字段长度、加了一个索引,下次启动就跟上,中间没有人手写过一句建表或改表的语句。
真相的边界
回到开头那句话:权威在定义上,数据库里的结构是产物。它说的不只是「框架会帮你产生建表语句」,产生语句是简单的部分。真正的内容是这几件加起来:结构只描述一次,描述的时候连哪一家数据库都不提;想知道现在该长什么样,读定义就够了;两边对不上的时候,被改的是数据库那一边。
这一层还多一件前面几层没有的事:这里有数据,所以真相这个词得有边界。数据库可以比定义多、不能比定义少。在别的层,定义是唯一真相说的是把重复收干净;在这一层,说的是知道自己不该碰哪些东西。
明天回到界面那一侧:同一份定义怎么长出四个前端的界面。
本文为 2026 iThome 铁人赛参赛文章,原文为繁体中文。
系列完整目录:ERP 架构师笔记:定义驱动的框架设计
文章摘自:https://www.cnblogs.com/jeff-tsai/p/23511369/day-06-schema-single-truth
