Elasticsearch 实战:从零构建电商商品搜索的 CRUD 全流程
你有没有遇到过这样的场景?
用户在电商平台搜索“蓝牙耳机”,结果半天出不来;或者刚下单成功,刷新页面却发现库存没变。背后很可能是数据同步出了问题——写入 MySQL 的订单信息还没来得及更新到搜索引擎里。
作为现代应用的核心组件之一,Elasticsearch(简称 ES)正是为解决这类高并发、低延迟查询而生。它不是传统数据库,却能让你的数据“秒级可见”;它不支持事务,但通过巧妙的设计也能实现接近实时的一致性。
今天,我们就以一个真实的电商商品管理系统为例,手把手带你走完 Elasticsearch 中最基础也最关键的环节:CRUD 操作全流程—— 即创建(Create)、读取(Read)、更新(Update)和删除(Delete)。不只是教你命令怎么写,更要讲清楚每一步背后的逻辑、陷阱与最佳实践。
一、先搞懂它的数据模型:为什么说 ES 是“面向文档”的?
很多初学者一开始就把 ES 当成 MySQL 来用,结果踩坑无数。关键在于没理解它的数据组织方式。
它没有“表”,只有“索引”
在关系型数据库中,我们习惯说“把数据插入 users 表”。但在 Elasticsearch 中,对应的结构叫Index(索引),比如你可以建一个products索引来存所有商品。
每个 Index 可以包含多个Document(文档),而每个文档就是一个 JSON 对象:
{ "title": "无线蓝牙耳机", "price": 299.0, "stock": 50, "tags": ["蓝牙", "降噪"] }这就像数据库里的一行记录,只不过它是自描述的、灵活的 JSON 格式。
⚠️ 注意:从 7.x 版本开始,Type 已被废弃,默认统一使用
_doc。别再纠结“该不该建 type”了,现在就是一条路:/index/_doc/id。
写进去之后,多久能搜到?
ES 的一大卖点是“近实时”(NRT, Near Real-Time)。什么意思?
当你 PUT 一条数据后,通常1 秒内就能被搜索到,而不是像某些系统那样要等几分钟。
但这不是魔法。底层其实是 Lucene 的段机制在起作用:新文档先写入内存缓冲区,然后定期刷新成不可变的段文件。只有刷过盘的段才会参与搜索。
所以如果你做调试时发现查不到刚写的数据,可以加个?refresh=true参数强制刷新——不过生产环境慎用,会影响性能。
二、Create:如何正确添加一条商品记录?
新增文档是最常见的操作之一。但在实际项目中,很多人一开始就选错了 API。
两种方式:PUT /_doc/{id}vsPOST /_doc/
| 方法 | 示例 | 适用场景 |
|---|---|---|
PUT显式指定 ID | PUT /products/_doc/1001 | 使用业务主键(如 SKU 编码),便于追踪 |
POST自动生成 ID | POST /products/_doc/ | 日志类数据,追求吞吐量 |
举个例子,管理员后台添加商品时,肯定希望用自己定义的商品编号作为 ID。这时候就应该用PUT:
PUT /products/_doc/1001 { "title": "无线蓝牙耳机", "category": "数码产品", "price": 299.0, "stock": 50, "tags": ["蓝牙", "降噪", "运动"], "created_at": "2025-04-05T10:00:00Z" }响应会返回类似这样:
{ "_index": "products", "_id": "1001", "_version": 1, "result": "created" }如果这个 ID 已经存在,ES 会直接报409 Conflict错误,防止误覆盖。这是幂等性的体现。
而如果是日志采集场景,比如每秒几万条用户行为事件,那就更适合用POST让 ES 自动分配唯一 ID,减少客户端压力。
✅最佳实践建议:
在业务系统中尽量使用业务主键做_id,比如商品 ID、订单号等。这样后续排查问题、做数据比对都方便得多。
三、Read:怎么快速查出你需要的信息?
搜索才是 ES 的主场。但很多人不知道,读操作也可以很精细地控制输出内容。
基础查询:根据 ID 获取文档
最简单的就是按 ID 查:
GET /products/_doc/1001默认会返回完整的_source字段,也就是你当初写进去的那个 JSON。
但很多时候你并不需要全部字段。比如前端列表页只展示标题和价格,没必要把description这种大文本传过去。
这时可以用_source_includes来做过滤:
GET /products/_doc/1001?_source_includes=title,price响应就只会包含这两个字段:
{ "_source": { "title": "无线蓝牙耳机", "price": 299.0 } }节省带宽不说,还能降低 GC 压力,尤其对移动端友好。
高级技巧:启用实时获取
还记得前面说的“近实时”吗?一般有 1 秒延迟。但如果某个请求特别紧急呢?
比如用户刚修改完商品名称,马上刷新页面,却发现还是旧名字。体验很差。
这时候可以用realtime=true跳过搜索队列,直接去查最新的段文件甚至内存中的未刷新数据:
GET /products/_doc/1001?realtime=true虽然性能代价略高,但在关键路径上值得考虑。
⚠️注意:不要滥用
_source返回超大字段。合理拆分数据模型,冷热分离,才能保证集群稳定。
四、Update:如何安全地扣减库存而不丢数据?
更新操作最容易出问题。特别是在高并发场景下,“读-改-写”流程极易导致数据错乱。
经典问题:两个用户同时下单,库存扣成了负数?
设想一下这个流程:
- 用户 A 查询库存 = 2
- 用户 B 查询库存 = 2
- A 下单,扣减 1,写回库存 = 1
- B 下单,也扣减 1,写回库存 = 1
表面看没问题,但如果两人几乎同时下单,B 的请求可能基于旧值计算,最终结果变成库存 = 1,但实际上卖出去了两单!
这就是典型的并发写冲突。
解法一:用脚本在 ES 内部完成原子操作
Elasticsearch 提供了 Painless 脚本语言,可以直接在服务端执行逻辑,避免来回传输:
POST /products/_update/1001 { "script": { "source": "ctx._source.stock -= params.count", "params": { "count": 1 } } }这里的ctx._source指的是当前文档。ES 会在同一分片内串行执行这些脚本,天然保证原子性。
更进一步,还可以加个判断,防止库存不足:
"script": { "source": """ if (ctx._source.stock >= params.count) { ctx._source.stock -= params.count; ctx._source.last_updated = params.now; } else { ctx.op = 'none'; // 不做任何操作 } """, "params": { "count": 1, "now": "2025-04-05T11:30:00Z" } }如果库存不够,就设置ctx.op = 'none',表示跳过本次更新。
解法二:乐观锁 + 自动重试
ES 每个文档都有版本号_version和序列号_seq_no。我们可以利用它们实现乐观锁。
比如你在更新时带上预期版本:
POST /products/_update/1001?if_seq_no=123&if_primary_term=2如果此时文档已经被别人改过,_seq_no就对不上,请求就会失败。这时客户端可以重新读取最新状态,再试一次。
为了省事,也可以直接让 ES 自动重试:
POST /products/_update/1001?retry_on_conflict=3这样最多尝试 4 次(首次 + 重试 3 次),直到成功或彻底失败为止。
✅最佳实践:
对于高频更新字段(如浏览量、点赞数),建议单独建模,并启用_source过滤,避免拖慢整体查询速度。
五、Delete:删数据真的安全吗?
删除看似简单,实则暗藏风险。
删除单个文档
最基础的操作:
DELETE /products/_doc/1001ES 不会立刻物理删除,而是标记为“已删除”,等到段合并时才真正清理。期间该文档不会出现在搜索结果中。
如果你想立即生效,可以加refresh=true,但同样不推荐频繁使用。
批量删除:慎用_delete_by_query
有时候我们需要批量清理数据,比如下架某个类别的所有商品:
POST /products/_delete_by_query { "query": { "term": { "category.keyword": "过季促销" } } }这条命令会扫描整个索引,找到匹配的文档并逐一删除。听起来很方便,但请注意:
- 是重量级操作,消耗大量 CPU 和 I/O
- 可能引发集群抖动,影响线上服务
- 删除过程不可逆
⚠️强烈建议:
- 在低峰期执行
- 先用Search API预览命中数量
- 开启慢日志监控执行情况
软删除设计:有些数据不能真删
在金融、电商等领域,出于审计或合规要求,很多数据必须保留历史痕迹。
这时就可以用“软删除”模式:
POST /products/_update/1001 { "doc": { "status": "deleted", "deleted_at": "2025-04-05T12:00:00Z" } }然后在所有查询中加上过滤条件:
"bool": { "must_not": { "term": { "status": "deleted" } } }这样一来,数据依然存在,但对外不可见。未来还能用于数据分析或恢复。
六、真实系统中的 CRUD 是怎么跑起来的?
光会单个命令还不够。在真实架构中,CRUD 往往是跨系统的联动过程。
典型架构:MySQL + Kafka + Elasticsearch
[前端 App] ↓ [业务服务] → 写入 MySQL ←→ Binlog 同步 → Kafka → Logstash/Custom Consumer → ES ↑ ↑ [事务保障] [异步解耦]在这个体系中:
- MySQL负责强一致性写入(比如扣款、锁库存)
- Kafka作为消息中间件,传递变更事件
- Elasticsearch接收变更,更新索引,提供高速检索
完整流程示例:用户下单后库存如何同步?
- 支付成功,订单服务更新 MySQL 中的商品库存
- Canal 或 Debezium 捕获 binlog,发送消息到 Kafka
- 消费者收到消息,构造如下请求发往 ES:
POST /products/_update/1001 { "script": { "source": "ctx._source.stock = params.new_stock", "params": { "new_stock": 48 } } }- 前端用户再次搜索该商品时,看到的就是最新库存
整个过程是最终一致的,牺牲了一点实时性,换来了系统的可扩展性和稳定性。
七、那些没人告诉你却很关键的最佳实践
1. Mapping 要提前规划,别依赖动态映射
ES 默认会根据第一次插入的数据自动推断字段类型。听着很方便,但很容易出问题。
比如第一次插入的价格是"price": "299"(字符串),后面再插数字就没法用了。
解决方案:提前创建索引模板,明确定义字段类型:
PUT /products { "mappings": { "properties": { "title": { "type": "text" }, "price": { "type": "float" }, "category": { "type": "keyword" }, "created_at": { "type": "date" } } } }尤其是分类、标签这类需要精确匹配的字段,一定要用keyword类型,否则会被分词器拆开,查不准。
2. 大批量操作一定要用_bulk
每次 HTTP 请求都有开销。如果你要导入 1 万条数据,逐条发送会慢到怀疑人生。
正确的做法是使用_bulkAPI 批量提交:
POST /_bulk { "create": { "_index": "products", "_id": "1002" } } { "title": "智能手表", "price": 899, "stock": 30 } { "index": { "_index": "products", "_id": "1003" } } { "title": "平板电脑", "price": 2999, "stock": 15 }create:仅当文档不存在时才创建index:无论是否存在都写入(覆盖)
批量提交能显著提升吞吐量,通常建议每批 1KB~5MB 数据为宜。
3. 分片别乱设,后期很难改
新建索引时就要想好分片数。一旦设定,就不能更改(除非 reindex)。
通用建议:
- 单个分片大小控制在 10GB~50GB
- 分片数 ≈ 节点数 × 1.5 ~ 3 倍
比如你有 3 个数据节点,初期可以设 5 个主分片。太多会导致管理开销大,太少又不利于扩容。
4. 敏感操作必须加权限控制
生产环境绝不能开放任意删除权限!
开启 X-Pack 安全模块,配置角色和用户:
# elasticsearch.yml xpack.security.enabled: true然后创建专用账号,限制其只能执行特定操作:
# 创建只读用户 bin/elasticsearch-users useradd reader -p mypass --role kibana_reader,monitoring_user禁止使用通配符删除(如/*),并对_delete_by_query等危险操作记录审计日志。
写在最后:CRUD 不只是命令,更是工程思维的体现
看到这里,你应该已经掌握了 Elasticsearch 中 CRUD 的完整链条。
但我想强调的是:学会命令只是第一步,理解背后的分布式机制、权衡取舍,才是进阶的关键。
- 你知道什么时候该用脚本,什么时候该用外部协调?
- 你能预判 Mapping 设计不当带来的长期维护成本吗?
- 你是否考虑过数据一致性模型的选择对用户体验的影响?
这些问题没有标准答案,但正是它们构成了工程师之间的差距。
未来的 Elasticsearch 正在向向量搜索、机器学习集成等方向演进,功能越来越强大。但无论技术如何变化,扎实的 CRUD 基础永远是你驾驭复杂系统的起点。
如果你在实践中遇到了其他挑战——比如如何处理嵌套对象更新、怎样优化 deep paging 性能——欢迎在评论区留言讨论。我们一起把这套系统跑得更稳、更快。