工程实践 · 2026 年 9 月 21 日

邮件 API 的幂等键

通过实现幂等键,防止网络重试时发送重复邮件。了解如何处理分布式系统故障,而不会给用户发送垃圾邮件。

重复邮件问题

重复邮件的产生过程是这样的:客户端发送请求,服务器处理了请求,但在客户端收到成功响应之前网络出现故障。客户端看到超时或 5xx 错误后重试请求。如果没有幂等处理,服务器会把重试当作新请求,再发一次邮件。幂等键可以让服务器识别出重复请求,直接返回原始结果,而不再重复执行副作用,从而避免这一问题。

对负责事件队列的工程师来说,没有什么比“重复邮件风暴”更糟糕了。它通常发生在上游服务商部分故障,或数据库死锁拖慢响应时间的时候。原本为可靠性设计的重试逻辑,反而变成了向用户狂发邮件、损害发件人信誉的武器。

为什么没有幂等时重试会出问题

在分布式系统中,任何 API 调用都有三个可能的失败点:

  1. 请求根本没有到达服务器。
  2. 服务器处理了请求,但响应丢失了。
  3. 服务器在处理过程中崩溃。

在第 1 种情况下重试是安全的。在第 2 种情况下重试会产生重复邮件。在第 3 种情况下,是否产生重复取决于崩溃发生在哪个环节。

发送邮件是一种外部副作用。与在数据库中更新用户名不同(如果使用 SET name = 'Alice',这天然就是幂等的),发送邮件是一种累加操作:每次调用 send 端点,都会在现实世界中产生一封新邮件。要让它变得幂等,您必须为“发送意图”引入一个唯一标识符,也就是幂等键。

实现幂等键

幂等键是由客户端生成、放在请求头中发送的唯一值(通常是 UUID v4)。服务器使用这个键来跟踪请求的状态。

服务端工作流程

  1. 接收请求:服务器检查是否存在 Idempotency-Key 请求头。
  2. 查找:服务器在高速存储(如 Redis)中查找该键。
  3. 缓存命中:如果该键已存在,服务器立即返回缓存的响应,而不调用邮件投递引擎。
  4. 缓存未命中:服务器锁定该键,处理邮件发送,存储响应,然后返回给客户端。
  5. 过期:为该键设置一个过期时间窗口(例如 24 小时),防止数据库无限增长。

负载示例

使用 SendHQ 这类 API 时,请求应该是这样的:

POST /v1/send Host: api.sendhq.cc Content-Type: application/json Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000 Authorization: Bearer YOUR_API_KEY { "to": "user@example.com", "template_id": "welcome-email", "variables": { "name": "Alex" } }

处理错误情况

并非所有重试都应一视同仁。您必须区分客户端错误和服务器错误。

  • 4xx 错误:如果服务器返回 400(Bad Request)或 422(Unprocessable Entity),说明请求无效。使用相同的键重试应返回相同的 4xx 错误。不要修改负载后复用同一个键,否则会造成冲突。
  • 5xx 错误:如果服务器返回 500 或 503,客户端应当重试。如果服务器此前已成功将邮件交给 MTA(邮件传输代理),幂等键可确保重试返回 200 OK,而不是再发一封邮件。
  • 并发请求:如果两个带有相同键的相同请求恰好在同一毫秒到达,服务器应对第二个请求返回 409 Conflict,表明第一个请求仍在处理中。

面向 AI 智能体的幂等

AI 智能体(使用 MCP 服务器或 A2A 卡片)带来了新的风险层。LLM 可能具有不确定性,如果它认为循环中出现了失败,就可能多次触发同一个工具调用。

在构建面向智能体的集成时,绝不应允许智能体在没有审批步骤、也没有由编排器生成的确定性幂等键的情况下触发 send 操作。编排器应将智能体的意图(例如“把周报发给 Bob”)映射为一个基于报告 ID 和日期的稳定键。这样可以防止智能体因为“以为”第一次调用失败了,而意外地把同一份报告发送五次。

失败的代价:服务商对比

不实现幂等,您不仅会惹恼用户,还会浪费金钱。虽然有些服务商更便宜,但重复邮件的成本增长得很快。

