Skip to content

Elasticsearch 商业搜索场景

商业系统接入 ES 的目标不是“能搜一下”这么简单,而是让用户在海量业务数据里快速找到想要的结果,同时让后台运营、客服、研发也能高效检索和分析数据。

本章用商业常见场景串起来:商品搜索、订单检索、工单检索、日志检索、搜索建议、地理位置查询、MySQL 到 ES 的同步、索引重建和线上排查。

商业场景为什么常用 ES

以商品搜索为例,页面上一个简单搜索框背后通常包含:

能力例子如果不用 ES 可能怎样
全文召回搜“无线降噪耳机”命中标题、卖点、标签like 慢,中文切词不准
结构化过滤品牌、分类、价格、有货、店铺主库压力大,组合索引很难覆盖
相关性排序标题命中比详情命中更重要只能按时间或销量排序,结果不贴近用户意图
聚合筛选左侧品牌、价格区间、分类数量每次查库统计成本高
高亮命中的词标红用户不知道为什么搜到
搜索建议输入“蓝牙”提示“蓝牙耳机”需要额外建前缀索引
数据分析按品牌、类目、状态统计OLTP 主库被分析查询拖慢

ES 的本质位置是:搜索视图,不是事实主库

mermaid
flowchart TD
    A["业务主库 MySQL"] --> B["商品、订单、工单事实数据"]
    B --> C["同步链路<br/>MQ、CDC、定时任务"]
    C --> D["Elasticsearch 搜索视图"]
    E["用户和后台系统"] --> F["搜索 API"]
    F --> D
    D --> G["列表、高亮、聚合、排序"]

典型商业架构

mermaid
flowchart TD
    A["管理后台修改商品"] --> B["业务服务校验和保存 MySQL"]
    B --> C["提交事务"]
    C --> D["发送商品变更消息"]
    D --> E["搜索同步服务消费消息"]
    E --> F["查询并组装商品搜索文档"]
    F --> G["写入 ES 别名 product_search"]
    H["用户搜索商品"] --> I["搜索服务解析参数"]
    I --> J["查询 ES"]
    J --> K["返回商品列表和聚合筛选"]
    L["补偿任务"] --> F

为什么要这样拆:

  1. MySQL 保存事实数据,保证订单、库存、价格等关键数据可靠。
  2. ES 保存适合搜索的冗余字段,减少运行时 Join。
  3. MQ 或 CDC 解耦主流程,避免 ES 抖动影响下单、改价、上下架。
  4. 补偿任务兜底,解决消息丢失、消费失败、ES 临时不可用导致的不一致。

商品搜索索引设计

商品搜索文档不是直接照搬商品表。它应该是一个“面向搜索页面的视图”。

字段来源为什么放进 ES
idSKU 或搜索文档 ID作为唯一标识和稳定排序兜底
spuIdskuId商品表跳转详情、聚合 SKU
productName商品标题关键词全文搜索和高亮
subTitle商品卖点辅助召回
brandIdbrandName品牌表品牌过滤和聚合
categoryIdcategoryName类目表类目过滤和聚合
shopId店铺表店铺过滤
tags商品标签、运营标签精确过滤和召回增强
price价格表价格范围和排序
stockStatus库存服务只看有货
saleCount销售统计销量排序和权重
location门店或仓库附近门店、同城配送
updatedAt更新时间排查和增量同步

创建索引:

json
PUT /product_search_v1
{
  "settings": {
    "number_of_shards": 3,
    "number_of_replicas": 1,
    "refresh_interval": "1s"
  },
  "mappings": {
    "dynamic": "strict",
    "properties": {
      "id": { "type": "long" },
      "spuId": { "type": "long" },
      "skuId": { "type": "long" },
      "productName": {
        "type": "text",
        "analyzer": "standard",
        "fields": {
          "keyword": { "type": "keyword", "ignore_above": 256 }
        }
      },
      "subTitle": { "type": "text", "analyzer": "standard" },
      "brandId": { "type": "long" },
      "brandName": { "type": "keyword" },
      "categoryId": { "type": "long" },
      "categoryName": { "type": "keyword" },
      "shopId": { "type": "long" },
      "tags": { "type": "keyword" },
      "price": { "type": "scaled_float", "scaling_factor": 100 },
      "stockStatus": { "type": "keyword" },
      "saleCount": { "type": "long" },
      "location": { "type": "geo_point" },
      "createdAt": { "type": "date" },
      "updatedAt": { "type": "date" }
    }
  }
}

