DeepSeek总结的有符号、无符号、未对齐:从汇编到 SQL 的教训

有符号、无符号、未对齐:从汇编到 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 年英国改用格里高利历。↩︎

相关推荐
夜雪一千1 小时前
MySQL 如何提高查询速度:从索引、SQL、表结构到实战调优
android·sql·mysql
麦壳饼1 小时前
SonnetDB SQL Cookbook:常用查询模式速查
数据库·sonnetdb
for_ever_love__2 小时前
LangChain Text-to-SQL:让不会写 SQL 的人查数据库
数据库·sql·langchain·大模型·agent·rag·问答
梦想不只是梦与想2 小时前
SQLAlchemy:查询与操作 (三)
数据库·python·sqlalchemy
Aric_Jones2 小时前
WAL / CDC / 复制槽 /位点
数据库·oracle
草莓熊Lotso2 小时前
【C++ 基于微服务的即时通讯系统】基建三件套:gflags + gtest + spdlog 源码编译安装与实战入门
linux·网络·数据库·c++·微服务·log4j
bksczm2 小时前
Redis基础篇之初识分布式
数据库·redis·分布式
for_ever_love__3 小时前
MySQL 索引类型全解:B+Tree / Hash / R-Tree / 全文 的底层差异,以及一次性建对的选型表
数据库·mysql·b+树·唯一索引·全文索引·索引类型·索引分类
仍然.11 小时前
Redis---缓存
数据库·redis·缓存