☰
校园二手交易平台SpringBoot实战:数据库设计与索引优化
2026/10/6 5:15:50 网站建设 项目流程

简介:基于SpringBoot的校园二手交易平台项目,是一套完整Java课程设计与毕业设计参考源码,面向计算机专业高年级学生解决校园闲置物品交易系统从零搭建与二次开发问题。项目涵盖用户管理、商品展示、交易撮合、订单处理等核心模块,数据库按第三范式设计并使用MySQL存储,前端采用响应式布局支持多终端;技术栈涉及SpringBoot 2.x、MyBatis Plus、Redis缓存及Shiro权限控制,整体采用前后端分离架构。压缩包共262个文件,约3.88MB,文件类型以Java源码、SQL脚本、前端HTML/JS/CSS资源及gif演示图为主,同时包含构建脚本、配置文件与项目文档,可直接导入IDE运行并对照学习。资源附有系统设计说明书、数据库设计文档、部署指南及API接口文档,代码注释完整、目录清晰,方便二次扩展。已有81人学习浏览,适合课程设计、项目实训及毕业设计参考。

1. 校园二手交易平台该做什么:不止是 CRUD,而是 Java 源码与数据库设计的咬合

做校园二手交易平台这类 SpringBoot 项目,价值不在把 CRUD 写完,而是搞明白一套 Java 项目源码里,用户、商品、订单和数据库设计是怎么咬合在一起的。这类平台要解决的是真实痛点:教材、耳机、电动车等闲置,信息落在微信群就被闲聊淹没,平台则用最朴素的商品记录把需求产品化。这篇笔记按复现这类项目的顺序展开:理清模块边界,落数据库设计,接 SpringBoot 后端闭环,再单独排查高频调试坑和搜索性能。目标是让做毕设、练手或备 Java 后端实习的读者,能把源码跑通、能改,也能说清楚每张表和每个接口为什么这么设计。

2. 源码先拆模块:用户、商品、订单和管理员的边界在哪里

拿到基于 SpringBoot 的校园二手交易平台源码,第一件事不是点启动,而是先回答一个问题:这个项目到底分几个功能域。模块边界决定了数据库表的数量、接口路径的划分、事务该加在哪个 Service 方法上。很多源码看起来文件几十个,拆完之后核心其实就是三块:用户、商品、订单,外加一个管理员后台。

2.1 用户、商品、订单三大域的职责划分

用户域是入口。注册、登录、角色区分这三件事是必须做的,校园场景还有一个特殊点:学生身份验证。常见做法是注册时填学号,后台按学号规则做格式校验,不强制做实名认证。这个域在源码里通常对应 UserController、UserService、UserMapper 一组文件,密码存 bcrypt 哈希而不是 MD5,登录成功签发 JWT,后续请求通过拦截器校验 token。Java 后端面试时经常问到的“密码为什么要加盐”“JWT 和 Session 有什么区别”,都会落在这部分代码里。

商品域是信息载体。卖家能发布商品、传图、设置分类和价格、主动下架;买家能浏览、搜索、收藏。商品必须有一个状态位来控制生命周期,这个状态驱动着整个列表页、详情页和下单逻辑。商品域最容易忽略的是图片处理。图片涉及存储和网络访问,数据库里不能只存一个本地磁盘路径了事,否则项目换个机器就全线 404。分类也值得想清楚:固定枚举适合毕设,可扩展的分类表更接近真实项目,这个决定会在数据库设计阶段直接体现。

订单域是交易的核心。二手交易的特点是线下见面交割,所以不需要完整支付流程。订单只需要对准“买家要买、卖家同意、交易完成”这几个动作。这个域通常会生成业务订单号、保存下单时的金额快照,同时把商品状态从“在售”改成“已预订”,防止别人重复下单。我见过不少源码把订单写成简单的“插入一条记录”,没有状态流转,也没有并发保护,这种代码跑演示可以,答辩时一问就露馅。

