用幂等键和事务发件箱,彻底避免 Next.js 表单重复提交

2026-08-18 31 预计阅读时间: 1 分钟
来源: postgr.es AI 摘要 Original link

Disclaimer: This article is an AI-assisted summary. Read it together with the original source when precision matters. The summary may omit context, version differences, or edge cases and is not official documentation.

预计阅读时间:17 分钟

用户填写表单、点击提交,却迟迟看不到响应。于是他再次点击。问题在于:第一次请求可能已经成功写入数据库,只是响应在网络中丢失了。结果可能是两个潜客、两封确认邮件、两次 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 事务,这就是事务发件箱模式。

核心流程如下:

  1. 使用 INSERT ... ON CONFLICT DO NOTHING 尝试插入提交;
  2. 如果没有插入新行,读取已存在的记录;
  3. 比较请求指纹,匹配则返回已保存的回执;
  4. 新提交则在同一事务中写入每个集成事件;
  5. 保存一个稳定的 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 和其他外部副作用。

最应该优先设计的失败场景,是“服务端成功了,但响应丢了”。当这个场景可以安全重试时,表单系统才真正准备好面对生产流量。


相关推荐