☰
基于Boost组件族的搜索引擎测试实践:从功能验证到72小时长稳
2026/10/14 3:47:53 网站建设 项目流程

写一个搜索引擎的demo不难,难的是写完之后能拿出一份像样的测试报告。最近我把手头一个基于Boost组件族的站内文档检索引擎完整测了一遍,从功能正确性到72小时长稳,中间翻了好几次车。这篇就当作这份Boost搜索引擎测试报告的公开版,把项目结构、测试方法、数据口径、翻车记录都放出来,给准备做检索类中间件或者正在写压测方案的同学一个参考。

1. 被测对象:基于Boost组件族的站内文档检索引擎

1.1 为什么从Boost库里挑组件

这个项目本来是给团队内部做的文档全文检索服务,数据规模在百万篇文档级别,查询走HTTP接口。规划阶段对比过不少方案,最终还是决定以Boost组件族为基础自己搭一套,而不是直接套用完整搜索引擎框架。核心原因是:Boost提供的不是某一个“搜索套件”,而是一整套可拼装的基础件——网络、并发容器、正则、字符串处理、索引容器都能在同一套技术体系里解决,CI里只需要维护一个版本,心智负担小很多。

实际用到的组件大致是这些:

  • Boost.Asio:异步网络IO,承载HTTP服务端和压测客户端。
  • Boost.Beast:HTTP协议解析,负责请求/响应编解码。
  • Boost.Multi-Index:管理倒排索引、文档元数据、各种排序维度。
  • Boost.Regex:查询语句的词法解析和合法校验。
  • Boost.Thread / Boost.Atomic:并发任务调度与无锁标志位。
  • Boost.Interprocess:可选的共享内存通道,用于进程间查询。

选型时也考虑过只用标准库硬写,但标准库缺了像Multi-Index这样“一个对象上维护多套索引视图”的容器,自己实现红黑树、哈希表、双链表的多索引同步很费劲,而且容易藏bug。Boost把这一层补齐了,代价是编译时间变长,但换来的是索引管理上的大幅简化。

1.2 模块划分与索引存储结构

整个引擎分三个模块:离线索引构建器、在线查询服务、存储访问层。

  • 离线索引构建器:扫描文档目录,做编码归一化、分词、构建倒排表,最后把词典和posting list序列化成自定义二进制索引文件。
  • 在线查询服务:基于Asio + Beast处理HTTP请求,把查询语句交给语法解析器,然后在倒排索引上执行检索、打分、排序和TopK截断。
  • 存储访问层:索引文件通过mmap加载,查询进程启动时把文件映射到虚拟地址空间,避免一次性读进堆内存。

倒排索引的存储结构是:词典表(有序哈希结构,键为词项,值为指向posting list的偏移量)+ posting list(doc_id、词频tf、关键词出现位置列表)+ 文档元数据表(文档标题、路径、发布时间、长度等)。文档元数据用Multi-Index同时维护“按doc_id唯一索引”和“按发布时间排序索引”,这样既能快速定位文档,又支持按时间范围过滤。

查询请求的简化流程是:接收HTTP请求 → 解析查询表达式 → 取出倒排表 → 做交集/并集或短语匹配 → 按相关度打分 → 返回TopK结果。打分公式第一版用了TF-IDF,后续又加上了文档新鲜度因子。

2. 测试环境与指标口径:先用一套规则把话说清楚

2.1 测试环境与数据集构造

测试机是一台16核心32GB内存的服务器,磁盘是NVMe SSD,操作系统是Linux,内核版本保持默认。测试期间没有其他负载,压测工具和引擎同机运行,避免网络传输成为瓶颈变量。

测试数据集分成三档:

数据集文档数量原始文本大小用途
小集S2万篇约42MB功能正确性对拍
中集M20万篇约430MB常规性能测试
大集L100万篇约2.2GB并发压测与长稳

