ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Spring Boot宠物领养系统实战:状态机、乐观锁与并发控制

Spring Boot宠物领养系统实战:状态机、乐观锁与并发控制 简介一份基于Spring Boot的宠物领养管理系统完整项目资源适合正在学习Spring Boot全栈开发的初学者或需要完成课程设计、毕业设计的同学使用。系统围绕宠物信息管理、用户管理、领养申请处理与系统管理四大模块展开采用B/S架构与前后端分离模式覆盖注册登录、宠物资料展示、申请审核等完整业务闭环。资源压缩包共132个文件约2.5MB以Java源码、JS逻辑、HTML页面和CSS样式为主同时包含XML配置、属性文件、字体图标、Maven构建文件等基本可还原一个可运行的项目骨架。目前已有79人学习下载。通过阅读代码可掌握Spring Boot Spring MVC JPA的典型分层写法、RESTful API设计思路以及前端页面与后端接口的对接方式对理解真实企业级项目结构有直接帮助。1. 宠物领养系统的核心是状态不是 CRUD基于springboot的宠物领养管理系统听起来是一个标准的后台增删改查项目真正做起来才发现最磨人的不是查列表而是状态宠物从「可领养」变成「已被申请」再从「审核中」变成「已领养」中间任何一步断了数据就对不上。我接手过的领养项目里最常见的线上问题就是两个人同时申请同一只宠物最后都收到了「申请成功」的提示宠物却只有一只。这个系统要解决的就是围绕 pet宠物、apply申请、user用户三条线把状态流转、并发控制、图片存储和领养后的回访提醒串起来。下文按 Spring Boot 实际开发顺序展开从表结构设计讲到事务边界再讲文件上传和定时任务里的坑适合正在用 Spring Boot 写此类系统的同学直接对照落地。2. 建表先行宠物表、申请表的边界要划清楚2.1 状态字段为什么不能只存字符串宠物领养系统里最常见的错误设计是给 pet 表加一个status字段直接存0、1、2然后在 Service 层写if(1.equals(status))。这种写法的隐患不是可读性差而是状态之间没有约束任何一段代码都可以把状态从「可领养」直接改成「已领养」中间跳过申请和审核环节。一旦出现这种脏数据业务上无法追溯前端界面展示的宠物状态也会和真实流转不一致。正确做法是把状态建模成枚举并在业务入口统一控制迁移路径。绑定在具体业务动作上的合法迁移例如当前状态合法动作目标状态说明AVAILABLE用户提交申请LOCKED申请成功后宠物先被锁定LOCKED管理员驳回申请AVAILABLE驳回后释放宠物LOCKED管理员确认领养ADOPTED终态进入回访阶段ADOPTED无无不再参与流转这里的核心思路是状态不是让别人随便改的字段而是被方法保护的资源。2.2 最小但完整的表结构 SQL以三张表为例user存用户和实名信息pet存宠物档案adoption_apply存每一次申请记录。apply 表必须有独立的apply_no申请单号后续审核、回访、投诉都以这个单号为准。CREATE TABLE pet ( id BIGINT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(32) NOT NULL, species VARCHAR(16) NOT NULL COMMENT cat/dog, breed VARCHAR(32), age TINYINT NOT NULL DEFAULT 0, health_status VARCHAR(64), status VARCHAR(20) NOT NULL DEFAULT AVAILABLE, shelter_id BIGINT NOT NULL, version INT NOT NULL DEFAULT 0, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_pet_status (status), KEY idx_pet_shelter (shelter_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE adoption_apply ( id BIGINT AUTO_INCREMENT PRIMARY KEY, apply_no VARCHAR(32) NOT NULL, pet_id BIGINT NOT NULL, applicant_id BIGINT NOT NULL, reason VARCHAR(255), status VARCHAR(20) NOT NULL DEFAULT PENDING, reject_reason VARCHAR(255), interview_time DATETIME, version INT NOT NULL DEFAULT 0, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_apply_no (apply_no), KEY idx_apply_pet (pet_id), KEY idx_apply_user (applicant_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;字段设计上有两个容易被忽略的地方。第一version字段是给乐观锁用的后面讲并发控制时会用到建表时就要预留不然后期加列要锁表。第二apply_no必须唯一它承担了对外凭证的作用用户打电话来问「我的申请单号是多少」时查这个字段就够了。shelter_id用于区分多个收容机构或门店是以后做数据隔离的基础现在不建后面拆分会很痛。2.3 ORM 选型MyBatis-Plus 还是 JPA这个体量的系统我一般选 MyBatis-Plus。原因很实际申请列表、宠物列表这种查询经常要带多条件动态拼接MyBatis-Plus 的LambdaQueryWrapper可以直接在业务里条件拼装不需要为每个查询单独写 XML。JPA 的 Specification 也能做但团队里如果还有人没写过 JPASpecification 的维护门槛明显更高。维度MyBatis-PlusJPA单表 CRUD继承BaseMapper即可需要写 Repository动态查询LambdaQueryWrapper 直观Specification 学习曲线陡乐观锁Version注解支持支持但字段处理要小心多表复杂查询XML 或注解 SQLJPQL / 原生 SQL2.4 自动装配帮我们省掉的样板代码Spring Boot 的核心机制是自动装配原理上通过META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports文件加载DataSourceAutoConfiguration、MybatisPlusAutoConfiguration等配置类。你引入mybatis-plus-boot-starter后SqlSessionFactory、SqlSessionTemplate都已经自动建好真正要写的只有 Mapper 扫描和一个分页插件Configuration MapperScan(com.example.adopt.mapper) public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }MapperScan指定 Mapper 接口所在包让 MyBatis 自动为每个接口生成代理实现类分页拦截器把Page参数转换成数据库方言的LIMIT语句。这里有个常见坑如果项目里同时存在多个数据源自动装配会直接失效需要手动指定每个数据源的MapperScan和事务管理器本系统单数据源自动装配就够。3. 核心链路申请到领养的状态机实现3.1 为什么状态流转要收敛到一个 Service 里我在代码评审里见过最危险的写法是AdoptController里直接注入PetMapper然后自己updateById去改宠物状态。这样做的后果是提交申请的逻辑散落在 Controller、定时任务、管理后台三处改状态规则时要找全所有调用点漏一个就是线上事故。状态流转必须收敛到 Service 层一个方法里Controller 只负责收参数和做权限判断规则变更时只需要改一个入口。先定义一个动作枚举把业务意图和具体 SQL 解耦public enum AdoptAction { APPLY, // 提交领养申请 REJECT, // 驳回 ADOPT; // 确认领养 }然后在 Service 里校验当前状态是否允许该动作不允许直接抛异常。这个校验逻辑虽然简单但它保证了不管谁调用submitApply都不可能把一只已经 ADOPTED 的宠物变成 AVAILABLE。3.2 提交申请一个事务里完成状态锁定和申请单创建提交申请时有两个操作把宠物状态从 AVAILABLE 改成 LOCKED同时插入一条申请记录。这两个操作要么都成功要么都失败否则会出现「申请单建了宠物还是可领养」的脏数据。用Transactional包住是最直接的方案Service RequiredArgsConstructor public class AdoptionService { private final PetMapper petMapper; private final UserMapper userMapper; private final AdoptionApplyMapper applyMapper; Transactional(rollbackFor Exception.class, timeout 10) public ApplyResult submitApply(ApplyRequest req) { User applicant userMapper.selectById(req.getApplicantId()); if (applicant null || !ACTIVE.equals(applicant.getStatus())) { return ApplyResult.failed(申请人状态异常); } // 乐观锁 状态条件一次 update 同时完成校验和锁定 int rows petMapper.lockAvailable(req.getPetId(), req.getVersion()); if (rows 0) { return ApplyResult.failed(这只宠物刚刚被其他人申请了); } AdoptionApply apply new AdoptionApply(); apply.setApplyNo(generateApplyNo()); apply.setPetId(req.getPetId()); apply.setApplicantId(req.getApplicantId()); apply.setReason(req.getReason()); apply.setStatus(PENDING); applyMapper.insert(apply); return ApplyResult.ok(apply.getApplyNo()); } }对应的 Mapper 用注解 SQL 实现条件更新Update(UPDATE pet SET status LOCKED, version version 1 WHERE id #{petId} AND status AVAILABLE AND version #{version}) int lockAvailable(Param(petId) Long petId, Param(version) Integer version);这段代码的关键在于lockAvailable不是先查后改而是把「宠物是否可领养」的校验直接放进 UPDATE 的 WHERE 条件。如果两个请求同时带着相同的version进来MySQL 的行锁会让第二个 UPDATE 等待第一个提交后版本号已经变成version 1第二个的 WHERE 条件不成立影响行数为 0于是返回「刚刚被申请了」。这是乐观锁在并发场景下的标准用法避免了SELECT后UPDATE之间的竞态窗口。Transactional(timeout 10)给整个事务设置了 10 秒超时防止慢 SQL 长时间占用数据库连接。这里要注意超时计时从事务内第一条 SQL 执行开始算如果事务方法前面有大量业务计算这段时间并不计入超时所以真正的兜底是数据库连接池和 SQL 本身的超时配置。3.3 Controller 只做两件事参数校验和权限标记Controller 层我一般保持很薄核心逻辑全部在 Service。参数上使用 Jakarta Validation 注解避免在方法体里写一长串if (req.getPetId() null)RestController RequestMapping(/api/adopt) RequiredArgsConstructor public class AdoptController { private final AdoptionService adoptionService; PostMapping(/apply) public ResultApplyResult submitApply(Valid RequestBody ApplyRequest req) { return Result.ok(adoptionService.submitApply(req)); } }Data public class ApplyRequest { NotNull(message petId不能为空) private Long petId; NotNull(message version不能为空) private Integer version; NotBlank(message 申请理由不能为空) Size(max 255, message 申请理由不能超过255字) private String reason; NotNull private Long applicantId; }Valid触发 DTO 上的校验注解校验失败时 Spring MVC 抛出MethodArgumentNotValidException在你的全局异常处理器里统一转成Result.failed(message)返回给前端。这么做的一个好处是后续要给申请理由加敏感词过滤只需要在 DTO 上加自定义校验注解Controller 不需要动。3.4 三个必调的 Spring Boot 参数这个链路跑起来后下面几个配置直接影响稳定性我一般在项目初始化时先写好spring: datasource: hikari: maximum-pool-size: 10 minimum-idle: 2 connection-timeout: 3000 max-lifetime: 1800000 transaction: default-timeout: 10参数含义建议值说明maximum-pool-size连接池最大连接数10小型系统 10 足够过大反而增加数据库压力connection-timeout获取连接的超时时间3000ms超过即抛异常避免请求无限等待spring.transaction.default-timeout全局事务默认超时10s防止异常事务长时间占用连接连接池大小的设置原则是((core_count * 2) effective_spindle_count)。本系统一次请求只消耗一个数据库连接maximum-pool-size设为 10 足以支撑几十并发真正需要关注的是connection-timeout如果业务高峰期连接池被打满新请求会在 3 秒内快速失败而不是全部堆积在应用里把内存耗尽。4. 图片上传本地目录、配置绑定与访问安全4.1 宠物照片放哪里宠物领养系统必然要上传宠物照片和用户身份证件。常见做法有三种本地磁盘、阿里云 OSS、MinIO。个人开发和小团队部署我建议先用本地磁盘配合 Nginx 或 Spring 静态资源映射提供访问等确实有多机部署、图片永久化存储需求时再切对象存储。方案优点缺点适用场景本地磁盘零成本、代码简单不能水平扩展、需手动备份单机部署、毕设、内部测试阿里云 OSS弹性、自带 CDN按量付费、有学习成本正式对外运营MinIO私有化、S3 兼容需自维护服务高可用内网部署、政务场景4.2 用 ConfigurationProperties 管理上传参数上传目录、允许的后缀、大小限制这些参数不要硬编码在业务代码里。Spring Boot 的ConfigurationProperties可以把 yml 里的配置绑定到类型安全的 Java 对象上adopt: upload: root-dir: ./upload max-size-mb: 5 allowed-extensions: - jpg - jpeg - png - webpConfiguration ConfigurationProperties(prefix adopt.upload) Validated Data public class UploadProperties { NotNull private Path rootDir Path.of(./upload); Min(1) private long maxSizeMb 5; private ListString allowedExtensions List.of(jpg, jpeg, png, webp); }prefix adopt.upload让 Spring 自动把 yml 里adopt.upload下的键值对映射到同名字段。这里有两个细节一是rootDir用Path类型Spring 的ConfigurationProperties会自动完成字符串到 Path 的转换二是这个类上加了ValidatedNotNull、Min会在应用启动阶段就校验配置如果忘记配置adopt.upload.root-dir启动直接报错而不是运行时才炸。4.3 静态资源映射与路径穿越防护配置好存储目录后怎么让前端直接通过 URL 访问图片添加一个资源映射Configuration RequiredArgsConstructor public class WebFileConfig implements WebMvcConfigurer { private final UploadProperties uploadProperties; Override public void addResourceHandlers(ResourceHandlerRegistry registry) { String location file: uploadProperties.getRootDir().toAbsolutePath() /; registry.addResourceHandler(/files/**) .addResourceLocations(location); } }/files/**是前端访问的 URL 前缀file:协议指定文件系统路径注意最后要带斜杠。这个写法在 Linux 下没问题但有个安全陷阱如果文件名里包含../攻击者可以构造/files/../application.yml去读取应用配置文件。因此上传接口里必须对原始文件名做处理只保留文件基本名public static String safeFileName(String originalFilename) { if (originalFilename null) return null; String name Path.of(originalFilename).getFileName().toString(); if (name.contains(..) || name.startsWith(.)) { throw new IllegalArgumentException(非法文件名); } return name; }Path.of(name).getFileName()会把所有路径前缀剥掉只留最后一段从根上杜绝了路径穿越然后再过滤..和以.开头的隐藏文件。这个处理放哪一层都可以但一定要在所有保存文件的地方统一调用不要只在 Controller 里做一次就以为安全。4.4 按角色过滤数据是业务需求不是安全需求领养系统天然有两种角色普通用户和管理员。用户只能看到自己的申请记录管理员能看到全部并执行审核。最简单的实现是定义一个RequireRole注解配合 HandlerInterceptor 在进入 Controller 之前检查角色Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface RequireRole { String value(); // ADMIN / USER }拦截器里拿到当前登录用户的角色与注解要求的角色比对不匹配返回 403。这个方案比在 Service 里写一堆if (currentRole.equals(ADMIN))干净得多也方便后续统一接入 Spring Security。数据权限部分则放在 Service 查询条件里用户查申请列表时强制拼上applicant_id 当前用户ID而不是让前端传一个参数来指定查谁。5. 三个必须验证的边界并发、定时任务、全链路自测5.1 用并发请求验证乐观锁生效写完提交申请接口后不要只在 Postman 里点一次。开两个终端同时发起两个请求模拟两个人抢同一只宠物for i in 1 2; do curl -s -X POST http://localhost:8080/api/adopt/apply \ -H Content-Type: application/json \ -d {petId:1,version:0,reason:想养一只猫,applicantId:1} done wait预期结果是一个返回成功另一个返回「这只宠物刚刚被其他人申请了」。验证完成后去数据库看pet表的version是否从 0 变成了 1adoption_apply表里应该只有一条有效申请。如果两个请求都返回成功问题几乎都在lockAvailable的 UPDATE 语句上检查 WHERE 条件是否真的同时包含status和version两个条件。5.2 领养成功后的 7 天回访定时任务宠物被领养不代表系统工作结束。真实业务里救助站通常要求领养后 7 天回访一次确认宠物状态。这个场景用 Spring Boot 的Scheduled注解实现最直接Component EnableScheduling RequiredArgsConstructor public class FollowUpJob { private final AdoptionApplyMapper applyMapper; Scheduled(cron 0 0 9 * * ?) Transactional public void remindUnfollowedAdoptions() { ListAdoptionApply list applyMapper.findAdoptedBefore( LocalDateTime.now().minusDays(7)); for (AdoptionApply apply : list) { // 调用短信服务或生成待办任务 // sendReminder(apply.getApplicantId(), apply.getApplyNo()); } } }cron 0 0 9 * * ?表示每天上午 9 点执行。EnableScheduling开启调度能力Transactional保证查询操作在一个事务里完成防止部分读取到不一致状态。这里有个要注意的坑定时任务里的this调用不会经过 AOP 代理所以如果任务方法内部调用同一类的另一个Transactional方法事务是不会生效的需要把要调用的方法放到另一个Component里。5.3 用一条 SQL 验证完整状态流转我把这个查询固定放在项目的文档里每次改完状态机代码就跑一遍确认状态没有跳变SELECT apply_no, status, create_time FROM adoption_apply WHERE pet_id 1 ORDER BY create_time; SELECT id, status, version FROM pet WHERE id 1;正常请求链路应该是先看到pet.status LOCKED再看到申请单状态从PENDING到APPROVED领养确认后pet.status ADOPTED。如果出现pet.status ADOPTED但申请单状态还是PENDING说明在某个地方直接改了 pet 表而没走 Service回到代码里搜索所有调用了PetMapper.updateById的地方把直改状态的入口全部删掉。状态机没有黑魔法靠的就是这些入口的统一。本文还有配套的精品资源点击获取
返回列表