站在内核设计者的视角

如果你是一名数据库内核设计者,面对的一个问题是:用户写的 SQL 是”宽松”的,而执行器是”严格”的。

用户会写 WHERE a = '5'、会把整数和小数加在一起、会用 ? 占位符却迟迟不告诉数据库它的类型。但数据库的执行器是一台“强类型的虚拟机”:元组(tuple)在内存和磁盘上是一段连续、按固定布局排列的字节;两个值的比较走的是类型专属的比较器;每一个运算符都是一份单态(monomorphic)的机器代码——运行期根本不存在”任意类型(any)”。

所以类型系统要解决的,不是一个”锦上添花”的问题,而是必须在执行之前,把宽松、模糊的 SQL 翻译成一棵”每个节点都有唯一、确定类型”的执行计划树。它必须做到三件事:确定性(同一输入永远得到同一结果)、廉价(在解析阶段完成,不拖慢执行)、不改变语义(转换不能偷偷改意思)。这就是内核设计者眼中类型系统的全部使命。

下面从第一性原理出发,逐层拆开这套设计。

一、第一性原理:为什么内核必须是强类型的

很多讲解把”每个表达式都要有类型”当成前提。但内核设计者会追问:为什么执行器不能像 Python 那样运行时再决定类型?

答案在执行模型里。数据库的执行器操作的是物理元组:一行记录在页面里就是一串定长/变长的字节,字段之间没有”类型标签”,只有偏移量和长度。要比较两个字段、要对它们做运算,执行器必须提前知道:

  • 这个字段占几个字节、按什么对齐;
  • 用什么比较器(字节比较?按数值?按排序规则 collation?);
  • 调用哪一份运算符代码。

如果运行期才去”猜”类型,每一次比较、每一次运算都要先做一次类型分支——这不但慢,而且物理布局根本无从安排(你不知道要预留多少字节)。因此,类型的不确定性必须在”执行之前”被彻底消除,这一步发生在分析(parse analysis)阶段,而不是执行阶段。

这是整座类型系统大厦的承重墙:后面所有的机制——推导、重载解析、隐式转换——都是为了满足”静态完成、唯一确定”这一个约束而存在的。

二、核心机制:类型推导是一场”约束求解”

理解了”必须静态定型”,下一个设计问题是:怎么从一堆模糊的输入里推出唯一类型?

常见的误解是”数据库也用 Hindley-Milner 那套类型推导”。其实不是。SQL 的类型推导更像一场约束求解(constraint satisfaction)

  1. 语法树每个节点最初都有一个候选类型集合:字面量 2.5 的候选很窄(numeric);整数 1 是 int4;而 $1NULL 的候选是整个类型域(unknown)。
  2. 每当遇到一个运算符/函数调用,就施加一条约束:它的操作数必须能被强制(coerce)成该运算符签名要求的类型。
  3. 求解器在候选集合上逐步收缩:直到每个节点都收敛到一个唯一类型;如果到最后仍有多个或零个合法类型,就报错。
SELECT 1 + 2.5;

走的就是这条路径:1 候选 {int4},2.5 候选 {numeric},+ 要求两端类型一致。没有 int+numeric 的精确运算符,于是求解器在”数字类”里把 int4 朝首选类型 numeric 提升,两端都收敛到 numeric,调用 numeric_add,结果类型 numeric。

设计者最在意的一点是确定性:同一个 SQL,无论解析多少次,都必须得到同一棵类型树。一旦发现”两条路径都能走通”(约束有多个解),那不是灵活,而是歧义——是 bug。所以类型系统宁可报错,也不许产生二义性的结果。

三、重载与解析:在”精确匹配”和”隐式提升”之间排定顺序

数据库里没有唯一的 +,只有成百上千份单态实现(int4plnumeric_addfloat8pl……)。给定 a + b,内核的任务是从众多实现里选出唯一一个

为了保证确定性,设计者排定了一条严格的优先级链

  1. 精确匹配优先:操作数类型与某份实现的签名完全一致,直接选用;
  2. 隐式提升次之:靠类型分类(category)+ 首选类型(preferred type)把操作数沿转换图(cast graph)提升到能匹配的实现;
  3. 显式转换最后:用户写了 CAST(...)::type 才启用。

这里的关键设计是:转换图是有向的、分类的、有唯一目标的,而不是”任意两种类型都能互转”。数字类 {int2, int4, int8, numeric, float4, float8} 首选 numeric;字符串类首选 text;时间类首选 timestamptz。于是”提升”变成了一个有界的最短路径搜索——既保证了能找到解,又保证了搜索空间有限、必然终止、且结果唯一。这是把”灵活”关进”确定”笼子里的方法论。

四、延迟定型:把”当下决定不了”的事,推迟到能决定的时刻

$1 参数和 NULL 在一开始是 unknown:它们的类型在解析那一刻原则上不可判定。一个合格的内核设计者不会强行拍板,而是做一个阶段(phase)设计:类型到底在哪个环节定型?

  • 解析时(parse):对即席 SQL,结合上下文(比如它和 int 列比较)当场推断;
  • 绑定时(bind):对预备语句 PREPARE q AS SELECT * FROM t WHERE id = $1,在 PREPARE$1 还是 unknown,真正定型发生在 EXECUTE 把实参类型传进来时;
  • 首次执行(first execution):此时才能拿到参数真实类型,生成”特定计划”。
