有符号、无符号、未对齐:从汇编到 SQL 的教训
https://cedardb.com/blog/unsigned/
dev 2026 年 9 月 29 日 • 12 分钟
有符号性不仅仅是一个类型注解。它决定了数据如何排序、存储以及相互比较。再加上 SQL NULL 作为 -1 哨兵值的恰当替代品,使用正确的类型意味着更少的 bug 和更少的开销。
Maximilian Bandle
目录
- 一切都是数字
- 数字还不够作为一种类型吗?
- 高级编程语言
- 系统编程
- 可空性 vs 无符号性
- 用 -1 标记无效数字
- SQL 中的约束
- CedarDB 中的无符号类型
- 使用无符号数字
还记得你在上学前是怎么学数数的吗?
一、二,扣上鞋扣,三、四,敲敲门,五、六,捡起木棍,七、八,把它们摆直,九、十,一只大肥母鸡。
没有负数,没有小数,没有分数,当然也没有浮点数。当然,从自然整数开始的原因之一是它简单,并且能对应到你的手指。但就像你的手指一样,现实世界中的大多数事物都是用正数描述的。班里的孩子数量、你的年龄、你书包的重量,以及你拥有的钱数(孩子通常没有债务)。¹
后来在生活中,你学到了负数和分数。两个值之间的差可以是负数,历史年份是公元前,当然还有你的账户余额。所以你需要很多不同的数字类型来映射你收集的所有数据。但对于生活中的大多数事情,无符号整数是正确的选择。无论是库存中有多少存货、表中的行数、IP 端口号、数据库中某一行的 ID、数组索引,还是循环迭代次数:现实生活中的几乎所有东西以及计算中的很多东西都是正数。这在 SQL 中尤其如此,因为我们有 NULL 值语义,所以不需要 -1 作为未知的特殊情况。
本文概述了我们在 CedarDB 如何处理类型信息,并确保你的 SQL 查询既快又正确。我们涵盖有符号性为何重要,从你的 CPU 运行的机器指令一直到你声明的列,以及为什么一个充满无符号整数的 Parquet 文件最终促使我们在 SQL 中暴露它们。
作为一个编译型系统,我们需要多个互操作的类型系统,以确保符号信息在从 SQL 用户输入经过我们的 C++ 实现一路到生成的机器代码的过程中不会丢失。我们在后续文章中逐层向下探讨。
一切都是数字
整数类型是每种编程语言的基础。无论你是系统程序员、嵌入式开发者还是 Web 开发者,你总是需要它们。它们有不同的风味:有符号用于差值和偏移,无符号用于标识符和地址。但归根结底,一切都归结为我们根据需要用不同方式解释的一和零。uint32_t、int 和 float 的大小都是 4 字节,UTF-8 中的 emoji 😀 也是。
例如,emoji 😀 在我们将其编码为 UTF-8 时存储为 4 个字节 0xF0 0x9F 0x98 0x80。² 作为一个 32 位值读取,那就是 0xF09F9880。把这些完全相同的字节喂给不同的类型,你会得到:³
| 类型 | 值 |
|---|---|
| uint32_t | 4036991104 |
| int32_t | -257976192 |
| float | ≈ -3.95e29 |
| char4 (UTF-8) | 😀 |
如你所见,同样的位可以意味着截然不同的东西。
数字还不够作为一种类型吗?
在汇编中,编程的基石,我们注意到没有类型。如果你以前从未看过汇编,把寄存器想象成有名字的比特桶。只有寄存器,它们可以存储一切。唯一的区别是大小,无论是 8 位、8 字节,还是更大的向量寄存器(x86 寄存器概述)。⁴
同样的存储可以按四种不同宽度寻址。
但汇编没有类型这件事真的对吗?如果没有类型,计算机如何区分这两种比较?
c
bool biggerSigned(const int8_t* s, int limit) {
return *s > limit;
}
bool biggerUnsigned(const uint8_t* u, unsigned limit) {
return *u > limit;
}
看汇编时,我们看到寄存器没有类型,但汇编有。取决于有符号性,我们使用不同的汇编指令。在这种情况下,要么是有符号情况下的 set if less setl,要么是无符号情况下的 set if below setb。在 x86 上,less 和 greater 意味着有符号,而 below 和 above 表示无符号比较。
asm
; *s > limit (signed)
movsx eax, byte ptr [rdi] ; sign-extend s into register
cmp esi, eax
setl al ; set al=1 if less (signed)
; *u > limit (unsigned)
movzx eax, byte ptr [rdi] ; zero-extend u into register
cmp esi, eax
setb al ; set al=1 if below (unsigned)
结果取决于指令和所含值的 MSB。寄存器中没有任何东西说明 AL 保存的是 200 还是 -56。
注意加载指令也不同:movsx 符号扩展有符号值,movzx 零扩展无符号值。例如,除法指令或带零扩展或符号扩展的移动也是如此。所以类型是通过选择正确指令隐式给出的。阅读汇编时,我们仍然可以在大多数地方推断出数据类型,但那相当繁琐。
把类型信息编码在操作中意味着,无论谁或什么在写汇编,都需要跟踪类型。虽然这对编译器来说是标准做法,但对程序员来说相当苛刻,这也是大多数人不再用汇编编程的原因之一。手工跟踪类型会打开无尽的错误空间。当然,除非你一直好奇 sqrt(😀) 是什么。⁵
高级编程语言
现在让我们走到另一个极端:高级语言如何处理数字类型?在阶梯的最底端,JavaScript 只支持 double,甚至没有整数。⁶ PHP 至少以不同方式存储整数和浮点数。两者都不区分有符号和无符号,这清楚地表明这些语言离硬件有多远。
Java、R 和 SQL 更上一层:它们区分整数和浮点数,所以你不会意外混合它们并因数值不稳定而得到不可靠的结果。但有符号和无符号在这里也仍然是同一类型。
Java 大约 10 年前注意到了这个缺口,并添加了 Integer.divideUnsigned 函数,而对于 PostgreSQL,你可以安装一个扩展来获得自定义无符号类型。有一些控制,但不是全貌。
系统编程
然而,对数据类型的完全控制对系统编程至关重要。如果你不同意,想想你第一次不得不调试这样的代码:
cpp
unsigned count = 10;
while (count >= 0) {
std::cout << count << '\n';
count--;
}
如果你没有立即看出问题,想想 count >= 0 何时为真,以及为什么答案总是为真!静态类型检查在这里可以发挥很大作用。它会在编译时警告你,你的代码不会终止。
镜像情况发生在有符号值上。一个经典陷阱是可能变为负数的计数遇到无符号循环上界:
cpp
int n = count_results(); // -1 on error
for (size_t i = 0; i < n; ++i) {
process(buffer[i]);
}
有符号直觉认为,如果 n 是 -1,那么 0 < -1 为假,所以循环体永远不会运行。但 n 是与 size_t i 比较,因此它被隐式转换为 SIZE_MAX,即最大的无符号值。现在 0 < SIZE_MAX 为真,你期望在出错时跳过的循环反而远远越过缓冲区末尾运行------几乎立即发生越界读取,而且如果没有崩溃,会在 3 GHz 机器上循环大约 200 年。⁷
两个 bug 都有相同的根本原因:程序员的心智模型使用有符号语义,但机器应用的是无符号算术。这也正是为什么 SQL 的 NULL 比 -1 更适合表示缺失值。
可空性 vs 无符号性
上一个例子中 count_results() 里 -1 的意图是标记无效值,因为计数不可能为负。它被声明为 int 的唯一原因就是错误情况。
乍一看,把可空性与无符号性进行比较似乎很奇怪。但它们有两点共同之处。首先,严格来说,两者都不是 SQL 标准的一等公民。可空性被表达为约束,而无符号根本没有被定义。其次,你经常用它们来报告无效结果,如上所示。
用 -1 标记无效数字
早在 C++ 有 optional 之前,SQL 就有可空性。这防止程序员养成用 -1 甚至更糟的 99998 或 1753 年 1 月 1 日⁹ 来表示无效值的不幸习惯。
只用负数作为错误码浪费了一半的数字范围,这远远不是这里最糟糕的事情。想象一下你的无符号数据整齐地存储在一个 int 列中,只有一些 -1 异常值。你无法计算平均值或求和。你总是需要处理你的特殊值。
SQL 可空性给了你一个更好的工具:不是劫持一个有效数字来表示"什么都没有",而是声明该值根本不存在。没有魔法常量,没有损坏的聚合,没有静默的 bug。
左:一棵没有叶子的树,它仍然是一棵树。右:没有树。前者是 0,后者是 NULL。
数字零与什么都没有不同,就像一棵没有叶子的树与没有树不同。
SQL 中的约束
你可以在普通 PostgreSQL 中这样表达无符号数字:
sql
CREATE TABLE inventory (
product_id INTEGER,
quantity INTEGER CHECK (quantity >= 0)
);
这给我们带来的优点很少,缺点却很大。好的一面是,我们现在列中有了正数。但我们付出的代价是浪费了一半的可表示范围,并且必须在每个计算步骤中评估约束。你加两个数,检查约束,插入一个,检查约束。而且你甚至不能在这里存储更大的数字。所以不值得。
此外,它给你一种虚假的安全感。它只保证存储在表中的值是非负的,而不保证它们在查询期间保持非负。两个数量相减,你又变成负数了,所以下游任何东西也不能依赖它。
所以两者我们都需要:可能缺失的值(NULL)和永不均为负的值(无符号)。SQL 只给了我们第一个。
CedarDB 中的无符号类型
SQL 标准没有定义无符号数据类型。为公平对待标准,它也没有定义如何分页浏览结果集,所以无符号数字在这里有很好的同伴。常见的变通方法是约束,但像 NOT NULL 一样,正确实现类型更有意义。这给它更宽的范围,并避免在每个操作上评估约束的开销。
在 CedarDB,我们相信你应该以最自然的表示存储数据,并避免不必要的转换。所以我们把我们内部已经在使用的无符号类型直接暴露给用户。我们以此为契机重构了我们的类型系统,使今后添加新类型和函数更容易。
集成的主要动机是 Parquet。Parquet 的整数类型在其位宽旁边带有一个有符号性标志,而实际中的文件用它来表示标识符、偏移、计数器和字节大小。如果你在解析 Parquet 数据类型,我们可以直接使用原生无符号类型,否则 UInt64 列必须加宽到 128 位数值,仅仅是为了避免溢出。这会为每一个值付出内存和速度代价,而这些数据完全可以放进 64 位宽的寄存器。
与继承它的 C 和 C++ 不同,无符号不会导致静默回绕。我们对有符号和无符号算术同样检查溢出,所以从数量 3 中减去 5 会报错,而不是给你 4294967294。我们如何在不放弃性能的情况下做到这一点,本身就是一个故事,详见我们关于溢出处理和向量化溢出检查的文章。
无符号类型隐式转换为任何总能容纳它们的类型,所以将它们与有符号整数混合使用就能正常工作。显式转换让你在需要时转回无符号。所以如果你选择不使用无符号数字,你永远不会知道它们存在。
使用无符号数字
一切听起来都很好很简单,使用它们也一样。只需用你想要的类型声明列:
sql
CREATE TABLE inventory (
product_id uint4,
quantity uint4
);
如果你已经按照我们的例子处理过 Parquet 文件,很有可能你已经在使用无符号数字而没有注意到。
你可以在文档中找到更多例子。
让无符号成为整个代码生成系统中的一等公民,不仅仅是把它添加到目录中。在下一篇博客文章中,我们从底层向上穿过我们的类型系统。我们将看看符号信息在哪里被跟踪,在哪里被有意不跟踪,为什么 PostgreSQL 的 oid 是伪装的无符号,以及 System V ABI 忘记指定什么。
想看看它的实际效果吗?把一个 Parquet 文件加载到 CedarDB,正确的无符号类型会免费随之而来。
虽然钱是十进制,但它不一定是。用分来报告你的净资产既技术上正确,又心理上有效。↩︎
一个字符如何变成字节取决于编码。同一个 emoji 在 UTF-32BE 中是 0x00 0x01 0xF6 0x00,而 Python 的 '😀'.encode('utf-32') 甚至会前置一个字节顺序标记。有些 emoji 根本不是单个码点,而是用零宽连接符粘在一起的序列。本文涵盖其余内容。↩︎
把四个字节读作一个数字本身就是一种解释。我们这里使用大端读取,即我们写字节的顺序。在像 x86 这样的小端机器上,把同样的字节加载到 uint32_t 会得到 2157486064。↩︎
是的,整数和浮点数有不同的寄存器。你也可以把整数存储在向量寄存器中以进行 SIMD 处理,或者把浮点数存储在常规寄存器中以进行位技巧操作。↩︎
它是 NaN。sqrt 接受一个浮点数,而作为浮点数 😀 是负数。↩︎
JavaScript 引擎内部确实支持整数,但只是作为类型化数组的存储优化,而不是作为暴露的类型。↩︎
是的,那是我放在那里的 em-dash。在它们成为 LLM 生成内容的象征之前,我喜欢使用它们,是时候把它们拿回来了。目前我只是在文章里放了一个,以免过度。↩︎
在 METAR 中,航空天气报告的标准格式,水平能见度以米为单位,最多四位数字,所以 9999 意味着"10 公里或更多",而不是"9,999 米"。参见 IVAO 文档中的 METAR 解释。↩︎
Microsoft 的 Dynamics NAV 日期数据类型文档。日期范围从 1753 年 1 月 1 日开始,这是 SQL Server 的 datetime 的下界,而后者又可以追溯到 1752 年英国改用格里高利历。↩︎