搜索引擎架构实战:Elasticsearch与OpenSearch的设计与优化

深入讲解Elasticsearch与OpenSearch的架构设计,涵盖索引设计、分片策略、Mapping优化、查询DSL、聚合分析、集群管理与性能调优,提供电商搜索、日志分析等实战案例。

引言

搜索是现代应用的核心功能之一。无论是电商商品搜索、日志分析还是全文检索,Elasticsearch和OpenSearch都是最常用的解决方案。本文将深入讲解搜索引擎的架构设计与性能优化实战。

Elasticsearch核心概念

Elasticsearch核心概念:
┌─────────────────────────────────────────┐
│ 集群(Cluster)                          │
│   由一个或多个节点组成                   │
│   共同持有整个数据                       │
│                                         │
│ 节点(Node)                             │
│   集群中的单个服务器                     │
│   存储数据并参与集群的索引和搜索能力     │
│                                         │
│ 索引(Index)                            │
│   具有相似特征的文档集合                 │
│   类似于关系数据库中的"数据库"           │
│                                         │
│ 分片(Shard)                            │
│   索引的水平分割单元                     │
│   每个分片是一个独立的Lucene索引         │
│                                         │
│ 副本(Replica)                          │
│   主分片的拷贝                           │
│   提供高可用和读取扩展                   │
│                                         │
│ 文档(Document)                         │
│   可被索引的基本信息单元                 │
│   以JSON格式表示                         │
└─────────────────────────────────────────┘

索引设计与Mapping

索引命名规范

索引命名规范:
┌─────────────────────────────────────────┐
│ 格式:<业务>-<类型>-<环境>-<版本>       │
│                                         │
│ 示例:                                  │
│   ecommerce-products-prod-v1            │
│   user-logs-dev-2026.08                 │
│   order-events-staging-v2               │
│                                         │
│ 时间序列索引(日志、指标):            │
│   logs-nginx-2026.08.12                 │
│   metrics-app-2026.08.12                │
│                                         │
│ 别名(Alias):                          │
│   logs-nginx-current → logs-nginx-2026.08.12
│   products-latest → ecommerce-products-prod-v2
└─────────────────────────────────────────┘

Mapping设计

// 电商商品索引Mapping
PUT /ecommerce-products-prod-v1
{
  "settings": {
    "number_of_shards": 3,
    "number_of_replicas": 1,
    "refresh_interval": "5s",
    "analysis": {
      "analyzer": {
        "ik_smart_analyzer": {
          "type": "custom",
          "tokenizer": "ik_smart"
        },
        "product_analyzer": {
          "type": "custom",
          "tokenizer": "ik_max_word",
          "filter": ["lowercase", "product_synonym"]
        }
      },
      "filter": {
        "product_synonym": {
          "type": "synonym",
          "synonyms_path": "synonyms.txt"
        }
      }
    }
  },
  "mappings": {
    "properties": {
      "product_id": {
        "type": "keyword"
      },
      "title": {
        "type": "text",
        "analyzer": "product_analyzer",
        "search_analyzer": "ik_smart_analyzer",
        "fields": {
          "keyword": {
            "type": "keyword",
            "ignore_above": 256
          }
        }
      },
      "description": {
        "type": "text",
        "analyzer": "ik_smart_analyzer"
      },
      "category_ids": {
        "type": "keyword"
      },
      "brand_id": {
        "type": "keyword"
      },
      "price": {
        "type": "scaled_float",
        "scaling_factor": 100
      },
      "original_price": {
        "type": "scaled_float",
        "scaling_factor": 100
      },
      "stock": {
        "type": "integer"
      },
      "sales_count": {
        "type": "integer"
      },
      "rating": {
        "type": "float"
      },
      "tags": {
        "type": "keyword"
      },
      "attributes": {
        "type": "nested",
        "properties": {
          "key": {
            "type": "keyword"
          },
          "value": {
            "type": "keyword"
          }
        }
      },
      "images": {
        "type": "keyword",
        "index": false
      },
      "status": {
        "type": "keyword"
      },
      "created_at": {
        "type": "date",
        "format": "yyyy-MM-dd HH:mm:ss||epoch_millis"
      },
      "updated_at": {
        "type": "date",
        "format": "yyyy-MM-dd HH:mm:ss||epoch_millis"
      },
      "location": {
        "type": "geo_point"
      },
      "suggest": {
        "type": "completion",
        "analyzer": "ik_smart_analyzer"
      }
    }
  }
}

字段类型选择指南

