Java Swing+MySQL图书管理系统3.0:从JDBC到打包全流程实战
简介这是一份基于Java Swing与MySQL的图书管理系统3.0完整项目资源面向需要完成课程设计或毕业设计的Java学习者也适合想了解桌面应用与数据库联动开发的初学者。系统实现了用户登录、图书类别添加与维护、图书信息添加与维护等核心功能可配合作者在CSDN发布的配套博文对照学习。压缩包共105个文件大小3.51MB主要包含14个Java源文件、43个编译后的class文件、36个png界面素材、4个jar依赖库、4个properties配置文件和1个sql数据库脚本源文件便于阅读修改sql脚本可直接初始化数据库png可辅助理解界面布局。内容预览显示项目按实体类、数据访问类和窗体类分层组织如Book、BookType、BookDao、BookTypeDao以及多个图书管理窗体结构清晰适合模仿和扩展。已有659人学习下载对Swing界面编程、JDBC数据操作及小型系统模块拆分有直观参考价值。1. JavaSwingMySQL图书管理系统3.0老技术栈却是最容易落地的桌面图书管理方案一所社区图书馆或者学校资料室最需要的不是高并发、不是微服务而是打开电脑双击图标就能录书借书的桌面小工具。JavaSwingMySQL图书管理系统3.0看起来是课设级别的老技术组合但把这个版本做完整之后登录鉴权、图书增删改查、借书还书流程、逾期统计已经串成了一条闭环业务链。它解决的问题很明确让不擅长部署Web环境的非专业人员也能在一台Windows机器上完成图书登记、借阅和归还管理。如果你刚学完Java基础、JDBC和MySQL正想找一个能练手又能交付的产品型项目这套技术栈比换汤不换药的Web CRUD合适得多。2. 想清楚再动手3.0版本的选型理由、数据表设计与项目分层Swing被很多人喊过“过时”但图书管理系统这个场景里它依然能打。这一章把为什么选它、表怎么建、代码怎么分层讲透后面几章的代码才有地方安放。2.1 为什么还在用Swing桌面图书管理场景的四个硬性需求先回答一个直白的问题这个年头为什么还要用Swing我复盘过自己把一个图书管理系统从1.0迭代到3.0的过程结论是桌面图书管理有几个硬性需求Web反而是绕远路。第一是部署成本。Swing程序打成一个jar双击就能跑不需要安装Tomcat、不需要配置Nginx、不需要担心端口被占用。图书管理员多数不是IT人员你让ta维护一个Web服务出了问题根本说不清。第二是数据流形态。图书管理是典型的表单加表格操作JTable加JTextField的组合比HTML表格加表单更直接没有前端路由、Ajax、浏览器兼容这些额外负担。第三是多人共享的需求不强。一个几百平米的图书室几台电脑连同一个MySQL实例就够Swing客户端通过JDBC直连比Web还要砍掉一层HTTP。第四是人力现实。能做这个系统的人往往是学Java起步的开发者Java基础扎实但没精力搞前端三件套Swing是唯一能只用Java就完成全部界面的成熟GUI框架。3.0版本在这四个前提下做的不是界面换皮而是把业务流程拉通。1.0可能只是图书记录的增删改查2.0加了读者表3.0才把借书、还书、逾期、权限、统计做成了一套可用的业务系统。这也是为什么后面的数据表设计要按业务来而不是按界面来。2.2 三张核心表怎么建图书、读者、借阅记录的关系与字段设计图书管理系统的数据模型多数可以从三张表开始设计图书表book、读者表reader、借阅记录表borrow_record。很多新手一上来就做一张书表把借给谁、什么时候借的写进book表的两个字段这是典型的把关系型数据硬塞进单表。3.0版本正确的做法是把借阅行为拆成独立的记录表book只保存当前状态borrow_record保存每一次借还历史。表结构按下面这个DDL来建覆盖了登录、图书管理、借还流程最基础的字段CREATE DATABASE IF NOT EXISTS library_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE library_db; CREATE TABLE user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL, role VARCHAR(20) NOT NULL DEFAULT admin, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE book ( id INT PRIMARY KEY AUTO_INCREMENT, isbn VARCHAR(20) NOT NULL UNIQUE, title VARCHAR(200) NOT NULL, author VARCHAR(100), publisher VARCHAR(100), category VARCHAR(50), total_count INT NOT NULL DEFAULT 1, available_count INT NOT NULL DEFAULT 1, location VARCHAR(50), status TINYINT DEFAULT 1, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_title (title), INDEX idx_category (category) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE reader ( id INT PRIMARY KEY AUTO_INCREMENT, card_no VARCHAR(20) NOT NULL UNIQUE, name VARCHAR(50) NOT NULL, phone VARCHAR(20), max_borrow INT DEFAULT 5, status TINYINT DEFAULT 1, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE borrow_record ( id INT PRIMARY KEY AUTO_INCREMENT, book_id INT NOT NULL, reader_id INT NOT NULL, borrow_time DATETIME NOT NULL, due_time DATETIME NOT NULL, return_time DATETIME DEFAULT NULL, oper_user VARCHAR(50), status TINYINT DEFAULT 0 COMMENT 0借出,1已还,2逾期未还, FOREIGN KEY (book_id) REFERENCES book(id), FOREIGN KEY (reader_id) REFERENCES reader(id), INDEX idx_reader (reader_id), INDEX idx_status (status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这套表的几个关键取舍第一book.available_count是冗余字段它跟borrow_record里status为0的记录数是对应的但保留它是为了让“能否借书”的判断变成一次查询而不是一次聚合计算。在小数据量下也可以去掉冗余靠实时统计但3.0选择保留换取查询简单。第二borrow_record.status用来标记每一条借阅记录的状态而book.status标记图书是否下架两个状态不要混淆。第三外键在这个规模下可以加但注意业务层必须先改子表状态再改主表不然会有约束冲突。第四password字段不要明文存3.0版本用MD5加盐虽然不算强安全但比明文强太多。字段类型上要注意isbn用VARCHAR(20)而不是数值类型因为ISBN可能含有X而且不需要计算DATETIME一定要用带时间的类型因为借阅需要时分秒。如果后面有罚款需求再加一个默认0的DECIMAL(10,2)字段不要用FLOAT。2.3 项目分层界面层、业务层、数据访问层的职责切分3.0版本的包结构我一般按下面这样组织不引入Spring等框架靠手工分层保持依赖清晰com.example.library ├── ui 界面层JFrame、JPanel、TableModel ├── service 业务层借书、还书、登录校验 ├── dao 数据访问层JDBC操作 ├── entity 实体类Book、Reader、BorrowRecord ├── util 工具类DBUtil、StringUtil、MD5Util └── launcher 入口类分层的价值在于当你要把Swing界面换成JavaFX或者为业务逻辑写单元测试时不用改动dao和service的代码。实体类就是字段加getter/setter而dao只负责SQLservice负责业务判断。比如借书这个动作dao只提供updateBookCount和insertBorrowRecord两个方法service里才判断“这本书是否可借、这个读者是否超过最大借阅数、这条读者记录是否有效”。很多课设代码把所有逻辑写在ActionListener里界面一复杂就完全没法维护3.0版本的核心改动之一就是这层剥离。service层还要管事务。借书要同时更新book表的available_count和插入borrow_record这两个操作必须在一个事务里否则会出现书被借走但记录没写上的情况。事务怎么写在第四章的代码里一起说明这里先记住一个原则事务只在service层开启和提交dao层不碰事务这样每个dao方法都能被其他业务复用。3. 从数据库到Java代码MySQL建库与JDBC连接层的完整落地这一章把连接数据库这层彻底讲透包括建库脚本、连接参数、数据访问层封装。很多花几天查“中文乱码”“连接超时”的问题根源都在这一章的参数上。3.1 MySQL准备与建库脚本从安装到建表的一次性命令MySQL的版本在这个场景下选5.7或8.0都行JDBC驱动和URL写法略有差异。如果你刚装好MySQL最常翻车的是字符集和认证插件。字符集问题会直接导致后面Swing界面看到一堆问号认证插件问题会导致Java连接时报Public Key Retrieval is not allowed。我建议在MySQL 8.0下把连接参数和用户创建一次性配好。建库、建用户、授权三个步骤命令如下CREATE DATABASE IF NOT EXISTS library_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; CREATE USER lib_userlocalhost IDENTIFIED BY lib_pass_2024; GRANT ALL PRIVILEGES ON library_db.* TO lib_userlocalhost; FLUSH PRIVILEGES;这里说明几点为什么用utf8mb4而不是utf8utf8mb4是完整的UTF-8实现能存生僻字和Emoji图书名里出现特殊字符时不会报错。为什么新建专用用户而不是直接用root桌面程序连接数据库只用最小权限万一连接串泄露损失也控制在library_db一个库里。注意MySQL 8.0默认认证插件是caching_sha2_password使用老版本JDBC驱动会直接报错。要么用mysql-connector-java 8.x要么在创建用户时指定IDENTIFIED WITH mysql_native_password BY ...。我一般直接用新驱动。然后执行上一章的DDL建表。注意执行顺序先user和book再reader最后borrow_record因为有外键引用。如果你是像这次一样从旧版本升级到3.0需要先备份旧库再以borrow_record表为中心做数据迁移。常见做法是把旧book表里状态为“借出”的数据导入borrow_record再把旧库归档。3.2 JDBC连接与参数设置驱动加载、URL、连接池配置dao层的第一步是拿到数据库连接。最开始我用的就是DriverManager直接获取但系统到后期会有多个窗口同时查库存频繁创建连接的开销不能忽略。3.0版本我用最简单的连接池思想自己维护一个LinkedList来复用连接而不是引入DBCP或HikariCP。对于单机桌面应用这样少一个依赖也更容易讲明白。连接工具类的核心代码public class DBUtil { private static final String URL jdbc:mysql://localhost:3306/library_db ?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8useUnicodetrue; private static final String USER lib_user; private static final String PASSWORD lib_pass_2024; private static final int MAX_POOL_SIZE 10; private static final LinkedListConnection pool new LinkedList(); static { try { Class.forName(com.mysql.cj.jdbc.Driver); } catch (ClassNotFoundException e) { throw new ExceptionInInitializerError(MySQL驱动未找到请检查依赖); } } public static synchronized Connection getConnection() throws SQLException { if (!pool.isEmpty()) { return pool.removeFirst(); } return DriverManager.getConnection(URL, USER, PASSWORD); } public static synchronized void closeConnection(Connection conn) { if (conn ! null) { pool.addLast(conn); } } }参数逐个说清楚URL上的useSSLfalse如果你的MySQL没配SSL证书桌面直连开SSL反而增加握手延迟开发阶段直接关掉。serverTimezoneAsia/Shanghai这个不加Java 8以上的驱动和MySQL服务器时区不一致会在插入DATETIME时差8小时。characterEncodingutf8和useUnicodetrue是配合的控制连接层字符集前面建库用了utf8mb4连接层也要对应否则中文在传输过程损坏。这里有一个需要注意的点这个简化版连接池没有探活机制。如果MySQL的wait_timeout把空闲连接关了下次取出来用就会报Communications link failure。最简单的处理是在getConnection时执行一次SELECT 1或者在业务层的每个查询都及时归还连接不让连接在池里闲太久。3.3 数据访问层封装用泛型和PreparedStatement挡住SQL注入数据访问层如果每个方法都写一遍Connection、Statement、ResultSet的样板代码会很枯燥。3.0版本我做了一个BaseDao把查询和更新的公共流程收拢起来public class BaseDao { private static final DBUtil DB_UTIL new DBUtil(); protected T ListT query(String sql, Object[] params, RowMapperT mapper) { ListT list new ArrayList(); try (Connection conn DB_UTIL.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { if (params ! null) { for (int i 0; i params.length; i) { ps.setObject(i 1, params[i]); } } try (ResultSet rs ps.executeQuery()) { while (rs.next()) { list.add(mapper.mapRow(rs)); } } } catch (SQLException e) { throw new RuntimeException(SQL执行失败: sql, e); } return list; } protected int update(String sql, Object[] params) { try (Connection conn DB_UTIL.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { if (params ! null) { for (int i 0; i params.length; i) { ps.setObject(i 1, params[i]); } } return ps.executeUpdate(); } catch (SQLException e) { throw new RuntimeException(更新失败: sql, e); } } }这段代码的好处是PreparedStatement把参数和SQL语句分开不会出现把单引号拼进SQL的注入问题try-with-resources自动关闭PreparedStatement和ResultSet避免连接泄漏RowMapper是一个函数式接口调用方只负责把ResultSet一行的数据映射到实体类。这里有一个设计上的衔接DBUtil.getConnection()返回的是真实连接BaseDao用try-with-resources会在结束时调用Connection的close方法而DBUtil的closeConnection是归还到池里而不是真正断开正好接上了。如果你直接使用DriverManager原始连接一旦关闭就不能再用所以这个池和try-with-resources是配套设计的。调用方示例public class BookDao extends BaseDao { public ListBook findByTitle(String keyword) { String sql SELECT id, isbn, title, author, publisher, category, total_count, available_count, location FROM book WHERE title LIKE ?; return query(sql, new Object[]{% keyword %}, rs - { Book b new Book(); b.setId(rs.getInt(id)); b.setIsbn(rs.getString(isbn)); b.setTitle(rs.getString(title)); b.setAuthor(rs.getString(author)); b.setPublisher(rs.getString(publisher)); b.setCategory(rs.getString(category)); b.setTotalCount(rs.getInt(total_count)); b.setAvailableCount(rs.getInt(available_count)); b.setLocation(rs.getString(location)); return b; }); } }LIKE查询里把百分号放在参数里而不是字符串拼接进SQL也是防注入的一部分。排序和分组操作放到SQL里做排序字段名不要接受用户原始输入要用白名单方式转换这一点在第四章的查询与统计中会再次碰到。4. 功能实现Swing界面下的登录、图书管理、借出归还全流程这一章直接落到界面和业务逻辑。代码都是可以在本地跑起来的最小实现我按模块拆开讲。4.1 登录窗口与角色权限session模拟的身份识别图书管理系统3.0有两种典型角色管理员admin和借阅员operator。admin可以管理图书、读者、查看所有记录operator只能做借书、还书和查询。这个控制在Swing端虽然只能“防君子不防小人”但至少要在界面层把菜单按钮按角色显示。登录窗口的核心逻辑输入用户名密码后用MD5加密密码去user表查询。查到了就把当前用户对象存到一个静态Session类里再按role字段决定打开哪个主窗口。// 登录按钮的ActionListener核心逻辑 String username usernameField.getText().trim(); String password new String(passwordField.getPassword()); String md5Pwd MD5Util.md5(password); User user userDao.login(username, md5Pwd); if (user ! null) { Session.setCurrentUser(user); if (admin.equals(user.getRole())) { new AdminMainFrame().setVisible(true); } else { new OperatorMainFrame().setVisible(true); } dispose(); // 关闭登录窗口 } else { JOptionPane.showMessageDialog(this, 用户名或密码错误); }这里有一个Swing新手很容易踩的坑密码框用getPassword()返回char[]不要用getText()返回String。原因不是功能问题而是String会在内存里驻留char[]用完后可以手动置空降低密码被内存dump的风险。MD5Util.md5是我自己封装的静态方法内部用MessageDigest输出十六进制字符串。MD5不算安全但这个场景够用了真正要负责的是不要把密码明文打印到日志里。Session类就是一个持有静态User引用的容器相当于Web里的session。登录后所有界面都通过Session.getCurrentUser()获取当前操作人借书还书记录里的oper_user字段也从这里来。4.2 图书管理主界面JTable结合TableModel的增删改查图书管理主界面是使用频率最高的窗口。JTable本身不存数据它依赖TableModel。最简单的做法是用DefaultTableModel把数据放进去但3.0版本我更推荐自定义TableModel便于控制单元格是否可编辑和数据类型。关键代码是把查询结果渲染到表格public void refreshBookTable() { ListBook books bookDao.findAll(); String[] columns {ID, ISBN, 书名, 作者, 出版社, 分类, 总数, 可借数, 位置}; DefaultTableModel model new DefaultTableModel(columns, 0) { Override public boolean isCellEditable(int row, int column) { return false; // 表格只读编辑走对话框 } }; for (Book b : books) { model.addRow(new Object[]{ b.getId(), b.getIsbn(), b.getTitle(), b.getAuthor(), b.getPublisher(), b.getCategory(), b.getTotalCount(), b.getAvailableCount(), b.getLocation() }); } bookTable.setModel(model); }这样做的好处是把“界面展示”和“数据编辑”分开了表格默认不可编辑双击某一行时再弹出一个编辑对话框避免用户直接在表格里乱改导致脏数据。刷新表格的代价是全量重新查询在几千册数据量下完全没问题。如果书籍超过几万册就要考虑分页但桌面图书管理系统里几千册是常态全量查询反而更简单直接。新增和编辑走同一个对话框根据是否有ID来判断是insert还是update。删除操作不要直接删行因为book表可能已经被borrow_record引用。3.0的做法是逻辑删除把book.status置为0表示下架而不是DELETE FROM book这样历史借阅记录还能关联到书的信息。4.3 借书与还书的状态流转从“在馆”到“借出”的更新逻辑借书是整个系统里最容易出bug的地方因为涉及两张表的状态同步。我见过最典型的问题只insert了borrow_record忘了减少book.available_count结果库存数据越跑越错。3.0版本的处理方式是把两步放进一个事务并且加前置检查。借书的service层代码public void borrowBook(int bookId, int readerId) { Book book bookDao.findById(bookId); Reader reader readerDao.findById(readerId); if (book null || book.getAvailableCount() 0) { throw new BusinessException(本书暂无可借数量); } if (reader null || reader.getStatus() ! 1) { throw new BusinessException(读者状态不可借); } int borrowedCount recordDao.countBorrowedByReader(readerId); if (borrowedCount reader.getMaxBorrow()) { throw new BusinessException(读者已达到最大借阅数); } Connection conn null; try { conn DBUtil.getConnection(); conn.setAutoCommit(false); // 1. 更新图书可借数 String updateBook UPDATE book SET available_count available_count - 1 WHERE id ? AND available_count 0; int updated updateByConn(conn, updateBook, new Object[]{bookId}); if (updated 0) { throw new BusinessException(库存更新失败可能已被借走); } // 2. 插入借阅记录 String insertRecord INSERT INTO borrow_record (book_id, reader_id, borrow_time, due_time, status) VALUES (?, ?, NOW(), DATE_ADD(NOW(), INTERVAL 30 DAY), 0); updateByConn(conn, insertRecord, new Object[]{bookId, readerId}); conn.commit(); } catch (Exception e) { rollbackQuietly(conn); throw new BusinessException(借书失败: e.getMessage()); } finally { DBUtil.closeConnection(conn); } }几个关键参数说明借期30天写在SQL里用DATE_ADD计算比在Java里new Date再转换省去类型麻烦。UPDATE语句里带上available_count 0这是一个乐观锁手法。如果并发下两笔借书请求同时执行只有一行能更新成功另一行updated为0直接抛异常不需要额外的SELECT FOR UPDATE。这是图书管理系统并发量低但依然要防超卖的一种简单做法而不是指望数据库事务隔离级别帮你挡。还书逻辑正好相反先UPDATE borrow_record SET return_timeNOW(), status1 WHERE id? AND status0再UPDATE book SET available_countavailable_count1 WHERE id?。注意最后要判断是否逾期如果return_time大于due_time弹出一个逾期提示对话框。不要真的把逾期做成禁用借阅除非产品要求否则图书管理员很难办。4.4 查询与统计条件组合查询和逾期判断的SQL写法图书管理系统的查询有两个高频场景按条件组合查图书以及统计逾期未还记录。条件组合查询最容易翻车的是动态SQL拼接。很多人会用StringBuffer逐个拼WHERE条件一旦条件里有LIKE和INSQL语义很容易错。我的做法是建立查询条件对象只保留非空字段再构建参数数组public ListBook searchBooks(String title, String category, String isbn) { StringBuilder sql new StringBuilder( SELECT id, isbn, title, author, publisher, category, total_count, available_count, location FROM book WHERE 11); ListObject params new ArrayList(); if (title ! null !title.trim().isEmpty()) { sql.append( AND title LIKE ?); params.add(% title.trim() %); } if (category ! null !category.trim().isEmpty()) { sql.append( AND category ?); params.add(category.trim()); } if (isbn ! null !isbn.trim().isEmpty()) { sql.append( AND isbn ?); params.add(isbn.trim()); } sql.append( ORDER BY id DESC); return query(sql.toString(), params.toArray(), bookRowMapper); }WHERE 11是一个很土但实用的技巧它让后面的AND条件可以无条件直接追加。category用等号而不是LIKE因为分类一般是要精确匹配ISBN也是精确匹配。排序字段id DESC是最新入库优先展示如果用户想按书名排序排序字段名必须经过白名单校验不能让用户传入任意字符串防止注入和报错。逾期统计的SQLSELECT r.id, b.title, r.name, br.borrow_time, br.due_time, DATEDIFF(NOW(), br.due_time) AS overdue_days FROM borrow_record br JOIN book b ON br.book_id b.id JOIN reader r ON br.reader_id r.id WHERE br.status 0 AND br.due_time NOW() ORDER BY overdue_days DESC;这个SQL在Swing界面的“逾期列表”里直接用可以理解为一个报表。注意DATEDIFF返回的是天数差当天逾期会显示1而不是0如果业务上要求显示精度更高就换成TIMESTAMPDIFF。这个查询没有做分页桌面上千条逾期记录也扛得住。如果担心结果集过大可以在SQL尾部加LIMIT 500并配合一个“只显示前500条”的提示。5. Swing MySQL常见问题避坑乱码、卡顿、连接泄漏……逐个拆这一章写的是我实际运行这个系统后最常被问到的5类问题每一条都是真实翻车场景按“现象 → 原因 → 解决”的顺序给你排查路径。5.1 表格中文乱码控制台正常、界面乱码的现象与解决现象在IDEA控制台里查询SQL返回的中文正常但Swing的JTable、JLabel里显示一串问号或者干脆是菱形乱码。原因字符集在三个环节断裂。数据库连接URL没加characterEncodingutf8时驱动会用MySQL服务器默认字符集而你在建库时用了utf8mb4但连接层用latin1传输过程就已经损坏了。更隐蔽的一个来源是IDE的文件编码如果你用Windows默认GBK保存.java源文件而JVM用UTF-8编译字符串常量里的中文在编译期就错了。解决第一步检查JDBC URL加上characterEncodingutf8和useUnicodetrue第二步把IDE的File Encoding统一改成UTF-8第三步在启动参数里加-Dfile.encodingUTF-8。按这个顺序查基本能覆盖90%的乱码。5.2 界面卡死把JDBC操作放进EDT的后果与SwingWorker解法现象点“刷新列表”按钮后整个窗口拖不动、按钮点不了过几秒才恢复。原因Swing的事件处理是在事件调度线程EDT上执行的你写在ActionListener里的同步JDBC查询占住了EDT界面重绘只能排队等。这不是MySQL慢而是你堵住了唯一的UI线程。解决耗时的数据加载放到SwingWorker的doInBackground里完成后自动回到EDT刷新表格。代码骨架如下class LoadBookWorker extends SwingWorkerListBook, Void { Override protected ListBook doInBackground() { return bookDao.findAll(); } Override protected void done() { try { ListBook books get(); refreshTable(books); } catch (Exception e) { JOptionPane.showMessageDialog(parent, 加载失败: e.getMessage()); } } }doInBackground在后台线程执行done会被调度回EDT。这样查询期间界面可以继续显示“加载中”状态。注意在done里get()会重新抛出doInBackground里的异常所以一定要try-catch。习惯上所有可能超过100毫秒的数据库操作都应该走这个模式不只是列表加载还包括导出、批量删除。5.3 连接泄漏Connection、Statement、ResultSet的关闭顺序与try-with-resources现象系统用了一上午之后再点任何按钮都报Too many connectionsMySQL的max_connections默认151很容易被耗尽。原因某条查询路径只关了ResultSet或者干脆一个都没关连接池里的连接被占用后没有归还。我排查过最常见的位置是查询方法里如果出现异常connection和statement的关闭代码被跳过。解决终极方案是全面使用try-with-resources它保证不管正常还是异常都调用close。这里要强调关闭顺序ResultSet、Statement、Connection但try-with-resources的语法是逆序关闭所以写在一个try里就行。如果你的关闭逻辑在finally里手写一定要在finally里再加一层嵌套判断null再close不要图省事。5.4 日期显示与计算java.util.Date、java.sql.Date、Timestamp三者的坑现象借书时间显示出来少了8小时或者归还日期变成了一串看不懂的英文格式。原因MySQL的DATETIME通过JDBC映射到java.sql.Timestamp但如果你在实体类里用的是java.util.DatesetObject时会有隐式转换如果直接用java.sql.Date它只保留日期部分时分秒全被截断借书时间自然就丢了。还有一个时区问题URL里没加serverTimezone时驱动用JVM默认时区加了则可能重复偏移。解决实体类的日期字段一律用java.util.DateJDBC查询时用rs.getTimestamp()去接MySQL的DATETIME插入时用new Timestamp(date.getTime())再setObject。展示给用户时用SimpleDateFormat或DateTimeFormatter格式化不要直接把Date的toString扔给JTable。5.5 MySQL锁等待并发借还时的Lock wait timeout exceeded现象两个管理员同时对一个热门书籍操作其中一个报Lock wait timeout exceeded; try restarting transaction。原因借书事务里先UPDATE book再INSERT borrow_record另一笔借书事务也同时UPDATE同一行book行锁竞争后到的事务等待前面提交。如果前面事务因为界面弹窗迟迟没提交等待超过innodb_lock_wait_timeout默认的50秒就会报错。解决一是把事务缩短避免在事务中间弹出任何对话框所有业务校验放在开启事务之前完成二是给UPDATE语句加上available_count 0这种条件减少无效等待三是如果业务确实要排队可以在service层加一个静态锁对象让借书操作串行化。虽然牺牲一点并发但这个系统是桌面应用完全可以接受。记得在连接上设置合理的事务超时不要用默认值扛到50秒。6. 交付前最后一公里外观统一、打包发布与完整验证清单6.1 外观主题与字体统一让默认界面不那么“丑”Swing默认的Metal外观容易被吐槽只需要在main方法开头加一行UIManager.setLookAndFeel换成系统外观Windows下就是原生风格。再统一字体中文字体用“微软雅黑”字号14JTable行高调到22像素观感会好很多。如果界面上有图书分类这种树形结构需要多选Swing的JTree配合CheckBoxNodeRenderer可以做出带勾选框的树这在3.0里是图书分类筛选的加分项。6.2 打包可执行JAR与依赖处理我一般用Maven插件打包成带依赖的可执行JARpom里配maven-assembly-plugin或shade插件把mysql-connector-java打进一个jar。主类指定launcher.MainMANIFEST里写Main-Class这样双击jar就能启动。如果你要让没有Java环境的人也能运行再用jpackage打原生安装包但那一步需要配置jlink模块路径桌面内部使用场景可做可不做。6.3 验证清单从业务流程到边界条件的检查项交付前我按下面这张表逐项验证表里的每一条都是真实项目出过问题的地方验证项操作预期结果重复借同本书两笔借书请求同时提交只有一笔成功库存不出现负数还书超量对可借数为0的书执行还书提示“无借出记录”库存不能变负未登录访问直接打开主窗口类被拦截跳回登录删除已关联图书对已有借阅记录的书执行删除提示逻辑下架不物理删除特殊字符书名书名含单引号和中文符号正常入库和查询无SQL语法错误日期跨月借书后跨月还书逾期天数计算正确连接回收连续操作100次后连接池状态连接数稳定在最大值以内这张表跑完再让一个完全不懂系统的同事按“借书→还书→查逾期”流程走一遍他能不看文档操作成功才算真的交付。我自己的习惯是每次改完代码先跑这张表再发版本经历过太多“功能看着正常一碰边界就崩”的翻车后来干脆把这套清单固定成了发布前的固定动作。3.0版本的这个系统技术层面没有一项是难的难点全在状态一致性、字符集、线程模型这些细节里。把这些细节一个个摁住Swing加MySQL的组合在今天依然能撑起一个好用不贵的桌面图书管理工具。希望这些踩坑记录能帮你少走几段我走过的弯路。本文还有配套的精品资源点击获取