1. 这不是教科书里的“Hello World”,而是一套能跑在真实Tomcat上的完整数据操作链
你点开这个标题,大概率不是为了重温大学《Java Web编程》期末考前突击笔记。我猜你正卡在某个实际场景里:可能是公司老系统要加个后台管理页,但没人愿意碰那堆JSP页面;也可能是自己接了个小外包,客户只要一个能录入、查数据、改信息、删记录的页面,不谈微服务、不聊Spring Boot,就问“能不能三天内上线”;又或者你在自学Java Web,翻遍教程发现全是零散片段——Servlet怎么接收参数?JDBC连接池怎么配才不报空指针?JSP里EL表达式和JSTL标签混用时为啥总404?这些细节,文档从不告诉你为什么。
这个标题背后,是一条被反复验证过、踩过坑、修过bug、最终稳定运行在生产环境边缘的最小可行路径:JSP负责视图渲染,Servlet承担请求调度与业务编排,JDBC完成数据存取,MySQL提供持久化支撑。它不炫技,不包装,不抽象,所有代码都直面HTTP协议、SQL执行、线程安全、资源释放这些底层事实。我带过的几个刚毕业的开发,在某高校实验室做课程设计时,就是靠这套组合拳把一个学生选课系统从需求文档直接跑通到验收演示——没有框架黑盒,没有自动装配,每一个request.getParameter()、每一次conn.prepareStatement()、每一行<c:forEach>循环,都清清楚楚。
它适合三类人:第一类是正在学Java Web基础、想亲手把“请求-处理-响应-数据库”这条链路串起来的新手;第二类是需要快速交付轻量级内部工具的在职开发者,比如HR部门要个员工信息登记页,IT运维要个服务器配置台账;第三类是教学场景中的实践导师,需要一套无依赖、可调试、易讲解的示例工程。它不解决高并发、分布式事务或实时推送,但它能让你在30分钟内看到一条INSERT语句如何变成浏览器里的一行表格数据——这种确定性,恰恰是初学者最需要的锚点。
2. 内容整体设计与思路拆解:为什么坚持用这套“老组合”而不是直接上Spring Boot?
2.1 不是怀旧,而是为理解本质留出呼吸空间
很多人看到JSP+Servlet+JDBC的第一反应是:“这都2024年了,还写JSP?”——这话没错,但错在混淆了学习路径和工程选型。就像学开车先练手动挡再碰自动挡,学Web开发也得先摸清HTTP请求怎么进容器、Servlet生命周期如何触发、JDBC驱动如何加载、ResultSet怎么映射成Java对象。Spring Boot的@RequestBody自动绑定、JPA的@Entity注解、MyBatis的Mapper XML,全都是对这些底层机制的封装。封装越厚,出问题时越难定位。我见过太多人在Spring Boot项目里遇到“Controller接收不到参数”,一查才发现是前端传的是JSON但后端没加@RequestBody,或者Content-Type设错了——这种问题,回到Servlet里写一句String name = request.getParameter("name"),立刻就能验证是不是前端传参的问题。
所以这套设计的第一个核心逻辑是:剥离所有中间层,让数据流经的每个环节都暴露在眼皮底下。JSP不是为了写页面,而是为了直观看到MVC中View层如何接收Model(request.setAttribute)并渲染;Servlet不是为了写业务,而是为了看清一次HTTP请求从到达容器到返回响应的完整生命周期;JDBC不是为了写SQL,而是为了理解Connection、Statement、ResultSet三者如何协作,以及为什么必须手动close()。
2.2 为什么选MySQL而不是H2或Derby这类内存数据库?
因为真实项目永远面对的是MySQL。H2启动快、无需安装,但它的SQL语法、事务隔离级别、索引行为和MySQL有差异。比如H2默认支持LIMIT子句,而MySQL的LIMIT写法和Oracle完全不同;H2的datetime类型精度处理和MySQL也不一致。我在某次给某公司做内部培训时,学员用H2写的分页查询在本地跑得好好的,一上测试环境MySQL就报错——原因就是H2允许SELECT * FROM user LIMIT 10,20,而MySQL要求必须写成LIMIT 20 OFFSET 10(虽然新版也兼容前者,但老版本不行)。这种细节差异,只有直连MySQL才能提前暴露。
另外,MySQL的错误提示更贴近生产环境。比如JDBC连接失败时,MySQL驱动会明确告诉你“Access denied for user 'root'@'localhost'”,而H2可能只报“database not found”。前者能立刻指向权限或密码问题,后者却让人怀疑是不是路径写错了。所以本方案强制使用MySQL 5.7或8.0,驱动用mysql-connector-java 8.0.33(适配MySQL 8.0的认证插件),所有SQL语句按MySQL语法书写,包括使用backtick包裹字段名防止关键字冲突(如order字段必须写成order)。
2.3 为什么不用连接池而坚持每次获取新Connection?
这是最容易被质疑的一点。很多教程一上来就教DBCP或HikariCP,但新手根本不知道连接池解决的是什么问题。我让A同学先写一个不带连接池的版本,然后让他用Apache Bench压测:ab -n 100 -c 10 http://localhost:8080/list。结果很直观——前10次请求平均200ms,第50次开始飙升到1200ms,最后几次直接超时。他立刻明白了“数据库连接创建销毁开销大”不是一句空话。这时再引入HikariCP,配置maxLifetime=1800000(30分钟)、connectionTimeout=30000(30秒),压测结果稳定在210ms左右。这个对比过程,比看10页文档都管用。
但在初始版本中,我们坚持不用连接池,理由有三:第一,它增加了配置复杂度(XML或properties文件多出十几行);第二,它掩盖了资源泄漏风险(比如忘了close(),连接池会默默回收,但你意识不到问题);第三,它让初学者误以为“连接池能解决一切”,而忽略了JDBC规范中最基本的try-with-resources用法。所以本方案第一步是写出带完整finally块的手动close(),第二步再升级为try-with-resources,第三步才引入连接池——每一步都对应一个可感知的认知跃迁。
3. 核心细节解析与实操要点:那些文档里不会写的“为什么这样写”
3.1 JSP页面里,为什么用<c:forEach>而不是<% while(rs.next()) %>?
这是新手最容易掉进的坑。早期JSP允许在页面里直接写Java代码片段(Scriptlet),比如:
<% List<User> users = (List<User>) request.getAttribute("users"); for(User u : users) { %> <tr><td><%= u.getName() %></td></tr> <% } %>看起来很直接,但问题极多:第一,Java代码和HTML混在一起,维护成本高,IDE无法语法检查;第二,如果users为null,页面直接500错误,没有任何友好提示;第三,无法复用,每个列表页都要重写一遍循环逻辑。
而JSTL的<c:forEach>是标准标签库,它强制你把业务逻辑放在Servlet里,JSP只负责展示。更重要的是,它内置了空值安全:当items属性为null时,<c:forEach>自动跳过,不会报错。我们实测过,当Servlet忘记设置request.setAttribute("users", null)时,用Scriptlet的页面崩了,而用<c:forEach>的页面只是空白,还能加<c:if test="${empty users}">暂无数据</c:if>做兜底。
另一个关键细节是EL表达式的隐式对象。很多人写${user.name}能取到值,但写${user.getName()}就报错。这是因为EL默认调用getter方法,但要求JavaBean规范严格——User类的getName()方法必须public,且不能有参数。我曾帮某导师调试一个学生作业,发现学生把getName()写成了getName(String prefix),EL就完全无法识别。这个细节,不亲手写一遍根本记不住。
3.2 Servlet中,为什么doPost和doGet要分开处理,而不是全写在service()里?
Servlet规范定义了service()方法作为统一入口,但实际开发中几乎没人重写它。原因在于HTTP方法语义的强制约束。GET请求用于获取数据,应该幂等、可缓存、参数在URL里(长度有限制);POST用于提交数据,可以携带大量body内容,且不幂等(重复提交会插入两条记录)。如果把增删改查全塞进service(),你得手动解析request.getMethod(),再switch判断,代码臃肿且易错。
更关键的是安全边界。比如删除操作,必须用POST+确认弹窗,绝不能用GET链接(否则搜索引擎爬虫一抓就把数据删了)。我们在某次给某公司做系统加固时,发现他们有个“/delete?id=123”的GET接口,被恶意扫描器批量调用,导致37条客户数据丢失。后来全部改成POST,前端用form提交,并在Servlet里加了CSRF Token校验(虽然本方案不展开,但这是必须知道的延伸点)。
所以本方案严格遵循:GET处理查询和页面跳转(如/list显示列表页,/add跳转到新增页),POST处理数据变更(/add.do执行插入,/update.do执行更新)。这种分离不是教条,而是用框架约定帮你规避低级错误。
3.3 JDBC连接字符串里,useSSL=false和serverTimezone=UTC到底要不要加?
这是MySQL 8.0驱动最常被问的问题。当你用mysql-connector-java 8.0+连接MySQL 5.7时,不加这两个参数,十有八九会报错:
The server time zone value 'йʱ' is unrecognized...或者
Public Key Retrieval is not allowed根本原因是MySQL 8.0驱动默认启用SSL连接和严格时区校验。而本地开发环境通常没配SSL证书,MySQL服务端时区也常是系统默认(如CST)。解决方案不是简单加参数,而是理解参数背后的机制:
useSSL=false:关闭SSL握手。生产环境必须开启SSL,但开发阶段可关闭以快速验证连通性。注意:加了这个参数,控制台会警告“Establishing SSL connection without server's identity verification is not recommended”,这是提醒,不是错误。serverTimezone=UTC:告诉驱动服务器时区是UTC。更稳妥的做法是查MySQL服务端时区:SELECT @@global.time_zone, @@session.time_zone;,如果返回SYSTEM,则需匹配系统时区,比如Windows下常是serverTimezone=GMT%2B8(URL编码后)。
我们实测发现,即使MySQL服务端时区是+0800,驱动用serverTimezone=GMT+8也会报错,必须用serverTimezone=Asia/Shanghai或serverTimezone=GMT%2B8。这个细节,网上90%的博客都写错了,只告诉你“加就行”,却不讲为什么。
4. 实操过程与核心环节实现:从零开始搭建可运行的完整工程
4.1 环境准备与项目结构搭建(以IntelliJ IDEA为例)
第一步不是写代码,而是建一个干净的、无框架污染的Web项目。我们不用Maven模板,而是手动创建,确保每个文件都在你掌控中:
- 创建空项目 → New Module → Java Enterprise → Web Application(勾选“Create web.xml”)
- 在
src/main/webapp/WEB-INF/下确认有web.xml,这是Servlet 3.0之前的部署描述符,虽已非必需,但显式声明能避免新手困惑 - 在
src/main/java/下建包:com.example.dao(数据访问)、com.example.entity(实体类)、com.example.servlet(Servlet类) - 在
src/main/webapp/下建JSP目录:/list.jsp(列表页)、/add.jsp(新增页)、/edit.jsp(编辑页)
关键点:不要用IDEA自动生成的index.jsp,而是新建/list.jsp作为首页。因为我们要让/list路径直接映射到列表页,而不是通过index.jsp中转。这样URL语义更清晰,也方便后续RESTful风格扩展。
web.xml中必须声明Servlet映射:
<servlet> <servlet-name>UserServlet</servlet-name> <servlet-class>com.example.servlet.UserServlet</servlet-class> </servlet> <servlet-mapping> <servlet-name>UserServlet</servlet-name> <url-pattern>/user/*</url-pattern> </servlet-mapping>这里用/user/*而不是/user,是为了支持/user/list、/user/add等子路径,避免每个功能写一个Servlet。UserServlet通过request.getPathInfo()获取后缀,再用switch分支处理不同操作——这是简化路由的实用技巧。
4.2 MySQL建表与JDBC驱动配置(含字符集避坑指南)
建表SQL必须显式指定字符集,否则中文入库变问号:
CREATE TABLE `user` ( `id` int(11) NOT NULL AUTO_INCREMENT, `name` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, `email` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;注意三点:第一,用utf8mb4而非utf8,因为MySQL的utf8实际是utf8mb3,不支持emoji;第二,COLLATE utf8mb4_unicode_ci保证排序和比较符合Unicode标准;第三,表名user用反引号包裹,因为user是MySQL保留字(SELECT USER()函数)。
JDBC驱动jar包放入WEB-INF/lib/目录。我们用mysql-connector-java-8.0.33.jar,下载地址是MySQL官网。切勿用Maven仓库里标着“latest”的版本,因为最新版可能要求Java 17,而Tomcat 9默认用Java 11。版本不匹配会导致java.lang.NoClassDefFoundError: javax/xml/bind/DatatypeConverter这类诡异错误。
连接工具类DBUtil.java的核心代码:
public class DBUtil { private static final String URL = "jdbc:mysql://localhost:3306/testdb?useSSL=false&serverTimezone=UTC&characterEncoding=utf8mb4"; private static final String USER = "root"; private static final String PASSWORD = "123456"; public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } public static void close(Connection conn, PreparedStatement ps, ResultSet rs) { if (rs != null) try { rs.close(); } catch (SQLException e) { /* ignore */ } if (ps != null) try { ps.close(); } catch (SQLException e) { /* ignore */ } if (conn != null) try { conn.close(); } catch (SQLException e) { /* ignore */ } } }重点看close()方法:三个参数都设为null安全,即允许传入null而不抛NPE。这是生产代码的必备习惯,因为PreparedStatement可能因SQL错误未创建成功,ResultSet也可能为null。我们试过,如果只写rs.close()不判空,一旦查询无结果,页面就500了。
4.3 User实体类与DAO层实现(含SQL注入防护实操)
User.java必须是标准JavaBean:
public class User { private Integer id; private String name; private String email; // 必须有无参构造函数 public User() {} // getter/setter省略,但必须完整 public Integer getId() { return id; } public void setId(Integer id) { this.id = id; } // ... 其他getter/setter }DAO层UserDao.java的关键是所有SQL参数必须用?占位符,绝不用字符串拼接:
public class UserDao { public List<User> findAll() throws SQLException { String sql = "SELECT id, name, email FROM `user` ORDER BY id DESC"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql); ResultSet rs = ps.executeQuery()) { List<User> list = new ArrayList<>(); while (rs.next()) { User u = new User(); u.setId(rs.getInt("id")); u.setName(rs.getString("name")); u.setEmail(rs.getString("email")); list.add(u); } return list; } } public void save(User user) throws SQLException { String sql = "INSERT INTO `user` (name, email) VALUES (?, ?)"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) { ps.setString(1, user.getName()); ps.setString(2, user.getEmail()); ps.executeUpdate(); // 获取自增主键 try (ResultSet rs = ps.getGeneratedKeys()) { if (rs.next()) { user.setId(rs.getInt(1)); } } } } }这里有两个易错点:第一,prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)参数必须显式传入,否则getGeneratedKeys()返回空结果集;第二,rs.getInt(1)中的1指的是第一个字段(即id),不是索引0——JDBC的ResultSet索引从1开始,这是反直觉但必须记住的规则。
4.4 UserServlet核心逻辑与请求分发(含路径解析实战)
UserServlet.java是整个流程的中枢,它根据request.getPathInfo()决定执行哪个操作:
protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { String pathInfo = request.getPathInfo(); // 返回 /list, /add, /update 等 try { switch (pathInfo) { case "/list": handleList(request, response); break; case "/add": handleAdd(request, response); break; case "/save": handleSave(request, response); break; case "/edit": handleEdit(request, response); break; case "/update": handleUpdate(request, response); break; case "/delete": handleDelete(request, response); break; default: response.sendError(HttpServletResponse.SC_NOT_FOUND); } } catch (Exception e) { e.printStackTrace(); request.setAttribute("error", "系统繁忙,请稍后再试"); request.getRequestDispatcher("/list.jsp").forward(request, response); } }handleList()方法示例:
private void handleList(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { try { UserDao dao = new UserDao(); List<User> users = dao.findAll(); request.setAttribute("users", users); // 传递给JSP request.getRequestDispatcher("/list.jsp").forward(request, response); } catch (SQLException e) { e.printStackTrace(); request.setAttribute("error", "查询失败:" + e.getMessage()); request.getRequestDispatcher("/list.jsp").forward(request, response); } }关键细节:forward()是服务器端跳转,URL不变,适合MVC模式;而sendRedirect()是客户端重定向,URL会变,适合POST后重定向防止刷新重复提交(PRG模式)。我们在handleSave()执行完插入后,必须用response.sendRedirect(request.getContextPath() + "/user/list");,否则用户刷新页面会再次执行INSERT。
4.5 JSP页面编写与EL/JSTL最佳实践(含错误处理兜底)
list.jsp是整个UI的门面,必须包含健壮的错误处理:
<%@ page contentType="text/html;charset=UTF-8" language="java" %> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <html> <head> <title>用户列表</title> <style> table { border-collapse: collapse; width: 100%; } th, td { border: 1px solid #ccc; padding: 8px; text-align: left; } </style> </head> <body> <h2>用户管理</h2> <!-- 错误提示 --> <c:if test="${not empty error}"> <div style="color: red; margin: 10px 0;">⚠️ ${error}</div> </c:if> <!-- 新增按钮 --> <a href="${pageContext.request.contextPath}/user/add">+ 新增用户</a> <br><br> <!-- 数据列表 --> <c:choose> <c:when test="${empty users}"> <p>暂无用户数据</p> </c:when> <c:otherwise> <table> <thead> <tr> <th>ID</th> <th>姓名</th> <th>邮箱</th> <th>操作</th> </tr> </thead> <tbody> <c:forEach items="${users}" var="user"> <tr> <td>${user.id}</td> <td>${user.name}</td> <td>${user.email}</td> <td> <a href="${pageContext.request.contextPath}/user/edit?id=${user.id}">编辑</a> | <a href="${pageContext.request.contextPath}/user/delete?id=${user.id}" onclick="return confirm('确定删除用户【${user.name}】吗?')">删除</a> </td> </tr> </c:forEach> </tbody> </table> </c:otherwise> </c:choose> </body> </html>这里埋了三个实用技巧:第一,<c:choose>替代if-else,语义更清晰;第二,删除链接的onclick="return confirm(...)"是前端最简陋但最有效的二次确认;第三,${pageContext.request.contextPath}确保路径不硬编码,适配不同部署上下文(如部署在/myapp下也能正确跳转)。
5. 常见问题与排查技巧实录:那些让我熬夜到凌晨三点的Bug
5.1 404错误的七种可能及逐级排查法
404是新手最常遇到的错误,但原因千差万别。我们整理了一个速查表,按发生概率从高到低排列:
| 排查步骤 | 检查项 | 正确做法 | 典型现象 |
|---|---|---|---|
| 1 | Tomcat是否启动成功 | 查看catalina.out日志末尾是否有Server startup in [xxx] ms | 控制台无任何输出,浏览器直接拒绝连接 |
| 2 | 项目是否部署到Tomcat | 进入tomcat/webapps/目录,确认有你的项目文件夹(如myweb) | URL输入http://localhost:8080/myweb/仍404 |
| 3 | web.xml中Servlet映射路径是否匹配 | 检查<url-pattern>和浏览器访问路径是否一致,注意大小写和斜杠 | 访问/user/list404,但/list能访问 |
| 4 | JSP文件是否在webapp/目录下(而非src/main/java/) | JSP必须放在src/main/webapp/或webapp/根目录 | 编译没问题,但请求时提示The requested resource is not available |
| 5 | request.getRequestDispatcher()路径是否以/开头 | 路径/list.jsp是绝对路径(相对于webapp),list.jsp是相对路径(相对于当前Servlet) | forward()后页面空白,控制台无报错 |
| 6 | @WebServlet注解是否与web.xml冲突 | 如果用了注解,web.xml中不要重复声明同名Servlet | 启动时报Duplicate servlet name异常 |
| 7 | 浏览器缓存是否导致旧路径失效 | 强制刷新(Ctrl+F5)或用隐身窗口测试 | 昨天还好好的,今天突然404 |
我们曾帮某高校实验室解决一个诡异问题:学生代码完全正确,但/user/list始终404。最后发现是IDEA的Artifact配置里,Output directory指向了错误的路径,导致编译后的class文件没打包进war包。这种问题,只能靠逐级查看Tomcat的work/Catalina/localhost/目录下生成的临时文件来定位。
5.2 中文乱码的三种场景及精准修复方案
乱码问题分三类,必须对症下药:
场景一:JSP页面显示中文为问号(????)
原因:JSP文件本身编码不是UTF-8,或页面未声明contentType。
修复:
- 在IDEA中右键JSP文件 → File Encoding → 设置为UTF-8
- 在JSP顶部加
<%@ page contentType="text/html;charset=UTF-8" %> - 检查
web.xml中<jsp-config>是否设置了<page-encoding>UTF-8</page-encoding>
场景二:表单提交后,Servlet中request.getParameter()获取中文为乱码
原因:Tomcat 8.5+默认使用ISO-8859-1解码,而浏览器用UTF-8编码。
修复:在Servlet开头加request.setCharacterEncoding("UTF-8")。注意:必须在调用getParameter()之前,且对POST有效,GET无效(GET参数在URL中,需改Tomcat的server.xml)。
场景三:MySQL中存入中文变问号,但JSP显示正常
原因:MySQL服务端、数据库、表、列四层字符集不一致。
修复:
- 登录MySQL执行
SHOW VARIABLES LIKE 'character_set_%';,确认character_set_server为utf8mb4 - 执行
ALTER DATABASE testdb CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci; - 执行
ALTER TABLE user CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
我们实测过,只要其中一层是latin1,中文就必乱码。最稳妥的方法是建库时就指定:CREATE DATABASE testdb DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
5.3 NullPointerException的五个高发位置及防御式编程
NPE是运行时最讨厌的错误,因为它不告诉你哪一行出问题。我们总结了五个高频位置:
request.getAttribute("xxx")返回null,直接强转
错误写法:List<User> users = (List<User>) request.getAttribute("users");
正确写法:Object obj = request.getAttribute("users"); if (obj instanceof List) { users = (List<User>) obj; } else { users = new ArrayList<>(); }ResultSet.getString("xxx")字段不存在
错误写法:u.setName(rs.getString("name"));(如果SQL是SELECT id FROM user,name字段根本没查)
正确写法:先rs.findColumn("name")检查列是否存在,或用rs.getString(2)按位置取(前提是知道SQL字段顺序)PreparedStatement未设置参数就执行
错误写法:ps.setString(1, name); ps.executeUpdate();(如果name为null,某些驱动会报错)
正确写法:ps.setString(1, name == null ? "" : name);DBUtil.getConnection()抛出SQLException,后续代码仍执行
错误写法:Connection conn = DBUtil.getConnection(); conn.close();(如果getConnection()抛异常,conn为null,close()就NPE)
正确写法:用try-with-resources,或在finally块中判空JSP中
${user.name}的user为null
错误写法:直接${user.name}
正确写法:${not empty user ? user.name : '未知'}或<c:out value="${user.name}" default="未知"/>
5.4 数据库连接超时与连接泄漏的监控技巧
连接泄漏是隐形杀手。表现是:刚开始一切正常,运行几小时后,新请求卡住,Tomcat日志出现Cannot get a connection, pool error Timeout waiting for idle object。
根本原因是Connection、PreparedStatement、ResultSet未正确关闭。监控技巧有两个:
技巧一:在DBUtil.close()中加日志
public static void close(Connection conn, PreparedStatement ps, ResultSet rs) { if (rs != null) { System.out.println("Closing ResultSet: " + rs); try { rs.close(); } catch (SQLException e) { e.printStackTrace(); } } // ... 其他类似 }启动Tomcat后,观察控制台是否每执行一次查询都有对应的“Closing”日志。如果没有,说明某处漏关了。
技巧二:用Druid连接池替换HikariCP(仅用于诊断)
Druid提供/druid/index.html监控页面,能实时看到活跃连接数、SQL执行时间、慢SQL列表。虽然本方案不用连接池,但临时换Druid跑一天,就能准确定位哪段代码在泄漏连接。
我们曾在一个电商后台系统中发现,某个报表导出功能每次执行都会泄漏2个连接——原因是导出用CallableStatement调用存储过程,但忘了关闭CallableStatement。这个Bug,靠日志打印和Druid监控,30分钟就定位到了。
6. 实际部署与性能调优建议:从本地开发到准生产环境的平滑过渡
6.1 Tomcat配置优化(针对中小流量场景)
本地开发用默认配置没问题,但部署到公司内网服务器时,需调整几个关键参数。修改conf/server.xml:
<Connector port="8080" protocol="HTTP/1.1" connectionTimeout="20000" redirectPort="8443" maxThreads="200" <!-- 最大线程数,原默认200,够用 --> minSpareThreads="10" <!-- 最小空闲线程,防突发流量 --> acceptCount="100" <!-- 等待队列长度,超过则拒绝 --> URIEncoding="UTF-8" <!-- 解决GET参数中文乱码 --> />重点解释acceptCount:当所有线程都在忙时,新请求会进入等待队列,长度由acceptCount决定。如果队列满了,新请求直接被操作系统拒绝(TCP RST包),浏览器显示“连接被拒绝”。我们建议设为100,既不过度消耗内存,又能缓冲短时高峰。
另一个重要配置是conf/web.xml中的defaultServlet:
<servlet> <servlet-name>default</servlet-name> <servlet-class>org.apache.catalina.servlets.DefaultServlet</servlet-class> <init-param> <param-name>debug</param-name> <param-value>0</param-value> </init-param> <init-param> <param-name>listings</param-name> <param-value>false</param-value> <!-- 关闭目录浏览,安全必须 --> </init-param> <load-on-startup>1</load-on-startup> </servlet>listings=false必须开启,否则黑客访问http://yourdomain:8080/WEB-INF/能看到web.xml,进而推测技术栈。
6.2 静态资源缓存与Gzip压缩配置
JSP页面里的CSS/JS图片,默认不缓存,每次请求都走Tomcat。在conf/web.xml中添加:
<servlet-mapping> <servlet-name>default</servlet-name> <url-pattern>*.css</url-pattern> </servlet-mapping> <servlet-mapping> <servlet-name>default</servlet-name> <url-pattern>*.js</url-pattern> </servlet-mapping>然后在conf/web.xml的<servlet>节点下加:
<init-param> <param-name>cacheTTL</param-name> <param-value>86400000</param-value> <!-- 缓存24小时 --> </init-param>Gzip压缩开启:在conf/server.xml的<Connector>中加compression="on"和compressableMimeType="text/html,text/xml,text/plain,application/javascript,application/json"。实测效果:一个50KB的JSON响应,压缩后只剩12KB,首屏加载快了近3倍。
6.3 日志分级与错误追踪实践
不要用e.printStackTrace()打日志!它只输出到控制台,线上环境看不到。用java.util.logging或Log4j2。本方案用JUL(Java Util Logging),配置conf/logging.properties:
# 全局日志级别 .level = INFO # Servlet日志 com.example.servlet.UserServlet.level = FINE handlers = java.util.logging.ConsoleHandler, java.util.logging.FileHandler # 文件日志 java.util.logging.FileHandler.pattern = ${catalina.base}/logs/myweb.%g.log java.util.logging.FileHandler.limit = 10000000 java.util.logging.FileHandler.count = 5 java.util.logging.FileHandler.formatter = java.util.logging.SimpleFormatter这样,所有UserServlet的FINE级别日志(相当于DEBUG)会同时输出到控制台和logs/myweb.0.log。当线上出问题时,直接查这个文件,比翻catalina.out高效十倍。
我们给某公司做的一个设备管理系统,就是靠这个配置,在用户报告“点击删除没反应”时,5分钟内从日志里定位到是MySQL主键冲突,而不是前端JS错误。
7. 后续演进路线图:从这个“简单CRUD”出发,你能走多远
这个项目不是终点,而是一个可生长的种子。基于它,你可以按需向三个方向延伸:
方向一:架构升级——引入轻量级框架
- 第一步:用Apache Commons DbUtils替换原始JDBC,减少样板代码(
QueryRunner类一行搞定查询) - 第二步:引入Servlet 3.0+注解,去掉
web.xml,用@WebServlet("/user/*")声明 - 第三步:接入Spring MVC,但保留原有DAO层,只替换Servlet为@Controller,体验IoC容器的好处
**方向二:功能增强——补齐企业级