从零实现Java Web登录功能:Servlet+JSP+JDBC全流程与踩坑实录

📅 发布时间:2026/10/18 2:13:49
从零实现Java Web登录功能:Servlet+JSP+JDBC全流程与踩坑实录
做 Java Web 开发早晚都要过登录页面这道坎。别小看这个“输入用户名密码点一下按钮”的流程真正动手从零写一遍遇到的坑比想象中多得多。这篇文章就记录我从零开始用 Java 写一个登录页面的完整过程包括设计思路、前后端代码、数据库交互以及那些让我调试到深夜的典型问题。如果你正准备独立完成一个带登录功能的小系统这篇文章应该能帮你少走不少弯路。1. 写登录页面前先把需求和方案盘清楚1.1 这个登录页面到底要解决什么问题登录页面的核心任务就三个收集用户凭证、验证身份、维持登录状态。听起来简单但把这三个环节拆开每一环都有不少细节。首先是收集凭证说白了就是用户名和密码的输入框加一个提交按钮。这里要考虑用户输错了怎么办、要不要做前端校验、密码框要不要支持切换明文显示。为了用户体验这些交互细节在产品里通常都会有要求。其次是验证身份。前端把用户名密码交给后端后端要去数据库里查这个人是否存在、密码是否正确。这里牵涉到密码的存储方式如果数据库里存的是明文密码那就是在给自己埋雷。最后是维持登录状态。用户登录成功后不能每点一个页面就重新登录一次这就需要用到会话机制。Java Web 里最经典的就是 HttpSession我们可以把用户信息塞进 Session 里后面的请求通过 Session 判断用户是否登录。我自己这次做的是一个典型的单体 Web 应用前端用 JSP 页面后端用 Servlet 处理请求数据层用 JDBC 操作 MySQL 数据库。选这套组合不是因为它最时髦而是因为它最能暴露基本功问题。现在很多教程一上来就是 Spring Boot 一把梭反而把 Servlet 生命周期、请求响应模型这些基础概念给掩盖了。如果你是想练内功用 Servlet JSP 走一遍登录流程收获会更大。1.2 常见的实现方案选型对比做登录功能摆在面前的路有好几条。我在动手前也列了一张对比表把几种主流方案的优缺点和适用场景理了一遍实现方案优点缺点适用场景JSP Servlet JDBC技术栈简单直接能看清底层原理代码冗余页面与逻辑耦合维护成本高学习练手、小型传统项目Spring MVC MyBatis分层清晰开发效率高社区生态成熟配置繁琐学习曲线较陡中小型企业项目Spring Boot Spring Data JPA起步快约定优于配置适合快速迭代抽象层级多出了问题排查链路长微服务、快速交付场景纯前后端分离Vue Spring Boot用户体验好前后端可独立开发需要处理跨域、Token 鉴权等额外问题中大型系统、专项前端团队考虑到我是为了吃透原理最终选了第一种。如果你是在公司里赶进度那我建议直接上 Spring Boot效率完全不一样。说到底选型没有绝对的“最好”只有“最合适”。2. 开发前的环境准备与配置细节2.1 JDK、IDE、依赖包的版本选择我这次用的环境是 JDK 8、IntelliJ IDEA、Maven 管理依赖外加一个 Tomcat 9 作为运行容器。之所以选 JDK 8不是因为懒而是 JDK 8 在 Servlet 容器兼容性上非常稳网上踩坑资料也多遇到诡异问题容易搜到答案。这里有一个很容易忽略的细节IntelliJ IDEA 里创建的 Web 项目如果你是用 Maven记得在 pom.xml 里声明 servlet-api 和 jsp-api 的依赖不然代码里 import javax.servlet 会直接报红。但如果你是在 IDEA 的 Java Enterprise 项目里新建的IDEA 已经帮你把 Tomcat 所需的库关联进来了大部分情况下不需要手动加依赖。我还是强烈建议用 Maven 来管理项目哪怕是一个极小型的登录 Demo。因为后续如果要引入 JSON 解析库比如 Jackson、数据库连接池比如 Druid在 Maven 里加一行依赖就行比自己手动下载 jar 包扔进 WEB-INF/lib 目录要省心得多。2.2 数据库准备与用户表设计登录功能对数据库的要求并不复杂一张用户表就够了。但“简单”不等于“随便设计”我见过很多新手把表结构建得特别随意后面出现问题又得回头改表非常折腾。我这里设计了这样一张用户表CREATE TABLE t_user ( id INT PRIMARY KEY AUTO_INCREMENT COMMENT 主键ID, username VARCHAR(64) NOT NULL UNIQUE COMMENT 用户名唯一索引, password VARCHAR(128) NOT NULL COMMENT 密码存储的是加密后的密文, nickname VARCHAR(64) DEFAULT NULL COMMENT 昵称登录成功后展示用, status TINYINT DEFAULT 1 COMMENT 账号状态1-正常0-禁用, create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 更新时间 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表;几个设计要点的思考过程用户名加 UNIQUE 约束这既是业务要求同一系统里用户名不能重复也是查询优化手段后续登录时按用户名查询能命中索引。密码字段用 VARCHAR(128)不是因为我密码只有 128 位长而是因为我准备用哈希算法存储摘要摘要长度通常就是 64 个十六进制字符留出余量。status 字段看起来多余但在真实系统里非常必要。账号被封禁、被停用的时候这一位就能挡住登录请求。时间字段两个都加上后面排查问题、做数据统计都用得上。建完表之后记得插入一条测试数据方便后面联调。我当时就插入了一条 username 为admin的测试记录密码我预先用 MD5 加密工具生成了一串密文存进去。直接用 SQL 插入摘要值是为了验证后端校验流程是否走通。3. 登录页面前端实现从静态页到动态交互3.1 表单结构与样式设计的踩坑记录前端页面我写的是 login.jsp放在项目的 webapp 目录下面。整体结构就是一个 form 表单包含用户名输入框、密码输入框、一个登录按钮外加一个用于显示错误信息的区域。如果你以为这部分很快就能搞定那你就错了。我在样式上花的精力一点都不比后端少。比如最简单的“输入框自动聚焦”按用户体验规范页面加载后光标应该自动定位在用户名输入框里我一开始忘记加 autofocus 属性用户登录时还得手动点一下输入框这个体验差距在验收时直接被指出来了。还有密码框的 placeholder 提示文案如果写“请输入密码”移动端输入法可能会把大小写键盘弹出来影响输入效率。后来我改成了“请输入6-16位密码”既给了输入约束提示也减少了用户输错的可能。整个表单我最开始做得比较粗糙后来参考了一些成熟登录页面的布局方式加了圆角边框、输入框阴影、按钮 hover 变色这些样式。前端这一块并不是简单堆代码而是要对用户操作习惯有一定敏感度。3.2 前端校验逻辑的实现要点页面上的校验核心目标是拦住“非空”这个最基本的问题。用户没填用户名就点击登录这属于一眼就能看出来的低级错误完全没有必要把请求发到后端去。我在 login.jsp 里嵌了一段 JavaScript 校验大概思路是这样的function validateForm() { var username document.getElementById(username).value.trim(); var password document.getElementById(password).value.trim(); if (username || password ) { document.getElementById(errorMsg).innerText 用户名和密码不能为空; return false; } if (password.length 6 || password.length 16) { document.getElementById(errorMsg).innerText 密码长度必须在6到16位之间; return false; } return true; }这段代码里有两个容易踩的小坑获取输入框的值时一定要先用 trim() 去掉首尾空格。用户复制粘贴用户名时很容易带一个空格如果不处理后端数据库里明明有这个用户却死活登录不上去这种问题排查起来特别费劲。校验失败时要返回 false并且要把错误提示显示出来。如果返回 true表单就会直接提交后端可就白忙活了。前端校验只是“体验层面”的第一道关卡真正的安全校验必须放在后端。这是我一直强调的一个原则前端的 JS 代码都是可以被篡改或者绕过的绝对不能把前端校验当作安全措施来依赖。3.3 Servlet 接收请求与处理流程用户点击登录按钮后form 表单以 POST 方式提交数据到服务端。这里为什么用 POST 而不是 GET原因很简单GET 请求会把参数拼在 URL 后面用户名是肉眼可见了密码也会直接暴露在地址栏、浏览器历史记录、服务端访问日志里这是绝对不可接受的。所以我坚定的选择了 POST。对应的 Servlet 我取名为 LoginServlet在 web.xml 或者用注解 WebServlet(/login) 来做映射。这里我选择用注解方式省去了 XML 里繁琐的配置。Servlet 的核心逻辑是这样的WebServlet(/login) public class LoginServlet extends HttpServlet { private static final long serialVersionUID 1L; Override protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { // GET 请求直接转发到登录页面 request.getRequestDispatcher(/login.jsp).forward(request, response); } Override protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { request.setCharacterEncoding(UTF-8); String username request.getParameter(username).trim(); String password request.getParameter(password).trim(); // 服务端二次校验参数非空 if (username null || username.isEmpty() || password null || password.isEmpty()) { request.setAttribute(error, 用户名和密码不能为空); request.getRequestDispatcher(/login.jsp).forward(request, response); return; } UserDao userDao new UserDao(); User user userDao.findByUsername(username); if (user null) { request.setAttribute(error, 用户不存在); request.getRequestDispatcher(/login.jsp).forward(request, response); return; } // 校验密码 String encryptedPassword MD5Util.md5(password); if (!user.getPassword().equals(encryptedPassword)) { request.setAttribute(error, 密码错误); request.getRequestDispatcher(/login.jsp).forward(request, response); return; } // 密码正确允许登录 HttpSession session request.getSession(); session.setAttribute(currentUser, user); response.sendRedirect(request.getContextPath() /welcome.jsp); } }这段代码是登录流程的主干写起来行云流水但在实际开发过程中我在几个环节卡了不少时间后面我会专门展开讲。这里有一个非常重要的细节POST 请求接收中文参数时乱码几率特别大所以我第一件事就是调用request.setCharacterEncoding(UTF-8)并且放在读取任何参数之前。这个调用的位置很关键如果你先读了参数再去设置编码那基本就晚了因为 Tomcat 在解析请求体的时候已经按照默认的 ISO-8859-1 把数据解码完了。4. 数据访问层JDBC 连接与密码加密的正确姿势4.1 手写 JDBC 工具类的几个关键取舍这个项目的持久层我用了最朴素的 JDBC没有引入 MyBatis 这些框架。自己手写一遍就能体会到一个道理框架是在帮你省时间但省下来的前提是你自己知道它帮你省了什么。我写了一个 DBUtils 类用来管理数据库连接核心代码里包含这几部分通过 Class.forName 加载驱动类然后使用 DriverManager.getConnection 获取连接。把数据库连接地址、用户名、密码放在配置文件里方便切换环境。我当时用了 db.properties 来保存这些信息再用 Properties 类来读取。每次操作完数据库在 finally 块里释放资源关闭的顺序是先 ResultSet、再 Statement、最后 Connection。你问我为什么不直接用连接池其实真实项目里肯定要用 Druid 或 HikariCP我自己这里接触过一段时间的 HikariCP知道它的优势。但在学习阶段先用原生 JDBC 把连接、释放资源的逻辑调通再去理解连接池会更容易。如果你现在是做公司项目千万不要学我这样直接用 DriverManager生产环境连接数是扛不住的。4.2 密码加密千万不要明文存储这是我在这类项目里最想强调的一件事密码在数据库里绝对不能存明文。很多新手写登录功能数据库表里的 password 字段直接就是123456然后登录验证时一句select * from t_user where username admin and password 123456完事。这在真实世界里属于安全事故级别的失误因为任何人只要看了一眼数据库所有用户的密码就全泄露了。我是用 MD5 做密码摘要的。虽然 MD5 在现在的一些安全标准里已经不算强哈希了但在学习项目里它的原理简单代码量少足够让你理解“单向哈希”这个概念。我写了一个 MD5Util 工具类把明文密码通过 MD5 算法转换成一串固定长度的十六进制字符存到数据库里。注册的时候用户输入的明文密码经过 MD5 转换后入库登录的时候把用户输入的密码也做一次 MD5 转换再和数据库里的摘要做比对。这样一来数据库里永远不会出现明文密码。也许你会问MD5 不是已经被破解了吗这里我想补充一点真实项目里更稳妥的做法是使用 BCrypt 或 PBKDF2 这类“加盐 慢哈希”算法。MD5 的主要问题在于它算得太快容易被暴力碰撞和彩虹表攻击。但它的思路是对的只是强度不够。如果你在做生产级项目直接改成 BCrypt 就好原理是一致的。4.3 查询用户信息的 DAO 层实现DAO 层我命名为 UserDao只放了两个核心方法findByUsername 用来按用户名查用户checkPassword 用来做密码比对。其中 findByUsername 的 SQL 是SELECT id, username, password, nickname, status FROM t_user WHERE username ?这里一定要用预编译的 PreparedStatement不要用字符串拼接 SQL。我见过有人在查询里直接写select * from t_user where username username 这在学习阶段一旦被恶作剧地输入 or 11这种值整个用户表就能被拖出来。SQL 注入不是传说它就是这么实实在在的原理。PreparedStatement 的好处不仅仅是防注入它在预编译时就把 SQL 的骨架固定下来了参数占位符 ? 会被视为数据而不是 SQL 的一部分所以攻击者输入的恶意内容再花哨也没法改变 SQL 结构。4.4 处理查询结果集的注意事项查询结果出来后我们从 ResultSet 里取值这里有一个新手必经的坑对 ResultSet.next() 的理解。新手容易犯的问题是先判断 rs.next() 是否还有记录如果有就取出数据然后再动手。如果查询到的结果集是空rs.next() 返回 false代码就不会进入取数逻辑。看似理所当然但实际写的时候很容易忘记第一步判断导致在空结果集上直接调 getString 而报异常。这里的常规写法是public User findByUsername(String username) { Connection conn null; PreparedStatement ps null; ResultSet rs null; User user null; try { conn DBUtils.getConnection(); String sql SELECT id, username, password, nickname, status FROM t_user WHERE username ?; ps conn.prepareStatement(sql); ps.setString(1, username); rs ps.executeQuery(); if (rs.next()) { user new User(); user.setId(rs.getInt(id)); user.setUsername(rs.getString(username)); user.setPassword(rs.getString(password)); user.setNickname(rs.getString(nickname)); user.setStatus(rs.getInt(status)); } } catch (Exception e) { e.printStackTrace(); } finally { DBUtils.close(rs, ps, conn); } return user; }数据库里如果有的字段本身可能为 NULL用 getString 没问题但如果你是做数值统计就要注意用合适的方法避免返回 NULL 导致后续出现空指针异常。5. 会话管理登录成功之后如何记住用户5.1 从 HttpSession 到 Cookie 的机制理解登录成功之后要做的第一步事情就是把用户信息放进 Session 里。Java 的 HttpSession 底层其实是靠 Cookie 里面的 JSESSIONID 来维持的。当服务端调用 request.getSession() 时服务器会在内存里创建一个会话对象并把一个唯一的会话 ID 通过响应的 Set-Cookie 头带回浏览器。浏览器的请求位置里会带上这个 JSESSIONID服务端再根据这个 ID 找到之前创建的那个 Session 对象然后读出你存在里面的用户信息。这个过程全自动完成。前端会问这不就是 Cookie 吗放在 Cookie 里的会话 ID 如果被偷了怎么办所以我在项目里就想到 Session 一定会过期设置了 Session 的超时时间避免用户长时间不操作但一直保持着登录状态。我设置了 30 分钟其实这个值可以按业务需求调整。5.2 登录成功后跳转策略的选择登录成功后我是用response.sendRedirect()来跳转到 welcome.jsp 页面而不是用request.getRequestDispatcher().forward()。这里两者的区别特别重要也有不少人在这里翻车。sendRedirect 是一次重定向服务器告诉浏览器“你去访问这个新地址吧”浏览器再发出第二次请求。地址栏会变成新的 URL刷新页面也不会重新提交表单。而 forward 是服务端内部转发地址栏不变刷新的时候浏览器可能会重复提交上一次的 POST 请求造成“重复登录”之类的奇怪问题。所以我坚持用重定向这种方案。使用重定向还有一个好处因为它是全新的请求之后浏览器访问欢迎页面时Servlet 那边再去读 Session 判断登录状态逻辑上更加清晰。5.3 登录状态的统一校验有了 Session 还不够还得做统一登录校验。也就是说用户可以直接在地址栏敲 welcome.jsp 的地址绕过登录页面进入系统这当然不行。我建议在项目里加一个过滤器 Filter通过它拦截所有 .jsp 请求先检查 Session 里的 currentUser 是否存在。如果存在就放行如果不存在就重定向到 login.jsp。过滤器的核心逻辑其实是比较直观的WebFilter(/*) public class LoginFilter implements Filter { Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req (HttpServletRequest) request; HttpServletResponse resp (HttpServletResponse) response; // 获取当前请求的路径 String uri req.getRequestURI(); // 如果是登录页面或登录请求以及静态资源直接放行 if (uri.endsWith(login.jsp) || uri.endsWith(/login) || uri.contains(static/)) { chain.doFilter(request, response); return; } // 判断 Session 里是否存在用户 HttpSession session req.getSession(false); if (session ! null session.getAttribute(currentUser) ! null) { chain.doFilter(request, response); } else { resp.sendRedirect(req.getContextPath() /login.jsp); } } }我在过滤器这里踩过一个时间不短的坑就是未登录访问欢迎页时出现了“无限重定向”的问题。原因是我 Filter 拦下了 login.jsp 请求但因为判断条件没写好导致 login.jsp 也被拦截后重定向到自身浏览器一直循环跳转。解决办法就是简化放行逻辑把登录页面本身排除在外。5.4 退出登录的正确处理方式退出登录看似简单其实也有讲究。按钮点击后要做的不是清掉某一个 Session 属性就完事最稳妥的方式是从 Session 中移除用户属性调用 session.invalidate() 让整个会话失效重定向回登录页面这里我要特别提醒为什么一定要调用 invalidate()如果你只是 removeAttribute那会话 ID 本身可能还保持有效继续用旧的 JSESSIONID 可能还会关联到旧的会话内容。把整个会话作废不仅可以清掉用户数据也是减少会话固定类风险的一个手段。退出按钮我用的是一个简单的 GET 请求访问 LogoutServlet然后做以上三步处理。当然严格来讲退出这种操作也可以要求 POST 请求用 GET 会不会有安全隐患比如发起一个图片链接就能让人退出实际上这里影响有限但你要是做严谨的系统还是建议用 POST 请求来处理退出这种操作。6. 书写过程中真正卡住我的难点与排查实录6.1 中文乱码问题一次请求就够让人绝望我第一次用 POST 提交表单时发现提示信息无论如何都是乱码页面上英文完全正常中文全是问号排查过程让当时的我有点着急。后来定位到问题我是在 servlet 代码里设置的编码但 Tomcat 解析请求体时如果发现没有显式声明编码会默认按 ISO-8859-1 来解码。我虽然写了 request.setCharacterEncoding(UTF-8)但写的位置太晚了参数已经被读走再设置就无效了。后来我把这行代码提到了代码最开头然后规定项目里所有页面无论是 JSP 还是 Servlet 的响应都必须统一使用 UTF-8 编码。为了避免下次踩坑我总结了一个硬性规范JSP 头部声明% page contentTypetext/html;charsetUTF-8 languagejava %Servlet 里 request.setCharacterEncoding(UTF-8) 必须放在最前response 设置resp.setContentType(text/html;charsetUTF-8)数据库连接串里加上?useUnicodetruecharacterEncodingutf8表结构统一使用 utf8mb4只要编码链路从头到尾统一乱码问题基本就不会再出现。6.2 数据库驱动未加载的经典报错在项目启动时我遇到过一个非常经典的报错ClassNotFoundException: com.mysql.jdbc.Driver。这个错误看起来很简单但当时的排查过程并不顺利。原因是我虽然把 MySQL 驱动包的依赖加到了 Maven 的 pom.xml 里但 Tomcat 运行时并没有从依赖里加载到这个类。后来我发现如果用的不是 Maven 构建的 war 包进行部署而是 IDEA 里直接用 Tomcat 启动有时依赖没有被打进 WEB-INF/lib 目录。解决办法很暴力也很有效把驱动 jar 包直接放到 WEB-INF/lib 目录下或者确保 pom.xml 里的依赖最终被打进 war 包的 lib 文件夹里。这个排查过程让我意识到编译通过不代表运行时能加载到类两者的 classpath 是完全不同的概念。6.3 空指针异常ResultSet 上的低级错误我在测试用户不存在的情况时控制台直接抛出了 NullPointerException。当时第一反应是 SQL 语句写错了最后才发现是自己没写 rs.next() 的判断直接去拿结果集数据。这个问题的教训很直接从 ResultSet 里取数据之前必须先确认有没有数据可拿。而且更要注意的是findByUsername 方法返回的 User 对象可能为 null在 Servlet 里拿到这个对象后调用任何 getter 之前都要判断一下非空。我在 Servlet 里加了这样一层防御性判断if (user null) { // 用户不存在回到登录页并提示 }这么一改空指针异常就消失了。代码运行过程中空指针几乎是 Java 开发者最常见的敌人防御性编程应该成为习惯。6.4 密码比对不一致的乌龙事件有一段时间我很确定数据库里存的密码没有问题但登录就是失败对比的字符串怎么都对不上。折腾许久之后我才意识到我往数据库里手工插入数据时直接写的目标密文是 MD5 加密过但在代码中把我的测试密码明文做了加密后发现两者根本不一致原因是我在手工造数据时给密码末尾多加了一个空格。这种问题特别隐蔽因为肉眼完全看不出来数据库里的空格和 Java 字符串里的空格在视觉上没有任何区别。解决的方式就是严格统一数据的生成路径不要有人工手工造数据的随意性。再一个查询时也先做 trim 处理把脏数据挡住一层。6.5 并发登录与会话固定问题初探项目里我还考虑过一个更进阶一点的问题一个用户能不能同时在两个设备上登录如果允许要不要限制如果限制是顶掉前面一个还是直接拒绝后者这个问题在真实系统里通常是有产品规则做约束的不是码农凭空决定的。但在代码层面你可以用一张登录会话表和 userId 关联来手动管理会话状态当新登录产生时把旧会话标记为失效。我自己的项目里没有做这么复杂只是控制了单点登录策略。但我知道从这台机器的角度看Session 默认机制其实允许同一用户在多地登录因为服务端为每个浏览器都创建了全新的会话。想要限制就要在表里做文章这是另一个设计级别的话题了。7. 登录功能的安全加固与经验沉淀7.1 基础安全清单五项登录功能写完不等于登录功能安全。我自己整理了一份基础安全清单对做这类功能的同学很实用密码不能明文存储至少使用 MD5 盐或者更高强度的 BCrypt。数据库操作必须用 PreparedStatement防止 SQL 注入。所有需要保护的后台资源下统一做登录校验不能信任 URL 路径自带安全属性。Session ID 不能出现在 URL 上防止通过浏览器历史泄露确认关闭 URL 重写。登录接口要增加失败次数限制防止暴力破解。最后一条我在项目里做了尝试。通过给 Session 里加一个 loginFailCount 的属性记录失败次数达到三次时在一定时间内拒绝登录请求并且在界面上提示“尝试次数过多请稍后再试”。这虽然不能完全防住攻击但至少提高了暴力破解的成本。7.2 验证码要不要加在做登录页面时我一度纠结要不要加验证码。不加吧觉得不安全加吧又觉得实现很烦。我最后的结论是如果这个系统注册了对外的互联网访问最好加上验证码并且建议使用图形验证码或者更复杂的滑块验证。如果只是内部系统用户量也不大可以先用账户密码加失败次数限制兜底后续再做验证码升级。验证码的 Java 实现思路也并不复杂用 ImageIO 生成一张带随机字符的图片把正确的验证码存到 Session 里提交表单时比对用户输入的验证码和 Session 里存的验证码是否一致。注意比对完要立刻清除 Session 里的验证码防止重复使用。这里我要强调一个很容易被忽略的点验证码最大的价值不是防人而是防机器。加了验证码以后自动化脚本发起高频登录请求的难度明显提升这个效果是失败次数限制替代不了的。7.3 日志记录与监控思路一个合格的登录模块还应该有日志记录。谁在什么时候登录成功了谁在什么时候登录失败了失败原因是什么这些都应该有迹可循。我用 Log4j 2 在 LoginServlet 里打了几个关键节点的日志比如用户登录成功、用户密码错误、用户账号被禁用等等。日志级别我用了 Info 记录正常流程用 Warn 记录异常情况。这里有一个实际操作的细节值得提及不要在日志里打印密码。无论明文还是密文密码信息都不应该出现在日志里。日志一旦泄露密码信息等同泄露。我使用的是匿名化记录方案记录的是用户名和 IP 地址和时间密码内容一律不进入日志。7.4 数据库连接池引入的实际收益前面说我用 JDBC 直连是为了学习原理但真要把这个登录功能放到生产环境连接池是不可跳过的环节。在我写完原生 JDBC 的版本后又顺手把连接池升级成了 HikariCP应用启动时初始化最小连接数、最大连接数等参数运行效率和稳定性都明显提升。连接池的核心价值在于复用省去每次创建和销毁连接的开销。一个简单的请求如果每次都要新建数据库连接在并发量稍大一点的情况下数据库会被频繁建立连接拖垮。连接池还会帮你做连接有效性检查避免拿到已经断开的连接。引入方式也很简单HikariConfig config new HikariConfig(); config.setJdbcUrl(jdbc:mysql://localhost:3306/yourdb); config.setUsername(root); config.setPassword(root); config.setMaximumPoolSize(10); HikariDataSource dataSource new HikariDataSource(config);但要提醒的是连接池参数不是随便填的连接数设太小会排队设太大又会吃掉数据库内存。一个常见经验值是最大连接数不要超过数据库 CPU 核心数的两倍具体还要看业务复杂度。8. 最终调试心得与几点建议写完登录页面经历各种小问题又逐个解决之后我自己最深的一点体会是做功能容易做可靠的功能难。登录页面这种基础功能正是锻炼代码可靠性的绝佳场景。如果你也在写类似的登录功能我建议你做完基础流程后一定自己过一遍这些场景输入正确的用户名密码能否正常登录。输入错误的密码是否给出明确提示。用户名不存在时提示是否合理有没有泄露“用户是否存在”的敏感信息。输入空值、超长字符串、特殊字符程序是否还能稳如磐石。未登录直接访问内部页面是否会被正确拦截。登录成功后直接访问登录页是否异常跳转。退出登录后再访问内部页面是否被正确拦截。这些场景我都会在自己的验证清单里走一遍只有当每个极端情况都不出问题我才会觉得这个功能算是真正写完了。最后再分享一个小技巧排查登录问题时不要只盯着 Java 代码把数据库里的数据、浏览器的网络请求、Tomcat 的控制台日志三个视角结合起来看绝大多数问题都能快速定位。尤其是浏览器开发者工具里的网络面板你会发现很多自以为是的“前端问题”实际上是请求参数没传对或者是响应状态码背后的服务端问题。希望这篇登录页面的完整记录能帮你绕过我踩过的那些坑。写代码的路就是这样踩坑、爬出来、记住然后继续往前走。