☰
JSP+Spring+JDBC+Servlet构建图书馆管理系统全流程解析
2026/10/14 5:56:00 网站建设 项目流程

简介:这是基于JSP、Spring、JDBC与Servlet技术的图书馆管理系统课程设计/毕业设计资源,面向需要完成同类Java Web项目的在校学生与初级开发者,同时也适合作为Java Web综合实训的参考案例。系统完整覆盖学生端和管理员端:学生端支持图书条件检索、对已借出图书的预约、个人当前借阅与历史借阅信息查询,以及图书丢失后的挂失登记;管理员端包含学生信息增删改查、图书信息维护、借书还书处理与借阅状态跟踪,并通过超级管理员与普通管理员权限区分演示了访问控制逻辑。资源以RAR压缩包形式提供,大小约2.8MB,原下载页未展示文件总数与类型明细,解压后应为可导入运行的完整Java Web工程,供实际部署与二次开发参考。当前已有128人学习下载,读者可据此理解从JSP页面到Servlet控制器、Spring业务层、JDBC数据访问层的完整调用链,学习预约、挂失、权限管理等典型模块的状态处理与代码组织方式,对梳理项目结构、完成同类系统设计具有较直接的参考价值。

1. 图书馆管理系统为什么还在用 JSP+Spring+JDBC+Servlet 这套组合

某高校的实训题目里,“图书馆管理系统”是出现频率最高的那种:要能管用户、管图书、管借还,还要在页面上看得见数据变化。用 JSP+Spring+JDBC+Servlet 来做,恰好把“页面展示”和“业务逻辑”分得足够清楚,又不会像前后端分离那样引入两套工程、两个端口、一堆跨域配置。这套组合适合课程设计、校内实训,也适合三五个人维护的小型内部系统,它最大的价值不是新,而是边界清晰:JSP 只负责把数据渲染成页面,Servlet 管请求和跳转,JDBC 管数据库读写,Spring 把对象和事务管起来。这篇文章按我自己做过的方案,把从建表到部署的完整路径讲一遍。

2. 工程结构与运行环境:把系统拆成 Servlet、Spring、JDBC、JSP 四个角色

2.1 四个组件各管一段:Servlet、Spring、JDBC、JSP 的分工边界

很多初学者拿到这个题目会先问:Spring 不是替代 Servlet 的吗?为什么这里两个都要用?实际上它们不冲突,分层反而更清晰。Servlet 是 Java Web 的入口层,所有 HTTP 请求先到 Servlet,它负责读参数、调服务、决定是转发页面还是重定向;Spring 在中间做对象管理,业务类由 Spring 容器创建,依赖通过构造器注入,不需要到处 new;JDBC 是最底层的数据访问方式,连接数据库、执行 SQL、把结果集映射成对象;JSP 则是渲染层,拿到业务层返回的数据后,用 EL 表达式和 JSTL 标签把表格、表单、按钮展示出来。

这四个角色的依赖方向是单向的:JSP 请求 Servlet,Servlet 调 Spring 管理的 Service,Service 通过 JDBC 访问 MySQL。Spring 的事务注解可以作用在 Service 方法上,只要配置好事务管理器,一个借书动作里的多条 SQL 就能保证要么全成功、要么全回滚。这套分层的收益在后期修 Bug 时特别明显:页面样式问题只改 JSP,接口问题只查 Servlet,SQL 错误只看 DAO 层,不用在一堆代码里翻来翻去。

2.2 Maven 依赖与 web.xml:最小可运行工程怎么搭

我一般用 Maven 的 war 包结构,因为 Tomcat 部署直接丢 webapps 就行。先建一个标准目录:src/main/java 放 Java 源码,src/main/resources 放 Spring 配置文件,src/main/webapp 放 JSP 和 WEB-INF。最小依赖里必须有Servlet API、Spring、JDBC 驱动、JSTL 和一个连接池。Servlet API 的 scope 设为 provided,因为 Tomcat 自己带,打包时不打进去,避免和容器里的类冲突。

<dependencies> <dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>4.0.1</version> <scope>provided</scope> </dependency> <dependency> <groupId>javax.servlet</groupId> <artifactId>jstl</artifactId> <version>1.2</version> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-context</artifactId> <version>${spring.version}</version> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-jdbc</artifactId> <version>${spring.version}</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.30</version> </dependency> <dependency> <groupId>com.alibaba</groupId> <artifactId>druid</artifactId> <version>1.2.8</version> </dependency> </dependencies>

