简介:这是一份基于Java Swing与MySQL的停车场管理系统完整源码包,定位于Java初学者、课程设计或毕业设计人群,也可作为Swing桌面应用开发的参考项目。系统采用用户与管理员的双角色权限设计,用户端包含登录、计费标准查询、当前在场信息、用户个人信息、出入场信息、可用车位查询,以及口令修改与退出;管理员端则提供车辆入场、车辆出场、用户注册/修改/充值、管理员注册、计费标准管理和系统维护等完整功能。源码共80个文件,以25个Java源文件与47个class编译文件为主,附带1个SQL数据库脚本、项目配置、运行截图和依赖jar包,整包仅2.11MB,层次简单清晰。已有728人学习下载;数据库表charger、park、station、users均有建表脚本,配合JDK1.8与MySQL 8.0.13可快速导入启动。通过阅读源码,可学习Swing界面布局、JDBC数据交互、角色权限控制和基本业务闭环,适合直接运行验证,也有利于二次扩展和撰写项目文档。
1. 停车场管理系统:为什么Java+Swing+MySQL的组合到今天还有实战价值
如果把「基于Java+Swing+MySQL停车场管理系统」当成一个真实交付物来评估,而不是课程里翻过去的一页PPT,你会发现这个组合解决的问题非常具体:在有限课时内把Java语法、GUI事件模型、JDBC持久化和MySQL表设计串成一条完整业务链。它要交付的不是高并发或微服务,而是一个能开机、能录数据、能算账、能查记录的桌面应用。
适合刚学完Java基础需要项目练手的开发者,也适合做课程设计和工程实训的带队工程师。后端思维还没建立的阶段,用Swing把界面拿在手里,反而容易理解数据是怎么流进数据库的。MySQL作为配套存储,索引、事务、连接池这些听上去很虚的概念,都能在这个小系统里真刀真枪验证一遍。
2. 需求拆解与数据建模:三张核心表怎么设计才能不漏收费、不丢记录
2.1 功能边界:这个规模下为什么不用Web也不用文件存储
很多人拿到停车场管理系统,第一反应是「现在谁还写桌面程序,不做成Web也好意思交」。我一般会先反问一句:你手头有多少辆车要管?给一个百车位以内的小区或单位停车场做管理,同时操作的收银员就一两个,数据写入频率是每分钟几笔,这时候Swing桌面端比Web后台更轻。没有浏览器、没有部署服务器、没有前端框架的版本兼容问题,双击jar包就能跑,这在课程设计、毕业设计和中小场景里是实打实的优势。
至于为什么不用文件存储或纯内存数据,原因更简单:文件存车场数据,并发冲突和崩溃恢复都要靠手写;用MySQL,事务、索引、备份全是现成的。这也决定了项目的分工边界——界面用Swing表达操作意图,MySQL负责数据可靠,中间用JDBC做通道。
在这个规模下,我建议把系统功能边界切三条:车辆进出场登记、按规则计费、车位状态查询与统计。界面只做这三件事,不做会员充值、不做远程授权、不做复杂的权限分级。把边界收窄,后续建模和联调才不会失控。Swing和JavaFX怎么取舍也常被问,JavaFX确实更现代,但停车场管理系统这套题,Swing的资料密度、存量代码维护需求,以及教学里对JDBC+GUI的考察重点,决定了用Swing的综合成本更低,对javase阶段的学习者也更友好。
2.2 数据表设计:车位、车辆、收费记录怎么分
做表设计时最常见的翻车,是上来就建一张大表,把车牌、车位、进场时间、出场时间、费用全塞进去。看似省事,实际一进一出两个动作要反复更新同一行,状态一多就分不清这条记录到底代表「一次停车」还是「一辆车」。我一般会拆成三张表,职责分开:车位表管物理位置和占用状态,车辆表管车牌和车主信息,收费记录表管每一次进出场。
-- 车位表:管理编号与占用状态 CREATE TABLE parking_space ( space_id INT PRIMARY KEY AUTO_INCREMENT COMMENT '车位主键', space_no VARCHAR(10) NOT NULL UNIQUE COMMENT '车位编号,如A-01', status TINYINT NOT NULL DEFAULT 1 COMMENT '0=禁用 1=空闲 2=占用', vehicle_plate VARCHAR(20) DEFAULT NULL COMMENT '占用车辆牌号,空闲为NULL', update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车位状态表'; -- 车辆表:车牌作为业务唯一键 CREATE TABLE vehicle ( vehicle_id INT PRIMARY KEY AUTO_INCREMENT, plate_no VARCHAR(20) NOT NULL UNIQUE COMMENT '车牌号', owner_name VARCHAR(50) COMMENT '车主姓名', owner_phone VARCHAR(20) COMMENT '联系电话', car_type TINYINT NOT NULL DEFAULT 1 COMMENT '1=小型车 2=大型车 3=新能源', create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车辆信息表'; -- 收费记录表:每次进出场独立成行 CREATE TABLE charge_record ( record_id INT PRIMARY KEY AUTO_INCREMENT, plate_no VARCHAR(20) NOT NULL COMMENT '车牌号,冗余便于查询', space_no VARCHAR(10) NOT NULL COMMENT '所用车位', enter_time DATETIME NOT NULL COMMENT '进场时间', exit_time DATETIME DEFAULT NULL COMMENT '出场时间,NULL表示在场', duration_min INT DEFAULT NULL COMMENT '停车分钟数,出场时计算', fee DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '应收金额', status TINYINT NOT NULL DEFAULT 0 COMMENT '0=在场 1=已离场 2=已结算' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='收费记录表';三张表为什么要这样切?先说车位表,status字段用TINYINT而不是VARCHAR,是为了后续做状态统计时能直接用SUM和GROUP BY,也方便写定时任务。vehicle_plate冗余在车位表里,是为了查「哪个车位停着什么车」时不用再连一次车辆表。这个冗余字段在纯关系设计里稍显越权,但在停车场这种「当前状态」高频查询的场景里,值得用空间换时间。
车辆表让plate_no做UNIQUE,是业务上的唯一键,因为一个车牌在系统里只该有一个主档。收费记录表则是典型的流水表,每一次入场插入一条status=0的记录,出场时更新同一条记录的exit_time、duration_min、fee和status。进场和出场操作的是同一条流水,而不是新建一条,这样「这辆车目前停没停、停了多久」在表结构上表达得非常直白。
还有一个常见问题:要不要给charge_record表加外键指向vehicle或parking_space?我建议不加。外键在桌面端单写场景里带来的级联约束,遇到车辆主档删除和停车位重新编组时,会变成联调期的拦路虎。数据一致性用事务在Service层保证,比用外键硬约束更灵活,报表查询也不容易被外键的索引策略带偏。
提示:如果你手头已有一套现成的MySQL 5.7环境,上面的建表SQL可以直接跑;如果是MySQL 8.x或8.4的新实例也没问题,注意连接参数里把认证方式配好,这一点到第5章避坑部分再说。
2.3 计费规则与索引设计:一条SQL算不清楚的事,交给代码算
计费规则一旦写进业务代码,后续调价就要重新编译打包;如果你希望改规则不改代码,可以把收费标准挪到MySQL的配置表里,再用一条查询把规则读出来。常见做法是建一张price_rule表,字段包括car_type、第一小时单价、后续每小时单价、单日封顶金额、免费分钟数。出场结算时,业务代码读规则、算时长、算金额,再回写收费记录。
这个方案的边界是:跨天和封顶逻辑放在代码里算更直观,也更容易写单元测试。我见过有项目把计费全写进MySQL存储过程,出发点好的,但联调时一旦加「免费15分钟」「跨天重新按天计费」这类规则,存储过程的调试体验会明显变差。这个规模下的推荐做法是表结构存规则、Java代码算费用,存储过程只留给真正需要数据库侧强一致的场景。
索引设计同样容易被忽视。charge_record表最频繁的查询是「某车牌最近一条在场记录」和「出场时段统计」,前者必须命中plate_no + status的联合索引,后者需要enter_time上的索引。下面这组索引在数据量到几万行时能稳住查询:
ALTER TABLE charge_record ADD INDEX idx_plate_status (plate_no, status), ADD INDEX idx_enter_time (enter_time);这条语句的逻辑是:idx_plate_status让WHERE plate_no=? AND status=0走索引覆盖,减少回表;idx_enter_time则给每天一两千行的进出场报表查询提速。索引不是越多越好,在这个系统里,车位数超过200个以后再考虑给parking_space加更多索引,否则维护成本比收益还高。
如果计费规则只有一档,也可以直接把单价字段放进charge_record表里,每次进场时把当前单价冗余进去。这样历史收费记录不会因为后续调价而被算错——这一点经常被人忽略:只存金额不存单价,月底对账看到金额是对的,但次年调价后复盘就说不清了。冗余单价字段是典型的防呆设计。duration_min用INT存分钟数而不是用FLOAT存小时数,是为了避免浮点误差,也方便做「不满15分钟按15分钟计」的ceil逻辑;fee用DECIMAL(10,2)而不是DOUBLE,是因为金额计算不能接受二进制浮点的精度损失。
3. 用Swing搭建操作界面:从主窗口布局到事件响应的完整过程
3.1 主窗口布局:先画组件层级,再写代码
Swing界面有一个容易犯的毛病:一上来就new JFrame,然后往ContentPane上猛add组件,最后横竖对不齐,改一次布局就重构一次。我一般先把主窗口画成一张组件层级图——顶部是操作栏,中间是车位表格,右侧是收费结算区,底部是日志。层级定下来后再写代码,每个面板只负责一块职责。
// MainFrame.java —— 停车场系统主窗口骨架 public class MainFrame extends JFrame { private JTextField plateInput; // 车牌输入框 private JComboBox<String> spaceBox; // 车位选择下拉 private JTable spaceTable; // 车位状态表格 private DefaultTableModel spaceModel; // 表格数据模型 private JTextArea logArea; // 操作日志 private JLabel feeLabel; // 出场费用显示 public MainFrame() { super("停车场管理系统"); setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); setSize(1024, 640); setLocationRelativeTo(null); initUi(); refreshSpaceTable(); // 启动时加载车位状态 } }setDefaultCloseOperation决定了点窗口关闭按钮时是退出进程还是只隐藏窗口;对管理类系统我一般用EXIT_ON_CLOSE,因为这类应用没有多窗口保留的意义。setLocationRelativeTo(null)让窗口居中,这在演示和录屏时观感好很多。
把组件装配到内容面板上时,我惯用BorderLayout套内层Panel:
private void initUi() { // 顶部操作栏 JPanel topBar = new JPanel(new FlowLayout(FlowLayout.LEFT, 8, 6)); topBar.add(new JLabel("车牌号:")); plateInput = new JTextField(12); topBar.add(plateInput); JButton enterBtn = new JButton("车辆进场"); JButton exitBtn = new JButton("车辆出场"); topBar.add(enterBtn); topBar.add(exitBtn); add(topBar, BorderLayout.NORTH); // 中部车位状态表格 spaceModel = new DefaultTableModel( new String[]{"车位编号", "状态", "车牌号", "更新时间"}, 0); spaceTable = new JTable(spaceModel); add(new JScrollPane(spaceTable), BorderLayout.CENTER); // 右侧操作日志 logArea = new JTextArea(8, 24); logArea.setEditable(false); add(new JScrollPane(logArea), BorderLayout.EAST); // 底部用于显示结算信息 feeLabel = new JLabel("当前无结算动作"); add(feeLabel, BorderLayout.SOUTH); }组件层级上,BorderLayout把窗口天然分成五块,操作区放北边、实时表格放中间、日志放东边、费用提示放南边。中间用JScrollPane包JTable是必须的,否则数据一多表格列头会挤变形。右侧日志我习惯用JTextArea + setEditable(false),它既承担记录功能,也充当调试期的黑匣子——用户操作了什么、出了什么错,都往这里写一行。
3.2 事件响应:进场、出场两个动作怎么绑定
Swing的事件模型核心是「注册监听器—事件触发—回调处理方法」。进场和出场两个按钮的监听器写法如下:
enterBtn.addActionListener(e -> { String plate = plateInput.getText().trim(); if (plate.isEmpty()) { JOptionPane.showMessageDialog(this, "车牌号不能为空", "提示", JOptionPane.WARNING_MESSAGE); return; } String result = parkingService.enter(plate); logArea.append("车辆进场: " + plate + " -> " + result + "\n"); refreshSpaceTable(); }); exitBtn.addActionListener(e -> { String plate = plateInput.getText().trim(); if (plate.isEmpty()) { JOptionPane.showMessageDialog(this, "请先输入车牌号", "提示", JOptionPane.WARNING_MESSAGE); return; } String result = parkingService.exit(plate); logArea.append("车辆出场: " + plate + " -> " + result + "\n"); refreshSpaceTable(); // 刷新车位状态和费用显示 });这里的关键是不要在每个按钮的回调里写JDBC代码。按钮只做三件事:收集输入、调用业务层Service、把结果刷新到界面上。parkingService.enter返回的是一个String,它能告诉用户「停车成功,车位A-01」还是「重复进场」还是「车位已满」。把业务规则从界面代码里剥出来后,Swing的ActionListener变得很薄,测试业务逻辑也不需要起窗口。
JOptionPane用来做校验提示和确认弹窗,常见做法是出场结算前先弹一个确认框,让用户看到费用金额再确认,这是收银场景里防误操作的兜底。刷新表格的refreshSpaceTable方法在启动和每次进出场后都要调用,它内部重新查库并用DefaultTableModel的setRowCount(0)清零旧数据,再逐行填充。用setRowCount(0)而不是new一个新的model去setModel,是为了避免表格列宽设置被冲掉。
3.3 Swing的EDT:界面卡死多半是阻塞了事件线程
Swing的所有界面刷新都必须在EDT(Event Dispatch Thread,事件分发线程)上执行,这是它的线程模型。实际开发中典型的翻车是:按钮点击后,回调里直接同步查MySQL,网络一慢,整个窗口冻结,用户以为程序死掉了。这里有个取舍——不查数据库没法刷新表格,查数据库又会阻塞EDT。
常见做法是给「可能耗时超过200毫秒」的操作开一个SwingWorker线程,查询结果通过done()方法回到EDT刷新界面。对停车场管理系统这种数据量级,进场和出场的单条SQL通常都在毫秒级,但刷新整个车位表格时如果一口气查询几百行,仍然建议放进SwingWorker。
private void refreshSpaceTable() { new SwingWorker<List<SpaceItem>, Void>() { @Override protected List<SpaceItem> doInBackground() { return parkingService.listAllSpaces(); // 后台查库 } @Override protected void done() { try { List<SpaceItem> list = get(); spaceModel.setRowCount(0); for (SpaceItem s : list) { spaceModel.addRow(new Object[]{ s.getSpaceNo(), s.getStatusText(), s.getPlateNo(), s.getUpdateTime() }); } } catch (Exception ex) { logArea.append("刷新车位失败: " + ex.getMessage() + "\n"); } } }.execute(); }SwingWorker的好处是doInBackground跑在后台线程,done回调自动回到EDT,省掉了手动SwingUtilities.invokeLater的样板代码。有个细节:execute()只是把任务提交给Swing的线程池,界面不会等它执行完,所以快速连续点刷新不会卡死窗口,但要注意数据库连接的问题,频繁创建新Connection反而会成为新的性能瓶颈。
3.4 表格渲染:状态列用颜色区分,比纯文字直观
管理系统的核心操作者不一定懂数据库,看到「1、2、3」这种状态码会懵。常见做法是在表格渲染器上做映射:状态列把TINYINT转成「空闲」「占用」「禁用」,并用颜色区分。这个功能不是必需,但加上之后,演示和验收的观感会好很多——这是很多项目看不出「工程完成度」的加分项。
spaceTable.getColumnModel().getColumn(1).setCellRenderer( new DefaultTableCellRenderer() { @Override public Component getTableCellRendererComponent( JTable table, Object value, boolean isSelected, boolean hasFocus, int row, int column) { Component c = super.getTableCellRendererComponent( table, value, isSelected, hasFocus, row, column); String status = String.valueOf(value); if ("空闲".equals(status)) { c.setForeground(new Color(0, 128, 0)); } else if ("占用".equals(status)) { c.setForeground(new Color(200, 0, 0)); } return c; } });这段代码的意思是:自定义TableCellRenderer拦截「状态」列的绘制,空闲显示绿色、占用显示红色。要特别注意isSelected参数的处理,如果选中行时前景色被覆盖成系统默认白色,会导致文字看不清,所以要在渲染器里判断isSelected再决定要不要恢复默认色。表格渲染器在Swing里是个细活,但改几行就能显著降低误读率。
4. 接入MySQL实现持久化:JDBC封装、预编译SQL与事务边界
4.1 JDBC驱动与连接参数:MySQL 8.x下的配置清单
JDBC是Java访问MySQL的固定通道,不管后面套不套MyBatis,底层都是这套东西。先解决连接问题——MySQL 8.x对认证插件和时区的要求与5.7不同,连接URL里那串参数直接决定你能否连上、能否正常存取中文时间。
// DBConfig.java —— 数据库连接配置 public class DBConfig { public static final String URL = "jdbc:mysql://localhost:3306/parking_db" + "?useSSL=false" + "&serverTimezone=Asia/Shanghai" + "&characterEncoding=utf8" + "&allowPublicKeyRetrieval=true" + "&rewriteBatchedStatements=true"; public static final String USER = "root"; public static final String PASSWORD = "你的密码"; static { try { Class.forName("com.mysql.cj.jdbc.Driver"); } catch (ClassNotFoundException e) { throw new ExceptionInInitializerError("缺少MySQL JDBC驱动jar"); } } }参数逐个说明:useSSL=false是开发环境关闭SSL握手,本地连接能省一次额外往返;serverTimezone=Asia/Shanghai告诉驱动时区,否则8.x驱动默认用UTC,查出来的时间会比本地慢8小时;characterEncoding=utf8保证中文不乱码;allowPublicKeyRetrieval=true解决的是8.x下caching_sha2_password认证插件首次连接需要公钥的报错;rewriteBatchedStatements=true允许批量插入时把多条语句合并成一条网络请求,批量初始化和批量导车位时会明显快。
提示:驱动类名在MySQL 8.x必须写com.mysql.cj.jdbc.Driver,旧版的com.mysql.jdbc.Driver在8.x已被标记过时。如果你跟着早年的教程写旧类名,会看到ClassNotFoundException。
4.2 数据访问层封装:一个DBHelper统一Connection管理
每次业务操作都new一个Connection,再手工关ResultSet、Statement、Connection,代码会膨胀到没法看。我一般会写一个极简的DBHelper,只负责拿连接和关资源。它不提供ORM功能,但对这个项目来说,SQL手写反而更可控。
// DBHelper.java —— 统一管理连接与资源释放 public class DBHelper { private DBHelper() {} public static Connection getConn() throws SQLException { return DriverManager.getConnection( DBConfig.URL, DBConfig.USER, DBConfig.PASSWORD); } public static void close(ResultSet rs, Statement st, Connection conn) { try { if (rs != null) rs.close(); } catch (SQLException ignored) {} try { if (st != null) st.close(); } catch (SQLException ignored) {} try { if (conn != null) conn.close(); } catch (SQLException ignored) {} } }这段封装的核心是让调用方的try/finally变得干净。用三个独立的try-catch关资源,是为了避免第一个资源关闭抛异常导致后面两个资源泄漏——这是JDBC资源管理的血泪经验。注意close顺序必须从ResultSet到Statement再到Connection,反着关在某些驱动实现下会隐式提交未完成的事务,倒不至于崩,但行为不干净。
为什么这个规模不引入数据库连接池?因为同时操作这个系统的桌面客户端只有一两台,连接频率不高,连接池能省下的连接建立开销并不显著。当系统从单机演示走向真实的多人客户端、再叠加报表查询时,再换成HikariCP这种连接池也只需要改DBHelper的getConn实现,接口层面不受影响。
4.3 预编译SQL:参数绑定与SQL注入的最小防线
在Swing项目里写JDBC时,最容易出问题的写法是字符串拼接SQL:
// 反面写法:不要这样拼 String sql = "SELECT * FROM charge_record WHERE plate_no = '" + plate + "'";一旦plate里出现单引号或OR 1=1,轻则报SQL语法错误,重则把全表记录拉出来。停车场管理系统做的是收费管理,数据安全要求不高不代表可以留这种洞。正确的写法一律用PreparedStatement占位符:
// ChargeRecordDao.java —— 查询最近一条在场记录 public ChargeRecord findActiveRecord(String plateNo) { String sql = "SELECT record_id, plate_no, space_no, enter_time, " + "exit_time, duration_min, fee, status " + "FROM charge_record " + "WHERE plate_no = ? AND status = 0 " + "ORDER BY enter_time DESC LIMIT 1"; try (Connection conn = DBHelper.getConn(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, plateNo); try (ResultSet rs = ps.executeQuery()) { if (rs.next()) { ChargeRecord r = new ChargeRecord(); r.setRecordId(rs.getInt("record_id")); r.setPlateNo(rs.getString("plate_no")); r.setSpaceNo(rs.getString("space_no")); r.setEnterTime(rs.getObject("enter_time", LocalDateTime.class)); return r; } } } catch (SQLException e) { throw new RuntimeException("查询在场记录失败", e); } return null; }这里用了try-with-resources,Connection、PreparedStatement、ResultSet在方法退出时自动关闭,避免在Swing回调里写一堆嵌套finally。?占位符通过ps.setString传给MySQL,驱动会把它当参数而不是SQL片段处理,注入路径从根上被堵死。
参数说明:rs.getObject("enter_time", LocalDateTime.class)是JDBC 4.2起的标准用法,比rs.getTimestamp再转换更安全,它直接拿到Java 8的LocalDateTime,后续做时长计算时不会因为时区再错一次。如果用的是MySQL 5.7和旧驱动,可能需要降级成getTimestamp,但新项目我建议直接上8.x的驱动。
4.4 事务边界:进场、出场、缴费三处提交点
停车场系统的数据一致性最容易出问题的地方,不是查询而是多个写操作之间的原子性。以进场为例,业务上要同时做三件写事:更新车位状态为占用、插入一条在场收费记录、更新车辆的最近进场时间。如果三条SQL各自自动提交,中间任何一条失败,就会出现「车位显示占用但收费记录没有」或反过来。解决方式是用事务把三条SQL包起来。
// ParkingService.java —— 车辆进场事务 public boolean enter(String plateNo) { String spaceNo = allocateSpace(plateNo); // 先查空闲车位 if (spaceNo == null) { return fail("车位已满"); } Connection conn = null; try { conn = DBHelper.getConn(); conn.setAutoCommit(false); // 1. 把车位状态改成占用 updateSpaceStatus(conn, spaceNo, 2, plateNo); // 2. 插入一条在场收费记录 insertChargeRecord(conn, plateNo, spaceNo); // 3. 更新车辆表进场时间 upsertVehicle(conn, plateNo); conn.commit(); return success("停入" + spaceNo); } catch (SQLException e) { rollbackQuietly(conn); return fail("进场失败: " + e.getMessage()); } finally { restoreAutoCommit(conn); } }事务在这里的边界要看清楚:只有三处写操作需要包在同一个事务里,allocateSpace查询不用包。第1步把space_no的状态置为占用,第2步插入收费流水,第3步若无车辆主档则插入、有则更新时间。因为这三者要么全部成功要么全部回滚,所以它们必须共享同一个Connection,事务才能感知彼此的提交状态。
rollbackQuietly和restoreAutoCommit是两个被JDBC反复强调的细节:conn.rollback()之前要判断conn不为空;finally里要把setAutoCommit(true)恢复原状,否则这个Connection被归还池子后,下一个使用者会在错误的事务状态下开始操作。在桌面端单连接场景问题不大,但一旦接上连接池,这个细节就会变成诡异的偶发bug。出场结算同样是一个事务:读收费记录算费用、更新记录状态、释放车位,三处写操作一次提交。
5. 避坑排查:从认证插件失效到界面卡死的五个真实教训
5.1 MySQL 8.x认证插件:Public Key Retrieval not allowed
现象:照着mysql在Windows上的安装教程装好8.x,项目里DriverManager连接抛Public Key Retrieval is not allowed,有时刚连上第一次查询就又断开,报错堆栈里还带着Access denied for user 'root'@'localhost'的影子。
原因:MySQL 8.x默认的caching_sha2_password认证方式要求客户端在SSL关闭时向服务端请求公钥来加密密码传输。JDBC驱动出于安全考虑默认不主动索取公钥,于是握手失败。这个问题在5.7的老教程里根本不会出现,属于典型的版本升级遗留债。
解决:在JDBC URL上加allowPublicKeyRetrieval=true,或者把MySQL用户改成mysql_native_password并重启服务。两者选一即可,我建议加参数,因为它不动服务端配置,换一台电脑部署时也能带过去。连接日志里如果还有Unable to load authentication plugin 'caching_sha2_password',那多半是驱动jar太旧,换成8.x的mysql-connector-j即可。
5.2 Swing界面卡死:不是查询慢,是阻塞了EDT
现象:点击「刷新车位」按钮后窗口变成白板,系统提示「未响应」,过几秒恢复,再点又卡。数据量明明只有几十行,按道理不该这么慢。
原因:按钮回调里同步执行了JDBC查询,而Swing的界面刷新全部跑在EDT线程上,查询阻塞了EDT,鼠标事件排不上队,窗口就像冻住。数据量小的时候只是卡顿,网络抖动或锁表时直接长时间无响应。还有一种刻意制造的卡死:有人在回调里加了Thread.sleep(2000)模拟慢查询,这更是直接在EDT里睡觉,百试百灵。
解决:把耗时操作包进SwingWorker,查询在doInBackground,刷新在done。如果你不想引入SwingWorker,也可以用new Thread + SwingUtilities.invokeLater,但SwingWorker封装得更完整。另一个排查角度是看MySQL端是否出现锁等待,用SHOW PROCESSLIST看一线SQL的State是不是Waiting for table metadata lock,如果有,优先查是不是某个未提交事务把表锁住了。
5.3 中文乱码:连接、建表、控制台三处字符集
现象:界面上输入中文车牌或用户名,写入数据库后变成??,或者从数据库读回时中文变成乱码。更隐蔽的是这台机器正常,换一台机器就乱。
原因:乱码是字符集不一致造成的,最少有三个来源——JDBC连接URL没写characterEncoding=utf8、建表时没指定utf8mb4、Swing自己的JVM编码不是UTF-8。三处只要有一处不一致,数据就可能坏。用SHOW CREATE TABLE charge_record能快速确认表的字符集,用SELECT @@character_set_client能确认连接字符集。
解决:连接URL加characterEncoding=utf8(并让服务器端也匹配);建表统一用DEFAULT CHARSET=utf8mb4;JVM启动参数加-Dfile.encoding=UTF-8。如果已经写进去的乱码数据,要在源头上堵而不是用replace硬修,因为replace对历史数据的修复经常造成二次破坏。控制台打印乱码时,先检查IDE运行配置里的VM options,再检查logArea的字体,前者是编码问题,后者是字体不支持生僻字。
5.4 时间字段的时区坑:DATETIME在JDBC里变了样
现象:存入MySQL的进场时间是18:00,查出来显示的是20:00或10:00;用Java算停车时长,结果差了8个小时。
原因:MySQL的DATETIME不带时区信息,JDBC驱动的serverTimezone参数决定了服务端时间如何映射到Java的LocalDateTime。如果你的URL没写serverTimezone=Asia/Shanghai,驱动按UTC解释本地写入的时间,再转回东八区就会偏移8小时。这类bug在开发机上偶尔不出现,因为安装MySQL时系统时区恰好与JVM一致,换服务器就露馅。另外DATETIME和TIMESTAMP也有区别:DATETIME存的是字面时间,TIMESTAMP存的是UTC时间戳,查询时按会话时区转换,混用两者会让问题更难排查。
解决:统一在连接URL里固定serverTimezone=Asia/Shanghai,代码里一律用LocalDateTime接收,不要混用java.util.Date和LocalDateTime来回转。判断车辆是否跨天计费时,用LocalDate对比enter_time和exit_time的日期部分,不要用字符串截取。日期时间处理是最容易被当玄学忽略的模块,但它直接决定收费金额对不对。
5.5 误删与联调阶段的后悔药:先备份再改表
现象:联调的时候为了造测试数据,执行了DELETE或UPDATE,忘了加WHERE条件,整个charge_record表被清空,几个小时的测试数据全没。
原因:MySQL默认把DELETE和UPDATE当普通SQL执行,没有WHERE就是全表操作。这个错误在开发阶段几乎人人都会犯一次,代价是重造数据。
解决:在MySQL Workbench或命令行执行写操作前,先SELECT COUNT(*)确认目标行数;更稳妥的做法是养成「改表前先mysqldump备份」的习惯,一句mysqldump -uroot -p parking_db > backup.sql就能把后悔药存下来,恢复时用mysql -uroot -p parking_db < backup.sql。这条不是技术难点,是操作纪律。项目里也可以把管理员的删除操作改成逻辑删除字段(如deleted=TINYINT),保留数据而不用物理删除,对管理类系统这是更实用的兜底。
6. 工程化收尾:配置外置、数据备份与一小时联调清单
6.1 数据库连接外置到db.properties
把连接字符串写死在Java类里,换一台环境就要改代码重新编译。常见做法是放到classpath的db.properties,DBHelper静态加载,部署时只改这个文件。
# db.properties jdbc.url=jdbc:mysql://localhost:3306/parking_db?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8&allowPublicKeyRetrieval=true jdbc.username=root jdbc.password=change_meDBHelper里用Properties读取后,所有DAO通过DBHelper拿连接,换库和换账号都只动一行配置。属性文件里的password用明文,适合本机和内网;一旦要连公网数据库,就要用环境变量替代。
6.2 每天自动备份MySQL数据:一个批处理脚本
桌面应用交付后,用户最怕的不是功能缺,而是数据丢。给项目配一个备份脚本,每天凌晨导一次SQL文件,保留最近7天,基本能满足中小停车场的需求。
#!/bin/bash # backup_parking.sh —— 按日期备份并清理7天前的文件 BACKUP_DIR=/data/backup/parking DATE=$(date +%Y%m%d_%H%M%S) mysqldump -uroot -p'密码' parking_db \ --single-transaction --default-character-set=utf8mb4 \ > "$BACKUP_DIR/parking_$DATE.sql" find "$BACKUP_DIR" -name "parking_*.sql" -mtime +7 -delete--single-transaction是针对InnoDB的在线备份参数,备份过程中不锁表,白天跑也不会影响停车场的正常写入;--default-character-set=utf8mb4保证备份文件里的中文字符集正确。恢复时先建库,再把备份文件导回:mysql -uroot -p parking_db < parking_20250101.sql。有了这个脚本,联调阶段误删数据的翻车事故就不会再吓出一身汗了。
6.3 一小时联调清单:从建库到跑通一次进出场
交付或自测时,我习惯按一张表格逐项验证,不凭感觉:
| 步骤 | 操作 | 预期结果 | 若失败先看哪里 |
|---|---|---|---|
| 1 | 建库并执行建表SQL | 三张表创建成功 | 字符集和存储引擎 |
| 2 | 程序启动加载车位表 | 表格显示车位状态 | JDBC URL参数和驱动jar |
| 3 | 输入车牌点「进场」 | 车位变占用,日志写成功 | 事务中第2步insert是否报错 |
| 4 | 输入同一车牌再点「进场」 | 提示重复进场,不允许 | 在场记录查询的status条件 |
| 5 | 点「出场」 | 弹窗显示费用,确认后车位释放 | 计费规则的计算分支 |
| 6 | 重启程序再查在场记录 | 数据还在,状态一致 | 自动提交与事务恢复逻辑 |
这张清单覆盖了建库、连接、进场、防重复、出场、持久化六个关键点,任何一个环节出问题,对照预期结果能快速定位到具体层。我习惯把这份清单放进项目README,团队接手或演示前照表走一遍,能省掉大量「我怎么连不上」的现场解释。
这套系统我带过多批人做过,每次翻车最多的都不是Swing布局,而是连接参数和事务恢复这两块。后来我给自己定了一条规矩:所有工程代码里,数据库连接的配置必须外置,所有写操作必须过一遍事务边界再提交。养成这两个习惯之后,类似停车场管理系统这类Java+Swing+MySQL的项目,从拿到需求到联调通过,基本能控制在一周以内。希望帮到你。
本文还有配套的精品资源,点击获取