SQLite是如何做测试的

SQLite 核心库采用四套独立测试框架进行验证。每套框架均独立设计、维护与管理。

 💬 82 条评论 |  SQLite/数据库 | 

目录

1. 引言

SQLite 的可靠性与健壮性部分得益于全面而严谨的测试。

截至 3.42.0 版本(2023-05-16),SQLite 库包含约 155.8 KSLOC 的 C 代码。(KSLOC指千行源代码,即排除空行和注释后的代码行数。)相比之下,该项目的测试代码与脚本总量达92053.1 KSLOC,是代码量的590倍。

元素周期表

1.1. 执行摘要

  • 四套独立开发的测试框架
  • 部署环境下100%分支测试覆盖率
  • 数以百万计的测试用例
  • 内存耗尽测试
  • I/O错误测试
  • 崩溃与断电测试
  • 模糊测试
  • 边界值测试
  • 禁用优化测试
  • 回归测试
  • 数据库格式异常测试
  • 广泛运用 assert() 及运行时检查
  • Valgrind 内存分析
  • 未定义行为检测
  • 检查清单

2. 测试框架

SQLite 核心库采用四套独立测试框架进行验证。每套框架均独立设计、维护与管理。

  1. TCL测试是SQLite的原始测试套件。它们与SQLite核心位于同一源代码树中,同样属于公共领域。TCL测试是开发过程中主要使用的测试工具,采用TCL脚本语言编写。TCL测试框架本身包含27.2 KSLOC的C代码,用于创建TCL接口。测试脚本分布于1390个文件中,总大小为23.2MB。共有51445个独立测试用例,但其中许多用例采用参数化设计并多次运行(使用不同参数),因此完整测试运行时将执行数百万个独立测试。
  2. **TH3**测试框架是一套专有测试集,采用C语言编写,为SQLite核心库提供100%分支测试覆盖率(及100%多分支/双覆盖率测试)。TH3测试专为嵌入式及专用平台设计,此类平台通常难以支持TCL或其他工作站服务。TH3测试仅使用SQLite公开接口,包含约76.9MB或1055.4千行C代码,实现50362个独立测试用例。TH3测试采用高度参数化设计,因此完整覆盖测试需运行约240万个不同测试实例。实现100%分支测试覆盖的案例构成TH3测试套件的子集。发布前的持续测试约执行2.485亿次测试。TH3的更多信息可另行查阅
  3. SQL逻辑测试(SLT测试框架)用于对SQLite及其他多种SQL数据库引擎执行海量SQL语句,验证其返回结果的一致性。当前SLT对比对象包括SQLite、PostgreSQL、MySQL、Microsoft SQL Server及Oracle 10g。该测试运行720万条查询,测试数据总量达1.12GB。
  4. dbsqlfuzz引擎为专有模糊测试工具。其他SQLite模糊测试工具仅变异SQL输入或数据库文件。Dbsqlfuzz同时变异SQL语句与数据库文件,从而能触发全新错误状态。Dbsqlfuzz基于LLVM的libFuzzer框架构建,并采用自定义变异器。包含336个种子文件。该模糊测试器每日运行约十亿次测试变异。Dbsqlfuzz确保SQLite能抵御恶意SQL或数据库输入的攻击。

除四大核心测试框架外,另有众多实现专项测试的小型程序,例如:

  1. “speedtest1.c” 程序评估 SQLite 在典型工作负载下的性能表现
  2. “mptester.c” 程序对多个进程并发读写单一数据库进行压力测试
  3. “threadtest3.c”程序用于测试多个线程同时使用SQLite时的压力。
  4. “fuzzershell.c”程序用于运行某些模糊测试
  5. “jfuzz”程序基于libfuzzer库,用于对JSON SQL函数JSONB输入进行模糊测试。

在每次 SQLite 发布前,上述所有测试必须在多个平台和多种编译配置下成功运行。

每次向 SQLite 源代码树提交代码前,开发者通常会运行 Tcl 测试的子集(称为“veryquick”),该子集包含约 304.7 万个测试用例。veryquick 测试涵盖除异常测试、模糊测试和持续测试外的绝大多数测试。其设计理念在于:既能有效捕获多数错误,又能将运行时间控制在数分钟而非数小时。

3. 异常测试

异常测试旨在验证SQLite在异常情况下的正确行为。构建能在功能完备的计算机上处理规范输入的SQL数据库引擎相对容易,而构建能对无效输入作出合理响应、并在系统故障后持续运行的系统则更为困难。异常测试正是为验证后者而设计。

3.1 内存耗尽测试

