第三阶段我们密集学习了索引、事务、JDBC 编程和连接池。现在是时候将这些知识整合成一个真正可运行的小应用了。本文将带你用纯 Java 实现一个命令行交互的图书借阅系统,它具备:
- 完整的借阅、归还、查询功能
- 使用事务保证借阅操作的原子性(库存更新 + 记录插入)
- 为查询频繁的列建立索引,提升搜索效率
- 使用
PreparedStatement防止 SQL 注入 - 通过 HikariCP 连接池管理数据库连接
- 模拟并发借阅场景,观察事务与锁的行为
读完并跟着敲完代码后,你将拥有一个可以放在简历里的完整项目雏形。
1. 项目概述与准备工作
1.1 功能清单
我们的命令行系统支持以下操作:
- 借阅图书:输入读者ID和图书ID,系统检查库存、创建借阅记录、扣减库存(事务保证)。
- 归还图书:输入借阅记录ID,系统更新归还日期、恢复库存(事务保证)。
- 查询图书:按书名关键词搜索(利用索引加速)。
- 查询借阅记录:查看某读者的当前借阅情况。
- 并发模拟:用多线程模拟两个读者同时抢最后一本书。
1.2 数据库准备
我们使用之前 library_db 数据库中的 readers、books、borrow_records 表。为了本节实验的完整性,请确保以下表结构存在(如果之前没建过,请执行):
sql
CREATE DATABASE IF NOT EXISTS library_db CHARACTER SET utf8mb4;
USE library_db;
-- 读者表
CREATE TABLE IF NOT EXISTS readers (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(50) NOT NULL,
email VARCHAR(100) UNIQUE,
phone VARCHAR(20) DEFAULT NULL,
registered_at DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB;
-- 图书表
CREATE TABLE IF NOT EXISTS books (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(200) NOT NULL,
author VARCHAR(100) NOT NULL,
isbn VARCHAR(20) UNIQUE,
stock INT UNSIGNED NOT NULL DEFAULT 0,
publish_date DATE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_title (title), -- 书名索引,加速模糊搜索
INDEX idx_author (author) -- 作者索引
) ENGINE=InnoDB;
-- 借阅记录表
CREATE TABLE IF NOT EXISTS borrow_records (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
reader_id INT UNSIGNED NOT NULL,
book_id INT UNSIGNED NOT NULL,
borrow_date DATE NOT NULL DEFAULT (CURRENT_DATE),
due_date DATE NOT NULL,
return_date DATE DEFAULT NULL,
FOREIGN KEY (reader_id) REFERENCES readers(id),
FOREIGN KEY (book_id) REFERENCES books(id),
INDEX idx_reader_id (reader_id), -- 用于查询某读者的借阅记录
INDEX idx_book_id (book_id), -- 用于查询某图书的借阅情况
INDEX idx_due_date (due_date) -- 用于查询逾期记录
) ENGINE=InnoDB;
索引设计思路:
books.title上的索引用于加速WHERE title LIKE '%关键词%'这样的搜索(虽然前缀通配无法完全利用索引,但全索引扫描仍优于全表)。borrow_records.reader_id和book_id的索引用于加速 JOIN 和条件过滤,尤其是在并发事务中行锁定位。due_date上的索引用于逾期查询的排序和筛选。
插入测试数据(如果数据丢失):
sql
INSERT INTO books (title, author, isbn, stock, publish_date) VALUES
('MySQL 从入门到精通', '张三', '978-7-111-00001', 5, '2023-01-15'),
('深入理解计算机系统', 'Randal E. Bryant', '978-7-121-00002', 3, '2016-11-01'),
('算法导论', 'Thomas H. Cormen', '978-7-111-00005', 1, '2013-07-01'),
('Java 编程思想', 'Bruce Eckel', '978-7-111-00008', 2, '2007-06-01'),
('三体', '刘慈欣', '978-7-229-00010', 1, '2008-01-01');
INSERT INTO readers (name, email) VALUES
('张三', 'zhangsan@example.com'),
('李四', 'lisi@example.com'),
('王五', 'wangwu@example.com');
2. 搭建项目骨架
2.1 Maven 依赖
xml
<dependencies>
<dependency>
<groupId>com.zaxxer</groupId>
<artifactId>HikariCP</artifactId>
<version>5.1.0</version>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<version>8.0.33</version>
</dependency>
</dependencies>
2.2 数据源工具类
java
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import javax.sql.DataSource;
public class DBUtil {
private static final HikariDataSource dataSource;
static {
HikariConfig config = new HikariConfig();
config.setJdbcUrl("jdbc:mysql://localhost:3306/library_db?useSSL=false&serverTimezone=UTC&characterEncoding=utf8");
config.setUsername("root");
config.setPassword("your_password");
config.setMinimumIdle(2);
config.setMaximumPoolSize(10);
config.setConnectionTimeout(10000);
config.setLeakDetectionThreshold(10000);
dataSource = new HikariDataSource(config);
}
public static DataSource getDataSource() {
return dataSource;
}
public static void close() {
if (dataSource != null && !dataSource.isClosed()) {
dataSource.close();
}
}
}
3. 核心业务层实现
3.1 图书搜索:利用索引
java
import javax.sql.DataSource;
import java.sql.*;
import java.util.ArrayList;
import java.util.List;
public class BookService {
private final DataSource ds;
public BookService() {
this.ds = DBUtil.getDataSource();
}
public static class Book {
public int id;
public String title;
public String author;
public int stock;
}
/** 按书名关键词搜索,利用 idx_title 索引 */
public List<Book> searchByTitle(String keyword) throws SQLException {
String sql = "SELECT id, title, author, stock FROM books WHERE title LIKE ?";
List<Book> list = new ArrayList<>();
try (Connection conn = ds.getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setString(1, "%" + keyword + "%");
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
Book b = new Book();
b.id = rs.getInt("id");
b.title = rs.getString("title");
b.author = rs.getString("author");
b.stock = rs.getInt("stock");
list.add(b);
}
}
}
return list;
}
/** 显示所有图书 */
public List<Book> listAll() throws SQLException {
String sql = "SELECT id, title, author, stock FROM books";
List<Book> list = new ArrayList<>();
try (Connection conn = ds.getConnection();
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery(sql)) {
while (rs.next()) {
Book b = new Book();
b.id = rs.getInt("id");
b.title = rs.getString("title");
b.author = rs.getString("author");
b.stock = rs.getInt("stock");
list.add(b);
}
}
return list;
}
}
3.2 借阅与归还:事务核心
这是整个系统最关键的部分------借阅操作在事务中同时完成库存更新和记录插入。
java
import java.sql.*;
import java.time.LocalDate;
public class BorrowService {
private final DataSource ds;
public BorrowService() {
this.ds = DBUtil.getDataSource();
}
/**
* 借阅图书
* @param readerId 读者ID
* @param bookId 图书ID
* @param borrowDays 借阅天数
* @return 借阅记录ID,失败返回 -1
*/
public int borrowBook(int readerId, int bookId, int borrowDays) {
String checkSql = "SELECT stock FROM books WHERE id = ?";
String insertSql = "INSERT INTO borrow_records (reader_id, book_id, borrow_date, due_date) VALUES (?, ?, CURDATE(), DATE_ADD(CURDATE(), INTERVAL ? DAY))";
String updateSql = "UPDATE books SET stock = stock - 1 WHERE id = ? AND stock > 0";
try (Connection conn = ds.getConnection()) {
conn.setAutoCommit(false);
// 1. 检查并锁定行(加悲观锁,防止并发超借)
int stock;
try (PreparedStatement ps = conn.prepareStatement(checkSql)) {
ps.setInt(1, bookId);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) {
throw new RuntimeException("图书不存在");
}
stock = rs.getInt("stock");
}
}
if (stock <= 0) {
throw new RuntimeException("库存不足");
}
// 2. 插入借阅记录
long recordId;
try (PreparedStatement ps = conn.prepareStatement(insertSql, Statement.RETURN_GENERATED_KEYS)) {
ps.setInt(1, readerId);
ps.setInt(2, bookId);
ps.setInt(3, borrowDays);
int rows = ps.executeUpdate();
if (rows != 1) {
throw new RuntimeException("插入借阅记录失败");
}
try (ResultSet keys = ps.getGeneratedKeys()) {
keys.next();
recordId = keys.getLong(1);
}
}
// 3. 更新库存
try (PreparedStatement ps = conn.prepareStatement(updateSql)) {
ps.setInt(1, bookId);
int rows = ps.executeUpdate();
if (rows != 1) {
throw new RuntimeException("更新库存失败,可能已被借完");
}
}
conn.commit();
System.out.println("借阅成功!记录ID: " + recordId);
return (int) recordId;
} catch (Exception e) {
System.err.println("借阅失败: " + e.getMessage());
return -1;
}
}
/**
* 归还图书
* @param recordId 借阅记录ID
* @return 是否成功
*/
public boolean returnBook(int recordId) {
String checkSql = "SELECT book_id, return_date FROM borrow_records WHERE id = ?";
String updateBorrowSql = "UPDATE borrow_records SET return_date = CURDATE() WHERE id = ? AND return_date IS NULL";
String updateStockSql = "UPDATE books SET stock = stock + 1 WHERE id = ?";
try (Connection conn = ds.getConnection()) {
conn.setAutoCommit(false);
int bookId;
try (PreparedStatement ps = conn.prepareStatement(checkSql)) {
ps.setInt(1, recordId);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) {
throw new RuntimeException("借阅记录不存在");
}
if (rs.getDate("return_date") != null) {
throw new RuntimeException("该书已归还,请勿重复操作");
}
bookId = rs.getInt("book_id");
}
}
try (PreparedStatement ps = conn.prepareStatement(updateBorrowSql)) {
ps.setInt(1, recordId);
if (ps.executeUpdate() != 1) {
throw new RuntimeException("更新借阅记录失败");
}
}
try (PreparedStatement ps = conn.prepareStatement(updateStockSql)) {
ps.setInt(1, bookId);
ps.executeUpdate();
}
conn.commit();
System.out.println("归还成功!");
return true;
} catch (Exception e) {
System.err.println("归还失败: " + e.getMessage());
return false;
}
}
/** 查询读者当前借阅 */
public void showReaderBorrows(int readerId) {
String sql = "SELECT br.id, b.title, br.borrow_date, br.due_date, br.return_date " +
"FROM borrow_records br JOIN books b ON br.book_id = b.id " +
"WHERE br.reader_id = ? ORDER BY br.borrow_date DESC";
try (Connection conn = ds.getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setInt(1, readerId);
try (ResultSet rs = ps.executeQuery()) {
System.out.println("\n借阅记录(读者ID=" + readerId + "):");
System.out.println("ID\t书名\t\t\t借出日期\t应还日期\t归还日期");
boolean hasData = false;
while (rs.next()) {
hasData = true;
System.out.printf("%d\t%s\t%s\t%s\t%s%n",
rs.getInt("id"),
rs.getString("title"),
rs.getDate("borrow_date"),
rs.getDate("due_date"),
rs.getDate("return_date") == null ? "未还" : rs.getDate("return_date"));
}
if (!hasData) System.out.println("(无记录)");
}
} catch (SQLException e) {
e.printStackTrace();
}
}
}
设计亮点解读:
- 借阅流程的 3 个步骤(检查库存→插入记录→更新库存)都在
conn.setAutoCommit(false)控制的事务中,任何一个步骤失败都会回滚。 UPDATE books SET stock = stock - 1 WHERE id = ? AND stock > 0中的stock > 0条件是"乐观锁"思想------虽然后面要引入悲观锁来彻底解决并发,但这条 WHERE 已经能提供一层基本保护。RETURN_GENERATED_KEYS用于获取自增 ID,使得归还时可通过记录 ID 操作。
4. 并发借阅模拟:见证事务与锁
4.1 为什么需要关注并发?
假设《算法导论》只有 1 本库存。如果两个读者同时点击"借阅",不加控制的话:
- 线程 A 查询到
stock = 1 - 线程 B 也查询到
stock = 1 - 两者都通过库存检查
- 两条借阅记录被插入,库存变为
-1
这显然不符合业务逻辑。我们需要用锁来保证同一时刻只有一个事务能修改同一行数据。
4.2 添加悲观锁
在 borrowBook 的库存查询中,加入 FOR UPDATE,这会在事务持续期间对查到的行加排他锁(行锁),其他并发事务如果也执行同样的 FOR UPDATE 查询,就会等待,直到当前事务提交或回滚。
修改 checkSql:
java
String checkSql = "SELECT stock FROM books WHERE id = ? FOR UPDATE";
这样,第一个进入事务的线程锁住该行,第二个线程的相同查询会阻塞,待第一个提交后才读取到最新的(已被扣减的)库存。
4.3 并发测试代码
java
public static void main(String[] args) {
BorrowService service = new BorrowService();
// 两个读者同时抢 bookId=3(算法导论,库存=1)
Runnable task = () -> {
String threadName = Thread.currentThread().getName();
System.out.println(threadName + " 尝试借阅...");
int result = service.borrowBook(2, 3, 14); // 读者2借书3
System.out.println(threadName + " 结果: " + (result > 0 ? "成功" : "失败"));
};
Thread t1 = new Thread(task, "读者-李四");
Thread t2 = new Thread(task, "读者-王五");
t1.start();
t2.start();
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
// 查看最终库存
try (Connection conn = DBUtil.getDataSource().getConnection();
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery("SELECT stock FROM books WHERE id = 3")) {
if (rs.next()) {
System.out.println("最终库存: " + rs.getInt("stock"));
}
} catch (SQLException e) {
e.printStackTrace();
}
DBUtil.close();
}
预期结果 :一个人成功借到,另一个人因"库存不足"失败。最终库存为 0。如果去掉 FOR UPDATE,可能会出现两人都"成功"但库存为 -1 的异常状况。
你可以尝试将 FOR UPDATE 注释掉再跑几次,观察超借现象(可能需要多跑几次因为并发时序不确定)。
5. 回滚场景模拟
我们编写一个测试方法,在借阅流程中间人为触发异常,验证事务回滚是否完整。
java
public void borrowBookWithFailPoint(int readerId, int bookId, int borrowDays, boolean shouldFail) {
String insertSql = "INSERT INTO borrow_records (reader_id, book_id, borrow_date, due_date) VALUES (?, ?, CURDATE(), DATE_ADD(CURDATE(), INTERVAL ? DAY))";
String updateSql = "UPDATE books SET stock = stock - 1 WHERE id = ?";
try (Connection conn = ds.getConnection()) {
conn.setAutoCommit(false);
// 插入借阅记录
try (PreparedStatement ps = conn.prepareStatement(insertSql)) {
ps.setInt(1, readerId);
ps.setInt(2, bookId);
ps.setInt(3, borrowDays);
ps.executeUpdate();
}
// 模拟中间故障
if (shouldFail) {
throw new RuntimeException("模拟系统故障!");
}
// 更新库存
try (PreparedStatement ps = conn.prepareStatement(updateSql)) {
ps.setInt(1, bookId);
ps.executeUpdate();
}
conn.commit();
System.out.println("操作成功提交");
} catch (Exception e) {
System.err.println("操作被回滚: " + e.getMessage());
}
}
调用 borrowBookWithFailPoint(1, 1, 14, true),你应该看到插入的借阅记录并没有留在数据库中------事务回滚保证了原子性。
6. 命令行交互主程序
将上述组件整合成一个简单的菜单驱动交互程序。
java
import java.util.List;
import java.util.Scanner;
public class LibraryCLI {
private static final BookService bookService = new BookService();
private static final BorrowService borrowService = new BorrowService();
private static final Scanner scanner = new Scanner(System.in);
public static void main(String[] args) {
System.out.println("===== 图书管理系统 =====");
while (true) {
System.out.println("\n选择操作: 1.搜索图书 2.借阅 3.归还 4.我的借阅 5.全部图书 0.退出");
System.out.print("请输入: ");
String choice = scanner.nextLine();
try {
switch (choice) {
case "1":
searchBooks();
break;
case "2":
borrowBook();
break;
case "3":
returnBook();
break;
case "4":
myBorrows();
break;
case "5":
listAllBooks();
break;
case "0":
System.out.println("再见!");
DBUtil.close();
return;
default:
System.out.println("无效选项");
}
} catch (Exception e) {
System.err.println("出错: " + e.getMessage());
}
}
}
private static void searchBooks() throws Exception {
System.out.print("输入书名关键词: ");
String kw = scanner.nextLine();
List<BookService.Book> books = bookService.searchByTitle(kw);
if (books.isEmpty()) {
System.out.println("未找到相关图书");
} else {
books.forEach(b -> System.out.printf("[%d] %s - %s (库存:%d)%n", b.id, b.title, b.author, b.stock));
}
}
private static void borrowBook() throws Exception {
System.out.print("读者ID: ");
int readerId = Integer.parseInt(scanner.nextLine());
System.out.print("图书ID: ");
int bookId = Integer.parseInt(scanner.nextLine());
borrowService.borrowBook(readerId, bookId, 14);
}
private static void returnBook() throws Exception {
System.out.print("借阅记录ID: ");
int recordId = Integer.parseInt(scanner.nextLine());
borrowService.returnBook(recordId);
}
private static void myBorrows() throws Exception {
System.out.print("读者ID: ");
int readerId = Integer.parseInt(scanner.nextLine());
borrowService.showReaderBorrows(readerId);
}
private static void listAllBooks() throws Exception {
List<BookService.Book> books = bookService.listAll();
books.forEach(b -> System.out.printf("[%d] %s - %s (库存:%d)%n", b.id, b.title, b.author, b.stock));
}
}
7. 小结
本文我们完成了第三阶段的综合实战项目,构建了一个命令行图书借阅系统的完整实现:
- 索引运用 :为
books.title、borrow_records.reader_id、book_id、due_date建立了索引,加速了搜索和关联查询。 - 事务控制 :借阅和归还均使用
conn.setAutoCommit(false)+commit()/rollback()保证多步操作的原子性。 - PreparedStatement:全部 SQL 通过占位符传参,避免 SQL 注入,同时获得预编译带来的性能优势。
- 连接池:使用 HikariCP 管理连接,避免频繁创建/关闭连接的开销。
- 并发安全 :通过
SELECT ... FOR UPDATE添加悲观锁,解决超借问题,并编写了多线程模拟测试。
这个项目虽然规模不大,但涵盖了真实业务系统中数据访问层的核心技术。你可以在此基础上扩展更多功能,例如:
- 逾期罚款计算
- 预约排队机制
- 使用
EXPLAIN分析并进一步优化查询 - 引入 Spring/Spring Boot 进行依赖注入和声明式事务
下一阶段我们将进入 MySQL 内核解析,深入理解架构、存储引擎和 InnoDB 的内部结构,从"会用"跃迁到"理解内部如何工作"。
思考题:
- 如果借阅请求量很大,
FOR UPDATE可能会导致什么性能问题?除了悲观锁,还有别的并发控制方案吗?(提示:乐观锁 → 版本号) - 上述归还逻辑中,如果归还成功后、库存恢复前系统崩溃,事务会如何保证一致性?
- 尝试为
borrow_records表的(reader_id, book_id, return_date)建立复合索引,思考它可能优化哪些查询。
参考资料
至此,第三阶段全部完成! 下一篇我们将开启第四阶段第 1 篇------《MySQL 整体架构与存储引擎对比》。