写一个搜索引擎的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,内核版本保持默认。测试期间没有其他负载,压测工具和引擎同机运行,避免网络传输成为瓶颈变量。
测试数据集分成三档:
| 数据集 | 文档数量 | 原始文本大小 | 用途 |
|---|---|---|---|
| 小集S | 2万篇 | 约42MB | 功能正确性对拍 |
| 中集M | 20万篇 | 约430MB | 常规性能测试 |
| 大集L | 100万篇 | 约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 索引构建吞吐:数据量增长时的真实表现
索引构建性能用三档数据集分别跑:
| 数据集 | 文档量 | 原始大小 | 索引大小 | 构建耗时 | 吞吐 |
|---|---|---|---|---|---|
| S | 2万 | 42MB | 23MB | 3.8秒 | 5260篇/秒 |
| M | 20万 | 430MB | 238MB | 46秒 | 4348篇/秒 |
| L | 100万 | 2.2GB | 1.2GB | 292秒 | 3425篇/秒 |
索引膨胀比稳定在55%到58%左右,这个数值对倒排索引来说是正常范围,词典、位置表、文档元数据都会额外占空间。构建吞吐随着文档量变大而下降,主要原因是临时倒排文件在磁盘上的合并次数越来越多,20万篇时还基本能在内存缓冲区完成,100万篇时就频繁触发多路归并。这个阶段我没有继续优化,因为离线构建不是核心链路,但数据记录下来,后续做增量索引时可以作为性能基线。
4.2 查询时延:高频词和长尾词完全两回事
单线程热查询下,按查询词的冷热程度分组统计:
| 查询类型 | 示例 | P50 | P95 | P99 |
|---|---|---|---|---|
| 高频热词 | “系统” | 1.2ms | 3.6ms | 8.9ms |
| 中频词 | “索引优化” | 0.74ms | 2.1ms | 6.2ms |
| 低频长尾词 | “恩格尔系数” | 0.31ms | 0.92ms | 2.4ms |
高频词时延明显更高,因为结果集大,需要读取的posting list更长,还要对大量候选文档做打分和排序。长尾词结果集小,往往几十个候选,时延自然低。这个结果很容易让人误以为引擎很快——只测热词的话,P50会被拉高但P95还能看;只测长尾词的话,数据会好看到不真实。所以压测脚本里的请求分布必须按Zipf分布混合三类词。
4.3 并发瓶颈复盘:锁竞争比CPU瓶颈更难缠
并发压测最值得记录的是从1线程加到64线程的过程。
| 并发线程 | QPS | P50 | P95 | P99 | CPU使用率 |
|---|---|---|---|---|---|
| 1 | 620 | 1.4ms | 3.8ms | 9.1ms | 12% |
| 4 | 2150 | 1.8ms | 5.2ms | 14ms | 45% |
| 8 | 3480 | 2.3ms | 6.8ms | 18ms | 68% |
| 16 | 4890 | 3.1ms | 8.4ms | 22ms | 82% |
| 32 | 5120 | 5.6ms | 15.2ms | 34ms | 86% |
| 64 | 5300 | 9.8ms | 31ms | 63ms | 85% |
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立刻露馅。压测方案里一定要有长尾词、未登录词、空查询这些“脏输入”,它们才最能反映真实用户的行为。还有就是测试报告里每个指标的口径务必写清,是请求维度还是事务维度、冷查询还是热查询、窗口期是多少秒,这些定义不统一,数据过了两周回头看就已经没法对比了。