给业务使用别名:

json
POST /_aliases
{
  "actions": [
    { "add": { "index": "product_search_v1", "alias": "product_search" } }
  ]
}

业务代码永远访问 product_search,不要写死 product_search_v1。以后重建索引时只切别名,代码不需要改。

商品写入 Demo

json
PUT /product_search/_doc/10001
{
  "id": 10001,
  "spuId": 9001,
  "skuId": 10001,
  "productName": "无线蓝牙降噪耳机 Pro",
  "subTitle": "主动降噪 长续航 低延迟 游戏音乐通用",
  "brandId": 101,
  "brandName": "SoundMax",
  "categoryId": 3001,
  "categoryName": "耳机",
  "shopId": 501,
  "tags": ["官方旗舰", "蓝牙耳机", "主动降噪"],
  "price": 29900,
  "stockStatus": "IN_STOCK",
  "saleCount": 5821,
  "location": { "lat": 31.2304, "lon": 121.4737 },
  "createdAt": "2026-06-01T10:00:00",
  "updatedAt": "2026-07-01T10:00:00"
}

价格用 scaled_float,这里的 29900 表示 299.00 元。这样比直接用浮点小数更稳定,避免金额精度问题。

商品搜索 API 设计

前端请求可以设计成业务语义,而不是直接暴露 ES DSL。

json
POST /api/search/products
{
  "keyword": "无线降噪耳机",
  "brandIds": [101, 102],
  "categoryId": 3001,
  "minPrice": 10000,
  "maxPrice": 30000,
  "onlyInStock": true,
  "sort": "RELEVANCE",
  "pageNo": 1,
  "pageSize": 20
}

服务端把它翻译成 ES 查询:

json
GET /product_search/_search
{
  "from": 0,
  "size": 20,
  "query": {
    "bool": {
      "must": [
        {
          "multi_match": {
            "query": "无线降噪耳机",
            "fields": ["productName^5", "subTitle^2", "tags"]
          }
        }
      ],
      "filter": [
        { "terms": { "brandId": [101, 102] } },
        { "term": { "categoryId": 3001 } },
        { "range": { "price": { "gte": 10000, "lte": 30000 } } },
        { "term": { "stockStatus": "IN_STOCK" } }
      ],
      "should": [
        { "term": { "tags": { "value": "官方旗舰", "boost": 2 } } },
        { "range": { "saleCount": { "gte": 1000, "boost": 1.5 } } }
      ]
    }
  },
  "sort": [
    { "_score": "desc" },
    { "saleCount": "desc" },
    { "id": "desc" }
  ],
  "highlight": {
    "fields": {
      "productName": {},
      "subTitle": {}
    }
  }
}

这里的设计原则:

  1. 关键词放 must,因为它影响相关性。
  2. 品牌、分类、价格、库存放 filter,因为它们只是硬条件,不应该影响 _score
  3. 运营标签、销量放 should,命中时提高排序,但不强制要求。
  4. 排序最后加 id,保证分页稳定。

聚合筛选 Demo

商品列表页常见左侧筛选项:品牌、分类、价格区间。它不是单独查数据库统计,而是和搜索条件一起在 ES 里聚合。

json
GET /product_search/_search
{
  "size": 20,
  "query": {
    "bool": {
      "must": [
        { "match": { "productName": "蓝牙耳机" } }
      ],
      "filter": [
        { "term": { "stockStatus": "IN_STOCK" } }
      ]
    }
  },
  "aggs": {
    "brand_count": {
      "terms": { "field": "brandName", "size": 20 }
    },
    "category_count": {
      "terms": { "field": "categoryName", "size": 20 }
    },
    "price_ranges": {
      "range": {
        "field": "price",
        "ranges": [
          { "to": 10000 },
          { "from": 10000, "to": 30000 },
          { "from": 30000, "to": 50000 },
          { "from": 50000 }
        ]
      }
    }
  }
}

注意:聚合字段必须适合聚合,通常用 keyword、数值、日期。不要直接对 text 字段聚合。

搜索建议 Demo