PREPARE q AS SELECT * FROM t WHERE id = $1;

这直接连到了执行计划缓存:是生成一份不依赖具体类型的”通用计划(generic plan)“,还是依赖类型的”特定计划(specific plan)“?类型系统把”何时定型”当成一个 deliberate 的设计决策,在计划的稳定性计划的精确度之间权衡。这也是为什么 unknown 不是瑕疵,而是内核刻意保留的”待定”状态。

五、隐式转换的代价:为什么”方便”会反噬正确性与性能

隐式转换看上去很友好,但它是类型系统里最危险的特性。从设计者视角看,根子在于:隐式转换会膨胀”约束求解”的解空间,从而制造出多个合法解析。

多个合法解析意味着优化器可能选错那一个。具体有三种反噬:

  1. 语义被悄悄改变WHERE int_col = '123',如果存在 int→text 的隐式转换,可能被解析成”把整列转成 text 再比较”。整数比较和文本比较的语义、排序规则(collation)都不同——结果可能”恰好一样”,也可能在某些 locale 下不一样,而你毫无察觉。
  2. 索引失效:一旦整列被转成 text,int 列上的 B+Tree 索引就用不上了,退化为全表扫描,性能暴跌。
  3. 跨版本不确定:今天选 int=int,明天某个新类型加进来可能让解析翻盘,应用行为随版本漂移。
WHERE int_col = '123';   -- 若有 int→text 隐式转换,可能被改写成 text 比较,丢掉索引

PostgreSQL 8.3 做了一个著名决策:删除了绝大多数”到 text 的隐式转换”。这表面是”少了点方便”,实质是夺回了确定性与计划质量——强制让 int_col = 123 走 int=int,要么匹配索引,要么明确报错。内核设计者的信条由此清晰:隐式转换永远不能偷偷改变语义,也不能藏起一条更优的执行路径。

六、内核落地:类型是一份”身份 + 编码契约 + 行为表”

前面讲的是”类型如何参与运算”。站在内核里,一个类型最终要变成可机器处理的数据结构。PostgreSQL 的答案是:把类型抽象成一份记录——pg_type 里的一行——它由三部分组成:

  • 身份(Identity):一个 OID。内核不认名字,只认 OID。分析器把类型名查成 OID,盖到语法树节点上。所有类型解析归根结底是 OID 的比对与转换。
  • 编码契约(Encoding contract):决定这个值在元组里怎么摆。
    • typlen:正数定长(int4=4),-1 变长(varlena),-2 cstring;
    • typbyval:是否按值传递;
    • typalign:对齐要求(‘c’/‘s’/‘i’/‘d’);
    • typstorage:存储策略(p/e/m/x),大值走 TOAST 搬到独立表,主行只留指针。
    • 于是定长 int4 是裸字节、变长 text 是”长度头+数据”的 varlena、超大 text 是 TOAST 指针——pg_column_size 看到的大小自然不等于字段和。
  • 行为表(Behavior vtable)typinput/typoutput 把字符串与内部二进制互转(typinput 正是 unknown 字面量定型的那一刻),typreceive/typsend 管二进制收发;再叠加 pg_operator/pg_cast 里注册的运算符与转换函数。
定长 int4:  [ 4 字节原始值 ]              (typbyval=true,按值传递)
变长 text:  [ 长度+标志 | 真实字节…… ]    (varlena,-1)
超长 text:  [ TOAST 指针 → 独立 TOAST 表 ] (typstorage='e')

类型在内核里就是 {身份, 编码契约, 行为} 三位一体。这也解释了为什么数据库能支持 CREATE TYPE 和用户扩展:你只要提供这份契约与行为表,内核就能像对待内建类型一样对待它——类型系统因此是可插拔的

而”解析”一个类型,闭环就是:类型名 → pg_type 查 OID → typinput 把文本解析成内部二进制 → 按编码契约落盘对齐

收束:类型系统是一条”灵活—严格”的边界

回头看,类型系统不是一堆零散功能的集合,而是内核设计者对一个问题的一连串回答:如何在”用户写的 SQL 很宽松”与”执行器必须严格”之间,画出一条既确定又高效的边界?

  • 因为执行器是强类型虚拟机 → 类型必须执行前静态完成(第一性原理);
  • 因为输入模糊 → 用约束求解推出唯一类型,且必须确定(核心机制);
  • 因为有大量重载 → 用精确匹配 > 隐式提升 > 显式转换的优先级保证唯一(重载解析);
  • 因为有不可判定 → 把定型推迟到能决定的阶段,权衡计划稳定与精确(延迟定型);
  • 因为隐式转换会膨胀解空间 → 最小化隐式转换,守住语义与性能(代价);
  • 因为要可机器处理、可扩展 → 把类型表示成 OID + 编码契约 + 行为表 的统一记录(内核落地)。

理解这些”为什么”,你就不再是记住了一堆规则,而是站在了内核设计者的位置上:知道每一个设计决策背后的约束与权衡。这,才是类型系统 80% 的核心。