文档内容不是随便灌的无意义字符串,而是按照真实搜索场景构造:准备5000个中文词条,按Zipf分布抽样,每篇约2KB。60%的文档会命中热点词,40%的文档主要由长尾词组成,这样能模拟出“少数热词被大量请求、大量长尾词偶发请求”的真实分布。如果测试数据全是均匀分布的随机词,压测结果会虚高,这个后面单独讲。

2.2 指标口径与压测工具

测试报告最怕口径不统一。这次统一记录以下指标:

指标说明
QPS每秒完成查询数,按完成请求计数
P50 / P95 / P99时延分位值,单位毫秒,取聚合窗口
索引构建吞吐每秒入库文档数
索引膨胀比索引文件大小 / 原始文本大小
内存驻留进程RSS,长稳时按小时采样
错误率非200响应 / 超时请求 / 全量请求

压测客户端是自己用Boost.Asio写的异步客户端,配合线程池发请求。之所以不用现成的压测工具,是因为查询协议是自定义的POST/JSON格式,现成工具要写一堆插件,还不如直接控制整个过程。压测逻辑很简单:预热20秒,让page cache和数据结构的懒加载都稳定下来,然后固定施压60秒,客户端每100毫秒汇总一次QPS和时延,测试结束时统一计算分位值。

有个细节必须注意:所有压测都要区分“冷查询”和“热查询”。冷查询指进程刚启动、索引页面还没进page cache时的首次请求;热查询是预热完成后的稳定状态。真实用户请求既包含冷也包含热,但压测报告里如果不分开,数据就失去了可复现性。

3. 功能正确性验证:小数据集对拍与两个翻车现场

3.1 用例矩阵与判定方法

功能测试不能只点几个页面,我按照检索场景设计了一张用例矩阵,覆盖了大部分可能出现的情况。

用例类别具体输入预期行为
单个关键词“搜索引擎”返回包含该词的所有文档
多词AND“搜索引擎 AND 性能”返回同时包含两个词的文档
多词OR“缓存 OR 索引”返回包含任一词的文档
短语查询““索引合并策略””返回短语连续出现的文档
字段过滤指定时间范围和文档类型在过滤条件内返回结果
空查询空字符串、只有空格返回400错误,不崩溃
超长查询1万字符的查询串返回413或明确错误,不拖垮进程
特殊字符引号、反斜杠、emoji正常转义或过滤,不产生语法异常
中文词组多字词、未登录词尽量正确分词,不误命中

结果判定用了一个笨但可靠的方法:对拍。在小数据集S上,另写一个朴素的暴力搜索程序,直接用字符串匹配逐篇扫文档,把候选结果算出来,然后和引擎返回结果逐条比对。第一次跑对拍,差异就出来了。

3.2 问题一:Multi-Index等值排序不稳定

现象是:查询一个高频词时,返回结果里相关度分数相同的文档,顺序经常和预期不一致。预期是按doc_id升序稳定返回,但实际混排,有时候同一个查询连续执行两次,顺序还会变。

定位过程是这样:先抽样统计了所有差异样本,发现全部集中在“得分相等”的文档之间。然后去查索引定义,发现我在Multi-Index上只建了“按相关度分数排序”的索引,doc_id只是查询阶段的条件筛选,没有参与索引排序。在Multi-Index里使用ordered索引时,如果只指定一个键,等值元素之间就退化为插入顺序,而并发构建下插入顺序又和文档扫描顺序相关,所以结果不稳定。

修复方式很直接:在索引定义里使用复合键composite_key(score, doc_id),让得分的次级排序键变成doc_id。这样相等分数的文档按doc_id稳定排序,既符合预期,也方便后续做分页游标。

3.3 问题二:2-gram分词导致的边界误命中

这个bug比较隐蔽。初始分词器为了快速上线,直接用了Unicode编码下的2-gram切分,也就是把每两个相邻汉字切成一个词片段。中文用2-gram做兜底本来没问题,问题是当时整个索引都只依赖2-gram,没挂词典。

后果是:检索“武汉”时,会返回只包含“武昌”的文档。原因不难解释,2-gram把“武昌市”切成了“武昌”“昌市”,把“武汉市”切成了“武汉”“汉市”,于是“武汉”和“武昌”在索引里都被映射到了同一个文档集合的一部分,边界词互相污染。类似的情况还有“食堂”误命中“堂食”。

