☰
JSP+Servlet+MySQL登录注册账号系统实战与避坑
2026/10/28 15:23:59 网站建设 项目流程

简介:围绕JavaWeb登录与注册功能展开的实战型代码包,面向Java Web入门学习者、课程设计开发者及需要梳理JSP、Servlet与MySQL协作流程的读者,用于解决表单提交、身份校验、会话保持与用户数据持久化等常见问题。压缩包共34个文件,约3.56MB,以7个Java源文件和7个class文件承载后端处理逻辑,3个JSP页面负责登录、注册界面,另含SQL建表脚本、properties数据库配置、xml部署描述及jar依赖,结构接近可直接导入运行的课程项目。目前已有3051人学习。读者可从中理解JSP页面与后端Servlet的请求响应链路、JDBC连接MySQL及参数化查询防注入的写法,并参考session登录状态保存、注册信息有效性校验、密码加密存储与错误提示处理,适合作为课程设计原型或分模块学习底稿。

1. 从一个登录框说起:JSP+Servlet+MySQL 能撑起的最小账号系统

去年帮人收拾一个毕业设计项目,打开压缩包,目录里只有三个东西能跑:login.jsp、RegisterServlet、一张 user 表。看起来简陋,但从表单提交、参数校验、数据库查重、密码比对到 Session 保持,一条完整链路一个环节都没缺。基于 JavaWeb 实现的登录及注册,本质上就是 JSP 出页面、Servlet 接请求、Java 类写业务、MySQL 存用户的一条最短通路,没有框架隔着,所有环节都摊在明面上。它最适合三类人:刚学完 Servlet 想跑通第一个完整 Demo 的新手、要给后台管理系统补一个账号模块的开发者、以及接手十年前的 JSP 老项目却不知道从哪下手的维护者。注册查重、密码加密、会话拦截这些事,它全都得亲手做一遍,跑通一次比看十篇原理管用。

2. 工程骨架:三层分层与 web.xml 路由怎么落地

2.1 为什么这套无框架组合今天还值得看

先把选型说清楚。这套项目里没有任何框架依赖,JSP 负责把后端数据渲染成 HTML,Servlet 负责接收请求、调用业务、决定跳转,普通 Java 类写业务逻辑和数据库访问,JDBC 直连 MySQL。放到今天,很多人第一反应是"为什么不直接上 Spring Boot",我的答案比较务实:看你要拿它干什么。如果目标是搞明白一个 Web 请求从浏览器发出到数据库返回的全过程,Spring 的自动装配反而像个黑匣子,你知道它跑通了,但不知道它跑了什么。JSP+Servlet 这套把每一步都摆在你面前,请求参数从哪来、Session 什么时候创建、Connection 什么时候关闭,全是你自己写的代码,出问题能一路断点跟下去。

它当然有时代局限。JSP 页面里允许写 Java 代码片段,新手很容易把校验、查询、跳转全塞进<% %>里,最后页面变成一锅粥;没有内置连接池,每次请求都 new 一个 Connection,几十个并发就能把数据库连接数打满;SQL 全靠手写字符串,稍不留神就拼出注入点。这些问题不是这套技术的死罪,而是它没替你兜底,得你自己兜。

我一般这样配环境:JDK 8 或 11、Servlet 3.1 以上(对应 Tomcat 8.5 及以上)、MySQL 5.7 或 8.0、驱动用 mysql-connector-java 8.x。Servlet 3.0 之后支持@WebServlet注解,可以完全不写 web.xml,但老项目基本还是 XML 配置,两种写法都得能看懂,不然接手别人代码时第一步就卡住。

提示:驱动版本和 MySQL 版本要对得上。8.x 驱动配 5.7 数据库能连但会有时区告警,5.x 驱动配 8.0 数据库直接报Unknown system variable,这个组合错误后面还会单独说。

2.2 目录结构与各层职责

一个能维护的版本,目录大致长这样。别看文件不多,每一层的边界划清楚了,后面加验证码、加找回密码才不会互相打架。

