构建支持多租户隔离的声明式搜索服务 Tekton 自动化 Algolia 与 Solr 选型


为我们的新SaaS平台构建一个高性能、实时、且严格隔离的多租户搜索功能时,我们面临第一个,也是最关键的架构决策:选择底层搜索引擎。平台的前端是基于SwiftUI构建的原生iOS应用,要求搜索体验必须流畅、响应迅速。随着业务预期增长,系统需要能够自动化地支撑数千个租户的入驻、配置变更与退出,同时将运维成本控制在合理范围内。

问题的核心是,如何在快速迭代和长期成本控制之间找到平衡点。两个主要候选方案进入了视野:Algolia,一个功能强大的SaaS搜索服务;以及Apache Solr,一个成熟的、可自托管的开源搜索引擎。

方案A:Algolia - 速度与便利的权衡

Algolia的优势显而易见:极低的接入成本。它将搜索基础设施的复杂性完全封装,对外提供简洁的REST API和丰富的SDK。对于多租户场景,Algolia提供了“Secured API Keys”机制。我们可以为每个租户生成一个带有filters参数的API Key,该Key在服务端强制执行过滤,确保租户A的请求永远无法查询到租户B的数据。

例如,所有数据存储在同一个索引中,每条记录带有一个tenant_id字段。为tenant-123生成一个只能访问其自身数据的Key,其核心逻辑如下:

// 注意:这是一个在后端服务中运行的Node.js示例,用于生成有时效性的安全API Key
// 绝不能在客户端执行此操作,因为这会暴露您的Admin API Key

const algoliasearch = require('algoliasearch');

// 只能在受信任的后端环境中使用Admin API Key
const client = algoliasearch('YourApplicationID', 'YourAdminAPIKey');

function generateTenantScopedKey(tenantId, validityInSeconds = 3600) {
  if (!tenantId || typeof tenantId !== 'string' || tenantId.trim() === '') {
    // 在真实项目中,这里应该是健壮的错误处理和日志记录
    throw new Error('Invalid tenantId provided.');
  }

  const securedApiKey = client.generateSecuredApiKey(
    'YourSearchOnlyAPIKey', // 使用一个基础的Search-Only Key
    {
      filters: `tenant_id:${tenantId}`,
      validUntil: Math.floor(Date.now() / 1000) + validityInSeconds,
    }
  );

  // 这个生成的key可以安全地分发给属于tenant-123的客户端
  return securedApiKey;
}

try {
  const tenantKey = generateTenantScopedKey('tenant-123');
  console.log(`Generated Secured API Key for tenant-123: ${tenantKey}`);
} catch (error) {
  console.error(`Error generating key: ${error.message}`);
  // 生产环境中应接入监控告警系统
}

优点:

  1. 运维解脱: 无需关心集群扩容、备份、高可用等问题。
  2. 快速集成: SDK质量很高,能极大加速开发进程。
  3. 内置安全: Secured API Keys是为多租户量身定做的功能,实现数据隔离非常直接。

缺点:

  1. 成本: Algolia的定价模型基于记录数和操作次数。当租户和数据量达到一定规模时,成本可能变得非常高昂,且难以预测。
  2. 黑盒: 无法进行底层性能调优。当遇到复杂的查询性能问题时,能做的有限。
  3. 数据主权: 对于某些对数据存储位置有严格要求的客户,使用第三方SaaS服务可能成为合规性障碍。

方案B:Solr - 完全控制与运维责任

Solr(尤其是在SolrCloud模式下)提供了另一条路径。我们可以为每个租户创建一个独立的Collection。这种物理层面的隔离模型最为彻底,完全杜绝了数据交叉访问的风险。

为新租户tenant-456创建一个Collection的典型操作如下:

#!/bin/bash
set -eo pipefail # 脚本健壮性设置

SOLR_HOST="solr-node-1.internal:8983"
COLLECTION_NAME="tenant-456_collection"
CONFIG_SET_NAME="base_tenant_config" # 预先上传的配置集
SHARDS=1
REPLICAS=2

# 检查参数
if [ -z "$COLLECTION_NAME" ]; then
  echo "Error: COLLECTION_NAME is not set." >&2
  exit 1
fi

# 在真实项目中,会增加更多检查,如配置集是否存在、节点是否健康等
echo "Attempting to create Solr collection: ${COLLECTION_NAME}"

# 使用Solr Collections API创建集合
# -D flags 用于传递Java系统属性,可用于配置
curl -sS --fail "http://${SOLR_HOST}/solr/admin/collections?action=CREATE&name=${COLLECTION_NAME}&collection.configName=${CONFIG_SET_NAME}&numShards=${SHARDS}&replicationFactor=${REPLICAS}&maxShardsPerNode=1"

