【实战】实现一个带事务与索引的命令行图书借阅系统

第三阶段我们密集学习了索引、事务、JDBC 编程和连接池。现在是时候将这些知识整合成一个真正可运行的小应用了。本文将带你用纯 Java 实现一个命令行交互的图书借阅系统,它具备:

  • 完整的借阅、归还、查询功能
  • 使用事务保证借阅操作的原子性(库存更新 + 记录插入)
  • 为查询频繁的列建立索引,提升搜索效率
  • 使用 PreparedStatement 防止 SQL 注入
  • 通过 HikariCP 连接池管理数据库连接
  • 模拟并发借阅场景,观察事务与锁的行为

读完并跟着敲完代码后,你将拥有一个可以放在简历里的完整项目雏形。


1. 项目概述与准备工作

1.1 功能清单

我们的命令行系统支持以下操作:

  1. 借阅图书:输入读者ID和图书ID,系统检查库存、创建借阅记录、扣减库存(事务保证)。
  2. 归还图书:输入借阅记录ID,系统更新归还日期、恢复库存(事务保证)。
  3. 查询图书:按书名关键词搜索(利用索引加速)。
  4. 查询借阅记录:查看某读者的当前借阅情况。
  5. 并发模拟:用多线程模拟两个读者同时抢最后一本书。

1.2 数据库准备

我们使用之前 library_db 数据库中的 readersbooksborrow_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_idbook_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 本库存。如果两个读者同时点击"借阅",不加控制的话:

  1. 线程 A 查询到 stock = 1
  2. 线程 B 也查询到 stock = 1
  3. 两者都通过库存检查
  4. 两条借阅记录被插入,库存变为 -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.titleborrow_records.reader_idbook_iddue_date 建立了索引,加速了搜索和关联查询。
  • 事务控制 :借阅和归还均使用 conn.setAutoCommit(false) + commit() / rollback() 保证多步操作的原子性。
  • PreparedStatement:全部 SQL 通过占位符传参,避免 SQL 注入,同时获得预编译带来的性能优势。
  • 连接池:使用 HikariCP 管理连接,避免频繁创建/关闭连接的开销。
  • 并发安全 :通过 SELECT ... FOR UPDATE 添加悲观锁,解决超借问题,并编写了多线程模拟测试。

这个项目虽然规模不大,但涵盖了真实业务系统中数据访问层的核心技术。你可以在此基础上扩展更多功能,例如:

  • 逾期罚款计算
  • 预约排队机制
  • 使用 EXPLAIN 分析并进一步优化查询
  • 引入 Spring/Spring Boot 进行依赖注入和声明式事务

下一阶段我们将进入 MySQL 内核解析,深入理解架构、存储引擎和 InnoDB 的内部结构,从"会用"跃迁到"理解内部如何工作"。

思考题

  1. 如果借阅请求量很大,FOR UPDATE 可能会导致什么性能问题?除了悲观锁,还有别的并发控制方案吗?(提示:乐观锁 → 版本号)
  2. 上述归还逻辑中,如果归还成功后、库存恢复前系统崩溃,事务会如何保证一致性?
  3. 尝试为 borrow_records 表的 (reader_id, book_id, return_date) 建立复合索引,思考它可能优化哪些查询。

参考资料


至此,第三阶段全部完成! 下一篇我们将开启第四阶段第 1 篇------《MySQL 整体架构与存储引擎对比》。

相关推荐
素材积累10 小时前
博士后出站来深可申请的项目补贴等
数据库
_1_711 小时前
SQL Server 磁盘满了 收缩日志
数据库·sqlserver
basketball61611 小时前
Redis基础:1. Redis介绍
数据库·redis·缓存
李可以量化12 小时前
成交量的终极量化策略:价量共振指标完整实现(下篇)
前端·数据库·人工智能
Song_da_da_12 小时前
C#与VisionPro联合编程实战:机器视觉二次开发完整指南
开发语言·microsoft·c#
汽车仪器仪表相关领域13 小时前
南华 NHAT-610 柴油车排放测试仪 产品详解
数据库·功能测试·汽车·压力测试·可用性测试
我滴老baby14 小时前
工业时序数据实战:基于 DolphinDB 流计算引擎的实现与调优
数据库
睡不醒男孩03082315 小时前
TiDB数据库调研
数据库·tidb
珠***格15 小时前
实操落地|防逆流装置的安装规范、调试标准与故障处置
网络·数据库·人工智能·分布式·能源·边缘计算