PRG解决哪种重复提交:POST之后为什么用303跳到GET

发布时间:2026/11/2 15:01:05
PRG解决哪种重复提交:POST之后为什么用303跳到GET

表单提交成功,服务器直接返回一张“保存完成”的页面。用户刷新时,浏览器可能询问是否重新提交表单;如果再次发送POST,后台就有再次创建记录的风险。

PRG把这两件事分开:POST执行操作,GET展示操作结果。浏览器完成跳转后,当前结果页对应的是GET,请求结果页的刷新不会再走原来的创建接口。

先看一组HTTP交互

下面是协议示例,假设应用把一条笔记保存为资源/notes/42。

POST /notes HTTP/1.1 Host: example.com Content-Type: application/x-www-form-urlencoded Content-Length: 11 title=hello

保存成功后,服务器返回:

HTTP/1.1 303 See Other Location: /notes/42 Content-Length: 0

浏览器按照重定向去读取结果:

GET /notes/42 HTTP/1.1 Host: example.com

这个GET处理器负责查询并展示笔记,不能顺便再创建一遍笔记。HTTP对GET的安全与幂等语义,也需要应用代码正确实现;“使用GET”本身不会阻止错误的副作用。

图为协议流程示意。真正发生变化的是结果页的获取方式,不是浏览器替后台保证了业务只执行一次。

为什么明确选择303

在POST之后希望用户查看另一份资源时,303 See Other能够清楚表达这个意图。RFC 9110第15.4.4节定义的是转向另一个资源进行检索;普通表单的这个后续请求使用GET。

几种重定向状态码不能随意替换:

状态码POST之后的关键区别
302由于历史兼容行为,客户端可能把POST改为GET;语义表达不如这里直接用303清楚
303让客户端读取另一份结果资源,适合本文的POST→GET流程
307、308跟随重定向时保留请求方法和请求体,不能拿来代替本文的POST→GET步骤

例如,使用Jakarta Servlet 6.1时,保存成功后的代码可以明确写出状态码:

// noteId由已成功保存的业务结果提供;这是Servlet方法内的代码片段。 response.sendRedirect( request.getContextPath() + "/notes/" + noteId, HttpServletResponse.SC_SEE_OTHER ); return;

带状态码的sendRedirect重载从Servlet 6.1开始提供。旧版本不能直接照搬这个API,应采用自身框架支持的重定向响应方式,并在网络面板核对实际状态码与Location。Servlet 6.1 API

用一次真实表单提交检查刷新行为

本次在Windows本机用Node.js v24.22.1的内置HTTP服务搭了一个最小实验,再通过Edge提交普通HTML表单。服务仅监听127.0.0.1的动态端口,记录保存在进程内存里,有意没有业务去重;结果页设置Cache-Control: no-store,方便观察刷新产生的请求。

计数在服务器处理真实请求时增加,页面没有JavaScript计数器。这里记录的是POST /notes和结果页GET的次数,favicon请求不计入这两列。

完整程序、启动与停止步骤放在文末,可以在本机独立复现这组操作。

实际操作POST创建请求累计结果页GET累计演示记录数
提交一次title=hello111
刷新当前/notes/1结果页121
人工再次提交title=hello232

服务器已完成请求日志的关键字段,按发生顺序整理如下:

POST /notes -> 303 Location: /notes/1 GET /notes/1 -> 200 GET /notes/1 -> 200 (刷新结果页) POST /notes -> 303 Location: /notes/2 GET /notes/2 -> 200

第一次提交后,浏览器位于/notes/1;刷新该页时,只增加了一次结果页GET,创建POST和记录数都没有增加。GET访问计数变化,也不等于又执行了一次创建业务。

再次提交同样的hello,仍然经过303和GET,但这个没有去重逻辑的服务创建了第2条记录。若应用要把两次请求认定为同一个业务动作,需要另行定义业务标识和约束;内容相同本身并不自动意味着应该去重。

这些结果来自本次Edge文档导航和这个最小服务。双击、网络重试、多客户端并发以及其它客户端行为,还需要分别验证;它们没有被这一次实验覆盖。

它防不住哪几种重复

PRG作用于成功跳到GET结果页之后的刷新。以下情况仍可能产生多个POST:

  • 用户连续点击两次提交,两个请求都已发出。
  • POST已经写入数据库,响应却在途中丢失,客户端重试。
  • 两个客户端同时提交同一个业务操作。

涉及“同一业务动作只能成功一次”时,需要后端识别这是不是同一动作,例如使用业务唯一约束,或把幂等键、处理状态和业务写入放进一致的处理流程。不能只凭前端按钮置灰或一次重定向响应判断重复已经解决。

结果地址也需要检查访问权限。把资源ID放进URL,只解决如何定位资源,不代表拿到链接的人都应该能读取它;它是否适合分享、缓存,还取决于内容和响应策略。

在浏览器里检查什么