# 检查API调用是否成功
if [ $? -ne 0 ]; then
  echo "Error: Failed to create Solr collection '${COLLECTION_NAME}'." >&2
  # 此处应有告警逻辑
  exit 1
fi

echo "Successfully created Solr collection '${COLLECTION_NAME}'."
# 后续可能需要更新服务发现或配置文件,告知应用新租户的Collection已就绪

优点:

  1. 成本可控: 硬件和人力是主要成本,对于大规模部署,长期总拥有成本(TCO)可能远低于Algolia。
  2. 完全控制: 从JVM调优到索引策略,一切尽在掌握。可以针对特定业务场景做深度优化。
  3. 数据合规: 数据可以部署在任何符合要求的私有云或数据中心。

缺点:

  1. 运维噩梦: 管理一个大规模的SolrCloud集群(包括Zookeeper)需要专业的SRE团队。扩容、故障恢复、版本升级都是复杂且高风险的操作。
  2. 开发成本: 需要自行实现客户端的负载均衡、认证授权、API网关等配套设施。
  3. 隔离成本: 每个租户一个Collection会消耗更多Zookeeper元数据和节点资源。当租户数达到数千乃至数万时,会对集群的管理能力提出巨大挑战。

决策:抽象与自动化优先

直接二选一并非最佳策略。一个常见的错误是早期被SaaS的便利性锁定,后期因成本问题而进退两难;或是过早投入自建方案,被运维拖垮了产品迭代速度。

我们的决策是:设计一个能够同时支持两种方案的抽象架构,并通过Tekton构建一个声明式的租户生命周期管理流水线。

这个架构的核心思想是,无论后端是Algolia还是Solr,对上层应用(包括SwiftUI客户端)都应该是透明的。我们通过一个内部的“租户配置服务”来实现这一点,该服务向客户端提供连接搜索后端所需的一切信息(如端点、安全Key、索引/Collection名)。

而实现这一架构的关键,在于自动化的能力。当一个新的租户注册时,我们不希望有任何手动操作。这正是Tekton的用武之地。我们将租户的配置(tenantId, searchProvider, planType等)以YAML文件的形式存储在Git仓库中,利用GitOps的模式来驱动整个租户的创建、更新和销毁流程。

graph TD
    subgraph Git Repository
        A[Tenant Manifest: tenant-xyz.yaml]
    end

    subgraph Kubernetes Cluster
        B(Tekton Controller) -- Watches Git Repo --> A
        B -- Triggers --> C(PipelineRun)
        C -- Executes --> D{Pipeline: provision-search-tenant}
        D -- When 'provider: algolia' --> E[Task: provision-algolia]
        D -- When 'provider: solr' --> F[Task: provision-solr]
        E -- Calls --> G[Algolia Management API]
        F -- Calls --> H[Solr Collections API]
        E --> I[Task: update-config-service]
        F --> I
        I -- Updates --> J[Tenant Config Database/ConfigMap]
    end

    subgraph Backend Services
        K[SwiftUI App] -- Requests Config --> L[Tenant Config Service]
        L -- Reads from --> J
        K -- Uses Config to Query --> M{Search Backend}
    end

    subgraph Search Backends
        G -- Manages --> N[Algolia Index]
        H -- Manages --> O[Solr Collection]
        M -- Can be --> N
        M -- Can be --> O
    end

Tekton流水线实现:声明式租户管理

Tekton是一个Kubernetes原生的CI/CD框架,它的PipelineTask资源让我们能用声明式YAML来定义复杂的自动化工作流。

1. Task: provision-algolia-tenant

这个Task负责与Algolia API交互。它运行在一个自定义的容器镜像中,该镜像打包了Algolia的SDK和我们的业务逻辑脚本。

apiVersion: tekton.dev/v1beta1
kind: Task
metadata:
  name: provision-algolia-tenant
spec:
  params:
    - name: tenant-id
      description: The unique identifier for the tenant
      type: string
    - name: admin-api-key-secret
      description: The name of the k8s secret holding the Algolia Admin API Key
      type: string
  steps:
    - name: generate-secured-key
      image: gcr.io/my-project/algolia-tooling:v1.2.0
      env:
        - name: ALGOLIA_ADMIN_API_KEY
          valueFrom:
            secretKeyRef:
              name: $(params.admin-api-key-secret)
              key: key
      script: |
        #!/usr/bin/env node
        // Simplified version of the provisioning script
        const algoliasearch = require('algoliasearch');
        const client = algoliasearch('YourApplicationID', process.env.ALGOLIA_ADMIN_API_KEY);

        const tenantId = "$(params.tenant-id)";

        // 在真实项目中,这里会先检查索引是否存在、执行schema配置等
        console.log(`Provisioning resources for tenant: ${tenantId}`);

        // 为演示目的,我们只生成一个永久的、带过滤的Key
        // 生产环境应使用上面提到的有时效性的Key生成逻辑,并通过一个服务来分发
        const publicKey = client.generateSecuredApiKey(
          'YourSearchOnlyAPIKey',
          { filters: `tenant_id:${tenantId}` }
        );

        // 将结果输出到Tekton的results中,以便后续Task使用
        // 这是一个关键的Tekton特性,用于在Task之间传递数据
        echo -n "algolia" > $(results.provider.path)
        echo -n "${publicKey}" > $(results.api-key.path)
        echo -n "YourApplicationID" > $(results.app-id.path)

  results:
    - name: provider
      description: The search provider name.
    - name: api-key
      description: The generated secured API key for the tenant.
    - name: app-id
      description: The Algolia Application ID.

