简介:基于SSM+MySQL的停车场管理系统设计与实现资料包,专为毕业设计、课程设计及Java Web学习者打造,覆盖车辆进出管理、停车位监控、收费计费、统计报表等核心业务。资源内含项目全套源码、设计文档、部署说明和视频演示,共1223个文件,其中Java源码与JSP页面构成后端业务与动态视图,JS、CSS及图片资源用于前端交互与界面展示,JAR包为项目依赖库,SQL脚本则完成数据库表结构与初始化数据,整体压缩包42.73MB,结构清晰便于直接导入IDE运行调试。系统采用Spring+SpringMVC+MyBatis框架整合MySQL,分层设计与模块化封装有利于理解SSM协同原理及实际项目开发流程,同时包含车辆进场出场记录、停车时长自动计费、车位状态监控、收入统计等模块,数据库设计规范且考虑安全与扩展性。已有378人学习下载,适合作为毕业设计参考或SSM项目练手,借助配套文档和演示视频可快速核对功能实现与部署步骤,节省从零搭建的时间。
1. 基于SSM+mysql的停车场管理系统,难点其实在计费状态上
SSM 框架配合 MySQL 做停车场管理系统,听起来就是一套标准的业务 CRUD:车位表、订单表、用户表,配上增删改查。但真正跑过这类系统的人会有个共同感受——入场扫码很顺,出场算钱时却容易对不上账。原因在于停车计费不是简单地把「入场时间」和「出场时间」相减乘单价,它牵扯到场内车位状态如何在并发下保持正确、跨天跨时段怎么换费率、免费时长和封顶金额按什么优先级叠加。这些规则如果散落在 Service 层的 if-else 里,改起来就会像打地鼠。
这篇内容围绕 SSM(Spring + SpringMVC + MyBatis)与 MySQL 的组合,从表结构设计、框架整合、状态机与并发控制,一直讲到上线前的验证手段。适合两类人:一类是拿它做课程设计或毕设,另一类是在公司内部做小型停车管理工具、想少走弯路的一线开发。MySQL 相关的索引、存储过程、事务隔离级别会穿插在业务场景里讲,不是孤立地罗列语法。
2. 停车场业务拆解:先想清车位、订单、计费规则这三类实体
2.1 用表结构把「车位占用」和「计费订单」分开建模
常见新手做法是一张表搞定一切:车位表里挂当前入场时间、车牌号、应收金额。表面看字段少了、查询方便了,但两个核心问题会立刻暴露。第一个问题是历史数据被覆盖,车走了之后,之前的停车记录没了,月底统计车场营收只能靠导出 Excel 手工拼。第二个问题是并发安全,两个保安同时在场口操作出场,后写的人可能覆盖先写的人,账单直接丢失。
更稳妥的做法是把实时状态和流水数据分开建表。车位表只负责「这个车位现在是否空闲」,真正计费时读的是订单表。下面的建表语句是这类系统里最常见的一组基础结构:
CREATE TABLE parking_space ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '车位ID', space_no VARCHAR(20) NOT NULL COMMENT '车位编号,如 A-001', area VARCHAR(20) DEFAULT 'A区' COMMENT '所属区域', status TINYINT NOT NULL DEFAULT 0 COMMENT '0空闲 1占用 2预约', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_space_no (space_no), KEY idx_status (status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车位表'; CREATE TABLE parking_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '订单ID', order_no VARCHAR(32) NOT NULL COMMENT '业务订单号', space_id BIGINT NOT NULL COMMENT '车位ID', plate_no VARCHAR(20) NOT NULL COMMENT '车牌号', entry_time DATETIME NOT NULL COMMENT '入场时间', exit_time DATETIME NULL COMMENT '出场时间', fee_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '应收金额', pay_status TINYINT NOT NULL DEFAULT 0 COMMENT '0未支付 1已支付 2已退款', UNIQUE KEY uk_order_no (order_no), KEY idx_plate_entry (plate_no, entry_time), KEY idx_entry_time (entry_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='停车订单表';这段建表 SQL 里有几个值得注意的参数设计。entry_time允许为空?不行,任何订单都必然有入场时间,所以用 NOT NULL;exit_time在入场时确实未知,所以允许为 NULL,这符合「订单创建在前、结算发生在后」的业务顺序。索引上,idx_plate_entry覆盖了「按车牌查历史记录」的常见查询场景,idx_entry_time则服务运营侧「查某个时段入场了多少车」的统计需求。
订单表里的pay_status单独独立出来,不跟车位的status耦合。车位状态是物理状态,订单支付状态是资金状态,混在一个枚举里会导致「车走了但没付钱」时不知道该把车位标记成什么。
2.2 设计 status 枚举与时间字段,避免事后改表
MySQL 里修改表结构本身不复杂,ALTER TABLE 可以加列、改类型,但如果业务已经上线、表里积累了几十万条订单,再回头加状态字段,代价就完全不同了。所以建表之前要把状态枚举定义清楚。
停车管理系统的订单状态通常不会超过五个:进行中(还没出场)、待支付(已出场未付款)、已支付、已退款、已作废。车位状态更简单,空闲、占用,最多加一个预约。把这些状态用整数存,代码里用常量类或者枚举类做映射,不要直接散落魔法数字。
时间字段的设计同样容易埋坑。entry_time和exit_time都用 DATETIME,但要注意跨时区部署的问题。如果将来部署到云上、数据库和服务器不在同一时区,建议统一用DATETIME存本地时间,项目里全局配置一个时区变量,展示层再转换。MySQL 的CURRENT_TIMESTAMP默认值只用在一处就够了,其余时间字段交给应用层写入,避免数据库时钟漂移导致计费偏差。
如果确实需要调整已有表,尽量用一条 ALTER 语句完成多个变更,减少锁表时间:
ALTER TABLE parking_order ADD COLUMN discount_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '优惠金额', ADD COLUMN coupon_id BIGINT NULL COMMENT '优惠券ID', MODIFY COLUMN fee_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '实收金额(原价-优惠)', ADD KEY idx_pay_status (pay_status);这条语句把「加列」「改列注释」「加索引」合并成一次执行。注意MODIFY COLUMN fee_amount会重建该列,如果表数据量大,执行时间会比较长,建议在业务低峰期操作,并先在测试库跑一遍确认耗时。
2.3 索引怎么加:订单表按入场时间,车位表按状态
很多 MySQL 慢查询不是 SQL 写错,而是索引没跟上。停车场管理系统里最高频的查询是这几个:查询某车位当前是否空闲、查询某车牌最近的入场记录、统计某段时间内的收入。对应到索引就是前面建表时的idx_status和idx_plate_entry。
这里有个容易忽略的点:idx_plate_entry (plate_no, entry_time)是联合索引,查询时如果只按entry_time过滤,这个索引用不上,只能走idx_entry_time。所以建索引前要梳理真实查询条件,把等值查询的字段放前面、范围查询放后面,这是 MySQL 索引设计的基本原则。
不要为了「查询快」而给每个字段单独建索引。索引会拖慢 INSERT 和 UPDATE,停车场系统写多读多,索引过多会让入场、出场时的写入明显变慢。一般原则是单表索引控制在五个以内,高频查询路径各覆盖一组联合索引即可。
3. 搭建 SSM 工程:从依赖到事务,一条链路配通
3.1 工程结构与依赖声明
SSM 项目的标准结构分三层:Controller 接收请求、Service 处理业务、Mapper 访问数据库。模型层用 POJO 对应表结构,VO 对外返回视图数据。工程初始化时,Maven 的 pom.xml 里需要声明 Spring、SpringMVC、MyBatis、MySQL 驱动和连接池这几组依赖。
下面是一份最小可运行的依赖清单,版本号只写主版本,实际使用时可根据仓库中最新稳定版调整:
<dependencies> <!-- Spring 核心与 MVC --> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-context</artifactId> <version>5.3.x</version> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-webmvc</artifactId> <version>5.3.x</version> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-jdbc</artifactId> <version>5.3.x</version> </dependency> <!-- MyBatis 整合 Spring 的桥接包 --> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis</artifactId> <version>3.5.x</version> </dependency> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis-spring</artifactId> <version>2.1.x</version> </dependency> <!-- MySQL 驱动与连接池 --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.x</version> </dependency> <dependency> <groupId>com.alibaba</groupId> <artifactId>druid</artifactId> <version>1.2.x</version> </dependency> </dependencies>连接池选 Druid 是常见做法,它在监控和 SQL 防注入上有现成支持,开发阶段可以打开 Druid 的 Web 监控页直接看慢查询和活跃连接数。MyBatis 和 Spring 整合必须引入mybatis-spring这个桥接包,缺了它SqlSessionFactory无法注入 Spring 容器。MySQL 驱动用 8.x 时要注意驱动类名是com.mysql.cj.jdbc.Driver,不是 5.x 时代的com.mysql.jdbc.Driver。
3.2 数据源、事务与 MyBatis 的配置入口
SSM 整合时,Spring 的配置文件是核心。数据源、事务管理器、SqlSessionFactory 三者的配置顺序有讲究:先配数据源,再让事务管理器引用它,最后把数据源交给 SqlSessionFactory 生成 MyBatis 的会话。以下是一份简化的 Spring 配置:
<context:component-scan base-package="com.parking"/> <bean id="dataSource" class="com.alibaba.druid.pool.DruidDataSource"> <property name="driverClassName" value="com.mysql.cj.jdbc.Driver"/> <property name="url" value="jdbc:mysql://localhost:3306/parking_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai"/> <property name="username" value="root"/> <property name="password" value="your_password"/> <property name="initialSize" value="5"/> <property name="maxActive" value="20"/> </bean> <bean id="transactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager"> <property name="dataSource" ref="dataSource"/> </bean> <bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean"> <property name="dataSource" ref="dataSource"/> <property name="mapperLocations" value="classpath:mapper/*.xml"/> </bean> <tx:annotation-driven transaction-manager="transactionManager"/>JDBC URL 里的serverTimezone=Asia/Shanghai是 MySQL 8 连接时最容易踩的坑,漏了它会报时区相关的异常。useUnicode=true&characterEncoding=utf8保证中文车牌和用户名不乱码,注意 XML 里 & 符号要写成&。mapperLocations指定了 MyBatis 的 XML 文件目录,所有 SQL 映射文件放在 classpath 下的 mapper 包里,后续新增 Mapper 不需要再改配置。
事务管理用的是 Spring 的DataSourceTransactionManager,配合@Transactional注解在 Service 方法上声明事务边界。入场操作涉及「插入订单 + 更新车位状态」两步,必须包在同一个事务里;出场操作涉及「更新订单 + 计算费用 + 更新车位状态」,同样要保证原子性。
3.3 MyBatis 的 SQL 与参数绑定
MyBatis 的 XML 文件推荐把动态 SQL 写在 mapper XML 里,Java 接口只声明方法签名。入场的核心 SQL 需要同时完成订单插入和车位状态更新,前者用insert语句,后者用带条件判断的update:
<insert id="insertOrder" parameterType="com.parking.entity.ParkingOrder"> INSERT INTO parking_order (order_no, space_id, plate_no, entry_time) VALUES (#{orderNo}, #{spaceId}, #{plateNo}, #{entryTime}) </insert> <update id="occupySpace"> UPDATE parking_space SET status = 1 WHERE id = #{spaceId} AND status = 0 </update>occupySpace里AND status = 0这个条件非常关键。它保证只有当车位确实是空闲状态时才能被占用,如果两个请求同时抢同一个车位,数据库的行锁会让后到的那个更新影响行数为 0,应用层据此判断「车位已被抢走」,返回友好提示。这种带业务条件更新的写法,比先 SELECT 再 UPDATE 更抗并发,少一次查询还更安全。
动态查询按车牌查订单时,车牌参数可能为空,用<if>处理:
<select id="listOrders" resultType="com.parking.entity.ParkingOrder"> SELECT * FROM parking_order <where> <if test="plateNo != null and plateNo != ''"> plate_no = #{plateNo} </if> <if test="startTime != null"> AND entry_time >= #{startTime} </if> </where> ORDER BY entry_time DESC LIMIT #{offset}, #{pageSize} </select><where>标签会自动去掉第一个多余的 AND,这是 MyBatis 动态 SQL 里最实用的细节。ORDER BY entry_time DESC让最近入场的记录排在最前,配合 LIMIT 做分页。MySQL 分页的深坑是偏移量越大越慢,offset到几十万时即使有索引也会扫描大量数据,后文排查部分会讲具体解法。
4. 计费状态机与并发结算:让账目和车位状态一致
4.1 用 MySQL 存储过程处理出场结算
出场结算的逻辑是:根据入场时间和当前时间计算停车时长,再套用计费规则得到费用,最后更新订单状态和车位状态。这套逻辑放 Java 里写也完全可行,但放在 MySQL 存储过程里有一个好处——如果后续有多个入口(比如岗亭 PC、自助缴费机、小程序)都要调用同一个结算规则,只需要改数据库里的存储过程,不用重新发布应用。
下面是一个面向「小时计费 + 单日封顶」规则的存储过程骨架:
DELIMITER $$ CREATE PROCEDURE sp_settle_order( IN p_order_id BIGINT, IN p_exit_time DATETIME, OUT p_fee DECIMAL(10,2), OUT p_result TINYINT ) BEGIN DECLARE v_entry_time DATETIME; DECLARE v_hours INT; DECLARE v_space_id BIGINT; DECLARE v_base_fee DECIMAL(10,2) DEFAULT 5.00; DECLARE v_per_hour DECIMAL(10,2) DEFAULT 2.00; DECLARE v_daily_cap DECIMAL(10,2) DEFAULT 30.00; -- 读取订单信息,FOR UPDATE 加行锁防止并发结算 SELECT entry_time, space_id INTO v_entry_time, v_space_id FROM parking_order WHERE id = p_order_id AND exit_time IS NULL FOR UPDATE; IF v_entry_time IS NULL THEN SET p_fee = 0; SET p_result = 0; -- 订单状态不对,可能已结算 ELSE SET v_hours = TIMESTAMPDIFF(HOUR, v_entry_time, p_exit_time); IF v_hours < 1 THEN SET v_hours = 1; END IF; SET p_fee = v_base_fee + v_per_hour * (v_hours - 1); -- 超过单日封顶时按封顶金额收取 IF p_fee > v_daily_cap THEN SET p_fee = v_daily_cap; END IF; UPDATE parking_order SET exit_time = p_exit_time, fee_amount = p_fee WHERE id = p_order_id; UPDATE parking_space SET status = 0 WHERE id = v_space_id; SET p_result = 1; END IF; END$$ DELIMITER ;这个存储过程里值得注意的参数和语句有:入参p_order_id定位订单、p_exit_time是出场时间,出参p_fee返回计算出的费用、p_result返回执行状态。SELECT ... FOR UPDATE在当前事务内对订单行加排他锁,如果两个请求同时对这个订单调用存储过程,后到的事务会阻塞等待,这是防止重复结算最直接的手段。TIMESTAMPDIFF(HOUR, ...)计算小时差,不足一小时按一小时计,头部条件DECLARE变量统一管理费率,后续调价只需改存储过程里的常量。
调用存储过程的方式很简单,应用层通过 MyBatis 的@Select注解或者 XML 里的select语句调CALL sp_settle_order(...)。注意p_fee这种输出参数需要通过 MyBatis 的Map接收返回值。
4.2 并发场景下的订单幂等与状态保护
存储过程解决了单订单并发结算的问题,但停车场还有其他并发场景:同一辆车在出口重复刷两次、扫码枪或道闸误触发送重复出场请求。这类请求本质上是对同一订单的重复结算,需要在业务上做幂等保护。
常见做法是给订单表加一个settle_version版本号字段,每次出场结算时带上条件WHERE id = ? AND settle_version = ?,更新成功后SET settle_version = settle_version + 1。第二次请求再来时,版本号不匹配,影响行数为 0,直接返回「该订单已结算」。这种做法比查一次状态再更新少一轮数据库交互。除了版本号,应用层在 Redis 里用订单号作为 key 做个短时锁也能达到同样效果,但 MySQL 行锁的方式不依赖额外组件,部署更简单。
车位的并发保护则用 3.3 节的AND status = 0条件更新。注意这里有一个容易忽略的细节:occupySpace更新成功但订单插入失败时,事务回滚会把两条 SQL 都撤销,车位状态不会停留在占用。前提是这两个操作必须在同一个事务方法里,且事务管理器配置正确。
MySQL 的隔离级别默认是 REPEATABLE READ,处理上述场景足够了。不要把隔离级别调到 SERIALIZABLE,那会让停车场的并发吞吐明显下降,在高峰期出场排队时,每条结算都要等前一条完全提交才能继续,体验会变得很差。普通的行锁和条件更新已经解决了核心竞争问题。
5. 上线前验证与慢查询排查技巧
5.1 用 Docker 拉起一个测试环境验证 DDL 和存储过程
开发机上装 MySQL 的方式有很多,用 Docker 起一个实例最省事,不污染本机环境,也不用为版本切换发愁。下面的命令启动一个带端口映射和数据卷的 MySQL 容器:
docker run -d \ --name parking-mysql \ -p 3306:3306 \ -e MYSQL_ROOT_PASSWORD=root123456 \ -e MYSQL_DATABASE=parking_db \ -v /opt/mysql-data:/var/lib/mysql \ mysql:8.0-e MYSQL_DATABASE=parking_db会在首次启动时自动创建数据库,省掉手动建库的步骤。-v挂载数据目录,容器删了数据还在。启动后用docker exec -it parking-mysql mysql -uroot -p进入客户端,把建表语句和存储过程脚本按顺序执行一遍,确认没有语法错误再开始写应用代码。
验证环境搭好后,有一个值得做的小实验:把 2.3 节提到的索引临时删掉,然后执行按车牌查订单的 SQL,看它的执行时间。这个实验能直观感受索引带来的数量级差异,也能帮你在写接口前就发现缺失索引的问题。
5.2 EXPLAIN 验证订单表索引是否真正命中
排查慢查询时,第一步不是看代码,而是用 EXPLAIN 看 SQL 的执行计划。以「查某个时间段所有出场未支付的订单」为例:
EXPLAIN SELECT * FROM parking_order WHERE pay_status = 0 AND exit_time IS NOT NULL AND entry_time BETWEEN '2025-01-01 00:00:00' AND '2025-01-31 23:59:59' ORDER BY entry_time DESC LIMIT 20;执行计划里重点看三个字段:type达到range或ref说明用上了索引,const是最好情况;key显示实际命中的索引名;rows是预估扫描行数,这个数字越大越可疑。如果type是ALL或者key为 NULL,说明在走全表扫描。
上面这条 SQL 的优化思路是让pay_status的等值条件优先走索引,然后通过idx_entry_time做范围过滤。如果订单量很大,ORDER BY entry_time DESC LIMIT 20这种深分页查询会越来越慢,常见改法是改成「基于游标」的分页——客户端传上一页最后一条记录的entry_time作为查询条件,而不是靠大偏移量硬跳。
5.3 计费金额自检脚本,把边界情况全部跑一遍
停车场计费规则最容易出错的不是正常停车,而是边界场景:停车不足一小时、跨天超过封顶金额、凌晨入场清晨出场、免费时长恰好卡在临界点。上线前把这几类数据直接插进测试库,跑一遍结算存储过程,对比预期金额和实际金额。下面是一组常见的边界用例:
-- 用例1:停车 20 分钟,不足 1 小时按 1 小时收费 INSERT INTO parking_order (order_no, space_id, plate_no, entry_time, exit_time) VALUES ('TEST001', 1, '测A0001', '2025-06-01 09:00:00', '2025-06-01 09:20:00'); -- 用例2:停车 5 小时,计算基础费用和超出部分 INSERT INTO parking_order (order_no, space_id, plate_no, entry_time, exit_time) VALUES ('TEST002', 2, '测A0002', '2025-06-01 08:00:00', '2025-06-01 13:00:00'); -- 用例3:停车 26 小时,触发单日封顶 INSERT INTO parking_order (order_no, space_id, plate_no, entry_time, exit_time) VALUES ('TEST003', 3, '测A0003', '2025-06-01 08:00:00', '2025-06-02 10:00:00');插入后用CALL sp_settle_order(...)逐条结算,然后对比fee_amount是否符合计费规则。这类自检脚本要保留下来,每次改动存储过程或费率常量后重新执行,比人工拿计算器核对快得多。
最后提一个运维侧的细节:MySQL 的general_log在排查「谁改了数据」时很有用,平时保持关闭,排查问题时再打开,避免日志文件快速膨胀。对停车场这种支付相关系统,更重要的是定期备份订单表,至少做到每日全量加 binlog,这样即使出现错误结算,也能从备份里追溯原始数据。
本文还有配套的精品资源,点击获取