🎯 引言:Mapping 设计,一错定终身
如果让我说 ES 项目里最容易"埋雷"的环节,Mapping 设计毫无疑问排第一。
原因很简单:ES 的主分片数和大部分字段类型,一旦索引创建后就无法直接修改 。你可以新增字段,但不能改变已有字段的类型。如果商品名称字段一开始被映射成了 keyword,后来发现需要全文检索,唯一的出路是 Reindex------把整个索引的数据重新写入一个新索引,代价极高。
我见过一个真实案例:某电商团队上线初期为了省事开启了 Dynamic Mapping(ES 自动推断字段类型),几个月后索引里多出来几百个莫名其妙的字段(用户上传了结构不规范的数据),查询性能急剧下降,最后不得不在业务低峰期做了一次痛苦的全量 Reindex,停了两个小时的搜索服务。
本篇会把 Mapping 设计的核心决策点讲透,让你在第一次建索引时就做对。
一、Dynamic Mapping:方便背后的危险
1.1 Dynamic Mapping 是什么
ES 默认开启 Dynamic Mapping,意思是:当你写入一个文档,如果字段在 Mapping 里没有定义,ES 会自动根据字段值推断类型并添加到 Mapping 中。
json
// 第一次写入这个文档
{
"name": "iPhone 15",
"price": 9999,
"is_active": true
}
ES 会自动推断出:
name→text+keyword(字符串默认双类型)price→long(整数默认 long)is_active→boolean
乍看很方便,实际上埋了几个坑:
坑一:数字字符串被推断为 text
json
// 如果第一条数据是
{ "order_id": "10086" } // 字符串,被推断为 text+keyword
// 后来的数据想存纯数字
{ "order_id": 10086 } // 报错!类型冲突,无法写入
ES 的类型推断按第一条写入的数据为准,后续数据必须与之兼容。如果业务代码在某个分支路径下传了不同类型的值,数据会丢失甚至导致整个索引不可写入。
坑二:字段数量爆炸
在 SaaS 场景里,不同租户的数据结构可能差异很大,或者某些字段是 JSON 嵌套的动态 key。开启 Dynamic Mapping 后,这些字段会全部被索引,几个月后你会发现一个索引有上千个字段,其中 90% 是你根本不需要搜索的垃圾字段。ES 存储了这些字段的倒排索引,不仅占用磁盘,还拖慢了所有查询(因为评分计算要遍历更多字段)。
坑三:推断精度问题
json
{ "created_at": "2024-01-15" } // 被推断为 date,格式 date
{ "created_at": "2024-01-15T08:00:00" } // 同一字段,格式不同,可能冲突
1.2 生产环境的推荐配置
java
// 通过 Java 代码创建索引时,设置 dynamic: strict
// 严格模式下,写入未定义的字段会直接报错,而不是静默忽略或自动创建
void createProductIndex(ElasticsearchClient client) throws IOException {
client.indices().create(c -> c
.index("products")
.mappings(m -> m
// strict:严格拒绝未定义字段,推荐生产使用
// false:接受未定义字段但不索引(不可搜索,但存在 _source 里)
// true(默认):自动推断并索引
.dynamic(DynamicMapping.Strict)
.properties(/* 显式定义所有字段,见下文 */)
)
.settings(s -> s
.numberOfShards("3")
.numberOfReplicas("1")
)
);
}
💡 有时候需要保留灵活性怎么办? 可以只对某个嵌套对象关闭 dynamic,而整体保持 strict:
json"extra_attributes": { "type": "object", "dynamic": false, // 这个子对象接受任意字段,但不索引 "enabled": false // 更激进:整个对象直接不索引,只存在 _source }
二、字段类型选择指南
这是 Mapping 设计最核心的部分。下面按实际业务场景逐一讲解。
2.1 text vs keyword------最高频的坑
这是初学者必定踩的第一个坑,也是 ES 字段设计中最重要的决策。
text :会经过分词器拆分成一个个 token 存入倒排索引,支持全文检索,但不支持精确匹配、排序、聚合。
bash
"Apple iPhone 15 Pro Max"
↓ 分词(standard analyzer)
["apple", "iphone", "15", "pro", "max"]
↓ 分别建立倒排索引
keyword :不分词 ,整体作为一个 token 存储,支持精确匹配、排序、聚合,但不支持全文检索。
bash
"Apple iPhone 15 Pro Max"
↓ 不分词
["Apple iPhone 15 Pro Max"]
↓ 整体建立倒排索引
来看一个典型的错误场景:
json
// 错误:把商品名称设成纯 keyword
"name": { "type": "keyword" }
// 结果:用户搜索 "iPhone" 找不到 "Apple iPhone 15 Pro Max"
// 因为 keyword 类型必须完整匹配 "Apple iPhone 15 Pro Max" 才能命中
json
// 错误:把品牌设成纯 text
"brand": { "type": "text" }
// 结果:无法按品牌做聚合统计(terms aggregation 报错)
// 因为 text 字段的值已经被分词,无法整体聚合
正确做法:字符串字段同时配置两种类型(multi-fields)
json
{
"name": {
"type": "text",
"analyzer": "ik_max_word",
"search_analyzer": "ik_smart",
"fields": {
"keyword": {
"type": "keyword",
"ignore_above": 256
}
}
}
}
这样,name 支持全文检索(用 name 字段),name.keyword 支持精确匹配和排序(用 name.keyword 字段)。ignore_above: 256 表示超过 256 字符的字符串不建立 keyword 索引,防止超长文本占用大量内存。
java
// Java 代码中使用 multi-fields
// 全文检索用 name
.query(q -> q.match(m -> m.field("name").query("iPhone")))
// 精确匹配用 name.keyword
.query(q -> q.term(t -> t.field("name.keyword").value("Apple iPhone 15 Pro Max")))
// 按名称排序用 name.keyword(不能对 text 字段排序)
.sort(s -> s.field(f -> f.field("name.keyword").order(SortOrder.Asc)))
2.2 数值类型的选择
bash
integer → Java int,范围 ±2.1亿,适合库存、数量
long → Java long,范围 ±9.2 × 10^18,适合时间戳(毫秒)、大ID
float → 单精度浮点,精度低,非金融场景可用
double → 双精度浮点,适合价格(但金融场景推荐用 scaled_float)
scaled_float → 缩放浮点,如 scaling_factor=100,存 9999.00 时内部存 999900(整数),精度高且省空间,财务金额强烈推荐
json
// 价格字段的推荐配置
"price": {
"type": "scaled_float",
"scaling_factor": 100 // 精确到分
}
⚠️ 踩坑 :价格千万不要用
float!float的精度是 7 位有效数字,价格1234567.89存进去可能变成1234567.8,这种问题在日志里根本看不出来,但账单对不上的时候会让你抓狂。
2.3 日期类型
json
// 日期字段的标准配置
"created_at": {
"type": "date",
"format": "yyyy-MM-dd HH:mm:ss||yyyy-MM-dd||epoch_millis"
// 多格式用 || 分隔,ES 会按顺序尝试解析
}
ES 内部统一把日期存为 UTC 时间戳(long),显示时按格式转回字符串。
⚠️ 踩坑 :时区问题是日期字段最常见的坑。ES 默认 UTC,但业务代码往往用北京时间(UTC+8)。如果在 Java 代码里直接把
LocalDateTime(没有时区信息)序列化后写入 ES,查询时按 UTC 解析会差 8 小时。推荐统一使用ZonedDateTime或在序列化时显式附加时区信息。
2.4 布尔、IP 等其他类型
json
{
"is_active": { "type": "boolean" },
"client_ip": { "type": "ip" }, // 支持 CIDR 范围查询,如 "192.168.0.0/24"
"location": { "type": "geo_point" }, // 地理坐标,支持距离查询和地图聚合
"product_vector": { // AI 向量(第08篇详细讲)
"type": "dense_vector",
"dims": 1536
}
}
三、object vs nested------嵌套关系的核心差异
这是 Mapping 设计里最容易被忽略、却最容易出 Bug 的地方。很多人在数据结构设计时随手用 object,然后在查询时发现结果完全不对,却不知道为什么。
3.1 object 类型的"展平"问题
ES 的 object 类型实际上会把嵌套的 JSON 对象展平成独立字段。来看一个例子:
json
// 一个订单文档,包含多个订单行
{
"order_id": "ORDER-001",
"items": [
{ "product_id": "P001", "quantity": 2 },
{ "product_id": "P002", "quantity": 1 }
]
}
如果 items 用 object 类型,ES 内部存储是:
bash
items.product_id → ["P001", "P002"] // 两个值合并成一个数组
items.quantity → [2, 1] // 两个值合并成一个数组
字段之间的关联关系被打散了。现在来查询"数量为 2 的 P002 商品":
json
{
"query": {
"bool": {
"must": [
{ "term": { "items.product_id": "P002" } },
{ "term": { "items.quantity": 2 } }
]
}
}
}
这个查询会命中 ORDER-001! 因为 ES 展平后,P002 确实在 items.product_id 里,2 确实在 items.quantity 里,但它们来自不同的数组元素,而 ES 不知道这一点,照样认为条件满足。
这就是 object 类型的陷阱:数组中对象之间的字段关联会丢失。
3.2 nested 类型的独立存储
nested 类型把每个子对象作为独立的隐藏文档存储,保留了字段间的关联关系:
json
// 正确的配置:items 用 nested
{
"mappings": {
"properties": {
"order_id": { "type": "keyword" },
"items": {
"type": "nested",
"properties": {
"product_id": { "type": "keyword" },
"quantity": { "type": "integer" }
}
}
}
}
}
查询时必须使用 nested query:
java
// Java 代码:查询包含 P002 且数量为 2 的订单
SearchResponse<Order> response = esClient.search(s -> s
.index("orders")
.query(q -> q
.nested(n -> n
.path("items") // 指定 nested 字段的路径
.query(nq -> nq
.bool(b -> b
.must(m -> m.term(t -> t.field("items.product_id").value("P002")))
.must(m -> m.term(t -> t.field("items.quantity").value(2)))
)
)
)
),
Order.class
);
现在这个查询不会命中 ORDER-001 了,因为 nested 模式下 P002 对应的 quantity 是 1,而不是 2。
⚠️ nested 的性能代价:
nested类型把每个子对象存为独立文档,如果一个订单有 100 个订单行,ES 内部就有 101 个文档(1 个主文档 + 100 个 nested 文档)。写入和查询的开销都会增加。对于数组元素很多(>100)且频繁更新的场景,需要评估是否值得用 nested。实际决策原则:
- 如果子对象数组元素之间需要精确关联查询 (如"某个商品的数量"),必须用
nested- 如果只是展示用,或者只需要单独查询每个字段,用
object即可,性能更好
四、SaaS 场景的完整 Mapping 设计实战
把前面的理论综合起来,设计一个 SaaS 电商平台的商品索引:
java
// ProductIndexInitializer.java
@Component
@RequiredArgsConstructor
@Slf4j
public class ProductIndexInitializer {
private final ElasticsearchClient esClient;
private static final String INDEX_NAME = "products";
@PostConstruct
public void initIndex() throws IOException {
// 检查索引是否已存在
boolean exists = esClient.indices()
.exists(e -> e.index(INDEX_NAME))
.value();
if (!exists) {
createIndex();
log.info("索引 [{}] 创建成功", INDEX_NAME);
} else {
log.info("索引 [{}] 已存在,跳过创建", INDEX_NAME);
}
}
private void createIndex() throws IOException {
esClient.indices().create(c -> c
.index(INDEX_NAME)
.settings(s -> s
// 主分片数:根据预估数据量设置,这里按中等规模 SaaS 设置
// 单分片目标数据量 10-30GB,预估总量 100GB → 3-5个主分片
.numberOfShards("3")
.numberOfReplicas("1")
// 刷新间隔:影响写入性能和搜索实时性
// 默认 1s,写入压力大时可适当增加
.refreshInterval(ri -> ri.time("1s"))
// 自定义分析器(中文分词,详见第05篇)
.analysis(a -> a
.analyzer("ik_with_synonym", an -> an
.custom(cu -> cu
.tokenizer("ik_max_word")
.filter("lowercase", "synonym_filter")
)
)
)
)
.mappings(m -> m
// 严格模式:不允许写入 Mapping 未定义的字段
.dynamic(DynamicMapping.Strict)
.properties(p -> p
// ── SaaS 必须字段:租户 ID ──────────────────────────
// 所有查询都必须带这个过滤条件,设为 keyword 用于精确匹配
.add("tenant_id", f -> f.keyword(k -> k))
// ── 基础信息 ─────────────────────────────────────────
.add("name", f -> f
.text(t -> t
.analyzer("ik_max_word") // 写入时最细粒度分词
.searchAnalyzer("ik_smart") // 搜索时智能分词(防止过度拆分)
.fields(sf -> sf
// name.keyword 用于排序和精确匹配
.put("keyword", ff -> ff.keyword(k -> k.ignoreAbove(256)))
// name.suggest 用于搜索建议(第05篇讲)
.put("suggest", ff -> ff.completion(comp -> comp))
)
)
)
.add("brand", f -> f.keyword(k -> k))
.add("category", f -> f.keyword(k -> k))
// ── 价格:使用 scaled_float 保证精度 ────────────────
.add("price", f -> f
.scaledFloat(sf -> sf.scalingFactor(100.0))
)
.add("original_price", f -> f
.scaledFloat(sf -> sf.scalingFactor(100.0))
)
// ── 长文本:商品描述,只需全文检索,不需要排序聚合 ──
.add("description", f -> f
.text(t -> t.analyzer("ik_max_word").searchAnalyzer("ik_smart"))
)
// ── 标签:字符串数组,需精确匹配和聚合 ──────────────
// ES 天然支持数组,只需声明元素类型,不需要特殊处理
.add("tags", f -> f.keyword(k -> k))
// ── 数值字段 ──────────────────────────────────────────
.add("stock", f -> f.integer(i -> i))
.add("sales_count", f -> f.long_(l -> l))
.add("rating", f -> f
.scaledFloat(sf -> sf.scalingFactor(10.0)) // 评分精确到0.1
)
// ── 布尔字段 ──────────────────────────────────────────
.add("is_active", f -> f.boolean_(b -> b))
.add("is_deleted", f -> f.boolean_(b -> b))
// ── 日期字段 ──────────────────────────────────────────
.add("created_at", f -> f
.date(d -> d.format("yyyy-MM-dd HH:mm:ss||epoch_millis"))
)
.add("updated_at", f -> f
.date(d -> d.format("yyyy-MM-dd HH:mm:ss||epoch_millis"))
)
// ── 嵌套对象:商品规格(必须用 nested,否则规格关联会丢失)──
// 例:颜色=红色 对应 库存=10,颜色=蓝色 对应 库存=5
// 如果用 object,"红色" 和 "5" 会被关联起来,查询错误
.add("specs", f -> f
.nested(n -> n
.properties(np -> np
.add("spec_name", ff -> ff.keyword(k -> k)) // 如"颜色"
.add("spec_value", ff -> ff.keyword(k -> k)) // 如"红色"
.add("spec_stock", ff -> ff.integer(i -> i)) // 该规格库存
.add("sku_id", ff -> ff.keyword(k -> k))
)
)
)
// ── 普通对象:商品主图(各元素间没有需要关联的查询)──
// object 类型展平后,查 "image_url 包含 xxx" 是安全的
.add("main_image", f -> f
.object(o -> o
.properties(op -> op
.add("url", ff -> ff.keyword(k -> k))
.add("width", ff -> ff.integer(i -> i))
.add("height", ff -> ff.integer(i -> i))
)
)
)
// ── 不需要索引的扩展字段 ─────────────────────────────
// dynamic: false 允许存入任意字段,但不建索引(节省资源)
// 这些字段存在 _source 里,可以读出来,但不能搜索
.add("extra_data", f -> f
.object(o -> o.dynamic(DynamicMapping.False))
)
)
)
);
}
}
五、Mapping 变更的正确姿势:Reindex + 别名切换
上线后发现 Mapping 设计有问题,该怎么办?这是工程实践中必须掌握的技能。
5.1 为什么不能直接改 Mapping
ES 允许新增 字段到已有 Mapping,但不允许修改已有字段的类型。原因是字段类型决定了倒排索引的数据结构,改变类型意味着所有历史数据需要重新建立索引,这个过程无法就地完成。
5.2 Reindex 方案(停机版)
如果允许短暂的搜索服务中断,Reindex 很简单:
java
// 1. 创建新索引(带有正确的 Mapping)
createNewIndex("products_v2");
// 2. 执行 Reindex(把 products_v1 的数据全量复制到 products_v2)
esClient.reindex(r -> r
.source(s -> s.index("products_v1"))
.dest(d -> d.index("products_v2"))
// 可选:在 Reindex 过程中做数据转换(需要 Painless 脚本)
.script(sc -> sc
.inline(i -> i
.source("ctx._source.price = ctx._source.price * 100")
.lang("painless")
)
)
);
// 3. 验证数据量一致
// 4. 切换应用配置,指向新索引
5.3 零停机迁移:别名切换技巧(生产推荐)
这是生产环境的标准做法。原理是在索引名称和应用之间加一层别名(Alias),应用始终通过别名访问,切换时只需切换别名指向,对应用完全透明:
bash
应用 → 别名 "products" → 实际索引 "products_v1"
↓ 迁移完成
应用 → 别名 "products" → 实际索引 "products_v2"
完整的零停机迁移流程:
java
// ZeroDowntimeReindexService.java
@Service
@RequiredArgsConstructor
@Slf4j
public class ZeroDowntimeReindexService {
private final ElasticsearchClient esClient;
public void reindexWithZeroDowntime(String oldIndex, String newIndex,
String alias) throws IOException {
// ── 阶段1:创建新索引 ──────────────────────────────────────────
log.info("阶段1:创建新索引 [{}]", newIndex);
// ... 创建新索引的代码(带正确的 Mapping)
// ── 阶段2:全量 Reindex ────────────────────────────────────────
// 注意:此时老索引还在继续接收写入(通过别名),
// 所以全量完成后,Reindex 期间新写入的数据还没有迁移到新索引
log.info("阶段2:开始全量 Reindex");
ReindexResponse reindexResponse = esClient.reindex(r -> r
.source(s -> s.index(oldIndex))
.dest(d -> d
.index(newIndex)
// op_type: create 确保不会覆盖新索引中已有的文档(幂等)
.opType(OpType.Create)
)
// 使用快照时间点,保证数据一致性
.waitForCompletion(true)
);
log.info("全量 Reindex 完成,迁移文档数: {}", reindexResponse.total());
// ── 阶段3:再次 Reindex(增量追赶)─────────────────────────────
// 全量期间新写入的文档,通过 updated_at 时间戳做增量同步
// 这个窗口很短(分钟级),通常 1-2 次就能追平
log.info("阶段3:增量追赶");
Instant reindexStartTime = Instant.now().minus(Duration.ofMinutes(30));
esClient.reindex(r -> r
.source(s -> s
.index(oldIndex)
.query(q -> q
.range(rg -> rg
.field("updated_at")
.gte(JsonData.of(reindexStartTime.toEpochMilli()))
)
)
)
.dest(d -> d.index(newIndex).opType(OpType.Index))
.waitForCompletion(true)
);
// ── 阶段4:原子切换别名 ────────────────────────────────────────
// 这是关键步骤:remove + add 是原子操作,切换瞬间完成,无停机
log.info("阶段4:切换别名 [{}] → [{}]", alias, newIndex);
esClient.indices().updateAliases(ua -> ua
.actions(
// 同时执行:移除旧别名 + 添加新别名
// ES 保证这两个操作是原子的
Action.of(a -> a.remove(r -> r.index(oldIndex).alias(alias))),
Action.of(a -> a.add(ad -> ad.index(newIndex).alias(alias)))
)
);
log.info("零停机迁移完成!新索引 [{}] 已接管别名 [{}]", newIndex, alias);
// ── 阶段5:延迟删除旧索引 ─────────────────────────────────────
// 不要立刻删除,等业务验证无误后再删
// esClient.indices().delete(d -> d.index(oldIndex));
}
}
⚠️ Reindex 期间的写入双流问题: 如果你的系统写入量很大(如每秒数千条),Reindex 期间老索引持续有新数据进来,增量追赶阶段可能会反复追不上。这时需要更复杂的方案:比如在数据同步层(Canal/Flink)同时双写新老两个索引,等新索引数据量与老索引齐平后再切换别名。这个方案会在第09篇数据同步章节详细讲。
六、Mapping 设计速查表
| 业务字段 | 推荐类型 | 原因 |
|---|---|---|
| 商品名称(全文检索+排序) | text + keyword(multi-fields) |
两种需求都要满足 |
| 商品ID、SKU、租户ID | keyword |
精确匹配,不需要分词 |
| 价格、金额 | scaled_float (factor=100) |
精确小数,避免浮点误差 |
| 库存、数量 | integer 或 long |
按数据范围选 |
| 评分 | scaled_float (factor=10) |
精确到0.1 |
| 创建时间、更新时间 | date + 明确format |
统一用UTC |
| 标签、分类列表 | keyword(数组) |
精确匹配+聚合 |
| 商品描述 | text |
只需全文检索 |
| 规格(颜色-库存的关联) | nested |
保留字段关联 |
| 非搜索字段(扩展数据) | object + dynamic: false |
存储但不索引 |
| AI 向量 | dense_vector |
语义检索专用 |
| IP 地址 | ip |
支持CIDR查询 |
| 地理坐标 | geo_point |
地图相关查询 |
❓ 高频面试 & AI 问答
Q: ES Mapping 能修改已有字段的类型吗?
A: 不能直接修改。只能新增字段。需要修改已有字段类型时,必须创建新索引(带新 Mapping),通过 Reindex API 迁移数据,再用别名切换实现零停机。
Q: text 和 keyword 的本质区别是什么?
A: text 会经过分词器处理,适合全文检索,但不能精确匹配、排序、聚合;keyword 不分词,适合精确匹配、排序、聚合,但不支持全文检索。字符串字段推荐配置 multi-fields 同时拥有两种类型。
Q: nested 和 object 有什么区别?什么时候必须用 nested?
A: object 会将数组中的子对象字段展平合并,导致字段间关联丢失;nested 将每个子对象独立存储,保留关联关系。当数组中的子对象需要关联查询时(如"数量=2 的 P002 商品"),必须用 nested。
Q: Dynamic Mapping 生产环境应该开启吗?
A: 不推荐。生产建议设置 dynamic: strict,强制要求显式定义所有字段,避免字段类型推断错误和字段数量爆炸问题。
🔗 上一篇
第02篇 :《搭建 ES 集群 + Spring Boot 整合实战------从 Docker Compose 到 Java 客户端全覆盖》
🔗 下一篇
第04篇 :《Query DSL 全景与高级检索实战------从入门查询到复杂业务场景》
Mapping 设好了,接下来就是如何高效地查。bool 查询的 must/should/filter/must_not 各有什么语义?怎么做品牌、价格的多维聚合?如何用 function_score 把销量、评分融入排名权重?下篇全部拿实际业务场景来讲。