☰
Java Web基础骨架:手写Servlet+JDBC实现登录CRUD分页搜索
2026/10/15 8:38:37 网站建设 项目流程

1. 项目概述:一个真实可跑、不注水的Java Web基础骨架

“Java--小项目(登录、增删改查、分页、搜索)”——这行标题在初学Java Web的开发者QQ群、技术论坛和自学笔记里高频出现,它不是某个具体产品的代号,而是一套被反复验证过的“能力通关地图”。我带过几届某高校的实训班,也帮不少转行的朋友搭过第一套能上线的后台系统,发现一个共性:当人学完Servlet、JSP、MySQL基础语法后,最卡住的不是“不会写代码”,而是“不知道该先写哪一行、为什么这么写、写完怎么串起来”。这个标题背后,实际承载的是五个不可拆解的工程闭环:身份可信(登录)、数据主权(CRUD)、信息密度控制(分页)、意图精准捕获(搜索)、以及贯穿始终的请求-响应一致性保障。它不追求高并发或微服务架构,但必须做到——部署到一台4G内存的云服务器上,用Chrome打开首页不报500,点三次“删除”按钮不误删隔壁用户的数据,搜索“张三”不把“张三丰”的记录也捞出来。关键词里的“小项目”二字是定调:它拒绝Spring Boot自动配置的黑盒感,坚持手写DAO层SQL映射;它不引入Redis缓存,但要求分页SQL必须用LIMIT offset, size而非List.subList();它允许用JSP做视图,但所有表单提交必须带CSRF Token校验逻辑。这不是教学Demo,而是你未来三年内80%后台管理系统的最小可行原型。如果你刚写完“Hello World”想摸真实业务,或者正在面试前突击手撕后端流程,又或者需要给非技术同事演示“系统后台长什么样”,这个项目就是你的第一块磨刀石——它不炫技,但每一步都踩在真实开发的关节上。

2. 整体设计与技术选型逻辑:为什么放弃“新潮”而选择“可控”

2.1 架构分层不是教条,而是故障隔离的物理边界

很多初学者一上来就照搬MVC三层结构图,却没想明白:为什么非得拆成Controller、Service、DAO?我带过的一个A同学,在第一次实现用户删除功能时,把数据库连接、SQL拼接、异常捕获、跳转路径全塞进一个Servlet里。结果测试时发现:当数据库连不上,页面直接抛出NullPointerException堆栈,而用户看到的却是“系统繁忙,请稍后再试”——这根本不是友好提示,是把底层错误裸露给了前端。后来我们重构成标准三层,问题立刻清晰:DAO层只负责和数据库对话,连不上就抛SQLException;Service层捕获这个异常,转换成BusinessException("用户数据加载失败");Controller层再根据异常类型决定返回JSON错误码还是跳转错误页。这种分层本质是错误传播的减速带:让数据库问题止步于DAO,业务规则错误卡在Service,最终呈现给用户的只有明确的操作反馈。所以本项目强制采用经典分层,不是为了贴标签,而是为后续加日志、做事务、写单元测试预留物理插槽。比如后面要加事务控制,只需要在Service方法上加@Transactional注解,完全不影响Controller的请求解析逻辑。

2.2 技术栈选择:用“少而精”对抗“多而乱”

当前Java生态里,Spring Boot+MyBatis Plus能3行代码生成CRUD,但新手往往卡在“为什么加了@MapperScan还是找不到Mapper接口”。本项目坚持使用原生技术栈:Servlet 4.0 + JSP 2.3 + JDBC + MySQL 8.0。原因很实在:

  • Servlet:它是所有Java Web框架的底层基石。当你亲手写request.getParameter("username")并处理空值,比用@RequestParam更早理解HTTP参数传递的本质;当你手动调用response.sendRedirect("login.jsp"),会自然意识到重定向和转发的区别——这些不是考点,而是线上排查IllegalStateException: Cannot forward after response has been committed的救命知识。
  • JSP:虽然被诟病“逻辑与视图耦合”,但它的<c:forEach>标签和EL表达式${user.name},恰恰是理解MVC中“View如何消费Model”的最佳沙盒。我见过太多人用Thymeleaf时,把业务判断逻辑写进模板里,结果导致Controller层越来越薄,View层越来越厚,最后维护成本爆炸。而JSP的原始感,反而逼着你把if(user.getRole().equals("admin"))这种判断提前到Controller里,View只做纯粹渲染。
  • JDBC:绕开ORM框架的手动SQL编写,是建立SQL语感的必经之路。比如分页功能,MyBatis Plus的PageHelper.startPage(1,10)像魔法,但你永远不知道它背后执行的是SELECT * FROM user LIMIT 0,10还是SELECT * FROM (SELECT * FROM user) tmp LIMIT 0,10。而本项目要求手写String sql = "SELECT * FROM user WHERE name LIKE ? LIMIT ?,?",参数绑定时必须计算offset = (pageNum-1)*pageSize——这个计算过程,就是你理解“第一页从0开始”而非“从1开始”的关键。