功能域必须包含可以砍掉
用户域注册、登录、角色、找回密码第三方登录、邮箱验证
商品域发布、分类、审核、上下架、搜索推荐、秒杀、竞价
订单域下单、确认、取消、状态查询在线支付、退款、售后

砍掉的部分不是不重要,而是在这个业务模型里属于“复杂度大头但技术点重复”。二手交易的关键是信任和撮合,不是支付通道。

2.2 交易状态机与管理后台的必要性

状态机是这个项目里最值得拿出来讲的设计点。商品状态我一般定义为 0 待审核、1 在售、2 已预订、3 已售、4 下架这五个。流转关系是:0 到 1 走管理员审核,1 到 2 是买家下单成功,2 到 3 是卖家确认交易完成,1 到 4 是卖家主动下架。这些状态如果散落在业务代码里用 if 到处判断,很快就会乱。我在源码里面更倾向用枚举或常量类统一管理,并且所有状态流转都收在 Service 层,不允许 Controller 直接改商品状态字段。

管理员审核这一步不是摆设。校园场景里有大量二手贩子和广告号,管理员角色的意义就是把“待审核”的商品挡在列表外面。这也是区分“写着玩的 CRUD”和“能用起来的项目”的分界线。后台不需要单独做一套技术栈,在源码里它就是一组要求 role=2 才能访问的接口,操作对象还是商品表和用户表。

订单状态建议做成 0 待确认、1 已完成、2 已取消。这里有个容易翻车的并发点:两个买家同时看到一件商品然后同时下单。常见做法是在下单事务里带着“status=1”的条件去更新商品表,如果受影响行数是 0,说明商品已经被抢或下架,直接抛业务异常。先查后改不行——两个请求都查到 status=1,然后都往下走,就会生成两笔订单。

2.3 拿到源码后的阅读顺序:先看配置还是先看代码

拿到这类 Java 项目源码后,我习惯按五步走,每一步都有明确目的。

  1. 先看 application.yml,确认数据源、端口、MyBatis-Plus 配置是否齐全。很多项目跑不起来,都是数据库密码没改或时区配置缺失,代码本身没问题。
  2. 看 entity 目录,数一数有几个实体类。实体类的数量基本等于数据库核心表的数量,先对整体规模有概念。
  3. 看 mapper 目录,区分哪些是 BaseMapper 直接提供的 CRUD,哪些是手写的自定义 SQL。手写 SQL 的地方往往是性能或业务的关键,值得重点读。
  4. 找带 @Transactional 的方法,把事务边界画出来。创建订单、修改商品状态这类写操作,事务加在 Service 方法上才算对,加在 Controller 上就很不合理。
  5. 看 config 目录,重点找拦截器和 MyBatis-Plus 配置类。JWT 校验、分页插件、自动填充全在这里,属于“跑起来但行为不对”时最先怀疑的地方。

这套顺序对 SpringBoot 项目结构不熟的读者尤其有用。先建立全局认知,再钻到具体代码里,不容易被零散的文件带偏。

3. 把数据库设计写在前面:五张核心表与索引落地的取舍

数据库设计是整套源码的底盘。实体关系先理顺了,SpringBoot 的代码写起来只是体力活;实体关系如果乱,后面每加一个功能都要回头改表。校园二手交易平台的表其实不多,五张核心表加两张附属表足够覆盖全部业务,关键是每张表的字段和索引对得上查询场景。

3.1 建表之前先定约束:保留字、逻辑外键与字符集

建表之前有四个约束需要先定下来,否则后面改起来很痛苦。

第一,表名避开 SQL 保留字。订单表不要叫 order,ORDER 是 SQL 的标准保留字,写 SELECT * FROM order 会直接语法报错。我一般用 t_order,干净且通用。第二,字符集统一 utf8mb4,不要用 utf8。utf8mb4 才能存表情符号和生僻字,二手商品描述里出现这类字符并不罕见。第三,金额字段用 DECIMAL(10,2),不要用 FLOAT 或 DOUBLE。浮点数在 Java 里做运算会出现 0.1+0.2 不等于 0.3 的问题,金额这种敏感数据必须精确。第四,不建物理外键,用逻辑外键由应用层保证一致性。物理外键在插入和删除时要额外做约束检查,影响性能,而且一旦业务要拆分表就不灵活。这个取舍在面试里是加分项,能讲清楚理由比直接建外键强得多。

