news 2026/10/6 13:34:53

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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加上自定义SQL
  • entity:数据库实体
  • 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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> <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>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency>

注意MyBatis-Plus版本不能随便选,3.5.3.1之前的版本在SpringBoot 2.7下会有分页插件兼容性告警,不影响运行但日志里会有红字。MyBatis-Plus在这里承担的是数据访问层的活,BaseMapper提供单表CRUD,复杂的多表查询用XML里的自定义SQL写。为什么用它而不是原生MyBatis或JPA?MyBatis-Plus对单表操作的代码量最少,一张表一个Mapper接口就能覆盖大部分场景,还带分页插件,适合这类需要快速出成果的项目。JPA虽然在多表关联上写起来更省,但它的懒加载和N+1查询问题在答辩时容易被深挖,不如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 LambdaQueryWrapper<User>().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 LambdaQueryWrapper<User>().eq(User::getUsername, username)); if (user == null || !encoder.matches(rawPassword, user.getPassword())) { throw new BusinessException("用户名或密码错误"); } // 用UUID生成一个token,存到Redis,key为token,value为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, List<CartItem> 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; List<OrderItem> 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 LambdaQueryWrapper<Order>().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. 真正扣减库存:从锁定库存转为已扣减 List<OrderItem> orderItems = orderItemMapper.selectList( new LambdaQueryWrapper<OrderItem>().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 DefaultRedisScript<Long>(luaScript, Long.class), Arrays.asList("seckill:stock:" + productId), quantity.toString() ); // 3. 判断结果:-1表示redis里没这个key,0表示库存不足,>0表示扣减成功 return result != null && result > 0; }

Lua脚本在这里的价值是原子性。Redis执行Lua脚本是单线程的,整个脚本执行期间不可能插入其他命令,所以脚本内部的“查库存→比较→扣减”三步骤不会被并发请求打断。如果不用Lua而分三步去写——先get、再判断、再decr——在并发下就会有空隙,两个请求同时get到库存是1,同时判断可以通过,同时decr,最终库存变成-1。

Redis里的库存初始化和数据库同步是另一个要点。秒杀开始前,需要把数据库中的库存数加载到Redis:

public 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?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true username: root password: yourpassword driver-class-name: com.mysql.cj.jdbc.Driver

allowPublicKeyRetrieval=true这个参数也很关键,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.StdOutImpl

log-impl这个配置建议在开发阶段一直开着,SQL日志会直接打印在控制台,字段有没有映射上、SQL长什么样一目了然。生产环境再关掉,不然日志量太大。

5.5 并发压测时库存变成负数

现象:用JMeter开100个线程同时下单同一个商品,结束后发现库存表的total_stock变成了-5,明显超卖了。

原因:代码里用的是“先查库存再判断再扣减”的三步操作,三步之间没有加锁。10个线程同时查到库存是8,同时判断8>0通过,同时执行set total_stock = total_stock - 1,MySQL最终只减了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.active=prod启动,检查三件事:外部配置文件是否生效、静态资源路径是否正确、数据库连接串是否指向了生产库。很多项目在IDEA里跑得好好的,一打包部署就各种404和500,基本都出在这三处。用curl打一遍核心接口,确认HTTP状态码都是200,再从前端页面走一遍完整的购物流程——注册、登录、浏览商品、加购物车、下单、模拟支付、查看订单,整个闭环走通才算验收合格。

每个项目到最后都会发现几个自己拍脑袋想当然的地方,比如我第一次做电商项目时,想当然地以为订单金额用double算没问题,直到对账时发现0.1加0.2不等于0.3,才老老实实把所有金额改成BigDecimal。这种坑不在代码量里,在每一个细节的较真上。希望这篇笔记能帮你少走几段弯路。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/6 13:33:58

Agent-Reach:多智能体系统触达治理的工程实践

Agent-Reach 是我们从实际业务里抽出来的一套工程实践&#xff0c;名字看着像一个框架&#xff0c;其实本质就一句话&#xff1a;让每个 Agent 都能被稳定、准确、低成本地触达。之前带团队做多 Agent 协作时&#xff0c;最头疼的从来不是模型效果&#xff0c;而是 Agent 之间互…

作者头像 李华
网站建设 2026/10/6 13:31:31

Superpowers:开源浏览器游戏开发平台,从安装到实战全记录

前些天有朋友问我&#xff1a;有一个叫 Superpowers 的开源项目&#xff0c;想装来玩玩&#xff0c;到底值不值得折腾&#xff1f;我先说结论——如果你正好对游戏开发感兴趣&#xff0c;又不想一上来就碰那些动辄几个 GB 的大家伙&#xff0c;Superpowers 绝对是个值得试一试的…

作者头像 李华
网站建设 2026/10/6 13:31:30

Notepad++ 安装与绿色便携版配置:从安装包选型到配置迁移全指南

简介&#xff1a;面向程序员与Web开发者的Notepad 7.5.8安装包&#xff0c;适合需要轻量级代码编辑器、并希望直接使用插件管理器的Windows用户。安装包内集成插件管理器&#xff0c;可便捷检索并安装语法高亮、代码折叠、自动完成等扩展插件&#xff1b;同时附带emeet插件&…

作者头像 李华
网站建设 2026/10/6 13:30:58

EF Core核心原理与高频面试题深度解析:从DbContext到性能优化

1. 为什么我劝你认真对待EF这门技术先说句实话&#xff1a;很多人在简历上写着“熟练使用Entity Framework”&#xff0c;但对着一道“为什么不要在每个请求中都new一个DbContext”的面试题就开始支支吾吾。这不是个别现象&#xff0c;而是国内.NET开发者一个普遍的老毛病——会…

作者头像 李华
网站建设 2026/10/6 13:30:56

HIS系统Oracle数据库性能优化实战:AWR诊断、SQL改写与索引重构全复盘

去年接了一个有点典型的活儿——给某市中心医院的HIS系统做数据库性能优化。这个系统上线跑了差不多六年&#xff0c;业务高峰期的卡顿已经严重到门诊护士想摔鼠标、收费窗口排队排到大厅的程度。对于搞数据库的人来说&#xff0c;HIS系统大概是所有OLTP系统里最“拧巴”的一种…

作者头像 李华
网站建设 2026/10/6 13:30:47

PSO优化Kmeans的居民用电行为聚类与Matlab实现

前阵子帮一个做能源服务的团队看用电数据&#xff0c;他们拿了几百户居民一年的负荷曲线&#xff0c;想分分类&#xff0c;做差异化运营。第一反应就是Kmeans&#xff0c;跑完发现结果很不稳定&#xff0c;连续跑几次出来的簇都不一样。后来我换了思路&#xff0c;用粒子群算法…

作者头像 李华