提示:技术选型不是守旧,而是把“未知变量”压缩到最少。当你的项目同时出现Spring Boot版本冲突、MyBatis Plus动态SQL语法错误、Lombok注解不生效三个问题时,你根本无法判断根因在哪一层。而原生栈的问题,永远定位到具体某一行代码。

2.3 安全底线:登录不是功能,而是系统入口的守门人

标题里“登录”排在第一位,但它绝不是简单的用户名密码比对。我参与过某公司内部系统的安全审计,发现其登录接口存在两个致命问题:一是密码明文传输(未用HTTPS),二是验证码校验在前端JavaScript里完成。结果攻击者用Python脚本1秒发起200次请求,轻松绕过验证码暴力破解。本项目登录模块强制包含三个硬性环节:

  1. 传输加密:所有登录请求必须走HTTPS(开发阶段可用Tomcat自签名证书模拟);
  2. 凭证存储:密码绝不存明文,采用BCrypt强哈希(BCrypt.hashpw("123456", BCrypt.gensalt(12))),盐值随机生成且每次不同;
  3. 会话管控:登录成功后,HttpSession中只存用户ID,不存密码、角色等敏感字段;Session超时时间设为30分钟,并在用户登出时显式调用session.invalidate()。
    这些不是“可选项”,而是当你的项目要部署到公网时,运维同事检查清单里的前三条。漏掉任何一条,整个系统的安全等级就归零。

3. 核心模块细节与实操要点:从代码行到业务逻辑的穿透式解析

3.1 登录模块:一次请求背后的七层校验

登录看似简单,实则串联了前后端完整的数据流。以用户输入username=admin&password=123456&captcha=ABCD为例,完整链路如下:

第一步:前端防抖与基础校验
JSP页面中,登录按钮绑定JavaScript:

