简介这份资源面向具备一定Java基础、希望系统掌握电商平台开发的学习者与开发者围绕Spring与Spring Boot技术栈完整呈现一个电商系统的构建思路与工程结构。内容涵盖数据库连接与ORM持久化、RESTful API设计、Spring Security安全控制、消息队列异步处理、Redis缓存管理、前后端分离架构以及Spring Cloud微服务方案并涉及Actuator监控与单元测试等工程实践帮助读者理解电商场景下的架构选型与组件协作。压缩包共1066个文件约14.36MB以231个js、186个java、121个xml、49个ftl模板、46个css及122张jpg图片为主另含properties配置、sql脚本与jar依赖覆盖前后端与配置资源。目前已有1530人学习下载适合作为电商项目实战参考与代码结构研读素材。1. 从单体到微服务Java Spring 电商平台到底该怎么搭很多开发者第一次接触“Java Spring开发电商完整平台”时脑子里浮现的是一套能跑通商品、订单、支付、库存的代码。但真正动手才发现难点不在写 CRUD而在“怎么拆、怎么连、怎么保证不超卖”。我见过不少团队用 Spring Boot 把功能堆在一个单体里上线后订单和库存对不上排查三天才发现是并发扣减没加锁。电商平台的核心诉求就三个高并发下的数据一致性、模块之间的清晰边界、以及可观测的排错链路。这篇文章面向已经会写 Spring Boot 但没完整落地过电商系统的后端开发者也适合想从单体迁移到微服务架构的团队参考。我会按“先定架构、再写核心链路、最后补坑”的顺序把商品、订单、库存、支付这几个模块的落地细节讲透代码可以直接抄参数可以按你的业务量调整。2. 架构选型与模块拆分为什么我不建议一上来就上微服务2.1 单体优先还是微服务优先按团队规模做决策电商平台的技术选型没有绝对答案但有一条铁律团队人数少于 8 人、日订单量低于 5 万单时优先做模块化单体。我见过一个 5 人团队硬上 Spring Cloud结果服务拆了 12 个光是 Nacos 配置同步和 Feign 超时排查就耗掉一半开发时间业务迭代反而变慢。模块化单体的做法是一个 Spring Boot 应用用 Maven 多模块或 Gradle 子项目把product、order、inventory、payment拆成独立 package每个模块只暴露 Service 接口给其他模块调用禁止跨模块直接访问对方的 Mapper。这样后期要拆微服务时边界已经天然清晰。判断是否该拆微服务的三个信号第一不同模块的并发量差异超过 10 倍比如商品查询 QPS 5000 而订单写入只有 200第二团队超过 15 人代码合并冲突频繁第三某个模块需要独立扩容或独立数据库。满足任意两条再考虑拆。2.2 用 Spring Initializr 搭出可运行的多模块骨架先建一个父工程再建四个子模块。父pom.xml只做依赖版本管理不写业务代码。!-- 父 pom.xml 关键片段 -- parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version3.2.0/version /parent modules moduleproduct-service/module moduleorder-service/module moduleinventory-service/module modulepayment-service/module /modules properties java.version17/java.version spring-cloud.version2023.0.0/spring-cloud.version /properties每个子模块的pom.xml引入spring-boot-starter-web、spring-boot-starter-validation、mybatis-plus-boot-starter。注意不要在每个模块里都引入spring-boot-starter-test的完整包只引spring-boot-starter-test的mockito-core和junit-jupiter即可否则测试启动会加载全部上下文单测跑一次要 40 秒。参数说明java.version用 17 是因为 Spring Boot 3.x 最低要求 17且虚拟线程在 21 才稳定生产环境建议 17 或 21。spring-cloud.version只在需要拆微服务时才加单体阶段可以去掉。2.3 模块间通信接口下沉与事件解耦模块化单体里订单模块需要调用库存模块扣减库存。常见做法是订单模块直接Autowired库存模块的InventoryService但这会造成强耦合。我一般会定义一个common-api模块里面只放接口和 DTO// common-api 模块中的接口定义 public interface InventoryFacade { /** * 扣减库存 * param skuId 商品SKU编号 * param quantity 扣减数量必须大于0 * return 扣减结果包含剩余库存 */ DeductResult deduct(Long skuId, Integer quantity); }订单模块只依赖common-api库存模块实现这个接口。这样后期拆微服务时把InventoryFacade的实现换成 Feign 客户端即可订单模块代码几乎不用改。对于非实时性要求高的操作比如订单完成后发短信、更新积分用 Spring 的ApplicationEventPublisher发本地事件监听器异步处理避免主链路被拖慢。3. 商品与库存从 SPU/SKU 设计到防超卖3.1 SPU 与 SKU 的表结构设计电商商品模型的核心是 SPU标准产品单位和 SKU库存量单位。比如一部手机是 SPU颜色内存组合是 SKU。表结构这样设计-- SPU 表 CREATE TABLE spu ( id bigint NOT NULL AUTO_INCREMENT, name varchar(128) NOT NULL COMMENT 商品名称, category_id bigint NOT NULL COMMENT 分类ID, status tinyint NOT NULL DEFAULT 0 COMMENT 0-下架 1-上架, PRIMARY KEY (id), KEY idx_category (category_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- SKU 表 CREATE TABLE sku ( id bigint NOT NULL AUTO_INCREMENT, spu_id bigint NOT NULL, specs json NOT NULL COMMENT 规格JSON如{颜色:黑,内存:256G}, price decimal(10,2) NOT NULL, stock int NOT NULL DEFAULT 0, version int NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, PRIMARY KEY (id), KEY idx_spu (spu_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;specs用 JSON 类型而不是拆成多列是因为不同类目的规格差异大JSON 更灵活。但注意JSON 字段不能直接建普通索引如果要用规格筛选得在应用层做或额外建冗余字段。version字段是给乐观锁用的后面防超卖会用到。3.2 库存扣减的三种方案与选型库存扣减是电商最容易翻车的地方。常见三种方案方案实现方式适用场景缺点数据库乐观锁UPDATE sku SET stockstock-1, versionversion1 WHERE id? AND version? AND stock1并发低于 1000 TPS冲突高时重试多Redis 预扣减Lua 脚本原子扣减异步落库高并发秒杀需处理 Redis 与 DB 一致性数据库悲观锁SELECT ... FOR UPDATE并发低但要求强一致锁等待严重我一般会日常销售用乐观锁秒杀场景用 Redis 预扣减。乐观锁的代码// InventoryServiceImpl.java Transactional(rollbackFor Exception.class) public DeductResult deduct(Long skuId, Integer quantity) { // 重试3次每次间隔50ms for (int i 0; i 3; i) { Sku sku skuMapper.selectById(skuId); if (sku.getStock() quantity) { throw new BizException(库存不足); } int affected skuMapper.deductStock(skuId, quantity, sku.getVersion()); if (affected 0) { return new DeductResult(sku.getStock() - quantity); } // 版本冲突短暂等待后重试 try { Thread.sleep(50); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } throw new BizException(系统繁忙请重试); }对应的 Mapper SQLupdate iddeductStock UPDATE sku SET stock stock - #{quantity}, version version 1 WHERE id #{skuId} AND version #{version} AND stock #{quantity} /update逻辑说明先查当前库存和版本号再用version做条件更新。如果更新影响行数为 0说明版本号被其他线程改了重试。参数quantity必须大于 0在 Controller 层用Min(1)校验。重试次数设 3 次是经验值超过 3 次说明冲突率太高应该考虑换 Redis 方案。3.3 用 Redis Lua 脚本扛住秒杀流量秒杀场景下乐观锁的重试会打满数据库连接。做法是把库存预热到 Redis用 Lua 脚本原子扣减-- deduct_stock.lua -- KEYS[1]: 库存key如 sku:stock:1001 -- ARGV[1]: 扣减数量 local stock redis.call(GET, KEYS[1]) if not stock then return -1 -- 库存未预热 end if tonumber(stock) tonumber(ARGV[1]) then return -2 -- 库存不足 end redis.call(DECRBY, KEYS[1], ARGV[1]) return tonumber(stock) - tonumber(ARGV[1])Java 调用public Long deductByRedis(Long skuId, Integer quantity) { String key sku:stock: skuId; DefaultRedisScriptLong script new DefaultRedisScript(); script.setScriptSource(new ResourceScriptSource(new ClassPathResource(lua/deduct_stock.lua))); script.setResultType(Long.class); Long result redisTemplate.execute(script, Collections.singletonList(key), quantity); if (result -1) throw new BizException(库存未预热); if (result -2) throw new BizException(库存不足); return result; }注意Redis 扣减成功后必须发消息到 MQ 异步落库并且要有对账任务补偿。不要直接在扣减后同步写数据库否则 Redis 的高并发优势就没了。对账任务每 5 分钟扫描一次 Redis 库存和 DB 库存的差值超过阈值就告警。4. 订单与支付状态机、幂等与分布式事务4.1 订单状态机设计别用 if-else 堆状态订单状态流转是电商最复杂的逻辑之一。常见状态待支付、已支付、待发货、已发货、已完成、已取消、退款中。如果用 if-else 判断代码会变成意大利面条。我一般用状态机public enum OrderStatus { PENDING_PAYMENT(0, 待支付), PAID(1, 已支付), SHIPPED(2, 已发货), COMPLETED(3, 已完成), CANCELLED(4, 已取消), REFUNDING(5, 退款中); private final int code; private final String desc; // 构造器和 getter 省略 // 定义允许的流转 private static final MapOrderStatus, SetOrderStatus TRANSITIONS Map.of( PENDING_PAYMENT, Set.of(PAID, CANCELLED), PAID, Set.of(SHIPPED, REFUNDING), SHIPPED, Set.of(COMPLETED), REFUNDING, Set.of(CANCELLED, COMPLETED) ); public boolean canTransitionTo(OrderStatus target) { return TRANSITIONS.getOrDefault(this, Collections.emptySet()).contains(target); } }在更新订单状态时先查当前状态调用canTransitionTo校验不合法就抛异常。这样所有状态流转规则集中在一处新增状态时只改枚举不用翻遍 Service 代码。4.2 支付回调的幂等处理防止重复加钱支付回调是外部系统触发的网络抖动会导致重复通知。如果回调里直接“更新订单状态为已支付”重复通知就会重复发货。幂等做法用支付流水号做唯一索引。CREATE TABLE payment_record ( id bigint NOT NULL AUTO_INCREMENT, order_no varchar(64) NOT NULL, trade_no varchar(64) NOT NULL COMMENT 外部支付流水号, amount decimal(10,2) NOT NULL, status tinyint NOT NULL DEFAULT 0, PRIMARY KEY (id), UNIQUE KEY uk_trade_no (trade_no) ) ENGINEInnoDB;回调处理逻辑Transactional(rollbackFor Exception.class) public void handlePaymentCallback(PaymentCallbackDTO dto) { // 1. 插入支付记录唯一索引冲突则说明已处理 try { PaymentRecord record new PaymentRecord(); record.setOrderNo(dto.getOrderNo()); record.setTradeNo(dto.getTradeNo()); record.setAmount(dto.getAmount()); paymentRecordMapper.insert(record); } catch (DuplicateKeyException e) { log.warn(重复回调tradeNo{}, dto.getTradeNo()); return; // 直接返回不重复处理 } // 2. 更新订单状态 orderService.markPaid(dto.getOrderNo()); // 3. 通知库存模块扣减真实库存 inventoryFacade.deduct(dto.getSkuId(), dto.getQuantity()); }参数说明trade_no是外部支付平台的流水号必须全局唯一。DuplicateKeyException是 Spring 对唯一索引冲突的封装捕获后直接返回保证幂等。注意插入支付记录和更新订单状态必须在同一个事务里否则插入成功但订单更新失败会导致用户付了钱订单还是待支付。4.3 分布式事务本地消息表 定时补偿订单支付成功后要通知库存扣减、积分增加。如果这些操作跨服务就需要分布式事务。我一般用本地消息表方案不引入 Seata 这类重组件CREATE TABLE local_message ( id bigint NOT NULL AUTO_INCREMENT, biz_type varchar(32) NOT NULL COMMENT 业务类型如ORDER_PAID, biz_id varchar(64) NOT NULL COMMENT 业务ID如订单号, payload json NOT NULL COMMENT 消息内容, status tinyint NOT NULL DEFAULT 0 COMMENT 0-待发送 1-已发送 2-已消费, retry_count int NOT NULL DEFAULT 0, next_retry_time datetime NOT NULL, PRIMARY KEY (id), KEY idx_status_retry (status, next_retry_time) ) ENGINEInnoDB;在订单支付的事务里同时插入一条local_message。然后有一个定时任务每 10 秒扫描status0的消息发送到 MQ发送成功更新为 1。消费方处理成功后回调更新为 2。如果消费失败retry_count加 1next_retry_time按指数退避设置10s、30s、60s、300s。超过 5 次告警人工介入。5. 避坑与排查那些让我加班到凌晨的坑5.1 坑一Transactional 自调用失效现象在同一个 Service 类里方法 A 调用方法 B方法 B 加了Transactional但 B 抛异常后 A 的事务没有回滚。原因Spring 的Transactional基于 AOP 代理自调用不走代理所以 B 的事务注解不生效。解决把 B 方法抽到另一个 Service 类或者注入自身代理Autowired private OrderService self;然后self.methodB()调用。更推荐前者代码更清晰。5.2 坑二MyBatis-Plus 乐观锁插件没配现象手动写了version字段的更新 SQL但并发下还是超卖。原因MyBatis-Plus 的OptimisticLockerInnerInterceptor没注册或者实体类version字段没加Version注解。解决在配置类里注册拦截器Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor()); return interceptor; }实体类字段加Version private Integer version;。注意乐观锁只对updateById和update方法生效自定义 SQL 需要自己处理 version 条件。5.3 坑三Redis 库存扣减后 DB 落库丢失现象秒杀时 Redis 扣减成功但 DB 库存没变导致超卖。原因异步落库的消息丢了或者消费者处理失败没有重试。解决本地消息表 定时对账。对账任务每 5 分钟对比 Redis 和 DB 的库存差值差值超过 10 就告警。同时消费者必须手动 ACK处理失败时nack并重新入队重试 3 次后进死信队列。5.4 坑四Feign 超时导致重复下单现象用户点击下单Feign 调用库存服务超时但库存实际扣减成功用户重试后重复下单。原因Feign 默认超时 1 秒网络抖动时容易触发。且下单接口没有做幂等。解决下单接口用order_no做唯一索引重复插入直接返回已有订单。Feign 超时时间调到 3 秒并配置重试Retryer.Default(100, 1000, 3)。但注意写操作不要自动重试只对读操作重试否则会重复扣库存。5.5 坑五JSON 字段查询不走索引现象商品列表按规格筛选查询越来越慢。原因specs字段是 JSON 类型WHERE specs-$.颜色 黑无法走索引。解决如果规格筛选是高频操作把常用规格抽成独立列比如color、memory建普通索引。或者用 Elasticsearch 做商品搜索MySQL 只存详情。6. 进阶技巧用 Arthas 定位线上库存扣减异常线上问题排查最怕“日志没打全”。Arthas 是阿里开源的 Java 诊断工具能在不重启应用的情况下查看方法调用、参数、返回值。我一般用它来定位库存扣减的玄学问题。先启动 Arthas 挂到目标进程java -jar arthas-boot.jar # 选择对应的 Java 进程编号然后监控InventoryServiceImpl的deduct方法watch com.example.inventory.service.InventoryServiceImpl deduct {params, returnObj, throwExp} -x 3 -n 5参数说明-x 3表示展开层级为 3-n 5表示只监控 5 次调用。这样能看到每次扣减的入参、返回值和异常。如果发现quantity是负数说明上游校验漏了如果throwExp是BizException且消息是“库存不足”但 DB 里库存明明够那可能是 Redis 和 DB 不一致。更进阶的用法是trace命令追踪整个调用链的耗时trace com.example.order.service.OrderServiceImpl createOrder -n 3 --skipJDKMethod false这会输出createOrder方法内部每个子调用的耗时一眼就能看出是库存扣减慢还是订单插入慢。我上次遇到一个订单创建要 8 秒的问题用trace发现是paymentService.getPayUrl()调外部接口超时加了熔断降级后降到 200ms。最后说一个血泪教训任何涉及钱和库存的操作必须打全日志。日志里要有订单号、用户ID、SKU、数量、前后库存值、时间戳。别等出了问题再临时加日志那时候用户已经在投诉了。我现在的习惯是写库存扣减代码时先把日志语句写好再写业务逻辑。希望帮到你。本文还有配套的精品资源点击获取