简介:基于Java Web的图书管理系统设计与实现文档面向计算机专业学生和Java Web开发者,可作为课程设计、毕业设计或小型图书馆管理项目的参考。系统采用MVC设计模式,基于Struts框架与SQL Server数据库实现,覆盖系统设置、读者管理、图书管理、图书借还、系统查询和更改口令六大功能模块。文档从需求分析切入,依次阐述可行性评估、功能需求、总体结构、数据库表结构和详细模块实现,便于快速理解开发全流程。资源包共1个docx文件,约2.34MB,虽为单一文档,但包含封面、摘要、目录、正文和数据库设计说明,结构完整。已有385人学习,其中图书信息表、读者信息表、借阅信息表等核心表结构对业务建模有直接参考价值。
1. 基于Java Web的图书管理系统到底在解决什么问题
小图书馆或者学院资料室里,借还书至今还靠Excel加手工签字。最大的风险不在Excel本身,而在两个人同时改动同一份表:一本书明明只剩1本,却可能被登记借出去2次,最后管理员自己也说不清书去了哪里。基于Java Web的图书管理系统就是把这一套流程拆成登录、编目、借书、还书、统计的Web应用,用JSP、Servlet和MySQL把“谁借了什么、什么时候到期、还有没有库存”变成可在浏览器里查询和操作的数据。
这套标题看起来像老掉牙的课程设计选题,但它恰恰是Java Web入门阶段最值得完整过一遍的项目。从JSP页面提交一个表单,到Servlet接收参数,再到DAO读写MySQL,整条链路没有任何框架替你隐藏细节。如果你是想搞懂Web系统怎么设计、分层为什么存在、事务到底在保护什么的从业者,啃下它比直接上手Spring Boot扎实得多。
以下是按我实际做过的一类实现路线来写的:传统三层结构、Druid连接池、手写JDBC事务,配合部署到Tomcat和压测验证。越往后越偏实战,第4章的避坑记录,每一条都是别人甚至我自己在真实环境里付过时间成本才换来的。
2. 系统架构与数据库设计:把MVC分层落到一张E-R图和三张核心表
动手写Servlet之前,先把目录结构和表结构定下来。多数Java Web项目的翻车现场不是代码逻辑跑不通,而是表设计少了一个约束,或者业务代码没地方放,最后全堆在Servlet里。
2.1 项目目录结构与MVC分层约定
我习惯在新建项目时直接按下面的目录搭,而不是等代码写多了再重构:
src/main/java com.library controller # Servlet类,只做参数接收、转发、重定向 service # 业务逻辑,事务边界写在service层 dao # JDBC操作,每行SQL对应一个方法 entity # Book / Reader / BorrowRecord util # Druid连接池、日期工具 src/main/resources jdbc.properties log4j.properties src/main/webapp WEB-INF/jsp static这个结构里最核心的约定只有一条:Servlet不写SQL,DAO不写业务判断。
常见做法是让LoginServlet只负责调用loginService,拿到结果后要么写Session,要么forward到error页面;而BookDao里只出现PreparedStatement和ResultSet。网上很多教程把SQL直接写在Servlet里,甚至用JSP连接数据库,当时能跑,但要加一个“借阅次数统计”就不知道该改哪个文件,这就是分层的价值。
2.2 图书、读者、借阅三大实体的表结构设计与索引
图书、读者、借阅记录是这类系统的三张核心表。下面是经过简化但可实际运行的建表SQL:
CREATE TABLE book ( id INT PRIMARY KEY AUTO_INCREMENT, isbn VARCHAR(20) NOT NULL UNIQUE, title VARCHAR(100) NOT NULL, author VARCHAR(50), publisher VARCHAR(80), total INT NOT NULL DEFAULT 0, available INT NOT NULL DEFAULT 0, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE reader ( id INT PRIMARY KEY AUTO_INCREMENT, card_no VARCHAR(20) NOT NULL UNIQUE, name VARCHAR(50) NOT NULL, phone VARCHAR(20), max_borrow INT NOT NULL DEFAULT 5 ); CREATE TABLE borrow_record ( id INT PRIMARY KEY AUTO_INCREMENT, book_id INT NOT NULL, reader_id INT NOT NULL, borrow_time DATETIME DEFAULT CURRENT_TIMESTAMP, due_time DATETIME NOT NULL, return_time DATETIME NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT '0 借出 1 已还' ); CREATE INDEX idx_borrow_reader ON borrow_record(reader_id, status); CREATE INDEX idx_borrow_book ON borrow_record(book_id, status);关键点在于book表的total和available字段是一对冗余字段:available理论上可以由总库存减去未归还记录算出来,但查询“哪些书可借”如果每次实时计算,数据量一大就拖慢列表页。保留冗余的代价是借还操作必须放到事务里同时更新两个字段,否则就会出现统计不一致。
索引参数方面,单查book_id或reader_id走普通索引即可,组合索引(reader_id, status)专门服务“某人名下有几本没还”的高频查询。索引不是越多越好,像borrow_record这种高频写入的表,每个索引都会拖慢INSERT,所以只建真正被查询条件命中的索引。
| 索引 | 字段组合 | 主要解决的查询 |
|---|---|---|
| idx_borrow_reader | reader_id, status | 查询某个读者未还记录 |
| idx_borrow_book | book_id, status | 查询某本书的借出记录 |
| uk_isbn | isbn | 录入图书时快速查重 |
2.3 为什么用连接池而不用DriverManager:Druid的配置与参数说明
很多人刚学JDBC时习惯每个DAO方法里Class.forName然后DriverManager.getConnection,开发阶段完全够用。一旦部署到图书馆局域网,十几个人同时打开网页查书,每次都新建物理连接会让MySQL的线程数暴涨,响应时间直接到秒级。连接池做的是复用连接这件事,其中Druid是Java Web项目里最常见的选型。
先建一个jdbc.properties,把连接信息和连接池参数放进去:
jdbc.driver=com.mysql.cj.jdbc.Driver jdbc.url=jdbc:mysql://localhost:3306/library_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai jdbc.username=root jdbc.password=123456 jdbc.initialSize=5 jdbc.maxActive=20 jdbc.minIdle=2 jdbc.maxWait=60000 jdbc.validationQuery=SELECT 1 jdbc.testWhileIdle=true jdbc.timeBetweenEvictionRunsMillis=60000然后在Web应用启动时初始化一个DruidDataSource,放到ServletContext里供所有DAO取用,而不是每个DAO new一个连接池:
@WebListener public class DruidInitListener implements ServletContextListener { @Override public void contextInitialized(ServletContextEvent sce) { try { Properties p = new Properties(); p.load(DruidInitListener.class.getClassLoader() .getResourceAsStream("jdbc.properties")); DruidDataSource ds = new DruidDataSource(); ds.setDriverClassName(p.getProperty("jdbc.driver")); ds.setUrl(p.getProperty("jdbc.url")); ds.setUsername(p.getProperty("jdbc.username")); ds.setPassword(p.getProperty("jdbc.password")); ds.setInitialSize(Integer.parseInt(p.getProperty("jdbc.initialSize"))); ds.setMaxActive(Integer.parseInt(p.getProperty("jdbc.maxActive"))); ds.setMinIdle(Integer.parseInt(p.getProperty("jdbc.minIdle"))); ds.setMaxWait(Long.parseLong(p.getProperty("jdbc.maxWait"))); ds.setValidationQuery(p.getProperty("jdbc.validationQuery")); sce.getServletContext().setAttribute("dataSource", ds); } catch (Exception e) { throw new IllegalStateException("Druid初始化失败", e); } } }参数说明:initialSize是启动时预创建的连接数,maxActive是最大连接数,maxWait是拿不到连接时最多等多久,单位毫秒,60000表示等60秒后抛异常。validationQuery设为SELECT 1,目的是在把连接交给业务方之前验证连接是否还活着,配合testWhileIdle打开,空闲连接被回收前也会做一次探测,避免拿到一个已被MySQL关闭的死连接。
3. 从登录到借阅:核心模块的实现步骤与代码
系统里的功能模块很多,但真正能体现Java Web基本功的是三个点:登录会话控制、图书分页查询、借还与事务。我把这三个模块按“能直接抄”的标准写出来。
3.1 登录和会话控制:Filter拦截器与Session超时设置
登录流程不复杂,先写一个LoginServlet负责校验用户名密码,通过后把用户对象放进Session:
@WebServlet("/login") public class LoginServlet extends HttpServlet { @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String username = req.getParameter("username"); String password = req.getParameter("password"); LoginResult result = loginService.checkLogin(username, password); if (result.isSuccess()) { HttpSession session = req.getSession(); session.setAttribute("loginUser", result.getUser()); resp.sendRedirect(req.getContextPath() + "/book/list"); } else { req.setAttribute("msg", "用户名或密码错误"); req.getRequestDispatcher("/login.jsp").forward(req, resp); } } }接着说会话控制。页面必须放在一个统一前缀下,比如/pages/*,然后用Filter把所有未登录请求挡在外面。只要Session里没有loginUser,直接重定向到登录页:
@WebFilter("/pages/*") public class AuthFilter implements Filter { @Override public void doFilter(ServletRequest sreq, ServletResponse sresp, FilterChain chain) throws IOException, ServletException { HttpServletRequest req = (HttpServletRequest) sreq; HttpServletResponse resp = (HttpServletResponse) sresp; Object loginUser = req.getSession().getAttribute("loginUser"); if (loginUser == null) { resp.sendRedirect(req.getContextPath() + "/login"); return; } chain.doFilter(sreq, sresp); } }逻辑说明:Servlet里用req.getSession()会创建新Session,哪怕原来不存在;Filter里只读属性,不做无谓的Session创建。另外登录成功用sendRedirect而不用forward,是为了防止用户按F5刷新时重复提交登录表单。
Session超时在web.xml里配置,单位是分钟:
<session-config> <session-timeout>30</session-timeout> </session-config>注意,这个超时时间是“最后一次访问后30分钟”。如果图书管理员的浏览器每隔20分钟自动刷新一次列表页,Session永远不会过期。实际图书馆环境里,我一般会把超时设成15分钟,避免读者在公用电脑上借完书忘记退出,留下风险。
3.2 图书查询与分页:LIMIT和页面翻页的SQL参数怎么设
图书列表和查询是访问最频繁的模块,分页查询的写法决定了页面在大数据量下的表现。核心DAO方法如下:
public List<Book> findPage(String keyword, int page, int pageSize) { String sql = "SELECT * FROM book WHERE title LIKE ? OR author LIKE ? " + "ORDER BY id DESC LIMIT ?,?"; try (Connection conn = DruidUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, "%" + keyword + "%"); ps.setString(2, "%" + keyword + "%"); ps.setInt(3, (page - 1) * pageSize); ps.setInt(4, pageSize); ResultSet rs = ps.executeQuery(); List<Book> list = new ArrayList<>(); while (rs.next()) { list.add(BookMapping.map(rs)); } return list; } }参数说明:LIMIT后面的第一个参数是偏移量,第二个是返回行数。页码从1开始,所以第2页的偏移量是(2-1)*10=10,也就是跳过前10条,取第11到第20条。keyword传入的是供模糊查询的关键字,两边拼接%表示包含式匹配。
这里最容易出问题的点是LIMIT ?,?只能走PreparedStatement占位符,不能把参数直接拼进SQL字符串。曾经见过新手这样写"LIMIT " + (page - 1) + "," + pageSize,结果页面参数被人传成1; DROP TABLE book,这就是SQL注入。你可以加一层参数校验,把page强制转成整数并限制在0到1000之间,至少能挡住大部分手动触发的异常请求。
分页还需要一个总记录数用于计算总页数,通常单独执行SELECT COUNT(*)。这两个SQL可以分开执行,不需要放在事务里,因为分页过程中新增一本书导致数量和清单对不上是可以接受的临时状态。
3.3 借阅/归还的事务处理:同一连接保证一致性
借书动作涉及两步:更新book表的available,插入一条借阅记录。这两步必须在一个事务里,否则就会出现“库存扣了但借阅记录没生成”这种了对不上的数据。纯JDBC下我会这样写:
public void borrowBook(int bookId, int readerId) throws Exception { Connection conn = null; boolean oldAutoCommit = true; try { conn = DruidUtil.getConnection(); oldAutoCommit = conn.getAutoCommit(); conn.setAutoCommit(false); String updateStock = "UPDATE book SET available = available - 1 " + "WHERE id = ? AND available > 0"; int rows = update(conn, updateStock, bookId); if (rows == 0) { throw new BusinessException("库存不足,书籍不可借"); } String insertRecord = "INSERT INTO borrow_record(book_id, reader_id, due_time) " + "VALUES(?,?,DATE_ADD(NOW(), INTERVAL 30 DAY))"; int insertRows = execute(conn, insertRecord, bookId, readerId); if (insertRows != 1) { throw new BusinessException("借阅记录写入失败"); } conn.commit(); } catch (Exception e) { if (conn != null) conn.rollback(); throw e; } finally { if (conn != null) { conn.setAutoCommit(oldAutoCommit); conn.close(); } } }这个写法有三个关键细节。
第一,扣减库存的SQL直接带AND available > 0,把“检查库存”和“扣库存”合并成一条原子语句,而不是先SELECT再UPDATE。否则两个并发的借书请求可能同时读到available=1,然后都执行更新,把库存扣成负数。
第二,conn.setAutoCommit(false)之前要保存原来的autoCommit状态,最终在finally里恢复。因为连接是从Druid连接池拿的,如果不恢复,这条连接归还后可能一直处于手动提交模式,下一个使用者提交时会造成事务边界错乱。这是最容易遗漏的隐藏坑。
第三,业务异常必须抛出后触发rollback。BusinessException是自定义运行时异常,在借阅记录的DAO里不捕获,统一交给service层处理,确保事务能感知失败。归还图书的流程和借阅对称,把status置为1,同时把available加回,同样放到一个事务里执行。
4. Java Web图书管理系统的避坑手册:部署、乱码和并发借阅的五个真实场景
这一章不是理论推演,是实实在在会出现在部署现场的问题。每个我都按“现象、原因、解决”三个层次拆开,方便你遇到时对照排查。
4.1 现象:页面和数据库中中文全部显示为问号
开发环境一切正常,部署到Tomcat后,页面上所有中文都变成“???”,数据库里存进去的也是乱码。
原因是编码链路断了。从浏览器到MySQL中间有四个编码点:JSP文件本身的编码、请求参数编码、响应输出编码、JDBC连接字符集。任何一个节点没有统一为UTF-8,中文就可能在某个环节丢失。
解决方式是在web.xml里增加一个编码过滤器,并用forceEncoding强制覆盖请求和响应编码:
<filter> <filter-name>encodingFilter</filter-name> <filter-class>org.apache.catalina.filters.SetCharacterEncodingFilter</filter-class> <init-param> <param-name>encoding</param-name> <param-value>UTF-8</param-value> </init-param> <init-param> <param-name>forceEncoding</param-name> <param-value>true</param-value> </init-param> </filter> <filter-mapping> <filter-name>encodingFilter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping>同时检查jdbc.url里是否带了characterEncoding=utf8,以及MySQL表本身是utf8mb4。这三个地方都对齐,中文在Java Web里基本不会出问题。注意utf8mb4和utf8的区别:前者能存表情符号,后者不能,既然建表了就顺手用utf8mb4。
4.2 现象:并发借阅把同一本书借给了两个人
一场活动结束,两个人同时到服务台还书,结果系统显示同一本书同时被借出去了两次。
原因是借书逻辑写成了先查库存再更新。两个请求同时查到available等于1,各自认为有库存,先后执行了扣减,最终available变成负数。
解决方式是在SQL里一次性完成检查和更新,也就是第3章写的UPDATE book SET available = available - 1 WHERE id = ? AND available > 0。这个方法利用数据库的行锁,多个并发请求执行UPDATE时天然串行,第二个请求会看到available已经变成0,更新行数为0,业务层据此抛出“库存不足”。
4.3 现象:部署到Tomcat后报类找不到或JDBC驱动加载失败
本地用IDEA运行没问题,打包成WAR放到服务器Tomcat,启动时报ClassNotFoundException: com.mysql.cj.jdbc.Driver。
原因是mysql-connector的jar没有进入最终发布包。IDEA运行时依赖由开发工具提供,但Tomcat看的是WAR里的WEB-INF/lib目录。如果你用的是Maven,常见做法是给驱动加runtime作用域,或者干脆不加任何作用域让它默认进包:
<dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency>另一种少见但真实的情况是Tomcat的lib目录里存在旧版本驱动,和项目里的驱动冲突。这种往往表现为NoSuchMethodError而不是ClassNotFound。我的处理习惯是:服务器Tomcat lib目录里只放核心jar,数据库驱动一律跟应用一起打包,避免不同应用之间相互影响。
4.4 现象:点“还书”按钮报404,路径里少了项目名
浏览器地址栏显示http://localhost:8080/borrow/returnBook,Tomcat返回404,但本地访问http://localhost:8080/library/borrow/returnBook是好的。
原因是JSP里的form表单action写成了绝对路径,少了项目上下文路径/library。本地IDE调试时,应用通常部署在根路径下,URL不带项目名也能访问;部署到独立Tomcat后,WAR包解压目录就是项目名,路径对不上自然404。
解决方式是在JSP中用动态上下文路径拼接,不要手写固定路径:
<form action="${pageContext.request.contextPath}/borrow/returnBook" method="post">同样,Servlet里重定向也必须写resp.sendRedirect(req.getContextPath() + "/login")。如果在Filter里漏了getContextPath(),登录成功后跳转地址就会缺前缀。这条属于部署环境的经典差异问题,本地没坑,一上服务器就炸。
4.5 现象:连接池被耗尽,数据库拒绝新连接
系统运行了几天后,Tomcat日志里大量出现Connection is not available, request timed out,MySQL的进程列表里全是Sleep连接。
原因是DAO里关闭连接不彻底。最常见的翻车写法是:
Connection conn = DruidUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql); ResultSet rs = ps.executeQuery();方法正常结束时没有finally块,也没有try-with-resources。如果SQL执行过程中抛了异常,conn.close()永远不会执行,连接池里的连接就不会归还,直到maxActive全被占用。
解决方式是统一用try-with-resources,连接、Statement、ResultSet全部自动关闭:
try (Connection conn = DruidUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql); ResultSet rs = ps.executeQuery()) { while (rs.next()) { // 处理结果集 } }需要特别说明的是,借还事务里用的conn不能放进try-with-resources,因为事务需要手动控制commit和rollback。这种情况下就在finally里显式关闭,并恢复autoCommit。如果你用的是Spring,事务交给@Transactional管理,但纯JDBC项目里这点必须自己兜住。
5. 用Maven重写构建脚本:三分钟从源码跑到可部署的WAR包
手工往Tomcat的webapps目录复制编译好的class文件,是最容易出错的部署方式。用Maven构建WAR包能把编译、资源打包、依赖引入这几件事自动化。这套项目没有框架依赖,构建脚本比Spring Boot项目轻得多。
5.1 pom.xml的核心依赖与版本选择
一个不依赖Spring的传统Java Web项目,pom.xml只需要四个核心依赖:
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <groupId>com.library</groupId> <artifactId>library-web</artifactId> <version>1.0.0</version> <packaging>war</packaging> <properties> <maven.compiler.source>8</maven.compiler.source> <maven.compiler.target>8</maven.compiler.target> </properties> <dependencies> <dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>4.0.1</version> <scope>provided</scope> </dependency> <dependency> <groupId>javax.servlet.jsp</groupId> <artifactId>javax.servlet.jsp-api</artifactId> <version>2.3.3</version> <scope>provided</scope> </dependency> <dependency> <groupId>jstl</groupId> <artifactId>jstl</artifactId> <version>1.2</version> </dependency> <dependency> <groupId>com.alibaba</groupId> <artifactId>druid</artifactId> <version>1.2.20</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency> </dependencies> <build> <finalName>library-web</finalName> </build> </project>参数说明:servlet相关两个依赖的scope设置为provided,意思是编译和测试时可用,但打包时不进WAR。因为Tomcat本身就带Servlet和JSP的实现,打进去反而可能和服务器版本冲突。Druid和JDBC驱动默认scope是compile,必须打包进WEB-INF/lib。
版本选择上,只要你的JDK是8或11,这套组合都可靠。Tomcat 9对应Servlet 4.0规范,用javax.servlet-api 4.0.1没问题;如果将来换到Tomcat 10或Jakarta规范,包名要跟着换,那是另一个话题。
5.2 资源文件与配置分离:jdbc.properties、log4j与打包过滤
IDE里跑和服务器上跑,数据库密码经常不一样。我的处理方式是让jdbc.properties从代码目录里挪出来,放进src/main/resources,Maven打包时会自动把resources目录下的文件复制到WAR里的WEB-INF/classes。这样源码和配置就分开了,换环境只改一个文件。
同时给log4j.properties固定一个滚动日志路径:
log4j.rootLogger=INFO, console, file log4j.appender.console=org.apache.log4j.ConsoleAppender log4j.appender.console.layout=org.apache.log4j.PatternLayout log4j.appender.console.layout.ConversionPattern=%d{yyyy-MM-dd HH:mm:ss} %-5p %c - %m%n log4j.appender.file=org.apache.log4j.DailyRollingFileAppender log4j.appender.file.File=${catalina.home}/logs/library-web.log log4j.appender.file.DatePattern=.yyyy-MM-dd log4j.appender.file.layout=org.apache.log4j.PatternLayout${catalina.home}是Tomcat启动时注入的系统属性,日志会写到$CATALINA_HOME/logs,而不是当前目录下一团乱麻的路径。DailyRollingFileAppender会按天切割日志,方便你排查“昨天某时刻是否有人用管理员账号登录过”这种问题。
5.3 在Tomcat 9上部署war包的三种方式和验证URL
打包命令很简单,执行:
mvn clean package在target/library-web.war拿到WAR包后,有三种部署方式。
第一种,复制到$CATALINA_HOME/webapps/目录下,Tomcat自动解压并部署。这是最直接的方式,适合单机小环境:
cp target/library-web.war $CATALINA_HOME/webapps/第二种,用Tomcat Manager在线部署,适合服务器不在手边时远程操作:
curl -u tomcat:123456 "http://server:8080/manager/text/deploy?path=/library&update=true" -T target/library-web.war第三种,用Maven插件部署,适合CI脚本:
mvn tomcat7:deploy -Dtomcat7.username=tomcat -Dtomcat7.password=123456注意tomcat7-maven-plugin虽然名字里带7,但部署到Tomcat 9通常也能用,只是URL路径的兼容性取决于服务器开放的Manager配置。
部署完做一次快速验证,而不是直接打开浏览器点来点去:
curl -s http://localhost:8080/library/book/list | grep "图书列表"如果返回结果里有“图书列表”字样,说明项目名、Filter、静态资源路径都正常。这个验证动作应该写进自己的部署清单里,比登录页面肉眼观察快得多。
6. 日志与性能验证:借阅接口响应时间的自查技巧
部署不是终点,上线后要能回答“系统跑得怎么样”。我自己的做法是用三个低成本的验证手段,让问题在影响读者之前暴露。
6.1 用Filter计算每个请求的耗时,输出到日志
一个简单的性能Filter,在进入业务前记录开始时间,输出响应后计算差值,超过阈值就打慢请求日志:
@WebFilter("/*") public class PerformanceFilter implements Filter { @Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { long start = System.currentTimeMillis(); chain.doFilter(req, resp); long cost = System.currentTimeMillis() - start; HttpServletRequest request = (HttpServletRequest) req; if (cost > 300) { System.out.println("[slow] " + request.getRequestURI() + " cost=" + cost + "ms"); } } }阈值先设300毫秒,运行几天后看日志,把稳定又频繁出现的慢请求找出来,再决定是否需要调优。
6.2 并发借阅压测:用JMeter跑一个简单的并发场景
JMeter的做法是建一个线程组,设20个线程循环10次,每个线程发一次借书请求。关键点在于借书请求需要登录后的Session,所以测试计划里要先加一个HTTP Cookie管理器,再放一个登录请求,否则压出来的全是重定向到登录页的响应。压测里看到的TPS和响应时间,才是这个系统在真实并发下的表现。
6.3 检查SQL执行计划:没有索引的全表扫描长什么样
如果压测发现借阅查询很慢,直接在MySQL里对高频SQL执行:
EXPLAIN SELECT * FROM borrow_record WHERE reader_id = 1 AND status = 0;结果里type是ALL就表示全表扫描,rows会显示扫描了上万行。这时按第2章的索引设计补上idx_borrow_reader,再跑一次EXPLAIN,type会变成ref,rows降到个位数。看到这个变化,性能问题才算真正定位。
我自己的习惯是每个Java Web项目上线前,都花半小时跑一遍这三个检查。一次压测发现问题,比读者反馈页面打不开再排查要省心得多。希望这套自查技巧也能帮到你。
本文还有配套的精品资源,点击获取