时间字段也有一个通用约定:created_at 默认 CURRENT_TIMESTAMP,updated_at 加上 ON UPDATE CURRENT_TIMESTAMP,这样更新记录时不需要手动维护时间。这套约定的核心目的,是让 SpringBoot 代码里少写重复的时间赋值逻辑。

提示:MySQL 5.7+ 的 utf8mb4 单字符最多占 4 字节,VARCHAR(255) 建立普通索引没有问题;如果字段超过 255 且需要索引,记得控制长度或用前缀索引。

3.2 用户表、商品表、订单表的建表语句与索引选择

用户表是最基础的,用户名做唯一索引,密码字段留足长度。学号不做唯一索引,因为管理员和游客也可能占用 user 表的记录。

CREATE TABLE `user` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', `student_no` VARCHAR(32) DEFAULT NULL COMMENT '学号,校园身份标识', `username` VARCHAR(64) NOT NULL COMMENT '登录名', `password_hash` VARCHAR(128) NOT NULL COMMENT 'bcrypt 加密后的密码串', `avatar` VARCHAR(255) DEFAULT NULL COMMENT '头像,存相对路径', `role` TINYINT NOT NULL DEFAULT 1 COMMENT '1 学生,2 管理员', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1 正常,0 封禁', `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_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';

password_hash 设成 128 是因为 bcrypt 输出固定 60 字符,留出长度是为了未来换加密算法不用改表结构。username 唯一索引既保证业务上登录名不冲突,又给登录查询提供快速检索。

商品表是查询压力最大的一张表。这里要注意 status 字段的默认值,新发布的商品默认进待审核,而不是直接在售。

CREATE TABLE `product` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `seller_id` BIGINT NOT NULL COMMENT '卖家,对应 user.id', `title` VARCHAR(128) NOT NULL COMMENT '标题', `description` TEXT COMMENT '描述,选填', `price` DECIMAL(10,2) NOT NULL COMMENT '售价', `original_price` DECIMAL(10,2) DEFAULT NULL COMMENT '原价,便于买家评估成色', `category_id` BIGINT NOT NULL, `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0 待审核,1 在售,2 已预订,3 已售,4 下架', `view_count` INT NOT NULL DEFAULT 0, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_seller` (`seller_id`), KEY `idx_category_status` (`category_id`, `status`), KEY `idx_status_created` (`status`, `created_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='二手商品表';

三个索引各有用途。idx_seller 覆盖“我的商品列表”,按卖家查是高频操作。idx_category_status 是组合索引,覆盖“分类页按状态过滤”的查询。idx_status_created 覆盖首页场景,“只查在售商品并按发布时间倒序”,这个索引在最前面放 status 等值条件,后面接排序字段,过滤和排序都能走索引。

订单表这里有一个关键字段:amount 是下单时的金额快照。订单一旦创建,这个值就不应该再随商品改价而变动。

CREATE TABLE `t_order` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `order_no` VARCHAR(32) NOT NULL COMMENT '业务订单号,展示给用户', `product_id` BIGINT NOT NULL, `buyer_id` BIGINT NOT NULL, `seller_id` BIGINT NOT NULL, `amount` DECIMAL(10,2) NOT NULL COMMENT '下单时商品价格快照', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0 待确认,1 已完成,2 已取消', `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `paid_at` DATETIME DEFAULT NULL COMMENT '卖家确认交易完成的时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_buyer` (`buyer_id`), KEY `idx_seller` (`seller_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表';

product_id 在订单表里不做唯一索引,因为同一商品会被取消再重新下单,物理上会留下多条记录。业务上的“唯一在售”由下单时把商品状态改成已预订来保证,而不是靠数据库约束。

