1. 项目背景与核心价值
去年接手的一个电商平台搜索模块改造项目让我深刻认识到ElasticSearch在现代分布式系统中的重要性。当时日均百万级查询量的系统,使用传统数据库LIKE查询导致页面加载经常超过5秒。接入ES后,搜索响应时间直接降到200毫秒内,商品转化率提升了37%。这个实战经历促使我系统梳理了ES在电商场景下的完整技术栈,今天要分享的正是基于SpringCloud+ElasticSearch的《黑马商城》实战教程。
这个教程最核心的价值在于:它不是简单的ES语法演示,而是完整呈现了从本地开发环境搭建到云服务器集群部署的全链路实践。你将掌握包括:
- 商品SPU/SKU的ES文档结构设计
- 中文分词与同义词扩展实战
- 聚合查询实现电商筛选功能
- 滚动查询处理百万级数据导出
- 基于Nginx的ES集群负载均衡方案
2. 技术架构解析
2.1 整体架构设计
项目采用经典的微服务架构,这里重点分析搜索服务模块的设计:
code复制[客户端] -> [Nginx] -> [SpringCloud Gateway]
-> [搜索服务(ES)] <- [商品服务(MySQL)]
-> [用户服务] <- [Redis缓存]
搜索服务通过Logstash定时同步MySQL商品数据到ES,同时提供RESTful API供前端调用。这种设计实现了:
- 读写分离:写操作走MySQL保证事务,读操作走ES保证性能
- 故障隔离:搜索服务崩溃不会影响订单等核心流程
- 弹性扩展:ES节点可独立横向扩展
2.2 版本选型考量
在技术栈选择上我们经过多轮压测对比:
- ES版本:放弃最新的8.x选择7.17.10,因为:
- 7.x有更稳定的中文分词插件支持
- 兼容公司现有监控体系
- 官方长期支持到2024年
- Java客户端:选用High Level REST Client而非Java API Client,因为:
- 更完善的文档和社区支持
- 与SpringData Elasticsearch整合更好
- 支持连接多集群配置
3. 核心功能实现
3.1 商品索引建模
商品索引的mapping设计直接影响搜索效果,这是我们最终采用的方案:
json复制{
"properties": {
"spuId": {"type": "keyword"},
"skuTitle": {
"type": "text",
"analyzer": "ik_smart",
"fields": {
"pinyin": {"type": "text", "analyzer": "pinyin"}
}
},
"attrs": {
"type": "nested",
"properties": {
"attrId": {"type": "keyword"},
"attrName": {"type": "keyword"},
"attrValue": {"type": "keyword"}
}
},
"price": {"type": "scaled_float", "scaling_factor": 100},
"sales": {"type": "integer"}
}
}
关键设计点:
- 使用ik_smart+拼音双分词器,支持"手机"和"shouji"两种搜索方式
- nested类型处理规格参数,避免扁平化后的数据冲突
- scaled_float存储价格,避免浮点数精度问题
3.2 搜索功能实现
3.2.1 基础搜索
java复制public SearchResult search(SearchParam param) {
NativeSearchQueryBuilder builder = new NativeSearchQueryBuilder();
// 关键词查询
if (StringUtils.isNotBlank(param.getKeyword())) {
builder.withQuery(QueryBuilders.multiMatchQuery(param.getKeyword(),
"skuTitle^3", "skuTitle.pinyin^2"));
}
// 品牌过滤
if (param.getBrandId() != null) {
builder.withFilter(QueryBuilders.termQuery("brandId", param.getBrandId()));
}
// 价格区间
if (param.getMinPrice() != null || param.getMaxPrice() != null) {
RangeQueryBuilder rangeQuery = QueryBuilders.rangeQuery("price");
if (param.getMinPrice() != null) rangeQuery.gte(param.getMinPrice());
if (param.getMaxPrice() != null) rangeQuery.lte(param.getMaxPrice());
builder.withFilter(rangeQuery);
}
// 分页处理
builder.withPageable(PageRequest.of(param.getPageNum()-1, param.getPageSize()));
// 高亮显示
builder.withHighlightFields(new HighlightBuilder.Field("skuTitle")
.preTags("<em style='color:red'>").postTags("</em>"));
return buildSearchResult(elasticsearchRestTemplate.search(builder.build(), SkuEsModel.class));
}
3.2.2 聚合筛选
java复制// 添加聚合查询
builder.addAggregation(AggregationBuilders.terms("brandAgg").field("brandId")
.subAggregation(AggregationBuilders.terms("brandNameAgg").field("brandName"))
.subAggregation(AggregationBuilders.terms("brandImgAgg").field("brandImg")));
// 解析聚合结果
ParsedLongTerms brandAgg = response.getAggregations().get("brandAgg");
List<BrandVo> brands = brandAgg.getBuckets().stream().map(bucket -> {
BrandVo brand = new BrandVo();
brand.setBrandId(bucket.getKeyAsNumber().longValue());
// 从子聚合获取名称和图片
ParsedStringTerms nameAgg = bucket.getAggregations().get("brandNameAgg");
brand.setBrandName(nameAgg.getBuckets().get(0).getKeyAsString());
return brand;
}).collect(Collectors.toList());
4. 性能优化实践
4.1 索引层面优化
-
分片策略:
- 设置3个主分片+1个副本(适合千万级数据量)
- 分片大小控制在30-50GB之间
bash复制PUT /goods { "settings": { "number_of_shards": 3, "number_of_replicas": 1, "refresh_interval": "30s" } } -
冷热数据分离:
- 热数据节点:SSD磁盘,32GB内存,专用于最近3个月商品
- 冷数据节点:HDD磁盘,16GB内存,存储历史数据
yaml复制# elasticsearch.yml node.attr.temperature: hot
4.2 查询层面优化
-
使用filter代替query:
- filter不计算相关性分数,利用缓存机制
java复制// 不好的写法 builder.withQuery(QueryBuilders.termQuery("categoryId", 1)); // 优化写法 builder.withFilter(QueryBuilders.termQuery("categoryId", 1)); -
深度分页处理:
- 使用search_after替代from/size
java复制SearchRequest request = new SearchRequest("goods"); SearchSourceBuilder sourceBuilder = new SearchSourceBuilder(); sourceBuilder.size(100); sourceBuilder.sort("_id", SortOrder.ASC); // 上次查询最后一条记录的sort值 Object[] lastSortValues = getLastSortValues(); if (lastSortValues != null) { sourceBuilder.searchAfter(lastSortValues); }
5. 集群部署方案
5.1 服务器规划
| 节点类型 | 数量 | 配置 | 磁盘 | 网络 |
|---|---|---|---|---|
| Master | 3 | 4C8G | 50GB | 千兆 |
| Data Hot | 2 | 8C32G | 1TB SSD | 万兆 |
| Data Warm | 2 | 4C16G | 4TB HDD | 千兆 |
| Ingest | 2 | 4C16G | 200GB | 千兆 |
5.2 关键配置
yaml复制# master节点配置
discovery.seed_hosts: ["master1:9300", "master2:9300", "master3:9300"]
cluster.initial_master_nodes: ["master1", "master2", "master3"]
node.master: true
node.data: false
# data hot节点配置
node.attr.temperature: hot
path.data: /ssd1,/ssd2
indices.query.bool.max_clause_count: 10000
5.3 安全防护
-
基础认证:
bash复制
bin/elasticsearch-keystore create bin/elasticsearch-keystore add xpack.security.transport.ssl.keystore.secure_password -
Nginx反向代理:
nginx复制upstream es_cluster { server 192.168.1.101:9200; server 192.168.1.102:9200; keepalive 32; } server { listen 80; location / { proxy_pass http://es_cluster; proxy_http_version 1.1; proxy_set_header Connection ""; } }
6. 踩坑实录
-
分词器版本冲突:
- 问题现象:测试环境正常但生产环境中文分词失效
- 原因:开发用的ik-7.16.3,生产环境ES是7.17.10
- 解决:统一版本并重新构建Docker镜像
-
GC overhead问题:
- 现象:大数据量导出时频繁Full GC
- 排查:发现是深分页查询导致内存堆积
- 优化:改用scroll API分批查询
java复制SearchScrollRequest scrollRequest = new SearchScrollRequest(scrollId); scrollRequest.scroll(TimeValue.timeValueMinutes(1L)); SearchResponse scrollResponse = client.scroll(scrollRequest, RequestOptions.DEFAULT); -
集群脑裂问题:
- 现象:主节点频繁切换,写入失败
- 解决:调整网络超时参数
yaml复制discovery.zen.ping_timeout: 30s discovery.zen.fd.ping_interval: 10s discovery.zen.fd.ping_timeout: 60s
7. 监控与维护
-
基础监控指标:
- 节点健康状态:
GET _cluster/health - 索引性能:
GET _cat/indices?v&h=index,docs.count,store.size,segments.count - 线程池状态:
GET _cat/thread_pool?v
- 节点健康状态:
-
告警规则示例:
json复制PUT _watcher/watch/cluster_health_watch { "trigger": { "schedule": { "interval": "10s" } }, "input": { "http": { "request": { "host": "localhost", "path": "/_cluster/health" } } }, "condition": { "compare": { "ctx.payload.status": { "eq": "red" } } }, "actions": { "send_email": { "email": { "to": "admin@example.com", "subject": "ES集群告警" } } } } -
日常维护命令:
- 强制合并分段:
POST /goods/_forcemerge?max_num_segments=1 - 清理缓存:
POST /_cache/clear - 节点排除:
PUT _cluster/settings { "transient": { "cluster.routing.allocation.exclude._ip": "192.168.1.100" } }
- 强制合并分段: