我们的技术文档平台正面临一个棘手的性能瓶颈。平台使用 Gatsby 构建,拥有超过一万篇 Markdown 文档,部署为纯静态站点,这保证了极快的页面加载速度。然而,搜索功能却是一个灾难。最初采用的客户端搜索方案 (如 lunr.js
) 在文档数量超过一千篇后,索引文件变得异常臃肿,浏览器内存占用飙升,搜索响应迟钝。
为了解决这个问题,我们引入了 Apache Solr 作为外部搜索引擎。初步的集成方案简单粗暴:在每次 CI/CD 流程中,我们清空整个 Solr 核心,然后遍历所有 Markdown 文件,逐一解析并提交到 Solr 进行全量索引。
这是一个在生产环境中运行的脚本片段,它暴露了所有问题:
#!/bin/bash
set -e
SOLR_HOST="http://solr.internal:8983"
SOLR_CORE="tech_docs"
CONTENT_DIR="./src/content/docs"
echo "Purging existing Solr index..."
curl -sS "$SOLR_HOST/solr/$SOLR_CORE/update?commit=true" -H "Content-Type: text/xml" --data-binary '<delete><query>*:*</query></delete>'
echo "Starting full re-indexing..."
# A simple Node.js script that reads all markdown files and posts them to Solr
node ./scripts/indexer.js --dir "$CONTENT_DIR" --mode=full
echo "Full re-indexing complete."
# Proceed with Gatsby build
echo "Building Gatsby site..."
gatsby build
这个流程在文档量少时勉强可用,但随着文档库的增长,一次全量索引的时间从2分钟延长到了15分钟以上。这意味着任何一个微小的文档修正(比如修正一个错别字),都会触发一次漫长且昂贵的 CI/CD 流程。这不仅浪费了大量的计算资源,也严重影响了内容更新的敏捷性。痛点非常明确:我们需要一个增量索引机制,一个只处理变更内容的智能管道。
我的初步构想是停止将文件系统视为唯一的“事实来源”,而是将 Git 的提交历史作为变更的权威记录。每一次 git push
都包含了精确的变更信息:哪些文件被添加、修改或删除。如果我们的 CI 管道能够解析这些信息,就可以精确地指导 Solr 进行原子化的更新,而不是推倒重来。
技术选型决策是这一构想的基石:
- Gatsby: 维持不变。其性能优势和 React 生态是平台的根基。
- Solr: 继续使用。它的文本分析能力、成熟的生态和可控的运维成本,使其成为比云托管服务(如 Algolia)更适合我们内部平台的选择。关键在于改变我们与它交互的方式。
- Git: 从版本控制工具提升为数据管道的核心驱动。我们将利用其底层命令来提取变更集。
- CI/CD (GitLab CI/GitHub Actions): 作为整个流程的编排器。我们需要一个能够访问 Git 历史并执行自定义脚本的环境。
核心思路是:CI 脚本不再盲目索引,而是计算两次构建之间的 Git diff
。它会生成三份清单:新增文件、修改文件和删除文件。然后,一个专门的索引器脚本将依据这些清单,向 Solr 发送精确的 add
和 delete
指令。
步骤化实现:构建增量索引管道
1. 核心 CI 编排脚本
我们需要一个入口脚本来 orchestrate 整个流程。这个脚本的核心任务是确定变更范围。在 CI 环境中,我们通常可以访问像 CI_COMMIT_SHA
和 CI_COMMIT_BEFORE_SHA
这样的预定义变量,它们分别代表当前 HEAD 的 commit 和上一次构建的 commit。
incremental-index.sh
:
#!/bin/bash
set -eo pipefail # Fail on error, and on command pipe failures
# --- Configuration ---
# In a real CI/CD environment, these would be environment variables
SOLR_HOST="http://localhost:8983"
SOLR_CORE="tech_docs_v2"
CONTENT_SRC_DIR="src/content/docs"
INDEXER_SCRIPT_PATH="scripts/incremental-indexer.js"
# --- Logic ---
# Get the commit range. In GitLab CI, this is CI_COMMIT_BEFORE_SHA and CI_COMMIT_SHA.
# For local testing, you can use git log --pretty=format:"%H" -n 2
# Fallback to a full re-index if the 'before' SHA is all zeros (e.g., first push to a new branch)
PREVIOUS_COMMIT=$1
CURRENT_COMMIT=$2
if [ -z "$PREVIOUS_COMMIT" ] || [ -z "$CURRENT_COMMIT" ] || [[ "$PREVIOUS_COMMIT" =~ ^0+$ ]]; then
echo "WARN: Previous commit hash is zero or not provided. Performing full re-index."
# A full re-index might still be necessary occasionally
# For simplicity, we'll focus on the incremental path here.
# In a production setup, you would call a different script for full indexing.
exit 0
fi
echo "Calculating diff between $PREVIOUS_COMMIT and $CURRENT_COMMIT..."
# Use git diff to find changed files.
# --name-status gives us a status (A/M/D) and the filename.
# We only care about files in our content directory.
CHANGED_FILES=$(git diff --name-status "$PREVIOUS_COMMIT" "$CURRENT_COMMIT" -- "$CONTENT_SRC_DIR" || true)
if [ -z "$CHANGED_FILES" ]; then
echo "No content files changed. Skipping indexing."
exit 0
fi
echo "Detected changes:"
echo "$CHANGED_FILES"
# Prepare file lists for the Node.js indexer script
ADDED_MODIFIED_FILES=$(echo "$CHANGED_FILES" | grep -E '^(A|M)' | cut -f2-)
DELETED_FILES=$(echo "$CHANGED_FILES" | grep -E '^D' | cut -f2-)
# --- Execute Indexing ---
# We pass the file lists to our Node.js script.
# Using process substitution and here-strings to avoid temporary files.
node "$INDEXER_SCRIPT_PATH" \
--solr-host="$SOLR_HOST" \
--solr-core="$SOLR_CORE" \
--add-modify-list <(echo "$ADDED_MODIFIED_FILES") \
--delete-list <(echo "$DELETED_FILES")
echo "Incremental indexing completed successfully."
# The rest of the CI pipeline (e.g., gatsby build) would follow.
这个脚本现在是流程的大脑。它不再是“全部删除,全部重建”,而是“计算差异,精确更新”。这里的坑在于,git diff
命令如果找不到 commit hash 会失败,因此 || true
是一个简单的保险措施,防止在某些边缘情况下 CI 任务中断。
2. Node.js 增量索引器
Shell 脚本负责“什么变了”,而 Node.js 脚本则负责“如何处理这些变化”。由于 Gatsby 生态是基于 Node.js 的,复用这个环境来处理文件解析和 API 调用是最自然的选择。
scripts/incremental-indexer.js
:
const fs = require('fs').promises;
const path = require('path');
const readline = require('readline');
const { Command } = require('commander');
const axios = require('axios');
const matter = require('gray-matter'); // For parsing Markdown frontmatter
// A simple logger to provide context in CI logs
const logger = {
info: (msg) => console.log(`[INFO] ${msg}`),
error: (msg, err) => console.error(`[ERROR] ${msg}`, err),
warn: (msg) => console.warn(`[WARN] ${msg}`),
};
/**
* Generates a unique and stable document ID from a file path.
* This is CRITICAL for incremental updates and deletions.
* The ID must be predictable.
* Example: 'src/content/docs/section/mypage.md' -> 'section/mypage'
* @param {string} filePath - The relative path to the markdown file.
* @returns {string} A unique ID for the Solr document.
*/
function generateDocId(filePath) {
// This logic must be consistent across the entire system.
const relativePath = path.relative('src/content/docs', filePath);
return relativePath.replace(/\.mdx?$/, '');
}
/**
* Reads a list of file paths from a stream (like process.stdin or a file stream).
* This is how we get the file lists from the bash script.
* @param {string} streamPath - Path to the stream (e.g., /dev/stdin).
* @returns {Promise<string[]>} A promise that resolves to an array of file paths.
*/
async function readFileListFromStream(streamPath) {
if (!streamPath || !await fs.stat(streamPath).catch(() => false)) {
return [];
}
const fileStream = fs.createReadStream(streamPath);
const rl = readline.createInterface({
input: fileStream,
crlfDelay: Infinity
});
const files = [];
for await (const line of rl) {
if (line.trim()) {
files.push(line.trim());
}
}
return files;
}
/**
* Main indexing logic.
*/
async function main() {
const program = new Command();
program
.option('--solr-host <host>', 'Solr host URL', 'http://localhost:8983')
.option('--solr-core <core>', 'Solr core name', 'tech_docs_v2')
.option('--add-modify-list <path>', 'Path to a file stream containing files to add/modify')
.option('--delete-list <path>', 'Path to a file stream containing files to delete')
.parse(process.argv);
const options = program.opts();
const solrUpdateUrl = `${options.solrHost}/solr/${options.solrCore}/update/json?commit=true`;
const filesToAddOrModify = await readFileListFromStream(options.addModifyList);
const filesToDelete = await readFileListFromStream(options.deleteList);
if (filesToAddOrModify.length === 0 && filesToDelete.length === 0) {
logger.info("No files to process. Exiting.");
return;
}
// --- Process additions and modifications ---
if (filesToAddOrModify.length > 0) {
logger.info(`Processing ${filesToAddOrModify.length} files for addition/modification...`);
const documents = [];
for (const file of filesToAddOrModify) {
try {
const content = await fs.readFile(file, 'utf8');
const { data: frontmatter, content: body } = matter(content);
// This is where you map your markdown file to a Solr document.
// The structure must match your Solr schema.
const doc = {
id: generateDocId(file), // Critical: use the stable ID
title: frontmatter.title || 'Untitled',
content: body,
path: `/${generateDocId(file)}`, // URL path for the frontend
tags: frontmatter.tags || [],
last_modified: new Date().toISOString(),
};
documents.push(doc);
} catch (err) {
logger.error(`Failed to process file: ${file}`, err);
// In a real project, you might want to decide whether to continue or fail the build.
}
}
if (documents.length > 0) {
logger.info(`Sending ${documents.length} documents to Solr...`);
try {
// Post documents in a single batch for efficiency
await axios.post(solrUpdateUrl, documents, {
headers: { 'Content-Type': 'application/json' }
});
logger.info("Successfully added/updated documents in Solr.");
} catch (err) {
logger.error("Failed to post documents to Solr.", err.response ? err.response.data : err.message);
throw new Error("Solr update failed."); // Fail the CI job
}
}
}
// --- Process deletions ---
if (filesToDelete.length > 0) {
logger.info(`Processing ${filesToDelete.length} files for deletion...`);
const idsToDelete = filesToDelete.map(generateDocId);
const deletePayload = {
delete: idsToDelete
};
logger.info(`Sending deletion request for IDs: ${idsToDelete.join(', ')}`);
try {
await axios.post(solrUpdateUrl, deletePayload, {
headers: { 'Content-Type': 'application/json' }
});
logger.info("Successfully deleted documents from Solr.");
} catch (err) {
logger.error("Failed to post delete command to Solr.", err.response ? err.response.data : err.message);
throw new Error("Solr delete failed."); // Fail the CI job
}
}
}
main().catch(err => {
logger.error("An unhandled error occurred in the indexer.", err);
process.exit(1);
});
这个脚本有几个关键的设计考量:
- 稳定的文档ID:
generateDocId
函数是整个增量更新机制的基石。如果 ID 不稳定(例如,基于文件 inode 或时间戳),我们就无法可靠地更新或删除文档。使用相对于内容根目录的文件路径是最佳实践。 - 错误处理: 脚本中包含了基本的错误处理。如果读取文件或提交到 Solr 失败,它会记录错误并以非零状态码退出,这会使整个 CI 任务失败,从而防止部署一个数据不一致的站点。
- 批量处理: 所有要新增/更新的文档被收集到一个数组中,通过一次 HTTP 请求发送给 Solr。这远比为每个文件单独发送请求高效得多。删除操作也同样如此。
- 流式输入: 脚本通过
<(echo "$VAR")
这种 shell 进程替换的方式接收文件列表,避免了在 CI runner 上创建临时文件的需要,让流程更干净。
3. Solr Schema 定义
虽然 Solr 的 schemaless 模式对于快速原型设计很有用,但在生产环境中,一个明确定义的 schema 对于性能和搜索相关性至关重要。我们需要通过 Solr Schema API 或 managed-schema.xml
文件来定义字段。
一个最小化的 schema 定义可能如下(使用 Schema API 的 cURL 命令):
curl -X POST -H 'Content-type:application/json' --data-binary '{
"add-field":{
"name":"id",
"type":"string",
"indexed":true,
"stored":true,
"required":true,
"multiValued":false },
"add-field":{
"name":"title",
"type":"text_general",
"indexed":true,
"stored":true },
"add-field":{
"name":"content",
"type":"text_general",
"indexed":true,
"stored":true },
"add-field":{
"name":"path",
"type":"string",
"indexed":false,
"stored":true },
"add-field":{
"name":"tags",
"type":"string",
"indexed":true,
"stored":true,
"multiValued":true },
"add-field":{
"name":"last_modified",
"type":"pdate",
"stored":true,
"indexed":true }
}' http://localhost:8983/solr/tech_docs_v2/schema
这里的关键是为 content
和 title
字段选择了 text_general
类型,它包含了标准的分词、小写转换、停用词处理等分析链。而 id
和 path
是 string
类型,意味着它们被视为精确值,不做分词处理。
4. Gatsby 前端集成
最后一步是在 Gatsby 应用中创建一个搜索组件,使其与我们的 Solr 后端通信。这通常是一个 React 组件,它获取用户输入,构建一个 Solr查询 URL,然后呈现结果。
一个简化的搜索组件 Search.js
:
import React, { useState } from 'react';
import axios from 'axios';
const SOLR_SEARCH_URL = 'http://solr.public.domain/solr/tech_docs_v2/select';
export default function Search() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);
const performSearch = async (e) => {
e.preventDefault();
if (!query.trim()) return;
setLoading(true);
setResults([]);
// A common mistake is not properly encoding the query parameter.
const encodedQuery = encodeURIComponent(query);
// Use DisMax or eDisMax query parser for better relevance over multiple fields.
const params = `qf=title^2 content&q=${encodedQuery}&defType=edismax&rows=10`;
try {
const response = await axios.get(`${SOLR_SEARCH_URL}?${params}`);
setResults(response.data.response.docs);
} catch (error) {
console.error("Search request failed:", error);
// Handle search errors gracefully in the UI
} finally {
setLoading(false);
}
};
return (
<div>
<form onSubmit={performSearch}>
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search documentation..."
/>
<button type="submit" disabled={loading}>
{loading ? 'Searching...' : 'Search'}
</button>
</form>
<ul>
{results.map((doc) => (
<li key={doc.id}>
{/* Gatsby's <Link> component would be used here in a real app */}
<a href={doc.path}>{doc.title}</a>
</li>
))}
</ul>
</div>
);
}
注意,这个前端组件需要直接访问 Solr。在生产环境中,这会带来安全问题(暴露 Solr
实例)和 CORS 问题。一个常见的错误是直接尝试从浏览器访问内部 Solr 地址。正确的架构是在 Gatsby 站点和 Solr 之间设置一个轻量级的代理或 API 网关,或者使用 Gatsby 的 Functions 功能创建一个 serverless 函数来代理请求。这将 CORS 配置和潜在的 API 密钥管理从 Solr 转移到了受控的 BFF (Backend-for-Frontend) 层。
最终成果的可视化
整个数据流可以用 Mermaid 图清晰地展示出来:
graph TD A[Developer pushes commit to Git] --> B{CI/CD Pipeline Triggered}; B --> C{1. Fetch Commit Range}; C --> D[2. Run `git diff --name-status`]; D --> E{3. Parse Diff Output}; E -->|Deleted Files| F[Deletion List]; E -->|Added/Modified Files| G[Add/Modify List]; subgraph "Node.js Indexer Script" direction LR G --> H{Parse Markdown}; H --> I[Transform to Solr Docs]; end I --> J[Solr API: Add/Update Documents]; F --> K[Solr API: Delete by ID]; subgraph "Search Backend" direction TB J --> L[Solr Core]; K --> L; end B --> M[4. Gatsby Build & Deploy]; subgraph "User Interaction" direction LR N[User visits Gatsby Site] --> O{Performs Search}; O --> P[Frontend Search Component]; P --> Q[API Gateway / BFF]; Q --> R{Solr API: /select query}; R --> L; L --> R; R --> Q; Q --> P; end
这个流程将原本15分钟的 CI 任务缩短到了平均不到1分钟,因为绝大多数提交只涉及少数几个文件。Gatsby 的构建时间保持不变,但索引更新步骤的效率得到了数量级的提升。
方案局限性与未来迭代
尽管这个方案解决了核心痛点,但在真实项目中,它并非银弹。
- 对 Git 历史的强依赖: 这个流程假设一个相对线性的提交历史。复杂的
git merge
操作,特别是解决大量冲突后,git diff
的输出可能不会完美反映文件的最终状态,尽管在多数情况下它工作得很好。处理分支合并的 diff 需要更复杂的git diff commit1...commit2
语法,它比较的是两个分支的顶端,而不是两个连续的 commit。 - 鲁棒性: 如果索引脚本在处理一批文件时中途失败,Solr 索引可能会处于不一致的状态。一个更健壮的实现需要引入事务性概念,或者至少是一个重试机制和失败队列,以确保变更最终能被处理。
- Schema 变更: 当前流程没有处理 Solr schema 变更的自动化方案。当 schema 改变时(例如,增加一个新字段),通常还是需要触发一次全量重建索引,并需要一个独立的脚本来处理这种情况。
- 状态管理: 当前脚本依赖 CI 环境变量来获取 commit range。如果 CI 任务失败并需要手动重跑,这些变量可能就不准确了。一个更可靠的方案是,在索引成功后,将最后处理的
CURRENT_COMMIT
SHA 持久化存储起来(例如,在一个 S3 bucket 的文件里,或者一个简单的键值存储中),下次任务启动时读取这个值作为PREVIOUS_COMMIT
。
未来的优化路径可能包括将这个索引逻辑封装成一个独立的、事件驱动的服务。它可以监听 Git 仓库的 webhooks,而不是被动地在 CI 流程中执行。这样可以进一步解耦内容更新和站点部署,为更复杂的索引策略(如实时索引)打下基础。