字段类型选择:
┌─────────────────────────────────────────┐
│ keyword:                                │
│   - 精确匹配(term、terms)             │
│   - 聚合、排序                          │
│   - 适合:ID、状态、标签、枚举值        │
│                                         │
│ text:                                   │
│   - 全文搜索(match、multi_match)      │
│   - 分词处理                            │
│   - 适合:标题、描述、内容              │
│                                         │
│ integer/long:                           │
│   - 数值范围查询                        │
│   - 聚合统计                            │
│   - 适合:数量、计数、年龄              │
│                                         │
│ scaled_float:                           │
│   - 精确小数(货币)                    │
│   - 避免浮点精度问题                    │
│   - 适合:价格、金额                    │
│                                         │
│ date:                                   │
│   - 时间范围查询                        │
│   - 时间聚合                            │
│   - 适合:创建时间、更新时间            │
│                                         │
│ nested:                                 │
│   - 对象数组的独立查询                  │
│   - 避免对象扁平化问题                  │
│   - 适合:SKU属性、标签键值对           │
│                                         │
│ geo_point:                              │
│   - 地理位置查询                        │
│   - 距离排序                            │
│   - 适合:门店位置、配送范围            │
│                                         │
│ completion:                             │
│   - 自动补全                            │
│   - 快速前缀匹配                        │
│   - 适合:搜索建议、自动完成            │
└─────────────────────────────────────────┘

查询DSL实战

电商搜索查询

// 综合搜索(相关性+销量+价格)
GET /ecommerce-products-prod-v1/_search
{
  "query": {
    "bool": {
      "must": [
        {
          "multi_match": {
            "query": "iPhone 15 Pro Max",
            "fields": ["title^3", "description^1", "tags^2"],
            "type": "best_fields",
            "fuzziness": "AUTO"
          }
        }
      ],
      "filter": [
        {
          "term": {
            "status": "on_sale"
          }
        },
        {
          "range": {
            "stock": {
              "gt": 0
            }
          }
        },
        {
          "range": {
            "price": {
              "gte": 5000,
              "lte": 15000
            }
          }
        }
      ]
    }
  },
  "sort": [
    {
      "_score": {
        "order": "desc"
      }
    },
    {
      "sales_count": {
        "order": "desc"
      }
    }
  ],
  "from": 0,
  "size": 20,
  "highlight": {
    "fields": {
      "title": {},
      "description": {}
    },
    "pre_tags": ["<em>"],
    "post_tags": ["</em>"]
  },
  "aggs": {
    "categories": {
      "terms": {
        "field": "category_ids",
        "size": 10
      }
    },
    "brands": {
      "terms": {
        "field": "brand_id",
        "size": 10
      }
    },
    "price_ranges": {
      "range": {
        "field": "price",
        "ranges": [
          { "to": 1000 },
          { "from": 1000, "to": 5000 },
          { "from": 5000, "to": 10000 },
          { "from": 10000 }
        ]
      }
    }
  }
}

自动补全查询

// 搜索建议
GET /ecommerce-products-prod-v1/_search
{
  "suggest": {
    "product-suggest": {
      "prefix": "iPh",
      "completion": {
        "field": "suggest",
        "size": 10,
        "skip_duplicates": true
      }
    }
  }
}

地理位置查询

// 附近门店搜索
GET /stores-index/_search
{
  "query": {
    "bool": {
      "must": [
        {
          "term": {
            "status": "open"
          }
        }
      ],
      "filter": [
        {
          "geo_distance": {
            "distance": "5km",
            "location": {
              "lat": 39.9042,
              "lon": 116.4074
            }
          }
        }
      ]
    }
  },
  "sort": [
    {
      "_geo_distance": {
        "location": {
          "lat": 39.9042,
          "lon": 116.4074
        },
        "order": "asc",
        "unit": "km"
      }
    }
  ]
}

Go客户端集成

Elasticsearch Go客户端

package search

import (
    "bytes"
    "context"
    "encoding/json"
    "log"
    
    "github.com/elastic/go-elasticsearch/v8"
    "github.com/elastic/go-elasticsearch/v8/esapi"
)

type SearchService struct {
    client *elasticsearch.Client
    index  string
}

func NewSearchService(addresses []string, index string) (*SearchService, error) {
    cfg := elasticsearch.Config{
        Addresses: addresses,
    }
    
    client, err := elasticsearch.NewClient(cfg)
    if err != nil {
        return nil, err
    }
    
    return &SearchService{
        client: client,
        index:  index,
    }, nil
}

type SearchRequest struct {
    Query       string
    CategoryIDs []string
    BrandIDs    []string
    MinPrice    float64
    MaxPrice    float64
    Sort        string
    Page        int
    PageSize    int
}

