Spring博文
我真的很小心了,但还是被 SpringEvent 坑了!
网上被吹爆的Spring Event事件订阅有缺陷,一个月内我被坑了两次!
@ConfigurationProperties VS @Value,你觉得哪个更好用 - 掘金
一个注解就搞定接口统一返回、统一异常处理、加签、验签、加密、解密
SpringBoot3 优雅集成 Knife4j - 掘金
Spring Boot 3.x 中的 RestClient 使用案例-Spring专区论坛-技术-SpringForAll社区
Maven项目Parent,可以试试用这个代替 Spring Boot Parent
SpringBoot集成Logback终极指南:从控制台到云端的多维日志输出
Knife4j:实时接口文档的利器
Spring Boot的Docker Layer优化:缩小镜像体积并提升启动速度-Spring专区论坛-技术-SpringForAll社区
使用Prometheus和Grafana监控Spring Boot应用
Spring Boot 4 新特性详解:5大核心更新助力企业级开发
SpringBoot3 http接口调用新方式RestClient + @HttpExchange像使用Feign一样调用
SpringBoot + SpringCloud Gateway + Sentinel + Redis:API 网关层的接口限流、黑名单拦截与用户认证
SpringBoot + Seata + MySQL + RabbitMQ:金融系统分布式交易对账与资金清算实战
SpringBoot + MyBatis-Plus + Elasticsearch + MySQL:电商商品搜索关键词高亮与库存实时展示
SpringBoot + RabbitMQ + MySQL + XXL-Job:物流系统运单状态定时同步与异常订单重试
本文档使用 MrDoc 发布
-
+
SpringBoot + MyBatis-Plus + Elasticsearch + MySQL:电商商品搜索关键词高亮与库存实时展示
作为一名有八年 Java 开发经验的 "老司机",我深知电商系统中搜索功能的重要性。一个流畅的搜索体验不仅能提升用户转化率,更能直接影响平台的竞争力。今天我想分享一个结合 SpringBoot、MyBatis-Plus、Elasticsearch 和 MySQL 实现的电商商品搜索方案,重点解决关键词高亮显示和库存实时展示这两个核心痛点。 ### 一、需求背景与技术选型 #### 1.1 业务痛点分析 在电商平台中,商品搜索功能面临两个核心挑战: - **搜索体验**:用户输入关键词后,需要快速获得精准结果,并且关键词要高亮显示提升可读性 - **数据一致性**:搜索结果中展示的库存必须实时准确,避免用户看到有货但下单时无货的情况 这些问题在高并发场景下会被放大,处理不好很容易引发用户投诉和订单流失。 #### 1.2 技术栈选型思路 基于八年的开发经验,我选择了以下技术组合: - **SpringBoot 2.7.x**:稳定成熟的微服务开发框架,简化配置提高开发效率 - **MyBatis-Plus 3.5.3**:在 MyBatis 基础上增强,提供 CRUD 操作简化和性能优化 > 版本选择小贴士:SpringBoot 2.7.x 最佳搭档是 MyBatis-Plus 3.5.3,这个组合在多数据源切换和事务管理上表现更稳定 - **Elasticsearch 7.x**:全文搜索引擎,支持复杂的分词和高亮功能 - **MySQL 8.0**:存储商品基础数据和库存信息,保证事务一致性 - **Redis 6.x**:缓存热点商品库存,减轻数据库压力 - **Canal 1.1.7**:基于 MySQL binlog 实现数据同步,保证 ES 与 MySQL 数据一致性 为什么不用 Solr?在实际项目中,ES 的社区活跃度更高,分词插件更丰富,特别是在中文处理上优势明显。而 MyBatis-Plus 相比原生 MyBatis,省去了大量重复 CRUD 代码,让开发者能聚焦核心业务逻辑。 ### 二、系统架构设计 #### 2.1 整体架构图 ```markdown ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ 客户端 │────▶│ SpringBoot │────▶│ Elasticsearch│ └─────────────┘ └──────┬──────┘ └─────────────┘ │ ┌────────────────┼────────────────┐ ▼ ▼ ▼ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ Redis │◀────│ MySQL │◀────│ Canal │ └─────────────┘ └─────────────┘ └─────────────┘ ``` #### 2.2 核心流程设计 1. 商品数据写入 MySQL,通过 Canal 监听 binlog 实时同步到 ES 1. 搜索请求优先查询 ES 获取商品基本信息和高亮结果 1. 库存信息通过 Redis 缓存 + MySQL 数据库双重保障,确保实时性 ### 三、环境搭建与配置 #### 3.1 依赖配置 ```xml <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-elasticsearch</artifactId> </dependency> <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> ``` #### 3.2 核心配置 ```yaml 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: uris: http://localhost:9200 username: elastic password: elastic connection-timeout: 5s socket-timeout: 3s redis: host: localhost port: 6379 password: lettuce: pool: max-active: 20 max-idle: 10 min-idle: 5 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 ``` ### 四、核心功能实现 #### 4.1 数据库设计 ```sql 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='商品库存表'; ``` #### 4.2 Elasticsearch 索引设计 ```kotlin public class ProductDocument { private Long id; private String name; private String description; private BigDecimal price; private Long categoryId; private String categoryName; private Date updateTime; } ``` > 索引设计经验:商品搜索建议 shards 数量根据数据量设置,3-5 个较合适。replicas 设置 1 个即可保证高可用,过多会影响写入性能。中文分词推荐使用 IK 分词器,ik\_max\_word 用于索引时分词更细,ik\_smart 用于搜索时更精确。 #### 4.3 数据同步实现 使用 Canal 实现 MySQL 到 ES 的实时同步: ```ini log-bin=mysql-bin binlog-format=ROW server-id=1 ``` ```sql CREATE USER 'canal'@'%' IDENTIFIED BY 'canal'; GRANT SELECT, REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'canal'@'%'; FLUSH PRIVILEGES; ``` ```typescript public class ProductDataSyncHandler implements MessageHandler<CanalMessage<Product>> { private ElasticsearchRestTemplate esTemplate; 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) { } } ``` > 八年经验之谈:数据同步是搜索功能的基石,采用 Canal 基于 binlog 的同步方式比定时任务更实时,延迟可控制在毫秒级。生产环境建议配合消息队列做异步处理,避免同步失败导致数据丢失。 #### 4.4 关键词高亮搜索实现 ```scss @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); HighlightBuilder.Field descHighlight = new HighlightBuilder.Field("description"); descHighlight.preTags("<em class='highlight'>"); descHighlight.postTags("</em>"); descHighlight.fragmentSize(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() ); } } ``` #### 4.5 库存实时展示实现 库存展示的核心挑战是高并发下的性能和一致性平衡: ```java public class ProductStockService { private StringRedisTemplate redisTemplate; private ProductStockMapper stockMapper; 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; String stockStr = redisTemplate.opsForValue().get(key); if (StringUtils.hasText(stockStr)) { return Integer.parseInt(stockStr); } 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(); } 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); } }); } } ``` > 库存设计最佳实践:采用 "MySQL+Redis" 的二级存储架构,结合 Redisson 分布式锁防止缓存击穿,使用乐观锁解决并发更新问题。库存缓存设置随机过期时间,避免大量缓存同时失效引发雪崩。 ### 五、性能优化实践 #### 5.1 Elasticsearch 优化 - - 对高频查询字段设置 doc\_values=true ```java SearchSourceBuilder sourceBuilder = new SearchSourceBuilder(); sourceBuilder.fetchSource(new String[]{"id", "name", "price", "categoryId"}, null); ``` #### 5.2 缓存策略优化 ```scss 本地缓存(Caffeine) → Redis缓存 → 数据库 ``` ```scss @Scheduled(cron = "0 0 2 * * ?") // 每天凌晨2点执行 public void preloadHotProductCache() { 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); } } ``` ```ini @PostConstruct public void initBloomFilter() { RBloomFilter<Long> bloomFilter = redissonClient.getBloomFilter("product:id:bloom") bloomFilter.tryInit(1000000, 0.01) // 加载所有商品ID到布隆过滤器 List<Long> allProductIds = productMapper.selectAllProductIds() for (Long id : allProductIds) { bloomFilter.add(id) } } ``` #### 5.3 数据库优化 ### 六、踩坑与解决方案 #### 6.1 Elasticsearch 高亮结果不显示 **问题**:搜索结果中高亮字段没有替换原始字段 **原因**:高亮结果需要手动替换,ES 不会自动更新原始字段 **解决方案**: ```less if (highlightFields.containsKey("name")) { vo.setName(highlightFields.get("name").get(0)); } else { vo.setName(doc.getName()); } ``` #### 6.2 数据同步延迟导致搜索结果不一致 **问题**:MySQL 更新后,ES 搜索结果未及时更新 **解决方案**: 1. 确保 Canal 正确配置 ROW 模式的 binlog ```typescript public void updateProduct(Product product) { productMapper.updateById(product); syncService.syncToEs(product); } ``` #### 6.3 高并发下库存超卖 **问题**:秒杀场景下库存检查和扣减不同步导致超卖 **解决方案**: ```ini // 乐观锁实现 <update id="updateStock"> UPDATE product_stock SET stock_num = stock_num + version = version + 1, update_time = NOW() WHERE product_id = AND version = AND stock_num + </update> ``` ### 七、总结与经验分享 经过多个项目实战,我总结出以下几点经验: 1. **技术选型要务实**:不要盲目追求新技术,适合业务场景的才是最好的。ES 虽好,但简单的搜索需求用 MySQL 全文索引也能满足。 1. **数据一致性是核心**:搜索结果与实际数据的一致性直接影响用户体验,Canal+binlog 是目前最可靠的同步方案。 1. **缓存策略要精细**:库存缓存设计要考虑穿透、击穿、雪崩三大问题,多级缓存 + 分布式锁是成熟方案。 1. **性能优化无止境**:从索引设计、查询优化到缓存策略,每个环节都有优化空间,持续监控持续优化。 1. **监控告警不可少**:为搜索响应时间、缓存命中率、数据同步延迟等关键指标设置监控,提前发现问题。 这套方案在实际项目中经受住了日均千万级搜索请求的考验,关键词高亮提升了用户体验,库存实时展示降低了订单取消率。希望这些经验能对你有所帮助。 最后,技术之路永无止境,保持学习心态最重要。八年开发经验告诉我,解决问题的能力比掌握特定技术更重要。如果你有更好的实践方案,欢迎一起交流探讨!
admin
2025年10月5日 10:14
转发文档
收藏文档
上一篇
下一篇
手机扫码
复制链接
手机扫一扫转发分享
复制链接
Markdown文件
PDF文档(打印)
分享
链接
类型
密码
更新密码