本节摘要:页面跳转有两条本质不同的路线——转发是容器内部把请求转手给另一资源,全程只有一次请求;重定向是让浏览器对新地址重新发一次请求,一共两次。参数是否还在、地址栏变没变、刷新会不会重复提交,全部由走哪条路线决定。本节用青梧书肆一次真实的重复下单故障,把这两条路线的差异钉进你的排错直觉。它是第 2 章"请求旅程"主线的收尾站,也是第 4 章 MVC 控制器决定跳转方式时的决策依据。
2.2 节说过,控制器传给视图的数据放在 request 作用域,"一次请求用完即弃"。这句话隐含了一个前提:视图和控制器必须在同一次请求里。维持这个前提的机制叫转发——容器在服务器内部把处理权转交给另一个资源,请求对象原封不动地带过去,request 里的数据在目标页面继续可见。浏览器对此一无所知,地址栏纹丝不动。
另一条路线是重定向:服务器不替你转手,而是回一句"请改地址重新访问某某"。浏览器收到这个指令后自己再发一次全新的请求。两次请求意味着两个不同的 request 对象——第一个请求里放的属性,到了第二个请求里全部蒸发。
两条路线的行为差异可以画成对照图,值得收进你的排错手册。

代码形态上,两者都只有一行,但出处与效果截然不同:
// 写法一:转发 —— 容器内部转手,request 原样带过去 request.setAttribute("order", order); request.getRequestDispatcher("/cart/checkout.jsp") .forward(request, response); // 写法二:重定向 —— 让浏览器对新地址重新发起请求 response.sendRedirect(request.getContextPath() + "/cart/checkout.jsp");
转发对象拿到的是容器内部的资源路径,浏览器永远看不到这行代码;重定向的地址则会出现在浏览器的地址栏里——这也是排查时最直观的区分手段。
背景:青梧书肆上线后运营接到投诉:顾客结算完成后习惯性按了 F5,结果购物记录里出现两笔一样的订单,其中一笔还得走退款流程。工单转到你手里,现象稳定复现:在订单完成页按刷新,每按一次多一笔订单。
操作:先用三问口诀定位。地址栏在跳转后没有变化——说明走的不是重定向;刷新会重复产生订单——说明刷新重放的是包含下单逻辑的那次请求。打开结算处理代码,确认了猜想:下单逻辑写在一个页面里,处理完数据库写入后,用转发跳到订单完成页。
结果:把完成页的跳转从转发改为重定向,并顺手把下单逻辑与展示拆开——处理页写完库后重定向到只读的完成页。刷新完成页,不再产生新订单。
解读:根因链条值得复盘一遍。转发意味着"完成页的响应"和"下单的写入"同属一次请求;浏览器刷新的语义是"把上一次请求重发一遍",于是写入逻辑原样重放。改成重定向后,浏览器上一次请求变成了那个只读的新地址,刷新重放的只是查询——这就是 Web 开发里流传几十年的"提交后重定向"模式,本质是用一次额外跳转换来刷新幂等。代价是 request 里的数据过不去了,下单结果得靠订单号在新请求里重新查询。
变式:反方向的故障也常见——有人按"转发后 request 取不到值"来报障,你到场一看,代码写的其实是重定向。这类报错的根源是混淆了两种写法的语义。诊断顺序记住:先看地址栏变没变,再谈取值。另外,转发链上如果目标页面又转发回来源页面,会形成容器内部的循环转发,最终以容器强制中断收场,报错信息里会明确出现"转发次数超限"字样——见到它,直接画转发路径图理清环路。
⚠️ 常见坑:在转发之后再写输出或再发重定向——响应已经"许给"转发的目标页了,后续动作要么抛状态异常要么被静默忽略。跳转语句之后立刻返回,是老代码里最值得顺手加固的卫生习惯。
把本节收敛成三条可执行的选择题。第一问:这次跳转发生在写操作之后吗?是,选重定向——刷新不得重放写操作,这条没有商量余地。第二问:目标页面需要本次请求里的数据吗?需要且不便重新查询,选转发。第三问:希望用户看到干净的地址吗?重定向让地址栏直达最终页面,用户收藏、分享、刷新都安全;转发会把中间处理页的地址留在地址栏里,用户一收藏就是坑。
三条问完,选择通常只有一个答案。青梧书肆后续的 MVC 改造(第 4 章)会把所有跳转收拢到控制器里,届时这三问会变成控制器代码里的固定注释——你现在打下的地基,三章之内就会派上用场。
排查实录之外,三个衍生问题值得备着。其一,转发能不能跨应用?默认不能——转发被限制在同一应用上下文之内,这是容器的安全边界之一;跨应用跳转只能走重定向,或由容器显式开启跨上下文能力(老站里极罕见,遇到先当陷阱处理)。其二,重定向的地址要不要带应用前缀?要。重定向是让浏览器重新发请求,地址必须从应用根开始写全,忘了拼前缀是"开发环境正常、部署后 404"的高发病因。其三,转发链上的输出顺序是怎样的?转发会清掉当前响应缓冲里未提交的内容,再由目标页面从头输出——所以"转发前多写了一段内容"通常不会显示出来,但如果内容已经提交出去(缓冲刷过),转发就会直接抛异常。这也再次解释了那条"跳转之后立刻返回"的纪律。
分享一个综合了本节全部知识的排查故事。青梧书肆曾有一段时期,运维偶尔收到"订单完成页地址栏里躺着处理页地址"的报告——用户把中间处理页的地址收藏了,下次打开就是一张空表单。按三问口诀判断:地址栏里是处理页,说明那跳转用的是转发;而处理页属于写操作,按纪律本该重定向。翻代码发现,这段跳转是多年前有人图省事写的转发,一直没出事是因为没人收藏过那个地址。整改动作有二:把该处跳转改成重定向,补一句口诀注释;随后做了一次全站跳转审计——搜索所有跳转语句,逐个核对"写后重定向、展示转发"的纪律,又揪出三处同类隐患。跳转审计半小时能做完,建议接手任何老站都做一次:跳转写法的错误不爆则已,一爆就是重复提交或收藏坏链这类直接影响用户的毛病。
至此,一次请求在容器里的完整旅程——匹配、编译、执行、状态、跳转——你已经全程走完。下一章拿着这张地图进入施工阶段:把青梧书肆页面里盘根错节的语法元素逐一清点、登记、拆解,为 MVC 重构备料。