简介:这是一套面向Java初学者与课程设计学习者的图书管理系统完整源码,基于Swing桌面界面与MySQL数据库实现,适合用于毕业设计参考、课程作业提交或Swing+JDBC技术栈的练手项目。系统覆盖用户登录、图书类别管理(类别添加与维护)、图书信息管理(图书添加与维护)等核心业务模块,功能结构清晰,便于理解桌面端增删改查的完整实现思路。压缩包共105个文件,约3.51MB,包含43个class编译文件、14个java源码、36个png界面素材、4个jar依赖包、4个properties配置、1个sql建库脚本及工程配置文件,源码与素材齐备,导入后即可对照学习。目前已有659人学习下载,读者可从中获取分层清晰的DAO与实体类设计、Swing多窗口交互写法、数据库连接配置以及建表脚本,适合作为二次开发与功能扩展的基础模板。
1. 从一次“数据全丢”说起:Java+Swing+MySQL 图书管理系统 3.0 到底在解决什么
很多同学做 Java+Swing+MySQL 图书管理系统,第一版能跑,第二版加功能就崩,第三版直接数据错乱。我见过最典型的一次:某高校课程设计答辩前夜,A 同学把“借书”按钮连点三下,库存从 5 变成 -1,第二天演示时管理员界面显示“可借数量:-1”,台下老师问了一句“负库存是什么意思”,场面直接冷掉。这不是 Swing 的锅,也不是 MySQL 的锅,是 3.0 版本该有的东西没补上:事务边界、连接池、分层解耦、并发下的库存扣减。
这个标题里的“3.0”不是版本号噱头,它代表从“能跑”到“敢用”的那道坎。1.0 是单表 CRUD,2.0 是加登录和查询,3.0 要解决的是:多角色权限、借还书事务一致性、模糊检索性能、以及 Swing 事件线程里不能碰数据库这条铁律。适合谁看?正在做课程设计、毕业设计,或者想把手头那个“一演示就翻车”的系统真正跑稳的开发者。下面我按自己踩过的顺序,把 3.0 该有的结构和参数一次讲透。
2. 3.0 的骨架怎么搭:分层、连接池与事务边界
2.1 为什么 1.0 的“一个类干到底”到 3.0 必崩
1.0 常见写法是BookFrame里直接new JButton().addActionListener,监听器里写DriverManager.getConnection,然后Statement.executeUpdate。这种写法在单用户、单操作时没问题,但 3.0 要同时支持管理员和读者两个角色,还要在借书时同时更新book表和borrow表。一旦两个操作之间抛异常,库存扣了但借阅记录没写,数据就成黑匣子。
3.0 的第一刀是分层:ui只负责画界面和收集输入,service负责业务规则和事务,dao只负责 SQL,util管连接和配置。Swing 的事件分发线程(EDT)里只做界面更新,数据库操作丢到SwingWorker或独立线程池。这不是架构洁癖,是避免界面卡死和事务跨线程失效的硬要求。
2.2 连接池选型:HikariCP 的最小配置与参数含义
3.0 不该再用DriverManager每次新建连接。常见做法是引入 HikariCP,它轻、快、配置少。下面是一个可直接抄的db.properties和初始化代码。
# db.properties jdbcUrl=jdbc:mysql://localhost:3306/library3?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false username=root password=your_password maximumPoolSize=10 minimumIdle=2 connectionTimeout=3000 idleTimeout=60000 maxLifetime=1800000// DataSourceUtil.java import com.zaxxer.hikari.HikariConfig; import com.zaxxer.hikari.HikariDataSource; import java.io.InputStream; import java.util.Properties; public class DataSourceUtil { private static HikariDataSource ds; static { try (InputStream in = DataSourceUtil.class.getClassLoader() .getResourceAsStream("db.properties")) { Properties p = new Properties(); p.load(in); HikariConfig cfg = new HikariConfig(); cfg.setJdbcUrl(p.getProperty("jdbcUrl")); cfg.setUsername(p.getProperty("username")); cfg.setPassword(p.getProperty("password")); // 最大连接数:按并发用户数估算,课程设计 10 足够 cfg.setMaximumPoolSize(Integer.parseInt(p.getProperty("maximumPoolSize"))); cfg.setMinimumIdle(Integer.parseInt(p.getProperty("minimumIdle"))); // 获取连接超时:3 秒拿不到就抛异常,避免界面无限等待 cfg.setConnectionTimeout(Long.parseLong(p.getProperty("connectionTimeout"))); cfg.setIdleTimeout(Long.parseLong(p.getProperty("idleTimeout"))); cfg.setMaxLifetime(Long.parseLong(p.getProperty("maxLifetime"))); ds = new HikariDataSource(cfg); } catch (Exception e) { throw new ExceptionInInitializerError("数据源初始化失败: " + e.getMessage()); } } public static HikariDataSource getDs() { return ds; } }逻辑说明:静态块保证整个 JVM 只初始化一次连接池;maximumPoolSize不是越大越好,课程设计场景 10 个连接足够,设成 100 反而会因 MySQLmax_connections限制导致连接被拒。connectionTimeout设 3000 毫秒,是为了在数据库没启动时快速失败,而不是让 Swing 界面卡死。serverTimezone必须显式指定,否则 MySQL 8 驱动会报时区异常,这是血泪经验。
2.3 借书事务:把“扣库存”和“写借阅”绑成一个原子操作
3.0 最核心的业务是借书。正确顺序是:开启事务 → 查询库存并加行锁 → 库存减一 → 插入借阅记录 → 提交。任何一步失败都回滚。下面用PreparedStatement和手动事务实现。
// BorrowService.java public boolean borrowBook(int bookId, int readerId) { String checkSql = "SELECT stock FROM book WHERE id = ? FOR UPDATE"; String updateSql = "UPDATE book SET stock = stock - 1 WHERE id = ? AND stock > 0"; String insertSql = "INSERT INTO borrow(book_id, reader_id, borrow_date, status) VALUES(?,?,NOW(),'BORROWED')"; Connection conn = null; try { conn = DataSourceUtil.getDs().getConnection(); conn.setAutoCommit(false); // 关闭自动提交,事务开始 try (PreparedStatement ps1 = conn.prepareStatement(checkSql)) { ps1.setInt(1, bookId); try (ResultSet rs = ps1.executeQuery()) { if (!rs.next() || rs.getInt("stock") <= 0) { conn.rollback(); return false; // 库存不足,直接回滚 } } } try (PreparedStatement ps2 = conn.prepareStatement(updateSql)) { ps2.setInt(1, bookId); if (ps2.executeUpdate() != 1) { conn.rollback(); return false; // 并发下库存被抢光 } } try (PreparedStatement ps3 = conn.prepareStatement(insertSql)) { ps3.setInt(1, bookId); ps3.setInt(2, readerId); ps3.executeUpdate(); } conn.commit(); return true; } catch (SQLException e) { if (conn != null) { try { conn.rollback(); } catch (SQLException ex) { ex.printStackTrace(); } } e.printStackTrace(); return false; } finally { if (conn != null) { try { conn.setAutoCommit(true); conn.close(); } catch (SQLException e) { e.printStackTrace(); } } } }参数说明:FOR UPDATE是行级锁,保证在事务提交前其他事务不能修改这行库存,这是防止负库存的关键。updateSql里加AND stock > 0是第二道保险,即使锁没拦住,更新影响行数为 0 也会触发回滚。conn.setAutoCommit(true)在归还连接前恢复,否则连接池里其他调用会继承“不自动提交”的状态,导致后续查询看不到数据,这个坑我踩过不止一次。
2.4 Swing 线程模型:为什么数据库操作不能写在 actionPerformed 里
Swing 的actionPerformed运行在 EDT 上,EDT 同时负责重绘界面。如果在里面执行一条 2 秒的 SQL,界面就会冻结 2 秒,用户以为程序死了。3.0 的做法是把耗时操作放进SwingWorker。
// BorrowButtonAction.java new JButton("借书").addActionListener(e -> { int bookId = Integer.parseInt(bookIdField.getText().trim()); int readerId = Integer.parseInt(readerIdField.getText().trim()); new SwingWorker<Boolean, Void>() { @Override protected Boolean doInBackground() { // 后台线程执行数据库事务,不阻塞 EDT return new BorrowService().borrowBook(bookId, readerId); } @Override protected void done() { try { boolean ok = get(); statusLabel.setText(ok ? "借阅成功" : "库存不足或借阅失败"); } catch (Exception ex) { statusLabel.setText("系统异常: " + ex.getMessage()); } } }.execute(); });逻辑说明:doInBackground在后台线程跑,done回到 EDT 更新statusLabel。这样界面不会卡,事务也不会因为线程切换而丢失。注意get()会抛出ExecutionException,必须捕获,否则异常会被吞掉,界面永远显示“处理中”。
3. 数据库表设计与检索性能:3.0 该加哪些索引和约束
3.1 三张核心表的最小字段与约束
3.0 至少需要book、reader、borrow三张表。字段不在多,在于约束到位。
CREATE TABLE book ( id INT PRIMARY KEY AUTO_INCREMENT, isbn VARCHAR(20) NOT NULL UNIQUE, title VARCHAR(100) NOT NULL, author VARCHAR(50), stock INT NOT NULL DEFAULT 0, total INT NOT NULL DEFAULT 0, CONSTRAINT chk_stock CHECK (stock >= 0) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE reader ( id INT PRIMARY KEY AUTO_INCREMENT, card_no VARCHAR(20) NOT NULL UNIQUE, name VARCHAR(50) NOT NULL, phone VARCHAR(20), status TINYINT DEFAULT 1 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE borrow ( id INT PRIMARY KEY AUTO_INCREMENT, book_id INT NOT NULL, reader_id INT NOT NULL, borrow_date DATETIME NOT NULL, return_date DATETIME, status VARCHAR(20) NOT NULL DEFAULT 'BORROWED', INDEX idx_book (book_id), INDEX idx_reader (reader_id), INDEX idx_status (status), CONSTRAINT fk_borrow_book FOREIGN KEY (book_id) REFERENCES book(id), CONSTRAINT fk_borrow_reader FOREIGN KEY (reader_id) REFERENCES reader(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;参数说明:CHECK (stock >= 0)是数据库层最后一道防线,MySQL 8.0.16 之后才真正生效,5.7 会忽略,所以不能只靠它。idx_status用于快速筛选“未归还”记录,3.0 的逾期查询会频繁用到。外键约束保证借阅记录不会指向不存在的书或读者,但注意外键会降低插入性能,课程设计数据量下可接受。
3.2 模糊检索的索引策略:LIKE '%关键词%' 为什么用不上索引
3.0 的图书查询通常支持书名、作者、ISBN 模糊匹配。很多人写WHERE title LIKE '%Java%',然后发现数据一多就慢。原因是 B+ 树索引只能用于前缀匹配,%开头的 LIKE 会全表扫描。
常见做法是:如果必须支持任意位置匹配,数据量小于 1 万条时全表扫描可接受;超过 1 万条,考虑加全文索引或把检索字段冗余到一个search_text列,用MATCH ... AGAINST。课程设计阶段,更实际的做法是限制检索字段,并给title加前缀索引。
-- 前缀索引,只索引前 20 个字符,节省空间 ALTER TABLE book ADD INDEX idx_title_prefix (title(20)); -- 查询时尽量让条件能走索引 SELECT id, title, author, stock FROM book WHERE title LIKE 'Java%' OR isbn = '9787111xxxxxx';逻辑说明:title LIKE 'Java%'能走前缀索引,LIKE '%Java%'不能。如果用户输入的关键词不确定在开头,可以在 Java 层先判断,若关键词长度大于 2 且不以通配符开头,再拼 SQL。不要为了“看起来支持模糊”而牺牲全部性能。
3.3 分页查询:LIMIT 偏移量大了为什么变慢
3.0 的图书列表要分页。LIMIT 0, 20很快,但LIMIT 100000, 20会先扫描 100020 行再丢弃前 100000 行。优化方式是记住上一页最后一条的 id,用WHERE id > lastId LIMIT 20。
-- 传统分页,偏移量大时慢 SELECT * FROM book ORDER BY id LIMIT 100000, 20; -- 游标分页,利用主键索引 SELECT * FROM book WHERE id > 100000 ORDER BY id LIMIT 20;参数说明:游标分页要求排序字段唯一且有序,主键id满足。Swing 界面里“上一页/下一页”按钮需要维护lastId状态,而不是维护页码。这个改动在 3.0 里值得做,因为借阅历史表会越来越大。
4. 避坑与排查:3.0 上线前必须过的五道坎
4.1 现象:界面点“查询”没反应,控制台无异常
原因:数据库操作在 EDT 里执行,且连接池connectionTimeout设得过大,或者 MySQL 服务没启动,getConnection一直阻塞。解决:把connectionTimeout降到 3000 毫秒,并在doInBackground里捕获SQLException,用done更新界面提示“数据库连接失败”。同时检查db.properties是否被正确加载,getResourceAsStream返回 null 时静态块会抛ExceptionInInitializerError,这个错误在 Swing 里常被吞掉。
4.2 现象:借书成功但库存没减,或者库存减了借阅记录没写
原因:事务边界不对。常见错误是在dao里各自getConnection,service里再开一个连接,两个连接不在同一事务。解决:整个借书流程只用一个Connection,从service传入dao,或者用ThreadLocal绑定当前线程连接。我一般会在service方法开头获取连接,然后显式传给每个 DAO 方法,虽然参数多一个,但事务边界一目了然。
4.3 现象:MySQL 8 启动报Public Key Retrieval is not allowed
原因:MySQL 8 默认使用caching_sha2_password认证插件,JDBC 连接串没允许公钥检索。解决:在jdbcUrl后加allowPublicKeyRetrieval=true,同时确保useSSL=false(本地开发)。生产环境应配置 SSL,但课程设计本地跑,加这个参数最快。注意这个参数有安全含义,不要在有真实数据的公网环境随意开。
4.4 现象:中文书名存进数据库变成问号
原因:连接串没指定characterEncoding=utf8,或者数据库/表字符集不是utf8mb4。解决:连接串加useUnicode=true&characterEncoding=utf8,建表时指定DEFAULT CHARSET=utf8mb4。如果已经建错,用ALTER TABLE book CONVERT TO CHARACTER SET utf8mb4修复。注意utf8在 MySQL 里是 3 字节,存不了 Emoji,utf8mb4才是完整 4 字节。
4.5 现象:程序运行一段时间后报Too many connections
原因:每次操作都DriverManager.getConnection且忘记close,连接数暴涨。解决:统一用 HikariCP,并在finally里close()归还连接。注意close()在连接池里是归还,不是物理关闭。另外检查maxLifetime是否小于 MySQL 的wait_timeout,否则连接被 MySQL 单方面断开后,池里拿到的可能是死连接。常见做法是maxLifetime设 1800000 毫秒(30 分钟),比 MySQL 默认wait_timeout28800 秒小很多,安全。
5. 进阶技巧:用 SwingWorker 批量导入并实时刷新进度条
3.0 如果只做单本借还,还体现不出“系统”的价值。真正让课程设计出彩的是批量导入图书:从 CSV 读 500 条数据,逐条插入,同时进度条实时更新。这里的关键是SwingWorker的publish/process机制,以及批量插入时的事务提交频率。
// BatchImportWorker.java public class BatchImportWorker extends SwingWorker<Integer, Integer> { private final List<String[]> rows; private final JProgressBar progressBar; public BatchImportWorker(List<String[]> rows, JProgressBar progressBar) { this.rows = rows; this.progressBar = progressBar; } @Override protected Integer doInBackground() throws Exception { int success = 0; Connection conn = DataSourceUtil.getDs().getConnection(); conn.setAutoCommit(false); String sql = "INSERT INTO book(isbn, title, author, stock, total) VALUES(?,?,?,?,?)"; try (PreparedStatement ps = conn.prepareStatement(sql)) { for (int i = 0; i < rows.size(); i++) { String[] r = rows.get(i); ps.setString(1, r[0]); ps.setString(2, r[1]); ps.setString(3, r[2]); ps.setInt(4, Integer.parseInt(r[3])); ps.setInt(5, Integer.parseInt(r[3])); ps.addBatch(); // 每 100 条提交一次,平衡内存和事务大小 if ((i + 1) % 100 == 0) { ps.executeBatch(); conn.commit(); publish(i + 1); // 通知进度 } } ps.executeBatch(); conn.commit(); success = rows.size(); } catch (Exception e) { conn.rollback(); throw e; } finally { conn.setAutoCommit(true); conn.close(); } return success; } @Override protected void process(List<Integer> chunks) { int latest = chunks.get(chunks.size() - 1); progressBar.setValue(latest); progressBar.setString(latest + " / " + rows.size()); } @Override protected void done() { try { int total = get(); JOptionPane.showMessageDialog(null, "导入完成,共 " + total + " 条"); } catch (Exception e) { JOptionPane.showMessageDialog(null, "导入失败: " + e.getMessage()); } } }逻辑说明:addBatch把多条 INSERT 攒起来,executeBatch一次性发给 MySQL,比逐条插入快一个数量级。每 100 条commit一次,避免一个巨大事务把 undo log 撑爆,也避免失败时全部回滚导致前功尽弃。publish在后台线程调用,process在 EDT 执行,所以进度条更新是线程安全的。参数上,100 这个批次大小可以调,数据量大就调到 500,但注意max_allowed_packet限制。
一个我自己的习惯:批量导入前先SELECT COUNT(*)看目标表已有多少行,导入后对比总数,确认没有静默丢数据。另外 CSV 里的 ISBN 如果重复,UNIQUE约束会让executeBatch抛异常,整个批次回滚。所以要么在 Java 层先去重,要么用INSERT IGNORE,但INSERT IGNORE会吞掉其他错误,我一般选择在导入前用Set去重,把重复的 ISBN 单独记到一个日志列表,导入结束后弹窗提示“跳过 N 条重复 ISBN”。这样既不丢数据,也不让用户一脸茫然。
最后说一个验证方法:导入完成后,随机抽 5 条 ISBN,用SELECT * FROM book WHERE isbn = ?逐条核对,确认书名、作者、库存都对得上。这个动作花不了一分钟,但能挡住 90% 的“导入成功但数据错位”问题。希望帮到你。
本文还有配套的精品资源,点击获取