查询场景索引方案设计理由
卖家查看我发布的商品idx_seller单列等值查询,返回行数少
分类列表按状态过滤idx_category_status两个等值条件联合命中
首页在售商品按时间倒序idx_status_created过滤和排序都走索引树
订单按买家查询idx_buyer个人中心高频查询

索引不是越多越好。这张表设计到四个二级索引已经够用,索引数量再往上增加,写入和更新时的维护成本会明显上升。

3.3 商品图片与收藏:两张附属表的落地方案

商品图片单独建表,而不是在 product 表里存一个 JSON 数组。这个取舍的理由很直接:图片要排序、要删除、要能查封面,独立表里一行一条记录,sort_order 字段控制展示顺序,0 表示封面,数字越小越靠前。

CREATE TABLE `product_image` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `product_id` BIGINT NOT NULL, `url` VARCHAR(255) NOT NULL COMMENT '相对路径,如 /images/2024/10/xxx.jpg', `sort_order` INT NOT NULL DEFAULT 0 COMMENT '0 为封面,数字越小越靠前', PRIMARY KEY (`id`), KEY `idx_product` (`product_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品图片表';

url 字段存相对路径,不存绝对路径,这条规则值得反复强调。开发机的 D:/upload 和服务器上的 /root/upload 完全不是一回事,存相对路径让项目换环境之后只改一个上传目录配置就能继续用。后续如果接对象存储,把 URL 换成 CDN 前缀拼接即可,不需要重刷数据库。

收藏表的唯一索引也要注意。同一个用户对同一件商品只能收藏一次,这个约束放在数据库层最可靠,应用层做判断总会有漏网之鱼。

CREATE TABLE `favorite` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `user_id` BIGINT NOT NULL, `product_id` BIGINT NOT NULL, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_user_product` (`user_id`, `product_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='收藏表';

两张附属表都不建物理外键,与核心表保持逻辑关联。删除商品时,业务代码里手动删除图片记录和收藏记录,这样数据流向是清晰的,不会被数据库外键牵制。

4. SpringBoot 后端怎么接上数据库:从依赖配置到订单事务的最小闭环

表结构定稿之后,SpringBoot 这边就是三件事:依赖配置、实体映射、服务接口。这也是源码里最值得一行行读的部分。实体类对应表,Mapper 负责 SQL,Service 写事务,Controller 只做参数接收和结果返回。

4.1 项目结构与 Maven 依赖:把 SpringBoot 骨架搭起来

先看目录结构。一个可维护的 SpringBoot 项目应该有清晰的包划分:config 放拦截器和 MyBatis-Plus 配置,controller 放接口层,service 放业务逻辑和事务,mapper 放数据访问接口和 XML,entity 放实体类,common 放统一返回结构和异常处理。如果源码里把全部类都堆在同一个包下,运行没问题,但扩展和维护会很吃力。

Maven 依赖是整个骨架的地基。核心依赖就四个:spring-boot-starter-web、mybatis-plus-boot-starter、mysql 连接器、lombok。

<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.2</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>

这里有一个版本匹配问题需要特别注意。SpringBoot 3.x 下 mysql 连接器的坐标变成了 com.mysql:mysql-connector-j,不再用 mysql:mysql-connector-java。JDK 版本直接决定 SpringBoot 大版本:JDK 8 配 SpringBoot 2.7.x,JDK 17 配 SpringBoot 3.x,对应的 Maven 构建方法和依赖坐标都不一样。先确认环境再贴依赖,避免依赖冲突。

依赖配好之后是 application.yml,这是 SpringBoot 启动时读取配置的入口,也是排查问题时的第一个关注点。

server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/campus_trade?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver mybatis-plus: mapper-locations: classpath*:mapper/**/*.xml configuration: map-underscore-to-camel-case: true global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0 id-type: auto

数据源 URL 里必须带上 serverTimezone=Asia/Shanghai,否则 MySQL 8 驱动会报时区错误。map-underscore-to-camel-case 配置让表里的 created_at 自动映射到实体类的 createdAt 字段,这是 SpringBoot 项目的常见配置,省去手写大量 ResultMap。

4.2 实体类与 MyBatis-Plus:从 Product 表到 Java 对象的映射

实体类是数据库表在 Java 世界的投影。用 MyBatis-Plus 时,实体类通过注解映射到表名,字段名走驼峰规则。下面以商品表为例。

@Data @TableName("product") public class Product { @TableId(type = IdType.AUTO) private Long id; private Long sellerId; private String title; private BigDecimal price; private Long categoryId; private Integer status; @TableField(fill = FieldFill.INSERT) private LocalDateTime createdAt; @TableField(fill = FieldFill.INSERT_UPDATE) private LocalDateTime updatedAt; }

@Data 是 Lombok 提供的注解,自动生成 getter 和 setter,让实体类保持干净。@TableName 指定表名,@TableId 配置主键生成策略,AUTO 对应数据库自增主键。price 必须是 BigDecimal,不能用 Double。@TableField 的 fill 属性配合 MetaObjectHandler 实现字段自动填充,这样插入和更新时不需要手动 set 时间。

Mapper 层也简单。继承 BaseMapper 之后,单表 CRUD 全部自带,不需要手写 SQL。但业务里有些复杂查询还是要自定义,比如关键词搜索。

@Mapper public interface ProductMapper extends BaseMapper<Product> { @Select("SELECT * FROM product WHERE status = 1 AND title LIKE CONCAT('%', #{keyword}, '%') ORDER BY created_at DESC") Page<Product> searchByKeyword(Page<Product> page, @Param("keyword") String keyword); }

这种 LIKE '%keyword%' 写法无法走索引,数据量过万后性能会明显下降。对这个项目来说,商品量级不会太大,这种宽松搜索可以接受;如果量上来了,可以换成全文索引或者把搜索功能独立出去。MyBatis-Plus 的条件构造器也能实现大部分查询,但像这种带了分页参数和动态条件的方法,我还是习惯在 Mapper 里用注解或 XML 写清楚。

4.3 Service 事务与 Controller 接口:创建订单的最小闭环

创建订单是整个项目里事务最核心的场景。它同时做了两件事:插入一条订单记录,把商品状态从在售改成已预订。这两件事必须在一个事务里,要么都成功,要么都回滚。

@Transactional(rollbackFor = Exception.class) public Long createOrder(Long productId, Long buyerId) { Product product = productMapper.selectById(productId); if (product == null || !product.getStatus().equals(1)) { throw new BizException("商品不存在或已不在售"); } Order order = new Order(); order.setOrderNo(IdGenerator.generate()); order.setProductId(productId); order.setBuyerId(buyerId); order.setSellerId(product.getSellerId()); order.setAmount(product.getPrice()); order.setStatus(0); orderMapper.insert(order); product.setStatus(2); int updated = productMapper.updateById(product); if (updated == 0) { throw new BizException("商品已被预订,请刷新后重试"); } return order.getId(); }

金额不下传,服务端从数据库重新读取当前价格写入订单,这是防止改价不一致的关键。@Transactional 默认只回滚 RuntimeException,所以这里必须显式写成 rollbackFor = Exception.class,否则抛出受检异常时事务不会回滚。updateById 返回受影响行数,如果商品状态已经在其他地方被改掉,这里更新行数就是 0,直接抛异常,比先查再判断更可靠。

分页插件也要单独配置。没有它,MyBatis-Plus 的 Page 对象只返回当前页数据,total 字段永远是 0,翻页功能会静默失效。

@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }

Controller 层保持薄。它只做三件事:接收参数、调用 Service、包装返回结果。统一返回 Result 结构,前端只需要处理 code、message、data 三个字段,而不是一接口一个返回格式。

@GetMapping("/api/products") public Result<Page<Product>> list(@RequestParam(defaultValue = "1") Integer page, @RequestParam(defaultValue = "20") Integer size, @RequestParam(required = false) Long categoryId) { Page<Product> p = new Page<>(page, size); LambdaQueryWrapper<Product> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(Product::getStatus, 1) .eq(categoryId != null, Product::getCategoryId, categoryId) .orderByDesc(Product::getCreatedAt); return Result.ok(productMapper.selectPage(p, wrapper)); }

LambdaQueryWrapper 的好处是类型安全,Product::getStatus 这种写法如果字段名拼错,编译期就能发现,而不是运行到 SQL 才报错。JWT 校验这块我一般用 HandlerInterceptor 实现,在 preHandle 里从 Authorization 头取出 token 校验,注册到 WebMvcConfigurer 后只拦截 /api/** 路径。密码加密用 BCryptPasswordEncoder,不要用 MD5,MD5 的哈希值可以在彩虹表里直接查出来。

5. 复现时的常见坑与排查记录:版本、SQL 保留字和事务边界

源码复现最容易翻车的地方是环境和配置,业务代码反而一般不会太折腾。以下五条是我跑这类项目时反复遇到的,前两条属于“跑不起来”,后三条属于“跑起来但结果不对”。每条都按现象、原因、解决三个步骤说清楚。

5.1 SpringBoot 版本太高,JDK 和依赖先对不上号

现象:导入源码后 Maven 一直报依赖错误,或者点击启动后主类直接抛 UnsupportedClassVersionError,控制台日志像天书一样。原因:SpringBoot 3.x 强制要求 JDK17,本地如果装的是 JDK8 必然失败;反过来,SpringBoot 2.7.x 在 JDK17 下如果 Lombok 版本太旧,也会出现编译错误。解决:先看 pom.xml 里的 parent 版本,再在命令行执行 java -version 确认本地 JDK 版本。JDK8 配 SpringBoot 2.7.x,JDK17 配 SpringBoot 3.x,Lombok 和 mybatis-plus 也同步换成对应版本。不要一上来就改代码,环境对齐了再谈下一步。

5.2 表名用了 order,SQL 一执行就报语法错

现象:建表脚本执行到一半报错,或者订单表查询一直提示 SQL syntax error。原因:order 是 SQL 标准保留字,MySQL 会把它解析成 ORDER BY 的排序关键字,“SELECT * FROM order” 必然语法错。解决:表名一律用 t_order,并在建表语句里加 COMMENT 注明这是一张订单表。不要依赖反引号去绕过,反引号在 MySQL 里能跑,但换数据库或动态拼接 SQL 时容易忘,属于给自己埋雷。

5.3 下单金额以客户端传值为准,改价后对不上账

现象:买家下单页显示 100 元,订单创建后却变成 80 元;或者卖家改价之后,历史订单金额也跟着变。原因:创建订单接口接收了前端传来的 price 参数,或者下单时读取的是缓存里的旧商品数据。解决:金额快照只能在服务端做,创建订单时从 product 表重新读取当前价格写入 amount,而且创建之后不再更新。交易状态只改 status,金额字段永远不动。这条也是二手交易平台与电商系统设计的核心差异点,值得在源码注释里写清楚。

5.4 图片存了本地绝对路径,换机器就 404

现象:开发机上图片一切正常,项目打成 jar 发给别人,或者部署到服务器后所有图片都不显示。原因:数据库里存的是 D:/upload/xxx.jpg 这类绝对路径,换一台机器路径根本不存在。解决:入库只存 /images/xxx.jpg 相对路径,然后在 SpringBoot 里把 /images/** 映射到本机上传目录。

@Configuration public class WebConfig implements WebMvcConfigurer { @Value("${upload.dir}") private String uploadDir; @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/images/**") .addResourceLocations("file:" + uploadDir + "/"); } }

这样项目只依赖一个 upload.dir 配置项,换环境改配置即可。数据库里永远存相对路径,后续接对象存储也只需改这一处映射。

5.5 自动填充不生效,created_at 插入后是空值

现象:实体类里加了 @TableField(fill = FieldFill.INSERT),但插入成功后 created_at 字段是 NULL。原因:MyBatis-Plus 的自动填充依赖 MetaObjectHandler 实现类,只加注解不会生效;另一种常见情况是 application.yml 里 map-underscore-to-camel-case 被改成了 false,导致 created_at 无法映射到 createdAt。解决:实现 MetaObjectHandler,在 insertFill 里调用 strictInsertFill 填充插入时间,在 updateFill 里填充更新时间。

@Component public class MyMetaObjectHandler implements MetaObjectHandler { @Override public void insertFill(MetaObject metaObject) { this.strictInsertFill(metaObject, "createdAt", LocalDateTime.class, LocalDateTime.now()); this.strictInsertFill(metaObject, "updatedAt", LocalDateTime.class, LocalDateTime.now()); } @Override public void updateFill(MetaObject metaObject) { this.strictUpdateFill(metaObject, "updatedAt", LocalDateTime.class, LocalDateTime.now()); } }

这个坑之所以隐蔽,是因为它在数据库里表现为“可空字段没填值”,系统不会报错,但列表页排序和后台统计会拿到一堆空时间。排查时先看启动日志里有没有 MetaObjectHandler 相关提示,再检查实体字段上注解和 yml 配置是否同时到位。自动填充从来不是黑匣子,它是配置类加注解的组合,缺一个都不动。

6. 商品搜索变慢怎么办:用慢查询日志和组合索引做一次排查

搜索性能是这类项目上线后最先暴露的问题。分类页、首页、关键词搜索,每个请求都在扫描同一张 product 表。优化思路不是从代码层面硬调,而是先看数据库是怎么执行的,再决定索引怎么写。

6.1 先看执行计划再谈优化

一条典型的分类列表查询是这种形态:按分类和状态过滤,再按创建时间倒序。在没有索引的情况下,MySQL 会全表扫描,把每一行都读一遍再过滤。

EXPLAIN SELECT * FROM product WHERE category_id = 5 AND status = 1 ORDER BY created_at DESC LIMIT 20;

EXPLAIN 的输出里,重点关注三个列:type、key、rows。type 从 ALL 变成 ref 或 range,说明语句开始走索引;key 会显示实际命中的索引名;rows 表示预估扫描的行数。rows 从几万降到几十,这条 SQL 的优化就算到位了。第 3 章里设计的 idx_category_status 和 idx_status_created 两个组合索引,就是为这类查询准备的。

开启慢查询日志也很直接。MySQL 里执行两条命令,超过阈值的 SQL 就会记录到日志文件,之后再针对性 EXPLAIN。

SET GLOBAL slow_query_log = 'ON'; SET GLOBAL long_query_time = 1;

long_query_time 是秒,1 秒在开发环境可能偏敏感,线上一般设 2 或 3。日志里的每条慢 SQL 都要和 EXPLAIN 结果对照着看,单看执行时间很难定位是缺索引还是语句本身有问题。

6.2 深分页的游标替代写法

翻页翻到后面,或者后台管理里查看第 5000 条之后的数据,LIMIT 的偏移量会变得很大。

-- 普通深分页:数据库要扫描前 100000 行再丢弃 SELECT * FROM product WHERE status = 1 ORDER BY created_at DESC LIMIT 100000, 20; -- 游标写法:用上一页最后一条 id 作为条件 SELECT * FROM product WHERE status = 1 AND id < 100540 ORDER BY id DESC LIMIT 20;

普通写法的问题是数据库要读完前 10 万行,再扔掉其中 99980 行,扫描量跟偏移量成正比。游标写法把“偏移”变成“条件”,直接利用主键索引定位,扫描多少行就返回多少行。前端配合“加载更多”而不是页码跳转,体验也更顺滑。排序时 id 和 created_at 的增减方向要保持一致,否则可能出现错漏。

以前我拿到一套源码,第一反应是先把所有字段都加上索引,后来发现索引数量越多,写入越慢,优化器还容易选错索引。现在我的习惯是:每条列表 SQL 先跑一遍 EXPLAIN,再谈优化方案;索引可以后加但顺序不能乱,查询条件里等值字段放最前面,排序字段放最后面。这是我折腾这类 SpringBoot 项目后最想留住的习惯,如果你也准备用这套源码做点东西,希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询