修复方案是两层分词策略:先走词典最大匹配分词,词典里能命中的词都按完整词入库;词典覆盖不到的未登录词,再用2-gram兜底。这样既解决了边界误命中,又保留了对新词的一定容忍度。改完后重新构建索引,再跑对拍,所有中文词组用例全部通过。

4. 性能与并发:从单线程压到64线程的瓶颈演变

4.1 索引构建吞吐:数据量增长时的真实表现

索引构建性能用三档数据集分别跑:

数据集文档量原始大小索引大小构建耗时吞吐
S2万42MB23MB3.8秒5260篇/秒
M20万430MB238MB46秒4348篇/秒
L100万2.2GB1.2GB292秒3425篇/秒

索引膨胀比稳定在55%到58%左右,这个数值对倒排索引来说是正常范围,词典、位置表、文档元数据都会额外占空间。构建吞吐随着文档量变大而下降,主要原因是临时倒排文件在磁盘上的合并次数越来越多,20万篇时还基本能在内存缓冲区完成,100万篇时就频繁触发多路归并。这个阶段我没有继续优化,因为离线构建不是核心链路,但数据记录下来,后续做增量索引时可以作为性能基线。

4.2 查询时延:高频词和长尾词完全两回事

单线程热查询下,按查询词的冷热程度分组统计:

查询类型示例P50P95P99
高频热词“系统”1.2ms3.6ms8.9ms
中频词“索引优化”0.74ms2.1ms6.2ms
低频长尾词“恩格尔系数”0.31ms0.92ms2.4ms

高频词时延明显更高,因为结果集大,需要读取的posting list更长,还要对大量候选文档做打分和排序。长尾词结果集小,往往几十个候选,时延自然低。这个结果很容易让人误以为引擎很快——只测热词的话,P50会被拉高但P95还能看;只测长尾词的话,数据会好看到不真实。所以压测脚本里的请求分布必须按Zipf分布混合三类词。

4.3 并发瓶颈复盘:锁竞争比CPU瓶颈更难缠

并发压测最值得记录的是从1线程加到64线程的过程。

并发线程QPSP50P95P99CPU使用率
16201.4ms3.8ms9.1ms12%
421501.8ms5.2ms14ms45%
834802.3ms6.8ms18ms68%
1648903.1ms8.4ms22ms82%
3251205.6ms15.2ms34ms86%
6453009.8ms31ms63ms85%

16线程到32线程,QPS只涨了不到5%,但P99从22ms涨到34ms;32到64线程,QPS几乎没动,P99直接翻倍。最反常的是CPU使用率只有85%左右,没有跑满,说明瓶颈不在运算,而在等待。

定位过程用三步走。第一步top -Hp查看线程CPU分布,发现没有单线程打满的情况。第二步perf record抓内核态热点,命中了一个spinlock相关的符号。第三步gdb attach到进程,thread apply all bt截所有线程堆栈,发现大量线程停在posting list迭代器的互斥锁上。

根因是posting list迭代器用shared_ptr保护底层数组,每次迭代、拷贝、赋值都会触发引用计数的原子增减。单线程下这个开销微不足道,但64线程并发读同一个热点词的posting list时,原子变量竞争异常激烈,所有线程都在等同一个内存位置。解法是查询路径改成“只读快照”模式:索引发布后不修改,posting list迭代器只持有裸指针,生命周期由外部快照管理器统一控制。需要更新索引时,构建新快照,等到旧快照上的查询全部结束后再回收。改造后64线程QPS从5300涨到8800,P99从63ms降到24ms,CPU使用率也逼近95%。

5. 稳定性与容错:72小时长稳和异常注入实测

5.1 72小时长时间运行的资源曲线

性能压测只能说明短时间内的表现,长时间运行才能暴露资源泄漏和积累性问题。我让引擎在100万篇索引、32线程混合负载下连续跑了72小时,每小时记录一次RSS、文件描述符数量、线程数和错误率。