搜索框输入“蓝牙”时,提示“蓝牙耳机”“蓝牙音箱”。可以使用 search_as_you_type

json
PUT /search_suggest_v1
{
  "mappings": {
    "properties": {
      "suggestText": { "type": "search_as_you_type" },
      "type": { "type": "keyword" },
      "hotScore": { "type": "long" }
    }
  }
}

写入候选词:

json
POST /search_suggest_v1/_doc/1
{
  "suggestText": "蓝牙耳机",
  "type": "PRODUCT_KEYWORD",
  "hotScore": 9800
}

查询建议:

json
GET /search_suggest_v1/_search
{
  "size": 10,
  "query": {
    "multi_match": {
      "query": "蓝牙",
      "type": "bool_prefix",
      "fields": [
        "suggestText",
        "suggestText._2gram",
        "suggestText._3gram"
      ]
    }
  },
  "sort": [
    { "hotScore": "desc" }
  ]
}

搜索建议要注意脏词过滤、低质词过滤和运营干预,否则容易把错误词、敏感词、低价值词提示给用户。

附近门店 Demo

如果商品可以按附近门店或仓库检索,需要 geo_point

json
GET /product_search/_search
{
  "query": {
    "bool": {
      "must": [
        { "match": { "productName": "咖啡" } }
      ],
      "filter": [
        {
          "geo_distance": {
            "distance": "5km",
            "location": {
              "lat": 31.2304,
              "lon": 121.4737
            }
          }
        },
        { "term": { "stockStatus": "IN_STOCK" } }
      ]
    }
  },
  "sort": [
    {
      "_geo_distance": {
        "location": {
          "lat": 31.2304,
          "lon": 121.4737
        },
        "order": "asc",
        "unit": "km"
      }
    }
  ]
}

地理查询不要只存城市名。城市名适合过滤,经纬度适合距离计算。

订单检索场景

订单检索和商品搜索不同:订单更强调精确条件和时间范围,全文只是辅助。

json
PUT /order_search_v1
{
  "mappings": {
    "dynamic": "strict",
    "properties": {
      "orderId": { "type": "keyword" },
      "buyerId": { "type": "long" },
      "buyerMobileSuffix": { "type": "keyword" },
      "receiverName": { "type": "keyword" },
      "productName": { "type": "text", "analyzer": "standard" },
      "orderStatus": { "type": "keyword" },
      "payAmount": { "type": "scaled_float", "scaling_factor": 100 },
      "createdAt": { "type": "date" }
    }
  }
}

后台订单查询:

json
GET /order_search_v1/_search
{
  "query": {
    "bool": {
      "must": [
        { "match": { "productName": "耳机" } }
      ],
      "filter": [
        { "term": { "orderStatus": "PAID" } },
        { "term": { "buyerMobileSuffix": "1823" } },
        {
          "range": {
            "createdAt": {
              "gte": "2026-07-01T00:00:00",
              "lte": "2026-07-01T23:59:59"
            }
          }
        }
      ]
    }
  },
  "sort": [
    { "createdAt": "desc" },
    { "orderId": "desc" }
  ]
}

订单金额、订单状态、库存扣减仍然以数据库为准。ES 只负责查得快,不负责做交易决策。

工单检索场景

工单系统常见条件:关键词、处理人、优先级、状态、租户、SLA 截止时间。

json
GET /ticket_search/_search
{
  "query": {
    "bool": {
      "must": [
        {
          "multi_match": {
            "query": "无法支付 超时",
            "fields": ["ticketTitle^3", "description", "latestReply"]
          }
        }
      ],
      "filter": [
        { "term": { "tenantId": 10001 } },
        { "terms": { "status": ["OPEN", "PROCESSING"] } },
        { "term": { "priority": "P1" } }
      ]
    }
  },
  "sort": [
    { "slaDeadline": "asc" },
    { "_score": "desc" }
  ]
}

工单检索一定要带租户、权限或数据范围过滤,不能只靠关键词,否则容易出现越权数据。

日志检索场景

日志适合按时间滚动索引,例如:

text
app-log-2026.07.01
app-log-2026.07.02

写入日志示例:

