Elasticsearch 商业搜索场景
商业系统接入 ES 的目标不是“能搜一下”这么简单,而是让用户在海量业务数据里快速找到想要的结果,同时让后台运营、客服、研发也能高效检索和分析数据。
本章用商业常见场景串起来:商品搜索、订单检索、工单检索、日志检索、搜索建议、地理位置查询、MySQL 到 ES 的同步、索引重建和线上排查。
商业场景为什么常用 ES
以商品搜索为例,页面上一个简单搜索框背后通常包含:
| 能力 | 例子 | 如果不用 ES 可能怎样 |
|---|---|---|
| 全文召回 | 搜“无线降噪耳机”命中标题、卖点、标签 | like 慢,中文切词不准 |
| 结构化过滤 | 品牌、分类、价格、有货、店铺 | 主库压力大,组合索引很难覆盖 |
| 相关性排序 | 标题命中比详情命中更重要 | 只能按时间或销量排序,结果不贴近用户意图 |
| 聚合筛选 | 左侧品牌、价格区间、分类数量 | 每次查库统计成本高 |
| 高亮 | 命中的词标红 | 用户不知道为什么搜到 |
| 搜索建议 | 输入“蓝牙”提示“蓝牙耳机” | 需要额外建前缀索引 |
| 数据分析 | 按品牌、类目、状态统计 | OLTP 主库被分析查询拖慢 |
ES 的本质位置是:搜索视图,不是事实主库。
flowchart TD
A["业务主库 MySQL"] --> B["商品、订单、工单事实数据"]
B --> C["同步链路<br/>MQ、CDC、定时任务"]
C --> D["Elasticsearch 搜索视图"]
E["用户和后台系统"] --> F["搜索 API"]
F --> D
D --> G["列表、高亮、聚合、排序"]典型商业架构
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为什么要这样拆:
- MySQL 保存事实数据,保证订单、库存、价格等关键数据可靠。
- ES 保存适合搜索的冗余字段,减少运行时 Join。
- MQ 或 CDC 解耦主流程,避免 ES 抖动影响下单、改价、上下架。
- 补偿任务兜底,解决消息丢失、消费失败、ES 临时不可用导致的不一致。
商品搜索索引设计
商品搜索文档不是直接照搬商品表。它应该是一个“面向搜索页面的视图”。
| 字段 | 来源 | 为什么放进 ES |
|---|---|---|
id | SKU 或搜索文档 ID | 作为唯一标识和稳定排序兜底 |
spuId、skuId | 商品表 | 跳转详情、聚合 SKU |
productName | 商品标题 | 关键词全文搜索和高亮 |
subTitle | 商品卖点 | 辅助召回 |
brandId、brandName | 品牌表 | 品牌过滤和聚合 |
categoryId、categoryName | 类目表 | 类目过滤和聚合 |
shopId | 店铺表 | 店铺过滤 |
tags | 商品标签、运营标签 | 精确过滤和召回增强 |
price | 价格表 | 价格范围和排序 |
stockStatus | 库存服务 | 只看有货 |
saleCount | 销售统计 | 销量排序和权重 |
location | 门店或仓库 | 附近门店、同城配送 |
updatedAt | 更新时间 | 排查和增量同步 |
创建索引:
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" }
}
}
}给业务使用别名:
POST /_aliases
{
"actions": [
{ "add": { "index": "product_search_v1", "alias": "product_search" } }
]
}业务代码永远访问 product_search,不要写死 product_search_v1。以后重建索引时只切别名,代码不需要改。
商品写入 Demo
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。
POST /api/search/products
{
"keyword": "无线降噪耳机",
"brandIds": [101, 102],
"categoryId": 3001,
"minPrice": 10000,
"maxPrice": 30000,
"onlyInStock": true,
"sort": "RELEVANCE",
"pageNo": 1,
"pageSize": 20
}服务端把它翻译成 ES 查询:
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": {}
}
}
}这里的设计原则:
- 关键词放
must,因为它影响相关性。 - 品牌、分类、价格、库存放
filter,因为它们只是硬条件,不应该影响_score。 - 运营标签、销量放
should,命中时提高排序,但不强制要求。 - 排序最后加
id,保证分页稳定。
聚合筛选 Demo
商品列表页常见左侧筛选项:品牌、分类、价格区间。它不是单独查数据库统计,而是和搜索条件一起在 ES 里聚合。
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。
PUT /search_suggest_v1
{
"mappings": {
"properties": {
"suggestText": { "type": "search_as_you_type" },
"type": { "type": "keyword" },
"hotScore": { "type": "long" }
}
}
}写入候选词:
POST /search_suggest_v1/_doc/1
{
"suggestText": "蓝牙耳机",
"type": "PRODUCT_KEYWORD",
"hotScore": 9800
}查询建议:
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。
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"
}
}
]
}地理查询不要只存城市名。城市名适合过滤,经纬度适合距离计算。
订单检索场景
订单检索和商品搜索不同:订单更强调精确条件和时间范围,全文只是辅助。
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" }
}
}
}后台订单查询:
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 截止时间。
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" }
]
}工单检索一定要带租户、权限或数据范围过滤,不能只靠关键词,否则容易出现越权数据。
日志检索场景
日志适合按时间滚动索引,例如:
app-log-2026.07.01
app-log-2026.07.02写入日志示例:
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"
}查询错误日志:
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 同步流程
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,演示商品搜索服务如何组装查询。真实项目里还要加权限、参数校验、异常处理和监控。
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());
}
}请求对象示例:
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;
}
}文档对象示例:
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、分词器、字段类型改错后,通常不能直接修改旧字段,需要重建索引。
flowchart TD
A["发现字段或分词需要调整"] --> B["创建 product_search_v2"]
B --> C["从 MySQL 全量重建数据"]
C --> D["对比 v1 和 v2 查询结果"]
D --> E["暂停或双写增量变更"]
E --> F["原子切换别名 product_search"]
F --> G["观察错误率和搜索指标"]
G --> H["确认无问题后删除旧索引"]切别名:
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 | 扩容、清理索引、修复节点 |
排查流程
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、分片"]本章小结
商业搜索落地要抓住五件事:
- MySQL 是事实来源,ES 是搜索视图。
- 索引文档按页面查询目标设计,不照搬数据库表。
- 关键词搜索、结构化过滤、业务排序要分清职责。
- 同步链路必须有重试、死信和补偿。
- Mapping、分词、别名、慢查询、集群健康是生产排障主线。