type SearchResult struct {
    Total       int64           `json:"total"`
    Products    []Product       `json:"products"`
    Aggregations map[string]interface{} `json:"aggregations"`
}

func (s *SearchService) Search(ctx context.Context, req SearchRequest) (*SearchResult, error) {
    // 构建查询
    query := map[string]interface{}{
        "query": map[string]interface{}{
            "bool": map[string]interface{}{
                "must": []interface{}{
                    map[string]interface{}{
                        "multi_match": map[string]interface{}{
                            "query":     req.Query,
                            "fields":    []string{"title^3", "description^1", "tags^2"},
                            "type":      "best_fields",
                            "fuzziness": "AUTO",
                        },
                    },
                },
                "filter": buildFilters(req),
            },
        },
        "sort": buildSort(req.Sort),
        "from": (req.Page - 1) * req.PageSize,
        "size": req.PageSize,
        "highlight": map[string]interface{}{
            "fields": map[string]interface{}{
                "title":       map[string]interface{}{},
                "description": map[string]interface{}{},
            },
        },
        "aggs": map[string]interface{}{
            "categories": map[string]interface{}{
                "terms": map[string]interface{}{
                    "field": "category_ids",
                    "size":  10,
                },
            },
            "price_ranges": map[string]interface{}{
                "range": map[string]interface{}{
                    "field": "price",
                    "ranges": []map[string]interface{}{
                        {"to": 1000},
                        {"from": 1000, "to": 5000},
                        {"from": 5000, "to": 10000},
                        {"from": 10000},
                    },
                },
            },
        },
    }
    
    var buf bytes.Buffer
    if err := json.NewEncoder(&buf).Encode(query); err != nil {
        return nil, err
    }
    
    res, err := s.client.Search(
        s.client.Search.WithContext(ctx),
        s.client.Search.WithIndex(s.index),
        s.client.Search.WithBody(&buf),
        s.client.Search.WithTrackTotalHits(true),
    )
    if err != nil {
        return nil, err
    }
    defer res.Body.Close()
    
    if res.IsError() {
        return nil, fmt.Errorf("search error: %s", res.String())
    }
    
    var result map[string]interface{}
    if err := json.NewDecoder(res.Body).Decode(&result); err != nil {
        return nil, err
    }
    
    return parseSearchResult(result)
}

func buildFilters(req SearchRequest) []interface{} {
    var filters []interface{}
    
    // 状态过滤
    filters = append(filters, map[string]interface{}{
        "term": map[string]interface{}{
            "status": "on_sale",
        },
    })
    
    // 库存过滤
    filters = append(filters, map[string]interface{}{
        "range": map[string]interface{}{
            "stock": map[string]interface{}{
                "gt": 0,
            },
        },
    })
    
    // 分类过滤
    if len(req.CategoryIDs) > 0 {
        filters = append(filters, map[string]interface{}{
            "terms": map[string]interface{}{
                "category_ids": req.CategoryIDs,
            },
        })
    }
    
    // 价格范围
    if req.MinPrice > 0 || req.MaxPrice > 0 {
        priceRange := map[string]interface{}{}
        if req.MinPrice > 0 {
            priceRange["gte"] = req.MinPrice
        }
        if req.MaxPrice > 0 {
            priceRange["lte"] = req.MaxPrice
        }
        filters = append(filters, map[string]interface{}{
            "range": map[string]interface{}{
                "price": priceRange,
            },
        })
    }
    
    return filters
}

func buildSort(sort string) []interface{} {
    switch sort {
    case "price_asc":
        return []interface{}{
            map[string]interface{}{"price": map[string]interface{}{"order": "asc"}},
        }
    case "price_desc":
        return []interface{}{
            map[string]interface{}{"price": map[string]interface{}{"order": "desc"}},
        }
    case "sales":
        return []interface{}{
            map[string]interface{}{"sales_count": map[string]interface{}{"order": "desc"}},
        }
    case "newest":
        return []interface{}{
            map[string]interface{}{"created_at": map[string]interface{}{"order": "desc"}},
        }
    default:
        return []interface{}{
            map[string]interface{}{"_score": map[string]interface{}{"order": "desc"}},
            map[string]interface{}{"sales_count": map[string]interface{}{"order": "desc"}},
        }
    }
}

性能优化

索引优化