json
POST /app-log-2026.07.01/_doc
{
  "traceId": "8e4f2b0c",
  "service": "order-service",
  "level": "ERROR",
  "path": "/api/orders/pay",
  "message": "payment timeout",
  "costMs": 3200,
  "@timestamp": "2026-07-01T10:15:00"
}

查询错误日志:

json
GET /app-log-2026.07.01/_search
{
  "query": {
    "bool": {
      "must": [
        { "match": { "message": "timeout" } }
      ],
      "filter": [
        { "term": { "service": "order-service" } },
        { "term": { "level": "ERROR" } },
        {
          "range": {
            "@timestamp": {
              "gte": "2026-07-01T10:00:00",
              "lte": "2026-07-01T11:00:00"
            }
          }
        }
      ]
    }
  }
}

日志索引要配合生命周期策略,过期日志及时降级或删除。否则磁盘会被写满,进而导致分片无法分配,甚至索引只读。

MySQL 到 ES 的同步方式

方式流程优点风险
同步双写写 MySQL 后立即写 ES实现简单ES 抖动会影响主流程,双写失败难处理
MQ 异步写 MySQL 后发消息,消费者写 ES解耦主流程,吞吐好有最终一致延迟,需要重试和补偿
Binlog CDC监听 MySQL Binlog 同步 ES对业务代码侵入小链路更复杂,要处理字段组装
定时补偿扫描变更数据修复 ES兜底可靠延迟较高,不能作为唯一实时方案

推荐商业项目使用:业务消息或 CDC 做主同步,定时补偿做兜底

更完整的一致性设计不要只停在“发消息写 ES”,还要覆盖双写失败、消息丢失、重复消费、乱序、删除下架、refresh 延迟、补偿对账和全量重建增量追平。详细原理继续看:MySQL 与 ES 数据一致性

MQ 同步流程

mermaid
flowchart TD
    A["商品服务保存 MySQL"] --> B["本地事务提交"]
    B --> C["发送 product.changed 消息"]
    C --> D["搜索同步服务消费消息"]
    D --> E{"查询商品是否存在"}
    E -- "存在且上架" --> F["组装搜索文档"]
    F --> G["upsert 到 ES"]
    E -- "删除或下架" --> H["删除或标记 ES 文档"]
    G --> I["记录同步成功"]
    H --> I
    D --> J["失败重试和死信队列"]

为什么消费端要重新查 MySQL:消息里只放变更 ID 更稳。因为商品详情可能由多张表组成,消费时查询最新快照,可以减少乱序消息造成的旧数据覆盖。

Java 服务端 Demo

下面示例使用官方 Elasticsearch Java Client,演示商品搜索服务如何组装查询。真实项目里还要加权限、参数校验、异常处理和监控。

java
import co.elastic.clients.elasticsearch.ElasticsearchClient;
import co.elastic.clients.elasticsearch._types.SortOrder;
import co.elastic.clients.elasticsearch.core.SearchResponse;
import co.elastic.clients.elasticsearch.core.search.Hit;
import org.springframework.stereotype.Service;

import java.io.IOException;
import java.util.List;
import java.util.stream.Collectors;

@Service
public class ProductSearchService {

    private final ElasticsearchClient elasticsearchClient;

    public ProductSearchService(ElasticsearchClient elasticsearchClient) {
        this.elasticsearchClient = elasticsearchClient;
    }

    public List<ProductSearchDoc> search(ProductSearchRequest request) throws IOException {
        SearchResponse<ProductSearchDoc> response = elasticsearchClient.search(s -> s
                .index("product_search")
                .from((request.getPageNo() - 1) * request.getPageSize())
                .size(request.getPageSize())
                .query(q -> q.bool(b -> b
                        .must(m -> m.multiMatch(mm -> mm
                                .query(request.getKeyword())
                                .fields("productName^5", "subTitle^2", "tags")
                        ))
                        .filter(f -> f.term(t -> t.field("stockStatus").value("IN_STOCK")))
                        .filter(f -> f.range(r -> r.number(n -> n
                                .field("price")
                                .gte((double) request.getMinPrice())
                                .lte((double) request.getMaxPrice())
                        )))
                ))
                .sort(sort -> sort.score(sc -> sc.order(SortOrder.Desc)))
                .sort(sort -> sort.field(f -> f.field("saleCount").order(SortOrder.Desc)))
                .sort(sort -> sort.field(f -> f.field("id").order(SortOrder.Desc)))
                .highlight(h -> h.fields("productName", hf -> hf))
                , ProductSearchDoc.class);

        return response.hits().hits().stream()
                .map(Hit::source)
                .collect(Collectors.toList());
    }
}

