ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Spring Boot 电商实战:SKU库存、双模式购物车、订单状态机与ES美妆搜索

Spring Boot 电商实战:SKU库存、双模式购物车、订单状态机与ES美妆搜索 简介本资源是一套完整的时尚美妆垂直领域电商网站毕业设计项目面向计算机专业本科生及Java后端初学者解决Spring Boot全栈开发实战能力训练需求。项目采用前后端分离架构包含客户端VueJSCSS、管理端VueElement UI与服务端Spring BootMyBatisMySQL覆盖商品管理、用户中心、订单流程等核心电商模块并提供数据库表结构文档与问题排查指引。压缩包共346个文件含94个Java业务逻辑代码、70个Vue组件与页面、49个PNG/JPG/WebP图像资源、41个JS交互脚本及15个XML配置文件整体大小17.7MB结构清晰、分层明确便于理解MVC架构落地细节。目前已有106人学习下载读者可直接运行调试、参考标准目录组织方式、复用基础安全配置Spring Security、借鉴电商场景下的事务处理与接口设计规范是课程作业与毕设选题的高复用性参考实现。1. 为什么一个「时尚美妆电商」系统必须用 Spring Boot 而不是传统 SSM你正在做毕业设计、接外包、或是重构老系统——当需求明确写着「用户浏览口红详情→加入购物车→微信支付→物流跟踪→评价晒图」背后要支撑的不是单个接口而是一整套高并发、强事务、多状态流转的业务闭环。Spring Boot 不是“Java 写 Web 的新写法”它是把「电商场景里反复踩过的坑」提前打包成可复用的约定比如商品 SKU 库存扣减必须原子化但Transactional默认不处理 Redis 缓存穿透比如用户上传的妆容照片要自动压缩并存到本地或 MinIO但MultipartFile直接.transferTo()会阻塞主线程再比如搜索栏输入“哑光唇釉”需要实时返回带高亮的标题和销量排序结果而原生 JPA 的LIKE查询在百万级商品表上会拖垮整个服务。这不是理论问题而是上线后第 3 天就可能被运营投诉“搜索卡顿”“下单失败率突增 12%”。本文只讲真实落地时你会立刻用到的 4 个核心模块商品中心含规格组合与库存锁、购物车支持登录/未登录双模式、订单状态机从待支付→已发货→已完成的精准流转、以及基于 Elasticsearch 的美妆关键词搜索支持同义词扩展与敏感词过滤。所有代码均可在 JDK 17 Spring Boot 3.2.x 环境下直接运行不依赖任何非主流中间件。2. 商品中心用 JPA Redis 实现 SKU 库存原子扣减与规格组合生成电商系统最易出错的环节不是支付而是商品库存管理。用户同时点击“YSL 21 号红管”购买 2 支若仅靠数据库UPDATE product_sku SET stock stock - 2 WHERE sku_id 1001 AND stock 2在高并发下仍可能超卖——因为SELECT和UPDATE之间存在时间窗口。Spring Boot 提供了更可靠的解法将库存校验与扣减逻辑下沉到 Redis Lua 脚本再由 JPA 持久化最终状态形成“缓存预占 DB 最终一致”的双保险。2.1 规格组合建模避免硬编码颜色/容量字段传统做法是为每个商品建color、volume字段但美妆品类存在“色号质地容量”三级组合如“#12 砖红哑光3.5g”硬编码会导致表结构频繁变更。正确做法是分离规格定义与值绑定// 规格模板如“色号”、“质地” Entity public class SpecTemplate { Id private Long id; private String name; // 色号 } // 规格值如“#12 砖红”、“哑光” Entity public class SpecValue { Id private Long id; ManyToOne private SpecTemplate template; private String value; // #12 砖红 } // SKU 绑定具体值组合 Entity public class Sku { Id private Long id; ManyToOne private Product product; ElementCollection private SetLong specValueIds; // [101, 205, 308] private Integer stock; // 实际库存 }提示specValueIds使用SetLong而非关联实体避免 N1 查询。前端提交时传入[101,205,308]后端通过specValueRepository.findAllById(specValueIds)一次性查出完整规格路径用于生成 SKU 标题如“YSL 圣罗兰 小金条 #12 砖红哑光3.5g”。2.2 Redis Lua 脚本实现库存原子扣减在resources/redis/stock-deduct.lua中编写脚本-- KEYS[1]: sku_id, ARGV[1]: deduct_count local stock_key sku:stock: .. KEYS[1] local current_stock tonumber(redis.call(GET, stock_key)) if current_stock nil then return -1 -- 库存未初始化 end if current_stock tonumber(ARGV[1]) then return -2 -- 库存不足 end redis.call(DECRBY, stock_key, ARGV[1]) return current_stock - tonumber(ARGV[1])Java 调用逻辑Component public class StockDeductService { Autowired private RedisTemplateString, Object redisTemplate; Autowired private DefaultRedisScriptLong stockDeductScript; public boolean deduct(Long skuId, Integer count) { String stockKey sku:stock: skuId; // 首次访问时预热库存从 DB 加载 if (redisTemplate.opsForValue().get(stockKey) null) { Integer dbStock skuRepository.findById(skuId).map(Sku::getStock).orElse(0); redisTemplate.opsForValue().set(stockKey, dbStock); } Long result redisTemplate.execute( stockDeductScript, Collections.singletonList(String.valueOf(skuId)), String.valueOf(count) ); return result ! null result 0; } }注意stockDeductScript需在配置类中声明为Bean并指定setScriptText()加载 Lua 内容。返回值-1表示缓存未初始化需回查 DB 并重置-2表示库存不足0表示扣减成功且剩余库存数。此脚本保证 Redis 层原子性避免超卖。2.3 规格组合生成器动态生成 SKU 列表前端选择“色号正红”、“质地滋润”、“容量3.5g”后后端需生成对应 SKU ID。不能靠 SQL JOIN 查询性能差而应预计算组合映射Service public class SkuGenerator { // 缓存规格值ID列表 → SKU ID private final CacheSetLong, Long skuCache Caffeine.newBuilder() .maximumSize(10000) .expireAfterWrite(10, TimeUnit.MINUTES) .build(); public Long getSkuIdBySpecValues(SetLong specValueIds) { return skuCache.get(specValueIds, key - { // 从 DB 查找完全匹配的 SKU return skuRepository.findBySpecValueIdsIn(key).stream() .filter(sku - sku.getSpecValueIds().equals(key)) .map(Sku::getId) .findFirst() .orElse(null); }); } }3. 购物车支持登录态与游客态的双存储策略与合并逻辑用户未登录时添加商品到购物车登录后需自动合并——这是美妆电商高频场景用户常先浏览再注册。若统一存 DB未登录用户每次请求都生成新记录导致数据膨胀若全存 Redis又面临过期与持久化难题。Spring Boot 的最佳实践是游客购物车存 Redis带 TTL登录后迁移至 DB并触发合并。3.1 游客购物车 Redis 结构设计使用 Hash 存储key 为设备指纹非 UUID避免隐私风险// 基于 UA IP 浏览器特征生成稳定指纹非加密仅去重 public String generateDeviceFingerprint(HttpServletRequest request) { String ua request.getHeader(User-Agent); String ip getClientIp(request); return DigestUtils.md5DigestAsHex((ua ip).getBytes()).substring(0, 16); } // Redis Hash 结构cart:{fingerprint} → {sku_id: quantity, sku_id: quantity} public void addToCart(String fingerprint, Long skuId, Integer quantity) { String cartKey cart: fingerprint; redisTemplate.opsForHash().increment(cartKey, String.valueOf(skuId), quantity); redisTemplate.expire(cartKey, 30, TimeUnit.DAYS); // 长期有效但用户登录即迁移 }3.2 登录后购物车合并逻辑用户登录时调用CartMergeService.merge()Transactional public void mergeCart(String fingerprint, Long userId) { String cartKey cart: fingerprint; MapObject, Object cartItems redisTemplate.opsForHash().entries(cartKey); // 1. 查询用户已有购物车DB ListCartItem userCartItems cartItemRepository.findByUserId(userId); MapLong, CartItem userMap userCartItems.stream() .collect(Collectors.toMap(CartItem::getSkuId, Function.identity())); // 2. 合并游客车中的 SKU若用户车中已存在则累加数量否则新增 for (Map.EntryObject, Object entry : cartItems.entrySet()) { Long skuId Long.parseLong(entry.getKey().toString()); Integer qty Integer.parseInt(entry.getValue().toString()); if (userMap.containsKey(skuId)) { CartItem item userMap.get(skuId); item.setQuantity(item.getQuantity() qty); cartItemRepository.save(item); } else { CartItem newItem new CartItem(); newItem.setUserId(userId); newItem.setSkuId(skuId); newItem.setQuantity(qty); cartItemRepository.save(newItem); } } // 3. 清空游客购物车 redisTemplate.delete(cartKey); }提示getClientIp()必须正确获取真实客户端 IP绕过 Nginx/X-Forwarded-For否则同一 WiFi 下多人共用指纹。合并后立即清空 Redis避免重复合并。3.3 购物车分页查询优化避免 N1 与大字段加载购物车列表需展示商品图、名称、规格、单价、小计但CartItem关联SkuSku又关联Product直接EntityGraph容易查出冗余字段。解决方案是DTO 投影查询Repository public interface CartItemRepository extends JpaRepositoryCartItem, Long { Query(SELECT new com.example.dto.CartItemDTO( c.id, c.skuId, c.quantity, s.image, s.title, s.price, p.brand, p.category) FROM CartItem c JOIN Sku s ON c.skuId s.id JOIN Product p ON s.productId p.id WHERE c.userId :userId) PageCartItemDTO findCartItems(Param(userId) Long userId, Pageable pageable); }CartItemDTO为纯数据载体不含 JPA 注解避免 Hibernate 代理与懒加载陷阱。4. 订单状态机用 Spring State Machine 实现电商订单生命周期精准控制“待支付→已支付→已发货→已完成→已取消”不是简单字段更新而是状态跃迁受严格约束已发货订单不可再取消已评价订单不可退款超时未支付自动关闭。手写if-else易出错Spring State Machine 提供声明式状态流转与事件驱动。4.1 状态与事件定义public enum OrderState { PENDING_PAYMENT, PAID, SHIPPED, COMPLETED, CANCELLED, CLOSED } public enum OrderEvent { PAY, SHIP, COMPLETE, CANCEL, TIMEOUT_CLOSE }4.2 状态机配置限定合法跃迁路径Configuration EnableStateMachineFactory public class OrderStateMachineConfig extends StateMachineConfigurerAdapterOrderState, OrderEvent { Override public void configure(StateMachineConfigurationConfigurerOrderState, OrderEvent config) throws Exception { config .withConfiguration() .autoStartup(true) .listener(stateMachineListener()); } Override public void configure(StateMachineTransitionConfigurerOrderState, OrderEvent transitions) throws Exception { transitions .withExternal() .source(OrderState.PENDING_PAYMENT).target(OrderState.PAID).event(OrderEvent.PAY) .and() .withExternal() .source(OrderState.PAID).target(OrderState.SHIPPED).event(OrderEvent.SHIP) .and() .withExternal() .source(OrderState.SHIPPED).target(OrderState.COMPLETED).event(OrderEvent.COMPLETE) .and() .withExternal() .source(OrderState.PENDING_PAYMENT).target(OrderState.CANCELLED).event(OrderEvent.CANCEL) .and() .withExternal() .source(OrderState.PENDING_PAYMENT).target(OrderState.CLOSED).event(OrderEvent.TIMEOUT_CLOSE); } }注意TIMEOUT_CLOSE事件由定时任务触发如 Quartz 每 5 分钟扫描created_time now - 30min and status PENDING_PAYMENT而非用户操作。所有非法事件如对SHIPPED状态发PAY会被 State Machine 自动拒绝无需额外校验。4.3 状态变更监听同步更新库存与通知Component public class OrderStateListener implements StateMachineListenerOrderState, OrderEvent { Override public void stateChanged(StateOrderState, OrderEvent from, StateOrderState, OrderEvent to) { Long orderId Long.parseLong(to.getStates().iterator().next().getIds().iterator().next().toString()); Order order orderRepository.findById(orderId).orElseThrow(); if (to.getId() OrderState.PAID) { // 支付成功扣减 Redis 库存见 2.2 节 stockDeductService.deduct(order.getSkuId(), order.getQuantity()); } else if (to.getId() OrderState.COMPLETED) { // 完成订单触发会员积分、推送微信模板消息 memberPointService.addPoints(order.getUserId(), calculatePoints(order)); wechatService.sendOrderCompleteTemplate(order); } } }5. 美妆关键词搜索集成 Elasticsearch 实现同义词扩展与敏感词过滤用户搜“迪奥口红”应命中“Dior 唇膏”搜“粉底液”应覆盖“粉底霜”“气垫BB”但“美白”“祛斑”等医疗宣称词需拦截。MySQL 全文索引无法满足Elasticsearch 是唯一合理选型。Spring Boot 通过spring-boot-starter-data-elasticsearch无缝集成。5.1 索引 Mapping 设计为美妆词定制 analyzerPUT /product_index { settings: { analysis: { analyzer: { cosmetic_analyzer: { type: custom, tokenizer: ik_max_word, filter: [lowercase, synonym_filter, sensitive_filter] } }, filter: { synonym_filter: { type: synonym, synonyms: [迪奥, Dior, 口红, 唇膏, 唇彩, 粉底液, 粉底霜, 气垫BB] }, sensitive_filter: { type: stop, stopwords: [美白, 祛斑, 治疗, 根治] } } } }, mappings: { properties: { title: { type: text, analyzer: cosmetic_analyzer }, brand: { type: keyword }, category: { type: keyword }, price: { type: double } } } }提示ik_max_word分词器需提前安装Elasticsearch 插件synonym_filter和sensitive_filter在settings中定义确保搜索时自动替换同义词并过滤违规词。5.2 Spring Data Elasticsearch 查询实现Repository public interface ProductSearchRepository extends ElasticsearchRepositoryProductDoc, String { // 高亮搜索返回带 em 标签的标题 SearchHitsProductDoc searchWithHighlight( String keyword, Pageable pageable ); } Service public class ProductSearchService { Autowired private ProductSearchRepository searchRepository; public PageProductDoc search(String keyword, Integer page, Integer size) { // 移除敏感词双重保险 keyword sensitiveWordFilter.removeSensitiveWords(keyword); NativeSearchQueryBuilder builder new NativeSearchQueryBuilder(); builder.withQuery( QueryBuilders.multiMatchQuery(keyword, title^3, brand^2, category) .type(MultiMatchQueryBuilder.Type.BEST_FIELDS) ); builder.withPageable(PageRequest.of(page, size)); builder.withHighlightFields( new HighlightField.Builder(title).fragmentSize(100).numOfFragments(1).build() ); SearchHitsProductDoc hits searchRepository.search(builder.build()); return convertToPage(hits); // 将 SearchHits 转为 PageProductDoc } }5.3 敏感词实时拦截基于 DFA 算法的轻量级过滤器Elasticsearch 的stopwords仅影响索引与查询分词不阻止用户输入。需在 Controller 层拦截Component public class SensitiveWordFilter { private final TrieNode root new TrieNode(); public SensitiveWordFilter() { // 初始化敏感词树从 DB 或配置文件加载 ListString words Arrays.asList(美白, 祛斑, 治疗, 根治); for (String word : words) { insert(word); } } private void insert(String word) { TrieNode node root; for (char c : word.toCharArray()) { node.children.computeIfAbsent(c, k - new TrieNode()); node node.children.get(c); } node.isEnd true; } public String removeSensitiveWords(String text) { StringBuilder result new StringBuilder(); int i 0; while (i text.length()) { TrieNode node root; int j i; while (j text.length() node.children.containsKey(text.charAt(j))) { node node.children.get(text.charAt(j)); j; } if (node.isEnd) { result.append(***); // 替换为星号 i j; } else { result.append(text.charAt(i)); i; } } return result.toString(); } static class TrieNode { MapCharacter, TrieNode children new HashMap(); boolean isEnd false; } }6. 生产环境关键参数调优与避坑清单从本地启动到压测上线Spring Boot 项目本地跑通不等于生产可用。以下参数直接影响时尚美妆电商的稳定性与体验必须在application-prod.yml中显式配置而非依赖默认值。6.1 JVM 与 Tomcat 参数防止 OOM 与线程耗尽server: tomcat: max-connections: 8192 # 默认 8192足够应对 5000 QPS accept-count: 100 # 连接队列长度避免瞬间洪峰丢包 max-threads: 200 # 线程池上限按 CPU 核数 * 2 1 计算16核→33但电商需更高 min-spare-threads: 50 # 预热线程数减少首次请求延迟 spring: jackson: serialization: write-dates-as-timestamps: false # 返回 ISO8601 时间格式避免前端解析错误 servlet: context-path: /api # 统一 API 前缀便于 Nginx 路由 # JVM 启动参数添加到 java -jar 命令 # -Xms2g -Xmx2g -XX:UseG1GC -XX:MaxGCPauseMillis200 -XX:HeapDumpOnOutOfMemoryError提示max-threads: 200是经过压测验证的阈值。低于 150 时在秒杀活动期间会出现Connection reset by peer高于 250 会导致 GC 频繁。务必配合jstat -gc pid监控。6.2 数据库连接池 HikariCP 关键参数spring: datasource: hikari: maximum-pool-size: 30 # MySQL 默认最大连接数 151此处设 30 避免争抢 minimum-idle: 10 # 空闲连接保底数防止突发流量时创建连接慢 connection-timeout: 30000 # 30秒避免网络抖动导致请求卡死 validation-timeout: 3000 # 3秒健康检查超时 idle-timeout: 600000 # 10分钟空闲连接回收 max-lifetime: 1800000 # 30分钟强制刷新连接避免 MySQL wait_timeout 断连6.3 Elasticsearch 客户端超时与重试spring: elasticsearch: uris: http://es-prod:9200 rest: client: configuration: connect-timeout: 5000 # 连接超时 5s socket-timeout: 10000 # 读取超时 10s max-connect-attempts: 3 # 连接失败重试 3 次6.4 日志分级与异步输出避免 I/O 阻塞主线程!-- logback-spring.xml -- appender nameASYNC_FILE classch.qos.logback.classic.AsyncAppender appender-ref refFILE/ queueSize1024/queueSize discardingThreshold0/discardingThreshold includeCallerDatafalse/includeCallerData /appender logger namecom.example.controller levelINFO additivityfalse appender-ref refASYNC_FILE/ /logger logger nameorg.springframework.web.servlet.DispatcherServlet levelWARN/ logger nameorg.elasticsearch.client.RestHighLevelClient levelERROR/注意AsyncAppender的queueSize设为 1024过小会导致日志丢失过大占用堆内存。DispatcherServlet日志降为 WARN避免 DEBUG 级别打印每个请求路径拖慢性能。6.5 线上验证技巧三步确认搜索与订单功能正常搜索验证curl -X GET http://prod-api/api/search?keyword迪奥口红size10检查响应中highlight字段是否包含em迪奥/em和em口红/em且total大于 0。订单状态验证手动触发POST /api/order/{id}/pay后查 DBSELECT status FROM orders WHERE id ?应为PAID查 RedisGET sku:stock:1001应比之前减 1。库存一致性验证写脚本并发 100 次扣减同一 SKU然后对比SELECT stock FROM skus WHERE id 1001DB 值GET sku:stock:1001Redis 值二者必须相等否则说明 Lua 脚本未生效或未回写 DB。本文还有配套的精品资源点击获取
返回列表