news 2026/10/22 23:38:28

MySQL+IDEA从零构建图书管理系统:原生JDBC实战与数据库设计

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
MySQL+IDEA从零构建图书管理系统:原生JDBC实战与数据库设计

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> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency> <dependency> <groupId>com.zaxxer</groupId> <artifactId>HikariCP</artifactId> <version>5.0.1</version> </dependency> <dependency> <groupId>com.formdev</groupId> <artifactId>flatlaf</artifactId> <version>3.2</version> </dependency> </dependencies>

FlatLaf是用来美化Swing界面的,加上之后整个程序的观感会提升一个档次,不再是那种Windows 98风格的灰色按钮。MySQL驱动8.0版本需要把URL里的时区参数带上,否则会报时区错误:

jdbc.url=jdbc:mysql://localhost:3306/library_db?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8 jdbc.username=root jdbc.password=your_password

useSSL=false在本地开发时没问题,但生产环境一定要开。characterEncoding=utf8是为了防止中文书名乱码,这个参数不加的话,插入“红楼梦”可能会变成问号。

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_timeout(8小时)短很多,避免连接被数据库端断开后池子里还留着死连接。

connectionTestQuery在MySQL驱动4.0以上其实可以省略,因为驱动支持JDBC4的isValid()方法。但加上也无妨,多一层保险。

3.3 图书查询功能的完整实现

查询功能我做了多条件动态拼接,核心思路是用StringBuilder拼SQL,用List<Object>存参数:

public List<Book> 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 1=1" ); List<Object> 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到List<Book> return executeQuery(sql.toString(), params.toArray()); }

这里用WHERE 1=1是为了后面拼接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_count=1,然后都减库存导致超借。行锁在事务提交后释放,这是数据库层面最可靠的并发控制手段。

归还操作相对简单,但要注意逾期判断:如果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 SwingWorker<List<Book>, Void>() { @Override protected List<Book> 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加serverTimezone=Asia/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报表。每一个扩展点都能带出一串新知识点。

我个人在实际操作中的体会是,不要急着用框架把项目“包装”起来。先用原生技术把逻辑跑通,把坑踩一遍,再去用框架,你会知道框架到底帮你做了什么,出了问题也知道去哪里找原因。这个项目我前后重构了三次,每次都能发现之前没注意到的细节,这种逐步深入的过程,比直接抄一个现成项目有价值得多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/22 23:35:54

OpenClaw部署实战与避坑指南:自托管AI智能体完整教程

先聊点实在的&#xff1a;如果你已经跟着课程走到了第10课&#xff0c;大概率不是来看“什么是OpenClaw”这种铺陈的&#xff0c;而是真的遇到了问题&#xff0c;或者想在部署前把坑都摸清楚。OpenClaw这个名字最近在开源智能体圈子里出镜率很高&#xff0c;尤其是那些想自己搭…

作者头像 李华
网站建设 2026/10/22 23:35:39

轻客洗衣微信小程序源码跑通实战:从解压到上线接单全链路避坑指南

简介&#xff1a;这份轻客洗衣微信小程序源码包面向微信小程序与Android开发初学者及进阶者&#xff0c;提供一套完整的洗衣服务应用实现案例&#xff0c;帮助理解预约、状态查询与支付等业务流程的代码组织方式。压缩包为rar格式&#xff0c;大小约934KB&#xff0c;文件总数暂…

作者头像 李华
网站建设 2026/10/22 23:35:38

视频插帧全流程:从光流原理到fps无限提升实操指南

视频插帧这事&#xff0c;我断断续续折腾了大半年。手机拍的30fps运动镜头想变顺滑&#xff0c;老动画的24fps想拉到120fps在高刷屏上看&#xff0c;游戏录屏想补帧让动作更跟手——这些场景核心都指向同一个词&#xff1a;无限提高视频fps。标题虽然夸张&#xff0c;但背后的技…

作者头像 李华
网站建设 2026/10/22 23:35:36

AI系统可扩展性架构设计文档:从量化指标到落地的完整指南

我最早接触可扩展性架构设计文档&#xff0c;是从一套需要支撑日活百万级请求的AI推理服务开始的。当时文档写得像系统部署说明&#xff0c;画了几张架构图、贴了一堆K8s配置&#xff0c;评审会上被问到“QPS翻三倍时瓶颈在哪”“GPU加一倍吞吐能跟着线性涨吗”“模型更新时扩展…

作者头像 李华
网站建设 2026/10/22 23:31:14

用Transformer从单张RGB图重建手部3D网格:复现与踩坑全记录

手部3D重建这个方向&#xff0c;我关注了挺长时间。之前一直用CNN为主的两阶段流程&#xff0c;中间步骤碎、误差容易累积。直到看到这篇论文——用Transformer直接从单张RGB图重建3D手部网格&#xff0c;输入一张图&#xff0c;输出带完整拓扑的mesh和关节位置。这种"通往…

作者头像 李华
网站建设 2026/10/22 23:29:20

NLP项目中表格数据处理全流程:从脏表到模型可读格式的实操指南

1. 从一张“脏表”说起&#xff1a;为什么NLP项目里最容易被低估的环节是表格处理做过自然语言处理项目的人大多有个共识&#xff1a;模型调参、网络结构设计、预训练权重选择这些环节&#xff0c;往往能获得最多的关注和讨论。但真正让一个项目从“跑得通”到“跑得稳”的&…

作者头像 李华