ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

SpringBoot电商平台全栈实战:从订单库存到秒杀优化

2026/10/6 13:35:04 拓冰建站 浏览量
SpringBoot电商平台全栈实战:从订单库存到秒杀优化 简介这是一份基于SpringBoot的电商平台毕业设计完整资料面向计算机相关专业的学生或需要快速搭建电商后端项目的开发者用以解决课程设计、毕业设计选题及实际开发中从零搭建功能模块耗时的问题。压缩包内含1个doc文档大小约4.5MB主要提供毕业设计论文与对应的源码实现方案从课题背景、系统分析到技术选型均有展开覆盖商家管理、商品订单、用户管理、商品管理、商品评价、购物车、订单支付及物流跟踪等核心模块。该方案以Mysql存储数据采用Java与Spring Boot框架实现既适合用于理解电商业务的数据流转与后端分层设计也可作为撰写设计文档的参考范本。目前已有35人学习下载适合需要完整项目方案、论文结构参照或准备答辩讲解的读者。1. SpringBoot电商平台一个能写进简历的全栈闭环但凡做过几个管理系统类的SpringBoot项目再去看电商平台第一反应通常是“这不就是商品、订单、用户三张表来回写吗”。真动手才发现订单状态机、库存扣减、支付回调、并发超卖随便一个点都能让项目在答辩现场翻车。这个标题所指向的正是一个以SpringBoot为核心、包含前后端与数据库设计文档的完整电商系统——它不是一个demo堆砌的CRUD工程而是一个能把“需求分析→表结构设计→接口实现→部署验证”整条链路讲清楚的教学型项目。适合两类人一是准备毕业设计或求职项目、需要一份能讲明白的完整系统的学生二是刚入行、想看看真实电商订单和库存怎么落地的后端开发。这篇笔记就把这类项目的设计思路、核心代码和最容易出问题的边界条件一次讲透。2. 先立框架单体电商的模块边界与数据模型设计2.1 为什么选择单体架构而不是微服务一看到“电商平台”很多人下意识就想到微服务、Spring Cloud、分布式事务。但回到这个标题的定位——设计与实现的完整文档加源码大部分场景是教学、课设、个人项目微服务在这类项目里属于典型的过度设计。微服务带来的服务拆分、注册中心、配置中心、分布式事务会让核心业务逻辑被大量非业务代码淹没读者学到的是“怎么搭基础设施”而不是“电商订单怎么流转”。单体架构在这个体量下反而是最务实的选择一个SpringBoot应用同时承载Web接口、业务逻辑和数据访问部署时一个jar包搞定。模块划分上用包结构做逻辑隔离controller接收HTTP请求做参数校验和结果封装service业务规则订单创建、库存扣减、支付回调处理mapper数据访问MyBatis-Plus的BaseMapper加上自定义SQLentity数据库实体dto / vo入参对象和出参对象避免实体直接暴露给前端这样的划分在答辩时也更容易讲清楚每一层只做一件事调用关系单向向下。等系统真正发展到需要拆分服务时这些包边界就是未来的服务边界不会白做。2.2 核心数据表设计与字段取舍电商平台再怎么复杂核心表就那几张用户、商品、购物车、订单、订单明细、支付流水、库存。但表之间的关联关系和字段设计才是决定项目质量的关键。以订单表为例最常见的错误是只存一个“总金额”不记录下单时的快照信息。实际项目中订单表至少需要这些字段字段类型说明idbigint主键order_novarchar(32)订单号业务唯一user_idbigint下单用户total_amountdecimal(10,2)订单总金额statustinyint订单状态0待支付 1已支付 2已发货 3已完成 4已取消address_snapshotvarchar(500)收货地址快照created_at / updated_atdatetime创建/更新时间地址为什么要存快照因为用户下单后可能修改收货地址如果订单表只存address_id联表查询到的就是修改后的地址物流发货时就会发错地方。商品名称和单价同样要在订单明细表里冗余一份否则商品改价后历史订单的金额就对不上了。这是电商设计里“用冗余换不可变性”的典型思路。数据库层面要明确两点所有金额字段用decimal绝对不用float/double二进制浮点数在金额计算上会有精度损失订单号不要用自增id而是用时间戳加随机数的形式生成避免订单量被猜测也方便分库分表时做全局唯一。2.3 商品-库存-订单的关联设计商品和库存是两张表还是一张表取决于项目定位。教学型项目我建议拆成两张product表和product_stock表。product存放商品基本信息名称、描述、主图、价格product_stock存放sku维度的库存数量。为什么要拆因为商品信息和库存数量的更新频率完全不同商品信息很少变库存每一笔订单都要扣减。拆开后缓存商品信息时不会因为库存的频繁变动导致缓存失效。库存表只保留三个核心字段product_id、sku_id、stock。这里有一个细节不要只存“当前库存”而是要存“总库存”和“锁定库存”两个字段。下单时先锁定库存支付成功后才真正扣减超时未支付则释放锁定。这种两段式库存管理是电商的标准做法能避免用户下单后长时间不支付导致的库存被无效占用。订单明细表order_item则记录订单下每个商品的快照信息商品id、商品名称、下单时的单价、数量、小计。主表算总金额明细表算每项金额两边要能对得上。答辩时如果被问到“对账怎么做”这两张表的金额比对就是最基本的对账逻辑。2.4 建表脚本直接能跑的初始化SQL下面这份SQL是电商平台的最小可用版本覆盖了用户、商品、库存、订单、订单明细五张核心表并加上必要的索引。-- 用户表 CREATE TABLE user ( id bigint NOT NULL AUTO_INCREMENT, username varchar(50) NOT NULL COMMENT 用户名, password varchar(100) NOT NULL COMMENT 密码BCrypt加密后存储, phone varchar(20) DEFAULT NULL COMMENT 手机号, created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表; -- 商品表 CREATE TABLE product ( id bigint NOT NULL AUTO_INCREMENT, name varchar(200) NOT NULL COMMENT 商品名称, description text COMMENT 商品描述, price decimal(10,2) NOT NULL COMMENT 销售单价, main_image varchar(500) DEFAULT NULL COMMENT 主图URL, status tinyint NOT NULL DEFAULT 1 COMMENT 1上架 0下架, created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_status (status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT商品表; -- 库存表 CREATE TABLE product_stock ( id bigint NOT NULL AUTO_INCREMENT, product_id bigint NOT NULL COMMENT 商品id, sku_id varchar(50) NOT NULL COMMENT SKU标识如颜色规格, total_stock int NOT NULL COMMENT 总库存, locked_stock int NOT NULL DEFAULT 0 COMMENT 锁定库存, PRIMARY KEY (id), UNIQUE KEY uk_product_sku (product_id, sku_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT库存表; -- 订单表 CREATE TABLE orders ( id bigint NOT NULL AUTO_INCREMENT, order_no varchar(32) NOT NULL COMMENT 订单号, user_id bigint NOT NULL COMMENT 下单用户id, total_amount decimal(10,2) NOT NULL COMMENT 订单总金额, status tinyint NOT NULL DEFAULT 0 COMMENT 0待支付 1已支付 2已发货 3已完成 4已取消, address_snapshot varchar(500) NOT NULL COMMENT 收货地址快照, created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_user_id (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单表; -- 订单明细表 CREATE TABLE order_item ( id bigint NOT NULL AUTO_INCREMENT, order_id bigint NOT NULL COMMENT 订单id, product_id bigint NOT NULL COMMENT 商品id, product_name varchar(200) NOT NULL COMMENT 商品名称快照, price decimal(10,2) NOT NULL COMMENT 下单时单价快照, quantity int NOT NULL COMMENT 购买数量, subtotal decimal(10,2) NOT NULL COMMENT 小计金额, PRIMARY KEY (id), KEY idx_order_id (order_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单明细表;几个参数说明所有表都使用InnoDB引擎因为订单和库存涉及事务InnoDB支持行级锁和事务回滚MyISAM在这类场景下根本不能用。字符集统一utf8mb4不要为了省空间用utf8——emoji表情和生僻字在utf8mb4下才不会乱码。订单号的唯一索引必须建这是后续对账和防重复支付的第一道防线。用户名的唯一索引是为了注册时防重。库存表的联合唯一索引uk_product_sku保证同一个商品下不会出现重复的SKU记录。3. 用SpringBoot把订单与库存跑通核心接口与事务落地3.1 项目初始化与依赖选型构建这类电商项目我一般用Spring Initializr生成基础工程Java版本选8或11都行SpringBoot版本不要追最新——2.7.x是当前兼容性最稳的版本线。3.x虽然已经发布但很多老教程、旧版本的MyBatis-Plus和代码生成器对3.x的支持还在磨合课设项目没必要在这个时间点上冒险。pom.xml里的核心依赖就四个spring-boot-starter-web、mybatis-plus-boot-starter、mysql-connector-java、lombok。其他的像spring-boot-starter-validation做参数校验、spring-boot-starter-data-redis做缓存和分布式锁都是后话先把基础跑通再加。dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency注意MyBatis-Plus版本不能随便选3.5.3.1之前的版本在SpringBoot 2.7下会有分页插件兼容性告警不影响运行但日志里会有红字。MyBatis-Plus在这里承担的是数据访问层的活BaseMapper提供单表CRUD复杂的多表查询用XML里的自定义SQL写。为什么用它而不是原生MyBatis或JPAMyBatis-Plus对单表操作的代码量最少一张表一个Mapper接口就能覆盖大部分场景还带分页插件适合这类需要快速出成果的项目。JPA虽然在多表关联上写起来更省但它的懒加载和N1查询问题在答辩时容易被深挖不如MyBatis的SQL直白可控。3.2 用户注册的密码处理与登录态设计用户模块是所有其他功能的前提但也是很多项目里最敷衍的部分。最常见的翻车现场是密码明文存数据库这在课设里属于致命伤。正确的做法是用BCrypt加密Spring Security的crypto包里直接有BCryptPasswordEncoder单独引这个工具类就行不需要引入整个Spring Security——那会把简单问题复杂化。Service public class UserServiceImpl implements UserService { Autowired private UserMapper userMapper; private final BCryptPasswordEncoder encoder new BCryptPasswordEncoder(); /** * 注册用户名唯一校验 BCrypt加密 */ public boolean register(String username, String password) { // 1. 查重 Long count userMapper.selectCount( new LambdaQueryWrapperUser().eq(User::getUsername, username)); if (count 0) { throw new BusinessException(用户名已存在); } // 2. 加密入库 User user new User(); user.setUsername(username); user.setPassword(encoder.encode(password)); return userMapper.insert(user) 0; } /** * 登录校验通过后返回简单token */ public String login(String username, String rawPassword) { User user userMapper.selectOne( new LambdaQueryWrapperUser().eq(User::getUsername, username)); if (user null || !encoder.matches(rawPassword, user.getPassword())) { throw new BusinessException(用户名或密码错误); } // 用UUID生成一个token存到Rediskey为tokenvalue为userId String token UUID.randomUUID().toString().replace(-, ); redisTemplate.opsForValue().set(login:token: token, String.valueOf(user.getId()), 30, TimeUnit.DAYS); return token; } }这段代码里有几个关键点。BCryptPasswordEncoder每次encode同一个密码会生成不同的密文所以校验时必须用matches方法而不是把数据库里的密文拿出来再加密一次比较。登录成功后生成的token写入Redis设置30天有效期这样一个token就是一个会话后端可以随时通过Redis查询或删除它这比在JWT里塞用户信息更可控——JWT一旦签发就无法主动失效面临“退出登录但token还能用”的尴尬局面。3.3 下单流程事务与库存扣减的同步锁方案下单是整个电商系统最核心的接口接口要做的事拆开来看有几件校验商品是否存在且上架、校验购买数量是否合法、扣减库存、生成订单主表和明细表、清除购物车中对应的商品。这些操作必须在一个事务里任何一个环节失败都要全部回滚不然就会出现“订单生成了但库存没扣”或者“库存扣了但订单没生成”的数据不一致。Transactional(rollbackFor Exception.class) public OrderVO createOrder(Long userId, ListCartItem cartItems) { // 1. 参数校验 if (cartItems null || cartItems.isEmpty()) { throw new BusinessException(购物车不能为空); } // 2. 生成订单号时间戳 用户id后四位 随机数 String orderNo generateOrderNo(userId); Order order new Order(); order.setOrderNo(orderNo); order.setUserId(userId); BigDecimal totalAmount BigDecimal.ZERO; ListOrderItem orderItems new ArrayList(); // 3. 遍历购物车项逐项校验并扣库存 for (CartItem item : cartItems) { // 3.1 查询商品加悲观锁 Product product productMapper.selectByIdForUpdate(item.getProductId()); if (product null || product.getStatus() ! 1) { throw new BusinessException(商品不存在或已下架); } // 3.2 查询库存并扣减 ProductStock stock stockMapper.selectByProductIdForUpdate(item.getProductId()); if (stock.getTotalStock() - stock.getLockedStock() item.getQuantity()) { throw new BusinessException(库存不足: product.getName()); } stock.setLockedStock(stock.getLockedStock() item.getQuantity()); stockMapper.updateById(stock); // 3.3 构建订单明细 OrderItem orderItem new OrderItem(); orderItem.setProductId(product.getId()); orderItem.setProductName(product.getName()); orderItem.setPrice(product.getPrice()); orderItem.setQuantity(item.getQuantity()); orderItem.setSubtotal(product.getPrice().multiply(BigDecimal.valueOf(item.getQuantity()))); totalAmount totalAmount.add(orderItem.getSubtotal()); orderItems.add(orderItem); } // 4. 保存订单和明细 order.setTotalAmount(totalAmount); order.setStatus(0); orderMapper.insert(order); for (OrderItem item : orderItems) { item.setOrderId(order.getId()); orderItemMapper.insert(item); } return convertToVO(order, orderItems); }代码里的selectByIdForUpdate和selectByProductIdForUpdate是重点它们对应的是MySQL的SELECT ... FOR UPDATE语句。在事务内对商品和库存记录加行级排他锁另一个并发事务要操作同一条记录时必须等当前事务提交才能继续。这是最简单的防超卖方案逻辑直观答辩时也很好解释。代价是并发性能差但在课设和中小型项目里完全够用——数据库锁等待通常不到1毫秒只有真正发生并发写时才需要等。事务注解上的rollbackFor Exception.class是另一个容易忽略的细节。Spring默认只对RuntimeException回滚如果业务代码里抛出的是Exception的子类比如自定义的BusinessException继承RuntimeException还好但如果是继承Exception就不回滚了库存就扣了但订单没建。显式声明rollbackFor Exception.class能避免这类玄学问题。3.4 支付回调处理幂等性与状态机的不可逆流转支付模块在真实项目中对接的是微信支付或支付宝回调接口是他们服务器主动请求我们的后端。但在课设项目中没有真实的支付渠道可用通常的做法是提供一个模拟支付的接口——用户点击“模拟支付成功”后端直接把订单标记为已支付。虽然简化了但回调接口的幂等性设计不能省。PostMapping(/api/pay/callback) public PayResult payCallback(RequestBody PayCallbackRequest request) { // 1. 根据商户订单号查询订单 Order order orderMapper.selectOne( new LambdaQueryWrapperOrder().eq(Order::getOrderNo, request.getOrderNo())); if (order null) { return PayResult.error(订单不存在); } // 2. 幂等校验订单已支付则直接返回成功不能重复处理 if (order.getStatus() 1) { return PayResult.success(重复回调已忽略); } // 3. 校验支付金额和订单金额是否一致 if (request.getAmount().compareTo(order.getTotalAmount()) ! 0) { return PayResult.error(金额不一致); } // 4. 更新订单状态待支付 - 已支付 order.setStatus(1); orderMapper.updateById(order); // 5. 真正扣减库存从锁定库存转为已扣减 ListOrderItem orderItems orderItemMapper.selectList( new LambdaQueryWrapperOrderItem().eq(OrderItem::getOrderId, order.getId())); for (OrderItem item : orderItems) { ProductStock stock stockMapper.selectByProductIdForUpdate(item.getProductId()); stock.setLockedStock(stock.getLockedStock() - item.getQuantity()); stock.setTotalStock(stock.getTotalStock() - item.getQuantity()); stockMapper.updateById(stock); } return PayResult.success(支付成功); }第2步的幂等校验是这个接口的灵魂。支付渠道的回调机制是“不成功就反复重试”如果没有幂等判断第一次回调把订单改成已支付第二次回调进来会再扣一次库存超卖就发生了。第5步“锁定库存转实际扣减”设计上是两阶段下单时只锁定库存不让别人买支付成功才真正从总库存里扣除。如果用户下单后不支付订单超时关闭时只需要把locked_stock减回去total_stock不受影响。4. 秒杀场景下的并发优化从数据库锁到Redis预扣减4.1 单体数据库锁的性能上限在哪里如果项目文档里写了“支持高并发秒杀”那纯靠数据库行锁的实现就会被质疑。SELECT ... FOR UPDATE能保证数据正确性但它的性能瓶颈很明显每一条订单都要持锁直到事务结束事务平均耗时50毫秒的话数据库的并发上限也就每秒20笔左右。更麻烦的是MySQL在高并发下对锁等待的吞吐能力会急剧下降连接池一满整个系统的接口都跟着卡。这不代表课设项目不能做秒杀而是要做分层优化。常见的做法是引入Redis做两层处理第一层是请求进来先到Redis里做库存预扣减挡住大部分无效请求第二层才是真正落库把Redis扣减成功的那部分请求放进队列或者直接异步写库。Redis是单线程模型INCR和DECR操作天然线程安全不存在并发扣减超卖的问题。4.2 Redis原子扣减Lua脚本把并发挡在数据库之前先看一段用Redis做库存预扣减的代码顺便说一下为什么用Lua脚本而不用单纯的DECR。public boolean preDeductStock(Long productId, Integer quantity) { // 1. 拼装Lua脚本 String luaScript local stock redis.call(get, KEYS[1]) if not stock then return -1 end if tonumber(stock) tonumber(ARGV[1]) then return 0 end return redis.call(decrby, KEYS[1], ARGV[1]); // 2. 执行脚本 Long result redisTemplate.execute( new DefaultRedisScriptLong(luaScript, Long.class), Arrays.asList(seckill:stock: productId), quantity.toString() ); // 3. 判断结果-1表示redis里没这个key0表示库存不足0表示扣减成功 return result ! null result 0; }Lua脚本在这里的价值是原子性。Redis执行Lua脚本是单线程的整个脚本执行期间不可能插入其他命令所以脚本内部的“查库存→比较→扣减”三步骤不会被并发请求打断。如果不用Lua而分三步去写——先get、再判断、再decr——在并发下就会有空隙两个请求同时get到库存是1同时判断可以通过同时decr最终库存变成-1。Redis里的库存初始化和数据库同步是另一个要点。秒杀开始前需要把数据库中的库存数加载到Redispublic void initSeckillStock(Long productId, Integer totalStock) { // 用setnx只有key不存在时才设置成功避免重复初始化覆盖已有值 Boolean success redisTemplate.opsForValue() .setIfAbsent(seckill:stock: productId, totalStock.toString()); if (Boolean.TRUE.equals(success)) { // 设置过期时间防止活动结束后key残留 redisTemplate.expire(seckill:stock: productId, Duration.ofHours(1)); } }用setIfAbsent而不是直接set是为了防止秒杀已经开始后有人误操作触发了重新初始化把已扣减的库存覆盖回初始值。4.3 异步落库与最终一致性秒杀后的订单怎么写Redis预扣减只是把“谁能买”的问题解决了真正的订单还是要写进MySQL。这里有两种做法同步写和异步写。同步写在低并发下没问题秒杀场景下建议异步——Redis扣减成功后就立即给前端返回“抢购成功”然后把用户id和商品id丢进消息队列由消费者去创建订单、写数据库。这样每个秒杀请求的响应时间不受数据库写入速度影响。Component public class SeckillOrderConsumer { Autowired private OrderService orderService; RabbitListener(queues seckill.order.queue) public void handleSeckillOrder(SeckillMessage message) { try { // 异步创建订单 orderService.createSeckillOrder(message.getUserId(), message.getProductId()); } catch (Exception e) { // 创建失败要补偿把预扣减的库存加回Redis并记录失败的userId log.error(秒杀订单创建失败userId: {}, productId: {}, message.getUserId(), message.getProductId(), e); redisTemplate.opsForValue() .increment(seckill:stock: message.getProductId(), message.getQuantity()); // 记录到失败表后续人工补偿 } } }这里有个一致性细节如果订单创建失败一定要把Redis里预扣减的库存加回去。不然会出现用户看到“抢购成功”但订单里什么都没有库存也永久少了。最终一致性的思想就是允许短暂的不一致但在一定时间窗口内必须对齐。秒杀场景下Redis库存可以比数据库库存“虚低”有人抢到但没支付但不能比数据库库存“虚高”订单没建但库存没了。4.4 秒杀接口的其他必调参数除了库存扣减秒杀接口还有几个容易被忽略的参数。Redis连接池的maxTotal和maxIdle要按预估峰值流量调默认的8个连接在秒杀场景下根本不够通常要调到50以上否则大量请求会阻塞在连接池获取上。接口的超时时间要单独设置秒杀接口不能跟着全局的3秒超时走Redis预扣减通常20毫秒内就能返回超时设置200毫秒比较合理——这样一旦Redis有问题请求快速失败不会拖垮整个应用。还有一个非常关键但经常被忽视的参数限流。秒杀接口需要加上简单的令牌桶或计数器限流比如单机每秒最多处理1000个请求多余的直接返回“活动太火爆”。不然Redis再快应用服务器的线程池也会被请求打满CPU飙升到100%连带其他接口一起不可用。写进项目文档里这会是答辩时的一个加分项。5. 电商项目避坑指南从启动失败到数据不一致的排查清单5.1 数据库连接失败时区报错和SSL告警现象SpringBoot启动时报java.sql.SQLException: The server time zone value Öйú±ê׼ʱ¼ä is unrecognized或者控制台出现大量SSL连接告警。原因MySQL 8.0及以上版本默认使用UTC时区而本机系统是东八区同时MySQL 8.0默认开启SSL认证JDBC连接时没有显式关闭就会反复握手。解决在application.yml的JDBC连接串里显式指定时区和SSL参数。spring: datasource: url: jdbc:mysql://localhost:3306/mall?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue username: root password: yourpassword driver-class-name: com.mysql.cj.jdbc.DriverallowPublicKeyRetrievaltrue这个参数也很关键MySQL 8.0的默认认证插件是caching_sha2_password首次连接时如果没有它会报Public Key Retrieval is not allowed。这三个参数是MySQL 8.0连接的三件套少一个都起不来。5.2 接口返回的JSON出现无限递归现象查询订单时接口报StackOverflowError或者返回的JSON数据里有无限嵌套的order: {user: {orders: [...]}}。原因JPA或MyBatis-Plus关联查询时实体类中加了OneToMany/ManyToOne双向关联注解Jackson序列化时在对象引用链上循环套娃。解决最省事的方案是尽量避免在实体类中直接写关联关系查询时直接用VO类承接多表查询结果实体类保持扁平。如果一定要用关联注解在关系的某一端加JsonIgnore。以MyBatis-Plus为例其实根本不建议在实体里做关联——Mapper层写一个连表查询结果映射到VO干净利落。5.3 事务失效库存扣了但订单没建现象日志里能看到扣减库存的SQL执行了但订单表里没有记录。排查发现事务方法里的异常被吞掉了或者事务没有生效。原因有三个高频原因。第一方法被this调用而不是通过Spring代理调用——同一个类里的方法直接互相调用事务注解不生效这属于Spring AOP的经典坑第二Transactional加在了非public方法上第三异常被catch后事务感知不到异常没法回滚。解决事务方法放到Service接口的实现里且必须是public调用方注入Service接口而不是直接new一个实现类catch异常后如果不需要吞掉就重新抛出或者手动调用TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。最稳妥的排查方式是打断点在事务方法入口看进入方法时有没有经过Spring的代理对象。5.4 前后端联调时接口返回的字段是null现象前端拿到订单数据后展示页面上全是空值后端接口返回的JSON中status、totalAmount等字段全部为null。原因MyBatis-Plus的驼峰映射默认开启但如果数据库字段是total_amount、created_at这种下划线命名而实体属性是totalAmount、createdAt并且application.yml里没有配置map-underscore-to-camel-case: true映射就会失败。另一个可能是返回的VO里没有getter方法。解决把MyBatis-Plus配置补齐。mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpllog-impl这个配置建议在开发阶段一直开着SQL日志会直接打印在控制台字段有没有映射上、SQL长什么样一目了然。生产环境再关掉不然日志量太大。5.5 并发压测时库存变成负数现象用JMeter开100个线程同时下单同一个商品结束后发现库存表的total_stock变成了-5明显超卖了。原因代码里用的是“先查库存再判断再扣减”的三步操作三步之间没有加锁。10个线程同时查到库存是8同时判断80通过同时执行set total_stock total_stock - 1MySQL最终只减了1但10个订单都创建成功了。如果是在扣减时用了update ... set total_stock total_stock - 1这种原子SQL结果就是库存变成-2因为没有判断条件。解决两种方案。第一种是带条件的原子更新SQL里带上库存余量判断UPDATE product_stock SET total_stock total_stock - #{quantity} WHERE product_id #{productId} AND total_stock - locked_stock #{quantity}受影响行数为0就说明库存不足。第二种是前面说的加锁方案SELECT ... FOR UPDATE锁住记录再操作。两种方案都行但一定要注意原子更新方案不能和“先查后改”混着用否则还是会超卖。6. 上线前的最后一公里压测验收与部署验证技巧项目写完只是第一步能不能在答辩现场和演示环境里稳定跑起来是另一回事。我自己的习惯是在提交文档和代码前花一个下午做三件事接口压测、数据一致性校验、生产模式部署演练。压测这步不用上JMeter那么重的东西直接在IDEA里装上JMeter插件或者用一个简单的Python脚本并发请求下单接口就够了。重点关注两个数字一是吞吐量二是错误率。如果100个并发请求下单同一个商品错误率超过5%就说明代码里有明显的锁竞争或者连接池配置问题。压测发现超卖不要慌按第5章的方式排查先看SQL日志里的执行顺序再确认锁有没有正确加上。数据一致性校验这块写一段简单的SQL把订单明细和订单主表对一遍-- 校验订单总金额 明细小计之和 SELECT o.id, o.total_amount, SUM(oi.subtotal) AS calc_amount FROM orders o JOIN order_item oi ON oi.order_id o.id GROUP BY o.id, o.total_amount HAVING o.total_amount ! calc_amount;如果有返回结果就是金额计算在某条路径上出了偏差。再跑一条查库存的校验把product_stock里的total_stock和order_item中已支付订单的数量加总做对比。这两条SQL是每次演示前的固定体检项目花不了两分钟但能拦住大部分低级错误。部署验证是最后一个环节。SpringBoot项目打包成jar后用java -jar mall.jar --spring.profiles.activeprod启动检查三件事外部配置文件是否生效、静态资源路径是否正确、数据库连接串是否指向了生产库。很多项目在IDEA里跑得好好的一打包部署就各种404和500基本都出在这三处。用curl打一遍核心接口确认HTTP状态码都是200再从前端页面走一遍完整的购物流程——注册、登录、浏览商品、加购物车、下单、模拟支付、查看订单整个闭环走通才算验收合格。每个项目到最后都会发现几个自己拍脑袋想当然的地方比如我第一次做电商项目时想当然地以为订单金额用double算没问题直到对账时发现0.1加0.2不等于0.3才老老实实把所有金额改成BigDecimal。这种坑不在代码量里在每一个细节的较真上。希望这篇笔记能帮你少走几段弯路。本文还有配套的精品资源点击获取