SpringBoot+MySQL智能停车场:状态机设计、计费与并发避坑
简介这是一份基于SpringBoot与MySQL的智能停车场管理系统完整源码包适用于商业综合体、写字楼、住宅小区等场景的停车数字化改造也适合SpringBoot学习者作为实战项目参考。压缩包共77个文件、约3.29MB包含11个Java核心源码、JSP页面、XML配置及MyBatis相关映射以及properties配置文件、依赖jar包、数据库脚本和说明文档覆盖前后端主要代码与部署环境要素。已有91人学习下载。系统实现了车位预约、动态停车费计算、车辆进出记录管理、用户权限分级控制、数据统计分析等功能并提供配套README和附赠资料便于理解项目结构并快速运行。通过研读源码可掌握SpringBoot整合MyBatis、JSPServlet开发模式、权限控制与数据库设计等关键技能对课程设计或毕业设计具有直接参考价值。1. 智能停车场管理系统为什么 SpringBoot MySQL 是最稳的组合做停车管理系统最怕的不是功能写不出来而是业务逻辑理不清。车位预约、停车费计费、车辆进出、权限控制、统计报表这五块业务单独拎出来都不难但合在一起就涉及一个核心问题状态怎么流转。车进来了要记入场时间车出去要算费用预约了车位但是超时没来怎么办月租车和临时车的计费规则怎么区分。这套状态流转要是靠散乱的 if else 堆出来后期改一个需求就是一场灾难。这份基于 SpringBoot 和 MySQL 的智能停车场管理系统源码把上述五块业务完整落地了。我拆完之后的感觉是它没有炫技用的都是 SpringBoot 生态里最主流的组件——Spring Data JPA 或 MyBatis 做持久层、Spring Security 做权限、MySQL 存业务数据但业务分层的写法很规范Controller、Service、Mapper、Entity 各司其职适合拿来改造成商业项目也适合作为毕业设计的骨架。系统解决的痛点很明确传统停车场靠人工登记、手动计费高峰时段出入口拥堵月卡用户和临时用户混杂管理困难。这套系统用数据库表和状态字段把整个停车流程串起来配合一个管理后台就能覆盖商业综合体、写字楼、住宅小区三类场景的自动化停车需求。适合正在做 JavaWeb 课设或毕设的学生也适合想快速搭一套内部停车管理原型的小团队参考。2. 系统架构与数据建模先看清表结构再动手改代码2.1 整体技术栈与分层结构这套系统的技术栈非常典型SpringBoot 负责提供 RESTful 接口MySQL 负责持久化存储前端部分如果源码里带页面就是 Thymeleaf 模板或 Vue 分离式开发如果只有接口文档那前端可以自己对接。我拆解时重点关注的是后端分层的清晰度。常见的 SpringBoot 工程会拆成这几层这套系统也不例外controller 层接收 HTTP 请求做参数校验和统一响应封装service 层承载业务逻辑比如计费计算、预约状态流转mapper/repository 层数据库操作MyBatis 写 SQL 或 JPA 写方法名entity/domain 层数据库表对应的实体类config 层配置类比如拦截器、权限过滤链、跨域配置启动流程没什么特别的配置好 application.yml 里的数据源Run 起来 SpringBoot 的 main 方法就行。如果源码里带前端静态页面会放在 resources/static 或 resources/templates 下由 SpringBoot 内嵌的 Tomcat 直接托管。2.2 核心数据库表设计六张表如何撑起整套业务我把源码里的 SQL 脚本一般放在 sql 目录或 resources/db 下过了一遍核心表通常就六张。这六张表之间的外键关联和状态字段是整个系统能不能跑通的根基我建议你拿到源码先不要急着跑打开 SQL 脚本逐张表看一遍。-- 车位表记录车位编号、所属区域、类型和当前状态 CREATE TABLE parking_space ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 主键, space_no VARCHAR(20) NOT NULL UNIQUE COMMENT 车位编号如 A-001, location VARCHAR(50) COMMENT 车位区域如 A区/B区, space_type TINYINT DEFAULT 0 COMMENT 0-普通车位 1-充电车位 2-无障碍车位, status TINYINT DEFAULT 0 COMMENT 0-空闲 1-占用 2-锁定, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) COMMENT 车位信息表;逻辑说明车位表是所有业务的基础status 字段是关键。车位预约时要判断 status 是否为 0空闲车辆进场时要把 status 改成 1占用预约超时释放车位时要把 status 改回 0。这个字段如果并发更新处理不好会出现两个用户同时约到同一个车位的翻车场景。-- 车辆进出记录表一次进场一条记录出场时回填出场时间和费用 CREATE TABLE parking_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, plate_number VARCHAR(20) NOT NULL COMMENT 车牌号, space_id BIGINT COMMENT 关联车位, entry_time DATETIME NOT NULL COMMENT 入场时间, exit_time DATETIME DEFAULT NULL COMMENT 出场时间, fee DECIMAL(10,2) DEFAULT 0.00 COMMENT 停车费用, status TINYINT DEFAULT 0 COMMENT 0-在场 1-已离场, CONSTRAINT fk_record_space FOREIGN KEY (space_id) REFERENCES parking_space(id) ) COMMENT 车辆进出记录表;逻辑说明停车费计算发生在出场这个动作上也就是 exit_time 不为空的时候。系统通过 update 语句把停车时长算出来按计费规则生成 fee。status 字段用于区分还在场内的车统计当前在场车辆数时只需要SELECT COUNT(*) FROM parking_record WHERE status 0。-- 预约记录表预约动作的核心涉及状态机流转 CREATE TABLE reservation ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL COMMENT 预约用户ID, space_id BIGINT NOT NULL COMMENT 预约车位ID, plate_number VARCHAR(20) COMMENT 预约车牌号, start_time DATETIME NOT NULL COMMENT 预约开始时间, end_time DATETIME NOT NULL COMMENT 预约结束时间, status TINYINT DEFAULT 0 COMMENT 0-待入场 1-已入场 2-已取消 3-超时失效, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) COMMENT 车位预约表;逻辑说明预约表的 status 是状态机设计的关键比用布尔值 0/1 表达能力强很多。预约后到场把 status 从 0 改成 1同时创建一条停车记录用户取消预约改成 2系统定时任务扫描发现预约开始时间已过但用户还没到场就改成 3 并释放车位。这里有个细节很多人会忽略 status3 的超时失效逻辑导致预约的车位被一直占用后面调用定时任务接口时你要重点验证这一条。除了这三张核心表剩下的用户表含角色字段、费率表按时段/按车型存计费单价、管理员操作日志表在源码里一般也会配齐。日志表容易被忽略但它对排查谁在什么时间改了车位状态这种问题是关键。2.3 关键索引与字段约束的坑我在拆这套源码时注意到一个细节car_number 这类查询频率极高的字段有些版本的建表脚本里居然没有加索引。如果你的 MySQL 数据量到了几十万条 records按车牌号查记录会直接全表扫描接口响应从毫秒级掉到秒级这个我后面在避坑章节还会展开。建议你拿到源码后先手动补充这几个索引ALTER TABLE parking_record ADD INDEX idx_plate_number (plate_number); ALTER TABLE parking_record ADD INDEX idx_status_entry (status, entry_time); ALTER TABLE reservation ADD INDEX idx_user_id (user_id); ALTER TABLE reservation ADD INDEX idx_space_status (space_id, status);参数说明idx_status_entry 这个组合索引是给当前在场车辆列表和按时间范围统计进出流量这两个高频查询用的where 条件里同时带上 status 和 entry_time 时组合索引能直接命中避免回表。所有索引字段的区分度越高效果越好status 这种只有 0/1 两个值的字段不要单独建索引必须和其他字段组合才有意义。3. 核心业务实战车位预约与停车费计算的实现路径3.1 车位预约的状态机逻辑与代码落地车位预约是整个系统里业务状态最复杂的一个模块难点不在接口怎么写而在状态流转的边界情况处理。预约一个车位用户可能按时来、提前来、迟到、不来、来了但想换车位每种情况对应不同的状态迁移路径。我看源码里通常是这样的处理流程用户发起预约请求 → 校验用户身份和权限 → 查询目标车位 status 是否为 0 → 创建预约记录status0→ 锁定车位把 parking_space.status 改成 2 锁定状态防止被其他人约走→ 用户到场后调用入场接口 → 预约状态改为 1车位状态改为 1 占用。Service public class ReservationService { Autowired private ParkingSpaceMapper spaceMapper; Autowired private ReservationMapper reservationMapper; Transactional(rollbackFor Exception.class) public Result createReservation(ReservationDTO dto) { // 1. 校验车位当前状态必须是空闲 ParkingSpace space spaceMapper.selectById(dto.getSpaceId()); if (space.getStatus() ! 0) { return Result.error(车位当前不可预约); } // 2. 创建预约记录初始状态为待入场 Reservation reservation new Reservation(); reservation.setUserId(dto.getUserId()); reservation.setSpaceId(dto.getSpaceId()); reservation.setPlateNumber(dto.getPlateNumber()); reservation.setStartTime(dto.getStartTime()); reservation.setEndTime(dto.getEndTime()); reservation.setStatus(0); reservationMapper.insert(reservation); // 3. 锁定车位防止并发预约 spaceMapper.updateStatus(dto.getSpaceId(), 2); return Result.success(预约成功); } }逻辑说明这段代码的关键在Transactional预约记录创建和车位锁定两个写操作必须在一个事务里否则会出现预约记录建了、车位锁失败的中间状态。往深一层说这里的锁其实是乐观锁的变种——通过更新 status 字段来占用资源。如果你要处理高并发预约场景还得在 update 语句里加WHERE status 0条件做 CAS 式的校验否则两个请求同时读到 status0都去更新就会超卖。参数说明Transactional(rollbackFor Exception.class)指定了运行时异常即回滚。有人会漏写 rollbackFor导致 catch 住了异常但事务没回滚数据就错乱了。这是血泪经验预约超卖和车位锁定失败大多是这个坑。入场时的接口逻辑也很直白根据预约记录里的车牌号和车位号校验预约状态是 0待入场然后创建一条 parking_record 记录把预约状态改成 1车位状态改成 1。注意入场时间要用数据库时间NOW()不要用应用服务器本地时间否则服务器时钟不准会直接导致后面计费出错。3.2 停车费计算规则临时车、月租车、免费时长怎么算停车费计算是这套系统里业务规则最密集的地方也是最容易跟产品经理吵起来的地方。我拆源码时发现费率配置通常在数据库里单独建表而不是硬编码在 Java 代码里。这个设计是明智的——不同停车场计费规则差异太大商业综合体普遍是15分钟内免费 首小时10元 之后每小时5元住宅小区可能是2小时内免费 每4小时2元写死在代码里改一次发一次版运维会疯掉。计费模块的核心是一个计算器类输入入场时间和出场时间输出费用金额。源码里的实现思路大致是public BigDecimal calculateFee(LocalDateTime entryTime, LocalDateTime exitTime, FeeRate rate) { // 1. 计算总停车分钟数 long minutes ChronoUnit.MINUTES.between(entryTime, exitTime); // 2. 扣除免费时长 long chargeableMinutes minutes - rate.getFreeMinutes(); if (chargeableMinutes 0) { return BigDecimal.ZERO; } // 3. 按时间片向上取整计费 long hours (chargeableMinutes 59) / 60; // 4. 封顶价格判断 BigDecimal fee rate.getHourlyRate().multiply(BigDecimal.valueOf(hours)); if (rate.getDailyCap() ! null fee.compareTo(rate.getDailyCap()) 0) { fee rate.getDailyCap(); } return fee; }逻辑说明这里最容易翻车的点在向上取整。停车 1 小时 01 分按小时计费应该是 2 小时还是 1 小时大多数停车场的规则是超出 1 分钟也按 1 小时算所以代码里用(chargeableMinutes 59) / 60做向上取整。但有些商场是超时 15 分钟内不额外收费的阶梯规则那这个计算器就满足不了了需要把分钟级的阶梯表引入进来逐段累加费用。你先确认好需求里的计费单位是小时还是分钟再决定改不改这个算法。参数说明FeeRate是一个费率实体字段一般包括 freeMinutes免费分钟数商业综合体常见 15、hourlyRate每小时的单价、dailyCap单日封顶价写字楼常见 50 或 80。月租车不走这个逻辑月租车判断方式是车辆表里有个到期时间字段入场时校验当前时间是否在有效期内直接放行出场时费用为 0。月租车和临时车混合场景下出场计费的完整判断顺序是先查车辆表看是不是月租车 → 月租车直接抬杆 → 临时车再算费率。还有一种首小时免费和跨天分段计费的组合规则处理跨天时不能简单用总时长乘以单价因为夜间和白天单价可能不同这就要在循环里按天切分计算。源码里如果已经写了分段计费函数就直接用没写的话你后续二次开发要重点补这块。3.3 MyBatis 动态 SQL 处理复杂查询这套系统中的停车记录查询往往带多个筛选条件车牌号模糊查询、日期范围、车辆类型、缴费状态。直接在 Java 代码里拼接 SQL 字符串又丑又容易出 SQL 注入问题。源码里的做法是 MyBatis 的if标签做动态 SQL这也是 MyBatis 比 JPA 更适合这类业务的原因之一——动态条件查询的写法更直观。select idselectRecordByCondition resultTypeParkingRecordVO SELECT * FROM parking_record where if testplateNumber ! null and plateNumber ! AND plate_number LIKE CONCAT(%, #{plateNumber}, %) /if if teststartTime ! null AND entry_time gt; #{startTime} /if if testendTime ! null AND entry_time lt; #{endTime} /if if teststatus ! null AND status #{status} /if /where ORDER BY entry_time DESC LIMIT #{offset}, #{pageSize} /select逻辑说明where标签有一个好处——它会自动去掉第一个条件前面多余的 AND不用你花心思处理首条 SQL 语句前缀的问题。三个查询条件里plateNumber 用了 LIKE 模糊匹配startTime 和 endTime 组成一个闭合的区间查询。注意和在 XML 里必须转义为gt;和lt;不转义 XML 直接解析报错这个误踩率很高。参数说明LIKE CONCAT(%, #{plateNumber}, %)用 CONCAT 函数拼接而不是直接在 Java 层拼好传进来是因为#{}预编译可以防 SQL 注入。如果换${}直接拼接虽然也能查出数据但用户输入%或_会干扰匹配结果输入 OR 11 --更是直接把你的用户表拖出来。永远不要在 MyBatis 里用${}拼接用户输入。分页这块如果源码用的是LIMIT #{offset}, #{pageSize}是物理分页数据量大时性能没问题。如果源码用的是 PageHelper那它是自动拦截 SQL 做 count 查询再拼 LIMIT两种方案都能用PageHelper 更省事但多一次 count 查询。这个热搜词里有人专门搜MyBatis 分页插件的用法重点就两个PageHelper 必须放在查询语句前一行执行且相邻两条查询不能共享同一个 PageHelper不然第二条查询会被错误分页。4. 权限控制与数据统计分析从登录鉴权到可视化报表4.1 基于角色的访问控制管理员、操作员、普通用户三级权限停车场系统里至少有三种角色超级管理员可以配置费率、查看全部报表、管理车位操作员比如岗亭收费员只能处理车辆进出场和收费普通用户车主只能预约车位、查看自己的停车记录。这套权限模型用 Spring Security 或拦截器都能实现就看源码里选的是哪一种。我通常的做法是用拦截器做简单的 URL 级别的权限控制加上注解做细粒度的方法级控制两层配合。如果源码里用的是 Spring Security核心配置在 SecurityConfig 类里关键代码大致如下Configuration EnableWebSecurity public class SecurityConfig { Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.authorizeHttpRequests(auth - auth .requestMatchers(/api/auth/**).permitAll() .requestMatchers(/api/admin/**).hasRole(ADMIN) .requestMatchers(/api/operator/**).hasAnyRole(ADMIN, OPERATOR) .requestMatchers(/api/user/**).authenticated() .anyRequest().authenticated() ).formLogin(form - form .loginProcessingUrl(/api/login) .permitAll() ).csrf(csrf - csrf.disable()); return http.build(); } }逻辑说明requestMatchers是路径匹配规则优先级从上到下。/api/admin/**只有 ADMIN 能访问/api/operator/**是 ADMIN 和 OPERATOR 都能访问。这种按 URL 前缀区分配权的方式很直观但如果源码里没按前缀分而是所有接口都在/api/**下那就得靠在方法上加PreAuthorize(hasRole(ADMIN))注解做细粒度控制。参数说明csrf.disable()在前后端分离项目里必须加否则 POST 请求会被 403 拦截。但要注意这是关闭了跨站请求伪造防护如果是互联网应用需要确认登录态是通过 Token 方式维护的不然会有安全风险。内网部署的停车场系统问题不大。还有一个常见的坑角色前缀。Spring Security 的hasRole(ADMIN)会自动拼接ROLE_前缀所以你的用户表里角色字段存的必须是ROLE_ADMIN而不是ADMIN。如果源码里角色字段存的是纯字符串需要手动加前缀或者改用hasAuthority(ADMIN)两行代码的差别排查起来能浪费半天。4.2 停车数据统计分析用 SQL 聚合代替手工报表数据统计分析模块是这套系统比较出彩的部分。管理者关心的核心指标无非是今日进场车辆数、当前在场车辆数、今日营收、车位利用率、高峰时段分布。这些用 SQL 的 GROUP BY 和聚合函数在数据库层面直接算出来比在 Java 内存里循环统计高效得多。-- 统计今日每小时的进场车辆数找出高峰期 SELECT DATE_FORMAT(entry_time, %Y-%m-%d %H:00) AS hour_point, COUNT(*) AS entry_count FROM parking_record WHERE entry_time CURDATE() AND entry_time DATE_ADD(CURDATE(), INTERVAL 1 DAY) GROUP BY hour_point ORDER BY hour_point; -- 统计当前车位利用率 SELECT COUNT(*) AS total_spaces, SUM(CASE WHEN status IN (1, 2) THEN 1 ELSE 0 END) AS occupied_spaces, ROUND(SUM(CASE WHEN status IN (1, 2) THEN 1 ELSE 0 END) * 100.0 / COUNT(*), 2) AS utilization_rate FROM parking_space;逻辑说明第一段 SQL 用DATE_FORMAT把入场时间格式化成小时级别的时间点再按这个时间点分组统计。得到的结果可以直接传给前端折线图展示一天里哪个时段车流最密集商场可以据此安排引导员。第二段 SQL 计算车位利用率时用了SUM(CASE WHEN)语法实现条件计数比先查总数再在 Java 里数一遍要少一次查询往返。参数说明CURDATE()取当前日期配合DATE_ADD加了 1 天间隔形成半开区间[今天零点, 明天零点)这样不会把明天进场的车也算进来。ROUND(..., 2)保留两位小数前端展示百分比时直接显示 87.50 这种格式。注意这里统计的是瞬时值如果要做连续 24 小时的利用率趋势就不能用这个 SQL得靠定时任务每小时采样一次存到一张统计表里。4.3 定时任务预约超时失效与停车记录归档预约超时失效这个功能靠用户操作触发是不行的用户不来你总不能一直等。必须在系统里跑一个定时任务扫描所有 status0 且 start_time 已经过去的预约记录把状态改成 3超时失效同时把对应车位释放回空闲状态。Scheduled(cron 0 */1 * * * ?) public void handleExpiredReservations() { // 1. 找出所有已超时且状态仍为待入场的预约 ListReservation expiredList reservationMapper.selectExpiredReservations(); for (Reservation r : expiredList) { // 2. 预约置为超时失效 reservationMapper.updateStatus(r.getId(), 3); // 3. 释放车位 spaceMapper.updateStatus(r.getSpaceId(), 0); } }逻辑说明Scheduled是 Spring Boot 自带的定时任务注解cron 0 */1 * * * ?表示每分钟的第 0 秒执行一次。每分钟扫一次是因为预约粒度和迟到容忍度通常不会精确到秒这个频率已经足够敏捷了。扫描逻辑要注意判断超时不能只比较 start_time还得有个宽限期。比如用户预约 10:00 入场可能 10:05 才到你 10:00:01 就把车位释放了用户到了发现车位没了就差评了。所以 SQL 里要写成start_time DATE_SUB(NOW(), INTERVAL 10 MINUTE)给用户留 10 分钟宽限。参数说明如果源码里没配EnableScheduling注解Scheduled是不会生效的。这个注解要加在 SpringBoot 启动类或者某个配置类上很多人复制了定时任务代码却忘了开开关接口一直不触发。检查顺序是先看启动类有没有EnableScheduling→ 再看任务方法所在类有没有被 Spring 扫描到 → 最后看 cron 表达式对不对。5. 部署与实施避坑指南MySQL 连接、MyBatis 映射、跨域与并发5.1 MySQL 版本选择与连接配置参数这套系统对 MySQL 版本没有苛刻要求5.7 和 8.0 都能跑但有一个区别你必须注意8.0 的驱动类名和连接参数跟 5.7 不一样。如果用 8.0驱动类要写com.mysql.cj.jdbc.Driver连接串要指定时区serverTimezoneAsia/Shanghai。这个时区参数没配会导致日期时间差了 8 个小时停车记录入场时间全部错乱计费直接爆表。spring: datasource: url: jdbc:mysql://localhost:3306/parking_system?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue username: root password: your_password_here driver-class-name: com.mysql.cj.jdbc.Driver参数说明characterEncodingutf8确保中文车牌和用户名不乱码useSSLfalse跳过 SSL 握手减少连接耗时serverTimezoneAsia/Shanghai强制数据库连接使用东八区时间。allowPublicKeyRetrievaltrue是 MySQL 8.0 特有的——新版驱动默认不允许从服务器获取公钥连不上时报Public Key Retrieval is not allowed加上这行就解决了。MySQL 安装这块很多人踩坑。如果你在 Windows 上装 MySQL 8.0用官方安装包一路 Next 就行但初始化时选对认证插件默认的 8.0 认证方式caching_sha2_password跟老版本 JDBC 驱动不兼容会报Unable to load authentication plugin。解决方案是安装时选Use Legacy Authentication或者连接串里加上allowPublicKeyRetrievaltrue并用新版驱动。这个跟上面的错误是同一个坑的两面我先提个醒。5.2 MyBatis 映射与 XML 路径的翻车现场MyBatis 项目最常见的报错是Invalid bound statement (not found)意思是 Java 接口方法找不到对应的 SQL 语句。原因通常就两类XML 文件没放在正确位置或者 mapper 接口和 XML 的 namespace 对不上。!-- 正确示范interface 的全限定名要跟 XML 的 namespace 完全一致 -- mapper namespacecom.parking.mapper.ParkingRecordMapper select idselectRecordByCondition resultTypecom.parking.entity.ParkingRecord SELECT * FROM parking_record /select /mapper排查路径先检查application.yml里 mybatis 配置的mapper-locations路径是否指向了 XML 所在目录常见配置是classpath:mapper/*.xml。再把 XML 头部 namespace 抄下来跟接口类的包路径逐一核对。如果源码里用的是注解式 SQLSelect写在接口方法上就不会走 XML报错原因就是注解里的 SQL 语法有问题。还有一个容易忽略的实体类字段和数据库列名的驼峰映射。如果数据库列名是plate_number实体字段是plateNumberMyBatis 不会自动转。需要在配置里开启驼峰映射map-underscore-to-camel-case: true或者在 SQL 里写别名AS plateNumber。不开这个开关查询结果是全 null但 SQL 单独拿到 Navicat 里跑又有数据玄学问题。5.3 跨域配置与前后端联调的坑如果这套系统带前端页面且是 Vue 独立部署的跨域问题跑不掉。浏览器请求后端接口报 CORS 错误解决方式有两种前端代理适合开发或后端加跨域配置适合生产。源码里通常会在 config 包里放一个 CorsConfig 类Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/api/**) .allowedOrigins(http://localhost:8081) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true); } }逻辑说明addMapping(/api/**)表示只对 /api 路径下的接口放开跨域限制不要/**全放开那样等于取消了浏览器同源策略任何网站都能请求你的接口。allowedOrigins是允许的前端地址allowCredentials(true)表示允许携带 Cookie。注意allowedOrigins不能配成*的同时allowCredentials(true)浏览器会直接拒绝这是一个不符合规范的处理。另外一个小提示POST 请求跨域时浏览器会先发一个 OPTIONS 预检请求如果你的拦截器或 Spring Security 配置把 OPTIONS 也拦截了前端会收到 401 而不是真正接口的响应。处理方式就是 SecurityConfig 里.requestMatchers(HttpMethod.OPTIONS, /**).permitAll()。5.4 并发场景下防止车位超卖这类系统的并发量通常不高一个停车场几百个车位同一时刻预约的请求量撑死几十个。但是如果有多个出入口同时放行入场接口和预约接口同时操作同一张车位表还是有可能出现数据错乱。最典型的场景用户 A 和用户 B 同时预约车位 002两个请求都查到 status0然后都插入了预约记录。-- 用 UPDATE 行数判断是否抢到受影响行数为 0 说明被别人抢走了 UPDATE parking_space SET status 2 WHERE id #{spaceId} AND status 0;这个方案的逻辑是让数据库替你做判断。WHERE status 0确保只有当前状态是空闲的车位才能被锁定如果另一个请求先更新了这个 UPDATE 影响的行数就是 0Java 层判断rows 0就返回车位已被预约。MyBatis 的update方法返回的是 int直接拿这个返回值做判断即可。这就是最常见的乐观锁方案不需要引入 Redis 分布式锁停车场场景足够了。要是你的业务量真的大到需要分布式锁那也是后面的事现在别过度设计。6. 二次开发进阶从这套源码扩展出你的生产级系统拿到一套能跑的源码只是第一步真要部署到商业综合体去至少还要补四块能力对账通知、异常自动恢复、操作审计、监控告警。这里我挑两个最实用也最容易被忽视的方向展开一个是计费对账脚本解决用户投诉多扣费时你需要快速定位的能力另一个是模拟并发预约的脚本用来验证超卖问题是否真的堵住了。先看计费对账的 SQL。这套系统里如果用户发起缴费后因为网络原因回调失败就会出现在场记录已出但费用没结算的脏数据。你需要跑一次巡检SELECT r.plate_number, r.entry_time, r.exit_time, TIMESTAMPDIFF(MINUTE, r.entry_time, r.exit_time) AS total_minutes, r.fee, f.id AS fee_rule_id FROM parking_record r LEFT JOIN fee_rule f ON r.fee_type f.type WHERE r.status 1 AND r.fee IS NULL AND r.exit_time IS NOT NULL;参数说明TIMESTAMPDIFF(MINUTE, ...)计算入场出场之间的分钟差这个值拿来和费用做交叉验证——如果计费金额跟分钟数对不上说明计费算法的边界条件有 bug。fee_rule_id用来追溯当时计费用的是哪条费率规则而不是只看最终金额。定位到异常记录之后补单操作要单独写接口而不是直接改数据库。后端补一个recalculateFee接口传入 recordId内部重新走一遍计费流程覆盖原 fee 字段同时写一条操作日志。直接改库看起来快但操作日志缺失会让你后续排查其他问题时少了一条关键线索。再来看并发预约的模拟验证。这是一个 Java 的并发测试代码用线程池模拟 50 个用户同时抢 1 个车位用来验证乐观锁是否真的拦住了超卖ExecutorService pool Executors.newFixedThreadPool(20); CountDownLatch start new CountDownLatch(1); CountDownLatch end new CountDownLatch(50); for (int i 0; i 50; i) { int userId i; pool.submit(() - { try { start.await(); Result result reservationService.createReservation(userId, 10086); System.out.println(用户 userId - result.getMsg()); } catch (Exception e) { e.printStackTrace(); } finally { end.countDown(); } }); } start.countDown(); end.await(); pool.shutdown();逻辑说明CountDownLatch在这里的作用是让 50 个线程同时起跑而不是一个一个排队执行这样才能模拟真实的并发冲击。如果乐观锁生效最终 50 个请求里只有 1 个返回预约成功其余 49 个返回车位已被预约。如果出现了多个成功说明UPDATE ... WHERE status 0没写对或者事务隔离级别有问题。参数说明线程池大小 20 意味着同一时刻最多 20 个线程在跑50 个任务会被分三批执行。测试通过后把这个脚本的生产版本删掉别留在工程里如果有人误触发会发出去 50 个真实的预约请求。这种脚本我会留着放到 test 目录下用 JUnit 加Disabled注解禁用下次要验证再打开。从那以后我每次拿到新系统第一件事就是写并发测试脚本验证写操作的原子性走一遍才敢动后续的二次开发。动手前建议你先把 SQL 脚本导入 MySQL、配好连接串、确认启动类能跑起来再逐模块去阅读和改造。希望帮到你。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联
返回资讯列表 →