login-register/ ├── pom.xml # 依赖:servlet-api、mysql-connector-java、jstl ├── src/main/java/com/example/auth/ │ ├── controller/ # 控制层:只做取参、调用、跳转 │ │ ├── LoginServlet.java │ │ ├── RegisterServlet.java │ │ └── LogoutServlet.java │ ├── service/ # 业务层:校验规则、事务边界 │ │ └── UserService.java │ ├── dao/ # 数据访问层:只跟 SQL 打交道 │ │ └── UserDao.java │ ├── entity/ # 实体:和 user 表一一对应 │ │ └── User.java │ ├── filter/ # 过滤器:编码、登录拦截 │ │ ├── EncodingFilter.java │ │ └── LoginFilter.java │ └── util/ # 工具:连接、摘要 │ ├── DBUtil.java │ └── PasswordUtil.java ├── src/main/resources/ │ └── db.properties # 数据库四要素,不进代码 └── src/main/webapp/ ├── WEB-INF/ │ ├── web.xml │ └── views/home.jsp # 登录后才可访问的页面 ├── login.jsp ├── register.jsp ├── css/style.css └── index.jsp # 入口,重定向到登录页

分层的关键在于职责不能串。Servlet 里不写 SQL,DAO 里不碰 request 和 response,Service 里不做页面跳转。我见过太多项目把request.getParameter直接写进 DAO 方法参数里,结果换个入口调用就得改数据层,血泪经验。按这个结构,Servlet 只负责三件事:从请求里取参数、把参数丢给 Service、根据返回结果决定 forward 还是 redirect。Service 负责业务规则,比如用户名长度、两次密码是否一致、用户名是否已存在。DAO 只认参数和结果集。

JSP 统一放在 webapp 根目录或 WEB-INF 下。放 WEB-INF 里更安全,因为浏览器无法直接访问,只能由 Servlet 转发进来,能防止用户绕过控制器直接打开 home.jsp 看到空数据。

2.3 web.xml 与请求路由

老项目基本都是 XML 配置,这是绕不开的一段。下面这段是能直接用的最小配置,包括编码过滤器、登录过滤器、两个核心 Servlet 和欢迎页。

<?xml version="1.0" encoding="UTF-8"?> <web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee" version="3.1"> <!-- 编码过滤器必须排第一位,否则后续 filter 里取到的参数已经是乱码 --> <filter> <filter-name>encodingFilter</filter-name> <filter-class>com.example.auth.filter.EncodingFilter</filter-class> <init-param> <param-name>encoding</param-name> <param-value>UTF-8</param-value> </init-param> </filter> <filter-mapping> <filter-name>encodingFilter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping> <!-- 登录拦截,同样拦所有路径,白名单在 Java 代码里判断 --> <filter> <filter-name>loginFilter</filter-name> <filter-class>com.example.auth.filter.LoginFilter</filter-class> </filter> <filter-mapping> <filter-name>loginFilter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping> <servlet> <servlet-name>loginServlet</servlet-name> <servlet-class>com.example.auth.controller.LoginServlet</servlet-class> </servlet> <servlet-mapping> <servlet-name>loginServlet</servlet-name> <url-pattern>/login</url-pattern> </servlet-mapping> <servlet> <servlet-name>registerServlet</servlet-name> <servlet-class>com.example.auth.controller.RegisterServlet</servlet-class> </servlet> <servlet-mapping> <servlet-name>registerServlet</servlet-name> <url-pattern>/register</url-pattern> </servlet-mapping> <welcome-file-list> <welcome-file>index.jsp</welcome-file> </welcome-file-list> </web-app>