对一个可以测试的普通HTML表单,打开开发者工具的Network面板并保留导航请求:

  1. 提交一次表单,找到POST请求,查看返回状态是否为303、Location是否指向正确结果。
  2. 找到随后的结果请求,核对方法为GET。
  3. 刷新结果页,确认新增的是GET,而不是又发一次创建POST;同时核对后台记录没有新增。
  4. 再单独测试双击和超时重试,验证后端的重复处理策略。它们不是PRG本身的验收结果。

如果提交来自fetch或前端框架,客户端即使跟随了HTTP重定向,也不会因此自动把整个页面导航到结果地址。此时需要结合前端路由与接口协议设计交互。

完整复现这个本机实验

新建一个空目录,将下面的完整代码保存为server.mjs。这是本次已运行的版本,包含HTML表单、POST与GET处理器、计数面板、过程日志和关闭逻辑;只使用Node内置模块,不需要安装npm依赖。本次验证的Node版本是v24.22.1。

在保存文件的目录运行:

node --version node server.mjs

启动时会输出一条包含origin的JSON记录,也会生成runtime.json。在浏览器打开本次输出的origin地址。程序通过listen(0, '127.0.0.1')让系统分配空闲端口,每次启动的地址可能不同。

import http from 'node:http'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import os from 'node:os'; const directory = path.dirname(fileURLToPath(import.meta.url)); const eventPath = path.join(directory, 'requests.jsonl'); const snapshotPath = path.join(directory, 'state.json'); const completed = []; const state = { postCount: 0, resultGetCount: 0, notes: [] }; let origin = ''; let shuttingDown = false; let sequence = 0; fs.writeFileSync(eventPath, '', 'utf8'); const escape = value => String(value).replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;'); const save = () => fs.writeFileSync(snapshotPath, JSON.stringify({ recordedAt: new Date().toISOString(), ...state }, null, 2) + '\n', 'utf8'); save(); function writeEvent(event) { const row = { sequence: ++sequence, recordedAt: new Date().toISOString(), ...event }; fs.appendFileSync(eventPath, JSON.stringify(row) + '\n', 'utf8'); completed.push(row); console.log(JSON.stringify(row)); } function page(currentPath, note = null) { const redirect = note && completed.findLast(event => event.method === 'POST' && event.status === 303 && event.location === `/notes/${note.id}`); const heading = note ? `已创建第 ${note.id} 条演示记录` : '先提交一次表单,再刷新结果页'; const value = note?.title ?? 'hello'; const rows = state.notes.map(item => `<tr><td>${item.id}</td><td>${escape(item.title)}</td></tr>`).join(''); return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>PRG最小实验</title><style> *{box-sizing:border-box}body{margin:0;background:#f4f6f9;color:#172033;font:17px/1.5 system-ui,"Microsoft YaHei",sans-serif}main{max-width:760px;margin:18px auto;padding:20px 26px;background:#fff;border:1px solid #d8e0eb;border-radius:10px}.tag{font-size:14px;color:#2563eb;font-weight:650}h1{font-size:30px;line-height:1.2;margin:5px 0 9px}.address{font:14px/1.4 ui-monospace,Consolas,monospace;overflow-wrap:anywhere;color:#536176}.counts{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin:17px 0}.count{background:#eef3fb;border:1px solid #dbe4f2;padding:10px 14px;border-radius:6px}.count span{display:block;font-size:15px;color:#46536a}.count strong{display:block;font-size:40px;line-height:1.2;color:#174baf}.outcome{font-size:21px;font-weight:650;margin:9px 0}.response{font-size:14px;color:#536176;margin:4px 0 12px}form{display:flex;gap:10px;align-items:center;margin:12px 0 17px}label{white-space:nowrap;font-size:16px}input{width:180px;min-width:0;border:1px solid #aebcd0;border-radius:5px;padding:8px;font:inherit}button{border:0;border-radius:5px;background:#245ed6;color:white;padding:10px 15px;font:600 16px/1.2 inherit;cursor:pointer}h2{font-size:18px;margin:10px 0 4px}table{width:100%;border-collapse:collapse;font-size:16px}td,th{padding:5px 10px;text-align:left;border-bottom:1px solid #e2e8f0}th{color:#536176;font-weight:500}.foot{font-size:13px;color:#657187;margin:13px 0 0}@media(max-width:600px){main{margin:0;border-radius:0;padding:18px}.counts{gap:6px}.count{padding:8px}.count span{font-size:13px}.count strong{font-size:33px}form{flex-wrap:wrap}h1{font-size:26px}} </style></head><body><main><div class="tag">HTTP行为验证 · 最小实验 · 仅本机</div><h1>POST → 303 → GET</h1><div class="address">当前页面:${escape(origin + currentPath)}</div><div class="counts"><div class="count"><span>POST创建请求</span><strong id="post-count">${state.postCount}</strong></div><div class="count"><span>GET结果页请求</span><strong id="get-count">${state.resultGetCount}</strong></div><div class="count"><span>创建记录数</span><strong id="record-count">${state.notes.length}</strong></div></div><p class="outcome">${heading}</p>${redirect ? `<p class="response">服务器已完成响应:POST /notes → ${redirect.status},Location: ${escape(redirect.location)}</p>` : '<p class="response">计数由Node处理真实HTTP请求时更新,页面不使用JavaScript计数。</p>'}<form method="post" action="/notes"><label for="title">演示内容</label><input id="title" name="title" value="${escape(value)}" maxlength="80" required><button type="submit">${note ? '再次提交相同内容' : '提交演示表单'}</button></form><h2>本进程已创建的记录</h2><table><thead><tr><th>记录ID</th><th>内容</th></tr></thead><tbody>${rows || '<tr><td colspan="2">尚无记录</td></tr>'}</tbody></table><p class="foot">响应缓存:no-store。示例使用进程内存,并有意不做业务去重;刷新与重新POST是不同操作。</p></main></body></html>`; } const server = http.createServer(async (req, res) => { const requestPath = new URL(req.url, origin || 'http://127.0.0.1').pathname; const requestTitle = { value: null }; res.setHeader('Cache-Control', 'no-store'); res.on('finish', () => writeEvent({ method: req.method, path: requestPath, status: res.statusCode, location: res.getHeader('Location') ?? null, title: requestTitle.value, counters: { post: state.postCount, resultGet: state.resultGetCount, records: state.notes.length }, userAgent: req.headers['user-agent'] ?? null, })); try { if (req.method === 'GET' && requestPath === '/') { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(page('/')); return; } if (req.method === 'POST' && requestPath === '/notes') { let body = ''; for await (const chunk of req) { body += chunk.toString('utf8'); if (Buffer.byteLength(body, 'utf8') > 4096) { res.writeHead(413).end(); return; } } const title = new URLSearchParams(body).get('title')?.slice(0, 80) ?? ''; if (!title.trim()) { res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' }).end('请提供演示内容'); return; } state.postCount += 1; const note = { id: state.notes.length + 1, title }; state.notes.push(note); requestTitle.value = title; save(); res.writeHead(303, { Location: `/notes/${note.id}` }); res.end(); return; } const match = requestPath.match(/^\/notes\/(\d+)$/); if (req.method === 'GET' && match) { const note = state.notes.find(item => item.id === Number(match[1])); if (!note) { res.writeHead(404).end(); return; } state.resultGetCount += 1; save(); res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(page(requestPath, note)); return; } if (req.method === 'GET' && requestPath === '/favicon.ico') { res.writeHead(204).end(); return; } res.writeHead(404).end(); } catch (error) { writeEvent({ type: 'handler_error', message: String(error) }); if (!res.headersSent) res.writeHead(500); res.end(); } }); server.listen(0, '127.0.0.1', () => { const address = server.address(); origin = `http://127.0.0.1:${address.port}`; const runtime = { pid: process.pid, origin, address, node: process.version, os: { platform: process.platform, release: os.release() }, startedAt: new Date().toISOString(), demoOnly: true }; fs.writeFileSync(path.join(directory, 'runtime.json'), JSON.stringify(runtime, null, 2) + '\n', 'utf8'); writeEvent({ type: 'server_started', ...runtime }); }); function shutdown(reason) { if (shuttingDown) return; shuttingDown = true; save(); server.close(() => { const final = { stoppedAt: new Date().toISOString(), reason, ...state }; fs.writeFileSync(path.join(directory, 'final-state.json'), JSON.stringify(final, null, 2) + '\n', 'utf8'); writeEvent({ type: 'server_stopped', ...final }); process.stdin.pause(); process.exit(0); }); server.closeIdleConnections?.(); } process.on('SIGINT', () => shutdown('SIGINT')); process.on('SIGTERM', () => shutdown('SIGTERM')); process.stdin.setEncoding('utf8'); process.stdin.on('data', value => { if (value.split(/\r?\n/).some(line => line.trim() === 'shutdown')) shutdown('owned_exec_stdin'); });