根据官方价格页面(截至 2026 年 9 月):

  • Amazon SES:按量计费为每 1,000 封邮件 0.10 USD(Amazon SES 价格)。2026 年 7 月 21 日推出的新分级套餐包括 Essentials(每 1,000 封 0.16 USD)、Pro(每 1,000 封 0.22 USD,另加每个区域每月 105 USD)和 Enterprise(每 1,000 封 0.23 USD,另加每月 500 USD)。
  • Resend:免费额度为每月 3,000 封邮件,每天上限 100 封。Pro 套餐为每月 20 USD、含 50,000 封邮件,超出部分每 1,000 封 0.90 USD(Resend 价格)。
  • SendGrid:免费版现已改为 60 天试用,Essentials 套餐起价为每月 19.95 USD(SendGrid 价格)。
  • Mailgun:每月 15 USD、含 10,000 封邮件,超出部分每 1,000 封 1.80 至 1.10 USD(Mailgun 价格)。
  • Postmark:每月 15 USD、含 10,000 封邮件,超出部分每 1,000 封 1.80 至 1.20 USD(Postmark 价格)。

直观地说,发送 50,000 封邮件在 SES 按量计费下约为 5 USD,而在 Postmark 的套餐下约为 66 USD。如果一个没有幂等保护的重试循环意外地把您的发送量放大了 10 倍,服务商之间的费用差异就会成为事故报告中一笔不小的开支。

送达与受理

务必明白,幂等只解决受理环节的问题。

  1. 受理:API 接受您的请求并返回 200 OK。幂等键作用于这一环节。
  2. 投递:API 将邮件交给收件服务器(例如 Gmail)。这一环节 SPF 和 DKIM/DMARC 很重要。
  3. 收件箱送达:收件服务器决定邮件进入收件箱还是垃圾邮件文件夹。

幂等键只能确保请求被受理一次,并不能保证邮件被送达,也不能保证它不进垃圾邮件文件夹。为确保您的基础设施已为投递正确配置,您应使用 SendHQ 邮件 DNS 检查工具等工具来验证您的记录。

工程师实施清单

如果您今天要审查自己的邮件发送逻辑,可以使用这份清单:

  • 客户端密钥生成:您是否为每个唯一邮件意图生成 UUID v4?
  • 邮件头实现:密钥是否通过标准请求头(例如 Idempotency-Key)而非请求体传递?
  • 存储层:您的幂等键是否设置了 TTL(生存时间),以防止存储膨胀?
  • 原子锁定:您的服务器是否使用分布式锁(如 Redis 中的 SET NX)来防止同一密钥上的竞争条件?
  • 响应缓存:您是否存储完整响应(状态码和正文),以便在重试时返回给客户端?
  • 智能体护栏:若使用 AI 智能体,密钥是否由系统编排器而非 LLM 生成?

代码示例:Node.js 幂等中间件

下面是一个简化示例,展示如何在 Node.js 环境中使用 Redis 实现这一逻辑。

const redis = require('redis'); const client = redis.createClient(); async function sendEmailHandler(req, res) { const idempotencyKey = req.headers['idempotency-key']; if (!idempotencyKey) { return res.status(400).json({ error: 'Idempotency-Key header is required' }); } // Try to acquire a lock and check for existing response const cachedResponse = await client.get(`idempotency:${idempotencyKey}`); if (cachedResponse) { const { status, body } = JSON.parse(cachedResponse); return res.status(status).json(body); } // Set a lock to prevent concurrent requests const lock = await client.set(`lock:${idempotencyKey}`, 'true', 'NX', 'EX', 30); if (!lock) { return res.status(409).json({ error: 'Request is currently being processed' }); } try { // Actual email sending logic const result = await emailProvider.send(req.body); const responsePayload = { status: 200, body: result }; // Cache the result for 24 hours await client.set(`idempotency:${idempotencyKey}`, JSON.stringify(responsePayload), 'EX', 86400); return res.status(200).json(result); } catch (error) { return res.status(500).json({ error: 'Internal Server Error' }); } finally { await client.del(`lock:${idempotencyKey}`); } }

总结

对事务性邮件来说,幂等不是“锦上添花”,而是任何重视用户体验和成本控制的系统的必备要求。把保证唯一性的责任交给客户端,并在服务器端提供跟踪唯一性的机制,您就能消除网络不稳定时重复发送的风险。

无论您是在构建传统的 SaaS 产品,还是自主运行的 AI 智能体,把邮件视为关键的副作用来对待,都能确保系统保持可靠、用户保持满意。如需一款能处理这些复杂问题、以开发者为先的邮件 API,请访问 https://sendhq.cc。