1. 项目概述为什么一个“文章发布系统”是JavaWeb入门的黄金切口你点开这个标题大概率不是冲着“第五节”来的而是被“手把手”和“入门级项目实战”这两个词钉住了——说明你正卡在学完Servlet、JSP、MySQL基础语法后那个最难受的阶段知道每个零件怎么转但完全不知道怎么把它们拧成一台能跑起来的机器。我带过几十期JavaWeb实训几乎每届都有学员在第四节就放弃原因很现实前四节讲的是“单点技术”而第五节开始才真正进入“系统思维”。这个文章发布系统表面看只是增删改查但它像一把解剖刀精准切开了JavaWeb开发中所有关键断面前端表单如何安全提交、后端如何校验并封装数据、DAO层怎么隔离SQL细节、Service层如何组织业务逻辑、JSP页面怎么用EL表达式动态渲染、还有最关键的——整个请求生命周期里数据在哪一层、以什么形态、经过哪些对象流转。它不追求高并发或分布式但把MVC三层结构的职责边界、数据传递路径、异常处理时机全部暴露在阳光下。比如用户点击“发布文章”按钮背后至少经历7个关键节点浏览器发起POST请求→Tomcat解析URL映射到Servlet→Servlet从request中提取title/content→调用Service层做空值校验→Service调用DAO执行INSERT→DAO通过JDBC获取Connection并执行PreparedStatement→最后跳转到成功提示页。这7步里任何一步出错页面就白屏或报500错误。而本节要解决的正是其中最易被忽略却最致命的一环表单重复提交问题。很多新手调试时发现点一次“发布”按钮数据库却多了两条一模一样的文章记录——这不是代码写错了是HTTP协议本身的无状态特性在作祟。我们不用Spring Boot的Valid或Redis防重就用最原始的ServletJSP方案手写一个基于Token机制的防重提交过滤器。这个方案看似简单但涉及Session生命周期管理、Token生成策略、页面隐藏域注入、以及Filter链的执行顺序每一个细节都踩中初学者的知识盲区。所以这节的价值远不止于“防止多插一条数据”它是在帮你建立对Web应用底层交互逻辑的肌肉记忆。2. 核心设计思路拆解为什么必须用Token机制而非前端禁用按钮2.1 前端禁用按钮的三大致命缺陷很多教程教新手在提交按钮上加onclickthis.disabledtrue看似立竿见影实则埋下三颗雷第一颗雷是刷新即失效。用户填完表单点了提交页面卡顿比如网络慢他习惯性按F5刷新——此时按钮重新启用再点一次重复提交就发生了。我见过某高校实训项目学生交作业时因校园网延迟反复刷新导致同一篇论文被插入数据库17次导师后台看到满屏重复标题直接崩溃。第二颗雷是后退再提交。用户提交后跳转到成功页又点浏览器后退键回到表单页此时所有字段仍保留着刚才的内容按钮也恢复可用他可能误以为没提交成功再次点击。这种场景在移动端尤其高频因为手机浏览器后退手势比PC更隐蔽。第三颗雷是多标签页共用状态。用户开两个标签页都访问同一表单页第一个页提交后按钮禁用但第二个页的按钮依然亮着他根本意识不到风险。这问题在Chrome最新版中更明显因为标签页间DOM状态完全隔离。提示前端禁用按钮只能作为辅助手段绝不能作为防重主方案。它解决的是“用户误操作”而非“HTTP协议缺陷”。2.2 Token机制的底层逻辑与不可替代性真正的防重必须在服务端拦截。核心思想就一句话让每一次合法提交都携带一个一次性凭证服务器验证凭证有效性后立即作废它。这个凭证就是Token它的设计必须满足三个硬性条件唯一性每个Token必须全局唯一不能出现两个用户拿到相同Token的情况。我们采用UUID.randomUUID().toString() System.currentTimeMillis()拼接再经MD5哈希确保碰撞概率低于10^-36。时效性Token不能永久有效否则攻击者截获后可无限次重放。我们设定有效期为15分钟超过时间自动失效这刚好覆盖用户填写表单的合理耗时。绑定性Token必须与当前用户Session强绑定。不能A用户生成的Token被B用户盗用。我们在Session中存一个MapString, LongKey是Token字符串Value是生成时间戳验证时先查Session是否存在该Key再判断时间戳是否超期。这个机制之所以可靠在于它把防重逻辑从“客户端行为控制”升级为“服务端状态管理”。HTTP本身无状态但我们可以用Session给它打上状态烙印。当用户首次访问发布页时Servlet生成Token并存入Session同时将Token值写入表单隐藏域提交时Servlet比对请求中的Token与Session中存储的Token是否一致且未过期验证通过后立刻从Session中移除该Token。这样即使用户刷新页面新页面会生成新Token旧Token已失效即使他后退表单里的旧Token在Session中已不存在验证必然失败。2.3 为什么不用数据库存Token性能与复杂度的权衡有学员问“Token存数据库不是更安全吗”——这是典型的新手思维陷阱。存数据库确实能实现持久化但会引入四个不可接受的代价额外IO开销每次表单展示都要查一次DB每次提交都要删一次DB而发布文章本身就要执行INSERT相当于单次请求触发两次数据库操作QPS直接腰斩事务复杂度飙升Token验证和文章插入必须在同一个事务里否则可能出现Token已删但文章插入失败导致用户无法再次提交分布式部署失效如果未来项目要水平扩展多台Tomcat实例间Session不共享数据库Token方案会要求所有实例连同一个DB变成单点瓶颈清理成本高过期Token需要定时任务清理否则DB表会无限膨胀而Session中的Token随Session超时自动销毁零维护成本。注意Session存储Token是单机部署下的最优解。若项目明确要支持集群本节会延伸讲解RedisLua脚本的原子化Token验证方案但那是进阶内容入门阶段必须先吃透单机原理。3. 核心环节实现从Token生成到过滤器落地的完整链条3.1 表单页publish.jsp的Token注入细节很多人以为Token只要塞进隐藏域就行其实这里有三个关键动作必须同步完成% page contentTypetext/html;charsetUTF-8 languagejava % % taglib prefixc urihttp://java.sun.com/jsp/jstl/core % html headtitle发布文章/title/head body form actionPublishServlet methodpost input typehidden nametoken value${requestScope.token} label标题input typetext nametitle required/labelbr label内容textarea namecontent required/textareabr button typesubmit发布文章/button /form /body /html重点看input typehidden nametoken value${requestScope.token}这一行。这里必须用requestScope而非sessionScope因为Token是在Servlet中生成后通过request.setAttribute(token, token)传入JSP的。如果误用sessionScope会导致所有页面共享同一个Token彻底失去防重意义。另外required属性不能省略——它强制浏览器端校验非空避免无效请求到达服务端这是第一道防线。3.2 Token生成与验证的Servlet核心代码PublishServlet.java的doPost方法需重构为三段式结构protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { // 第一段Token验证前置拦截 String clientToken request.getParameter(token); HttpSession session request.getSession(false); if (session null || clientToken null) { request.setAttribute(error, 非法请求缺少Token); request.getRequestDispatcher(error.jsp).forward(request, response); return; } MapString, Long tokenMap (MapString, Long) session.getAttribute(tokenMap); if (tokenMap null || !tokenMap.containsKey(clientToken)) { request.setAttribute(error, Token已失效请重新加载页面); request.getRequestDispatcher(publish.jsp).forward(request, response); return; } long createTime tokenMap.get(clientToken); if (System.currentTimeMillis() - createTime 15 * 60 * 1000) { tokenMap.remove(clientToken); // 过期自动清理 request.setAttribute(error, Token已过期请重新加载页面); request.getRequestDispatcher(publish.jsp).forward(request, response); return; } // 第二段业务逻辑仅当Token有效时执行 String title request.getParameter(title); String content request.getParameter(content); Article article new Article(title, content); boolean success articleService.publish(article); // 第三段Token销毁无论成功失败都必须执行 tokenMap.remove(clientToken); if (success) { request.setAttribute(message, 文章发布成功); request.getRequestDispatcher(success.jsp).forward(request, response); } else { request.setAttribute(error, 发布失败请重试); request.getRequestDispatcher(publish.jsp).forward(request, response); } }这段代码的精妙之处在于第三段的强制销毁。很多新手把tokenMap.remove(clientToken)写在if(success)分支里结果发布失败时Token未销毁用户修改内容后重试Token仍有效但业务逻辑可能因数据冲突再次失败形成死循环。必须保证Token在验证通过后立即作废这是防重的铁律。3.3 全局过滤器TokenFilter的标准化封装把Token验证逻辑写死在每个Servlet里是灾难性的。我们用Filter统一拦截所有发布类请求public class TokenFilter implements Filter { Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req (HttpServletRequest) request; HttpServletResponse resp (HttpServletResponse) response; // 只拦截POST请求且URL包含Publish if (POST.equalsIgnoreCase(req.getMethod()) req.getRequestURI().contains(Publish)) { String clientToken req.getParameter(token); HttpSession session req.getSession(false); if (session null || clientToken null) { forwardError(req, resp, 非法请求缺少Token); return; } MapString, Long tokenMap (MapString, Long) session.getAttribute(tokenMap); if (tokenMap null || !tokenMap.containsKey(clientToken) || System.currentTimeMillis() - tokenMap.get(clientToken) 15 * 60 * 1000) { forwardError(req, resp, Token无效或已过期); return; } // 验证通过继续执行后续Filter或Servlet chain.doFilter(request, response); // 关键在chain之后执行销毁确保Servlet已执行完毕 tokenMap.remove(clientToken); } else { chain.doFilter(request, response); } } private void forwardError(HttpServletRequest req, HttpServletResponse resp, String msg) throws ServletException, IOException { req.setAttribute(error, msg); req.getRequestDispatcher(/error.jsp).forward(req, resp); } }注意chain.doFilter()的位置——它必须在验证逻辑之后、销毁逻辑之前。这样能保证1验证失败时直接返回错误页不执行后续逻辑2验证成功时先让Servlet处理业务再销毁Token避免业务异常导致Token残留。3.4 web.xml中Filter的精确配置Filter的URL匹配规则决定其作用范围配置不当会导致漏防或误杀filter filter-nameTokenFilter/filter-name filter-classcom.example.filter.TokenFilter/filter-class /filter filter-mapping filter-nameTokenFilter/filter-name url-pattern/PublishServlet/url-pattern !-- 必须指定具体Servlet路径不能写/* -- dispatcherREQUEST/dispatcher /filter-mapping这里url-pattern必须精确到/PublishServlet而不是/publish/*或/*。因为/*会拦截所有静态资源CSS/JS导致页面样式丢失而/publish/*会误伤其他以publish开头的无关接口。Dispatcher设为REQUEST表示只拦截客户端直接发起的请求不拦截RequestDispatcher.forward()内部跳转避免干扰正常流程。4. 实操避坑指南那些文档里不会写的血泪教训4.1 Session超时导致Token丢失的静默故障现象用户打开发布页后去喝杯咖啡超过30分钟回来填完表单点击提交页面直接跳转到登录页或空白页控制台无任何错误日志。原因Tomcat默认Session超时时间为30分钟而我们的Token有效期是15分钟。当用户停留超时Session被容器销毁session.getAttribute(tokenMap)返回nullFilter中tokenMap.containsKey()抛出NullPointerException但Filter未捕获该异常导致整个请求链路中断。解决方案在Filter的doFilter方法最外层加try-catch并在catch块中显式转发错误页try { // 原有验证逻辑 } catch (Exception e) { // 记录日志便于排查 e.printStackTrace(); forwardError(req, resp, 系统繁忙请稍后重试); }同时在web.xml中延长Session超时时间session-config session-timeout60/session-timeout !-- 单位分钟 -- /session-config实操心得永远不要假设Session会一直存在。我在某电商后台项目中就吃过这个亏——促销期间用户停留时间长大量Token验证失败客服电话被打爆。后来我们加了前端倒计时提醒“您的编辑会话还剩2分钟点击此处续期”这才是用户体验的终极解法。4.2 中文乱码引发的Token校验失败现象用户在标题中输入“测试文章”提交后报“Token无效”但控制台打印的clientToken和Session中存储的Token看起来一模一样。原因JSP页面未声明字符编码导致表单提交时中文被ISO-8859-1编码而Servlet中request.getParameter()默认用ISO-8859-1解码造成Token字符串实际是乱码。例如原始Token是abc123乱码后变成ábc123自然比对失败。解决方案三重保障缺一不可JSP页面顶部添加% page contentTypetext/html;charsetUTF-8 %Tomcat的server.xml中Connector节点增加URIEncodingUTF-8属性Servlet中手动设置请求编码request.setCharacterEncoding(UTF-8);注意setCharacterEncoding()必须在getParameter()之前调用否则无效。这是JavaWeb中最经典的“顺序依赖”陷阱我带过的学员里90%都在这里卡过半小时以上。4.3 多线程环境下TokenMap的并发安全问题现象系统压力测试时偶尔出现“Token已失效”错误但单用户测试完全正常。原因HashMap不是线程安全的。当两个请求几乎同时到达都执行tokenMap.containsKey(clientToken)可能第一个请求刚查完存在第二个请求还没来得及remove第一个请求就执行了remove导致第二个请求验证失败。解决方案将HashMap替换为ConcurrentHashMap并在web.xml中初始化时注入// 在ServletContextListener中初始化 public class AppContextListener implements ServletContextListener { Override public void contextInitialized(ServletContextEvent sce) { ServletContext context sce.getServletContext(); context.setAttribute(tokenMap, new ConcurrentHashMapString, Long()); } }然后在Filter中改为MapString, Long tokenMap (MapString, Long) req.getServletContext().getAttribute(tokenMap);这样TokenMap成为全局共享对象所有Session都向它注册Token避免每个Session单独维护Map带来的内存浪费。4.4 浏览器开发者工具暴露的Token泄露风险现象安全审计报告指出“Token在HTML源码中明文暴露存在CSRF风险”。分析这确实是事实但对入门项目而言这是可接受的风险。CSRF攻击需要攻击者诱导用户访问恶意网站而该网站必须能预知目标网站的Token值——而我们的Token是随机生成且有时效的攻击者无法预测。真正的防护应在进阶阶段加入Referer校验或SameSite Cookie属性。临时缓解方案在生产环境启用HTTPS确保Token不会在HTTP明文中传输。本地开发时可在Filter中增加Referer白名单校验String referer req.getHeader(Referer); if (referer null || !referer.contains(publish.jsp)) { forwardError(req, resp, 非法来源请求); return; }警告Referer校验不能作为主要防护手段因为Referer可被客户端伪造。它只是给初级攻击者设置一道小门槛真正的安全水位要靠后续学习JWT和OAuth2来提升。5. 常见问题速查表与进阶演进路径问题现象根本原因快速定位方法修复方案提交后页面空白无任何错误提示Filter中未捕获NullPointerException查看Tomcat日志末尾是否有java.lang.NullPointerException堆栈在Filter doFilter方法外层加try-catch捕获Exception并转发错误页同一用户连续提交只有第一次成功后续均报“Token已失效”Token销毁逻辑位置错误在PublishServlet中搜索tokenMap.remove()确认是否在chain.doFilter()之后将销毁逻辑移至Filter的chain.doFilter()之后确保Servlet执行完毕本地测试正常部署到Linux服务器后Token总校验失败Linux系统默认时区为UTC与开发机时区不一致在服务器执行date命令对比与开发机时间差在web.xml中添加context-paramparam-nametimezone/param-nameparam-valueAsia/Shanghai/param-value/context-param并在Servlet中用Calendar.getInstance(TimeZone.getTimeZone(Asia/Shanghai))获取时间使用Chrome无痕模式时Token验证总是失败无痕模式禁用第三方Cookie导致Session ID无法持久化打开开发者工具Application标签页查看Cookies中是否有JSESSIONID在web.xml中配置Cookie属性session-configcookie-confighttp-onlytrue/http-onlysecurefalse/secure/cookie-config/session-config5.1 从本节到下一节的关键跃迁点本节的Token防重是单机Web应用的基石能力但真实项目必然走向更复杂的场景。以下是三个明确的进阶方向每个都对应下一节的核心内容方向一前后端分离改造。当前JSP渲染模式已过时下一节将用Ajax重构表单提交Servlet改为返回JSON前端用Vue接管UI。此时Token需从隐藏域转移到HTTP Header中Filter验证逻辑要适配application/json请求体。方向二数据库事务一致性。当前文章发布成功但Token销毁失败会导致用户无法再次提交。下一节将引入JDBC事务管理用connection.setAutoCommit(false)包裹Token销毁和文章插入确保二者原子性。方向三分布式Token存储。当项目部署到多台Tomcat时Session不共享问题浮现。下一节将集成Redis用SET token:abc123 valid EX 900 NX命令实现原子化Token写入并用Lua脚本保证验证删除的原子性。我个人在实际操作中的体会是不要试图一步到位做分布式。我曾在一个初创公司强行上Redis防重结果因网络抖动导致Token写入失败整个发布功能瘫痪两小时。后来回归单机Session方案稳定运行三年。技术选型的第一原则永远是“够用就好”先把单点逻辑锤炼到肌肉记忆再谈扩展。5.2 一个被99%教程忽略的终极验证技巧所有防重方案最终都要回归到“能否被暴力破解”这一本质。教你一个5分钟自测法打开浏览器开发者工具切换到Network标签页找到publish.jsp的GET请求右键选择“Copy as cURL”粘贴到终端执行curl http://localhost:8080/PublishServlet \ -H Content-Type: application/x-www-form-urlencoded \ --data-raw tokenabc123titletestcontenthello修改tokenabc123为任意字符串连续执行10次。合格的防重系统应做到1前9次返回“Token无效”2第10次即使token正确也返回“Token已失效”因为已被消耗。如果某次返回了“发布成功”说明你的Token销毁逻辑存在竞态条件必须回溯检查ConcurrentHashMap的使用方式。这个测试比写100行单元测试更直观因为它模拟了真实攻击者的操作路径。记住安全不是靠文档承诺的而是靠你亲手撕开它验证出来的。