用${spring.version}占位是让团队统一在一个 properties 节点里控制版本,避免 spring-context 和 spring-jdbc 版本不一致导致方法签名对不上。Druid 连接池在这里承担两件事:一是管理数据库连接,避免每次请求都新建连接;二是提供监控能力,借还流程频繁时能通过 Druid 的日志看出连接是否泄漏。MySQL 8 的驱动类名必须写com.mysql.cj.jdbc.Driver,老项目里的com.mysql.jdbc.Driver已经退役,直接跑会抛 ClassNotFoundException。

2.3 Spring 配置里最容易被忽略的连接参数

applicationContext.xml 是整个项目的心脏。我在里面配数据源、JdbcTemplate、组件扫描和事务管理器。组件扫描只扫 service 和 dao 两个包,Controller 由 Servlet 容器自己管理,这是 JSP+Servlet 架构下比较常见的约定。

<context:component-scan base-package="com.library.service,com.library.dao"/> <bean id="dataSource" class="com.alibaba.druid.pool.DruidDataSource" init-method="init" destroy-method="close"> <property name="driverClassName" value="com.mysql.cj.jdbc.Driver"/> <property name="url" value="jdbc:mysql://localhost:3306/library?useUnicode=true&amp;characterEncoding=UTF-8&amp;useSSL=false&amp;serverTimezone=Asia/Shanghai"/> <property name="username" value="root"/> <property name="password" value="root"/> <property name="initialSize" value="5"/> <property name="maxActive" value="20"/> <property name="maxWait" value="60000"/> </bean> <bean id="jdbcTemplate" class="org.springframework.jdbc.core.JdbcTemplate"> <property name="dataSource" ref="dataSource"/> </bean> <bean id="transactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager"> <property name="dataSource" ref="dataSource"/> </bean>

连接串里那几个参数是血泪经验堆出来的:useUnicode=true&characterEncoding=UTF-8保证中文存取不乱码,serverTimezone=Asia/Shanghai解决 MySQL 8 的时区报错,useSSL=false避免本地环境 SSL 握手开销。Druid 的initialSize设为 5,意思是应用启动时先建好 5 个连接,尖峰请求来临时不用现开;maxActive是上限,超过后请求会等maxWait毫秒,超时就抛异常而不是无限堆积。maxWait设 60 秒在开发环境够用,生产环境我一般压到 10 秒,让故障快速暴露出来。

3. 图书与借阅核心表:JDBC 数据访问层的设计与参数调优

3.1 用户表、图书表、借阅表:字段设计与约束

图书馆管理系统的数据模型比表面看起来要多想一层。除了用户和图书两张主表,借阅记录必须单独建表,因为一次借书动作涉及“图书库存减少”和“借阅记录新增”两条同时发生的事。用户表最少要有 id、学号、姓名、借书数量上限;图书表要有 isbn、书名、作者、库存总数、当前可借数;借阅表要记录用户 id、图书 id、借出时间、应还时间、实际归还时间、状态。状态字段建议用整数枚举:0 表示借出未还,1 表示已归还,2 表示超期未还。用整数而不是字符串,是为了后面写统计 SQL 时能用SUM和CASE WHEN直接聚合。

