部落冲突皇室战争
1.09GB · 2025-09-23
作为一名有八年 Java 开发经验的 "老司机",我深知电商系统中搜索功能的重要性。一个流畅的搜索体验不仅能提升用户转化率,更能直接影响平台的竞争力。今天我想分享一个结合 SpringBoot、MyBatis-Plus、Elasticsearch 和 MySQL 实现的电商商品搜索方案,重点解决关键词高亮显示和库存实时展示这两个核心痛点。
在电商平台中,商品搜索功能面临两个核心挑战:
这些问题在高并发场景下会被放大,处理不好很容易引发用户投诉和订单流失。
基于八年的开发经验,我选择了以下技术组合:
为什么不用 Solr?在实际项目中,ES 的社区活跃度更高,分词插件更丰富,特别是在中文处理上优势明显。而 MyBatis-Plus 相比原生 MyBatis,省去了大量重复 CRUD 代码,让开发者能聚焦核心业务逻辑。
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ 客户端 │────▶│ SpringBoot │────▶│ Elasticsearch│
└─────────────┘ └──────┬──────┘ └─────────────┘
│
┌────────────────┼────────────────┐
▼ ▼ ▼
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Redis │◀────│ MySQL │◀────│ Canal │
└─────────────┘ └─────────────┘ └─────────────┘
<!-- SpringBoot核心 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- MyBatis-Plus -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.3</version>
</dependency>
<!-- Elasticsearch -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-elasticsearch</artifactId>
</dependency>
<!-- Redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.17.6</version>
</dependency>
<!-- 数据库驱动 -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
# application.yml
spring:
datasource:
url: jdbc:mysql://localhost:3306/ecommerce?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
username: root
password: password
driver-class-name: com.mysql.cj.jdbc.Driver
# Elasticsearch配置 (注意:7.x版本去掉了rest前缀)
elasticsearch:
uris: http://localhost:9200
username: elastic
password: elastic
connection-timeout: 5s
socket-timeout: 3s
# Redis配置
redis:
host: localhost
port: 6379
password:
lettuce:
pool:
max-active: 20
max-idle: 10
min-idle: 5
# MyBatis-Plus配置
mybatis-plus:
mapper-locations: classpath*:mapper/**/*.xml
global-config:
db-config:
id-type: auto
logic-delete-field: deleted
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
-- 商品表
CREATE TABLE `product` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '商品ID',
`name` varchar(255) NOT NULL COMMENT '商品名称',
`description` text COMMENT '商品描述',
`price` decimal(10,2) NOT NULL COMMENT '商品价格',
`category_id` bigint NOT NULL COMMENT '分类ID',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`deleted` tinyint DEFAULT 0 COMMENT '逻辑删除',
PRIMARY KEY (`id`),
KEY `idx_category` (`category_id`),
KEY `idx_name` (`name`) COMMENT '支持模糊查询'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品表';
-- 库存表
CREATE TABLE `product_stock` (
`id` bigint NOT NULL AUTO_INCREMENT,
`product_id` bigint NOT NULL COMMENT '商品ID',
`stock_num` int NOT NULL DEFAULT 0 COMMENT '库存数量',
`locked_num` int NOT NULL DEFAULT 0 COMMENT '锁定数量',
`version` int NOT NULL DEFAULT 0 COMMENT '版本号,用于乐观锁',
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_product_id` (`product_id`) COMMENT '唯一索引,确保一个商品一条库存记录'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品库存表';
@Document(indexName = "product_index", shards = 3, replicas = 1)
@Data
public class ProductDocument {
@Id
private Long id;
@Field(type = FieldType.Text, analyzer = "ik_max_word", searchAnalyzer = "ik_smart")
private String name;
@Field(type = FieldType.Text, analyzer = "ik_max_word", searchAnalyzer = "ik_smart")
private String description;
@Field(type = FieldType.Double)
private BigDecimal price;
@Field(type = FieldType.Long)
private Long categoryId;
@Field(type = FieldType.Keyword)
private String categoryName;
@Field(type = FieldType.Date, format = DateFormat.basic_date_time)
private Date updateTime;
}
使用 Canal 实现 MySQL 到 ES 的实时同步:
# my.cnf
log-bin=mysql-bin
binlog-format=ROW
server-id=1
CREATE USER 'canal'@'%' IDENTIFIED BY 'canal';
GRANT SELECT, REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'canal'@'%';
FLUSH PRIVILEGES;
@Component
public class ProductDataSyncHandler implements MessageHandler<CanalMessage<Product>> {
@Autowired
private ElasticsearchRestTemplate esTemplate;
@Override
public void handleMessage(CanalMessage<Product> message) {
List<Product> products = message.getDatas();
for (Product product : products) {
// 转换为文档对象
ProductDocument doc = convertToDocument(product);
// 根据操作类型处理
switch (message.getType()) {
case INSERT:
case UPDATE:
esTemplate.save(doc);
break;
case DELETE:
esTemplate.delete(String.valueOf(product.getId()), ProductDocument.class);
break;
}
}
}
private ProductDocument convertToDocument(Product product) {
// 转换逻辑
}
}
@Service
public class ProductSearchService {
@Autowired
private ElasticsearchRestTemplate esTemplate;
@Autowired
private ProductStockService stockService;
/**
* 搜索商品并高亮关键词
*/
public PageResult<ProductSearchVO> searchProducts(String keyword, Long categoryId,
Integer pageNum, Integer pageSize) {
// 构建高亮查询
NativeSearchQueryBuilder queryBuilder = new NativeSearchQueryBuilder();
// 组合查询条件
BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();
// 关键词匹配
if (StringUtils.hasText(keyword)) {
boolQuery.should(QueryBuilders.matchQuery("name", keyword));
boolQuery.should(QueryBuilders.matchQuery("description", keyword));
}
// 分类过滤
if (categoryId != null) {
boolQuery.filter(QueryBuilders.termQuery("categoryId", categoryId));
}
queryBuilder.withQuery(boolQuery);
// 高亮设置
HighlightBuilder highlightBuilder = new HighlightBuilder();
// 高亮字段
HighlightBuilder.Field nameHighlight = new HighlightBuilder.Field("name");
nameHighlight.preTags("<em class='highlight'>");
nameHighlight.postTags("</em>");
highlightBuilder.field(nameHighlight);
// 也可以对description字段设置高亮
HighlightBuilder.Field descHighlight = new HighlightBuilder.Field("description");
descHighlight.preTags("<em class='highlight'>");
descHighlight.postTags("</em>");
descHighlight.fragmentSize(100); // 描述太长时只展示100字符片段
highlightBuilder.field(descHighlight);
queryBuilder.withHighlightFields(nameHighlight, descHighlight);
// 分页设置
queryBuilder.withPageable(PageRequest.of(pageNum - 1, pageSize));
// 执行查询
SearchHits<ProductDocument> searchHits = esTemplate.search(
queryBuilder.build(), ProductDocument.class);
// 处理结果
List<ProductSearchVO> results = new ArrayList<>();
for (SearchHit<ProductDocument> hit : searchHits) {
ProductDocument doc = hit.getContent();
ProductSearchVO vo = convertToVO(doc);
// 设置高亮结果
Map<String, List<String>> highlightFields = hit.getHighlightFields();
if (highlightFields.containsKey("name")) {
vo.setName(highlightFields.get("name").get(0));
}
if (highlightFields.containsKey("description") && !highlightFields.get("description").isEmpty()) {
vo.setDescription(highlightFields.get("description").get(0));
}
// 查询实时库存
Integer stock = stockService.getRealTimeStock(vo.getId());
vo.setStock(stock);
results.add(vo);
}
// 构建分页结果
return new PageResult<>(
results,
pageNum,
pageSize,
searchHits.getTotalHits()
);
}
}
库存展示的核心挑战是高并发下的性能和一致性平衡:
@Service
public class ProductStockService {
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private ProductStockMapper stockMapper;
@Autowired
private RedissonClient redissonClient;
private static final String STOCK_KEY_PREFIX = "product:stock:";
private static final String LOCK_KEY_PREFIX = "lock:stock:";
/**
* 获取商品实时库存
*/
public Integer getRealTimeStock(Long productId) {
String key = STOCK_KEY_PREFIX + productId;
// 1. 先查Redis缓存
String stockStr = redisTemplate.opsForValue().get(key);
if (StringUtils.hasText(stockStr)) {
return Integer.parseInt(stockStr);
}
// 2. 缓存未命中,查数据库并更新缓存
RLock lock = redissonClient.getLock(LOCK_KEY_PREFIX + productId);
try {
// 尝试获取锁,防止缓存击穿
if (lock.tryLock(0, 30, TimeUnit.SECONDS)) {
// 双重检查
stockStr = redisTemplate.opsForValue().get(key);
if (StringUtils.hasText(stockStr)) {
return Integer.parseInt(stockStr);
}
// 查询数据库
ProductStock stock = stockMapper.selectByProductId(productId);
Integer realStock = stock == null ? 0 : stock.getStockNum() - stock.getLockedNum();
// 缓存结果,设置随机过期时间防止缓存雪崩
int randomExpire = 300 + new Random().nextInt(600);
redisTemplate.opsForValue().set(key, realStock.toString(), randomExpire, TimeUnit.SECONDS);
return realStock;
}
} catch (InterruptedException e) {
log.error("获取库存锁异常", e);
} finally {
// 释放锁
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
// 获取锁失败时直接查库(兜底方案)
ProductStock stock = stockMapper.selectByProductId(productId);
return stock == null ? 0 : stock.getStockNum() - stock.getLockedNum();
}
/**
* 库存变更时更新缓存
*/
@Transactional
public void updateStock(Long productId, int quantity) {
// 使用乐观锁更新库存
int rows = stockMapper.updateStock(productId, quantity);
if (rows == 0) {
throw new BusinessException("库存不足");
}
// 更新缓存(使用延时双删策略)
String key = STOCK_KEY_PREFIX + productId;
redisTemplate.delete(key);
// 延时再次删除,确保缓存一致性
CompletableFuture.runAsync(() -> {
try {
Thread.sleep(500);
redisTemplate.delete(key);
} catch (InterruptedException e) {
log.error("延时删除库存缓存异常", e);
}
});
}
}
// 优化的查询方式
SearchSourceBuilder sourceBuilder = new SearchSourceBuilder();
sourceBuilder.fetchSource(new String[]{"id", "name", "price", "categoryId"}, null); // 只返回需要的字段
本地缓存(Caffeine) → Redis缓存 → 数据库
@Scheduled(cron = "0 0 2 * * ?") // 每天凌晨2点执行
public void preloadHotProductCache() {
// 查询热门商品ID列表
List<Long> hotProductIds = analysisService.getTopViewProducts(1000);
// 批量查询并缓存
List<ProductStock> stocks = stockMapper.selectByProductIds(hotProductIds);
for (ProductStock stock : stocks) {
String key = STOCK_KEY_PREFIX + stock.getProductId();
redisTemplate.opsForValue().set(key,
String.valueOf(stock.getStockNum() - stock.getLockedNum()),
3600 + new Random().nextInt(3600), TimeUnit.SECONDS);
}
}
@PostConstruct
public void initBloomFilter() {
RBloomFilter<Long> bloomFilter = redissonClient.getBloomFilter("product:id:bloom");
bloomFilter.tryInit(1000000, 0.01); // 预计100万商品,误判率0.01
// 加载所有商品ID到布隆过滤器
List<Long> allProductIds = productMapper.selectAllProductIds();
for (Long id : allProductIds) {
bloomFilter.add(id);
}
}
问题:搜索结果中高亮字段没有替换原始字段
原因:高亮结果需要手动替换,ES 不会自动更新原始字段
解决方案:
// 正确处理高亮结果
if (highlightFields.containsKey("name")) {
vo.setName(highlightFields.get("name").get(0)); // 手动替换
} else {
vo.setName(doc.getName()); // 无高亮时使用原始值
}
问题:MySQL 更新后,ES 搜索结果未及时更新
解决方案:
// 关键场景主动同步
@Transactional
public void updateProduct(Product product) {
productMapper.updateById(product);
// 主动同步到ES
syncService.syncToEs(product);
}
问题:秒杀场景下库存检查和扣减不同步导致超卖
解决方案:
// 乐观锁实现
<update id="updateStock">
UPDATE product_stock
SET stock_num = stock_num + #{quantity},
version = version + 1,
update_time = NOW()
WHERE product_id = #{productId}
AND version = #{version}
AND stock_num + #{quantity} >= 0
</update>
经过多个项目实战,我总结出以下几点经验:
这套方案在实际项目中经受住了日均千万级搜索请求的考验,关键词高亮提升了用户体验,库存实时展示降低了订单取消率。希望这些经验能对你有所帮助。
最后,技术之路永无止境,保持学习心态最重要。八年开发经验告诉我,解决问题的能力比掌握特定技术更重要。如果你有更好的实践方案,欢迎一起交流探讨!