// 索引优化配置
PUT /my-index
{
  "settings": {
    "number_of_shards": 3,
    "number_of_replicas": 1,
    
    // 刷新间隔(默认1s,写入密集场景可调大)
    "refresh_interval": "5s",
    
    // 合并策略
    "merge": {
      "scheduler": {
        "max_thread_count": 2
      }
    },
    
    // 转储设置
    "translog": {
      "durability": "async",
      "sync_interval": "5s",
      "flush_threshold_size": "1gb"
    },
    
    // 缓存设置
    "queries": {
      "cache": {
        "enabled": true
      }
    },
    
    // 字段数据缓存
    "fielddata": {
      "cache": "node"
    }
  }
}

查询优化

查询优化技巧:
┌─────────────────────────────────────────┐
│ 1. 使用filter代替query                  │
│    - filter不计算相关性分数,更快         │
│    - filter结果可缓存                   │
│                                         │
│ 2. 避免深度分页                         │
│    - 使用search_after代替from/size      │
│    - 或使用scroll API                   │
│                                         │
│ 3. 只返回需要的字段                     │
│    - 使用_source过滤                    │
│    - 减少网络传输                       │
│                                         │
│ 4. 使用routing                          │
│    - 将相关数据路由到同一分片           │
│    - 减少跨分片查询                     │
│                                         │
│ 5. 预热filesystem cache                 │
│    - 使用index.store.preload            │
│    - 启动时加载常用索引                 │
│                                         │
│ 6. 使用doc_values                       │
│    - 用于排序和聚合                     │
│    - 磁盘存储,内存友好                 │
│                                         │
│ 7. 避免wildcard查询                     │
│    - 使用前缀查询代替                   │
│    - 或使用n-gram分词                   │
└─────────────────────────────────────────┘

批量操作优化

// 批量索引文档
func (s *SearchService) BulkIndex(ctx context.Context, products []Product) error {
    var buf bytes.Buffer
    
    for _, product := range products {
        // 元数据行
        meta := map[string]interface{}{
            "index": map[string]interface{}{
                "_index": s.index,
                "_id":    product.ID,
            },
        }
        json.NewEncoder(&buf).Encode(meta)
        
        // 数据行
        json.NewEncoder(&buf).Encode(product)
    }
    
    res, err := s.client.Bulk(
        s.client.Bulk.WithContext(ctx),
        s.client.Bulk.WithBody(&buf),
        s.client.Bulk.WithRefresh("wait_for"),
    )
    if err != nil {
        return err
    }
    defer res.Body.Close()
    
    if res.IsError() {
        return fmt.Errorf("bulk error: %s", res.String())
    }
    
    return nil
}

集群管理

集群健康检查

# 集群健康状态
GET /_cluster/health

# 返回示例:
{
  "cluster_name": "production",
  "status": "green",
  "number_of_nodes": 5,
  "number_of_data_nodes": 3,
  "active_primary_shards": 45,
  "active_shards": 90,
  "unassigned_shards": 0
}

# 状态说明:
# green: 所有分片正常分配
# yellow: 主分片正常,部分副本未分配
# red: 部分主分片未分配

索引生命周期管理(ILM)

// 定义ILM策略
PUT /_ilm/policy/logs-policy
{
  "policy": {
    "phases": {
      "hot": {
        "min_age": "0ms",
        "actions": {
          "rollover": {
            "max_size": "50gb",
            "max_age": "1d"
          },
          "set_priority": {
            "priority": 100
          }
        }
      },
      "warm": {
        "min_age": "7d",
        "actions": {
          "shrink": {
            "number_of_shards": 1
          },
          "forcemerge": {
            "max_num_segments": 1
          },
          "set_priority": {
            "priority": 50
          }
        }
      },
      "cold": {
        "min_age": "30d",
        "actions": {
          "freeze": {}
        }
      },
      "delete": {
        "min_age": "90d",
        "actions": {
          "delete": {}
        }
      }
    }
  }
}

总结

Elasticsearch最佳实践

场景推荐配置原因
电商搜索3主分片+1副本,IK分词平衡性能与扩展性
日志分析按天索引+ILM策略便于管理和清理
实时搜索refresh_interval=1s近实时可见
批量导入refresh_interval=-1提升写入性能
高并发读取增加副本数分散读取压力

关键原则

  1. 合理设计Mapping:选择正确的字段类型
  2. 优化查询DSL:使用filter、避免深度分页
  3. 批量操作:使用Bulk API提升性能
  4. 监控集群:关注健康状态和资源使用
  5. 生命周期管理:自动管理索引的冷热数据
  6. 定期优化:forcemerge、reindex等操作

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「backend」更多文章

  1. 幂等性设计模式:构建可靠的分布式系统
  2. WebSocket实时通信架构:从连接到百万并发的实战指南
  3. BFF架构模式:为不同前端定制专属后端服务