CREATE TABLE `user` ( `id` INT NOT NULL AUTO_INCREMENT, `student_no` VARCHAR(20) NOT NULL UNIQUE, `name` VARCHAR(30) NOT NULL, `max_borrow` INT NOT NULL DEFAULT 5, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `book` ( `id` INT NOT NULL AUTO_INCREMENT, `isbn` VARCHAR(20) NOT NULL UNIQUE, `title` VARCHAR(100) NOT NULL, `author` VARCHAR(50), `total_stock` INT NOT NULL DEFAULT 0, `available_stock` INT NOT NULL DEFAULT 0, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `borrow` ( `id` INT NOT NULL AUTO_INCREMENT, `user_id` INT NOT NULL, `book_id` INT NOT NULL, `borrow_time` DATETIME NOT NULL, `due_time` DATETIME NOT NULL, `return_time` DATETIME, `status` TINYINT NOT NULL DEFAULT 0, PRIMARY KEY (`id`), KEY `idx_user` (`user_id`), KEY `idx_book` (`book_id`), CONSTRAINT `fk_borrow_user` FOREIGN KEY (`user_id`) REFERENCES `user`(`id`), CONSTRAINT `fk_borrow_book` FOREIGN KEY (`book_id`) REFERENCES `book`(`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

建表时有三个地方容易翻车。第一,表名不要叫user以外的保留字,但既然这里用了user,写 SQL 时必须加反引号。第二,available_stock不要用total_stock减borrow表统计来实时计算,并发高时统计结果会飘,宁可每借一本书就available_stock = available_stock - 1。第三,外键约束一定要加,虽然理论上业务层也能保证数据一致,但数据库层面的约束能在测试阶段帮你拦住脏数据,比如删了一个还没还书的用户。所有表都要用 InnoDB,因为 MyISAM 不支持事务,借还流程只有 InnoDB 能兜住。

3.2 用 JdbcTemplate 写 DAO:连接池参数与防注入写法

以前手写 JDBC 的时代,每访问一次数据库要写一遍Class.forName、getConnection、prepareStatement、resultSet,大量重复代码。用了 Spring 的JdbcTemplate之后,连接获取、关闭、异常转换都封装掉了,我只需要写 SQL 和参数映射。DAO 层用@Repository注解注册到 Spring 容器,构造器注入JdbcTemplate,这是依赖注入的标准写法。

@Repository public class BookDao { private final JdbcTemplate jdbcTemplate; public BookDao(JdbcTemplate jdbcTemplate) { this.jdbcTemplate = jdbcTemplate; } public Book findById(int bookId) { String sql = "SELECT id, isbn, title, author, total_stock, available_stock FROM book WHERE id = ?"; return jdbcTemplate.queryForObject(sql, new BeanPropertyRowMapper<>(Book.class), bookId); } public int decreaseStock(int bookId) { String sql = "UPDATE book SET available_stock = available_stock - 1 WHERE id = ? AND available_stock > 0"; return jdbcTemplate.update(sql, bookId); } public int increaseStock(int bookId) { String sql = "UPDATE book SET available_stock = available_stock + 1 WHERE id = ?"; return jdbcTemplate.update(sql, bookId); } }

decreaseStock里那句AND available_stock > 0是关键。它把“库存检查”和“库存扣减”合成一条 UPDATE,数据库行锁保证同一本书不会被两个人同时借走最后一本;update方法返回受影响行数,返回 0 就说明库存不足。BeanPropertyRowMapper要求表的列名和 Java 实体类的属性名能对应上,available_stock对应availableStock,这是 Spring 的驼峰转换规则,不用手动写ResultSet映射。queryForObject查不到数据时抛EmptyResultDataAccessException,调用方要捕获它并转成业务异常,而不是让 500 错误直接打到页面上。

3.3 借阅 DAO 的插入与超期判断:时间字段的处理

借阅记录插入时,我用数据库的NOW()而不是 Java 端new Date(),原因是服务器时间和数据库时间可能有时差,统一以数据库时间为准,后面算超期天数才不会出现几十秒的偏差。应还时间用DATE_ADD把当前时间加上 30 天。查询超期记录时,直接在 SQL 里比较due_time < NOW() AND status = 0,这个条件在索引设计良好的前提下,扫描成本很低。

@Repository public class BorrowDao { private final JdbcTemplate jdbcTemplate; public BorrowDao(JdbcTemplate jdbcTemplate) { this.jdbcTemplate = jdbcTemplate; } public int insert(int userId, int bookId, int days) { String sql = "INSERT INTO borrow(user_id, book_id, borrow_time, due_time, status) " + "VALUES (?, ?, NOW(), DATE_ADD(NOW(), INTERVAL ? DAY), 0)"; return jdbcTemplate.update(sql, userId, bookId, days); } public List<Borrow> listOverdue() { String sql = "SELECT id, user_id, book_id, borrow_time, due_time, return_time, status " + "FROM borrow WHERE status = 0 AND due_time < NOW()"; return jdbcTemplate.query(sql, new BeanPropertyRowMapper<>(Borrow.class)); } }

参数化查询里的问号占位符能防 SQL 注入,这是 JDBC 规范里最基础也最容易被忽略的一环。千万不要用字符串拼接把 userId 和 bookId 直接塞进 SQL,一旦有人构造恶意输入,表都可能被删掉。DATE_ADD的间隔参数用?传递时,MySQL 驱动会把它当整数处理,所以 borrow 天数从配置里读,不要写死在代码里。超期查询我放在BorrowDao里而不是 Service 里,是因为它本质上是一个查询动作,不涉及业务规则组合,放在 DAO 层保持简单更合适。

4. Servlet 路由与 Spring 事务:借书流程怎么串起来不丢数据

4.1 Servlet 请求路由:一个 /borrow 接口怎么分发

借书、还书、查询借阅记录这几个动作都可以走同一个 Servlet,通过隐藏字段或者 URL 后缀来区分。我用@WebServlet("/borrow")注解注册,避免在 web.xml 里写一长串映射。doGet 负责查询类操作,doPost 负责写操作,这样语义清楚,页面里表单提交和链接跳转也能分开处理。

@WebServlet("/borrow") public class BorrowServlet extends HttpServlet { private BorrowService borrowService; @Override public void init() { ApplicationContext ctx = new ClassPathXmlApplicationContext("applicationContext.xml"); this.borrowService = ctx.getBean(BorrowService.class); } @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding("UTF-8"); String action = req.getParameter("action"); String userIdStr = req.getParameter("userId"); String bookIdStr = req.getParameter("bookId"); int userId = Integer.parseInt(userIdStr); int bookId = Integer.parseInt(bookIdStr); try { if ("borrow".equals(action)) { borrowService.borrow(userId, bookId); } else if ("return".equals(action)) { borrowService.returnBook(userId, bookId); } resp.sendRedirect(req.getContextPath() + "/list?msg=success"); } catch (BorrowException e) { req.setAttribute("error", e.getMessage()); req.getRequestDispatcher("/borrow.jsp").forward(req, resp); } } }

这里有个容易踩的坑:init()每次都 new 一个ClassPathXmlApplicationContext,如果多个 Servlet 都这样做,Spring 容器会被初始化好几次。我一般在正式项目里改成ServletContextListener,在应用启动时创建一次容器放进 ServletContext,Servlet 的init()里只读——更优雅的做法是让 Spring 提供 WebApplicationContext 的工具类,但把容器放进 ServletContext 已经是能跑通的最简方案。req.setCharacterEncoding("UTF-8")必须放在读取参数之前,否则 POST 提交的中文参数会乱码。成功时用重定向而不是转发,刷新页面不会再次提交表单,这就是 Post/Redirect/Get 模式。

4.2 借书流程的 Spring 事务边界:多条 SQL 怎么保证一起成功

一次借书操作在数据库层面要做四件事:查用户是否达到借书上限、更新图书库存减一、插入借阅记录、更新用户当前借书数。这四条 SQL 任何一条失败,前面成功的都要回滚,否则会出现在“库存已减但借阅记录没有”的脏数据。我把这个流程收敛在 Service 的一个方法里,加上@Transactional注解,由 Spring 的 AOP 统一管理事务边界。

@Service public class BorrowService { private final BookDao bookDao; private final BorrowDao borrowDao; private final UserDao userDao; public BorrowService(BookDao bookDao, BorrowDao borrowDao, UserDao userDao) { this.bookDao = bookDao; this.borrowDao = borrowDao; this.userDao = userDao; } @Transactional(rollbackFor = Exception.class) public void borrow(int userId, int bookId) { User user = userDao.findById(userId); if (user.getCurrentBorrowCount() >= user.getMaxBorrow()) { throw new BorrowException("借书数量已达上限"); } int rows = bookDao.decreaseStock(bookId); if (rows == 0) { throw new BorrowException("图书库存不足"); } borrowDao.insert(userId, bookId, 30); userDao.increaseBorrowCount(userId); } }

rollbackFor = Exception.class是必须写的细节。Spring 默认只对 RuntimeException 回滚,如果业务异常继承的是 Exception,不加这个参数时事务会照常提交,等到数据错了再查问题就晚了。decreaseStock返回 0 时直接抛异常,事务管理器捕获到异常后会把前面所有 SQL 都回滚,包括那个影响 0 行的 UPDATE——虽然它本身什么都没改,但插入借阅记录和更新用户数的操作会被撤销。事务粒度控制在这个方法边界内,查询类操作不开启事务,避免长事务锁表。

4.3 JSP 页面数据回显:用 JSTL 和 EL 替换脚本片段

早期的 JSP 页面经常看到大段<% %>脚本,页面里嵌业务代码,维护成本很高。我用 JSTL 标签和 EL 表达式替代,Servlet 把数据放进 request 或 session 作用域,JSP 只负责读取和循环。图书列表页的核心逻辑是遍历books这个列表,逐行显示书名、作者、库存,并生成一个借书按钮。

<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <%@ taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt" %> <table> <thead> <tr> <th>书名</th> <th>作者</th> <th>可借数</th> <th>操作</th> </tr> </thead> <tbody> <c:forEach var="book" items="${books}"> <tr> <td>${book.title}</td> <td>${book.author}</td> <td>${book.availableStock}</td> <td> <form action="${pageContext.request.contextPath}/borrow" method="post"> <input type="hidden" name="action" value="borrow"/> <input type="hidden" name="bookId" value="${book.id}"/> <input type="hidden" name="userId" value="${sessionScope.userId}"/> <button type="submit">借书</button> </form> </td> </tr> </c:forEach> </tbody> </table>

页面里没有一行 Java 脚本,所有动态数据来自 EL 的${}表达式。pageContext.request.contextPath用来拼应用的上下文路径,这样部署到不同名字的 war 包下都能正确找到 Servlet。sessionScope.userId假设用户登录后把 ID 放进了 session,这里直接读取。借书按钮的表单是整页刷新提交,不做 AJAX,因为 JSP+Servlet 这套组合本来就是服务端渲染,保持简单比炫技重要。表单里bookId和userId都从服务端输出,不依赖用户手输,减少了参数篡改的风险。

5. 部署与运行排查:JSP+Spring+JDBC+Servlet 的 5 个高频坑

5.1 缺 jar 包导致的 500:ClassNotFoundException 与 NoClassDefFoundError

现象:Tomcat 启动正常,但一访问某个页面就报 500,日志里出现java.lang.ClassNotFoundException: org.springframework.jdbc.core.JdbcTemplate或者NoClassDefFoundError。翻遍代码发现依赖都写在 pom.xml 里,本地 IDEA 运行也正常,为什么部署到 Tomcat 就崩。

原因:Maven 的 war 打包默认只把runtime和compile作用域的依赖放进 WEB-INF/lib,如果你用的是 Eclipse 的 Dynamic Web Project 手动导包,或者在 IDEA 里没做 war 包同步,依赖不会自动带过去。本地 IDE 能运行是因为 IDE 把 Maven 依赖加进了 classpath,但 Tomcat 只看 WEB-INF/lib 目录。

解决:打开最终生成的 war 包确认 WEB-INF/lib 里的 jar 是否齐全。自己手动管理依赖时,检查 spring-context、spring-jdbc、druid 和 mysql-connector 这四件套是否都在。再核对 pom 里 Servlet API 的 scope 是否为 provided——如果不是,Servlet 类会和 Tomcat 自带的冲突,出现java.lang.LinkageError,把 scope 改回 provided 重新打包就行。

5.2 中文乱码:从上到下三层统一 UTF-8

现象:页面上输入中文书名,保存后数据库里变成问号;或者数据库里的中文正常,但 JSP 页面显示成乱码。这个坑在新手项目里几乎必现,而且有时候只乱一层。

原因:乱码问题要从前端页面、Servlet、数据库三处分别检查。JSP 页面没有指定pageEncoding,浏览器用默认编码提交表单;Servlet 没设置请求编码,Tomcat 8 默认的 POST 编码是 ISO-8859-1,中文必然变问号;数据库连接串没加characterEncoding=UTF-8,JDBC 写入时按错误的编码转换。

解决:JSP 顶部写<%@ page contentType="text/html;charset=UTF-8" language="java" %>;Servlet 的 doPost 第一行执行req.setCharacterEncoding("UTF-8");连接串带上useUnicode=true&characterEncoding=UTF-8;最后确认 MySQL 表结构是 utf8mb4 而不是 latin1。这四处全部对齐之后,中文问题基本绝迹。如果有个别页面还是乱码,检查 web.xml 里有没有配置 CharacterEncodingFilter,配了以后所有请求统一走 UTF-8,省得每个 Servlet 重复设置。

5.3 刷新页面重复借书:用 Post/Redirect/Get 兜底

现象:借书成功后手动刷新浏览器,发现同一本书被借了两次,或者出现多条相同的借阅记录。用户可能不是故意刷的,可能是网卡导致重复提交,或者按了 F5。

原因:借书表单是 POST 提交,成功之后 Servlet 直接转发到某个 JSP 页面,地址栏还是原来的表单地址。浏览器刷新时会把上一个 POST 请求重新发一遍,借书逻辑就会再执行一次。表单里没有做防重令牌,请求到达 Service 层时,前面代码已经过了库存检查,第二次请求进来时库存已经减过一次,如果没设置available_stock > 0的条件,就会重复插入记录。

解决:最直接的方案是 PRG 模式,成功后重定向到查询页面,地址栏变成 GET 请求,刷新只重复查询不重复借书。把req.getRequestDispatcher("/list.jsp").forward(...)换成resp.sendRedirect(req.getContextPath() + "/list")。另外在 DAO 层的decreaseStock务必带上AND available_stock > 0,即使并发请求同时进来,数据库行锁也会让第二个请求拿到 0 行,事务回滚后不会产生脏数据。靠 URL 重定向治标,靠条件更新治本,两个一起上才稳。

5.4 MySQL 8 连接报错:SSL 与 Public Key Retrieval 参数

现象:项目在本地 MySQL 5.7 跑得好好的,换到另一台机器的 MySQL 8 之后,Tomcat 启动时日志出现Communications link failure,或者Public Key Retrieval is not allowed的异常。应用没改任何代码,只是换了个数据库环境。

原因:MySQL 8 默认使用 caching_sha2_password 做身份认证,驱动第一次连接时需要从服务器获取公钥来加密密码传输。本地开发环境 SSL 证书不可信,驱动默认尝试建立 SSL 连接又会失败。这两件事堆在一起,报错信息看起来像网络不通,其实全是配置参数的问题。

解决:连接串里补上useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai。useSSL=false让驱动放弃 SSL 握手,allowPublicKeyRetrieval=true允许客户端从服务器获取公钥,serverTimezone指定时区避免The server time zone value的报错。如果公司数据库不允许allowPublicKeyRetrieval,可以换一种思路:给数据库账号配置 mysql_native_password 认证方式,但这需要数据库管理员操作。建议先把连接串参数改好,再考虑改账号认证,前者对现有环境的影响最小。

5.5 事务回滚没生效:注解位置与同类调用

现象:借书流程里故意让库存不足时抛异常,结果数据仍然被部分写入——借阅记录插进去了,但库存没减,或者反过来。代码里明明加了@Transactional,事务就是不回滚。

原因:这里通常有三个原因。第一,事务管理器没有配置,或者没有在 Spring 容器里启用注解驱动;第二,@Transactional加在 DAO 方法上,而 DAO 方法是public但由this调用,绕过了 Spring 的代理;第三,异常被 try-catch 吞掉了,事务感知不到异常,自然不触发回滚。前两个是配置问题,第三个是编码习惯问题。

解决:事务管理器必须注入dataSource,并且要开启<tx:annotation-driven transaction-manager="transactionManager"/>,或者用@EnableTransactionManagement注解。@Transactional统一加在 Service 层public方法上,不要加在 DAO。Service 内部不要用 try-catch 吃掉异常,业务失败直接抛自定义的运行时异常;如果需要处理部分异常,用TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()标记回滚。排查时先看 Spring 配置文件里有没有事务管理器,再看异常有没有被吞,按这个顺序能快速定位问题。

6. 进阶:超期提醒任务与上线前的检查清单

借阅流程跑通之后,一个能挂出去用的系统还缺一个超期提醒模块。我常用的做法是在应用启动时通过ServletContextListener启动一个定时任务,每天凌晨扫描一遍borrow表,查出所有status=0 && due_time < NOW()的记录,给对应的用户生成一条通知。用ScheduledExecutorService就够了,不必为了一个定时任务引入完整的 Quartz,这套组合的哲学就是轻量。

public class OverdueCheckListener implements ServletContextListener { private ScheduledExecutorService scheduler; @Override public void contextInitialized(ServletContextEvent sce) { scheduler = Executors.newSingleThreadScheduledExecutor(); scheduler.scheduleAtFixedRate(new OverdueCheckTask(), 0, 24, TimeUnit.HOURS); } @Override public void contextDestroyed(ServletContextEvent sce) { scheduler.shutdownNow(); } }

scheduleAtFixedRate的第三个参数 24 是小时,任务第一次启动在应用加载后立即执行,之后每 24 小时跑一次。这个模块上线前要把任务扫描间隔改成可配置,不要写死,因为测试环境和生产环境的作息不一样;更重要的是让任务跑了以后把执行结果写进日志,方便确认任务真的在跑,而不是通过肉眼去数据库里验证。

上线前的检查清单我每次都会过一遍:数据库连接串的参数是否带了字符集、时区、SSL 三件套;WEB-INF/lib 里依赖是否齐全;Servlet 是否统一设置了请求编码;JSP 是否用了 JSTL 而不是 Java 脚本;事务管理器是否配置并启用注解;超期任务是否注册到了 web.xml。每一条都对应一次真实翻车,哪条没做到,等部署到别人的环境下就会以随机的方式爆发。这套技术栈虽然不新,但把边界理清楚、把参数配到位,它依然能稳稳地撑起一个小型业务系统。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询