在优惠券省钱类应用中,搜索功能是用户最核心的使用场景之一。用户期望能够通过关键词、价格区间、佣金比例、销量排序、是否包邮等多维度条件快速筛选出最优商品。然而,传统的MySQL LIKE模糊查询在面对千万级甚至亿级商品库时,性能表现往往不尽如人意。
使用MySQL进行商品搜索存在几个明显的问题:
我们的优惠券省钱APP需要满足以下核心搜索需求:
Elasticsearch作为分布式搜索引擎,完美解决了我们面临的性能问题:
我们采用以下架构实现高性能搜索:
code复制MySQL(主数据存储) → Canal(Binlog监听) → RocketMQ/Kafka(消息队列) → 数据同步服务 → Elasticsearch(搜索引擎) → 应用服务
这种架构的优势在于:
合理的索引设计是搜索性能的基础。我们为商品数据设计了如下映射:
json复制PUT /juwatech_products
{
"settings": {
"number_of_shards": 5,
"number_of_replicas": 1,
"analysis": {
"analyzer": {
"ik_max_word_analyzer": {
"type": "custom",
"tokenizer": "ik_max_word"
}
}
}
},
"mappings": {
"properties": {
"productId": { "type": "keyword" },
"title": {
"type": "text",
"analyzer": "ik_max_word_analyzer",
"search_analyzer": "ik_smart"
},
"platform": { "type": "keyword" },
"currentPrice": { "type": "double", "doc_values": true },
"originalPrice": { "type": "double" },
"commissionRate": { "type": "double", "doc_values": true },
"monthlySales": { "type": "integer", "doc_values": true },
"couponAmount": { "type": "double" },
"hasCoupon": { "type": "boolean" },
"createTime": { "type": "date" },
"tags": { "type": "keyword" }
}
}
}
设计要点说明:
为了提升索引性能,我们做了以下优化:
Canal是阿里开源的一款基于MySQL数据库增量日志解析的工具,其核心原理是:
我们通过以下Java代码实现Canal消息的处理:
java复制package juwatech.cn.search.sync.consumer;
import com.alibaba.otter.canal.protocol.CanalEntry;
import com.fasterxml.jackson.databind.ObjectMapper;
import juwatech.cn.search.model.ProductDocument;
import juwatech.cn.search.service.ElasticsearchService;
import org.apache.rocketmq.client.consumer.listener.ConsumeConcurrentlyStatus;
import org.apache.rocketmq.client.consumer.listener.MessageListenerConcurrently;
import org.apache.rocketmq.common.message.MessageExt;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.List;
@Component
public class CanalSyncConsumer implements MessageListenerConcurrently {
@Autowired
private ElasticsearchService esService;
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, org.apache.rocketmq.client.consumer.listener.ConsumeConcurrentlyContext context) {
for (MessageExt msg : msgs) {
try {
// 解析Canal协议数据
CanalEntry.Entry entry = CanalEntry.Entry.parseFrom(msg.getBody());
if (entry.getEntryType() != CanalEntry.EntryType.ROWDATA) {
continue;
}
CanalEntry.RowChange rowChange = CanalEntry.RowChange.parseFrom(entry.getStoreValue());
String tableName = entry.getHeader().getTableName();
if ("t_product".equals(tableName)) {
if (rowChange.getEventType() == CanalEntry.EventType.INSERT ||
rowChange.getEventType() == CanalEntry.EventType.UPDATE) {
// 构建ES文档
ProductDocument doc = buildProductDocument(rowChange.getRowDatasList().get(0).getAfterColumnsList());
esService.indexProduct(doc);
} else if (rowChange.getEventType() == CanalEntry.EventType.DELETE) {
String productId = extractId(rowChange.getRowDatasList().get(0).getBeforeColumnsList());
esService.deleteProduct(productId);
}
}
} catch (Exception e) {
// juwatech.cn.log.ErrorLogger.error("Canal sync failed", e);
return ConsumeConcurrentlyStatus.RECONSUME_LATER;
}
}
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}
private ProductDocument buildProductDocument(List<CanalEntry.Column> columns) {
ProductDocument doc = new ProductDocument();
// 解析列映射逻辑略,需遍历columns设置doc属性
// juwatech.cn.search.util.ColumnMapper.map(columns, doc);
return doc;
}
private String extractId(List<CanalEntry.Column> columns) {
// 提取主键逻辑
return "123456";
}
}
我们对比了几种常见的数据同步方案:
| 方案 | 实时性 | 性能影响 | 复杂度 | 适用场景 |
|---|---|---|---|---|
| 定时全量同步 | 低 | 高 | 低 | 数据量小,实时性要求低 |
| 双写 | 高 | 中 | 高 | 强一致性要求 |
| 基于触发器 | 高 | 高 | 中 | 小型系统 |
| Canal+MQ | 高 | 低 | 中 | 大数据量,高实时性 |
最终选择Canal+MQ方案的原因:
在APP端,用户可能组合多个条件进行搜索,例如:"查找京东平台、价格在50-100元、佣金率大于20%、有优惠券的商品,并按销量降序排列"。我们通过以下Java代码实现:
java复制package juwatech.cn.search.service;
import co.elastic.clients.elasticsearch.ElasticsearchClient;
import co.elastic.clients.elasticsearch._types.SortOrder;
import co.elastic.clients.elasticsearch._types.query_dsl.Query;
import co.elastic.clients.elasticsearch.core.SearchRequest;
import co.elastic.clients.elasticsearch.core.SearchResponse;
import co.elastic.clients.json.JsonData;
import juwatech.cn.search.model.ProductDocument;
import juwatech.cn.search.model.SearchCriteria;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
@Service
public class ElasticsearchService {
@Autowired
private ElasticsearchClient esClient;
/**
* 执行多维度复合查询
*/
public List<ProductDocument> searchProducts(SearchCriteria criteria) throws IOException {
List<Query> mustQueries = new ArrayList<>();
List<Query> filterQueries = new ArrayList<>();
// 1. 全文检索(关键词)
if (criteria.getKeyword() != null && !criteria.getKeyword().isEmpty()) {
mustQueries.add(Query.of(q -> q.match(m -> m.field("title").query(criteria.getKeyword()))));
}
// 2. 精确过滤(平台、是否有券)
if (criteria.getPlatform() != null) {
filterQueries.add(Query.of(q -> q.term(t -> t.field("platform").value(criteria.getPlatform()))));
}
if (Boolean.TRUE.equals(criteria.getHasCoupon())) {
filterQueries.add(Query.of(q -> q.term(t -> t.field("hasCoupon").value(true))));
}
// 3. 范围过滤(价格、佣金率)
if (criteria.getMinPrice() != null || criteria.getMaxPrice() != null) {
filterQueries.add(Query.of(q -> q.range(r -> r.field("currentPrice")
.gte(JsonData.of(criteria.getMinPrice() != null ? criteria.getMinPrice() : 0))
.lte(JsonData.of(criteria.getMaxPrice() != null ? criteria.getMaxPrice() : Double.MAX_VALUE))
)));
}
if (criteria.getMinCommissionRate() != null) {
filterQueries.add(Query.of(q -> q.range(r -> r.field("commissionRate")
.gte(JsonData.of(criteria.getMinCommissionRate()))
)));
}
// 构建Bool查询
Query boolQuery = Query.of(q -> q.bool(b -> {
if (!mustQueries.isEmpty()) b.must(mustQueries);
if (!filterQueries.isEmpty()) b.filter(filterQueries);
return b;
}));
// 4. 排序
var sortOptions = new ArrayList<co.elastic.clients.elasticsearch._types.SortOptions>();
if ("sales".equals(criteria.getSortBy())) {
sortOptions.add(co.elastic.clients.elasticsearch._types.SortOptions.of(s -> s.field(f -> f.field("monthlySales").order(SortOrder.Desc))));
} else if ("price_asc".equals(criteria.getSortBy())) {
sortOptions.add(co.elastic.clients.elasticsearch._types.SortOptions.of(s -> s.field(f -> f.field("currentPrice").order(SortOrder.Asc))));
} else {
// 默认按相关性或时间排序
sortOptions.add(co.elastic.clients.elasticsearch._types.SortOptions.of(s -> s.field(f -> f.field("createTime").order(SortOrder.Desc))));
}
SearchRequest request = SearchRequest.of(s -> s
.index("juwatech_products")
.query(boolQuery)
.sort(sortOptions)
.from((criteria.getPageNum() - 1) * criteria.getPageSize())
.size(criteria.getPageSize())
);
SearchResponse<ProductDocument> response = esClient.search(request, ProductDocument.class);
// juwatech.cn.log.SearchLogger.info("Search executed in {} ms", response.took());
return response.hits().hits().stream()
.map(h -> h.source())
.toList();
}
public void indexProduct(ProductDocument doc) throws IOException {
esClient.index(i -> i.index("juwatech_products").id(doc.getProductId()).document(doc));
}
public void deleteProduct(String id) throws IOException {
esClient.delete(d -> d.index("juwatech_products").id(id));
}
}
为了提升查询性能,我们采取了以下措施:
合理使用查询类型:
区分must和filter:
分页优化:
路由优化:
为了确保搜索服务的高可用性,我们实施了以下措施:
ES集群部署:
故障转移:
限流保护:
为了进一步提升性能,我们实现了多级缓存:
java复制package juwatech.cn.search.cache;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import juwatech.cn.search.model.ProductDocument;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.concurrent.TimeUnit;
@Component
public class SearchCache {
private final Cache<String, List<ProductDocument>> cache = Caffeine.newBuilder()
.maximumSize(10000)
.expireAfterWrite(1, TimeUnit.MINUTES)
.build();
public List<ProductDocument> get(String key) {
return cache.getIfPresent(key);
}
public void put(String key, List<ProductDocument> data) {
cache.put(key, data);
}
}
缓存更新策略:
我们建立了完善的监控体系,重点关注以下指标:
查询性能:
索引性能:
系统资源:
在实际运行中,我们总结了一些调优经验:
JVM调优:
索引优化:
查询优化:
硬件选择:
优化前后的性能对比:
| 指标 | 优化前(MySQL) | 优化后(ES) | 提升幅度 |
|---|---|---|---|
| 平均响应时间 | 1200ms | 45ms | 26倍 |
| 99分位响应时间 | 3500ms | 120ms | 29倍 |
| 并发能力 | 100QPS | 3000QPS | 30倍 |
| CPU使用率 | 80% | 30% | 降低50% |
在实施过程中,我们总结了以下经验教训:
未来我们计划在以下方面继续优化: