简介这是一套面向Java Web初学者的完整入门项目以用户登录、注册、信息修改和删除四项核心功能为主线适合正在学习Servlet、JSP、JDBC及基础MVC分层的学生或自学者。包体共89个文件主要包括14个Java源码与对应的class文件、8个JSP页面、8个CSS样式、8个JavaScript脚本、3个Jar依赖库以及web.xml配置文件等压缩包大小4.67MB目录结构规范便于按控制层、实体类、DAO、Service、工具类和WebContent模块对照学习。项目采用Bootstrap前端框架搭建页面并引入jQuery处理交互登录后可通过Session管理会话状态数据库操作使用PreparedStatement预编译方式便于理解SQL防注入的基本思路。通过阅读源码并运行项目可以掌握从请求分发、业务逻辑处理到数据库增删改查的完整开发流程也能学习到JSP页面中header、footer等公共部分的复用方式。资源已有6300余人学习下载适合作为课程设计参考或实训入门素材。1. 一个“简单”的 JavaWeb 登录注册项目卡住新手的从来不是代码把“登录注册修改删除”这几个词拆开看每个功能单独拎出来都不难但放在同一个 JavaWeb 项目里跑通新手几乎都要在几个固定环节翻车要么表单提交后一片空白要么中文乱码要么查出了用户却跳转不过去要么改了密码后发现 Session 里的旧数据还在。这个标题描述的其实是一个最小但完整的 JavaWeb 闭环Servlet 接收请求、JDBC 操作数据库、JSP 渲染页面、Session 维持登录态外加对用户表的增删改查。它能解决的问题也很明确让你在没接触 Spring 全家桶之前先把 HTTP、会话、请求参数、状态管理这些地基打牢。适合两类人一类是刚学完 Java 基础、想找个项目练手的在校生另一类是能写业务代码但没独立从零搭过一个 Web 工程、想补课的在职开发者。本文不教你怎么背框架只讲怎么用最直白的方式把这个项目从零到可部署完整走一遍并说清每一步为什么这么设计。2. 技术选型为什么是 JSP Servlet JDBC而不是一上来就 Spring Boot2.1 传统三层结构的边界与价值标题里写着“简单的 javaweb 项目”所以这里我默认你还没到必须上框架的阶段。常见做法是用 JSP Servlet JDBC 做一个小型应用配合 Tomcat 运行数据库选 MySQL。这个组合在今天看起来确实“老”但它最大的价值是让 Servlet 容器帮你把 HTTP 协议细节遮住同时又把请求、响应、会话这些核心对象暴露在你面前你能亲眼看见一次请求从浏览器走到服务端再返回的全部过程。用 Spring Boot 当然也能写同样功能但自动配置把很多关键决策替你做了比如过滤器顺序、字符编码、连接池初始化出问题时排查成本反而更高。我一般会建议凡是这个项目都先用纯 Servlet 分层写一版。Controller 用 Servlet 承担Service 包一层业务逻辑DAO 里只写 SQL 操作。某个公司里带的新人一开始想把所有代码塞进一个 Servlet 里一个方法处理登录、一个方法处理注册页面跳转全靠 out.println 拼 HTML。我把它拆开后他看懂请求流向的速度反而快了一倍。所谓“简单项目”结构简单不等于逻辑可以乱恰恰相反越简单的项目越应该用清晰的分层把代码边界划出来。2.2 所需环境与基础组件清单在动手写代码前先把环境列清楚。JDK 用 8 或 11 都行这个项目没有用到高版本特性Tomcat 用 9.x 配 Servlet 4.0 足够没必要追新MySQL 5.7 或 8.0 均可IDE 用哪个顺手都行但要把“部署到 Tomcat 运行”这一套跑起来IDEA 社区版加本地 Tomcat 的配置步骤要提前确认好。整个工程不建议用 Maven 的一个简单项目手工复制 jar 包反而更直观等你熟悉了 jar 的位置和依赖关系再换 Maven 心智负担会小很多。需要准备的 jar 包有三个MySQL 驱动、JSTL 标签库、Servlet API编译期用。Tomcat 自带 servlet-api.jar路径在 Tomcat 安装目录的 lib 下编译时引入即可。JDBC 连接建议抽成一个 DBUtil 类统一管理连接和释放而不是在每个 Servlet 里重复写 Class.forName。下面是最小可用的工具类写法。import java.sql.Connection; import java.sql.DriverManager; import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Statement; public class DBUtil { private static final String URL jdbc:mysql://localhost:3306/testdb?useSSLfalsecharacterEncodingutf8serverTimezoneAsia/Shanghai; private static final String USER root; private static final String PASSWORD 你的数据库密码; static { try { Class.forName(com.mysql.jdbc.Driver); } catch (ClassNotFoundException e) { e.printStackTrace(); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } public static void close(Connection conn, Statement stmt, ResultSet rs) { try { if (rs ! null) rs.close(); } catch (SQLException ignored) {} try { if (stmt ! null) stmt.close(); } catch (SQLException ignored) {} try { if (conn ! null) conn.close(); } catch (SQLException ignored) {} } }这里有三点要说明。URL 里的 characterEncodingutf8 是保证中文不乱码的前提之一但注意这只是数据库连接层的一部分完整解决乱码还需要配置 Servlet 的请求和响应编码这在后面避坑章里会详细讲。serverTimezone 是 MySQL 8.0 的时区要求5.7 下写上也无害不写新版驱动会报错。close 方法用空 catch 忽略异常是因为关闭操作本身失败不影响主流程但连接池方案里不能这样随手关那是后话。2.3 为什么登录状态必须依赖 Session而不是反复查数据库登录功能写起来很简单比较用户名密码正确就放行。但“放行”这个动作在 Web 项目里要谨慎设计。HTTP 协议是无状态的浏览器每次请求都是“陌生人”如果你不做任何处理用户登录一次后下次刷新页面又要重新登录。常见做法是用 Cookie 配合 Session用户登录成功后把用户 ID 或用户名存进 Session服务器通过一个 JSESSIONID 的 Cookie 识别同一个浏览器的后续请求。新手容易犯的错误是把密码或整个用户对象直接塞到 Cookie 里。Cookie 存在浏览器端用户能看能改把敏感信息放进去等于把密码贴在额头上。正确做法是只往 Session 里放用户标识页面通过 Session 判断是否已登录。Session 本身存放在服务器内存里相对安全但要注意它的过期时间默认 30 分钟无操作会被回收。这个配置可以在 web.xml 里显式声明后面避坑章会提到。理解了这个机制后面所有需要判断“当前登录的是谁”的功能才写得自然。3. 建立用户表与登录注册的完整实现从建表到 Session 写入3.1 用户表的 DDL 设计字段不该多但关键约束不能少用户表是这个项目的地基字段设计不需要像大系统那么复杂但有两个原则必须守唯一标识必须有主键用户名必须加唯一约束。否则注册接口重复提交时你会查出两条相同的用户名记录。下面这个建表语句在实践中够用。CREATE TABLE user ( id INT NOT NULL AUTO_INCREMENT, username VARCHAR(50) NOT NULL, password VARCHAR(255) NOT NULL, email VARCHAR(100) DEFAULT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;密码字段用 VARCHAR(255)不是因为它需要那么长而是给后面升级密码加密算法留空间。如果你现在用明文密码50 长度确实够但一旦改成加密字符串存储长度可能超过 60到时再 ALTER 表就是多余的麻烦。username 上的唯一索引同时承担了查询加速和约束两件事注册逻辑里判断用户名是否存在的 SQL 就能直接利用这个索引。create_time 设默认值插入时少写一个字段。3.2 注册功能后端校验比前端校验重要得多注册流程看起来就是“填表单 → 提交 → 插入数据库”但生产环境里必须加一道后端校验用户名是否为空、是否已存在、两次密码是否一致。前端的 JS 校验只能提升用户体验不能作为安全屏障因为请求可以被绕过前端直接构造后发出。这里用一个最典型的 RegisterServlet 演示完整流程。WebServlet(/register) public class RegisterServlet extends HttpServlet { protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { request.setCharacterEncoding(utf-8); String username request.getParameter(username); String password request.getParameter(password); String password2 request.getParameter(password2); if (username null || username.trim().isEmpty() || password null || password.isEmpty()) { request.setAttribute(msg, 用户名和密码不能为空); request.getRequestDispatcher(/register.jsp).forward(request, response); return; } if (!password.equals(password2)) { request.setAttribute(msg, 两次密码不一致); request.getRequestDispatcher(/register.jsp).forward(request, response); return; } try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement( INSERT INTO user(username, password, email) VALUES(?,?,?))) { ps.setString(1, username.trim()); ps.setString(2, password); ps.setString(3, request.getParameter(email)); int rows ps.executeUpdate(); if (rows 0) { response.sendRedirect(login.jsp?registered1); } else { request.setAttribute(msg, 注册失败请重试); request.getRequestDispatcher(/register.jsp).forward(request, response); } } catch (SQLException e) { if (e.getMessage().contains(Duplicate)) { request.setAttribute(msg, 用户名已存在); request.getRequestDispatcher(/register.jsp).forward(request, response); } else { throw new ServletException(e); } } } }这段代码有四个细节值得单独说。第一request.setCharacterEncoding(utf-8) 必须放在读取任何参数之前否则 POST 请求的中文会乱码。第二SQL 用 PreparedStatement 而不是拼接字符串这是防 SQL 注入的最基础手段setString 会把输入作为数据而不是 SQL 语句的一部分。第三唯一索引冲突是通过捕获 SQLException 并检查 Duplicate 关键字实现的这种方式不算优雅但对你现在这个阶段来说比先查一遍再插入更稳妥——查询不是原子的两个并发请求可能同时查到不存在然后同时插入。第四注册成功用 sendRedirect 而不是 forward因为 forward 后用户刷新页面会重复提交表单重定向能避免这个经典问题。3.3 登录功能验证密码后写入 Session这是整个项目最关键的三行代码登录的 SQL 和注册很接近但逻辑不止“查出来就行”而是查出来之后还要做密码比对。有些人偷懒写 SELECT * FROM user WHERE username ? AND password ?这样也行但项目升级到密码加密后这种写法必须改所以更规范的做法是分开先按用户名查出记录再比对密码字段。为了演示完整链路这里把两种方式折中一下查出来的记录用于比对然后写入 Session。WebServlet(/login) public class LoginServlet extends HttpServlet { protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { request.setCharacterEncoding(utf-8); String username request.getParameter(username); String password request.getParameter(password); try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement( SELECT id, username, email FROM user WHERE username ? AND password ?)) { ps.setString(1, username); ps.setString(2, password); try (ResultSet rs ps.executeQuery()) { if (rs.next()) { User user new User(); user.setId(rs.getInt(id)); user.setUsername(rs.getString(username)); user.setEmail(rs.getString(email)); request.getSession().setAttribute(loginUser, user); response.sendRedirect(list); } else { request.setAttribute(msg, 用户名或密码错误); request.getRequestDispatcher(/login.jsp).forward(request, response); } } } catch (SQLException e) { throw new ServletException(e); } } }登录成功后这三行不要省new User() 组装对象、setAttribute 存入 Session、sendRedirect 跳转。把 User 对象整个放 Session 而不是只放用户名是为了后续在页面顶部显示“欢迎你XXX”时不用再查一次数据库。跳到 list 而不是直接去某个静态页面是因为列表页需要从数据库取数据得走 Servlet 逻辑。这里注意 response.sendRedirect(list) 是一个相对路径浏览器会把它解析成相对于当前 URL 目录的地址如果当前路径是 /login那么目标会是 /list。如果你在 Servlet 里配了 WebServlet(/login) 且项目名是 demo整个 URL 是 /demo/login重定向逻辑仍然正确因为相对路径的基点是当前请求的目录部分。3.4 登录状态的守卫一个简单的 Filter 解决“未登录不能访问”的问题登录写好后你会发现一个问题用户不登录直接输 URL 也能访问列表页和修改页。这就是为什么必须有一个过滤器统一守门。它的逻辑非常简单放行登录页、注册页、静态资源其他请求一律检查 Session 里有没有 loginUser。WebFilter(/*) public class LoginFilter implements Filter { 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) || uri.endsWith(/register) || uri.endsWith(.css) || uri.endsWith(.js) || uri.endsWith(.png) || uri.endsWith(login.jsp) || uri.endsWith(register.jsp)) { chain.doFilter(request, response); return; } // 这里校验 Session 是否已登录 Object loginUser req.getSession().getAttribute(loginUser); if (loginUser null) { resp.sendRedirect(login.jsp); return; } chain.doFilter(request, response); } }实现 Filter 接口时需要同时重写 init、doFilter、destroy 三个方法其中 init 和 destroy 写空实现即可否则会因为抽象方法没实现而报错。WebFilter(/*) 表示拦所有请求包括 JSP 和图片所以白名单判断必不可少。这里用一个 String 的 endsWith 链做判断代码直观但不够严谨。例如用户 ID 里含 register 的业务路径会被误放行但现阶段项目小这个粒度足够。你如果愿意也可以把白名单抽成一个 List用 contains 判断代码会好维护一些。4. 用户列表、修改与删除把 CRUD 的 C、R、U、D 全部补齐4.1 用户列表查询与 JSP 页面渲染JSTL 让页面不再藏着 Java 代码登录后跳转的路由是 list它对应的就是一个查询所有用户的 Servlet。SQL 很简单SELECT * FROM user ORDER BY id DESC把结果放进 request 域然后 forward 到 userList.jsp 渲染。问题在于 JSP 页面怎么写。如果你在 JSP 里直接写 % for(...) { %代码是能跑但页面会乱成一锅粥。这里推荐 JSTL 标签库配合 EL 表达式页面里可以完全不出现 Java 代码。WebServlet(/list) public class ListUserServlet extends HttpServlet { protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { ListUser users new ArrayList(); try (Connection conn DBUtil.getConnection(); Statement stmt conn.createStatement(); ResultSet rs stmt.executeQuery(SELECT id, username, email, create_time FROM user ORDER BY id DESC)) { while (rs.next()) { User u new User(); u.setId(rs.getInt(id)); u.setUsername(rs.getString(username)); u.setEmail(rs.getString(email)); u.setCreateTime(rs.getTimestamp(create_time)); users.add(u); } } catch (SQLException e) { throw new ServletException(e); } request.setAttribute(userList, users); request.getRequestDispatcher(/userList.jsp).forward(request, response); } }Servlet 只负责收集数据不做任何输出。页面部分用 JSTL 的 c:forEach 循环生成表格每一行的末尾放“修改”和“删除”两个操作链接。修改链接要带上用户的 id 作为参数这是列表页最常见的设计。删除链接同理但这里有一个隐患是“删除用链接”意味着 HTTP GET 请求GET 请求的副作用是能被浏览器预加载、能被搜索引擎爬虫触发所以一个严谨的项目不会用链接做删除但很多简单项目都这么干了。如果你要保持简单至少要在删除前加 confirm 二次确认。4.2 修改用户信息回显表单与更新时的两个安全问题修改功能分成两步第一步根据 id 查出用户信息回显到表单第二步提交更新。查回显比较简单GET 请求到 /edit?id1Service 层拿到用户对象放到 request 域跳转 edit.jsp 把值填到 value 属性。这里需要特别说明一个新手常踩的坑JSP 里 value${user.username} 如果 user 为 null有些容器会输出字符串 null 而不是空值所以回显前必须判断对象是否存在。WebServlet(/update) public class UpdateUserServlet extends HttpServlet { protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { request.setCharacterEncoding(utf-8); int id Integer.parseInt(request.getParameter(id)); String username request.getParameter(username); String email request.getParameter(email); String password request.getParameter(password); String sql UPDATE user SET username?, email? (password ! null !password.isEmpty() ? , password? : ) WHERE id?; try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setString(1, username); ps.setString(2, email); if (password ! null !password.isEmpty()) { ps.setString(3, password); ps.setInt(4, id); } else { ps.setInt(3, id); } ps.executeUpdate(); response.sendRedirect(list); } catch (SQLException e) { throw new ServletException(e); } } }这段代码处理了一个容易被忽略的细节修改用户信息时密码可能留空留空代表不修改密码。如果 SQL 里总是包含 password 字段一个空字符串就会把原密码清掉造成用户无法登录。所以我把密码字段的条件判断揉进了 SQL 拼接。这里要注意SQL 拼接虽然带了条件分支但拼接的是结构而不是用户输入用户名和密码仍然通过参数传递所以不违反防注入原则。更清晰的写法是分成两个 SQL 分支一个带密码一个不带逻辑都一样。Integer.parseInt 放在 try 之前是为了让参数格式错误时能抛到容器层面避免进入数据库操作。4.3 删除用户事务边界与关联数据的处理思路删除是最容易写出“烂代码”的操作因为表面看就一行 DELETE FROM user WHERE id?。但实际项目中用户数据往往会被其他表引用比如订单表里有 user_id 外键。如果直接删用户要么被外键约束拦住抛异常要么留下孤儿数据。这个简单项目里没有订单表但正确的思维方式要从现在养成删除之前想一想还有谁引用了这条记录。常见做法是两步走先删除关联子表再删除主表记录并且把这两步包在同一个事务里。WebServlet(/delete) public class DeleteUserServlet extends HttpServlet { protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { int id Integer.parseInt(request.getParameter(id)); Connection conn null; try { conn DBUtil.getConnection(); conn.setAutoCommit(false); // 开启事务 try (PreparedStatement ps conn.prepareStatement(DELETE FROM user_log WHERE user_id?)) { ps.setInt(1, id); ps.executeUpdate(); } try (PreparedStatement ps conn.prepareStatement(DELETE FROM user WHERE id?)) { ps.setInt(1, id); ps.executeUpdate(); } conn.commit(); } catch (SQLException e) { if (conn ! null) { try { conn.rollback(); } catch (SQLException ignored) {} } throw new ServletException(e); } finally { DBUtil.close(conn, null, null); } response.sendRedirect(list); } }这里 DBUtil.close 的第二个和第三个参数传 null是因为 PreparedStatement 在 try-with-resources 里已经自动关了如果工具类在 close 方法里对 null 做了判空这样写没问题。事务的关键点是 conn.setAutoCommit(false) 之后所有的 SQL 都在同一个事务里执行要么全部成功要么全部回滚。如果删主表失败日志表已经删了正好回滚恢复。这个 delete 用一个 GET 请求处理的实际操作中我会把它改成 POST或者用一个隐藏表单加确认弹窗但那样代码量会多一倍这个度你自己把握。5. JavaWeb 登录注册项目常见问题排查从 500 到中文乱码再到 Session 丢失5.1 前端的请求到了 Servlet 但读到的中文是乱码前端里明明显示正常现象是注册时用户名填“张三”插入数据库后变成“å¼ ä¸‰”数据库查询结果也是一堆乱码。原因有三层每一层都可能出问题页面本身是 UTF-8 编码但浏览器提交时没有按 UTF-8 编码参数Tomcat 8 及更高版本对 GET 请求默认就是 UTF-8但对 POST 请求需要显式指定数据库表的字符集如果不是 utf8mb4存进去的数据在读写过程中会丢失字符。解决方法是三层都立规矩JSP 页面顶部加 % page contentTypetext/html;charsetUTF-8 %所有处理 POST 的 Servlet 第一行写 request.setCharacterEncoding(utf-8)数据库连接 URL 带 characterEncodingutf8建表用 utf8mb4。做完这三步乱码九成能解决。最后那一成的排查方向是 Tomcat 的 server.xml 里 connector 的 URIEncoding 属性默认 UTF-8 就不用改。5.2 登录后点击“修改”跳到 500 错误异常信息显示 id 转换失败现象是从列表页点“修改”按钮URL 确实变成了 /edit?id3但 Servlet 里 parseInt 直接抛 NumberFormatException页面白屏。原因是拼接超链接时用了 JSP 里的 EL 表达式比如 正常情况下这个 id 是个数字字符串不会出问题。出问题的地方在于列表里某些用户是通过注册功能创建的注册成功后被重定向到登录页用户 ID 在数据库里是自增的不会为空。真正的原因是修改链接被包在了 HTML 表格里而这一行数据渲染时 item 为 null——循环变量作用域写错了比如 c:forEach 里用的 varStatus 和 value 搞混。排查方法很简单在 JSP 里先用一个隐藏的 td 输出 ${item.id}再看页面源码。如果为空回头看循环的 items 属性是不是写错了比如写成 userList 而不是 users容器会把它当成一个不存在的属性名EL 表达式不会报错但输出空白。5.3 登录后手动把浏览器的 Cookie 清掉再刷新就跳回登录页这正常吗现象是登录成功跳到了列表页一切正常但用户手动清了浏览器 Cookie 或在新的无痕窗口打开页面立刻又被过滤器拦回登录页。这个现象很多人觉得是自己代码写错了其实这是 Session 机制的正常表现。Session 的实现依赖 JSESSIONID 这个 CookieCookie 没了服务器内存里的 Session 就“找不到了”它不会立刻销毁但后续请求携带不了 SessionID服务器会创建一个新的空 SessionloginUser 属性自然不存在。解决办法有两种一种是接受这个行为它本来就是安全的默认策略另一种是把记住登录状态的逻辑做成“Remember Me”用独立的 Cookie 存一个随机 TokenToken 在数据库里对应到用户 ID下次访问时通过 Token 重建 Session。显然第二种已经超出了简单项目范围所以遇到这个现象不用紧张检查代码逻辑没问题就可以。5.4 数据库连接没关运行一段时间后页面突然全部报错现象是项目刚启动时一切正常连续操作几十次后某个页面开始报 Connection refused 或者 Too many connections。原因几乎可以肯定是连接没释放。Java 的 Connection 如果不显式 close最终会被 GC 回收但数据库连接是资源GC 来不及回收时连接池或数据库服务器会先耗尽连接。这个项目用的 DriverManager 直接创建连接没有连接池所以只要有一条连接漏关用完不还累积到 MySQL 的 max_connections 上限整个应用就挂了。解决方法是所有读写操作都放进 try-with-resources连接、语句、结果集三层都要关或者统一走 DBUtil.close。强调一下 try-with-resources 的闭合顺序先关内层 ResultSet再关 Statement最后关 Connection这个顺序在多层嵌套时很重要倒过来关有时会在连接还占着资源时提前把外层关掉引发接口报错。5.5 修改密码后旧密码还能登录排查半天发现是 Session 里放了旧值现象是用户改了密码后台数据库里也确认是新的但页面上的登录校验仍然用旧密码能过。这个现象的原因有两种。第一种是修改操作根本没执行成功UPDATE 语句被某个条件堵住了用日志打印 rows 的值能快速排除。第二种是登录校验压根没查数据库代码在某个版本里为了“性能”把登录状态判断简化成了 Session 里有没有 loginUser而修改密码这个动作没有同步更新 Session 里的 User 对象。密码改完后Session 里存放的 User 还是旧密码对应的那一条但因为登录状态判断只看 Session 对象存在与否旧密码在当前会话里就一直有效。解决方法是修改密码成功后要把 Session 里的 loginUser 取出来更新密码字段或者干脆重新登录一次。这个坑藏得比较深因为它只在“修改密码后不重新登录”这个场景出现而不少人测试时是先退出再登录所以没暴露出来。6. 项目跑通后再往前走一步密码存储、连接池与分页查询的三个技巧登录注册修改删除跑通之后这个项目已经能作为课设或简历里的作品了但距离“真正能拿去上生产”还有三件事值得做而且每一件的成本都很低。密码明文存储是当前最大的安全隐患。哪怕是最基础的改进也要用加盐哈希代替明文例如用 SHA-256 加随机盐值存储格式约定为 盐值:哈希值。注册时生成盐值登录时取出盐值重新计算哈希再比对。Java 标准库的 MessageDigest 就能实现不用引第三方包。升级到 PBKDF2 或 BCrypt 也只是换一个算法实现存储结构不变。这一步做完就算数据库泄露了攻击者拿到也只是哈希串不能直接登录。数据库连接换成连接池比如 HikariCP是对项目稳定性提升最明显的一件事。改动也不大把 DBUtil 的 DriverManager 换成 HikariDataSource初始化时配置好最大连接数和超时时间。连接池的价值不只是性能更是让“连接用完就还”成为一种纪律性约束从池子里借连接用完归还池子负责维护连接的健康状态。这样上面第五节的 5.4 类报错会大幅减少。最后是列表页加分页。现在的 list 是查出全部用户数据到几十条时还好到几千条时页面会明显变卡。常见做法是用 LIMIT ? OFFSET ? 配合请求参数 page、pageSize查询总数用 COUNT(*) 单独执行一次然后算出总页数在页面上渲染页码。注意 PreparedStatement 的 LIMIT 参数需要 setInt 绑定MySQL 支持这个用法但一些老版本驱动有坑建议先在数据库客户端里手工执行一遍确认语法兼容。这三个技巧我从“能跑”的项目向“能演示”的项目过渡时依次加进去的。第一个技巧让我避开了数据库泄露后的尴尬第二个技巧让我不再半夜被“连接数满了”的短信吵醒第三个技巧最实在——我第一次给模拟项目X做演示时数据三千条直接卡了页面从那次之后任何列表页我都习惯性带上分页哪怕现在是“简单项目”。如果你的登录注册项目已经跑通建议从密码加盐开始改收益最明确改动范围又最小。希望帮到你。本文还有配套的精品资源点击获取