Elasticsearch Mapping 与查询
如果说 ES 的核心思想是“建索引后再搜索”,那么 Mapping 回答的是:
字段应该怎么被索引?
Query DSL 回答的是:
用户的搜索条件应该怎么匹配这些索引?
这两者必须配套。字段类型设计错了,查询语句再复杂也救不回来;查询类型用错了,明明有数据也可能搜不到。
为什么 Mapping 很重要
同样一个字段,建模方式不同,搜索效果完全不同。
以商品名 无线蓝牙降噪耳机 Pro 为例:
- 如果按
text建模,适合搜索“蓝牙耳机”“降噪”。 - 如果按
keyword建模,适合完整商品名精确匹配、排序或聚合。 - 如果只建成
keyword,用户搜“蓝牙耳机”可能搜不到。 - 如果只建成
text,做品牌聚合、精确过滤、排序会很麻烦。
常用字段类型
| 类型 | 是否分词 | 适合场景 | 示例字段 |
|---|---|---|---|
text | 是 | 全文搜索、高亮 | productName、description |
keyword | 否 | 精确过滤、排序、聚合 | brandName、status、orderId |
long | 否 | ID、数量 | brandId、saleCount |
scaled_float | 否 | 金额 | price、payAmount |
date | 否 | 时间范围、排序 | createdAt、updatedAt |
boolean | 否 | 是否类字段 | deleted、newUserOnly |
geo_point | 否 | 经纬度距离查询 | location |
object | 视字段而定 | 普通 JSON 对象 | extra |
nested | 视字段而定 | 数组对象需要保持内部关系 | skuList、coupons |
金额建议使用 scaled_float 或整数分。不要用普通浮点承载核心金额判断,否则容易出现精度问题。
Mapping 设计原则
设计 Mapping 前先问六个问题:
| 问题 | 决定什么 |
|---|---|
| 这个字段要不要被搜索 | 是否需要 index |
| 是全文搜索还是精确匹配 | 用 text 还是 keyword |
| 是否要排序或聚合 | 是否需要 doc values 友好的类型 |
| 是否需要高亮 | 通常需要 text 字段 |
| 是否会频繁更新 | 更新频繁字段要谨慎放入 ES |
| 是否来自多张表 | 是否需要写入前反范式冗余 |
常见字段选择:
| 字段 | 推荐类型 | 为什么 |
|---|---|---|
| 商品名 | text + keyword 子字段 | 全文搜索 + 必要时精确匹配 |
| 卖点 | text | 辅助召回 |
| 品牌 ID | long | 精确过滤 |
| 品牌名 | keyword | 展示和聚合 |
| 类目 ID | long | 精确过滤 |
| 标签 | keyword | 精确过滤、聚合、运营加权 |
| 价格 | scaled_float | 范围查询和排序 |
| 库存状态 | keyword | 是否有货过滤 |
| 销量 | long | 排序、业务加权 |
| 更新时间 | date | 增量同步、排查、排序 |
不要所有字段都无脑用 text。text 会分词,适合自然语言搜索;状态、ID、标签、枚举这类字段被分词反而会让过滤和聚合出问题。
商品搜索 Mapping Demo
PUT /product_search_v1
{
"settings": {
"number_of_shards": 3,
"number_of_replicas": 1,
"analysis": {
"analyzer": {
"product_text_analyzer": {
"type": "standard"
}
}
}
},
"mappings": {
"dynamic": "strict",
"properties": {
"id": { "type": "long" },
"spuId": { "type": "long" },
"skuId": { "type": "long" },
"productName": {
"type": "text",
"analyzer": "product_text_analyzer",
"fields": {
"keyword": { "type": "keyword", "ignore_above": 256 }
}
},
"subTitle": {
"type": "text",
"analyzer": "product_text_analyzer"
},
"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" }
}
}
}这里的 dynamic: strict 表示不允许写入 Mapping 中没有定义的字段。它能防止字段名写错导致 ES 自动创建错误字段,例如把 stockStatus 写成 stockStats。
动态 Mapping 的风险
ES 可以根据写入文档自动推断字段类型,但生产项目不建议完全依赖动态 Mapping。
POST /dynamic_demo/_doc/1
{
"status": "1",
"price": "100"
}如果第一次写入时 price 是字符串,ES 可能把它映射成 text 或 keyword,后续再想按数值范围查询就很麻烦。
| 风险 | 后果 |
|---|---|
| 字段类型自动推断错误 | 范围查询、排序、聚合异常 |
| 字段名写错也被创建 | 索引里出现脏字段 |
| 字段数量无限膨胀 | Mapping 爆炸,集群状态变大 |
| 后期修改成本高 | 已有字段类型通常不能直接修改 |
推荐做法:核心业务索引手写 Mapping,日志类索引用 index template 管理。
text 和 keyword 的本质区别
flowchart TD
A["写入字段 productName"] --> B{"字段类型"}
B -- "text" --> C["经过 analyzer 分词"]
C --> D["多个 term 写入倒排索引"]
B -- "keyword" --> E["整体值作为一个 term"]
E --> F["适合精确过滤、排序、聚合"]例如:
{
"productName": "无线蓝牙降噪耳机 Pro"
}text 可能得到:
无线, 蓝牙, 降噪, 耳机, prokeyword 得到:
无线蓝牙降噪耳机 Pro所以:
- 想搜“蓝牙耳机”,用
productName的text字段。 - 想完整匹配商品名,使用
productName.keyword。 - 想按品牌统计数量,使用
brandName这种keyword字段。
ES 查询大致分两类
叶子查询
针对具体字段直接查:
termtermsmatchmulti_matchrangeexists
组合查询
把多个查询条件组合起来,最常见的是 bool。
term 和 match 的区别
term
更偏向精确匹配,不分析查询词。适合查 keyword、数值、日期、布尔等结构化字段。
match
更偏向全文检索,会对输入做分析。适合查 text 字段。
为什么 term 查 text 经常查不到
假设商品名字段是 text:
{
"productName": "无线蓝牙降噪耳机 Pro"
}写入时可能被分成:
无线, 蓝牙, 降噪, 耳机, pro如果你这样查:
GET /product_search/_search
{
"query": {
"term": {
"productName": "无线蓝牙降噪耳机 Pro"
}
}
}term 不会对查询词再分词,它会拿整句话去倒排索引里找完全一样的 term,自然很可能找不到。
全文搜索应该用:
GET /product_search/_search
{
"query": {
"match": {
"productName": "无线蓝牙降噪耳机 Pro"
}
}
}精确匹配则应该查 keyword 字段:
GET /product_search/_search
{
"query": {
"term": {
"productName.keyword": "无线蓝牙降噪耳机 Pro"
}
}
}bool 查询是组合核心
flowchart TD
A["用户搜索请求"] --> B["must: 关键词召回"]
A --> C["filter: 品牌、价格、库存"]
A --> D["should: 销量、标签、运营权重"]
A --> E["must_not: 排除不可见内容"]
B --> F["候选文档"]
C --> F
D --> G["提高相关性分数"]
E --> F
F --> H["排序和返回"]
G --> H| 子句 | 是否必须匹配 | 是否影响评分 | 适合场景 |
|---|---|---|---|
must | 是 | 是 | 关键词全文搜索 |
filter | 是 | 否 | 状态、品牌、类目、价格、时间范围 |
should | 默认可选,可配合 minimum_should_match | 是 | 提升相关性、召回扩展 |
must_not | 不允许匹配 | 否 | 排除下架、删除、黑名单数据 |
商品搜索典型组合查询
用户搜索“无线降噪耳机”,同时只看有货、品牌为 101 或 102、价格 100 到 300 元。
GET /product_search/_search
{
"from": 0,
"size": 20,
"query": {
"bool": {
"must": [
{
"multi_match": {
"query": "无线降噪耳机",
"fields": ["productName^5", "subTitle^2", "tags"]
}
}
],
"filter": [
{ "term": { "stockStatus": "IN_STOCK" } },
{ "terms": { "brandId": [101, 102] } },
{ "range": { "price": { "gte": 10000, "lte": 30000 } } }
],
"should": [
{ "term": { "tags": { "value": "官方旗舰", "boost": 2 } } },
{ "range": { "saleCount": { "gte": 1000, "boost": 1.5 } } }
],
"must_not": [
{ "term": { "tags": "隐藏商品" } }
]
}
},
"sort": [
{ "_score": "desc" },
{ "saleCount": "desc" },
{ "id": "desc" }
],
"highlight": {
"fields": {
"productName": {},
"subTitle": {}
}
}
}为什么品牌、价格、库存放 filter:
- 它们是硬条件,不需要参与相关性评分。
filter语义清晰,排查时容易判断哪些条件影响召回。- 相同过滤条件更容易被缓存。
为什么销量和旗舰标签放 should:
- 它们不是必须条件。
- 命中后应该排得更靠前。
- 可以体现业务排序策略。
常见查询类型
| 查询 | 用途 | 商业示例 |
|---|---|---|
match | 全文搜索 | 搜商品名、工单描述、日志 message |
multi_match | 多字段全文搜索 | 商品名、卖点、标签同时搜 |
term | 精确匹配 | 状态、品牌、订单号 |
terms | 多值精确匹配 | 多个品牌、多个类目 |
range | 范围查询 | 价格、时间、耗时 |
prefix | 前缀匹配 | 小规模输入提示 |
wildcard | 通配符 | 小规模编码模糊匹配 |
match_phrase | 短语匹配 | 要求词语顺序接近 |
exists | 字段存在 | 过滤有图片、有地理位置的数据 |
geo_distance | 地理距离 | 查询 5km 内门店 |
谨慎使用 wildcard,尤其是 *xxx 这种前缀通配,会导致查询成本很高。商业搜索里通常用分词、ngram、search_as_you_type 或搜索建议解决输入提示问题。
分页、排序和深分页
普通分页:
GET /product_search/_search
{
"from": 0,
"size": 20,
"query": {
"match": {
"productName": "蓝牙耳机"
}
}
}from + size 很大时会产生深分页问题。比如 from=10000&size=20,每个分片都可能要取很多候选结果,协调节点再合并丢弃前面的数据,成本很高。
深分页推荐使用 search_after:
GET /product_search/_search
{
"size": 20,
"query": {
"term": {
"stockStatus": "IN_STOCK"
}
},
"sort": [
{ "saleCount": "desc" },
{ "id": "desc" }
],
"search_after": [5821, 1001]
}注意:使用 search_after 必须提供稳定排序字段,通常会加一个唯一字段作为兜底排序,例如 id。
聚合能做什么
聚合可以理解成“在搜索结果集上做统计”。商品列表页常见的品牌筛选、类目筛选、价格区间,就是聚合。
GET /product_search/_search
{
"size": 0,
"query": {
"bool": {
"must": [
{ "match": { "productName": "耳机" } }
],
"filter": [
{ "term": { "stockStatus": "IN_STOCK" } }
]
}
},
"aggs": {
"brand_count": {
"terms": {
"field": "brandName",
"size": 20
}
},
"price_ranges": {
"range": {
"field": "price",
"ranges": [
{ "to": 10000 },
{ "from": 10000, "to": 30000 },
{ "from": 30000 }
]
}
}
}
}size: 0 表示不返回具体文档,只返回聚合结果。聚合字段通常应该是 keyword、数值或日期类型,不建议直接对 text 字段聚合。
地理位置查询
附近门店、附近仓库、同城配送可以用 geo_point。
GET /product_search/_search
{
"query": {
"bool": {
"must": [
{ "match": { "productName": "咖啡" } }
],
"filter": [
{
"geo_distance": {
"distance": "3km",
"location": {
"lat": 31.2304,
"lon": 121.4737
}
}
}
]
}
},
"sort": [
{
"_geo_distance": {
"location": {
"lat": 31.2304,
"lon": 121.4737
},
"order": "asc",
"unit": "km"
}
}
]
}经纬度适合距离计算,城市名适合行政区域过滤,两者不是一回事。
Explain 和 Profile
搜索结果不符合预期时,可以用 _explain 看某个文档为什么得分高或低:
GET /product_search/_explain/1001
{
"query": {
"match": {
"productName": "无线降噪耳机"
}
}
}查询慢时可以用 profile 看每个查询部分耗时:
GET /product_search/_search
{
"profile": true,
"query": {
"match": {
"productName": "蓝牙耳机"
}
}
}profile 会增加查询开销,适合排查和测试环境,不建议长期在生产接口中开启。
修改 Mapping 后怎么办
ES 已有字段类型通常不能直接改。例如 productName 原来是 keyword,后来想改成 text,一般要新建索引并重建数据。
推荐流程:
flowchart TD
A["发现 Mapping 需要调整"] --> B["创建新索引 product_search_v2"]
B --> C["从 MySQL 或旧索引重建数据"]
C --> D["验证查询结果和聚合结果"]
D --> E["切换别名 product_search"]
E --> F["保留旧索引一段时间"]
F --> G["确认无问题后删除旧索引"]别名切换 Demo:
POST /_aliases
{
"actions": [
{ "remove": { "index": "product_search_v1", "alias": "product_search" } },
{ "add": { "index": "product_search_v2", "alias": "product_search" } }
]
}业务代码始终访问 product_search 这个别名,而不是直接写死 product_search_v1,这样重建索引时可以做到平滑切换。
代码 Demo:后台订单查询
订单查询通常精确条件更多,全文搜索只是辅助。
GET /order_search/_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 就看到所有租户、所有店铺、所有用户的订单。
排查清单
| 现象 | 优先检查 |
|---|---|
| 明明有数据却搜不到 | 数据是否同步、字段类型、分词结果、match/term 是否用错 |
| 搜出来无关内容太多 | analyzer、同义词、minimum_should_match、字段权重 |
| 排序不符合预期 | _score、boost、sort 字段、是否用了 filter |
| 聚合报错 | 是否对 text 字段聚合,是否应该用 .keyword |
| 查询很慢 | DSL、深分页、wildcard、聚合范围、分片数量、慢日志 |
| 修改 Mapping 不生效 | 已有字段类型不能直接改,需要重建索引 |
| 商品下架还搜得到 | 同步链路、删除策略、状态过滤 |
本章小结
Mapping 决定“数据怎么进索引”,Query DSL 决定“数据怎么被搜出来”。
商业搜索最重要的是先学会:
- 哪些字段要分词。
- 哪些字段要精确匹配。
- 哪些条件应该参与评分,哪些只是过滤。
- 聚合、排序、分页会对字段类型提出什么要求。
- Mapping 改错后为什么通常要重建索引。
