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 操作简化和性能优化
  • 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 整体架构图

┌─────────────┐     ┌─────────────┐     ┌─────────────┐
│   客户端    │────▶│  SpringBoot  │────▶│  Elasticsearch│
└─────────────┘     └──────┬──────┘     └─────────────┘
                           │
         ┌────────────────┼────────────────┐
         ▼                ▼                ▼
┌─────────────┐     ┌─────────────┐     ┌─────────────┐
│    Redis    │◀────│   MySQL     │◀────│   Canal     │
└─────────────┘     └─────────────┘     └─────────────┘

2.2 核心流程设计

  1. 商品数据写入 MySQL,通过 Canal 监听 binlog 实时同步到 ES
  1. 搜索请求优先查询 ES 获取商品基本信息和高亮结果
  1. 库存信息通过 Redis 缓存 + MySQL 数据库双重保障,确保实时性
  1. 搜索结果聚合商品信息和库存数据后返回给前端

三、环境搭建与配置

3.1 依赖配置

<!-- 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>

3.2 核心配置

# 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

四、核心功能实现

4.1 数据库设计

-- 商品表
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 索引设计

@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;
}

4.3 数据同步实现

使用 Canal 实现 MySQL 到 ES 的实时同步:

  1. MySQL 开启 binlog
# my.cnf
log-bin=mysql-bin
binlog-format=ROW
server-id=1
  1. 创建 Canal 用户并授权
CREATE USER 'canal'@'%' IDENTIFIED BY 'canal';
GRANT SELECT, REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'canal'@'%';
FLUSH PRIVILEGES;
  1. Canal 配置与启动(略)
  1. 数据同步处理器
@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) {
        // 转换逻辑
    }
}

4.4 关键词高亮搜索实现

@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()
        );
    }
}

4.5 库存实时展示实现

库存展示的核心挑战是高并发下的性能和一致性平衡:

@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);
            }
        });
    }
}

五、性能优化实践

5.1 Elasticsearch 优化

  1. 索引优化
    • 合理设计分片数量,避免过多或过少
    • 使用 keyword 类型存储不需要分词的字段
    • 对高频查询字段设置 doc_values=true
  1. 查询优化
    • 优先使用 filter 查询,可缓存结果
    • 减少返回字段,只获取需要的字段
    • 复杂查询拆分为简单查询组合
// 优化的查询方式
SearchSourceBuilder sourceBuilder = new SearchSourceBuilder();
sourceBuilder.fetchSource(new String[]{"id", "name", "price", "categoryId"}, null); // 只返回需要的字段

5.2 缓存策略优化

  1. 多级缓存架构
本地缓存(Caffeine) → Redis缓存 → 数据库
  1. 热点数据预加载
@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);
    }
}
  1. 布隆过滤器防穿透
@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);
    }
}

5.3 数据库优化

  1. 索引优化:为搜索和过滤字段建立合适索引
  1. 读写分离:主库写入,从库查询库存
  1. 分库分表:超大规模电商可考虑按商品 ID 分片

六、踩坑与解决方案

6.1 Elasticsearch 高亮结果不显示

问题:搜索结果中高亮字段没有替换原始字段

原因:高亮结果需要手动替换,ES 不会自动更新原始字段

解决方案

// 正确处理高亮结果
if (highlightFields.containsKey("name")) {
    vo.setName(highlightFields.get("name").get(0)); // 手动替换
} else {
    vo.setName(doc.getName()); // 无高亮时使用原始值
}

6.2 数据同步延迟导致搜索结果不一致

问题:MySQL 更新后,ES 搜索结果未及时更新

解决方案

  1. 确保 Canal 正确配置 ROW 模式的 binlog
  1. 实现同步失败重试机制
  1. 关键业务场景可在更新后主动触发 ES 同步
// 关键场景主动同步
@Transactional
public void updateProduct(Product product) {
    productMapper.updateById(product);
    // 主动同步到ES
    syncService.syncToEs(product);
}

6.3 高并发下库存超卖

问题:秒杀场景下库存检查和扣减不同步导致超卖

解决方案

  1. 采用乐观锁控制库存更新
  1. 结合 Redis 分布式锁保证检查和扣减的原子性
// 乐观锁实现
<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>

七、总结与经验分享

经过多个项目实战,我总结出以下几点经验:

  1. 技术选型要务实:不要盲目追求新技术,适合业务场景的才是最好的。ES 虽好,但简单的搜索需求用 MySQL 全文索引也能满足。
  1. 数据一致性是核心:搜索结果与实际数据的一致性直接影响用户体验,Canal+binlog 是目前最可靠的同步方案。
  1. 缓存策略要精细:库存缓存设计要考虑穿透、击穿、雪崩三大问题,多级缓存 + 分布式锁是成熟方案。
  1. 性能优化无止境:从索引设计、查询优化到缓存策略,每个环节都有优化空间,持续监控持续优化。
  1. 监控告警不可少:为搜索响应时间、缓存命中率、数据同步延迟等关键指标设置监控,提前发现问题。

这套方案在实际项目中经受住了日均千万级搜索请求的考验,关键词高亮提升了用户体验,库存实时展示降低了订单取消率。希望这些经验能对你有所帮助。

最后,技术之路永无止境,保持学习心态最重要。八年开发经验告诉我,解决问题的能力比掌握特定技术更重要。如果你有更好的实践方案,欢迎一起交流探讨!

本站提供的所有下载资源均来自互联网,仅提供学习交流使用,版权归原作者所有。如需商业使用,请联系原作者获得授权。 如您发现有涉嫌侵权的内容,请联系我们 邮箱:[email protected]