Spring Boot+MyBatis实现绿植领养系统:从表设计到并发防重

📅 发布时间:2026/10/18 18:15:16
Spring Boot+MyBatis实现绿植领养系统:从表设计到并发防重
简介这是基于BS架构的绿植领养系统完整项目源码后端采用Spring Boot搭建持久层使用MyBatis Plus视图由Thymeleaf渲染结合Shiro完成权限控制前端基于Bootstrap构建整体技术栈贴近真实企业开发。面向正在学习Java Web、需要完成毕业设计或课程项目的学生以及想理解权限管理、数据表设计与增删改查流程的初级开发者。压缩包共40个文件包含22个Java源文件、13个HTML页面模板、1个SQL脚本、配置文件和启动脚本包体约70KB按Maven标准结构组织并附有README说明文件导入IDE后即可运行。通过源码可重点学习Thymeleaf与Spring Boot的整合方式、MyBatis Plus的CRUD写法、Shiro认证授权逻辑以及Bootstrap响应式页面的布局思路并掌握完整数据库表设计要点。目前已有49人学习使用代码量适中、模块完整、文档清晰适合快速读懂并在此基础上二次开发。1. 绿植领养从纸质登记到在线系统B/S架构到底解决了什么某高校的环保社团期末清点绿植登记表上写着 20 株绿萝花架上只剩 13 株被谁领走、何时归还全无记录。这是绿植领养最常见的痛点领养关系散落在纸张和 Excel 里状态无法同步去向成了黑匣子。基于B/S系统的绿植领养系统让浏览器做客户端、服务器管业务、数据库管状态领养、归还、成长记录在浏览器里闭环完成。这个系统的核心价值有三点状态实时可见管理员打开页面就知道每株绿植在谁手里流程可追溯谁在什么时间领养了哪株绿植、归还了没有全部留痕免安装平板、手机、教室电脑都能访问。适合高校社团、社区绿化角、企业前台绿植角这类中小场景也是练手B/S架构的理想完整项目。接下来从表设计、核心接口、并发防重到图片 404逐段拆开讲。2. 技术选型与项目初始化为什么BS系统首选分层架构2.1 三层架构与请求流转浏览器、业务层、数据库各管什么B/S 系统的本质是计算和服务集中在服务器端浏览器只负责展示和采集。用户点下“申请领养”按钮到页面弹出“领养成功”请求要经过四段才能走完浏览器把表单数据组装成 HTTP 请求发到服务器控制器接收请求调用业务层校验用户是否登录、绿植是否还在待领养状态业务层调用数据访问层把状态变更写进数据库数据库返回结果逐层回传最终以 JSON 或渲染后的 HTML 呈现在浏览器里。这个分层不是可选项。绿植领养天然涉及多用户并发操作如果业务判断和 SQL 混在一个方法里后面要加权限控制、加消息提醒时只能在旧代码上继续打补丁越补越乱。分层之后Controller 只负责参数解析和响应组装Service 只负责业务规则DAO 只负责 SQL出了问题顺着链路逐层定位就行。我一般会在设计阶段给 Service 层定一条硬规矩事务边界必须落在 Service 方法上绝不在 Controller 里开事务。领养业务中“修改绿植状态”和“插入领养记录”这两个动作必须同生共死拆到两个方法里并且不加事务的话任何一步失败都会留下数据不一致的烂摊子。2.2 技术栈对比SSM、Spring Boot、前后端分离怎么选这个标题下面最常见的三种实现路线差异集中在工程化程度和部署方式上方案前端后端适合场景主要坑点Servlet JSPJSPServlet教学最小版页面与 Java 耦合后期难维护SSMJSP/ThymeleafSpring SpringMVC MyBatis经典企业版XML 配置多起步慢Spring Boot ThymeleafThymeleafSpring Boot MyBatis中小型真实项目没有明显短板前后端分离Vue/ReactSpring Boot多人协作的大项目要处理跨域、Token 鉴权、接口文档对绿植领养这种规模我推荐 Spring Boot MyBatis Thymeleaf MySQL。理由有三个。第一Spring Boot 内置 Tomcat一条 java -jar 就能跑起来省掉单独装 Tomcat、打 war 包、配置数据源这一整套操作新手最友好。第二MyBatis 的 SQL 写在 XML 里像 UPDATE t_plant SET status1 WHERE id? AND status0 这种带条件的原子更新写起来比 ORM 直观后面讲并发防重还会用到。第三Thymeleaf 在服务端渲染页面天然适合小团队不做复杂前端工程的情况页面数据通过 Model 传过去直接输出 HTML。如果你的团队有人特别熟悉 Vue把前端拆出去做前后端分离当然可行但代价要算清楚CORS 跨域配置、Token 登录态、接口文档维护、前端构建部署这四件事一起加进来工作量至少上浮 30%。绿植领养的业务复杂度撑不起这个成本服务端渲染已经够了。2.3 初始化项目Maven 工程结构与最小启动配置我建这类工程时用标准的 Spring Boot 分层包结构按 controller / service / mapper / entity / config / common 分目录这样职责边界和 2.1 讲的三层一一对应com.greenadopt └── src/main/java ├── Application.java // 启动类加 SpringBootApplication ├── controller // 接收 HTTP 请求 │ ├── UserController.java │ ├── PlantController.java │ └── AdoptionController.java ├── service // 业务规则与事务边界 │ ├── PlantService.java │ └── AdoptionService.java ├── mapper // MyBatis 接口 │ ├── UserMapper.java │ ├── PlantMapper.java │ └── AdoptionMapper.java ├── entity // 数据库实体 │ ├── User.java │ ├── Plant.java │ └── Adoption.java ├── config // 拦截器、资源映射、CORS │ └── WebConfig.java └── common // 统一返回封装 └── Result.javapom.xml 的最小依赖集合长这样dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-thymeleaf/artifactId /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.1/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency /dependencies三个细节值得注意。第一mybatis-spring-boot-starter 不受 Spring Boot 父工程依赖管理管必须写版本号而 spring-boot-starter-web 和 spring-boot-starter-thymeleaf 由父 POM 统一管版本不用写。第二mysql 驱动的新坐标是 com.mysql:mysql-connector-j老资料里的 mysql-connector-java 在 Spring Boot 高版本里会自动替换成新坐标直接写新的省得控制台打 deprecation 告警。第三绿植总量通常几百条不需要分页插件如果要加再引入 pagehelper 也不迟。application.yml 的最小配置如下server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/green_adopt?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: your_password thymeleaf: cache: false jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai mybatis: mapper-locations: classpath:mapper/*.xml configuration: map-underscore-to-camel-case: true这里有两个参数是血泪经验的产物。serverTimezoneAsia/Shanghai 不加的话MySQL 驱动按 UTC 处理时间你存进去的 12:00 查询出来会变成 20:00前后端一起查错。map-underscore-to-camel-case 打开后create_time 自动映射到 createTime 字段几十个实体类加 Column 注解的重复劳动全省了。提示如果项目用的 Spring Boot 版本偏老数据库驱动坐标仍是 mysql-connector-java版本由框架管理不必精确指定。3. 数据库设计绿植档案、领养订单与成长记录的三张核心表3.1 表结构设计与字段约束绿植领养系统一共四张表用户表、绿植表、领养表、成长记录表。用户表管准入绿植表管资源领养表管关系成长记录表管过程。用户表的关键字段字段类型说明idbigint 自增主键用户 IDusernamevarchar(50) 唯一登录名passwordvarchar(64)加密后密码nicknamevarchar(50)微信/页面展示昵称phonevarchar(20)联系手机roletinyint0 普通用户1 管理员create_timedatetime注册时间绿植表是资源表status 是灵魂字段后续所有并发问题都围着它转字段类型说明idbigint 自增主键绿植 IDnamevarchar(50)绿植名称如绿萝speciesvarchar(50)品种descriptionvarchar(500)养护说明image_urlvarchar(200)图片路径locationvarchar(100)摆放位置statustinyint0 待领养1 已领养2 下架create_timedatetime入库时间领养表记录一次完整的领养关系谁、什么时间、领了哪株、归还没有。成长记录表挂靠在领养记录下面一次领养可以有多条成长记录所以必须独立成表。这两张表的字段按 3.2 的建表脚本展开。关于外键我的做法是不建物理外键只建普通索引。物理外键在数据导入、分表时会变成拖累而且 green_adopt 这种业务规模完全靠 Service 层保证一致性SQL 里 JOIN 查询一样快。3.2 建表SQL全文与关键约束说明直接给一套能跑的 MySQL 建表脚本CREATE DATABASE IF NOT EXISTS green_adopt DEFAULT CHARACTER SET utf8mb4; USE green_adopt; CREATE TABLE t_user ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 用户ID, username VARCHAR(50) NOT NULL COMMENT 登录名, password VARCHAR(64) NOT NULL COMMENT 加密密码, nickname VARCHAR(50) DEFAULT COMMENT 昵称, phone VARCHAR(20) DEFAULT COMMENT 手机号, role TINYINT NOT NULL DEFAULT 0 COMMENT 0普通用户 1管理员, create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 注册时间, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB COMMENT用户表; CREATE TABLE t_plant ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 绿植ID, name VARCHAR(50) NOT NULL COMMENT 绿植名称, species VARCHAR(50) NOT NULL COMMENT 品种, description VARCHAR(500) DEFAULT COMMENT 养护说明, image_url VARCHAR(200) DEFAULT COMMENT 图片路径, location VARCHAR(100) DEFAULT COMMENT 摆放位置, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待领养 1已领养 2下架, create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 入库时间, PRIMARY KEY (id), KEY idx_status (status) ) ENGINEInnoDB COMMENT绿植表; CREATE TABLE t_adoption ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 领养ID, user_id BIGINT NOT NULL COMMENT 领养人, plant_id BIGINT NOT NULL COMMENT 绿植, adopt_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 领养时间, return_time DATETIME DEFAULT NULL COMMENT 归还时间, status TINYINT NOT NULL DEFAULT 0 COMMENT 0领养中 1已归还 2逾期未还, remark VARCHAR(200) DEFAULT COMMENT 备注, PRIMARY KEY (id), UNIQUE KEY uk_user_plant (user_id, plant_id), KEY idx_plant (plant_id) ) ENGINEInnoDB COMMENT领养记录表; CREATE TABLE t_growth ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 记录ID, adoption_id BIGINT NOT NULL COMMENT 关联领养记录, plant_id BIGINT NOT NULL COMMENT 冗余绿植ID, record_date DATE NOT NULL COMMENT 记录日期, note VARCHAR(500) DEFAULT COMMENT 成长描述, image_url VARCHAR(200) DEFAULT COMMENT 拍照路径, create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 提交时间, PRIMARY KEY (id), KEY idx_adoption (adoption_id) ) ENGINEInnoDB COMMENT成长记录表;逐个说约束的用意。uk_username 保证登录名不重复。idx_status 加上是因为“查所有待领养绿植”是这个系统最频繁的查询带有 WHERE status0 条件索引能省一次全表扫描。uk_user_plant 是给并发防重加的兜底唯一索引——业务层的 UPDATE 条件判断万一有漏网之鱼数据库层面会直接拒绝同一用户重复领养同一株绿植这是最后一道保险。t_growth 里的 plant_id 看似冗余实则是故意的。管理员想按绿植维度看“这株绿萝这一年长了多少”如果只有 adoption_id就得先查领养表拿到 plant_id 再回查多一次 JOIN。冗余字段以极小的空间代价换常见查询的高效这是数据库设计里的常规取舍。密码字段长度设 64 而不是 32是为了将来从 MD5 换 BCrypt 时不改表。MD5 摘要 32 位BCrypt 输出 60 位长度设 64 一步到位这种后悔药能不吃就不吃。注意所有字符集相关配置都显式指定 utf8mb4。旧项目里只写 utf8 的话emoji 表情和生僻字入库会报错绿植名称里的“”这种字直接存不进去。3.3 领养状态流转待领养→已领养→归还→异常绿植表和领养表各有一个 status我必须先说清楚它们的配合关系。绿植表的 status 管资源是否可用领养表的 status 管这次领养关系是否正常结束。两边的变更必须同步这是整个系统最容易出错的地方。业务操作绿植表 status领养表 status前置条件申请领养0 → 1插入新记录status0绿植当前 status0正常归还1 → 00 → 1记录 return_time当前用户是领养人管理员下架任意 → 2保持原样绿植枯死或丢失逾期标记保持原样0 → 2定时任务发现超过归还日期新手最容易犯的错误是在 Service 里先 SELECT 再 UPDATE先查绿植的 status 是不是 0是的话执行 UPDATE。两个用户并发操作时两个请求都查到 status0然后先后执行 UPDATE同一株绿植就被领养了两次。正确做法是把状态判断直接揉进 UPDATE 的 WHERE 条件里交给数据库的行锁去裁决下一章给出完整代码。还有一种情况归还操作要把绿植表改成待领养同时把领养表改成已归还。这两个动作如果分开执行中间断电或抛异常就会留下“绿植显示已领养但领养记录已经归还”的矛盾态。要么用 Transactional 把两个动作包进一个事务要么在代码里先更新领养表再更新绿植表配合失败补偿脚本。实务中前者是标准解法。4. 核心功能实现登录鉴权、绿植展示与领养申请4.1 统一返回格式与登录鉴权拦截器前后端交互需要一个稳定的协议。我习惯用 code / msg / data 三段式 JSONcode 等于 0 表示成功非 0 是业务错误码前端拿 code 做分支判断拿 msg 做人读提示public class ResultT { private int code; // 0 成功非 0 业务失败 private String msg; private T data; public static T ResultT success(T data) { ResultT r new Result(); r.code 0; r.msg ok; r.data data; return r; } public static T ResultT error(int code, String msg) { ResultT r new Result(); r.code code; r.msg msg; return r; } }错误码要有约定1001 未登录、1002 绿植已被领养、1003 参数缺失、1004 无权限。前端只判断 code 是否等于 0不要用 msg 字符串去做流程分流——字符串一旦改动前端和后端就要联调一次这是项目后期接口改不动的主要原因之一。登录态我用 Session 拦截器实现。拦截器在请求进入 Controller 之前校验 Session 里有没有 loginUserComponent public class LoginInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session request.getSession(false); if (session ! null session.getAttribute(loginUser) ! null) { return true; // 已登录放行 } String requestedWith request.getHeader(X-Requested-With); if (XMLHttpRequest.equals(requestedWith)) { // Ajax 请求返回 JSON 错误码让前端统一处理 response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:1001,\msg\:\未登录或登录已过期\}); } else { // 普通页面跳转直接重定向到登录页 response.sendRedirect(/login); } return false; } }这段代码解决了一个很隐蔽的体验问题。Ajax 请求如果收到 302 重定向浏览器会静默跟随前端拿到的是登录页的 HTML而不是 JSON提示语全是乱的。所以拦截器要区分请求类型带 X-Requested-With 头的是 Ajax返回 JSON 错误码普通链接访问则重定向。Spring MVC 里也可以用 ControllerAdvice 统一捕获异常但拦截器处理未登录更前置两种方式配合使用。注意 request.getSession(false) 传了 false表示 Session 不存在时不要新创建。如果传 true每个未登录请求都会强行造一个 Session虽然功能上没错但垃圾 Session 会占满服务器内存。Session 里存 loginUser 对象而不是只存 user id是为了页面渲染时直接拿昵称、角色等字段省一次数据库查询。4.2 领养申请接口并发场景下的原子更新领养申请是核心业务接口代码分三层。Controller 负责取登录用户、收集参数、调用 ServiceService 负责事务和业务规则Mapper 负责那条关键的原子 UPDATE。ControllerRestController RequestMapping(/api/adoption) public class AdoptionController { Autowired private AdoptionService adoptionService; PostMapping(/apply) public Result? apply(RequestBody AdoptionApplyDTO dto, HttpSession session) { User loginUser (User) session.getAttribute(loginUser); if (loginUser null) { return Result.error(1001, 请先登录); } return adoptionService.apply(loginUser.getId(), dto.getPlantId()); } }Service 实现Service public class AdoptionServiceImpl implements AdoptionService { Autowired private PlantMapper plantMapper; Autowired private AdoptionMapper adoptionMapper; Override Transactional(rollbackFor Exception.class) public Result? apply(Long userId, Long plantId) { // 核心把“状态校验”放进 UPDATE 条件里 int rows plantMapper.updateStatusIfAvailable(plantId, 0, 1); if (rows 0) { return Result.error(1002, 绿植已被领养或不存在); } Adoption adoption new Adoption(); adoption.setUserId(userId); adoption.setPlantId(plantId); adoption.setStatus(0); adoptionMapper.insert(adoption); return Result.success(null); } }PlantMapper.xml 里的声明update idupdateStatusIfAvailable UPDATE t_plant SET status #{targetStatus} WHERE id #{plantId} AND status #{expectStatus} /update理解这条 UPDATE 是理解整个并发防重的关键。WHERE 条件里写了 status 0数据库执行 UPDATE 时会锁定命中的行其他事务再对这个 id 做相同的条件更新要么被行锁挡住要么影响行数为 0。第一个用户提交成功影响行数是 1继续插入领养记录第二个用户的 UPDATE 因为 status 已经不是 0影响行数为 0直接走 rows 0 分支返回“已被领养”。这比先 SELECT 再 UPDATE 少了两次数据库往返且彻底杜绝了检查与更新之间的时间窗口。Transactional(rollbackFor Exception.class) 保证“改绿植状态”与“插入领养记录”在同一事务。rollbackFor 指定运行时异常和受检异常都回滚Spring 默认只在运行时异常回滚写明白可以避免查错异常类型。为什么强调这一点因为如果插入领养记录抛了异常而绿植状态已经改成已领养事务一提交这株绿植就永远卡在“已领养”状态但没有人认领只能人工改库补救。4.3 前端对接列表渲染与表单提交的最小实现前端我提供一套无框架的最小实现原生 HTML jQuery方便理解数据流。真实项目里可以换成 Vue 或 React但接口协议完全不用变。div idplantList/div script srcjquery-3.6.0.min.js/script script // 加载绿植列表 function loadPlants() { $.ajax({ url: /api/plant/list?status0, // 只查待领养的 type: GET, headers: { X-Requested-With: XMLHttpRequest } }).done(function (res) { if (res.code ! 0) { alert(res.msg); return; } renderPlants(res.data); }).fail(function () { alert(网络异常请稍后重试); }); } function renderPlants(list) { var html ; list.forEach(function (p) { html div classcard img src p.imageUrl alt p.name h3 p.name small p.species /small/h3 p p.description /p p位置 p.location /p button onclickapplyAdopt( p.id )申请领养/button /div; }); $(#plantList).html(html); } // 提交领养申请 function applyAdopt(plantId) { $.ajax({ url: /api/adoption/apply, type: POST, contentType: application/json, data: JSON.stringify({ plantId: plantId }), headers: { X-Requested-With: XMLHttpRequest } }).done(function (res) { if (res.code 0) { alert(领养成功请按时浇水并记录成长情况); loadPlants(); // 刷新列表已领养的不再显示 } else if (res.code 1001) { location.href /login; // 未登录则跳登录页 } else { alert(res.msg); // 其他业务错误直接提示 } }); } // 初始化 loadPlants(); /script第一处细节每个 Ajax 请求都手动带了 X-Requested-With 头。jQuery 的 $.ajax 只有在跨域请求时才会自动带这个头同源请求不会带。4.1 的拦截器靠这个头区分 Ajax 和页面请求所以前端必须在 headers 里显式声明否则未登录时 Ajax 请求会被当页面请求重定向报错信息全丢。第二处细节领养成功后调用 loadPlants() 刷新列表使已被领养的绿植从“待领养”列表消失避免用户反复点击同一株。第三处细节后端接口用 POST JSON body 而不是表单编码这样 DTO 里的嵌套对象和后续新增参数都不用调整前端协议。如果你不想用字符串拼接来渲染把 renderPlants 换成 Vue 的 v-for 模板即可数据格式和接口协议保持原样。前端工程化属于可选优化不要在项目初期引入构建工具链先把接口跑通。5. 避坑指南绿植领养系统开发中常见的5个翻车现场下面五个坑是我维护模拟项目X的绿植领养模块时踩过的每个都按现象、原因、解决三段记录按出现频率排序。5.1 并发抢领导致同一株绿植被重复领养现象两个用户几乎同时提交领养申请数据库里出现两条领养记录指向同一株绿植两个人都收到了成功提示绿植实际只能归一方。原因没有用 4.2 节的原子 UPDATE而是先 SELECT t_plant 查 status发现是 0 再 UPDATE。两个线程都通过了 SELECT 检查先后执行无条件的 UPDATE后执行的把先执行的覆盖掉领养记录却插了两条。解决共三层。第一层是业务上的原子更新 UPDATE ... WHERE status0影响行数为 0 就返回业务错误这是主防线。第二层是 t_adoption 表加 uk_user_plant 唯一索引防止同一用户重复认领同一株绿植这是兜底。第三层是前端领养成功后立即刷新列表并禁用按钮减少人为重复点击。三层都做了数据层面基本不存在重复领养的可能。5.2 上传的绿植图片访问 404现象管理员在后台成功上传绿植照片数据库里存的路径也正确但浏览器打开图片地址直接 404页面上的绿植图片全是裂图。原因Spring Boot 默认只把 classpath:/static/ 等目录映射为静态资源。项目把图片写到服务器本地磁盘 /data/upload/这个路径不在映射范围内Tomcat 收到 /upload/xxx.jpg 的请求找不到对应文件返回 404。解决在 WebConfig 里追加一个资源处理器把 URL 前缀映射到磁盘目录Configuration public class WebConfig implements WebMvcConfigurer { Value(${upload.path}) private String uploadPath; Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/upload/**) .addResourceLocations(file: uploadPath); } }注意 addResourceLocations 的参数必须以 file: 开头且目录末尾要有斜杠写成 file:D:/data/upload/ 才对。upload.path 在 application.yml 里配部署环境不同就改配置不重新打包。数据库里存相对路径 /upload/plant/1001.jpg前端拼接域名直接访问。5.3 前后端分离部署后接口调用被跨域拦截现象前端跑在 8081 端口后端跑在 8080 端口浏览器控制台报 No Access-Control-Allow-Origin header is present所有接口都调不通。原因浏览器同源策略把端口不同的两个地址视为跨源Ajax 发出前会先发一个 OPTIONS 预检请求后端没返回 CORS 响应头浏览器就拦截了实际请求。在 Controller 上零散加 CrossOrigin 只能解决单个接口维护成本高。解决写一个全局 CORS 配置按环境指定允许的来源Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/api/**) .allowedOrigins(http://localhost:8081) .allowedMethods(GET, POST, PUT, DELETE) .allowCredentials(true) .maxAge(3600); } }三个注意点。allowCredentials(true) 不允许和 allowedOrigins(*) 同时使用带 Cookie 的请求必须写明具体来源域名allowedOrigins 里不要带结尾斜杠浏览器匹配时会精确到源如果项目里自定义了拦截器必须放行 OPTIONS 方法否则预检请求会被拦截器拦下来CORS 配置形同虚设。5.4 数据库连接池耗尽导致系统假死现象系统运行几天后某段时间所有接口响应从几十毫秒涨到几十秒甚至超时重启立刻恢复过一阵又复发。原因排查日志能看到 Connection is not available, request timed out 这类关键字说明连接池被借光了。常见诱因有三种事务方法里做了耗时的外部调用如远程图片上传连接被长期占用某条 SQL 漏了分页条件导致大结果集慢查询异常路径断了连接归还。解决两件事并行做。第一找出慢 SQL 和未释放连接的位置开启连接池的泄漏检测HikariCP 的 leak-detection-threshold 设置 60000 毫秒超过就打印告警日志能直接定位到具体 DAO 方法。第二合理调参spring: datasource: hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 30000 idle-timeout: 600000 leak-detection-threshold: 60000绿植领养这种规模的系统20 个连接完全够用。连接池调参只能缓解症状根治还得找到占用连接的那条代码路径。事务里不要做远程调用、大文件上传这类操作把它们挪到事务提交之后再执行。注意调连接池上限前先看数据库侧 max_connections连接池每个实例的最大连接数加起来不能超过 MySQL 的 max_connections否则数据库直接拒绝连接。5.5 日期字段显示成13位时间戳现象接口返回的 createTime 在前端显示成 1689241234567 这种 13 位数字用户完全看不懂。原因Jackson 默认把 java.util.Date 序列化为毫秒时间戳。如果实体字段是 LocalDateTime老版本 Jackson 不认可能直接报 Invalid definition for field新版本会输出数组前端拿到更懵。解决在 application.yml 里配全局格式spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai如果个别字段需要不同格式在字段上加 JsonFormat(pattern yyyy-MM-dd HH:mm, timezone GMT8)。反向的坑也要注意前端传日期字符串给后端时DTO 字段同样要加 JsonFormat否则 Jackson 解析 2024-06-01 12:00 失败直接报 400。数据库连接 URL 里的 serverTimezone 和这里的 time-zone、JsonFormat 里的 timezone 三者要保持一致否则时间显示会差 8 小时。6. 进阶技巧领养到期提醒与接口并发验证6.1 用Spring Schedule实现到期自动提醒绿植领养的运营闭环里到期归还提醒是不可少的一环。常见做法是用定时任务每天扫一遍领养记录把超过应还日期还没归还的记录标记为逾期同时给领养人发站内信。Component public class AdoptionRemindTask { Autowired private AdoptionMapper adoptionMapper; Autowired private MessageService messageService; // 每天 9 点执行 Scheduled(cron 0 0 9 * * ?) public void checkOverdue() { ListAdoption list adoptionMapper.findOverdueList(); for (Adoption a : list) { adoptionMapper.updateStatusToOverdue(a.getId()); messageService.sendRemind(a.getUserId(), a.getPlantId()); } } }启动类上记得加 EnableScheduling否则定时任务不生效。Spring 的 cron 是 6 段格式分别对应秒、分、时、日、月、周0 0 9 * * ? 表示每天 9 点整周字段用 ? 表示不指定。注意 findOverdueList 的 SQL 要加 WHERE status 0 AND return_time NOW()只处理领养中且已过期的记录避免重复发提醒。6.2 用JMeter压测领养接口验证数据一致性接口写完后我习惯用 JMeter 做一轮并发验证确认防重逻辑真的生效而不是只在单元测试里用单线程跑。做法很简单先在数据库里准备一株 status0 的绿植准备两个测试账号分别登录拿到 Session在 JMeter 里建一个线程组线程数设 2并发执行 POST /api/adoption/apply参数都指向同一株绿植跑完看聚合报告正确的预期是一个请求 code0 领养成功另一个 code1002 已被领养。如果两个都成功说明 5.1 的防重失效回去查 UPDATE 条件。压测通过后我再做一次全链路验证领养人登录后台能看到自己的领养记录管理员能查到绿植当前归属归还操作后绿植重新回到待领养列表。这套验证流程跑完核心链路才算真正闭环。做了多年一线开发我养成了一个习惯凡是涉及状态变更的接口写完代码先压一遍看并发表现再看慢查询日志。绿植领养这种小项目也一样少写一个 WHERE 条件就多一个翻车现场多测一轮并发就能少一次线上救火。排查问题别一上来就猜代码先看连接池告警和数据库日志定位效率高得多。希望帮到你。本文还有配套的精品资源点击获取