构建基于 Git 差量分析的 Solr 增量索引管道以驱动 Gatsby 站点搜索


我们的技术文档平台正面临一个棘手的性能瓶颈。平台使用 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 进行原子化的更新,而不是推倒重来。

技术选型决策是这一构想的基石:

  1. Gatsby: 维持不变。其性能优势和 React 生态是平台的根基。
  2. Solr: 继续使用。它的文本分析能力、成熟的生态和可控的运维成本,使其成为比云托管服务(如 Algolia)更适合我们内部平台的选择。关键在于改变我们与它交互的方式。
  3. Git: 从版本控制工具提升为数据管道的核心驱动。我们将利用其底层命令来提取变更集。
  4. CI/CD (GitLab CI/GitHub Actions): 作为整个流程的编排器。我们需要一个能够访问 Git 历史并执行自定义脚本的环境。

核心思路是:CI 脚本不再盲目索引,而是计算两次构建之间的 Git diff。它会生成三份清单:新增文件、修改文件和删除文件。然后,一个专门的索引器脚本将依据这些清单,向 Solr 发送精确的 adddelete 指令。

步骤化实现:构建增量索引管道

1. 核心 CI 编排脚本

我们需要一个入口脚本来 orchestrate 整个流程。这个脚本的核心任务是确定变更范围。在 CI 环境中,我们通常可以访问像 CI_COMMIT_SHACI_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);
});

这个脚本有几个关键的设计考量:

  1. 稳定的文档ID: generateDocId 函数是整个增量更新机制的基石。如果 ID 不稳定(例如,基于文件 inode 或时间戳),我们就无法可靠地更新或删除文档。使用相对于内容根目录的文件路径是最佳实践。
  2. 错误处理: 脚本中包含了基本的错误处理。如果读取文件或提交到 Solr 失败,它会记录错误并以非零状态码退出,这会使整个 CI 任务失败,从而防止部署一个数据不一致的站点。
  3. 批量处理: 所有要新增/更新的文档被收集到一个数组中,通过一次 HTTP 请求发送给 Solr。这远比为每个文件单独发送请求高效得多。删除操作也同样如此。
  4. 流式输入: 脚本通过 <(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

这里的关键是为 contenttitle 字段选择了 text_general 类型,它包含了标准的分词、小写转换、停用词处理等分析链。而 idpathstring 类型,意味着它们被视为精确值,不做分词处理。

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 的构建时间保持不变,但索引更新步骤的效率得到了数量级的提升。

方案局限性与未来迭代

尽管这个方案解决了核心痛点,但在真实项目中,它并非银弹。

  1. 对 Git 历史的强依赖: 这个流程假设一个相对线性的提交历史。复杂的 git merge 操作,特别是解决大量冲突后,git diff 的输出可能不会完美反映文件的最终状态,尽管在多数情况下它工作得很好。处理分支合并的 diff 需要更复杂的 git diff commit1...commit2 语法,它比较的是两个分支的顶端,而不是两个连续的 commit。
  2. 鲁棒性: 如果索引脚本在处理一批文件时中途失败,Solr 索引可能会处于不一致的状态。一个更健壮的实现需要引入事务性概念,或者至少是一个重试机制和失败队列,以确保变更最终能被处理。
  3. Schema 变更: 当前流程没有处理 Solr schema 变更的自动化方案。当 schema 改变时(例如,增加一个新字段),通常还是需要触发一次全量重建索引,并需要一个独立的脚本来处理这种情况。
  4. 状态管理: 当前脚本依赖 CI 环境变量来获取 commit range。如果 CI 任务失败并需要手动重跑,这些变量可能就不准确了。一个更可靠的方案是,在索引成功后,将最后处理的 CURRENT_COMMIT SHA 持久化存储起来(例如,在一个 S3 bucket 的文件里,或者一个简单的键值存储中),下次任务启动时读取这个值作为 PREVIOUS_COMMIT

未来的优化路径可能包括将这个索引逻辑封装成一个独立的、事件驱动的服务。它可以监听 Git 仓库的 webhooks,而不是被动地在 CI 流程中执行。这样可以进一步解耦内容更新和站点部署,为更复杂的索引策略(如实时索引)打下基础。


  目录