document.getElementById("loginBtn").onclick = function() { const username = document.getElementById("username").value.trim(); const password = document.getElementById("password").value; if (!username || username.length < 2) { alert("用户名至少2位"); return false; // 阻止表单提交 } if (password.length < 6) { alert("密码至少6位"); return false; } }

这里的关键是return false——它阻止了浏览器默认的表单提交行为,避免用户连续点击导致重复请求。很多新手忽略这点,结果用户狂点登录按钮,后端收到5个相同请求,造成会话混乱。

第二步:后端参数清洗与验证码核验
Servlet中接收参数:

String username = request.getParameter("username").trim(); // 必须trim!防止空格注入 String password = request.getParameter("password"); String captchaInput = request.getParameter("captcha"); String captchaInSession = (String) request.getSession().getAttribute("captcha"); // 从Session取验证码 if (!captchaInput.equalsIgnoreCase(captchaInSession)) { request.setAttribute("error", "验证码错误"); request.getRequestDispatcher("login.jsp").forward(request, response); return; // 立即返回,不执行后续逻辑 }

注意equalsIgnoreCase:验证码不区分大小写,但==比较字符串会失败(因为是对象引用比较)。这是新手常踩的坑。

第三步:密码比对与会话创建

UserDao userDao = new UserDao(); User user = userDao.findByUsername(username); if (user != null && BCrypt.checkpw(password, user.getPasswordHash())) { // 密码正确,创建会话 HttpSession session = request.getSession(true); session.setAttribute("userId", user.getId()); // 只存ID,不存整个User对象 session.setMaxInactiveInterval(30 * 60); // 30分钟无操作自动失效 response.sendRedirect("main.jsp"); // 重定向到主页 } else { request.setAttribute("error", "用户名或密码错误"); request.getRequestDispatcher("login.jsp").forward(request, response); }

这里有两个关键点:一是BCrypt.checkpw()用于验证哈希密码,它会自动提取存储密码中的盐值进行重新哈希比对;二是response.sendRedirect()而非forward()——重定向会生成新的GET请求,避免用户刷新页面时重复提交登录表单。

第四步:登录态拦截(Filter实现)
创建LoginFilter,在doFilter()中检查Session:

public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; HttpServletResponse response = (HttpServletResponse) res; HttpSession session = request.getSession(false); // 白名单:登录页、验证码接口、静态资源放行 String uri = request.getRequestURI(); if (uri.contains("/login.jsp") || uri.contains("/captcha") || uri.endsWith(".css") || uri.endsWith(".js")) { chain.doFilter(req, res); return; } // 检查登录态 if (session == null || session.getAttribute("userId") == null) { response.sendRedirect(request.getContextPath() + "/login.jsp"); return; } chain.doFilter(req, res); }

Filter的配置在web.xml中:

<filter> <filter-name>LoginFilter</filter-name> <filter-class>com.example.filter.LoginFilter</filter-class> </filter> <filter-mapping> <filter-name>LoginFilter</filter-name> <url-pattern>/*</url-pattern> <!-- 拦截所有请求 --> </filter-mapping>

这个Filter是系统的“安检门”,所有受保护资源(如用户列表页、新增页面)必须经过它验证。没有它,用户直接在浏览器地址栏输入/user/list.jsp就能绕过登录。

3.2 增删改查(CRUD):数据库操作的原子性与事务边界

CRUD不是四个孤立功能,而是一个数据生命周期的闭环。以“新增用户”为例,需求文档里只写“管理员可添加新用户”,但实际开发中必须考虑:

  • 如果用户表有唯一索引(如手机号),插入重复数据时数据库抛SQLIntegrityConstraintViolationException,后端该如何友好提示?
  • 新增成功后,是否要记录操作日志(谁、什么时候、添加了什么)?
  • 如果系统要求“新增用户同时发送欢迎邮件”,邮件发送失败,用户数据要不要回滚?

本项目采用显式事务控制,代码位于UserService中:

public class UserService { private final UserDao userDao = new UserDao(); public boolean addUser(User user) { Connection conn = null; try { conn = JdbcUtils.getConnection(); // 从Druid连接池获取连接 conn.setAutoCommit(false); // 关闭自动提交,开启事务 // 步骤1:插入用户 int rows = userDao.insert(conn, user); if (rows != 1) { throw new RuntimeException("用户插入失败"); } // 步骤2:记录操作日志(假设日志表log_table) Log log = new Log("add_user", user.getId(), new Date()); new LogDao().insert(conn, log); conn.commit(); // 所有步骤成功,提交事务 return true; } catch (SQLException e) { if (conn != null) { try { conn.rollback(); // 任一环节失败,回滚所有操作 } catch (SQLException ex) { ex.printStackTrace(); } } // 将数据库错误转换为业务可读提示 if (e.getMessage().contains("Duplicate entry")) { throw new BusinessException("手机号已存在,请更换"); } else { throw new BusinessException("系统繁忙,请稍后再试"); } } finally { JdbcUtils.closeConnection(conn); // 无论成功失败,都关闭连接 } } }

这段代码体现了三个核心原则:

  1. 连接复用:JdbcUtils.getConnection()从连接池取连接,避免频繁创建销毁TCP连接;
  2. 事务粒度:addUser()方法内所有DB操作共享同一个Connection,保证原子性;
  3. 异常翻译:将晦涩的SQLException消息(如Duplicate entry '13800138000' for key 'uk_mobile')转换为用户能懂的“手机号已存在”。

注意:事务不能跨方法传递。如果addUser()调用sendEmail(),而sendEmail()又开了新连接,那么邮件发送失败不会触发用户数据回滚。这就是为什么本项目把日志记录也放在同一事务内——它们属于同一业务上下文。

3.3 分页模块:从“查全部再截取”到“数据库级分页”的性能跃迁

分页是CRUD的放大器,也是性能杀手。新手常犯的错误是:

// ❌ 危险写法:先查全部,再用subList截取 List<User> allUsers = userDao.findAll(); // SELECT * FROM user int fromIndex = (pageNum - 1) * pageSize; int toIndex = Math.min(fromIndex + pageSize, allUsers.size()); List<User> pageUsers = allUsers.subList(fromIndex, toIndex);

这种写法在用户表只有100条数据时没问题,但当数据量达到10万条时,findAll()会把10万条记录全加载进JVM内存,极易触发OutOfMemoryError。本项目强制采用数据库原生分页:

public List<User> findUsersByPage(int pageNum, int pageSize) { String sql = "SELECT id, username, mobile, create_time FROM user ORDER BY id DESC LIMIT ?, ?"; return queryForList(sql, (pageNum - 1) * pageSize, pageSize); }

关键点在于LIMIT ?, ?:第一个参数是偏移量(offset),第二个是每页数量(size)。MySQL会直接在存储引擎层跳过前(pageNum-1)*pageSize行,只返回后续pageSize行,内存占用恒定。

但这里有个隐藏陷阱:当pageNum很大时(如第10000页),LIMIT 999999, 10依然需要扫描前999999行,性能急剧下降。解决方案是游标分页(Cursor-based Pagination):

// ✅ 推荐:用上一页最后一条记录的ID作为游标 public List<User> findUsersAfterId(long lastId, int pageSize) { String sql = "SELECT id, username, mobile FROM user WHERE id < ? ORDER BY id DESC LIMIT ?"; return queryForList(sql, lastId, pageSize); }

用户首次请求/user/list?pageNum=1,返回第1页数据及最后一条记录ID(如id=100);下一页请求/user/list?cursor=100,SQL变成WHERE id < 100,数据库利用主键索引快速定位,性能几乎不受数据总量影响。本项目在基础分页外,额外提供游标分页接口,供高数据量场景切换。

3.4 搜索模块:从“模糊匹配”到“意图识别”的精度升级

搜索功能常被简化为WHERE name LIKE '%关键词%',但这会导致严重问题:

  • 输入“张”会匹配“张三”“张三丰”“王小张”,噪音极大;
  • 输入“张三 丰”(带空格)时,LIKE '%张三 丰%'完全匹配不到“张三丰”;
  • 中文分词缺失,无法支持“手机”搜索出“iPhone”“华为”等同义词。

本项目采用三级搜索策略:
第一级:精确匹配(主键/唯一字段)
先尝试按ID、手机号精确查询:

if (StringUtils.isNumeric(keyword)) { User user = userDao.findById(Long.parseLong(keyword)); if (user != null) return Collections.singletonList(user); }

第二级:前缀匹配(用户名、邮箱)
WHERE username LIKE ?,参数为keyword + "%":

String sql = "SELECT * FROM user WHERE username LIKE ? OR email LIKE ?"; List<User> users = queryForList(sql, keyword + "%", keyword + "%");

这样输入“张”只匹配“张三”“张四”,不匹配“王小张”。

第三级:全文检索(MySQL 5.7+内置)
对name字段建立FULLTEXT索引:

ALTER TABLE user ADD FULLTEXT(name);

搜索时用MATCH ... AGAINST:

String sql = "SELECT *, MATCH(name) AGAINST(?) AS score FROM user WHERE MATCH(name) AGAINST(?) ORDER BY score DESC"; List<User> users = queryForList(sql, keyword, keyword);

AGAINST('张三丰')会自动进行中文分词,匹配“张三”“丰”等词根,并按相关度score排序。

实操心得:搜索不是越“全”越好,而是越“准”越好。我在某电商后台项目中,曾把搜索框默认行为设为“全文检索”,结果运营同事搜“苹果”时,既出现“iPhone”也出现“红富士苹果”,完全偏离业务意图。后来改为“输入3个字符后触发前缀匹配,输入空格后切换为全文检索”,准确率提升60%。

4. 实操过程与核心环节实现:从零部署到功能验证的完整流水线

4.1 环境准备:Tomcat 9 + MySQL 8 的最小化配置

本项目不依赖IDE集成,所有操作均可通过命令行完成,确保环境纯净。

Tomcat 9安装与配置:

  1. 下载apache-tomcat-9.0.83.zip,解压到/opt/tomcat;
  2. 修改conf/server.xml,设置UTF-8编码(防止中文乱码):
<Connector port="8080" protocol="HTTP/1.1" connectionTimeout="20000" redirectPort="8443" URIEncoding="UTF-8"/> <!-- 添加此行 -->
  1. 启动Tomcat:bin/startup.sh(Linux/Mac)或bin/startup.bat(Windows);
  2. 访问http://localhost:8080,看到Tomcat欢迎页即成功。

MySQL 8初始化:

  1. 创建数据库及用户:
CREATE DATABASE java_demo CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; CREATE USER 'demo_user'@'localhost' IDENTIFIED BY 'StrongPass123!'; GRANT ALL PRIVILEGES ON java_demo.* TO 'demo_user'@'localhost'; FLUSH PRIVILEGES;
  1. 创建用户表(含索引优化):
USE java_demo; CREATE TABLE `user` ( `id` bigint NOT NULL AUTO_INCREMENT, `username` varchar(50) NOT NULL, `password_hash` varchar(100) NOT NULL, `mobile` varchar(11) DEFAULT NULL, `email` varchar(100) DEFAULT NULL, `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_mobile` (`mobile`), UNIQUE KEY `uk_email` (`email`), FULLTEXT KEY `ft_name` (`username`) -- 为全文检索建索引 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

注意utf8mb4:MySQL的utf8实际只支持3字节UTF-8字符(不支持emoji),utf8mb4才是真正的UTF-8。

JDBC驱动配置:
下载mysql-connector-java-8.0.33.jar,放入WEB-INF/lib/目录;在WEB-INF/web.xml中配置初始化参数:

<context-param> <param-name>jdbcUrl</param-name> <param-value>jdbc:mysql://localhost:3306/java_demo?useSSL=false&amp;serverTimezone=Asia/Shanghai&amp;allowPublicKeyRetrieval=true</param-value> </context-param> <context-param> <param-name>jdbcUsername</param-name> <param-value>demo_user</param-value> </context-param> <context-param> <param-name>jdbcPassword</param-name> <param-value>StrongPass123!</param-value> </context-param>

serverTimezone=Asia/Shanghai解决时区问题,否则create_time可能比系统时间慢8小时。

4.2 项目结构搭建:符合Servlet规范的物理目录

项目必须严格遵循Java Web标准目录结构,否则Tomcat无法识别:

java-demo/ ├── WEB-INF/ │ ├── web.xml # 部署描述符(必须) │ ├── lib/ # 第三方jar包(mysql-connector-java.jar等) │ └── classes/ # 编译后的.class文件(按包路径存放) │ └── com/example/ │ ├── servlet/ │ │ ├── LoginServlet.class │ │ └── UserListServlet.class │ ├── dao/ │ │ └── UserDao.class │ └── filter/ │ └── LoginFilter.class ├── login.jsp # 登录页面 ├── main.jsp # 主页 └── user/ ├── list.jsp # 用户列表页 └── add.jsp # 新增用户页

编译命令(Linux/Mac):

# 进入src目录,编译所有Java文件到WEB-INF/classes javac -d ../WEB-INF/classes -cp ".:../WEB-INF/lib/mysql-connector-java-8.0.33.jar" com/example/**/*.java

-cp参数指定类路径,包含当前目录(.)和MySQL驱动jar包。若缺少-cp,编译会报package java.sql does not exist错误。

4.3 功能验证清单:每个按钮都要有对应的测试用例

部署完成后,必须按顺序执行以下验证,确保各模块联动正常:

步骤操作预期结果常见问题
1访问http://localhost:8080/java-demo/login.jsp显示登录表单,验证码图片可正常加载验证码不显示 → 检查CaptchaServlet是否在web.xml中正确映射
2输入正确账号密码,错误验证码页面提示“验证码错误”,URL仍为login.jsp提示不显示 → 检查request.setAttribute("error", ...)后是否调用forward()
3输入正确账号密码和验证码跳转到main.jsp,地址栏变为/java-demo/main.jsp仍停留在登录页 → 检查response.sendRedirect()路径是否带项目名/java-demo/
4在main.jsp点击“用户管理”链接进入/user/list.jsp,显示用户列表(初始为空)列表空白 → 查看Tomcat日志,确认UserListServlet是否抛出SQLException
5点击“新增用户”,填写username=test&mobile=13800138000提交后返回列表页,新用户出现在第一页新用户未显示 → 检查UserDao.insert()是否执行conn.commit(),或web.xml中<load-on-startup>1</load-on-startup>是否配置
6在列表页点击“删除”按钮(ID=1)弹出确认框,点击确定后该用户消失删除后仍存在 → 检查UserDeleteServlet中request.getParameter("id")是否获取到值,或SQL的WHERE id = ?条件是否正确

提示:验证时务必打开浏览器开发者工具(F12),在Network标签页观察每个请求的状态码。200表示成功,302表示重定向,404表示资源未找到,500表示服务器内部错误——这是你定位问题的第一现场。

4.4 性能压测实录:用Apache Bench验证分页与搜索的稳定性

当项目功能跑通后,必须进行基础压测,验证其在真实流量下的表现。使用Apache Bench(ab)工具:

# 测试登录接口(POST请求) ab -n 100 -c 10 -p login_data.txt -T "application/x-www-form-urlencoded" http://localhost:8080/java-demo/login # 测试用户列表页(GET请求,第1页) ab -n 100 -c 10 http://localhost:8080/java-demo/user/list?pageNum=1&pageSize=10 # 测试搜索接口(GET请求) ab -n 100 -c 10 http://localhost:8080/java-demo/user/search?keyword=张

其中login_data.txt内容为:

username=admin&password=123456&captcha=ABCD

压测结果关键指标:

  • Requests per second:应稳定在50+ req/s(本地开发机标准);
  • Time per request:平均响应时间<200ms;
  • Failed requests:必须为0;
  • Percentage of the requests served within a certain time:90%请求应在300ms内完成。

若Failed requests大于0,需检查:

  • 数据库连接池是否耗尽(Druid监控页面/druid/index.html查看活跃连接数);
  • web.xml中<session-config><session-timeout>30</session-timeout></session-config>是否设置过短,导致并发用户Session挤占内存。

5. 常见问题与排查技巧实录:那些文档里不会写的血泪经验

5.1 中文乱码:从请求到响应的全链路编码治理

中文乱码是Java Web新手的头号敌人,它可能出现在三个环节:
环节1:JSP页面显示乱码
现象:list.jsp中<%= user.getUsername() %>显示为“李国隌”。
根因:JSP文件本身是UTF-8编码,但浏览器未被告知。
解决方案:在JSP顶部添加page指令:

<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>

contentType告诉浏览器用UTF-8解析HTML,pageEncoding告诉JSP引擎用UTF-8读取该文件。

环节2:表单提交乱码
现象:登录时输入“张三”,后端request.getParameter("username")得到“甹三”。
根因:Tomcat 8.5+默认用ISO-8859-1解码POST请求,而浏览器用UTF-8编码。
解决方案:在web.xml中配置CharacterEncodingFilter:

<filter> <filter-name>CharacterEncodingFilter</filter-name> <filter-class>org.apache.catalina.filters.CharacterEncodingFilter</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>CharacterEncodingFilter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping>

forceEncoding=true强制覆盖请求的编码,无论浏览器发来什么编码都用UTF-8处理。

环节3:数据库存储乱码
现象:用户表中username字段存入“张三”,但查询结果显示为“??”。
根因:MySQL连接URL未指定字符集,或数据库/表字符集非utf8mb4。
解决方案:

  • 检查连接URL:jdbc:mysql://localhost:3306/java_demo?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8mb4;
  • 检查数据库字符集:SHOW CREATE DATABASE java_demo;,确认为DEFAULT CHARACTER SET = utf8mb4;
  • 检查表字符集:SHOW CREATE TABLE user;,确认ENGINE=InnoDB DEFAULT CHARSET=utf8mb4。

注意:这三个环节的编码必须全程一致,任何一环断裂都会导致乱码。我曾帮一个团队排查,发现他们JSP和数据库都是UTF-8,但Tomcat的CharacterEncodingFilter配置漏掉了forceEncoding=true,结果POST请求依然乱码。

5.2 Session丢失:分布式环境下的会话粘滞陷阱

在单机Tomcat下,Session天然存在,但一旦部署到Nginx+多台Tomcat集群,就会出现“登录后访问其他页面又跳回登录页”的问题。
根因:用户第一次请求被Nginx分发到Tomcat-A,登录态存在A的内存中;第二次请求被分发到Tomcat-B,B没有该Session,判定为未登录。
解决方案有三种:

  1. 会话粘滞(Sticky Session):Nginx配置ip_hash,保证同一IP的请求总到同一台Tomcat;
  2. Session复制:Tomcat配置<Cluster>,各节点间同步Session(消耗网络带宽);
  3. Session共享:将Session存入Redis,所有Tomcat节点读写同一份数据(推荐)。
    本项目在单机模式下无需处理,但必须在代码中预留扩展点:将HttpSession操作封装成SessionManager接口,未来可无缝替换为RedisSessionManager实现。

5.3 SQL注入:从Statement到PreparedStatement的生死之隔

新手常写:

// ❌ 危险!直接拼接SQL String sql = "SELECT * FROM user WHERE username = '" + username + "'"; Statement stmt = conn.createStatement(); ResultSet rs = stmt.executeQuery(sql);

攻击者输入username=' OR '1'='1,SQL变成:

SELECT * FROM user WHERE username = '' OR '1'='1'

结果查出所有用户!
正确做法:

// ✅ 安全!预编译参数化查询 String sql = "SELECT * FROM user WHERE username = ?"; PreparedStatement pstmt = conn.prepareStatement(sql); pstmt.setString(1, username); // 参数自动转义 ResultSet rs = pstmt.executeQuery();

PreparedStatement会将username参数视为纯数据,即使输入' OR '1'='1,也会被当成一个字符串字面量,SQL实际执行为:

SELECT * FROM user WHERE username = '\' OR \'1\'=\'1'

完全规避注入风险。本项目所有DAO方法均强制使用PreparedStatement,并在JdbcUtils工具类中封装executeUpdate(String sql, Object... params)方法,统一处理参数绑定。

5.4 内存泄漏:未关闭资源的静默杀手

Tomcat运行几天后变慢,重启又恢复,大概率是内存泄漏。最常见的泄漏源是未关闭数据库连接:

// ❌ 危险!忘记关闭Connection Connection conn = JdbcUtils.getConnection(); User user = userDao.findById(conn, 1L); // 忘记 conn.close()

连接池中的连接被占用却不归还,池子逐渐枯竭,新请求只能等待,响应时间飙升。
解决方案:使用try-with-resources(Java 7+):

// ✅ 安全!自动关闭 try (Connection conn = JdbcUtils.getConnection(); PreparedStatement pstmt = conn.prepareStatement(sql)) { pstmt.setLong(1, userId); ResultSet rs = pstmt.executeQuery(); // 处理结果集... } catch (SQLException e) { e.printStackTrace(); } // 自动调用conn.close()和pstmt.close()

本项目所有涉及IO、数据库、文件操作的代码,均采用此模式。在JdbcUtils中,getConnection()返回的Connection对象,必须在finally块或try-with-resources中显式关闭,这是硬性编码规范。

5.5 部署失败:war包与Tomcat版本的兼容性雷区

将项目打包为java-demo.war放到webapps/目录后,Tomcat启动时控制台报错:

SEVERE [RMI TCP Connection(2)-127.0.0.1] org.apache.catalina.core.StandardContext.startInternal One or more Filters failed to start.

根因:web.xml中使用的Servlet版本与Tomcat不匹配。例如:

  • Tomcat 9 支持 Servlet 4.0,web.xml头应为:
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd" version="4.0">
  • 若误用Servlet 3.1的xsd(web-app_3_1.xsd),Tomcat 9会启动失败。
    解决方案:
  1. 查看Tomcat官方文档确认支持的Servlet版本;
  2. 在IDE中新建Dynamic Web Project时,选择

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

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

立即咨询