与所有SQL数据库引擎相同,SQLite大量使用malloc()函数(详见[SQLite动态内存分配]专题报告[https://sqlite.org/malloc.html])。在服务器和工作站环境中,malloc() 实际运行中从未失败,因此内存不足(OOM)错误的正确处理并不特别重要。但在嵌入式设备上,OOM 错误却频繁得令人不安。鉴于 SQLite 常被用于嵌入式设备,确保其能优雅处理 OOM 错误至关重要。

内存不足测试通过模拟内存不足错误来实现。SQLite允许应用程序通过sqlite3_config(SQLITE_CONFIG_MALLOC,…)接口替换默认的malloc()实现。TCL和TH3测试框架均能插入修改版malloc()函数,使其在完成特定次数分配后触发故障。这些可监控的malloc实现可设置为仅失败一次后恢复正常,或首次失败后持续报错。OOM测试采用循环执行模式:循环首轮中,监控版malloc被设定为首次分配即失败。随后执行SQLite操作并验证其是否正确处理内存不足错误。此时将仪器化malloc的故障计时器递增一次,循环重复测试。该过程持续进行,直至整个操作流程在未触发模拟内存不足错误的情况下完成。此类测试需执行两次:首次将仪器化malloc设置为仅失败一次,第二次则设置为首次失败后持续失败。

3.2. I/O错误测试

I/O错误测试旨在验证SQLite对I/O操作失败的合理响应。I/O错误可能由磁盘空间耗尽、磁盘硬件故障、网络文件系统连接中断、SQL操作过程中系统配置或权限变更,或其他硬件/操作系统异常引发。无论成因如何,关键在于SQLite能否正确处理这些错误,而I/O错误测试正是为此而设。

I/O错误测试与内存不足测试原理相似: 通过模拟I/O错误并验证SQLite能否正确响应,来实现测试目的。在TCL和TH3测试框架中,均通过插入特殊设计的虚拟文件系统对象来模拟I/O错误——该对象会在执行固定次数I/O操作后触发模拟错误。与内存不足测试类似,I/O错误模拟器可设置为仅失败一次,或首次失败后持续失败。测试以循环方式运行,逐步提高失败阈值直至测试用例无误完成。该循环执行两次:首次设置模拟器仅模拟单次失败,第二次则设置为首次失败后所有I/O操作均失败。

在I/O错误测试中,禁用错误模拟机制后,将通过PRAGMA integrity_check检查数据库,确保I/O错误未导致数据损坏。

3.3. 崩溃测试

崩溃测试旨在验证SQLite数据库在应用程序或操作系统崩溃、或数据库更新过程中遭遇断电时不会发生损坏。另一篇题为SQLite中的原子提交的白皮书详细描述了SQLite为防止崩溃后数据库损坏所采取的防御措施。崩溃测试致力于验证这些防御措施是否有效。

当然,使用真实断电进行崩溃测试并不现实,因此崩溃测试通过模拟方式实现。系统会插入一个替代的虚拟文件系统,使测试框架能够模拟崩溃后数据库文件的状态。

在TCL测试框架中,崩溃模拟由独立进程执行。主测试进程会生成子进程,该子进程执行SQLite操作并在写入过程中随机引发崩溃。特殊VFS会随机重排并破坏未同步的写入操作,以模拟缓冲文件系统的影响。子进程终止后,原始测试进程会打开并读取测试数据库,验证子进程尝试的变更要么已成功完成,要么已被完全回滚。通过使用integrity_check PRAGMA指令确保数据库完整性不受破坏。

TH3测试框架需在嵌入式系统上运行,而这类系统未必具备创建子进程的能力,因此它采用内存中的虚拟文件系统来模拟崩溃。该内存虚拟文件系统可配置为在完成设定次数的I/O操作后生成文件系统快照。崩溃测试以循环方式运行:每次迭代时,快照生成点将向前推进,直至被测SQLite操作在未触发快照的情况下完成执行。在循环内部,待测SQLite操作完成后,文件系统将回滚至快照状态,并引入随机文件损坏——这种损坏特征与断电后常见的损坏类型一致。随后打开数据库并执行检查,确保其结构完整且事务要么已完全执行,要么已完全回滚。每次快照都会重复执行循环内部逻辑,每次随机损坏模式均不相同。

3.4. 复合故障测试

SQLite测试套件还探索了多重故障叠加的情形。例如,当系统尝试从先前崩溃中恢复时,若发生I/O错误或内存不足故障,相关测试将验证其正确行为。

4. 模糊测试

模糊测试旨在验证SQLite能否正确响应无效、超出范围或格式错误的输入。

4.1. SQL模糊测试

SQL模糊测试通过生成语法正确但语义荒谬的SQL语句,观察SQLite的处理行为。通常会返回某种错误(如“表不存在”)。偶尔纯属偶然,SQL语句也可能语义正确。此时需执行生成的预编译语句,确保其返回合理结果。

4.1.1. 使用American Fuzzy Lop模糊测试器进行SQL模糊测试

模糊测试的概念已存在数十年,但直到2014年Michal Zalewski发明首个实用的剖析引导模糊测试器——American Fuzzy Lop(简称AFL)后,该技术才成为有效的漏洞发现手段。与早期盲目生成随机输入的模糊测试器不同,AFL通过修改C编译器的汇编语言输出对被测程序进行仪器化改造,利用仪器化机制检测输入是否导致程序行为改变——例如进入新控制路径或循环次数变化。引发新行为的输入将被保留并进一步变异。通过这种方式,AFL能够“发现”被测程序的新行为,包括开发者从未预见的异常行为。

AFL在发现SQLite的隐蔽缺陷方面表现卓越。多数发现是条件判断在特殊情况下失效的assert()语句,但也成功定位了大量SQLite崩溃缺陷,甚至发现若干计算结果错误的案例。

凭借过往的成功,AFL自3.8.10版起成为SQLite测试策略的标准组成部分 (2015-05-07) 开始成为 SQLite 测试策略的标准组成部分,直至被 版本 3.29.0 (2019-07-10) 中的更优模糊测试器取代。

4.1.2. Google OSS Fuzz

自2016年起,谷歌工程师团队启动了OSS Fuzz项目。OSS Fuzz基于AFL风格的引导式模糊测试器,运行于谷歌基础设施之上。该测试器会自动下载参与项目的最新提交代码,执行模糊测试后通过邮件向开发者报告问题。当修复代码提交后,测试器会自动检测并向开发者发送确认邮件。

SQLite是OSS Fuzz测试的众多开源项目之一。SQLite代码库中的test/ossfuzz.c源文件即为SQLite对接OSS Fuzz的接口。

目前OSS Fuzz已不再发现SQLite的历史漏洞,但该系统仍在持续运行,并偶尔能在新提交的代码中发现问题。示例:[1] [2] [3]

4.1.3. dbsqlfuzz 与 jfuzz 模糊测试工具

自2018年末起,SQLite开始采用名为“dbsqlfuzz”的专有模糊测试工具进行测试。该工具基于LLVM的libFuzzer框架构建。

dbsqlfuzz模糊测试工具同时变异SQL输入和数据库文件。它采用定制的结构感知变异器,处理专用的输入文件——该文件既定义输入数据库,也定义针对该数据库执行的SQL文本。由于同时变异输入数据库和SQL文本,dbsqlfuzz成功发现了SQLite中某些被传统模糊测试工具遗漏的隐蔽缺陷——这些工具仅变异SQL输入或数据库文件。SQLite开发团队始终在主干代码库上运行约16核的dbsqlfuzz测试。每个 dbsqlfuzz 实例每秒可评估约 400 个测试用例,意味着每天检查约 5 亿个用例。

dbsqlfuzz 模糊测试器在强化 SQLite 代码库抵御恶意攻击方面成效显著。自其纳入 SQLite 内部测试套件后,来自 OSSFuzz 等外部模糊测试器的漏洞报告已基本停止。

需注意:dbsqlfuzz 并非 Chromium所采用的基于Protobuf的SQLite结构感知模糊测试器,后者详见结构感知变异器文章。这两款模糊测试工具除同基于libFuzzer外并无关联。SQLite的Protobuf模糊测试工具由谷歌Chromium团队编写维护,而dbsqlfuzz则由SQLite原始开发者团队负责开发与维护。拥有多个独立开发的SQLite模糊测试工具是好事,这意味着更可能发现隐蔽问题。

2024年1月底,基于libFuzzer的第二款工具“jfuzz”投入使用。Jfuzz生成损坏的JSONB二进制数据块,并将其输入JSON SQL函数以验证这些函数能否安全高效地处理损坏的二进制输入。

4.1.4. 其他第三方模糊测试工具

SQLite似乎是第三方模糊测试的热门目标。开发者们了解到许多针对SQLite的模糊测试尝试,偶尔也会收到独立模糊测试工具发现的漏洞报告。所有此类报告均得到及时修复,从而提升产品品质,使整个SQLite用户群体受益。这种依靠众多独立测试者的机制与林纳斯定律相通:“只要有足够多双眼睛,所有漏洞都将浮出水面”。

值得特别关注的模糊测试研究者是Manuel Rigger。多数模糊测试工具仅能发现断言错误、崩溃、未定义行为(UB)等易检测异常,而Rigger博士的工具能定位SQLite计算出错误结果的场景。里格已发现大量此类案例。其中多数是涉及类型转换和亲和性转换的晦涩边界情况,且不少发现针对的是未发布的特性。尽管如此,这些发现仍具有重要价值——它们都是真实存在的漏洞,SQLite开发者们也感激能借此识别并修复底层问题。

4.1.5. fuzzcheck测试框架

来自AFL的历史测试用例、 OSS Fuzzdbsqlfuzz的历史测试用例,均收录于SQLite主源代码树中的数据库文件集。每当执行“make test”时,“fuzzcheck”工具程序会自动重运行这些用例。在各类模糊测试工具多年积累的数十亿测试案例中,Fuzzcheck仅会筛选数千个“有价值”的案例进行运行。所谓“有价值”案例,即指展现出前所未见行为的测试场景。模糊测试工具发现的实际缺陷始终包含在这些有价值的测试案例中,但Fuzzcheck运行的多数案例从未成为实际缺陷。

4.1.6. 模糊测试与100% MC/DC测试的矛盾

模糊测试与100% MC/DC测试存在矛盾关系。具体而言:通过100% MC/DC测试的代码往往更易受模糊测试发现的问题影响;而在模糊测试中表现良好的代码,其MC/DC覆盖率通常远低于100%。这是因为覆盖率测试会抑制包含不可达分支的防御性代码,而缺乏防御性代码时,模糊测试更容易发现引发问题的路径。覆盖率测试似乎更适合构建日常使用中稳健的代码,而模糊测试则擅长构建能抵御恶意攻击的稳健代码。

当然,用户更希望代码在正常使用时既能保持健壮性,又能抵御恶意攻击。SQLite开发者正致力于实现这一目标。本节仅旨在指出同时达成这两点实属不易。

在漫长的发展历程中,SQLite始终专注于100%的覆盖率/数据覆盖率(MC/DC)测试。直到2014年模糊测试框架AFL问世后,抵御模糊攻击才成为关注焦点。当时模糊测试工具曾发现SQLite存在诸多问题。近年来,SQLite的测试策略已进化为更侧重模糊测试。我们仍保持核心代码100%的MC/DC覆盖率,但当前大部分测试CPU周期都投入于模糊测试。

尽管模糊测试与100% MC/DC测试存在张力,但二者并非完全背道而驰。SQLite测试套件实现100%覆盖的事实意味着:当模糊测试发现问题时,既能快速修复,又可最大限度避免引入新错误。

4.2. 异常数据库文件

大量测试用例验证了SQLite处理异常数据库文件的能力。这些测试首先构建结构完整的数据库文件,随后通过非SQLite手段修改文件中一个或多个字节引入损坏。接着使用SQLite读取该数据库。部分测试中,修改发生在数据中间位置,导致数据库内容改变但结构仍完整;另一些测试则修改文件未使用字节,不影响数据库完整性。关键测试场景在于修改定义数据库结构的字节。这些测试验证SQLite能否识别文件格式错误,并通过SQLITE_CORRUPT返回码报告问题,同时避免缓冲区溢出、空指针解引用等异常操作。

dbsqlfuzz模糊测试工具同样出色地验证了SQLite对格式错误数据库文件的合理响应机制。

4.3. 边界值测试

SQLite对其操作定义了若干限制,例如表的最大列数、SQL语句的最大长度以及整数的最大值。TCL和TH3测试套件均包含大量将SQLite推至定义边界的测试,以验证其在所有允许值下的正确性。额外测试则突破定义边界,验证SQLite能否正确返回错误。源代码中包含测试用例宏,用于确保每个边界两侧均被测试。

5. 回归测试

每当SQLite出现已报告缺陷,在TCL或TH3测试套件中新增能复现该缺陷的测试用例前,该缺陷均不视为修复。多年来,此机制已催生数以千计的新测试。这些回归测试确保历史修复的缺陷不会在SQLite后续版本中复现。

6. 自动资源泄漏检测

资源泄漏指系统资源被分配后未被释放。多数应用中最棘手的泄漏是内存泄漏——即使用malloc()分配内存后未通过free()释放。但其他资源也可能泄漏:文件描述符、线程、互斥锁等。

TCL和TH3测试框架均能自动追踪系统资源,并在每次测试运行时报告泄漏情况。无需特殊配置或设置。测试框架对内存泄漏尤为警惕:若变更导致内存泄漏,测试框架将迅速识别。SQLite设计上确保永不泄漏内存,即使在内存不足(OOM)或磁盘I/O错误等异常情况下亦然。测试框架对此执行机制极为严格。

7. 测试覆盖率

SQLite核心(含Unix虚拟文件系统VFS)在默认配置下,经gcov测得其TH3分支覆盖率达100%。FTS3和RTree等扩展模块未纳入本次分析。

7.1. 语句覆盖率与分支覆盖率

测试覆盖率存在多种衡量方式,最常见的是“语句覆盖率”。当人们提及程序“XX%测试覆盖率”时,若无进一步说明,通常指的就是语句覆盖率。该指标衡量测试套件中至少执行过一次的代码行占比。

分支覆盖率比语句覆盖率更严谨。它衡量的是机器码分支指令在两个方向上均至少被评估一次的数量。

为说明语句覆盖率与分支覆盖率的区别,请考虑以下假设的C代码行:

if( a>b && c!=25 ){ d++; }

此类C代码行可能生成十余条独立的机器码指令。只要其中任意一条指令被执行过,我们就认为该语句已被测试。例如,当条件表达式始终为假时,变量“d”可能永远不会被递增。即便如此,语句覆盖率仍将此行代码计为已测试。

分支覆盖率则更为严格。它要求对语句内的每个测试条件及子代码块分别进行评估。要使上述示例达到100%分支覆盖率,至少需要三组测试用例:

*   a<=b
*   a>b && c==25
*   a>b && c!=25

上述任一测试用例可实现100%语句覆盖率,但需三者齐备才能达成100%分支覆盖率。通常而言,100%分支覆盖率意味着100%语句覆盖率,但反之则不成立。需要再次强调的是,SQLite的TH3测试框架提供了更严格的测试覆盖标准——100%分支测试覆盖率。

7.2. 防御性代码的覆盖率测试

编写良好的C程序通常包含某些防御性条件语句,这些语句在实际运行中总是为真或总是为假。这引发编程困境:是否应移除防御性代码以获得100%分支覆盖率?

在SQLite中,答案是否定的。为测试需求,SQLite源代码定义了ALWAYS()和NEVER()宏。ALWAYS()宏包裹预期始终为真的条件,NEVER()包裹预期始终为假的条件。这些宏作为注释标识条件语句属于防御性代码。在正式构建中,这些宏会直接传递参数:

#define ALWAYS(X)  (X)
#define NEVER(X)   (X)

但在多数测试场景中,若参数未达到预期真值,这些宏将触发断言错误,从而快速警示开发者设计假设存在谬误。

#define ALWAYS(X)  ((X)?1:assert(0),0)
#define NEVER(X)   ((X)?assert(0),1:0)

在测量测试覆盖率时,这些宏被定义为常量真值,因此不会生成汇编语言分支指令,从而在计算分支覆盖率时不被计入:

#define ALWAYS(X)  (1)
#define NEVER(X)   (0)

测试套件设计为运行三次,分别对应上述ALWAYS()和NEVER()的定义。三次测试运行应产生完全相同的结果。运行时测试使用sqlite3_test_control (SQLITE_TESTCTRL_ALWAYS, …) 接口,可验证宏是否正确设置为部署所需的首选形式(即直通形式)。

7.3. 强制覆盖边界值与布尔向量测试

另一个与测试覆盖率测量配合使用的宏是 testcase()。其参数为需要生成同时评估为真和假的测试用例的条件表达式。在非覆盖率构建(即发布构建)中,testcase() 宏不执行任何操作:

#define testcase(X)

但在覆盖率测量构建中,testcase() 宏会生成评估其参数中条件表达式的代码。分析阶段会检查是否存在使该条件同时为真和假的测试用例。例如,testcase()宏可用于验证边界值是否被测试:

testcase( a==b );
testcase( a==b+1 );
if( a>b && c!=25 ){ d++; }

当switch语句中两个或多个分支指向相同代码块时,同样需要使用测试案例宏确保所有分支均被执行:

switch( op ){
  case OP\_Add:
  case OP\_Subtract: {
    testcase( op==OP\_Add );
    testcase( op==OP\_Subtract );
    /\* ... \*/
    break;
  }
  /\* ... \*/
}

位掩码测试中,testcase()宏用于验证掩码的每个位均影响结果。例如以下代码块中,若掩码包含MAIN_DB或TEMP_DB任一标识位则条件成立: if语句前的testcase()宏确保两种情况均被测试:

testcase( mask & SQLITE\_OPEN\_MAIN\_DB );
testcase( mask & SQLITE\_OPEN\_TEMP\_DB );
if( (mask & (SQLITE\_OPEN\_MAIN\_DB|SQLITE\_OPEN\_TEMP\_DB))!=0 ){ ... }

SQLite源代码中共使用了1184次testcase()宏。

7.4. 分支覆盖率与MC/DC覆盖率

上文描述了两种测试覆盖率测量方法:“语句覆盖率”和“分支覆盖率”。除这两种外,还有许多其他测试覆盖率指标。另一种常见指标是“修改条件/决策覆盖率”(MC/DC)。维基百科对MC/DC的定义如下:

  • 每个决策点尝试所有可能的结果
  • 决策点中的每个条件都呈现所有可能的结果
  • 每个入口点和出口点均被调用
  • 决策点中的每个条件都独立影响决策结果

在C语言中,由于**&&||**是“短路”运算符,MC/DC与分支覆盖率几乎完全一致。主要区别在于布尔向量测试。即使未满足决策覆盖的第二项要求(即每个决策条件必须呈现所有可能结果),只要对位向量中的任意多个位进行测试,仍可获得100%分支测试覆盖率。

SQLite通过前节所述的testcase()宏确保位向量决策中的每个条件都呈现所有可能结果。由此,SQLite在实现100%分支覆盖率的同时,也达成了100%的MC/DC覆盖率。

7.5. 分支覆盖率测量

SQLite当前使用gcov配合“-b”选项测量分支覆盖率。首先使用“-g -fprofile-arcs -ftest-coverage”选项编译测试程序,随后运行测试程序。接着执行“gcov -b”生成覆盖率报告。由于原始报告冗长且不易阅读,会通过简单脚本处理gcov生成的报告,将其转换为更易读的格式。整个过程当然已通过脚本实现自动化。

需注意:使用 gcov 运行 SQLite 并非对 SQLite 本身的测试,而是对测试套件的验证。gcov 运行不会测试 SQLite,因为 -fprofile-args 和 -ftest-coverage 选项会导致编译器生成不同代码。gcov 运行仅验证测试套件是否提供 100% 分支测试覆盖率。gcov 运行本质上是对测试的测试——即元测试。

在通过 gcov 验证 100% 分支测试覆盖率后,需使用交付编译器选项(不带特殊参数 -fprofile-arcs 和 -ftest-coverage)重新编译测试程序,并再次运行测试。第二次运行才是对 SQLite 的实际测试。

关键在于验证gcov测试运行与第二次实际测试运行必须产生相同输出。任何输出差异均表明SQLite代码存在未定义行为或不确定行为(即存在缺陷),或是编译器本身存在缺陷。需注意,过去十年间SQLite曾先后在GCC、Clang和MSVC编译器中发现过缺陷。编译器缺陷虽罕见但确实存在,因此在交付配置环境下测试代码至关重要。

7.6. 变异测试

使用 gcov(或类似工具)验证每个分支指令在两个方向上均至少被执行一次,是衡量测试套件质量的有效手段。但更优的验证方式是证明每个分支指令都能改变输出结果。换言之,我们不仅要验证每个分支指令都存在跳转和穿透两种情况,更要确保每个分支都在执行有效工作,且测试套件能够检测并验证该工作。若发现某分支指令对输出结果毫无影响,则表明:与该分支关联的代码可被移除(从而缩减库体积并可能提升运行速度),或者测试套件未能充分验证该分支所实现的功能特性。

SQLite 致力于通过变异测试验证每个分支指令的有效性。具体流程如下:脚本首先将 SQLite 源代码编译为汇编语言(例如使用 gcc 的 -S 选项)。随后该脚本逐行遍历生成的汇编代码,将每个分支指令依次替换为无条件跳转或空操作指令,重新编译后验证测试套件能否捕获该变异。

遗憾的是,SQLite中存在大量不改变输出结果却能提升运行效率的分支指令。此类分支在变异测试中会产生误报。以用于加速表名查找的哈希函数为例:

55  static unsigned int strHash(const char \*z){
56    unsigned int h = 0;
57    unsigned char c;
58    while( (c = (unsigned char)\*z++)!=0 ){     /\*OPTIMIZATION-IF-TRUE\*/
59      h = (h<<3) ^ h ^ sqlite3UpperToLower\[c\];
60    }
61    return h;
62  }

若将第58行实现“c!=0”测试的分支指令改为空操作,则while循环将无限循环,测试套件将因超时失败。但若将该分支改为无条件跳转,哈希函数将始终返回0。问题在于0本身是有效的哈希值。始终返回0的哈希函数在某种意义上仍能正常工作——SQLite仍能得到正确结果。此时表名哈希表会退化为链表,导致解析SQL语句时的表名查找速度略有下降,但最终结果保持一致。

为解决此问题,SQLite源代码中插入了“/*OPTIMIZATION-IF-TRUE*/‘和’/*OPTIMIZATION-IF-FALSE*/”形式的注释,用于告知变异测试脚本忽略某些分支指令。

7.7. 全覆盖测试实践

SQLite开发者发现,全覆盖测试是定位和预防缺陷的极高效方法。由于SQLite核心代码中的每个分支指令都受到测试用例覆盖,开发者可确信代码某部分的变更不会在其他部分引发意外后果。近年来SQLite新增的众多功能与性能优化,若无全覆盖测试的支持将难以实现。

维持100%的MC/DC覆盖率既费时又费力。对于普通应用程序而言,维持全面覆盖测试所需的投入可能并不划算。然而对于SQLite这类广泛部署的基础设施库,尤其是本质上“记忆”历史错误的数据库库而言,我们认为全覆盖测试具有充分合理性。

8. 动态分析

动态分析指在SQLite代码运行时执行的内部与外部检查。实践证明动态分析对维护SQLite质量大有裨益。

8.1. 断言

SQLite核心包含6754个assert()语句,用于验证函数先决条件、后置条件及循环不变量。assert()作为ANSI-C标准宏,其参数默认为布尔值且始终被视为真。若断言为假,程序将打印错误信息并终止。

通过定义NDEBUG宏进行编译可禁用Assert()宏。多数系统默认启用断言功能,但SQLite因断言数量庞大且分布于性能关键位置,启用断言会导致数据库引擎运行速度降低约三分之二。因此SQLite默认(生产环境)构建版本禁用了断言功能。仅当编译时定义 SQLITE_DEBUG 预处理器宏时,断言语句才会启用。

有关 SQLite 如何使用 assert() 的详细信息,请参阅文档SQLite 中 assert 的使用

8.2. Valgrind

Valgrind 堪称全球最强大实用的开发工具。它本质上是模拟器——模拟x86架构运行Linux二进制文件的环境。(Valgrind在Linux以外平台的移植版本正在开发中,但截至本文撰写时,Valgrind仅能在Linux上稳定运行。SQLite开发者认为这意味着Linux应成为所有软件开发的优先平台。)由于Valgrind运行的是Linux二进制程序,它能检测各类关键错误,例如数组越界、读取未初始化内存、栈溢出、内存泄漏等。Valgrind能发现其他SQLite测试中容易遗漏的问题。当Valgrind检测到错误时,可将开发者直接定位到错误发生的精确位置,进入符号调试器,从而快速修复问题。

由于Valgrind本质是模拟器,在其中运行二进制文件的速度会慢于原生硬件。(粗略估算,工作站上Valgrind运行的应用程序性能约等同于智能手机原生运行时的表现。)因此通过Valgrind运行完整的SQLite测试套件并不现实。不过每次发布前,我们都会通过Valgrind运行快速测试和TH3测试的覆盖部分。

8.3. Memsys2

SQLite包含一个可插拔的内存分配子系统。默认实现使用系统的malloc()和free()函数。但若SQLite在编译时启用SQLITE_MEMDEBUG选项,则会插入替代内存分配封装器(memsys2),该封装器能在运行时检测内存分配错误。memsys2封装器不仅检测内存泄漏,还可识别缓冲区溢出、未初始化内存使用及内存释放后尝试访问等错误。这些检测功能Valgrind同样具备(且实现更完善),但memsys2的优势在于运行速度远快于Valgrind,这意味着检测频率更高且能支持更长时间的测试。

8.4. 互斥锁断言

SQLite内置可插拔的互斥锁子系统。根据编译时选项,默认互斥锁系统包含sqlite3_mutex_held()sqlite3_mutex_notheld() 用于检测特定互斥锁是否被调用线程持有。SQLite 在 assert() 语句中广泛使用这两个接口,以验证互斥锁在正确时机被获取和释放,从而确保 SQLite 在多线程应用中正常运行。

8.5. 日志测试

SQLite为确保事务在系统崩溃和断电时保持原子性,会在修改数据库前将所有变更写入回滚日志文件。TCL测试框架包含一个替代的操作系统后端实现,用于验证此机制是否正确运行。“日志测试VFS”监控数据库文件与回滚日志间的全部磁盘I/O流量,确保任何写入数据库文件的操作都已事先写入并同步至回滚日志。若发现任何差异,将触发断言错误。

日志测试是对崩溃测试的额外双重检查,旨在确保SQLite事务在系统崩溃和断电时保持原子性。

8.6. 未定义行为检测

在C语言编程中,极易编写出具有“未定义”或“实现定义”行为的代码。这意味着代码在开发阶段可能正常运行,但在不同系统或使用不同编译器选项重新编译时却产生不同结果。ANSI C中未定义及实现定义行为的示例包括:

  • 有符号整数溢出(与多数人预期的不同,有符号整数溢出未必会发生溢出循环)。
  • 对N位整数进行超过N位的位移操作
  • 执行负位移操作
  • 对负数进行位移操作
  • 对重叠缓冲区使用memcpy()函数
  • 函数参数的求值顺序
  • “char”变量是否为有符号或无符号类型
  • 等等…

由于未定义行为和实现定义行为缺乏可移植性且易导致错误结果,SQLite竭力规避此类情况。例如在SQL语句中对两个整数列值进行加法运算时,SQLite不会直接使用C语言的“+”运算符,而是先检查加法是否会溢出,若会则改用浮点运算。

为确保SQLite不涉及未定义或实现定义行为,测试套件会通过检测未定义行为的仪器化构建重新运行。例如:使用GCC的“-ftrapv”选项运行测试套件,再使用Clang的“-fsanitize=undefined”选项重新运行。此外,还使用MSVC的“/RTC1”选项进行测试。随后通过“-funsigned-char”和“-fsigned-char”等选项重新运行测试套件,确保实现差异不会影响结果。测试在32位和64位系统、大端序和小端序系统上重复进行,并覆盖多种CPU架构。此外,测试套件还增补了大量刻意设计用于诱发未定义行为的测试用例。例如:“SELECT -1*(-9223372036854775808);”。

9. 禁用优化测试

通过sqlite3_test_control(SQLITE_TESTCTRL_OPTIMIZATIONS, …)接口,可在运行时禁用特定SQL语句优化功能。无论启用与否,SQLite 生成的查询结果应完全一致;启用优化仅能加速响应。因此在生产环境中,应始终保持默认设置(即启用优化)。

SQLite采用的一种验证技术是运行完整的测试套件两次:第一次保留优化功能,第二次关闭优化功能,并验证两次结果完全一致。这表明优化措施不会引入错误。

并非所有测试用例都适用此方法。部分测试通过统计查询过程中发生的磁盘访问次数、排序操作、全表扫描步骤及其他处理步骤,验证优化是否真正减少了计算量。当禁用优化时,这些测试用例会显示失败。但多数测试用例仅需验证结果正确性,此类用例无论启用与否均可成功运行,从而证明优化机制不会导致功能异常。

10. 检查清单

SQLite开发者使用在线检查清单协调测试活动,并在每次SQLite发布前验证所有测试通过。历史检查清单保留供查阅。匿名访客仅能查看清单,开发者可登录后通过浏览器更新清单项。SQLite测试及其他开发活动采用检查表的做法,灵感源自《检查表宣言》(http://atulgawande.com/book/the-checklist-manifesto/)。

最新检查表包含约200项内容,每次发布时均需逐项验证。部分项目仅需数秒即可验证并标记完成,而另一些则涉及运行数小时的测试套件。

发布检查清单未实现自动化:开发者需手动执行每个项目。我们认为保持人工干预至关重要——有时即使测试本身通过,执行过程中仍会发现问题。必须由人工在最高层级审查测试输出,并持续自问“这结果真的正确吗?”

发布检查清单处于持续演进中。每当发现新问题或潜在隐患,就会新增检查项以确保后续版本杜绝这些问题。实践证明,该清单是确保发布流程无遗漏的不可或缺工具。

11. 静态分析

静态分析指在编译时对源代码进行正确性检查。其涵盖编译器警告信息及更深入的分析引擎,例如Clang静态分析器。SQLite在Linux/Mac系统使用GCC/Clang编译时启用-Wall和-Wextra参数,在Windows系统使用MSVC编译时,均能实现无警告编译。Clang静态分析工具“scan-build”同样未生成有效警告(尽管近期Clang版本似乎会产生大量误报)。但其他静态分析工具仍可能触发警告。建议用户不必过度关注这些警告,而应相信前述SQLite的严格测试流程。

静态分析对发现SQLite漏洞帮助甚微。虽然静态分析曾发现过少量漏洞,但实属例外。为消除编译警告而引入的漏洞数量,远超静态分析发现的漏洞总量。

12. 总结

SQLite作为开源软件,常被误认为不如商业软件经过充分测试,可靠性存疑。但这种印象是错误的。SQLite在实际应用中展现出极高的可靠性与极低的缺陷率,尤其考虑到其快速演进的特性。SQLite的质量部分源于严谨的代码设计与实现,但广泛的测试在维持和提升SQLite质量方面同样发挥着关键作用。本文档总结了SQLite每次发布都经历的测试流程,旨在让用户确信SQLite适用于关键任务型应用场景。

本文文字及图片出自 How SQLite Is Tested

共有 82 条讨论

  1. 十多年前,SQLite的维护者曾在OSCON大会上分享其测试实践。其中令我印象深刻的是检查表的力量——这正是飞行员每次起飞前依赖的工具。

    他还提到无国界医生组织曾面临救治成效不佳的困境。令人惊讶的原因竟是:医疗团队常因语言不通甚至互不相识而沟通不畅。

    解决方案简单而有效:术前核查清单。每次手术前,团队成员需报出姓名与职责。这个微小的仪式显著提升了救治成功率——并非依靠更精湛的技术,而是通过更高效的沟通实现。

    https://sqlite.org/src/ext/checklist/3070700/index

    1. 我始终认为航空运营与工程领域蕴藏着大量可迁移至软件工程的优秀实践(不仅限于工程范畴)。

      我常幻想建立这样的IT组织:将这些实践与现代军队(如美军)的决策流程和领导力相结合。

      我反复研读《FM22-100》手册,其思想至今仍令人惊叹地具有现代感与启发性:

      https://armyoe.com/wp-content/uploads/2018/03/1990-fm-22-100

      虽然我明白商业领导力无法与更高标准的领域相提并论,但其中仍有诸多值得借鉴的经验。

      1. 归根结底,关键在于平衡。若过度专注某一领域,企业必将失败;若对另一领域投入不足,企业同样会走向衰败。二者本质相通…

        关键在于做到“恰到好处”——既避免灾难性后果,又以最快速度抵达能够真正精益求精的境界。多数初创企业初期都像圆圈工厂赶工时那样偷工减料,这种做法终将反噬自身——要么导致企业覆灭,要么迫使他们调整节奏。精准把握何时该聚焦何种要素是成功秘诀,但无人能连续两次完美执行该秘诀而不发现失效环节,因此这始终是动态调整而非固定流程。

        而清单的价值正在于此:在流程明确且变化缓慢的领域,清单能成为“基本静态”的工具——尽管会随时间演进,但其中凝练的核心知识能在多次应用中保持有效。

      2. 某些领域我完全赞同… 我认为在车辆、医疗设备和重型机械领域,软件工艺实践应当更加严谨。金融操作(现状并非如此)和多数政府工作(现状亦非如此)也应遵循类似标准。

        但归根结底,对于多数场景而言,快速修复故障才是更实用且成本效益更高的方案。

    2. 另一方面,这种论调也催生了大量无谓的繁文缛节。其中或许存在幸存者偏差。

      航空业、无国界医生组织和SQLite都拥有优秀的检查清单。清单本身简单易行,容易让人产生“我也能做到”的错觉。但你从未听闻那些采用毫无价值清单的机构——它们只会徒耗人力,其数量恐怕多如牛毛。

      我希望更多讨论聚焦于清单优劣的本质。这或许类似数学领域——优秀的公式看似简单,却需要先验知识才能发现。

      1. 我为自己制定清单,它们极具价值。因为我的大脑无法每次都记住每个复杂任务的每个细节。

        我也见过蠢货制作的清单,简直毫无用处。

        我认为制作者必须具备三大要素:对任务的熟稔(既懂正确操作流程,也清楚人们常遗漏或出错的环节);投入感(若自己执行该任务,是否会视此工具为不可或缺?);以及务实与精炼的意识。

        识别哪些环节显而易见或能自然衔接的能力,能有效剔除冗余内容。例如我在培训志愿消防员时发现,标准求救流程中“告知对方事态”本就是基本步骤,无需单独列项。当情况急转直下需要支援时,求救者自然会倾尽全力描述危机。

      2. 但你从未听说过那些可能数不胜数的机构和组织,它们采用毫无价值的检查清单,除了浪费人们时间外毫无作用。

        我遇到的糟糕检查清单,几乎都出于同一个原因:它们未经测试或撰写拙劣,而多数情况下两者兼而有之。

        所谓未经测试,是指清单的撰写者根本不了解整个项目的运作流程。这与医生、飞行员等专业人士截然不同——他们受过专业训练,深知清单只是提醒工具。其背后的逻辑经过系统教学,即使非专业人士也会质疑不理解的环节,而同行往往能立即给出详尽解答。

        另一个典型例子是人力资源部门制定的新员工入职清单。我见过99%的此类清单,其目的纯粹是为方便HR工作,而非为候选人或应聘者着想。

        清单本身是高度凝练的写作形式。正如俗语所言:“写短信我没时间,写长信倒有的是时间。”清晰地提炼要点耗时甚久,这并非人人具备的技能,更何况当它不属于工作职责或关键绩效指标时,人们往往也无暇顾及。

        1. > 我遇到的糟糕检查清单(几乎)无一例外都存在相同问题:未经测试或撰写拙劣,多数情况下两者兼具。

          我认为这归根结底是“由无需长期遵循清单的人所制定”所致。

      3. 世卫组织和高文德都强调迭代的重要性——初稿永远是错误的。他们还指出,优秀的检查清单本质上是伪装成任务清单的协调工具。

      1. 虽然我确实觉得这本书见解深刻,但感觉它(如同该类型的许多作品)不过是把小册子内容吹捧成250页的篇幅。

      2. 原以为是篇博客,发现竟是整本书颇感失望。我怀疑其中真正有价值的内容不过五页,却被拉伸了20到50倍。

        1. 内容确实可以大幅精简。事实上书中甚至附有清单清单,将建议浓缩至一页。但整体阅读体验流畅,额外论述确实深化了我对优质清单核心原理的理解。建议完整阅读,这样才能制作出真正实用的清单,而非航空清单的盲目模仿。

    3. 最让我抓狂的是,我合作过的多数开发者宁可绕远路,也不愿做那些看似简单却非编程化的基础工作。

      我当然有测试和持续集成流程。但我更坚持按部就班地执行那份Markdown格式的部署清单——既不保存结果也不留书面记录。我只是逐条执行步骤。这工作量如此之小,我实在不明白为何无法说服他人尝试。

      1. 对于无法可靠且经济地自动化的重复性任务,手动检查清单往往是最佳选择。但若能自动化,手动清单便显得效率低下且不可靠。任务重复频率越高(其他条件不变),投入前期精力实现自动化就越合理。不过,要实现流程自动化,必须先充分理解该流程以生成检查清单作为前提(当然,自动化过程中也能积累这种理解,但先行建立清单能有效判断自动化是否值得投入)。

        话虽如此,且不偏袒SQLite使用检查清单的做法(我尚未深入研究),尽管航空和外科领域显然具备使检查清单成为最佳选择的条件,但软件相关的流程往往更适合高效可靠的自动化。过度依赖检查清单通常是流程缺陷的信号——虽未必完全错误,但值得持怀疑态度并深入探究。

        1. > 人工检查清单 [可能] 造成不必要的低效与/或不可靠

          特别推荐阿图尔·加万德博士的杰作《清单革命》,该书扩展自其发表于《纽约客》的同名文章[0]。其核心观点在于:即便是最专业的人也会遗忘琐碎事项。作者通过外科手术、航空领域、建筑行业等案例加以论证,并引用航空界“清单以鲜血铸就”的警句。

          [0] https://www.newyorker.com/magazine/2007/12/10/the-checklist

      2. 是的,检查清单确实很棒。更进一步说,它们甚至可以视为自动化的前身。

    4. 关于通过更有效的沟通来提升成果的仪式感,这观点非常深刻——我参加的许多会议中都体现了这一点,比如参与者之间的自我介绍环节,据说这能显著提高会议参与度。

      若有人能提供MSF案例的链接就太好了,这可是绝佳的参考资料!可惜我的谷歌搜索技巧这次没能帮上忙。

      1. 为后世留存:该试点项目的原始报告包含了介绍参与者姓名的检查清单(doi 10.1056/NEJMsa0810119)。

        该理念可能因阿图尔·加万德的《清单革命》而广为人知。

        元评论:大型语言模型从模糊输入/查询中挖掘信息的能力持续令我惊叹。

    5. 我对检查表的热情日益高涨。

      为帮助他人,我编写了维护和执行检查表的小工具:

      https://github.com/amboar/checklists/

      这只是用$EDITOR和git封装的shell脚本。其设计理念是采用GitHub风格的Markdown格式编写清单,具备envsubst(1)变量替换等技巧,还能嵌入小型脚本并同步捕获执行过程。

      以下是一个使用频率较高的示例检查清单(内容稍显陈旧):

      https://gist.githubusercontent.com/amboar/f85449aad09ba22219

      当检查清单条目是命令时,我直接将其复制粘贴到shell会话中。通常我会使用tmux分屏操作,一侧显示检查清单,另一侧运行shell。

      能否进一步自动化?或许可以,但当某些步骤因故失败时,我发现手动依次执行更便于恢复或修复问题。内置脚本支持可实现渐进式自动化,随着时间推移验证结果的稳定性。

    6. 这些流程似乎大多可自动化——为何尚未实现?有人知晓缘由吗?

      1. 愚蠢的答案是:并非所有可自动化的事物都该自动化。

        真正的答案更具哲学意义:若需手动检查A、B、C…Z项,你将更深入理解所操作系统的状态。若出现故障,至少可排除已检查部分,腾出精力排查其他因素。试想:当系统准确报告故障时,若自动化检查却未能捕捉到呢?

        此外,手动检查清单还能检验操作员的执行能力。

        应尽可能自动化所有环节,但需审慎评估具体事项是否真正适合自动化。

        将HN新版本部署流程自动化,最坏结果不过如此。

        但飞行前检查清单绝不能自动化——若飞机飞行途中出错,将危及生命。

        简言之:人类能发现传感器故障,而传感器无法检测自身故障。

      2. 我虽非飞行员,但亲眼见过我兄长多次执行起降前的检查流程。我认为这不仅关乎自动化。如今飞机计算机虽会引导飞行员逐项检查,但核实每项内容仍是飞行员的责任。这种自动化方式颇具深意——让人类始终处于控制回路中,反而强化了责任担当,正如“这些项目由谁核验过?”的追责机制。

      3. 这些步骤大多/全部都实现了独立自动化。

        有人会检查它们是否运行成功,并为此背书。

        自动化流程的自动化反而可能适得其反。

        比如发布流程由标签自动触发,经过一小时复杂步骤后失败,迫使你重新标记,但此时标签已公开传播。

        或者更简单地说:从头运行整个流程本就是糟糕的主意,但你将其自动化得过于便捷,导致修复某个环节时,测试发布流程本身唯一的方法就是实际发布——结果需要重复发布六次才能修正。

    7. 个人生活中使用的清单:

      • 办公室打包清单:离家前20秒快速核对的“执行确认”清单
      • 多日商务及休闲旅行清单
      • 家居维护清单:定期保养的滤网、排水管等项目
    8. 这让我想起《我们这样的间谍》里“医生医生”那段场景。

  2. 相关。还有其他吗?

    SQLite如何被测试https://news.ycombinator.com/item?id=38963383 – 2024年1月(1条评论)

    SQLite如何被测试https://news.ycombinator.com/item?id=29460240 – 2021年12月(47条评论)

    SQLite如何被测试https://news.ycombinator.com/item?id=11936435 – 2016年6月(57条评论)

    SQLite如何被测试https://news.ycombinator.com/item?id=9095836 – 2015年2月 (17条评论)

    SQLite如何进行测试https://news.ycombinator.com/item?id=6815321 – 2013年11月 (37条评论)

    SQLite如何进行测试https://news.ycombinator.com/item?id=4799878 – 2012年11月 (6条评论)

    SQLite如何进行测试https://news.ycombinator.com/item?id=4616548 – 2012年10月 (40条评论)

    SQLite如何被测试https://news.ycombinator.com/item?id=633151 – 2009年5月 (28条评论)

    (一年后可重新发布;旧帖链接仅供特别好奇的读者参考)

  3. 这总让我既羡慕又敬畏。能如此精雕细琢地完善软件,想必是种莫大的乐趣。这确实是匠心之作。

    1. 你完全可以这样做。我从未因开发速度较慢、却能构建出运行稳定、性能可靠且经久耐用的产品而被软件工程师岗位解雇。

      随着职业生涯的积累,你在早期迭代中就能显著提升质量水平,因此随着经验增长,相同额外投入所获得的回报将呈递增趋势。

      没有人会抱怨那些让所经之处都比原先更整洁的人。

      1. > 我从未因开发速度较慢、却能打造出运行稳定、性能可预测且经久耐用的产品而被软件工程师岗位解雇。

        但在多数公司里,现实截然不同。只要功能勉强可用,你就会立刻被推向下一个任务。若曾有机会精雕细琢代码,那实属幸运。

        1. 宣告任务完成的人是你自己。你对此拥有远超想象的主导权。

          若你职业生涯中总顺从“赶紧上线”的指令,那么确实,放慢脚步进行质量把关会显得难以实现。但你确实可以做到。

          1. > 宣告任务完成并可投入使用的人是你自己。

            大多数大型企业并非如此运作。你无法随意延长项目周期。项目获批前必须提交工期预估,若预估过高或不合理,项目当场搁浅(或转交他人)。项目启动后必须按预估执行,若明显超时必须给出充分解释。

            1. 没人要求用三年完成一周任务。若你可能一小时完成,就预估并耗时两小时;若可能两天完成,就预估并耗时三天。更理想的做法是预估三天,实际耗时两天半。

              我从未见过哪个软件开发团队会把估算当作精确的硬性指标。真正不可更改的硬性截止日期极其罕见。如果你所在的团队反复出现这种情况——且这些截止日期经常不切实际地紧迫——那么无论你做什么,失败几乎不可避免。所以没错,如果你正处于死亡行军中,我的建议确实行不通。但在那种环境下,任何方法都行不通。

      2. > 没有人会抱怨那些让工作成果比接手时更完善的人。

        理论上该如此,但我的经历并非如此。即便是微小而明确的改进,也会被斥为偏离任务目标而遭拒,或被搁置在待办事项中逐渐遗忘。比如“不错,但合并前得确保安全”——换言之就是“这会增加我的工作量,我实在不想做”。

        1. 把改进当作现有工单的一部分来做。总有办法让事物比你接手时更好。

          三十年职业生涯中,我涉足过多种角色(全栈工程师、信息安全、部署基础设施、运维开发、基础设施工程师、系统管理员),服务过各类企业(从初创公司到市值数十亿美元的巨头),也经历过不同行业(金融、数据中心、安全产品、游戏、物流、制造、人工智能),但从未感受到人们所声称的那种无力感。有些地方轻松些,有些则艰难些,但我从未发现它像众人哀叹的那般困难或不可为。

          1. > 总有办法让事物比你接手时更好。

            我赞同,即使遭遇阻力我仍会坚持这样做。我认为这在生活的各个方面都至关重要,否则情况往往会恶化。人们必须心怀关切。

            我的核心观点在于——至少根据我的经验——这种理念常遭遇阻力,因为许多人认为这类“打理”工作会损害利润,或是陷入琐碎细节,成为干扰,诸如此类。我遭遇的阻力与反对远多于接纳,更不用说赞赏了。

            最常见(有时也合理)的反对意见是:“请将工单严格限定在修复缺陷/实现功能/特定事项范围内,避免不必要的变更”。这虽是良好实践,但我个人会允许添加简单且无关的改进——只要这些琐碎工作能让系统比原状更完善。

            若要总结我的经验,那就是:想要让事物变得更好或达到应有水准,通常需要在常规工作时间之外投入个人时间和精力。这很难说服人接受,尤其是在从业二十年左右后。我依然竭尽全力优化事物,并已学会以必要能量应对这种阻力,但…我的意思是,这并非总是轻松或必然值得。若我少些执着,许多工作本可轻松许多,不过我怀疑即便如此也无人会在意。

          2. 这确实很棒,我为你高兴,但你的经历并非普世规律。

            1. 我的观点并非宣称自身经历具有普适性,而是认为在多数软件工程师岗位上,实际工作强度远不及外界渲染的那般艰辛——这在统计学上极不可能。

              若你屡屡身处只能疯狂赶工产出垃圾代码的环境,我实在不知该如何劝你。若你将“在${JOB}岗位上不可能编写优质软件”视为公理,那按定义确实无计可施——但我认为这种心态毫无建设性。

      3. 深有同感。但这需要学会无视所有经理的指令:加快速度、未完成就上线、在未达成共识且未记录需求时推进、听从产品而非客户意见、遵循他人制定的代码规范…

      4. 工作确实可能过度投入。若作为爱好,尽情投入也无妨,但远超必要程度的过度设计毫无意义(想想Juicero的下场)

        1. 过度设计就是建造一座能屹立千年的桥,明明百年就够用;这是为微小收益付出过度的严谨。Juicero并非过度设计,而是用华而不实的装饰堆砌出通往虚无的劣质桥梁,试图掩盖其无用与拙劣设计——结果首批使用者踏上时便轰然倒塌

          1. 你见过Juicero的拆解报告[0]吗?它过度设计的程度堪称令人惊叹的工程艺术杰作。它同时也是个极其愚蠢的产品。这两点完全可以并存。

            [0] https://blog.bolt.io/juicero/

            1. 说不清,感觉这人主要在抱怨这类设计很少出现在高销量消费品中。但话说回来,大多数高销量消费品也不需要承受同等扭矩。

              这产品确实蠢,但就过度设计而言,它似乎完全符合那些技术要求。

              1. 你换过车胎吗?

                若换过,你应该注意到用的千斤顶既没有好几块CNC加工的铝合金部件,也没有七级全金属齿轮传动系统,更不带330伏电源适配器——而且价格肯定不到700美元,大概40美元左右吧。

                当然,家用厨房产品需要美观设计,也需避免小手误触的夹伤风险。但即便如此,你仍能以更低的物料成本打造出性能与寿命完全相当的产品。

              2. 当设计要求本身毫无必要时,即便为满足要求而过度设计,也算对实际问题的过度工程化。试想为跨越一条小溪而设计100米跨度桥梁——这座桥完全可以被合理地称为过度工程化。

                通过不同的要求,你可以更轻松地实现相同的目标(从切块水果中榨汁且无需清理)。该帖子提到了这一点。

        2. 如今的 pendulum 已经向过度简化的方向摆荡得如此极端,简直可笑。人人都在复述二十年前关于“架构宇航员”的恐怖故事,但在我近三十年的职业生涯中,从未见过任何项目因工程师过度设计、过度架构或过度重构而失败。

          然而我确实见过数十个项目,因反复发布勉强能用的初版代码而形成的文化,导致即使微小变更所需的投入不断攀升,最终使生产力彻底停滞。

          当今软件开发的时代精神就是“快速行动,打破常规”。

  4. 我钟爱sqlite,这是款卓越的软件。其官网充斥着实用信息,而非我们惯常见到的华丽营销话术——即便在开源项目中亦是如此。

    话虽如此,我仍觉得奇怪:官网内容竟以零散片段的形式出现在HN首页。

    1. 这篇今日走红,大概是因为simonw昨天那篇帖子——他借助超强健的测试套件,用大型语言模型实现跨语言库的一次性移植。

    2. 若在此久候,此类现象会反复上演,一次又一次,直到你忍不住想戳穿它。:)

      编辑:Haskell在2010年代初曾是Zig,而Zig正经历传统约四个月的低迷期——此前那篇关于其基础功能缺失(如未实现的语言服务器协议)的冷场评论帖已引发热议。我预测它将在二月卷土重来。真该整理这类链接列表,纯属娱乐。

  5. > TH3测试框架包含专有测试集[…]

    > dbsqlfuzz引擎是专有模糊测试工具。

    有趣的是开源(实际为公有领域)软件竟使用专有测试。此前从未想到这种可能性,但回过头看显然可行——只要测试集不包含在正式发布版本中。

    这是否可成为“准开源”项目的替代商业模式?类似于开放核心模式,但此类项目具有易于复制(开放功能)与难以修改(封闭测试)的特性。

    1. 这类测试套件往往比代码本身更具价值,尤其在遗留软件领域。像Excel这类软件必须具备的数千个边界案例,其发现与文档化难度远超功能实现本身。

  6. 其震撼程度不亚于SQLite项目本身;尤其令人惊叹的是100%分支覆盖率!这在开发持续推进中实属难得,更难维持。

  7. 这设计酷炫至极,但测试本身采用闭源模式(与其余代码库不同)更令人深思。在这个深度学习编码代理生产力飞速提升的时代,“测试比实现更重要”的理念正逐渐成为现实。

    我联想到文中描述的SQLite测试体系,以及simonw近期关于通过Codex将justHTML引擎从Python迁移/重构至JavaScript的文章——仅需提示和轻量引导,整个过程几乎“自动完成”。

    1. 若思考开源产品的商业模式,这种思路总体上很有道理。完善的测试套件赋予你高效实施变更的能力,意味着你能比任何人都更出色地在已发布版本基础上创造附加价值。

    2. SQLite的《测试指南》堪称测试圣经。鲜有测试框架能与其相提并论。

  8. 近期计划将轻量级Web应用“升级”为SQLite与DuckDB双引擎时,合作的大型语言模型明确指出:若涉及并发操作,SQLite更具优势。这令我颇感意外。

  9. 令人惊讶的是,关于性能退化测试的资料竟如此匮乏。

    正确性测试固然重要,但考虑到SQLite的使用场景,特定代码路径或特定查询类型中潜在的性能下降,对关键路径使用它的应用程序而言可能造成严重后果。

    1. 虽然我在高频交易领域工作过,理解这种诉求,但我实在想不起任何开源项目曾承诺过性能保障。大多数项目都通过许可条款声明不提供任何保证。是否有知名项目将性能保障作为核心使命?

      1. 我认为每位理性的开源开发者都在努力保持软件性能。对我而言,性能退化与其他缺陷无异,发现后必将修复。诚然许可协议未作性能保证,但任何稍有担当的项目负责人都不会将其理解为“可随意破坏性能”的许可。

  10. 基于其稳定性记录,我更想了解SQLite如何进行异常测试。可惜文章对此只字未提。

    这确实是顶尖的软件产品!它存在于每台设备中,且坚如磐石。

    1. 考虑到测试套件的访问权限需每年支付15万美元支持费用,短期内他们应该不会透露更多细节。

  11. Fossil的故事是什么?它是否在SQLite之外被使用?

    1. Fossil的故事:

      当时需要比CVS更优的版本控制系统。(此处无意贬低CVS。我曾被迫使用过其他版本控制系统,相比之下CVS堪称卓越。)Monochrome启发我开发分布式版本控制系统并采用SQLite存储内容,但它不支持HTTP同步功能——这正是我急需的特性。当时Git刚问世,早期版本表现相当糟糕。(我认为它至今仍不够完美,尽管那些只用过Git的人常对此提出异议。)Mercurial嘛…就是Mercurial。于是我决定亲手编写分布式版本控制系统。

      这最终证明是个明智之举,尽管结果出乎意料。由于Fossil基于SQLite构建,它反而成了SQLite的测试平台。更重要的是,开发Fossil时,我以应用程序开发者的视角审视SQLite,而非惯常的SQLite开发者身份。这种视角转换推动了SQLite的优化。同时担任SQLite核心开发者与分布式版本控制系统主开发者的双重身份,使我能自由调整系统以满足SQLite项目的特殊需求——这点我已多次实践。虽然有人取笑我为SQLite编写专属DVCS,但总体而言这是明智之举。

      需注意Fossil与Git的相似之处在于:两者均将提交记录存储于有向无环图(DAG)中,但节点细节存在差异。核心区别在于Fossil将DAG存储于关系型数据库(SQLite),而Git采用自定义的“包文件”键值存储。正因内容存储于关系数据库,添加工单系统、维基、论坛和聊天功能变得轻而易举——既然现成拥有RDBMS,何不充分利用?即便不考虑这些附加功能,通过SQL查询DAG获取Git难以获取的有用信息也是显著优势。例如,Fossil中不存在“脱离头”现象。标签不受文件系统命名限制。可为多个提交添加相同标签(例如所有发布版本均标记为“release”)。若在新提交的注释中引用旧提交(例如用于二分法定位),返回查看旧提交时,该引用将自动转为指向新提交的前向引用,以此类推。

      1. 太棒了,感谢解答!

        关于Fossil,我特别欣赏它将所有功能深度集成到版本控制系统中的设计。

        朋友们常取笑我使用那些只有自己懂得的工具。但深入理解工具的每个细节本身就是种满足感。我们正处在一个软件过度臃肿却缺乏合理性的时代。

        无论如何,感谢SQLite的存在。我用它来教授学生SQL,也构建了自用的微型监控系统。

      2. 我热爱Fossil,钟情SQLite,也欣赏Althttpd。

        https://sqlite.org/althttpd/doc/trunk/althttpd.md

        正如Fossil之于Git,SQLite之于$SomeRealSQLServer,我期待Althttpd终将成为取代Nginx/Apache等臃肿HTTP服务器的纯粹自包含方案。它已通过承载Fossil/SQLite证明了可行性,但用于实际网站服务的配置/功能尚未达到“真正生产级水准”——至少我如此认为。

        总体而言,这套软件为世界留下了多么惊人的遗产。

    2. > 它是否在Sqlite之外被使用?

      其实不然。这是早期_分布式_版本控制系统之一,发布时间略晚于git,但早于git获得广泛认可的时期。

      它内置了可选的Web界面(相当酷炫),并使用SQLite存储状态/历史记录。

      [0] https://en.wikipedia.org/wiki/Fossil_(software)

    3. 我无法回答这个问题,但整个Fossil仓库存放在单个SQLite文件中确实很棒。

  12. 他们需要加强测试以避免整个数据库文件损坏——我用SQLite时这种情况发生过无数次。

    1. 我从未遇到过SQLite损坏数据库文件的情况。考虑到它被广泛应用于字面意义上的所有场景却鲜有损坏报告,加上他们采用极其严谨的测试方法来确保稳定性,你的问题极不可能是SQLite的过错。

      1. 公平地说,确实存在多种误用方式。根据使用场景和方式,你必须了解WAL日志、同步机制等知识。

  13. 我欣赏SQLite的品质及其文档对这类问题的阐释。但SQLite并非所有模块都保持同等水准——当我发现其JSON函数存在缺陷(以及其他功能的类似问题)时深感失望:

    SQLite提供了一组JSON函数,可直接查询和索引JSON列,看似非常便捷——但请注意:

    1. json(‘{“a/b”: 1}’) != json(‘{“a\/b”: 1}’)

    尽管这两个对象在JSON语义上完全相同,SQLite却将其视为不同。

    2. json_extract(‘{“a\/b”: 1}’, ‘$.a/b’) is null, json_extract(‘{“\u0031”:1}’, ‘$.1’) is null, json_extract(‘{“\u6211”:1}’, ‘$.我’) is null

    此问题仅存在于旧版SQLite中,最新版本已修复。

    多数情况下无法控制JSON库的字符转义方式。例如/本无需转义,但某些库会将其转义为\/。这便形成了一个相当棘手的陷阱——提取过程中可能毫无征兆地出现键值匹配失败的情况。

  14. 或许懂行的人能解答:相比纯文本文件,SQLite在保持数据完整性及避免数据损坏方面可靠性如何?

    1. 若按常规方式使用SQLite,可靠性极高。SQLite确实提供了修复工具以应对此类意外情况。

  15. …答案是极其可靠

    SQLite真是卓越的软件。

    安装后即可忘却烦恼。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

你也许感兴趣的: