简介一份基于JavaWeb的登录注册实例解析文档面向正在学习JavaWeb的初学者或需要完成课程设计的学生旨在帮助理解并实现带验证码校验的安全登录注册功能。文档按需求分析、页面设计、后端逻辑、数据库设计、服务器部署五条主线展开涵盖Servlet、JSP、MySQL、JDBC Template、Druid连接池、BeanUtils、Tomcat等技术栈的整合使用并重点讲解验证码生成与校验、用户名主键约束、注册失败处理、登录成功后的Session传递等关键环节。资源为1个PDF文件约148KB体积适中便携易读可随时查看代码和设计说明目前已有4792人学习参考。这份文档不仅梳理了完整的登录注册实现思路还提供了可复用的代码片段、数据库建表方案和连接池配置经验能够帮助读者快速上手JavaWeb项目也可作为课程设计或毕业设计的参考资料。1. 什么是JavaWeb登录注册里的验证码先弄懂这个小项目解决你的什么需求登录注册是JavaWeb新手第一个绕不过去的完整闭环一台服务器、一个数据库、两张JSP页面就能把HTTP协议、Session会话、JDBC、Servlet全部串起来。而验证码是这个项目里最容易被抄代码带过、却最值得自己写一遍的环节。图形验证码本身不复杂但它逼着你去处理三件事在服务器端怎么动态画图、把答案安全地放进Session、提交时确保一次性校验。做完这个模块你才敢说自己理解了服务端渲染和会话管理。读这篇的人我按两类需求理解要么是课程作业和实训任务需要交一个能跑的JavaWeb登录注册实例要么是把登录注册当脚手架后面要接Shiro或Spring Security。这篇把最简单但能跑通的方案讲透让你拿到代码时知道每个参数在干什么遇到验证码不刷新、中文乱码、Session丢失能自己定位而不是瞎试。2. 项目结构与数据库设计登录注册实例的表、连接和实体怎么定2.1 目录结构从IDEA里新建一个不迷路的JavaWeb工程我一般会用IDEA直接创建JavaWeb工程按后端分层把代码归类Servlet放在控制层数据库工具单独抽出来。对新手来说最怕的是把所有类堆在一个包里这个项目规模小全堆也能跑但后面接Spring Boot或者Shiro的时候你会发现改一个类要动三个地方。所以一开始就按下面的结构建目录不亏。src/main/java/com/example/ ├── entity/User.java ├── dao/UserDao.java ├── util/DBUtil.java ├── util/MD5Util.java ├── servlet/VerifyCodeServlet.java ├── servlet/LoginServlet.java └── servlet/RegisterServlet.java src/main/webapp/ ├── index.jsp ├── login.jsp ├── register.jsp └── WEB-INF/web.xml src/main/resources/ └── db.properties这个结构把数据层、工具层、控制层分开了。entity包放User对象对应数据库里的一条记录dao包负责查询和插入util包放数据库连接、MD5加密这种横向工具servlet包接收HTTP请求并转发页面。如果你用的不是Maven工程直接在src下建同样路径的包也行。IDEA里跑这个项目需要注意Tomcat的部署方式把Artifact选成war exploded然后在Deployment里让Application context和项目名一致。我因为context配错遇到过安全配置正确但CSS和图片全部404的问题因为只有jsp被Tomcat当资源访问静态资源路径全被拦在前面了。项目依赖只需要四样MySQL驱动、Druid连接池、Servlet API、标准JSP引擎。版本号你直接用IDE拉得到的最新稳定版不要为了配套某个教程去故意降级反而踩到旧版本不兼容的坑。2.2 建表语句用户名加唯一索引密码别用明文用户表最少要有这几列主键id、用户名、密码、盐、邮箱、注册时间。核心点有两个。第一username必须加唯一索引这比在代码里先查再插入更可靠并发注册时会直接报Duplicate entry避免两个相同名字的用户被插进去。第二password不存明文用char(32)存MD5加密后的32位十六进制salt用char(4)存四位盐值。CREATE TABLE user ( id INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 主键, username VARCHAR(32) NOT NULL COMMENT 用户名, password CHAR(32) NOT NULL COMMENT MD5(password salt) 的32位十六进制密文, salt CHAR(4) NOT NULL COMMENT 四位随机盐, email VARCHAR(64) DEFAULT NULL COMMENT 邮箱, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 注册时间, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;id设成无符号自增整型避免将来数据量大了int不够用。username排序规则跟随表的utf8mb4_general_ci即可如果项目要对用户名区分大小写建表后单独把这列改成utf8mb4_bin。create_time用DEFAULT CURRENT_TIMESTAMP让数据库自动写时间不要在Java代码里拼时间字符串本地时间与数据库时区不一致会导致差八小时的经典问题。这个建表语句在MySQL 5.7以上直接执行没问题。注意DEFAULT CHARSETutf8mb4而不是utf8因为utf8在MySQL里是utf8mb3的别名存不了emoji和四字节字符中文没问题但万一用户注册时带上特殊字符就会插入失败。2.3 User实体类字段映射与Servlet之间传递数据的最简载体User类的作用是让数据库记录在Java代码里变成对象避免在Servlet里用Map和数组到处倒腾数据。public class User { private Integer id; private String username; private String password; private String salt; private String email; private Date createTime; public Integer getId() { return id; } public void setId(Integer id) { this.id id; } public String getUsername() { return username; } public void setUsername(String username) { this.username username; } public String getPassword() { return password; } public void setPassword(String password) { this.password password; } public String getSalt() { return salt; } public void setSalt(String salt) { this.salt salt; } public String getEmail() { return email; } public void setEmail(String email) { this.email email; } public Date getCreateTime() { return createTime; } public void setCreateTime(Date createTime) { this.createTime createTime; } }这里我用了Integer接收id而不是int因为数据库返回自增主键时如果是空值int会报NPE。密码和salt用String密文固定32位不会有多大出入。createTime用java.util.Date避免在Servlet里直接和java.sql.Date纠缠后面接JSON序列化框架时也更顺。不要为了省事在User类里加上确认密码confirmPassword这种字段那是页面表单的专属属性不是用户的持久化属性。放进去会让注册和登录的校验逻辑混在一起别人读代码时会疑惑为什么用户实体多出一个和数据库不对应的字段。2.4 DBUtil与数据库连接池为什么不在每个Servlet里手动new连接把Connection在一个个Servlet里手动创建是新手项目里最常见的做法。每个请求都来一次DriverManager.getConnection创建连接的开销比执行SQL大得多并发稍微上来页面就卡住。我用Druid连接池做统一管理配置放db.propertiesDBUtil在类加载时把池建好之后每次getConnection是从池里借一个用完归还而不是真的关闭。public class DBUtil { private static DruidDataSource dataSource; static { Properties props new Properties(); try (InputStream in DBUtil.class.getResourceAsStream(/db.properties)) { props.load(in); dataSource (DruidDataSource) DruidDataSourceFactory.createDataSource(props); } catch (Exception e) { throw new ExceptionInInitializerError(初始化数据库连接池失败 e.getMessage()); } } public static Connection getConnection() throws SQLException { return dataSource.getConnection(); } }driverClassNamecom.mysql.cj.jdbc.Driver urljdbc:mysql://localhost:3306/user_db?useUnicodetruecharacterEncodingUTF-8serverTimezoneAsia/Shanghai usernameroot password123456 initialSize2 maxActive10 maxWait3000这段代码里最值得注意的坑是url上的参数useUnicodetrue在前characterEncodingUTF-8紧随其后中间用连接。很多中文乱码问题就出在漏掉characterEncoding或者把serverTimezone写成不存在的时区。Druid初始化时如果配置不对报错往往发生在第一个请求而不是程序启动排查起来容易被绕进去。参数说明initialSize2是启动时预建2个连接maxActive10是池上限maxWait3000表示借不到连接时最多等3秒就抛异常防止满负载时请求无限挂起。这三个值在登录注册这种低并发场景保持默认即可不要贪大把maxActive调到100连接池大了反而加大数据库压力。3. 验证码的生成与校验30行Java代码把图形验证码做出来3.1 验证码Servlet从生成图片到写入响应的完整流程这是整个项目里画图的部分。我见过几种做法引JCaptcha、用hutool的CaptchaUtil、或者把第三方接口返回的图片直接当验证码。对登录注册实例来说自己用BufferedImage画最合适不为炫技而是为了让新手看清验证码的本质一张临时生成的图片答案存在服务端Session里图片本身是会被浏览器丢弃的临时资源。图形验证码是验证码家族里最基础的形式滑块验证码、短信验证码都是在它之上叠加的业务约束。WebServlet(/captcha) public class VerifyCodeServlet extends HttpServlet { private static final int WIDTH 120; private static final int HEIGHT 40; private static final int CODE_LENGTH 4; private static final String CODES ABCDEFGHJKMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789; Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { BufferedImage image new BufferedImage(WIDTH, HEIGHT, BufferedImage.TYPE_INT_RGB); Graphics2D g image.createGraphics(); g.setColor(new Color(240, 240, 240)); g.fillRect(0, 0, WIDTH, HEIGHT); Random random new Random(); StringBuilder code new StringBuilder(); for (int i 0; i CODE_LENGTH; i) { char c CODES.charAt(random.nextInt(CODES.length())); code.append(c); g.setColor(new Color(30 random.nextInt(180), 30 random.nextInt(180), 30 random.nextInt(180))); g.setFont(new Font(宋体, Font.BOLD, 22 random.nextInt(6))); int x 10 i * 26 random.nextInt(6); int y 28 random.nextInt(6); double angle (random.nextInt(30) - 15) * Math.PI / 180; g.rotate(angle, x, y); g.drawString(String.valueOf(c), x, y); g.rotate(-angle, x, y); } for (int i 0; i 8; i) { g.setColor(new Color(150 random.nextInt(80), 150 random.nextInt(80), 150 random.nextInt(80))); g.drawLine(random.nextInt(WIDTH), random.nextInt(HEIGHT), random.nextInt(WIDTH), random.nextInt(HEIGHT)); } g.dispose(); req.getSession().setAttribute(captcha, code.toString()); resp.setContentType(image/jpeg); resp.setHeader(Pragma, No-cache); resp.setHeader(Cache-Control, no-cache); resp.setDateHeader(Expires, 0); ImageIO.write(image, JPEG, resp.getOutputStream()); } }几个容易改坏的参数说明CODES字符串是我去掉易混淆字符后的字符集把0/O、1/I、l/1全去掉降低人工识别成本不然用户连输三遍都错体验直线下降。CODE_LENGTH4是验证码位数不要改成4以上的同时不动图片宽度120像素宽放6个字符会挤在一起。WIDTH和HEIGHT就是图片尺寸字符位数改多时Width要同步增大。干扰线8条能挡住简单OCR又不至于让人看不清字符。字符绘制时对每个字母做了随机旋转和颜色偏移角度范围在正负15度之间旋转过大用户看不出是什么字符。旋转用g.rotate(angle, x, y)指定旋转中心让字符绕自己的坐标轴转而不会把整张图转偏。这里把答案存到Session的captcha属性必须在输出图片之前完成因为在resp.getOutputStream写入后再碰Session属性会影响响应提交的完整性。3.2 校验逻辑验证码必须一次性用完就销毁画出来只是第一步真正让登录注册安全的是校验逻辑。图形验证码防的是机器人自动化提交如果同一个验证码能重复登录十次那它和没有验证码没区别。所以校验方法里第一件事是removeAttribute把Session里的captcha删掉再去比对用户输入的值。private boolean checkCaptcha(HttpServletRequest request, String inputCode) { HttpSession session request.getSession(); Object captcha session.getAttribute(captcha); if (captcha null || inputCode null) { return false; } session.removeAttribute(captcha); return captcha.toString().equalsIgnoreCase(inputCode.trim()); }逻辑拆开看先从Session取服务器生成的验证码字符串取不到说明要么没请求过验证码图片要么已经用过一次直接返回false。然后无论用户输入的验证码对不对都立刻把Session里的captcha删掉实现一次性使用。用户输错一次即使记住了刚才图片上的字符这个验证码在服务端都已经不存在了。这里用equalsIgnoreCase而非equals是让用户在大小写上不用较劲生成时用aBcD用户输入AbCd也能通过。trim去除首尾空格解决复制验证码时带上空白字符的问题。如果项目希望验证码区分大小写更严格把equalsIgnoreCase换回equals即可但页面要有重新输入的友好提示不然用户会反复撞错。3.3 前端刷新img标签为什么不换图验证码页面经常出现这种情况第一次打开有图点击图片刷新没反应整页刷新又能换一张。这十有八九是浏览器HTTP缓存在做怪。img的src指向同一个URL时浏览器认为资源没变直接复用缓存图片而服务器端的验证码已经更新了用户看到的还是旧图提交自然失败。img src${pageContext.request.contextPath}/captcha onclickrefreshCaptcha(this) title看不清点击换一张 / script function refreshCaptcha(img) { img.src ${pageContext.request.contextPath}/captcha?t new Date().getTime(); } /script点击图片时JavaScript把当前毫秒时间戳拼在URL后面作为查询参数。对浏览器来说?t1699999999999是一个新URL必须重新请求服务器于是拿到新图片。服务端的VerifyCodeServlet不读这个参数它只负责生成新图并覆盖原来的Session值所以加参数不影响后端逻辑。我顺便说明为什么要把刷新逻辑放在onclick而不是页面加载时如果只在URL上放一次随机值整页刷新会重新生成但点击图片不会触发新参数缓存问题的边界仍然存在。放在onclick里每次点击都产生新参数覆盖了页面不刷新但图片要换的场景。有些项目用a标签包一层图片效果一样但a标签会把URL更新到浏览器地址栏不太好看。4. 登录与注册Servlet从表单接收参数到数据库读写完整链路4.1 注册流程用户名查重、密码加密、验证码三件套注册接口要做的事接收表单参数、校验合法性、检查用户名是否被占用、把密码加盐加密后插入数据库。边界处理按先校验参数、再校验验证码、最后查数据库的顺序来。这个顺序不能乱否则一个无效请求也会打到数据库验证码形同虚设。WebServlet(/register) public class RegisterServlet extends HttpServlet { private final UserDao userDao new UserDao(); 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 captchaInput req.getParameter(captcha); if (isBlank(username) || password null || password.length() 6) { req.setAttribute(error, 用户名不能为空密码不得少于6位); req.getRequestDispatcher(/register.jsp).forward(req, resp); return; } if (!password.equals(confirm)) { req.setAttribute(error, 两次密码输入不一致); req.getRequestDispatcher(/register.jsp).forward(req, resp); return; } if (!checkCaptcha(req, captchaInput)) { req.setAttribute(error, 验证码错误或已过期); req.getRequestDispatcher(/register.jsp).forward(req, resp); return; } if (userDao.findByUsername(username) ! null) { req.setAttribute(error, 用户名已被注册); req.getRequestDispatcher(/register.jsp).forward(req, resp); return; } String salt UUID.randomUUID().toString().substring(0, 4); String encrypted MD5Util.md5(password salt); User user new User(); user.setUsername(username.trim()); user.setPassword(encrypted); user.setSalt(salt); if (userDao.insert(user) 0) { resp.sendRedirect(req.getContextPath() /login.jsp); } else { req.setAttribute(error, 注册失败请稍后重试); req.getRequestDispatcher(/register.jsp).forward(req, resp); } } }关于密码加密这里用的是MD5(password salt)的方式。说句大实话MD5单独用早被彩虹表打穿了加盐之后强度好很多但放到生产项目里我更推荐BCrypt这类慢哈希算法。实例代码保留MD5加盐是为了让新手先理解密码不能明文入库这一层后面接Spring Security时自然替换成BCrypt的加密实现。参数说明salt取UUID前四位足以打散相同密码的密文又不至于让salt列加长。密码最少6位这个约束在Servlet里验一遍前端HTML里的required属性只是体验层不能当安全边界。所有失败路径统一forward回register.jsp并携带error参数成功路径用sendRedirect避免F5刷新时重复提交注册请求这是前端的后悔药后端也得配合。MD5Util里我没有用第三方库直接用JDK的MessageDigest写了一个封装省去引入commons-codec的麻烦。public class MD5Util { public static String md5(String text) { try { MessageDigest md MessageDigest.getInstance(MD5); byte[] bytes md.digest(text.getBytes(StandardCharsets.UTF_8)); StringBuilder sb new StringBuilder(); for (byte b : bytes) { String hex Integer.toHexString(b 0xff); if (hex.length() 1) { sb.append(0); } sb.append(hex); } return sb.toString(); } catch (NoSuchAlgorithmException e) { throw new RuntimeException(e); } } }这里的b 0xff是关键Java的byte是有符号的直接Integer.toHexString(b)在负数时会输出两字节长度的字符串导致密文变成33位或更长入库时和char(32)字段对不上。加上0xff转成无符号整型再补前导零保证每个字节固定输出两位十六进制。4.2 登录流程验证码校验、Session会话、数据库配合登录Servlet比注册多两件事检查Session里有没有已登录用户有就直接跳转避免二次登录以及登录成功后把用户对象放入Session。还有一个容易被忽略的细节登录失败时统一提示用户名或密码错误不区分用户名不存在和密码不对防止别人枚举用户名。WebServlet(/login) public class LoginServlet extends HttpServlet { private final UserDao userDao new UserDao(); Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { HttpSession session req.getSession(); Object loginUser session.getAttribute(loginUser); if (loginUser ! null) { resp.sendRedirect(req.getContextPath() /index.jsp); return; } String username req.getParameter(username); String password req.getParameter(password); String captchaInput req.getParameter(captcha); if (isBlank(username) || password null || !checkCaptcha(req, captchaInput)) { req.setAttribute(error, 验证码错误或已过期); req.getRequestDispatcher(/login.jsp).forward(req, resp); return; } User user userDao.findByUsername(username.trim()); if (user ! null user.getPassword().equals(MD5Util.md5(password user.getSalt()))) { session.setAttribute(loginUser, user); session.setMaxInactiveInterval(30 * 60); resp.sendRedirect(req.getContextPath() /index.jsp); } else { req.setAttribute(error, 用户名或密码错误); req.getRequestDispatcher(/login.jsp).forward(req, resp); } } }登录成功后session.setMaxInactiveInterval(30 * 60)的单位是秒表示30分钟不操作会话自动失效。这个值要不要单独配看项目规范有些团队直接依赖web.xml的session-timeout。但要注意别把30 * 60误写成30 * 60 * 1000那是毫秒数实际会话会变成30个小时不失效。验证码校验放在用户名密码校验之前因为验证码计算成本几乎为零而查数据库有网络和CPU开销。攻击者大量发登录请求时先被验证码挡掉一批连数据库都不用碰。如果顺序反过来先查库再校验证码数据库压力会被垃圾请求直接打到登录接口变成天然的攻击入口。登录成功后的user对象存的是完整用户信息包括password和salt。这个对象进了Session如果项目里有输出用户信息的日志千万别把整个user对象打出来否则MD5密文和盐值全泄露了。常见做法是在存Session前把user的password字段置空或者干脆新建一个只含id、username的轻量对象。4.3 DAO层sqlfindByUsername和insert的边界行为UserDao里的findByUsername返回null还是对象直接决定登录Servlet里user ! null这个判断是否可靠。我见过有人把查不到用户的情况直接抛异常结果被全局异常处理器吞掉页面永远显示系统错误排查半天才发现是DAO的返回约定出了问题。正确且简单的约定是查不到就返回null由调用方决定怎么处理。public User findByUsername(String username) { String sql SELECT id, username, password, salt, email, create_time FROM user WHERE username ?; try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setString(1, username); try (ResultSet rs ps.executeQuery()) { if (rs.next()) { User user new User(); user.setId(rs.getInt(id)); user.setUsername(rs.getString(username)); user.setPassword(rs.getString(password)); user.setSalt(rs.getString(salt)); user.setEmail(rs.getString(email)); user.setCreateTime(rs.getTimestamp(create_time)); return user; } } } catch (SQLException e) { e.printStackTrace(); } return null; }这里用PreparedStatement而不是Statement拼SQL是防SQL注入的基础操作。如果写成SELECT * FROM user WHERE username username 那username传一个 or 11就能把整张用户表拖出来。PreparedStatement用?占位让JDBC驱动做参数转义从根上堵住这个洞。ResultSet一层也用try-with-resources包装ps和rs自动关闭不需要在finally里手动close。连接池的Connection在try结束后归还因为getConnection拿到的连接执行close时是回池而不是真关闭。这个查询没开事务登录注册的单条读写不需要事务事务在注册后同时初始化用户资料表这种多表操作时才需要。insert方法同理PreparedStatement执行executeUpdate返回受影响行数大于0说明插入成功。注意这里的User对象需要先setSalt和setPassword再传进来否则加密字段为空登录时永远匹配不上。5. 避坑手册登录注册里验证码环节最容易的5个翻车现场5.1 验证码图片能显示但提交永远报验证码错误或已过期现象图片正常显示把图片里的字符原样输入点击登录后端仍然提示验证码错误。原因常见来源有三个。一是页面和Servlet取的不是同一个Session项目部署到多台服务器做负载均衡时最容易出现Session没做同步二是验证码Servlet里存Session用的key和登录Servlet取Session用的key不一致一个写captcha一个写CAPTCHA大小写查不出来三是用户输入的验证码被HTML表单做了大小写转换代码用的是equals匹配不上。解决先确认两个Servlet对Session里captcha这个key的读写完全一致统一用equalsIgnoreCase加trim。我建议把存储验证码的Session key定义成常量放在公共类里比如CaptchaConstant.CAPTCHA_SESSION_KEY两个Servlet都引用它避免各写各的字符串导致拼错。如果项目上了Nginx之类的负载均衡还得把Session粘性打开或者把验证码挪到Redis否则每台Tomcat里的Session不互通。5.2 注册中文用户名数据库里存成了问号现象注册时填张三页面显示注册成功打开数据库看到username字段是???。原因乱码在JavaWeb里是分层产生的任何一层没设UTF-8都会出问题。第一层是JSP页面编码第二层是Servlet读取参数前的request.setCharacterEncoding第三层是JDBC连接URL里的characterEncodingUTF-8第四层是MySQL表本身的charset四层断一层中文就可能变成问号。解决给项目加一个编码过滤器统一处理这是最不容易漏的办法。WebFilter(/*) public class EncodingFilter implements Filter { Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { request.setCharacterEncoding(UTF-8); response.setCharacterEncoding(UTF-8); chain.doFilter(request, response); } }过滤器放在最外层所有Servlet和JSP的请求都过一遍。加上过滤器后再把db.properties里的url改成useUnicodetruecharacterEncodingUTF-8把MySQL建表语句的DEFAULT CHARSET改成utf8mb4四层齐了中文乱码基本消失。排查时用排除法先在JSP页面里直接用out.print打印中文能显示说明页面编码没问题再逐层往下查数据库和连接串。5.3 验证码图片点击刷新没反应换个浏览器又好了现象用Chrome打开登录页点图片刷新图片纹丝不动按F5整页刷新又能换用Edge打开同一个页面点图片刷新却正常。原因典型的浏览器缓存问题。Chrome的缓存机制比Edge更严格一旦发现URL没变化就直接命中本地缓存完全不会发请求到服务端。服务端虽然设了Cache-Control: no-cache但如果中间经过Nginx之类的代理头信息可能被改写或丢弃浏览器认不到no-cache就把旧图用上了。解决最稳妥的方式是前端刷新时加随机参数第3.3节的refreshCaptcha函数就是这个作用。我遇到过加上时间戳后依然不换图的情况因为有的Tomcat版本对同一URL的响应做了应用层缓存此时在VerifyCodeServlet的doGet里把Cache-Control改成no-store比no-cache更强直接禁止任何缓存存储。改完记得重启Tomcat这属于改了配置不生效的常见坑。5.4 登录成功后刷新页面又跳回登录页现象登录成功跳转到index.jsp按F5或者点击页面里的普通链接马上被重定向回login.jsp。原因第一反应是Session丢了。Session丢失一般是三件事登录Servlet里虽然setAttribute了loginUser但跳转方式有问题如果用的是forward而不是sendRedirect浏览器地址栏还是login.jsp的URLF5会再次提交登录请求或者web.xml里session-timeout配得太小调试时几分钟不动就过期再或者项目里有登录拦截Filter放行规则没包含login.jsp、captcha、register.jsp导致接口正常但页面资源被拦。解决先打开浏览器开发者工具在Application面板看Cookie里有没有JSESSIONID没有说明Session创建后被重置检查Filter里是不是调用了session.invalidate()。再看拦截器代码放行规则一般要包含login.jsp、captcha、register.jsp三个路径剩下的业务页面才需要登录校验。最后把session超时按需求设成30分钟而不是图方便设个90秒那会让调试过程反复掉线看着就像Session每天都在丢。5.5 密码加密后查询速度慢得诡异不是MD5的锅现象注册插入一条数据正常登录时每次要等一两秒才返回去掉密码加密后时间骤降。原因这个现象迷惑性很强容易让人把锅扣在MD5头上其实MD5加密本身只要几微秒。慢的根源一般有两个一是项目里每请求都自己new了Connection没走连接池在并发不高时能忍登录这种被刷新率带动的高频接口就暴露了二是连接池连接数耗尽池内连接被MySQL的wait_timeout空闲断开Druid没探活感知到重建连接要走完TCP握手肉眼可见地卡。解决把数据库访问收口到统一DAO方法里一律通过DBUtil.getConnection拿连接用完自动归还。其次调整Druid配置把initialSize从2提到5打开连接的keepAlive避免MySQL默认8小时后把空闲连接踢掉。排查时先看Druid监控页面里的连接池活跃数如果活跃连接长期顶着上限说明连接泄漏比如查询时关了ResultSet没关Connection或者手动jdbc代码里try不彻底。我还遇到过一种更隐蔽的情况Druid的SQL日志拦截器把每次参数打印到控制台IDEA里跑没感觉部署到服务器后日志写文件I/O把请求堵了。先关掉SQL日志看一眼如果速度恢复就是日志刷写的问题。6. 进阶自检用重放与并发验证登录注册模块靠不靠得住项目不是写完功能就结束还得能证明它靠得住。我最常用的自检办法有三个每个都能在10分钟内跑完。第一个是重放测试。用HTTP客户端工具抓一个登录请求把验证码改成正确值提交一次再把这个请求原样提交第二次。第二次如果返回验证码错误或已过期说明验证码一次性销毁的代码写对了。如果第二次还能登录就得回头查是不是忘了removeAttribute或者校验逻辑里先比较后删除导致并发重放时两个请求都拿到了同一个captcha值。这个问题在三次重放测试里最容易露馅也是验证码模块最核心的安全指标。第二个是并发注册测试。准备一个空库用一个简单脚本同时发10个相同用户名的注册请求看数据库里最终只留下一条记录。这个场景完全依赖建表语句里的UNIQUE KEY如果没有唯一索引代码里先查再插存在时间差并发下确实可能插进两条相同的用户名。数据库约束是最低成本的黑匣子监控这一点在登录注册里体会特别深。第三个是验证码时效测试。在login.jsp页面放着超过5分钟再提交如果还能成功登录说明验证码本身没有过期概念是靠Session的默认存活时间撑着的。一旦session超时被改短到几十秒验证码也会随之失效这时候要决定是提示用户刷新页面重新获取还是把验证码的有效期独立管理。生产项目一般会存Redis并单独设置过期时间登录注册实例里的Session方案足够但要把过期时间想明白。我的习惯是做第三件事时顺手在登录Servlet末尾打印一行debug日志记录用户名、验证码、校验结果。这行日志在排查验证码到底错在哪一步时能省去半小时追代码的时间。整个项目跑通后我还会花10分钟对比一下密码错误时的响应时间确认验证码校验在查数据库之前。这个顺序对单次请求的体验影响不大但对数据库的压力有好几倍的差距希望这套做法能帮到你。本文还有配套的精品资源点击获取