2. Task: provision-solr-tenant

同理,这个Task用于创建Solr Collection。

apiVersion: tekton.dev/v1beta1
kind: Task
metadata:
  name: provision-solr-tenant
spec:
  params:
    - name: tenant-id
      description: The unique identifier for the tenant
      type: string
    - name: solr-host
      description: The address of the Solr host
      type: string
  steps:
    - name: create-collection
      image: docker.io/library/alpine/git # 使用一个带curl的轻量级镜像
      script: |
        #!/bin/sh
        set -e
        TENANT_ID="$(params.tenant-id)"
        COLLECTION_NAME="${TENANT_ID}_collection"
        SOLR_HOST="$(params.solr-host)"
        CONFIG_SET="base_tenant_config"

        echo "Creating Solr collection ${COLLECTION_NAME} on ${SOLR_HOST}"

        response_code=$(curl -s -o /dev/null -w "%{http_code}" "http://${SOLR_HOST}/solr/admin/collections?action=CREATE&name=${COLLECTION_NAME}&collection.configName=${CONFIG_SET}&numShards=1&replicationFactor=2")

        if [ "$response_code" -ne 200 ]; then
          echo "Error creating Solr collection. HTTP status: $response_code"
          exit 1
        fi

        echo "Successfully created Solr collection."
        echo -n "solr" > $(results.provider.path)
        echo -n "${COLLECTION_NAME}" > $(results.collection-name.path)
        echo -n "http://${SOLR_HOST}/solr/${COLLECTION_NAME}" > $(results.endpoint.path)

  results:
    - name: provider
      description: The search provider name.
    - name: collection-name
      description: The name of the Solr collection.
    - name: endpoint
      description: The full endpoint URL for the collection.

3. Pipeline: provision-search-tenant

这个Pipeline编排了整个流程,它使用when表达式来根据租户清单中的provider字段选择执行哪个Task。

apiVersion: tekton.dev/v1beta1
kind: Pipeline
metadata:
  name: provision-search-tenant
spec:
  params:
    - name: git-repo-url
      type: string
    - name: git-revision
      type: string
    - name: tenant-manifest-path # e.g., tenants/tenant-xyz.yaml
      type: string
  tasks:
    - name: fetch-manifest
      taskRef:
        name: git-clone
      params:
        - name: url
          value: $(params.git-repo-url)
        - name: revision
          value: $(params.git-revision)
    - name: parse-manifest
      runAfter: [fetch-manifest]
      workspaces:
        - name: source
          workspace: shared-workspace
      taskSpec: # 使用内联TaskSpec来执行简单脚本
        workspaces:
          - name: source
        params:
          - name: manifest-path
            type: string
        results:
          - name: tenant-id
          - name: provider
        steps:
          - name: parse
            image: mikefarah/yq:4
            script: |
              #!/bin/sh
              MANIFEST_FILE=$(workspaces.source.path)/$(params.manifest-path)
              yq e '.metadata.name' $MANIFEST_FILE > $(results.tenant-id.path)
              yq e '.spec.searchProvider' $MANIFEST_FILE > $(results.provider.path)
    - name: provision-on-algolia
      runAfter: [parse-manifest]
      taskRef:
        name: provision-algolia-tenant
      params:
        - name: tenant-id
          value: $(tasks.parse-manifest.results.tenant-id)
        - name: admin-api-key-secret
          value: "algolia-credentials"
      when:
        - input: "$(tasks.parse-manifest.results.provider)"
          operator: in
          values: ["algolia"]
    - name: provision-on-solr
      runAfter: [parse-manifest]
      taskRef:
        name: provision-solr-tenant
      params:
        - name: tenant-id
          value: $(tasks.parse-manifest.results.tenant-id)
        - name: solr-host
          value: "solr.internal.service:8983"
      when:
        - input: "$(tasks.parse-manifest.results.provider)"
          operator: in
          values: ["solr"]
    - name: update-config-db # 这是一个伪任务,实际实现可能更复杂
      runAfter: [provision-on-algolia, provision-on-solr]
      taskSpec:
        params:
          - name: provider
          - name: tenant-id
          # ... 其他参数
        steps:
          - name: update
            image: gcr.io/my-project/config-updater:latest
            script: |
              echo "Updating config for tenant $(params.tenant-id) with provider $(params.provider)..."
              # 此处是调用配置中心API的逻辑

