用户填写表单、点击提交,却迟迟看不到响应。于是他再次点击。问题在于:第一次请求可能已经成功写入数据库,只是响应在网络中丢失了。结果可能是两个潜客、两封确认邮件、两次 CRM 更新,以及两条分析事件。
这不是单纯的前端交互问题,而是一个缩小版的分布式系统问题。浏览器会重试,移动网络会在服务端提交成功后断开,Serverless 函数可能在提交后超时,队列也可能重复投递任务。服务端无法仅凭两份相同数据判断它们是一次逻辑操作的重复传递,还是用户真的提交了两次。
真正要保证的不是“只收到一个 HTTP 请求”,而是“同一个逻辑提交被重复传递时,仍然只产生一个业务结果”。这就是幂等性。
禁用按钮不等于幂等
禁用提交按钮仍然值得做。它能阻止快速双击,给用户明确的处理中反馈。但它无法覆盖这些情况:
- 用户刷新页面后重新提交;
- 用户在两个浏览器标签页中提交;
- 客户端、代理或 SDK 自动重试;
- Serverless 函数已经提交成功,但响应超时;
- 后台 Worker 重复处理同一个事件;
- 对账任务主动重试不确定的失败。
前端状态负责改善交互,数据库约束才是系统保证。两者应该同时存在,但不能把 React 状态当作唯一防线。
幂等契约可以明确为:
- 每个新的逻辑提交生成一个不可预测的幂等键;
- 同一次提交的所有重试都复用这个键;
- 用户明确开始新的提交时,才生成新键;
- 相同表单、相同幂等键、相同数据,返回之前保存的结果;
- 相同幂等键携带不同数据,返回
409 Conflict,而不是悄悄复用旧结果。
在提交边界生成幂等键
幂等键应该在一次逻辑提交开始时生成,而不是在每次 fetch 调用中生成。网络错误意味着客户端不知道服务端是否已经提交成功,此时换一个新键重试,正是把一次成功变成两次提交的原因。
下面是一个可以直接改造到 Next.js 客户端组件中的最小示例。activeKey 会在当前页面会话中保存同一次提交的身份:
'use client'
import { useRef, useState } from 'react'
export function LeadForm({ formId }: { formId: string }) {
const activeKey = useRef<string | null>(null)
const [submitting, setSubmitting] = useState(false)
async function submit(payload: Record<string, unknown>) {
const key = activeKey.current ?? crypto.randomUUID()
activeKey.current = key
setSubmitting(true)
try {
const response = await fetch(`/api/forms/${formId}/submit`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': key,
},
body: JSON.stringify(payload),
})
if (response.status === 409) {
activeKey.current = null
throw new Error('This submission was retried with different data.')
}
if (!response.ok) {
throw new Error('Submission failed')
}
const receipt = await response.json()
activeKey.current = null
return receipt
} finally {
setSubmitting(false)
}
}
// Render form fields and pass `submitting` to the submit button.
return null
}
如果产品要求用户刷新页面后仍能恢复未完成的提交,可以把幂等键和草稿一起放到 sessionStorage 或本地存储中,并定义明确的过期时间。不要让一个访客永久复用同一个键,否则用户后续真正的新提交可能会被错误地当成旧请求。
让 PostgreSQL 解决并发竞争
“先查询是否存在,再插入”的应用层逻辑无法单独解决并发问题。两个请求可能同时查询,都看到没有记录,然后同时插入。必须让数据库的唯一约束成为原子保证。
可以这样创建提交表和下游事件表:
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE form_submissions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
form_id uuid NOT NULL,
schema_version integer NOT NULL,
idempotency_key text NOT NULL,
request_hash text NOT NULL,
payload jsonb NOT NULL,
status text NOT NULL DEFAULT 'accepted',
response_json jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (form_id, idempotency_key)
);
CREATE TABLE integration_events (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
submission_id uuid NOT NULL REFERENCES form_submissions(id),
integration_id uuid NOT NULL,
delivery_key text NOT NULL UNIQUE,
status text NOT NULL DEFAULT 'pending',
attempt_count integer NOT NULL DEFAULT 0,
next_attempt_at timestamptz NOT NULL DEFAULT now(),
lease_expires_at timestamptz,
last_error_code text,
UNIQUE (submission_id, integration_id)
);
唯一键的范围要和业务操作一致。表单平台通常使用 (form_id, idempotency_key)。如果一个 API 端点执行多种操作,还应把操作类型纳入唯一范围。除非所有租户确实共享同一个命名空间,否则不要使用全局幂等键。
为什么还要保存请求指纹
幂等键表达的是“这一次逻辑操作的身份”,请求指纹则保护这个身份不被错误复用。服务端应当在完成校验和规范化之后,对 formId、Schema 版本和规范化后的 payload 计算哈希。
例如,邮箱可以先去除首尾空格,电话号码可以先按表单契约统一格式,然后再计算指纹。时间戳、Trace ID、重试次数等每次都会变化的元数据不应参与哈希。对象键顺序也不应影响结果,因此需要稳定的 Canonical JSON:
import { createHash } from 'node:crypto'
function canonicalJson(value: unknown): string {
if (Array.isArray(value)) {
return `[${value.map(canonicalJson).join(',')}]`
}
if (value && typeof value === 'object') {
const entries = Object.entries(value as Record<string, unknown>)
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, item]) => `${JSON.stringify(key)}:${canonicalJson(item)}`)
return `{${entries.join(',')}}`
}
return JSON.stringify(value)
}
export function fingerprintSubmission(input: {
formId: string
schemaVersion: number
payload: Record<string, unknown>
}) {
return createHash('sha256')
.update(canonicalJson(input))
.digest('hex')
}
当数据库发现相同的 (form_id, idempotency_key) 时,比较新旧 request_hash:相同就返回原始回执,不同就返回 409 Idempotency Conflict。这样可以暴露客户端 bug,而不是把新数据错误地绑定到旧提交上。
用事务发件箱延伸到 CRM 和邮件
只保存 form_submissions 仍然不够。服务端可能刚写入提交记录就崩溃,导致 CRM、邮件、短信或 Webhook 永远没有被调度。解决办法是把提交记录和下游集成事件放进同一个 PostgreSQL 事务,这就是事务发件箱模式。
核心流程如下:
- 使用
INSERT ... ON CONFLICT DO NOTHING尝试插入提交; - 如果没有插入新行,读取已存在的记录;
- 比较请求指纹,匹配则返回已保存的回执;
- 新提交则在同一事务中写入每个集成事件;
- 保存一个稳定的
response_json,让重试得到同一个业务结果。
import type { Pool } from 'pg'
export async function acceptSubmission(db: Pool, input: {
formId: string
schemaVersion: number
key: string
requestHash: string
payload: Record<string, unknown>
integrationIds: string[]
}) {
const client = await db.connect()
try {
await client.query('BEGIN')
const inserted = await client.query(
`INSERT INTO form_submissions
(form_id, schema_version, idempotency_key, request_hash, payload)
VALUES ($1, $2, $3, $4, $5::jsonb)
ON CONFLICT (form_id, idempotency_key) DO NOTHING
RETURNING id`,
[
input.formId,
input.schemaVersion,
input.key,
input.requestHash,
JSON.stringify(input.payload),
],
)
if (inserted.rowCount === 0) {
const existing = await client.query(
`SELECT id, request_hash, response_json
FROM form_submissions
WHERE form_id = $1 AND idempotency_key = $2`,
[input.formId, input.key],
)
const row = existing.rows[0]
if (!row || row.request_hash !== input.requestHash) {
const error = new Error('Idempotency conflict')
;(error as Error & { status?: number }).status = 409
throw error
}
await client.query('COMMIT')
return { ...row.response_json, replayed: true }
}
const submissionId = inserted.rows[0].id
const receipt = { submissionId, accepted: true }
for (const integrationId of input.integrationIds) {
const deliveryKey = `${submissionId}:${integrationId}`
await client.query(
`INSERT INTO integration_events
(submission_id, integration_id, delivery_key)
VALUES ($1, $2, $3)
ON CONFLICT (submission_id, integration_id) DO NOTHING`,
[submissionId, integrationId, deliveryKey],
)
}
await client.query(
`UPDATE form_submissions
SET response_json = $2::jsonb
WHERE id = $1`,
[submissionId, JSON.stringify(receipt)],
)
await client.query('COMMIT')
return { ...receipt, replayed: false }
} catch (error) {
await client.query('ROLLBACK')
throw error
} finally {
client.release()
}
}
在 Next.js Route Handler 中,应先完成认证、表单查询、payload 校验、授权和同意信息校验,再写入幂等记录。无效请求不应占用幂等键。接口还需要继续使用限流、机器人检测、验证码或风险评分,因为幂等性解决的是重复传递,不是恶意流量。
每个外部副作用都需要自己的身份
提交记录去重只解决了一半问题。事件 Worker 也可能重复运行,因此每个“提交 + 集成”组合都要有稳定的 delivery_key。Worker 应把已完成的事件视为成功,并在供应商支持时把该键作为供应商幂等键传递出去;不支持幂等键的供应商,则尽量使用稳定的外部引用或 Upsert。
本地 completed 标志无法单独提供 Exactly-once 保证:进程可能在供应商已经成功后、更新本地状态前崩溃。下一次重试仍可能再次调用供应商。
Worker 还应使用带过期时间的租约。租约解决“同一时刻多个 Worker 同时处理”的问题,delivery_key 解决“调用结果不确定后再次重放”的问题,两者缺一不可。
WITH candidate AS (
SELECT id
FROM integration_events
WHERE (
status IN ('pending', 'retryable')
AND next_attempt_at <= now()
) OR (
status = 'processing'
AND lease_expires_at < now()
)
ORDER BY next_attempt_at
FOR UPDATE SKIP LOCKED
LIMIT 1
)
UPDATE integration_events AS event
SET status = 'processing',
attempt_count = attempt_count + 1,
lease_expires_at = now() + interval '2 minutes'
FROM candidate
WHERE event.id = candidate.id
RETURNING event.*;
对超时、限流等临时错误使用指数退避和随机抖动。无效凭证、错误映射和达到最大重试次数的事件应进入需要人工调查的终态,而不是无限重试。
如果外部 API 既没有幂等键,也没有可 Upsert 的外部引用,那么跨网络边界的 Exactly-once 交付在技术上无法保证。此时应记录这种限制,缩短不确定窗口,并提供运维工具,让人工在重放前查看供应商侧是否已经成功。
让日志能解释一次重复提交
排查问题时,需要区分“同一提交的重放”和“用户进行了第二次合法提交”。建议记录表单 ID、提交 ID、事件 ID、请求指纹、投递尝试次数和供应商请求 ID。幂等键本身是否进入日志要遵循安全策略;很多系统只记录它的哈希或缩短后的关联值。
不要把完整 payload 附加到错误监控中。表单数据通常包含邮箱、电话、自由文本和同意信息。稳定的内部标识已经足够让授权人员回到业务数据库中查询详情。
幂等键还需要明确保留策略。删除旧键后,迟到的重试可能被当作新操作;永久保留则会增加存储成本,并可能掩盖客户端长期复用键的问题。应记录 created_at,定义重放窗口,并在数据量较大时归档或分区。键过期后,迟到请求是否算新操作,是业务决策,不是实现细节。
测试真正危险的失败窗口
只写顺序单元测试不足以证明幂等性。高价值测试应同时发送多个相同请求,并断言数据库只有一条提交记录、每个配置中的集成只有一个事件:
it('accepts concurrent retries only once', async () => {
const key = crypto.randomUUID()
const attempts = await Promise.all(
Array.from({ length: 10 }, () =>
submitForm({
formId,
key,
payload: validLead,
}),
),
)
expect(new Set(attempts.map(item => item.submissionId)).size).toBe(1)
expect(await countSubmissions({ formId, key })).toBe(1)
expect(
await countEventsForSubmission(attempts[0].submissionId),
).toBe(enabledIntegrations.length)
})
it('rejects the same key with different data', async () => {
const key = crypto.randomUUID()
await submitForm({ formId, key, payload: validLead })
await expect(
submitForm({
formId,
key,
payload: { ...validLead, email: 'other@example.com' },
}),
).rejects.toMatchObject({ status: 409 })
})
还应覆盖这些场景:数据库提交成功但响应丢失、Worker 在供应商成功后崩溃、两个对账任务并行运行、租约过期、供应商临时错误、供应商永久错误,以及初次提交和重试之间发生 Schema 版本变化。
上线前检查清单
- [ ] 提交按钮在请求处理中禁用,并提供明确反馈
- [ ] 每次逻辑提交生成一个随机幂等键
- [ ] 网络错误和超时重试时复用原键
- [ ] 服务端校验幂等键、权限和 payload
- [ ] 对规范化输入计算请求指纹
- [ ] PostgreSQL 建立
UNIQUE(form_id, idempotency_key) - [ ] 相同键和相同数据返回原始回执
- [ ] 相同键和不同数据返回
409 - [ ] 提交与集成事件在一个事务中写入
- [ ] 每个下游事件拥有稳定的
delivery_key - [ ] 外部供应商尽量使用幂等键或外部引用
- [ ] Worker 使用带过期时间的租约
- [ ] 对账任务能够恢复废弃和可重试事件
- [ ] 测试并发请求和提交后的崩溃窗口
- [ ] 日志只记录脱敏后的关联信息
- [ ] 文档化幂等键的保留和过期策略
可靠的表单并不是永远不会收到重复请求,而是即使重复收到同一个逻辑请求,也只产生一个提交、一组持久化的集成事件和一个可解释的结果。
禁用按钮让界面更清晰,稳定的幂等键让重试拥有身份,PostgreSQL 唯一约束解决并发竞争,指纹防止键被错误复用,事务发件箱、投递键、租约和对账则把这项保证延伸到邮件、CRM 和其他外部副作用。
最应该优先设计的失败场景,是“服务端成功了,但响应丢了”。当这个场景可以安全重试时,表单系统才真正准备好面对生产流量。