按前面的实验方式,在普通浏览器页面中依次操作:

  1. 保持演示内容为hello,点击“提交演示表单”。本次Edge中的成功标志是地址来到/notes/1,三个计数依次为1 / 1 / 1。
  2. 刷新这个结果页。地址仍为/notes/1,计数变为1 / 2 / 1:只增加了结果页GET。
  3. 点击“再次提交相同内容”。地址来到/notes/2,计数为2 / 3 / 2,表中出现两条hello记录。这个示例没有业务去重,第二个POST仍会创建记录。

终端输出与同目录的requests.jsonl记录每次已完成响应的方法、路径、状态、Location和计数;state.json保留当前快照。可以用这些记录对照页面,而不是只看按钮点击次数。favicon请求单独记录,不计入结果页GET次数。

完成后,先关闭这个实验页面,再回到运行Node的终端,输入shutdown并回车。看到server_stopped且命令结束,说明该服务已经关闭,结束状态会写入final-state.json。

每次启动都从零计数并重写本例请求日志;需要保留一轮结果时,先复制该实验目录。这里保存的是进程内存中的演示记录,不会连接外部数据库或修改网络设置。

参考:MDN 303、MDN 307、RFC 9110安全与幂等语义。

修订于2026年11月1日:明确303与307/308的区别,补最小浏览器实验、实际计数与业务幂等边界。