SwiftUI客户端的适配

客户端的设计必须与后端的抽象层保持一致。我们定义一个SearchService协议,而不是直接依赖任何特定的SDK。

import Foundation

// 定义搜索结果的数据模型,保持与后端无关
struct SearchResultItem: Identifiable, Decodable {
    let id: String
    let title: String
    let snippet: String
}

// 定义搜索服务的抽象协议
protocol SearchService {
    func search(query: String) async throws -> [SearchResultItem]
}

// Algolia 实现
class AlgoliaSearchService: SearchService {
    private let client: SearchClient // Algolia SDK的Client
    private let indexName: String

    // 通过依赖注入传入配置
    init(appId: String, apiKey: String, indexName: String) {
        self.client = SearchClient(appID: ApplicationID(rawValue: appId), apiKey: APIKey(rawValue: apiKey))
        self.indexName = indexName
        // ... 错误处理与日志
    }

    func search(query: String) async throws -> [SearchResultItem] {
        let index = client.index(withName: IndexName(rawValue: indexName))
        do {
            let result = try await index.search(query: Query(query))
            // 将Algolia的返回结果映射到我们自己的通用模型
            return result.hits.map { /* ... mapping logic ... */ }
        } catch {
            // 健壮的错误处理
            print("Algolia search failed: \(error)")
            throw error
        }
    }
}

// Solr 实现
class SolrSearchService: SearchService {
    private let endpoint: URL

    init(endpoint: URL) {
        self.endpoint = endpoint
    }

    func search(query: String) async throws -> [SearchResultItem] {
        var components = URLComponents(url: endpoint.appendingPathComponent("select"), resolvingAgainstBaseURL: false)!
        components.queryItems = [
            URLQueryItem(name: "q", value: query),
            URLQueryItem(name: "wt", value: "json")
        ]
        
        guard let url = components.url else {
            throw URLError(.badURL)
        }

        let (data, response) = try await URLSession.shared.data(for: URLRequest(url: url))
        
        guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
            throw URLError(.badServerResponse)
        }
        
        // 解析Solr的JSON响应并映射到通用模型
        let solrResponse = try JSONDecoder().decode(SolrResponse.self, from: data)
        return solrResponse.response.docs.map { /* ... mapping logic ... */ }
    }
    
    // Solr响应的简化模型
    private struct SolrResponse: Decodable {
        let response: SolrDocs
    }
    private struct SolrDocs: Decodable {
        let docs: [SearchResultItem]
    }
}

// 在ViewModel中,动态创建SearchService
@MainActor
class SearchViewModel: ObservableObject {
    @Published var results: [SearchResultItem] = []
    private var searchService: SearchService?

    func initializeService(for tenant: Tenant) {
        // tenant.config是从我们自己的后端获取的
        switch tenant.config.searchProvider {
        case "algolia":
            self.searchService = AlgoliaSearchService(
                appId: tenant.config.appId,
                apiKey: tenant.config.apiKey,
                indexName: "shared_index"
            )
        case "solr":
            self.searchService = SolrSearchService(endpoint: tenant.config.endpoint)
        default:
            // 处理未知或未配置的情况
            self.searchService = nil
        }
    }

    func performSearch(query: String) async {
        guard let service = searchService, !query.isEmpty else { return }
        do {
            self.results = try await service.search(query: query)
        } catch {
            // 向用户显示错误信息
            print("Search failed: \(error)")
        }
    }
}

架构的局限性与未来迭代

这种基于Tekton和抽象层的架构提供了极大的灵活性,让我们可以根据业务发展阶段、成本考量和客户需求,为不同租户选择不同的搜索后端,甚至实现无缝迁移。

但它并非没有缺点。首先,客户端的抽象层意味着我们可能无法利用某个搜索引擎特有的高级功能(例如Algolia的Query Rules或Solr强大的地理空间查询)。任何这类需求都将导致抽象层变得复杂或出现漏洞。其次,Tekton流水线本身的维护也需要成本,它依赖于一个稳定运行的Kubernetes集群。对于非常早期的项目,这套基础设施可能过于沉重。最后,此方案只解决了租户“配置”的生命周期,而数据“索引”的ETL流水线是另一个同样复杂的话题,需要独立的解决方案。未来的工作将集中在构建一个同样与后端解耦的、声明式的数据注入管道。


  目录