Go搜索引擎:Elasticsearch与Meilisearch
摘要: 本篇讲解Go语言集成Elasticsearch和Meilisearch两个搜索引擎,用olivere/elastic实现索引CRUD和全文搜索加聚合分析,用Meilisearch的轻量HTTP API实现即时搜索,分享ES mapping字段类型设置错误导致查询无结果的踩坑经验,对比Elasticsearch、Meilisearch和数据库LIKE三种搜索方案。
开篇故事
我们的商品搜索原先是MySQL的LIKE查询,商品表30万条数据。用户搜"无线耳机",LIKE '%无线%'扫全表,平均耗时1.2秒,高峰期2秒以上。产品要求改成毫秒级返回,还要支持按价格区间过滤、按销量排序、按品牌聚合。
先上了Elasticsearch,把30万商品数据同步过去。全文搜索+过滤+排序组合查询,平均返回时间降到30毫秒。但ES的mapping配置有坑,踩过一次查询死活不出结果,排查半天发现是字段类型设错了。
后来有个内部小项目只需要简单全文搜索,数据量不到5万条,上ES太重了。发现Meilisearch,单二进制部署,开箱即用,索引5万条数据搜索延迟在10毫秒以内。这篇把两个搜索引擎的Go集成写清楚。
一、Elasticsearch索引与搜索
olivere/elastic是Go操作ES最成熟的客户端库。核心概念是索引(类似数据库表)、文档(类似行记录)、mapping(类似表结构)。先建索引设mapping,再写文档,最后搜索。
先看索引创建和文档写入。mapping定义字段类型,text类型会分词建立倒排索引,keyword类型不分词用于精确匹配和聚合。
packagesearchimport("context""encoding/json""errors""log""strconv""time""github.com/olivere/elastic/v7")// Product 商品结构体typeProductstruct{IDint64`json:"id"`// 商品IDNamestring`json:"name"`// 商品名称,全文搜索Brandstring`json:"brand"`// 品牌,精确匹配Categorystring`json:"category"`// 分类,聚合Pricefloat64`json:"price"`// 价格,范围过滤Salesint`json:"sales"`// 销量,排序CreatedAtstring`json:"created_at"`// 创建时间}// ESClient Elasticsearch客户端封装typeESClientstruct{client*elastic.Client// olivere/elastic客户端}// NewESClient 创建ES客户端// url: ES地址,如"http://127.0.0.1:9200"funcNewESClient(urlstring)(*ESClient,error){// 创建客户端,设置超时和重试client,err:=elastic.NewClient(elastic.SetURL(url),// 请求超时10秒elastic.SetTimeout(10*time.Second),// 健康检查elastic.SetSniff(false),// 错误日志elastic.SetErrorLog(log.Default()),)iferr!=nil{returnnil,err}return&ESClient{client:client},nil}// CreateIndex 创建商品索引// indexName: 索引名,如"products"func(c*ESClient)CreateIndex(ctx context.Context,indexNamestring)error{// 先检查索引是否已存在exists,err:=c.client.IndexExists(indexName).Do(ctx)iferr!=nil{returnerr}ifexists{returnerrors.New("索引已存在: "+indexName)}// 定义mapping,指定每个字段的类型mapping:=`{ "mappings": { "properties": { "id": { "type": "long" }, "name": { "type": "text", "analyzer": "ik_max_word", "search_analyzer": "ik_smart" }, "brand": { "type": "keyword" }, "category": { "type": "keyword" }, "price": { "type": "double" }, "sales": { "type": "integer" }, "created_at":{ "type": "date" } } } }`// 创建索引并应用mapping_,err=c.client.CreateIndex(indexName).Body(mapping).Do(ctx)returnerr}// IndexDoc 写入单个文档// indexName: 索引名// doc: 商品文档func(c*ESClient)IndexDoc(ctx context.Context,indexNamestring,doc*Product)error{// 用商品ID作为文档ID,保证幂等_,err:=c.client.Index().Index(indexName).Id(strconv.FormatInt(doc.ID,10)).BodyJson(doc).Do(ctx)returnerr}// BulkIndex 批量写入文档// 批量写入比逐条写入快很多,减少网络往返func(c*ESClient)BulkIndex(ctx context.Context,indexNamestring,docs[]*Product)error{// 创建批量处理器bulk:=c.client.Bulk()for_,doc:=rangedocs{// 每个文档一个Index请求req:=elastic.NewBulkIndexRequest().Index(indexName).Doc(doc)bulk=bulk.Add(req)}// 一次性提交_,err:=bulk.Do(ctx)returnerr}搜索是ES的核心能力。bool查询组合must(必须匹配)、filter(过滤不评分)、should(可选匹配)。aggregations做聚合统计。
// SearchResult 搜索结果typeSearchResultstruct{Totalint64`json:"total"`// 总匹配数Items[]*Product`json:"items"`// 商品列表Brandsmap[string]int64`json:"brands"`// 品牌聚合}// SearchProducts 搜索商品// keyword: 搜索关键词// minPrice, maxPrice: 价格区间// brand: 品牌过滤,空则不过滤// from, size: 分页func(c*ESClient)SearchProducts(ctx context.Context,indexName,keywordstring,minPrice,maxPricefloat64,brandstring,from,sizeint)(*SearchResult,error){// 构造bool查询boolQuery:=elastic.NewBoolQuery()// 关键词全文搜索,放在must里参与评分ifkeyword!=""{matchQuery:=elastic.NewMatchQuery("name",keyword)boolQuery=boolQuery.Must(matchQuery)}// 价格区间过滤,放在filter里不参与评分priceRange:=elastic.NewRangeQuery("price").Gte(minPrice).Lte(maxPrice)boolQuery=boolQuery.Filter(priceRange)// 品牌精确匹配ifbrand!=""{brandTerm:=elastic.NewTermQuery("brand",brand)boolQuery=boolQuery.Filter(brandTerm)}// 构造搜索请求search:=c.client.Search().Index(indexName).Query(boolQuery).From(from).// 分页起始位置Size(size).// 每页数量// 按销量降序,销量相同按价格升序Sort("sales",false).Sort("price",true)// 添加品牌聚合,统计每个品牌的商品数量brandAgg:=elastic.NewTermsAggregation().Field("brand")search=search.Aggregation("brands",brandAgg)// 执行搜索res,err:=search.Do(ctx)iferr!=nil{returnnil,err}// 解析结果result:=&SearchResult{Total:res.TotalHits(),Items:make([]*Product,0),Brands:make(map[string]int64),}// 遍历命中文档for_,hit:=rangeres.Hits.Hits{varp Productiferr:=json.Unmarshal(hit.Source,&p);err!=nil{continue}result.Items=append(result.Items,&p)}// 解析聚合结果ifbrands,found:=res.Aggregations.Terms("brands");found{for_,bucket:=rangebrands.Buckets{result.Brands[bucket.Key.(string)]=bucket.DocCount}}returnresult,nil}全文搜索放must里参与相关度评分,过滤条件放filter里不评分只筛选。filter不评分性能更好,ES会对filter结果做缓存。聚合放顶层aggregations里,搜索同时返回聚合统计。
二、Meilisearch轻量替代
Meilisearch比ES轻很多,单个二进制文件部署,没有复杂的集群配置。适合数据量中小、搜索场景简单的项目。Go通过HTTP API集成,也可以用官方Go SDK。
packagesearchimport("bytes""context""encoding/json""fmt""net/http""time")// MeiliClient Meilisearch客户端typeMeiliClientstruct{urlstring// Meilisearch地址keystring// API密钥http*http.Client// HTTP客户端}// NewMeiliClient 创建Meilisearch客户端// url: 地址,如"http://127.0.0.1:7700"// key: API密钥,无鉴权可传空funcNewMeiliClient(url,keystring)*MeiliClient{return&MeiliClient{url:url,key:key,http:&http.Client{Timeout:10*time.Second},}}// AddDocuments 批量添加文档// index: 索引名// docs: 文档列表,自动序列化JSONfunc(c*MeiliClient)AddDocuments(ctx context.Context,indexstring,docsinterface{})error{// 序列化文档列表body,err:=json.Marshal(docs)iferr!=nil{returnerr}// 构造请求url:=fmt.Sprintf("%s/indexes/%s/documents",c.url,index)req,err:=http.NewRequestWithContext(ctx,"POST",url,bytes.NewReader(body))iferr!=nil{returnerr}req.Header.Set("Content-Type","application/json")ifc.key!=""{req.Header.Set("Authorization","Bearer "+c.key)}// 发送请求resp,err:=c.http.Do(req)iferr!=nil{returnerr}deferresp.Body.Close()returnnil}// Search 执行搜索// query: 搜索关键词// filter: 过滤表达式,如"price >= 50 AND price <= 200"// limit: 返回数量func(c*MeiliClient)Search(ctx context.Context,index,query,filterstring,limitint)(map[string]interface{},error){// 构造搜索请求体searchReq:=map[string]interface{}{"q":query,"limit":limit,}iffilter!=""{searchReq["filter"]=filter}body,_:=json.Marshal(searchReq)url:=fmt.Sprintf("%s/indexes/%s/search",c.url,index)req,err:=http.NewRequestWithContext(ctx,"POST",url,bytes.NewReader(body))iferr!=nil{returnnil,err}req.Header.Set("Content-Type","application/json")ifc.key!=""{req.Header.Set("Authorization","Bearer "+c.key)}resp,err:=c.http.Do(req)iferr!=nil{returnnil,err}deferresp.Body.Close()// 解析响应varresultmap[string]interface{}json.NewDecoder(resp.Body).Decode(&result)returnresult,nil}// UpdateSearchableAttributes 设置可搜索字段// 默认所有字段都可搜索,指定后只搜索指定字段func(c*MeiliClient)UpdateSearchableAttributes(ctx context.Context,indexstring,fields[]string)error{body,_:=json.Marshal(map[string]interface{}{"searchableAttributes":fields})url:=fmt.Sprintf("%s/indexes/%s/settings",c.url,index)req,_:=http.NewRequestWithContext(ctx,"PATCH",url,bytes.NewReader(body))req.Header.Set("Content-Type","application/json")ifc.key!=""{req.Header.Set("Authorization","Bearer "+c.key)}resp,err:=c.http.Do(req)iferr!=nil{returnerr}resp.Body.Close()returnnil}Meilisearch开箱支持拼写纠错和前缀匹配,搜索体验比ES默认配置好。filter语法简单,类似SQL表达式。适合不需要复杂聚合的场景。
三、踩坑经验:ES mapping类型错误导致查询无结果
这个坑排查了半天。商品索引的price字段,第一版mapping里设成了text类型。写入数据正常,文档能查到。但价格区间过滤怎么都查不出结果,price范围查询返回0条命中。
// 错误的mapping,price设成了textbadMapping:=`{ "mappings": { "properties": { "name": { "type": "text" }, "price": { "type": "text" } } } }`text类型会被分词器处理。写入价格99.9,分词后变成"99"和"9"两个token。range查询要在数字类型上操作,text类型没有数值范围,自然查不出结果。
ES的字段类型一旦索引了文档就不能改。要修mapping必须删索引重建。
// 修复步骤: 删除旧索引,用正确mapping重建func(c*ESClient)FixMapping(ctx context.Context,indexNamestring)error{// 删除旧索引_,err:=c.client.DeleteIndex(indexName).Do(ctx)iferr!=nil{// 索引不存在不算错误log.Printf("删除索引: %v",err)}// 用正确的mapping重建// price必须是double或scaled_float类型mapping:=`{ "mappings": { "properties": { "name": { "type": "text", "analyzer": "ik_max_word" }, "price": { "type": "double" }, "brand": { "type": "keyword" } } } }`_,err=c.client.CreateIndex(indexName).Body(mapping).Do(ctx)returnerr}踩过这个坑后总结了几条mapping设置原则。数值字段用integer或double,需要范围过滤绝不能用text。品牌、分类等需要精确匹配和聚合的字段用keyword。只有需要全文搜索的字段用text。text和keyword的区别是text会分词,keyword不分词整体存储。一个字段既要全文搜索又要精确匹配,可以用multi-field,主字段text加一个子字段keyword。
// multi-field: name既能全文搜索又能精确匹配multiMapping:=`{ "mappings": { "properties": { "name": { "type": "text", "analyzer": "ik_max_word", "fields": { "keyword": { "type": "keyword" } } } } } }`上线前先用少量数据验证mapping,确认所有查询都能正常返回结果,再全量同步。避免数据同步完了才发现mapping有问题,重建索引费时费力。
四、对比分析
| 特性 | Elasticsearch | Meilisearch | MySQL LIKE |
|---|---|---|---|
| 搜索性能 | 高(毫秒级) | 高(毫秒级) | 低(秒级) |
| 部署复杂度 | 高(集群+JVM) | 低(单二进制) | 无(随数据库) |
| 全文搜索 | 强(分词+评分) | 强(开箱即用) | 弱(只有LIKE) |
| 聚合分析 | 支持 | 不支持 | GROUP BY |
| 资源占用 | 高(1G起步) | 低(百M级) | 低 |
| 数据量上限 | 亿级 | 千万级 | 百万级 |
数据量百万以内且只有简单搜索需求,数据库LIKE勉强能用但性能差。数据量千万以内搜索需求简单,Meilisearch部署简单搜索体验好。数据量千万以上需要复杂聚合和评分,Elasticsearch是标准选择。三者的资源占用差距很大,ES动辄几个G内存,Meilisearch几百M,选型要考虑运维成本。
总结
Elasticsearch用olivere/elastic集成,mapping里数值字段用数值类型,文本搜索用text,精确匹配用keyword,复杂查询用bool组合must和filter。Meilisearch单二进制部署,HTTP API集成简单,适合中小数据量的即时搜索。mapping类型设错会导致查询无结果,上线前用少量数据验证。选型看数据量和搜索复杂度,别对小数据量上ES,也别对大数据量指望LIKE。