请求对象示例:

java
public class ProductSearchRequest {

    private String keyword;
    private int minPrice;
    private int maxPrice;
    private int pageNo;
    private int pageSize;

    public ProductSearchRequest(String keyword, int minPrice, int maxPrice, int pageNo, int pageSize) {
        this.keyword = keyword;
        this.minPrice = minPrice;
        this.maxPrice = maxPrice;
        this.pageNo = pageNo;
        this.pageSize = pageSize;
    }

    public String getKeyword() {
        return keyword;
    }

    public int getMinPrice() {
        return minPrice;
    }

    public int getMaxPrice() {
        return maxPrice;
    }

    public int getPageNo() {
        return pageNo;
    }

    public int getPageSize() {
        return pageSize;
    }
}

文档对象示例:

java
public class ProductSearchDoc {

    public Long id;
    public String productName;
    public String subTitle;
    public Long brandId;
    public String brandName;
    public Long categoryId;
    public String categoryName;
    public Integer price;
    public String stockStatus;
    public Long saleCount;

    public ProductSearchDoc() {
    }
}

这个 Demo 重点不是 Java 语法,而是服务端边界:前端传业务参数,后端统一翻译 DSL,避免前端直接拼 ES 查询。

索引重建和别名切换

Mapping、分词器、字段类型改错后,通常不能直接修改旧字段,需要重建索引。

mermaid
flowchart TD
    A["发现字段或分词需要调整"] --> B["创建 product_search_v2"]
    B --> C["从 MySQL 全量重建数据"]
    C --> D["对比 v1 和 v2 查询结果"]
    D --> E["暂停或双写增量变更"]
    E --> F["原子切换别名 product_search"]
    F --> G["观察错误率和搜索指标"]
    G --> H["确认无问题后删除旧索引"]

切别名:

json
POST /_aliases
{
  "actions": [
    { "remove": { "index": "product_search_v1", "alias": "product_search" } },
    { "add": { "index": "product_search_v2", "alias": "product_search" } }
  ]
}

如果业务代码直接写死版本索引,就无法平滑切换;这也是生产项目一定要用别名的原因。

常见线上问题

现象可能原因排查方式处理
商品改价后搜索页还是旧价格同步消息失败、消费延迟、ES 写入失败查消息堆积、同步日志、ES 文档 updatedAt重试消息,补偿同步
商品下架后仍能搜到删除或状态变更没有同步查 MySQL 状态和 ES stockStatus、上下架字段删除 ES 文档或标记不可售
搜不到明明存在的商品分词不匹配、字段用错、状态过滤错误_analyze_explain、检查 DSL调整分词、Mapping 或查询
结果相关性差字段权重、同义词、业务排序不合理对比 _score、看 explain调整 fields boost、should 权重
查询慢深分页、聚合过大、wildcard、分片过多slowlog、profile、hot threads限制分页,优化 DSL,调整分片
集群 yellow/red副本未分配、磁盘水位、节点异常health、cat shards、allocation explain扩容、清理索引、修复节点

排查流程

mermaid
flowchart TD
    A["搜索结果异常"] --> B{"是搜不到还是搜不准"}
    B -- "搜不到" --> C["检查数据是否同步到 ES"]
    C --> D["检查 Mapping 和分词"]
    D --> E["检查 term/match 是否用错"]
    B -- "搜不准" --> F["检查字段权重和同义词"]
    F --> G["检查 filter 是否误伤"]
    G --> H["用 explain 查看评分"]
    B -- "查询慢" --> I["看 slowlog 和 profile"]
    I --> J["检查深分页、聚合、wildcard、分片"]

本章小结

商业搜索落地要抓住五件事:

  1. MySQL 是事实来源,ES 是搜索视图。
  2. 索引文档按页面查询目标设计,不照搬数据库表。
  3. 关键词搜索、结构化过滤、业务排序要分清职责。
  4. 同步链路必须有重试、死信和补偿。
  5. Mapping、分词、别名、慢查询、集群健康是生产排障主线。