最终数据:RSS稳定在1.28GB到1.32GB之间,没有持续上涨;文件描述符数量在280到310之间浮动,没有泄漏;线程数恒定为128,没有越加越多;错误率维持在0.0004%以下。同时在压测过程中抽取了前100万请求单独跑了一遍ASan构建,也没有发现堆内存增长异常。ASan在高并发下会严重拖慢性能,所以我没有把它直接放在正式长稳环境里,而是单独验证内存安全,这点要特别注意。

5.2 异常注入:进程杀掉、文件损坏、请求超时

稳定性测试不能只看正常跑,还得人为制造故障。我做了几组异常注入:

场景操作结果
强杀进程kill -9后重启3.2秒恢复查询,索引不损坏
截断索引文件直接截掉尾部4KB启动时CRC校验失败,自动回滚到上一个检查点
超大请求体发送超过配置上限的HTTP报文修复前返回500,修复后返回413
客户端突发断开请求发出后立即关闭连接修复前服务端继续无效计算,修复后主动取消任务

第一组和第二组能通过,是因为索引落盘时每隔一段时间会写一个带CRC校验的检查点。强杀进程最坏只会丢失最近几分钟的增量数据,歪打正着验证了检查点机制的有效性。

第三组是一个典型的Beast使用坑。最初我在处理HTTP请求的lambda里局部构造了一个parser,然后试图对它的body设置limit。问题是lambda每次执行完,局部parser就被析构了,上传限制配置随之失效。超大URI请求直接触发了解析器内部异常,被外层catch拦下后返回了500。正确的做法是把parser对象放进连接生命周期内,每次新的请求只重置其状态,而不是重新构造:

// 错误:lambda内部局部parser http::request_parser<http::string_body> parser; parser.body_limit(1024 * 1024); // 正确:parser与连接对象同生命周期 struct Conn { http::request_parser<http::string_body> parser{}; Conn() { parser.body_limit(1024 * 1024); } };

第四组的处理方式是给每个查询任务增加“客户端断开感知”,在Asio的回调链路上检测socket状态,一旦发现对端关闭就提前终止任务,避免无效计算占用检索线程。

6. 结论、遗留风险与后续规划

6.1 本次测试达成的结论

维度结论
功能正确性用例矩阵全部通过,修复2个检索缺陷
单机性能16C/32G下,修复后支持约8800QPS,P99低于25ms
索引构建100万篇文档索引构建约5分钟,膨胀比约0.55
稳定性72小时长稳无内存泄漏,无句柄泄漏,异常恢复有效

这个项目从“能用”到“敢在生产环境上用”,测试报告是最关键的依据。没有压测数据之前,谁也不敢说系统能扛多少流量;有了基线之后,后面每个优化都能用数字验证。

6.2 遗留风险和下一阶段规划

遗留风险主要集中在三块。第一是分词词典仍然是冷启动的静态词典,新词、品牌词无法实时识别,需要定期离线更新语料后重新构建分词词典。第二是单机容量有上限,按当前索引膨胀比计算,32GB内存的机器最多支撑千万篇级文档,再往上就要考虑分片。第三是增量索引还没做,目前是定时全量重建,对实时性要求高的场景不够友好。

下一阶段计划按优先级排列:先做增量索引和段合并,解决全量重建窗口期的数据新鲜度问题;再把排序模型从TF-IDF升级到BM25,配合查询缓存减少热点词的重复计算;最后是分布式扩容,把索引分片和查询路由做出来。每一步都会沿用这次搭好的测试框架去回归验证。

最后分享一个体会:测试搜索类服务,不要只跑热点词。第一轮压测我全用热点词,数据非常漂亮,P50低得惊人。后来把Zipf请求分布一混进去,P95立刻露馅。压测方案里一定要有长尾词、未登录词、空查询这些“脏输入”,它们才最能反映真实用户的行为。还有就是测试报告里每个指标的口径务必写清,是请求维度还是事务维度、冷查询还是热查询、窗口期是多少秒,这些定义不统一,数据过了两周回头看就已经没法对比了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询