


开发数据库索引扩展,保持测试通过并能承受崩溃
在让 AI 构建或修改数据库索引扩展时使用。它把每个测试失败都当真,提交前按固定顺序检查,并把每次页面变更写入预写日志,让崩溃恢复和备库正常工作。
要使用这个AI规则
## 重新安装后重启服务器,并先读存储规范
你参与开发的是一个数据库扩展,它实现了一种索引访问方法,底层由位于数据库自身关系文件内部的分层磁盘存储支撑。这项工作紧邻持久性、恢复和并发领域,因此在每一次判断取舍中,正确性都优先于交付速度。
需要始终坚持的运行前提:
- 该扩展在服务器启动时加载。必须先将其加入服务器的预加载配置并重启,扩展才能被创建;在开发过程中重新安装二进制文件之后,还需要再次重启。加载的旧二进制文件会产生误导性的测试结果;当行为与你刚刚构建的源代码相矛盾时,首先应怀疑是漏掉了重启。
- 只要项目条件允许,就把扩展的对象安装到专用 schema 中,而不是默认的 public 命名空间,以保持命名空间整洁。
- 优先采用能够完整解决问题的最小改动。不要重构任务并未要求改动的相邻代码。
- 仓库中的设计规格具有约束力。在改动写入、读取或溢出路径之前,先阅读存储规格;当设计本身发生变化时,要在同一次改动中同步更新规格文档。
## 不把测试失败说成无关,逐个调查并修复
主分支要始终保持绿色。每一次测试失败都很重要。
- 绝不要以「原本就有」或「与本次无关」为由忽视失败。实际上它几乎从来都不是无关的。
- 如果你确实认为某个失败与你的改动无关,就去验证它:检出主分支并在那里运行同样的测试。
- 在主分支上能复现并不能成为放过该失败的理由。它依然需要被调查和修复,而不是被归档或标注为「已知问题」。
- 绝不要仅仅为了把红色的运行结果变绿,就把测试标记为跳过、放宽断言,或者修改预期输出文件。只有当你能够准确说明新输出为何是正确的之后,才可以修改预期值。
- 诚实并尽早地报告失败,包括你自己造成的失败。隐瞒一次失败的运行比缺陷本身更糟糕。
## 每次提交前运行回归测试并查看差异文件
在提交任何改动之前,完成以下每一个步骤。不要因为某个改动「看起来很简单」就调换顺序或省略步骤。
1. 编译扩展。
2. 针对已安装的构建版本运行声明式回归测试套件。
3. 检查该次运行产生的回归差异文件,而不只是看通过或失败的汇总结果。套件有可能报告成功,却掩盖了你引入的差异。
4. 如果你修改了任何用户可见的消息,同步更新相应的预期输出文件使之匹配,并在提交说明中写明该消息为何变更。
5. 运行格式检查。
6. 在更新任何预期文件之后重新运行回归套件,使最终记录下来的那次运行反映的是已提交的代码树。
除常规套件之外,只要改动涉及加锁、写入路径、恢复或段生命周期,就要运行并发、崩溃恢复、多后端和压力这几个层级的测试。明确说明你运行了哪些层级、没有运行哪些层级。
## 把每个更改放在能解决问题的最低代码层
源代码按层次组织,每一处改动都应落在能够完整解决问题的最低层。不要把存储细节向上泄漏到数据库接口层,也不要为了走捷径修复问题而向下越过某个抽象层。
- 第一层,数据库接口:访问方法处理器、索引构建、扫描和 vacuum 入口点;自定义数据类型;规划器钩子与代价估算。
- 第二层,索引协调:排名与打分、top-k 优化、索引状态生命周期、注册表、元页处理,以及对倒排源的抽象。
- 第三层,存储:零级写缓冲、不可变段、合并与压缩。
- 横切部分:调试与转储工具,以及模块初始化。
当一处改动跨越了多个层次时,要在改动说明中明确描述这次跨界。悄悄拓宽接口的修复属于设计变更,必须按设计变更来评审。
## 修改存储前,先弄懂写缓冲和不可变段的结构
该索引采用日志结构化的分层存储方案。在改动它的任何部分之前,先理解这个模型。
- 零级是写缓冲:一条面向追加的页面链,存放在索引关系自身内部,并锚定在一个元页上。写入会把带有行标识符、长度和打包负载的记录追加到尾页。所有变更都在标准缓冲区锁的保护下进行,并且都写入预写日志。
- 段是不可变的、按块组织的磁盘结构,其中携带跳跃条目,使扫描可以直接定位到某个块,而不必遍历每一条倒排记录。
- 从写缓冲溢出到新段是由阈值驱动的;当某一层积累到足够数量的段时,段会按层进行合并压实。
- Top-k 查询利用块级上界,把不可能对结果产生贡献的块整块跳过;一旦剩余上界无法超过当前结果集,就提前终止。
内存缓存可以为查询读取提供加速,但持久化在磁盘上的链条始终是唯一的事实来源,只读备库也始终读取持久化的链条而不是缓存。
## 所有页面变更都走标准预写日志,不用自定义日志
所有页面变更都通过数据库的通用预写日志设施来记录,而不要为该扩展注册自定义的资源管理器。
这条约束存在的原因:使用通用日志时,数据库自带的重放机制会在崩溃恢复期间以及在流复制备库上重建每一个页面,包括写缓冲链页、段页和元页。正是这一点,才使得单页 redo 辅助工具以及任何无法加载扩展共享库的重放场景都能正确工作。
必须遵守的后果:
- 引入自定义资源管理器是一项重大设计决策,而不是实现细节。它会破坏所有无法加载该扩展的重放场景。绝不要在无关的改动中隐式引入它;应当作为独立提案提出,并接受单独评审。
- 任何新的磁盘结构都必须能够仅凭已记录的日志记录、由数据库自带的重放机制重建出来。如果某个结构只能由扩展代码重建,那么设计就是错的。
- 绝不要绕过缓冲区管理器,也不要在未记录日志的情况下写页面,哪怕你认为这个结构是可丢弃的。
## 合并腾出的页面,要等备库查询读完后再回收
合并操作不得立即把被替换掉的源页面归还给空闲空间映射。这样做在主库上是安全的,但对热备库上正在执行的读查询是不安全的,因为在没有自定义资源管理器的情况下,重放期间没有任何机制可以解决恢复冲突:备库会重用某个正在运行的查询仍在读取的页面。
必须采用的模式:
- 把被替换掉的页面暂存到一条有日志记录的墓碑链中,该链锚定在元页上,并打上此次合并的事务标识符。
- 只有当后续的 vacuum 或下一次合并观察到该标记早于最旧的不可移除事务视界时,才把暂存页面归还给空闲空间映射。那个视界就是备库安全的回收边界。
- 要求在部署中为承担查询的热备库启用反馈。这样它们最旧的快照就会拖住主库的视界,直到它们读完那些暂存页面。
- 提供一个诊断函数,报告当前暂存了多少页面,使运维人员能够观察积压的增长,而不是靠猜测。
把这一原则一般化:当某个组件释放的共享资源可能仍被远端或延迟的读取方持有时,把释放动作推迟到一个可观测的视界之后,并给运维人员提供衡量被推迟集合的手段。
## 用排名分数排序,而不是拿它过滤
排名分数的存在是为了排序,而不是为了过滤。它们的数值是不透明的,不存在有意义的阈值,因此只要是把分数与常量比较用作图省事的过滤手段——无论出现在测试、示例还是文档中——都属于反模式。
规则:
- 不要为了筛出「匹配」的行,而写出把排名分数与某个数字比较的过滤条件。
- 包含排名运算符的过滤条件会触发通过顺序扫描进行的独立打分,而不是索引扫描。索引扫描需要排序子句。
- 正确的写法是按排名表达式排序并施加行数限制。
- 若要统计匹配的文档数,应包裹一个带排序的子查询并统计其行数,而不是在分数上做过滤。
- 例外:如果某个测试的明确目标就是独立打分路径,例如把索引扫描的结果与独立打分的结果作对比,那么它可以在过滤条件中使用该运算符。这类测试必须在注释中说明这一意图,以免后来的读者误抄这种写法。
一般性的教训:当一个 API 只有一种预期用法时,就要让错误的用法在团队交付的每一个示例中都显得明显不对,并且只在有明确文档说明意图的前提下留出例外。
## 把每个设置连同安全的默认值写进同一张表
每一个运行时可调参数都要记录在同一张表中,包含名称、说明和默认值。未被记录的可调参数视为缺陷。
- 选择对普通部署而言安全且不改变行为的默认值。对阈值类设置用零表示「禁用」,对预算类设置用零表示「不限」,并在说明中写清楚这一点。
- 注入故障或强制失败的设置仅用于测试,必须限制为特权用户可用,并且必须在说明中标注为「仅供测试」。
- 用于打分细节、优化统计和缓存结果的日志开关默认关闭;它们是诊断辅助手段,不属于正常运行的一部分。
- 内存预算是分级的,而不是一堵硬墙:单索引软上限会让该索引降级到持久化的回退路径,全局软上限会驱逐调用方之外占用最大的消费者,全局硬上限则直接拒绝新的分配。每一级都必须能在日志中观察到。
- 索引级选项,例如文本分析配置以及排名模型的饱和度和长度归一化参数,要以同样的严格程度记录;必填选项要标注为必填,而不是给一个悄无声息的默认值。
## 标记失效文档,并写明下次合并前统计信息会略微过时
Vacuum 会在每个段的存活位图中标记已死文档,每个文档占一位,而不是重建整个段。其代价与已死文档的数量成正比,而不是与整个语料库成正比。已死文档在打分期间被过滤掉,并在下一次段合并时被物理删除。
已知的取舍必须保持记录在案而不是被隐藏:把文档标记为已死并不会更新该段的文档总数、总长度统计或每词项的文档频率。因此在下一次合并或压实修正这些数据之前,排名使用的是略显陈旧的语料统计。删除量少时影响可以忽略;而大规模删除则可能在下次合并之前对排名质量产生可见的影响。
把这当作一个范本。当某项优化以牺牲准确性或新鲜度来换取成本时,要把这个取舍写在维护者能找到的地方,说明它在什么条件下会变得显著,并指明由哪个事件来消解它。未被记录的近似处理,日后会变成无法解释的缺陷报告。
## 分层维护测试,并提供内置诊断函数
维护相互独立的测试层级,并清楚每一层各自保护什么:
- 声明式回归测试,覆盖核心功能、打分正确性、存储与段、查询优化路径、分区表和继承表等表变体,以及边界情况,其参考输出纳入版本控制。
- 脚本驱动的测试,覆盖多会话并发、崩溃恢复、多后端下的段行为,以及长时间运行的压力测试。
持续集成覆盖每一个受支持的数据库主版本、sanitizer 构建、格式校验、版本升级测试、发布打包、基准测试,以及带内存泄漏检测的夜间压力运行。
把诊断能力作为一等公民的函数来交付,而不是临时的打印输出:转储内部结构及其词项字典、倒排列表和文档条目;把整个索引转储到文件;汇总高层统计信息;强制触发一次溢出并报告移动了多少条目;报告暂存页面数量;以及渲染页面布局的可视化图示。
保持基准测试可复现:用一个小型的经典信息检索数据集获取快速信号,用更大的公开数据集检验规模,配一个内存与容量压力脚本,再加上与成熟外部引擎的对比,以验证结果不只是自洽而已。
## 使用自动格式化工具,推送前变基到 main
- 格式化是自动化的。提供并使用相应命令来格式化整个代码树、在不修改的前提下检查格式、显示差异,以及格式化单个文件。绝不要手工排版去对抗工具。
- 遵循宿主数据库项目的编码约定和内存分配模式,而不是自创一套本地规矩,并按项目配置的宽度折行。
- 并发不变式:每一个共享结构都由合适的锁保护,内部化结构通过哈希表锁来保证安全,事务隔离则通过在提交和中止两条路径上严格执行清理来维持。
- 拉取请求合并之后,务必检查主分支是否已经前进,即使执行合并的是别人。抓取更新并把活跃分支变基到更新后的主分支上,以免冲突不断累积。
- 推送之前,确认分支与主分支保持同步,以免历史分叉把一个小改动变成一次冲突重重的合并。
- 遵循仓库中记录的发布流程,而不是随手打标签或自行制作产物。
