
1. 项目缘起与整体设计思路1.1 为什么选择图书管理系统作为练手项目做CRUD项目的人都有一个共识图书管理系统几乎是每个后端开发者的“必修课”。但很多人做完就扔觉得太简单、没技术含量。我一开始也这么想直到真正动手用MySQL加IDEA从零搭了一套之后才发现这个项目能覆盖的知识面远比想象中广——数据库设计、JDBC连接池、事务控制、前后端数据交互、异常处理、甚至界面布局的细节全都能在一个项目里串起来。这个项目的核心定位很明确面向中小型图书馆或个人书房的轻量级管理工具。它要解决的核心问题是把纸质台账搬到数据库里实现图书信息的增删改查、借阅归还记录追踪、以及简单的用户权限区分。适合谁参考刚学完Java基础、想找一个完整项目练手的同学或者已经会写增删改查、但没系统梳理过数据库设计和项目分层的人。我选IDEA作为开发环境原因很直接它对Java生态的支持最完整Maven依赖管理、数据库工具窗口、调试器都是一站式的。MySQL选的是8.0版本因为窗口函数和CTE在后续做统计报表时能省不少事。整个项目没有用Spring Boot而是用原生JDBC加Swing做界面目的就是把底层原理暴露出来不让自己被框架的便利性掩盖掉基础漏洞。1.2 系统模块划分与功能边界整个系统我拆成了四个核心模块每个模块的职责边界划得很清楚图书信息管理模块负责图书的录入、修改、删除、多条件查询。核心表是book字段包括ISBN、书名、作者、出版社、出版日期、库存数量、分类编号。读者管理模块维护借阅者的基本信息包括读者编号、姓名、联系方式、可借数量上限。核心表是reader。借阅归还模块这是整个系统的业务核心涉及borrow_record表记录借出时间、应还时间、实际归还时间、逾期状态。统计查询模块按分类统计藏书量、按时间段统计借阅频次、查询逾期未还清单。模块之间通过外键关联但没有做级联删除。这是有意为之图书被借阅过就不能物理删除只能标记为“已下架”否则历史借阅记录会变成孤儿数据。这个设计决策在后面踩坑环节会详细说。1.3 技术选型背后的取舍逻辑很多人问为什么不用MyBatis或者Hibernate。我的考虑是这个项目的SQL语句复杂度不高但涉及多表联查和事务控制用原生JDBC反而能让我把Connection、PreparedStatement、ResultSet的生命周期管清楚。如果一上来就用ORM框架遇到连接泄漏或者事务不回滚的问题排查起来会多一层迷雾。界面层用Swing而不是JavaFX纯粹是因为Swing的教程资源多遇到问题容易搜到答案。虽然Swing的默认外观比较复古但通过设置系统外观和自定义渲染器也能做出清爽的效果。数据库连接池用的是HikariCP配置简单性能稳定比手写连接管理省心得多。提示如果你打算把这个项目写进简历建议至少把连接池换成HikariCP或Druid并且加上事务注解或手动事务控制。面试官看到原生JDBC加连接池的组合会比看到纯JDBC更认可。2. 数据库设计与核心表结构拆解2.1 表结构设计与字段类型选择数据库设计是整个项目的地基地基没打好后面改起来就是连锁反应。我一开始图省事把ISBN设成了VARCHAR(20)结果发现有些旧书的ISBN只有10位有些带连字符长度不统一。后来改成VARCHAR(17)并加唯一索引才把数据规范住。下面是核心表的DDL我直接贴出来你可以照着建CREATE TABLE book ( book_id INT PRIMARY KEY AUTO_INCREMENT, isbn VARCHAR(17) NOT NULL UNIQUE, title VARCHAR(200) NOT NULL, author VARCHAR(100) NOT NULL, publisher VARCHAR(100), publish_date DATE, category_id INT, total_count INT DEFAULT 1, available_count INT DEFAULT 1, status TINYINT DEFAULT 1 COMMENT 1在架 0下架, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (category_id) REFERENCES category(category_id) ); CREATE TABLE reader ( reader_id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, phone VARCHAR(20), max_borrow INT DEFAULT 5, current_borrow INT DEFAULT 0, register_date DATE DEFAULT (CURRENT_DATE) ); CREATE TABLE borrow_record ( record_id INT PRIMARY KEY AUTO_INCREMENT, book_id INT NOT NULL, reader_id INT NOT NULL, borrow_date DATETIME DEFAULT CURRENT_TIMESTAMP, due_date DATETIME NOT NULL, return_date DATETIME, status TINYINT DEFAULT 0 COMMENT 0借出 1已还 2逾期, FOREIGN KEY (book_id) REFERENCES book(book_id), FOREIGN KEY (reader_id) REFERENCES reader(reader_id) );字段类型的选择有几个细节值得说。status用TINYINT而不是BOOLEAN因为后面可能扩展更多状态比如预约中、丢失。borrow_date用DATETIME而不是DATE因为借阅时间精确到秒对逾期计算更友好。available_count和total_count分开存而不是每次去borrow_record里算是为了查询性能——用空间换时间在中小规模数据下完全划算。2.2 索引设计与查询优化索引这块我踩过坑。一开始只在主键上建了索引结果按书名模糊查询的时候几万条数据就开始卡。后来加了几个关键索引book表的title字段建了前缀索引INDEX idx_title (title(20))因为书名通常前20个字符就能区分。borrow_record表的reader_id和status建了联合索引idx_reader_status (reader_id, status)查某个读者当前借了几本书时直接走索引。borrow_record表的due_date单独建索引用于定时任务扫描逾期记录。但索引不是越多越好。我一开始给author也加了索引后来发现按作者查询的频率极低反而拖慢了插入速度就删掉了。索引的取舍标准是读多写少的字段才值得建区分度低的字段比如性别建了也没用。2.3 事务控制与并发安全借书这个操作涉及三个步骤插入借阅记录、更新图书可借数量、更新读者当前借阅数。这三步必须在一个事务里完成否则会出现“记录插入了但库存没减”的脏数据。我用的是手动事务控制核心代码结构如下Connection conn null; try { conn dataSource.getConnection(); conn.setAutoCommit(false); // 1. 检查库存 // 2. 插入借阅记录 // 3. 更新book表available_count // 4. 更新reader表current_borrow conn.commit(); } catch (SQLException e) { if (conn ! null) conn.rollback(); throw new RuntimeException(借阅失败, e); } finally { if (conn ! null) { conn.setAutoCommit(true); conn.close(); } }这里有个细节setAutoCommit(true)必须在close()之前调用否则连接回到池子里还是手动提交模式下一个拿到这个连接的操作会莫名其妙不提交。这个坑我排查了两个小时才找到原因。注意HikariCP默认的autoCommit是true但如果你在代码里改成了false归还连接前一定要改回来。更稳妥的做法是用Spring的Transactional但原生JDBC下只能自己管。3. 实操过程与核心环节实现3.1 IDEA项目搭建与依赖配置在IDEA里新建一个Maven项目pom.xml里只需要三个核心依赖dependencies dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency dependency groupIdcom.zaxxer/groupId artifactIdHikariCP/artifactId version5.0.1/version /dependency dependency groupIdcom.formdev/groupId artifactIdflatlaf/artifactId version3.2/version /dependency /dependenciesFlatLaf是用来美化Swing界面的加上之后整个程序的观感会提升一个档次不再是那种Windows 98风格的灰色按钮。MySQL驱动8.0版本需要把URL里的时区参数带上否则会报时区错误jdbc.urljdbc:mysql://localhost:3306/library_db?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8 jdbc.usernameroot jdbc.passwordyour_passworduseSSLfalse在本地开发时没问题但生产环境一定要开。characterEncodingutf8是为了防止中文书名乱码这个参数不加的话插入“红楼梦”可能会变成问号。3.2 数据库连接池的配置与调优HikariCP的配置我调过好几轮最终稳定下来的参数是这样的HikariConfig config new HikariConfig(); config.setJdbcUrl(prop.getProperty(jdbc.url)); config.setUsername(prop.getProperty(jdbc.username)); config.setPassword(prop.getProperty(jdbc.password)); config.setMaximumPoolSize(10); config.setMinimumIdle(2); config.setConnectionTimeout(30000); config.setIdleTimeout(600000); config.setMaxLifetime(1800000); config.setConnectionTestQuery(SELECT 1);maximumPoolSize设10是因为这个系统的并发量很低设大了反而浪费资源。minimumIdle设2保证随时有两个热连接可用。maxLifetime设30分钟比MySQL默认的wait_timeout8小时短很多避免连接被数据库端断开后池子里还留着死连接。connectionTestQuery在MySQL驱动4.0以上其实可以省略因为驱动支持JDBC4的isValid()方法。但加上也无妨多一层保险。3.3 图书查询功能的完整实现查询功能我做了多条件动态拼接核心思路是用StringBuilder拼SQL用ListObject存参数public ListBook searchBooks(String keyword, Integer categoryId, Boolean availableOnly) { StringBuilder sql new StringBuilder( SELECT b.*, c.category_name FROM book b LEFT JOIN category c ON b.category_id c.category_id WHERE 11 ); ListObject params new ArrayList(); if (keyword ! null !keyword.trim().isEmpty()) { sql.append( AND (b.title LIKE ? OR b.author LIKE ? OR b.isbn LIKE ?)); String likePattern % keyword.trim() %; params.add(likePattern); params.add(likePattern); params.add(likePattern); } if (categoryId ! null categoryId 0) { sql.append( AND b.category_id ?); params.add(categoryId); } if (availableOnly ! null availableOnly) { sql.append( AND b.available_count 0); } sql.append( ORDER BY b.create_time DESC); // 执行查询填充ResultSet到ListBook return executeQuery(sql.toString(), params.toArray()); }这里用WHERE 11是为了后面拼接AND条件时不用判断是否是第一个条件虽然有点“土”但胜在逻辑清晰。LIKE查询的%放在两边会导致索引失效但在数据量不大的情况下可以接受。如果数据量上到十万级建议改用全文索引或者Elasticsearch。3.4 借阅与归还的业务逻辑实现借阅操作我加了几个校验读者是否存在、图书是否在架、库存是否大于零、读者当前借阅数是否达到上限。这些校验全部在事务内完成避免并发时超借。public BorrowResult borrowBook(int bookId, int readerId) { Connection conn null; try { conn dataSource.getConnection(); conn.setAutoCommit(false); // 校验读者 Reader reader readerDao.findById(conn, readerId); if (reader null) return BorrowResult.fail(读者不存在); if (reader.getCurrentBorrow() reader.getMaxBorrow()) { return BorrowResult.fail(已达借阅上限); } // 校验图书 Book book bookDao.findByIdForUpdate(conn, bookId); if (book null || book.getStatus() 0) { return BorrowResult.fail(图书已下架); } if (book.getAvailableCount() 0) { return BorrowResult.fail(库存不足); } // 插入借阅记录 LocalDateTime dueDate LocalDateTime.now().plusDays(30); borrowDao.insert(conn, bookId, readerId, dueDate); // 更新库存和借阅数 bookDao.decreaseAvailable(conn, bookId); readerDao.increaseBorrow(conn, readerId); conn.commit(); return BorrowResult.success(借阅成功应还日期 dueDate.toLocalDate()); } catch (SQLException e) { rollbackQuietly(conn); return BorrowResult.fail(系统异常 e.getMessage()); } finally { closeQuietly(conn); } }findByIdForUpdate里用了SELECT ... FOR UPDATE这是为了防止两个线程同时借同一本书时都读到available_count1然后都减库存导致超借。行锁在事务提交后释放这是数据库层面最可靠的并发控制手段。归还操作相对简单但要注意逾期判断如果return_date超过due_date把status置为2逾期并可以计算逾期天数用于后续罚款逻辑。3.5 界面布局与事件绑定Swing的界面我用的是BorderLayout加GridBagLayout的组合。主窗口分三块顶部是搜索栏中间是JTable展示图书列表底部是操作按钮。JTable的数据模型继承AbstractTableModel这样刷新数据时只需要调用fireTableDataChanged()。事件绑定这块有个小技巧按钮的ActionListener里不要直接写业务逻辑而是调用Service层的方法然后用SwingUtilities.invokeLater()更新界面。因为Swing不是线程安全的所有界面更新必须在事件分发线程EDT里执行。searchButton.addActionListener(e - { String keyword searchField.getText(); new SwingWorkerListBook, Void() { Override protected ListBook doInBackground() { return bookService.searchBooks(keyword, null, null); } Override protected void done() { try { tableModel.setBooks(get()); } catch (Exception ex) { JOptionPane.showMessageDialog(null, 查询失败 ex.getMessage()); } } }.execute(); });用SwingWorker的好处是查询在后台线程执行不会卡住界面。如果直接在EDT里查数据库数据量大的时候界面会假死用户体验很差。4. 常见问题与排查技巧实录4.1 数据库连接相关故障速查问题现象可能原因排查方法解决方案启动时报时区错误URL缺少serverTimezone参数看异常堆栈里有没有The server time zoneURL加serverTimezoneAsia/Shanghai连接池耗尽连接未归还或泄漏打印活跃连接数检查finally块确保每个Connection都close中文乱码字符集不匹配查数据库、表、连接的字符集统一用utf8mb4连接超时网络问题或数据库未启动telnet数据库端口检查服务状态和防火墙死连接maxLifetime大于数据库wait_timeout查MySQL的wait_timeout变量maxLifetime设为小于wait_timeout连接泄漏是最隐蔽的问题。我当时的做法是在HikariCP配置里加上config.setLeakDetectionThreshold(60000)这样连接超过60秒没归还就会打印警告日志顺着日志就能找到哪段代码忘了close。4.2 事务不回滚的典型场景有一次测试借书功能故意在更新库存后抛异常结果发现借阅记录回滚了但库存没回滚。排查后发现是bookDao.decreaseAvailable方法内部自己获取了一个新连接不在外层事务的管理范围内。教训事务方法内所有数据库操作必须共用同一个Connection。要么把Connection作为参数传递要么用ThreadLocal绑定当前线程的连接。我后来改成了参数传递的方式虽然代码啰嗦一点但事务边界一目了然。另一个坑是异常被吞掉。如果catch块里只打印日志不抛出异常外层事务管理器感知不到错误就不会回滚。所以catch块里要么重新抛出RuntimeException要么显式调用conn.rollback()。4.3 界面卡顿与数据刷新问题Swing界面卡顿通常有两个原因一是在EDT里做了耗时操作二是频繁刷新整个表格。我的优化方案是所有数据库查询放到SwingWorker的doInBackground里。表格数据更新用fireTableRowsInserted或fireTableRowsUpdated而不是fireTableDataChanged减少重绘范围。分页查询每页只查20条翻页时再查下一页。还有一个细节JTable的getColumnClass方法要正确返回列的数据类型否则排序会按字符串排出现“10”排在“2”前面的情况。4.4 项目打包与部署的注意事项用Maven的maven-shade-plugin打可执行jar包时要把MySQL驱动和HikariCP的依赖都打进去。配置文件db.properties放在src/main/resources下打包后会在jar包内通过getClass().getClassLoader().getResourceAsStream(db.properties)读取。如果要在没有Java环境的机器上运行可以用jpackage生成带JRE的安装包。但注意MySQL数据库需要单独部署或者改用嵌入式数据库如H2不过H2的SQL语法和MySQL有差异迁移时需要改SQL。提示打包前把db.properties里的密码改成从环境变量读取避免密码硬编码在jar包里。虽然是小项目但养成这个习惯没坏处。5. 项目感悟与后续扩展方向这个项目做完之后我最大的体会是CRUD项目的水深不深取决于你愿不愿意往深处挖。表面上看就是增删改查但每一个操作背后都有事务、并发、索引、异常处理这些底层知识在支撑。把图书管理系统做扎实了再去看Spring Boot的自动配置会发现它帮你省掉的正是那些你曾经手动处理过的繁琐细节理解起来会快很多。后续如果想继续扩展有几个方向可以考虑加一个定时任务扫描逾期记录并自动发送提醒把Swing界面换成Web界面用Servlet加JSP或者前后端分离引入Redis缓存热门图书的查询结果甚至可以把借阅数据导出成Excel报表。每一个扩展点都能带出一串新知识点。我个人在实际操作中的体会是不要急着用框架把项目“包装”起来。先用原生技术把逻辑跑通把坑踩一遍再去用框架你会知道框架到底帮你做了什么出了问题也知道去哪里找原因。这个项目我前后重构了三次每次都能发现之前没注意到的细节这种逐步深入的过程比直接抄一个现成项目有价值得多。