几个参数值得单独说。<url-pattern>写/*表示拦截所有请求,包括静态资源,所以 LoginFilter 内部一定要有白名单,否则 css、js、登录页本身都会被拦下来重定向,页面会变成一个死循环。<filter-mapping>的顺序就是执行顺序,编码过滤器必须在前,因为它要在其他过滤器读参数之前把request.setCharacterEncoding设好。<welcome-file>决定访问根路径时打开哪个文件,通常放一个只做跳转的 index.jsp。

如果换成注解方式,Servlet 上写@WebServlet("/login"),过滤器上写@WebFilter("/*"),可以省掉 web.xml 里那一大段,但过滤器的执行顺序就变成按类名字母序,不再可控。项目里只要有一个以上过滤器,我还是倾向 XML 配置,顺序这种事不能靠猜。

3. 数据层拆解:user 表、JDBC 工具类与 UserDao 的增查改

3.1 user 表字段怎么定

表结构决定后面代码长什么样,一开始就想清楚能省很多返工。登录注册场景最少需要用户名、密码,但要留扩展位,不然加个手机号登录就得改表、改实体、改 DAO 三处。

CREATE TABLE `user` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', `username` VARCHAR(32) NOT NULL COMMENT '登录名,唯一', `password` VARCHAR(64) NOT NULL COMMENT '密码摘要,非明文', `salt` VARCHAR(32) DEFAULT NULL COMMENT '盐值,加盐摘要时使用', `email` VARCHAR(64) DEFAULT NULL COMMENT '邮箱,找回密码用', `phone` VARCHAR(20) DEFAULT NULL COMMENT '手机号', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1正常 0禁用', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';

字段说明几处。username上的唯一索引是硬约束,比在 Java 里先查再插可靠得多——两个请求同时注册同一个名字,先查后插的写法会双双通过检查,最后插进去两条,只有数据库的唯一索引能挡住。password长度给到 64,是因为存摘要而不是明文,MD5 是 32 位十六进制,SHA-256 是 64 位,留够空间。status字段现在用不上,但等你要做封号功能时,加字段比加表省事。字符集统一用utf8mb4,别用 utf8,后者存不了四字节字符,昵称里带个生僻字就会插入失败。

注意:不要用user之外的保留字做表名,比如order、group、key,写 SQL 时全得加反引号,漏一个就报语法错误。

3.2 DBUtil:连接、驱动与关闭

JDBC 直连的样板代码就那么几行,但写错的地方特别集中。下面这个工具类把配置外置到 properties,静态块加载驱动,并提供统一的关闭方法。

# src/main/resources/db.properties jdbc.driver=com.mysql.cj.jdbc.Driver jdbc.url=jdbc:mysql://127.0.0.1:3306/auth_demo?useUnicode=true&characterEncoding=utf8mb4&useSSL=false&serverTimezone=Asia/Shanghai jdbc.username=root jdbc.password=your_password
package com.example.auth.util; import java.io.InputStream; import java.sql.*; import java.util.Properties; public class DBUtil { private static String url; private static String username; private static String password; // 静态块只执行一次,驱动注册和配置读取放在这里最省事 static { try (InputStream in = DBUtil.class.getClassLoader() .getResourceAsStream("db.properties")) { Properties props = new Properties(); props.load(in); Class.forName(props.getProperty("jdbc.driver")); url = props.getProperty("jdbc.url"); username = props.getProperty("jdbc.username"); password = props.getProperty("jdbc.password"); } catch (Exception e) { // 配置加载失败属于启动期致命错误,直接抛出,别吞 throw new ExceptionInInitializerError("数据库配置加载失败: " + e.getMessage()); } } /** 获取连接,调用方负责关闭 */ public static Connection getConnection() throws SQLException { return DriverManager.getConnection(url, username, password); } /** 按 结果集 -> 语句 -> 连接 的顺序关闭,任何一个为 null 都跳过 */ public static void close(ResultSet rs, Statement stmt, Connection conn) { if (rs != null) { try { rs.close(); } catch (SQLException ignored) {} } if (stmt != null) { try { stmt.close(); } catch (SQLException ignored) {} } if (conn != null) { try { conn.close(); } catch (SQLException ignored) {} } } }

逐段看。静态块里用getResourceAsStream从 classpath 读配置,而不是写死绝对路径,这样打成 war 包部署到任何机器上都能找到文件。Class.forName在 JDBC 4.0 之后其实可以省略,驱动 jar 里有 SPI 自动注册,但老代码和部分容器环境下显式加载更稳,留着不亏。URL 里的serverTimezone=Asia/Shanghai是给 MySQL 8.x 驱动用的,不写会报时区无法识别;useSSL=false是本地开发关掉证书校验,生产环境该开还得开。

close方法里三层 try-catch 看着啰嗦,但这是必须的。连接关闭抛异常不能影响结果集已经拿到的数据,也不能让上层业务因为关闭失败而中断。更现代的写法是try-with-resources,下面 DAO 里用的就是它,能自动关闭且代码更短。

3.3 UserDao:三个方法覆盖注册登录

DAO 层的方法不在多,够用就行。注册要插入,登录要按用户名查,改密码要更新,三个方法撑起主要场景。

package com.example.auth.dao; import com.example.auth.entity.User; import com.example.auth.util.DBUtil; import java.sql.*; public class UserDao { /** 按用户名查用户,查不到返回 null */ public User findByUsername(String username) { String sql = "SELECT id, username, password, salt, email, status " + "FROM `user` WHERE username = ?"; // try-with-resources 自动关闭,顺序是反的:conn -> stmt -> rs try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, username); // 参数化,杜绝拼接注入 try (ResultSet rs = ps.executeQuery()) { if (rs.next()) { User u = new User(); u.setId(rs.getLong("id")); u.setUsername(rs.getString("username")); u.setPassword(rs.getString("password")); u.setSalt(rs.getString("salt")); u.setEmail(rs.getString("email")); u.setStatus(rs.getInt("status")); return u; } } } catch (SQLException e) { throw new RuntimeException("查询用户失败: " + username, e); } return null; } /** 插入新用户,返回自增主键;用户名重复时由唯一索引抛异常 */ public long insert(User user) { String sql = "INSERT INTO `user`(username, password, salt, email, phone) " + "VALUES(?, ?, ?, ?, ?)"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) { ps.setString(1, user.getUsername()); ps.setString(2, user.getPassword()); ps.setString(3, user.getSalt()); ps.setString(4, user.getEmail()); ps.setString(5, user.getPhone()); ps.executeUpdate(); try (ResultSet keys = ps.getGeneratedKeys()) { return keys.next() ? keys.getLong(1) : -1L; } } catch (SQLException e) { // 1062 是 MySQL 唯一键冲突的错误码,交给上层转成"用户名已存在" if (e.getErrorCode() == 1062) { throw new IllegalStateException("用户名已存在"); } throw new RuntimeException("注册失败", e); } } /** 更新密码,同时刷新盐值 */ public int updatePassword(long userId, String password, String salt) { String sql = "UPDATE `user` SET password = ?, salt = ? WHERE id = ?"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, password); ps.setString(2, salt); ps.setLong(3, userId); return ps.executeUpdate(); } catch (SQLException e) { throw new RuntimeException("修改密码失败", e); } } }

findByUsername里最能说明问题的是PreparedStatement加setString。如果写成"WHERE username = '" + username + "'",用户输入' OR '1'='1就能绕过密码直接登录,这是最经典的注入姿势,参数化之后占位符会被驱动转义,注入字符串只会被当成普通内容去匹配。executeQuery返回的结果集用嵌套 try-with-resources 包住,方法结束时三层资源自动关闭,不会出现"连接忘了关,跑几十次就卡死"的情况。

insert里用了Statement.RETURN_GENERATED_KEYS,插入后能把自增 id 拿回来,注册成功后想直接帮用户登录时用得上。捕获SQLException之后判断getErrorCode() == 1062,这是 MySQL 唯一键冲突的固定错误码,转成IllegalStateException让 Service 层去决定提示文案,DAO 不掺业务语义。

updatePassword同时更新盐,因为每次改密码重新生成盐更安全,旧盐只对旧摘要有效,留着没什么意义。

4. 主流程走查:注册四道校验与登录 Session 的完整链路

4.1 注册表单与四道校验

注册页是最普通的表单,但字段的 name 必须和 Servlet 里取参的 key 严格一致,大小写错了就是null,这是新手最常见的翻车点之一。

<!-- register.jsp --> <form action="${pageContext.request.contextPath}/register" method="post"> <label>用户名</label> <input type="text" name="username" required maxlength="32"> <label>密码</label> <input type="password" name="password" required minlength="6"> <label>确认密码</label> <input type="password" name="confirmPassword" required minlength="6"> <label>邮箱</label> <input type="email" name="email"> <button type="submit">注册</button> <span class="error">${requestScope.error}</span> </form>

${pageContext.request.contextPath}取的是应用上下文路径,部署在/auth下时拼出来就是/auth/register。硬编码成/register在本地能跑,换个路径部署立刻 404,这个坑我踩过不止一次。required和minlength只是浏览器端的便利校验,关掉 JS 就失效,服务端必须再校验一遍。

下面是 RegisterServlet 的核心逻辑,四道校验一次走完。

@WebServlet("/register") public class RegisterServlet extends HttpServlet { private final UserService userService = new UserService(); @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String username = req.getParameter("username"); String password = req.getParameter("password"); String confirm = req.getParameter("confirmPassword"); String email = req.getParameter("email"); // 校验一:非空与格式,用户名限定字母数字下划线 4-20 位 if (username == null || !username.matches("^\\w{4,20}$")) { req.setAttribute("error", "用户名需为 4-20 位字母数字下划线"); req.getRequestDispatcher("/register.jsp").forward(req, resp); return; } // 校验二:两次密码一致 if (!password.equals(confirm)) { req.setAttribute("error", "两次输入的密码不一致"); req.getRequestDispatcher("/register.jsp").forward(req, resp); return; } // 校验三:长度下限,规则写在 Service 里便于复用 try { userService.register(username, password, email); } catch (IllegalStateException e) { // 校验四:用户名唯一,由数据库唯一索引兜底后抛上来 req.setAttribute("error", e.getMessage()); req.getRequestDispatcher("/register.jsp").forward(req, resp); return; } // 注册成功走重定向,避免刷新页面重复提交 resp.sendRedirect(req.getContextPath() + "/login.jsp?registered=1"); } }

四道校验分别管格式、一致性、业务规则、唯一性。前两道在 Servlet 里做是因为它们只依赖请求参数;第三第四道下沉到 Service,是因为改密码、后台建号这些场景也要复用同样的规则。失败时用forward而不是redirect,因为要把错误信息带回去,forward 能共享 request 域;成功时用redirect,防止用户按 F5 重复提交表单——这是 POST-Redirect-GET 模式,少了这一步,注册接口能被刷新键反复触发。

Service 层对应的方法大概长这样,密码在这里完成加盐摘要,Servlet 全程不接触明文存储。

public class UserService { private final UserDao userDao = new UserDao(); public void register(String username, String password, String email) { if (password.length() < 6) { throw new IllegalArgumentException("密码至少 6 位"); } // 先查一次给出友好提示,真正的唯一性由数据库索引保证 if (userDao.findByUsername(username) != null) { throw new IllegalStateException("用户名已存在"); } String salt = PasswordUtil.randomSalt(); User user = new User(); user.setUsername(username); user.setSalt(salt); user.setPassword(PasswordUtil.hash(password, salt)); // 存摘要不存明文 user.setEmail(email); userDao.insert(user); } }

4.2 登录:密码比对与 Session 写入

登录流程比注册短,但出错点更多,因为涉及会话状态。核心是三件事:查到用户、比对密码、把用户写进 Session。

@WebServlet("/login") public class LoginServlet extends HttpServlet { private final UserService userService = new UserService(); @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String username = req.getParameter("username"); String password = req.getParameter("password"); User user = userService.login(username, password); if (user == null) { // 统一提示,不区分"用户不存在"和"密码错误",避免撞库探测 req.setAttribute("error", "用户名或密码错误"); req.getRequestDispatcher("/login.jsp").forward(req, resp); return; } if (user.getStatus() == 0) { req.setAttribute("error", "账号已被禁用"); req.getRequestDispatcher("/login.jsp").forward(req, resp); return; } // 登录成功必须换一个新 Session,防止会话固定攻击 HttpSession oldSession = req.getSession(false); if (oldSession != null) { oldSession.invalidate(); } HttpSession session = req.getSession(true); session.setAttribute("loginUser", user); session.setMaxInactiveInterval(30 * 60); // 30 分钟无操作失效 resp.sendRedirect(req.getContextPath() + "/views/home.jsp"); } }

userService.login内部做的事是:按用户名查记录,用记录里的 salt 对用户输入重新算摘要,再和库里的 password 做equals比较。这里有个细节值得强调,字符串比较必须用equals而不是==,==比的是引用地址,两个内容相同的字符串对象未必相等,这个 bug 表现为"明明密码对却登录失败",而且时灵时不灵,非常玄学。

session.setAttribute("loginUser", user)之后,所有页面都能用 EL 表达式${sessionScope.loginUser.username}取到当前登录人。setMaxInactiveInterval单位是秒,30 分钟是后台系统的常规值,面向公众的应用可以缩短到 15 分钟。登录前先invalidate旧会话再新建,是为了防会话固定:攻击者先拿到一个未登录的 sessionId 诱导你使用,你登录后如果沿用同一个 id,他就直接获得了登录态。

4.3 LoginFilter:把未登录请求挡在门外

Session 写进去只是第一步,还得有地方检查它。如果在每个 JSP 顶部都写一段"没登录就跳转"的 Java 代码,二十个页面就要改二十遍,加一个公开页面还要删二十遍。过滤器把这件事收成一处。

@WebFilter("/*") public class LoginFilter implements Filter { // 白名单:登录、注册、静态资源、错误页放行 private static final List<String> WHITE_LIST = Arrays.asList( "/login", "/register", "/login.jsp", "/register.jsp", "/css/", "/js/", "/images/", "/error" ); @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req = (HttpServletRequest) request; HttpServletResponse resp = (HttpServletResponse) response; // 去掉上下文路径再比对,否则部署在 /auth 下时永远匹配不上 String path = req.getRequestURI().substring(req.getContextPath().length()); boolean white = WHITE_LIST.stream().anyMatch(path::startsWith); if (white) { chain.doFilter(request, response); return; } HttpSession session = req.getSession(false); if (session == null || session.getAttribute("loginUser") == null) { resp.sendRedirect(req.getContextPath() + "/login.jsp?redirect=" + URLEncoder.encode(path, "UTF-8")); return; } chain.doFilter(request, response); } }

三个地方容易写错。getRequestURI()拿到的路径带上下文,必须减掉getContextPath()才是应用内路径,不减的话白名单永远不命中。getSession(false)表示没有会话就返回 null,不要新建,用getSession()会在每次未登录访问时凭空创建一堆空会话。重定向时把原始路径放进redirect参数,登录成功后可以跳回用户本来想去的页面,体验会好很多。

提示:过滤器里不要调resp.getWriter().write()输出一段 alert 再跳转,编码没设对就是乱码,而且响应已经提交,后面的重定向会失效。统一用sendRedirect。

5. 排查实录:登录注册里五个让人怀疑人生的坑

5.1 中文用户名变问号,注册进去全是乱码

现象:注册页输入中文昵称,数据库里查出来是???或者å¼ ä¸,登录时按原名字查不到人。

原因:编码链有三段,浏览器提交、容器解析、数据库存储,任何一段不是 UTF-8 都会断。JSP 页面没写pageEncoding,POST 请求体默认按 ISO-8859-1 解析,这是最常见的一段;数据库表建的时候用了 latin1,是另一段。

解决:三层一起改。JSP 顶部加<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" %>;过滤器里统一request.setCharacterEncoding("UTF-8")和response.setContentType("text/html;charset=UTF-8");建表语句指定DEFAULT CHARSET=utf8mb4,JDBC URL 带上characterEncoding=utf8mb4。四个地方缺一个都还会乱。另外 Tomcat 8 之后 GET 请求默认按 UTF-8 解,但老版本要在 server.xml 的 Connector 上加URIEncoding="UTF-8",这个参数不查文档根本想不到。

5.2 跑几十次之后页面卡死,重启 Tomcat 才恢复

现象:开发阶段反复注册登录,跑一阵子后所有请求都卡住,日志里冒出Too many connections或者干脆没反应。

原因:Connection 用完没关。JDBC 的连接是有限资源,MySQL 默认最大连接数 151,每次请求泄漏一个,一百多次之后新请求拿不到连接就阻塞在那里。常见的泄漏写法是在 DAO 里conn = DBUtil.getConnection()之后,方法中途return,关闭代码在最后一行,异常路径和提前返回路径全都漏掉。

解决:全部改用 try-with-resources,让编译器保证关闭;临时排查可以在 DBUtil 里打印连接创建堆栈,跑一轮就能看出哪个方法在漏。改完用SHOW PROCESSLIST看连接数是否稳定回落,正常应该在个位数。

5.3 明明密码正确却登录失败,改天又自己好了

现象:同一套账号密码,有时候能登进去,有时候提示错误,重启应用后又正常。

原因:两个经典问题叠在一起。一是字符串用==比较,两个内容相同的 String 对象引用不同,结果取决于字符串常量池有没有命中,所以时灵时不灵;二是数据库字段用CHAR而不是VARCHAR,CHAR会自动补空格,取出来的密码后面带几个空格,和用户输入永远对不上。

解决:密码比对统一用equals,或者更稳的做法是先trim()再比;密码字段用VARCHAR;如果已经建了表,用SELECT LENGTH(password), password FROM user看一眼长度是不是比实际多,能立刻定位。

5.4 本地跑得好好的,部署到服务器就 404

现象:IDEA 里跑一切正常,打成 war 包丢到服务器,访问首页 404 或者所有链接都跳错地方。

原因:路径写死了。表单 action 写/login,重定向写/home.jsp,本地部署在根路径看不出问题,服务器上部署在/auth下就以为什么都对不上。另一种情况是 war 包名和访问路径不一致,比如打出来叫auth-1.0.war,访问路径就是/auth-1.0。

解决:所有路径统一用request.getContextPath()拼,JSP 里用${pageContext.request.contextPath},Servlet 里用req.getContextPath()。war 包名在 pom 里用<finalName>固定下来,别让版本号混进访问路径里。

5.5 数据库里能直接看到所有人的密码

现象:翻 user 表,密码一栏是123456这种明文,任何人拿到数据库权限就等于拿到了所有账号。

原因:为了调试方便直接存了原文,或者用了 MD5 但没加盐,彩虹表一查就出来。

解决:改成加盐摘要,每位用户单独生成随机盐,存hash(password + salt)。更稳的做法是用 PBKDF2 或 BCrypt 这类慢哈希,虽然多几行代码,但抗暴力破解能力强很多。已经上线的系统迁移时可以让老用户下次登录成功时静默重算一次摘要,不用强制所有人改密码。

6. 二次开发:从课堂 Demo 到可维护账号模块的四步改造

这套代码跑通之后,多数人的下一步是往上加功能,加着加着就乱了。我一般按四步改造,顺序不能颠倒。

第一步,把密码存储换掉。MD5 加盐只是及格线,真正要抗住拖库得用慢哈希。下面这段可以直接替换掉原来的PasswordUtil,用 JDK 自带的 PBKDF2,不引第三方包。

public class PasswordUtil { private static final int ITERATIONS = 10000; // 迭代次数,越大越慢越安全 private static final int KEY_LENGTH = 256; public static String hash(String raw, String salt) { try { PBEKeySpec spec = new PBEKeySpec(raw.toCharArray(), salt.getBytes(StandardCharsets.UTF_8), ITERATIONS, KEY_LENGTH); byte[] key = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256") .generateSecret(spec).getEncoded(); return Base64.getEncoder().encodeToString(key); } catch (Exception e) { throw new IllegalStateException("密码摘要失败", e); } } }

ITERATIONS决定计算耗时,一万次在普通机器上是几十毫秒,用户无感知,但暴力破解的成本被放大了四个数量级。改完之后要给user表加一个algo字段标记摘要算法,否则老用户的 MD5 值和新用户的 PBKDF2 值混在一起没法验证。

第二步,把DriverManager换成连接池。每次请求新建连接的开销在小流量下看不出来,一上压力测试就原形毕露。HikariCP 或 Druid 都行,配置里的maximumPoolSize我一般设成 CPU 核数乘 2 再加磁盘数,别贪大,连接数超过数据库承受能力反而更慢。

第三步,把 JSP 里的 Java 代码清干净。用 JSTL 的<c:if>、<c:forEach>替代<% %>,页面只负责展示,逻辑全在 Servlet 和 Service 里。这一步做完,前端同事才有可能接手改样式。

第四步,补上验证码和登录失败次数限制。这两件事都是防撞库的,login_fail表记录每个用户名最近十分钟的失败次数,超过五次就要求输验证码,比单纯弹个警告有用得多。

改造过程中验证手法也要跟上。我习惯每次改完密码相关逻辑,用手工构造的四组数据跑一遍:正确密码、错误密码、空密码、超长密码,四组结果都符合预期才算过。这套动作花不了五分钟,但能挡住绝大多数低级错误。

说个教训。早年我图省事,觉得加盐摘要这种"以后再说"的东西不着急,直接把明文密码推上了测试环境,结果那份数据被导出来做演示,几十个人的常用密码全露在表格里。从那以后我每次建用户表,第一件事就是把密码字段长度设成 64 以上、把摘要工具类写好,再开始写注册接口——顺序反了,后面补起来要动表、动数据、动所有登录入口,代价大得多。希望帮到你。

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

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

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

立即咨询