← 返回蜂巢洞察

为什么你绝不应该在API请求中包含电子邮件地址

在开发环境中,你的注册接口看起来没有任何问题。用户完成注册后,你会将相关数据保存到数据库中,然后调用邮件服务提供商,并返回状态码 201 Created ,这样用户就会收到欢迎邮件。一切似乎都很顺利。 然而,当生产环境中的请求开始涌入时,问题出现了: 邮件发送接口现在需要2秒钟才能完成响应,而不是原本的200毫秒。因此,有些请求会超时失败。而在邮件服务提供商出现故障的情况下,所有注册请求都会返回状态码 500 。技术支持人员很困惑:为什么用户能够创建账户,但却始终收不到确认邮件链接? 其实问题并不出在你的邮件模板上,而在于你选择将邮件发送处理逻辑放在HTTP请求路径中这一决策。 在这篇文章中,

在开发环境中,你的注册接口看起来没有任何问题。用户完成注册后,你会将相关数据保存到数据库中,然后调用邮件服务提供商,并返回状态码201 Created,这样用户就会收到欢迎邮件。一切似乎都很顺利。

然而,当生产环境中的请求开始涌入时,问题出现了:

邮件发送接口现在需要2秒钟才能完成响应,而不是原本的200毫秒。因此,有些请求会超时失败。而在邮件服务提供商出现故障的情况下,所有注册请求都会返回状态码500。技术支持人员很困惑:为什么用户能够创建账户,但却始终收不到确认邮件链接?

其实问题并不出在你的邮件模板上,而在于你选择将邮件发送处理逻辑放在HTTP请求路径中这一决策。

在这篇文章中,你将会了解到:为什么在HTTP请求路径中处理邮件发送操作会带来生产环境中的各种问题;在系统规模扩大时会出现哪些故障;以及如何将这类任务移至后台作业队列中处理,从而确保你的API能够保持高效且稳定的运行。

先决条件

如果你具备以下知识,那么阅读这篇文章会更有帮助:

  • REST API的工作原理(请求、响应、状态码等)

  • Node.js或任何能够调用邮件服务接口的后端编程语言

  • 后台作业队列的基本概念

你不需要事先具备使用BullMQ、Redis或特定邮件服务提供商的经验。

目录

1. “请求路径中的处理内容”究竟意味着什么

当客户端向你的API发送请求时,请求路径中所包含的所有操作都必须完成,之后你才能返回HTTP响应。

通常来说,这些操作包括:

  • 验证用户输入的数据

  • 检查用户的身份认证信息

  • 将数据写入数据库

  • 返回JSON格式的响应结果

然而,几乎从来不应该在请求路径中包含以下操作:

  • 调用SMTP服务器发送邮件

  • 等待Resend、SendGrid、SES、Mailgun或Postmark等服务完成邮件发送

  • 渲染复杂的HTML模板并上传附件

  • 重试那些不稳定、容易出错的第三方网络请求

电子邮件服务提供商属于外部系统,它们自身存在延迟、速率限制以及故障等问题。如果你的API依赖这些外部服务,那么它们的问题就会直接影响到你的用户。

错误的处理流程(同步模式):

客户端 ──▶ API ──▶ 数据库 ──▶ 电子邮件服务提供商 ──▶ 客户端
                         ▲
                         └── 用户需要等待所有这些环节完成

2. 导致问题的“诱人代码”

大多数团队最初都会采用这种编写方式。这种代码结构清晰、简洁,而且在笔记本电脑上也能正常运行。

app.post('/signup', async (c) => {
  const body = await c.req.json();

  const user = await db.users.create({
    email: body.email,
    passwordHash: await hash(body.password),
  });

  // 这里就是问题所在。
  await emailClient.send({
    to: user.email,
    subject: '确认账户信息',
    html: renderConfirmEmail(user),
  });

  return c.json({ id: user.id }, 201);
});

只有当电子邮件服务提供商完成响应后,这个请求才能结束。如果这个接口的响应速度很慢,那么你的应用程序的响应时间也会变慢;如果这个接口出现故障,用户的注册操作要么会失败,要么你会捕获到错误但仍然继续执行后续流程,从而忽略发送邮件的步骤。

这两种情况都不好。

3. 为什么在生产环境中会出现这些问题

你的响应时间实际上变成了第三方服务的SLA限制

一个正常的注册接口应该能在几十到几百毫秒内完成响应,但电子邮件服务API的响应时间通常会更长;在高负载情况下,这种延迟还会进一步加剧。

最终,决定你的应用程序响应时间的是路径中最慢的那个第三方接口。

故障会导致一些糟糕的后果

如果`send()`方法出现错误,你面临两个糟糕的选择:

  1. 让整个请求失败:即使用户账户已经存在,用户也会看到错误信息。

  2. 隐藏错误:虽然用户账户被创建了,但他们却收不到确认邮件。

在第二种情况下,尤其是在进行账户确认或密码重置操作时,问题会更加严重:数据库显示操作成功,但用户的邮箱里却什么也没有收到。

超时现象会引发连锁反应

API网关、负载均衡器以及浏览器本身都设有超时机制。如果电子邮件发送请求耗时过长,原本成功的操作就会在客户端端出现故障。用户会重新尝试登录,这时就可能会导致重复创建用户账户或重复发送邮件的情况。

速率限制会惩罚突发的高流量请求

电子邮件服务提供商会对请求进行速率限制。产品上线、数据同步以及密码重置等操作都可能引发这种限制。如果发送邮件请求发生在请求处理流程的中间阶段,那么速率限制错误就会直接表现为用户端遇到的问题。

你无法顺利地重新尝试

后台作业可以通过延迟重试的方式来处理失败情况,但HTTP请求却不能。一旦你返回了响应结果,请求就结束了。如果邮件发送失败,而你之前已经向用户告知操作成功,那么就需要另一个系统来修复这个错误——这个系统通常就是邮件发送队列。

当邮件服务出现延迟时,用户会遇到以下情况:

点击注册按钮
   │
   ▼
API等待邮件服务响应……2秒……5秒……超时
   │
   ▼
显示“发生错误”
   │
   ▼
用户再次尝试注册 → 导致重复请求,支持团队也会因此收到大量混乱的工单

4. 规则:你的API应该将任务放入队列中执行,而不是立即处理它们

应将发送邮件的操作视为异步任务,这种任务必须因为请求的存在而被执行,而不是在请求内部就被处理。

请求的处理流程应该包括以下步骤:

  1. 验证用户输入的信息

  2. 完成相应的业务操作

  3. 将发送邮件的任务放入队列中

  4. 尽快返回响应结果

而在其他地方,有专门的处理程序应该执行以下操作:

  1. 从队列中取出相应的任务并开始处理

  2. 生成所需的邮件模板

  3. 调用邮件服务进行发送

  4. 在失败时重新尝试

  5. 记录处理结果

正确的处理流程(异步方式):

客户端 ──▶ API ──▶ 数据库 ──▶ 队列 ──▶ 客户端(快速响应)
                                   │
                                   ▼
                                处理程序 ──▶ 邮件服务

处理文件上传、生成PDF文件、调用AI接口或将数据同步到CRM系统,其实都是遵循同样的原理。只不过发送邮件是其中最常见的例子而已。

5. 如何将发送邮件的任务放入后台队列中

下面是一个使用BullMQ和Redis实现的简单Node.js示例。具体使用哪些库并不重要,关键在于要遵循异步处理的原理。

定义任务对象

// jobs/email.ts
export type SendEmailJob = {
  to: string;
  template: 'confirm-account' | 'password-reset' | 'welcome';
  variables: Record;
  idempotencyKey: string;
};

从API端将任务放入队列

import { emailQueue } from '../queues/email-queue';

app.post('/signup', async (c) => {
  const body = await c.req.json();

  const user = await db.users.create({
    email: body.email,
    passwordHash: await hash(body.password),
  });

  await emailQueue.add(
    'send-email',
    {
      to: user.email,
      template: 'confirm-account',
      variables: {
        name: user.name,
        confirmUrl: `https://example.com/confirm/${user.confirmToken}`,
      },
      idempotencyKey: `confirm-account:${user.id}`,
    },
    {
      jobId: `confirm-account:${user.id}`,
      attempts: 5,
      backoff: { type: 'exponential', delay: 2000 },
      removeOnComplete: 1000,
      removeOnFail: 5000,
    },
  );

  return c.json({ id: user.id }, 201);
});

注意看处理流程发生了哪些变化。API仍然会创建用户账户,但它不再等待SMTP服务的响应了。而是等待Redis将任务添加到队列中——这种方式通常比直接等待邮件服务更快、也更可靠。

在工作者进程中处理任务

// worker/email-worker.ts
import { Worker } from 'bullmq';
import { emailClient } from '../lib/email-client';
import { renderTemplate } from '../lib/templates';

new Worker(
  'email',
  async (job) => {
    const { to, template, variables } = job.data;

    await emailClient.send({
      to,
      subject: subjectFor(template),
      html: renderTemplate(template, variables),
    });
  },
  {
    connection: redisConnection,
    concurrency: 5,
  },
);

应将工作者作为与API分离的进程来运行。这样,即使电子邮件发送出现故障,也不会影响其他HTTP请求的处理速度。

返回正确的状态码

对于注册操作来说,如果账户已经存在且确认邮件已被放入队列中,那么返回201 Created状态码是完全合理的。

对于那些仅用于触发任务处理的接口而言,建议使用202 Accepted状态码,并在客户端需要跟踪处理进度时,返回相应的任务ID或邮件发送ID。

6. 如何处理重试、故障及幂等性问题

仅仅将电子邮件放入队列是远远不够的,还需要制定相应的故障处理规则。

重试临时性错误

对于网络波动、429 请求过多503 服务不可用这类暂时性错误,应采用退避策略进行重试;而对于接收方地址无效这类永久性故障,则应立即停止尝试,并将相关任务放入死信队列或失败任务表中。

确保任务的幂等性

工作者进程可以被多次执行。因此,需要使用稳定的jobIdidempotencyKey来保证重试不会导致同一注册操作被重复处理多次。

一些实际可行的方法包括:

  • 根据业务事件生成唯一的键:例如confirm-account:user_123

  • 当邮件发送成功时,记录相应的消息ID

  • 如果该键已经用于发送邮件,则跳过当前任务

不要丢失业务事件的相关信息

如果在db.users.create()emailQueue.add()这两个操作之间进程发生崩溃,可能会导致邮件发送失败。

有两种常见的方法可以降低这种风险:

  1. “发件箱模式”:将邮件发送指令与用户信息的插入操作放在同一数据库事务中,并将这些数据写入outbox表。之后由专门的程序读取这些数据并放入发送队列。

  2. 在支持的事务环境中直接执行发送操作:尽量将邮件写入操作与发送队列插入操作合并到同一事务中,对于那些未能成功发送的邮件,再单独运行补发任务。

对于大多数应用程序来说,最初只需使用简单的队列系统并进行监控即可;只有当确保邮件准确送达变得至关重要时,才需要引入“发件箱模式”。

区分紧急邮件与普通邮件

密码重置邮件或一次性验证码邮件绝对不能被排在普通新闻邮件的后面进行发送。

使用优先级或单独的队列来进行处理:

  • 高优先级:一次性密码、密码重置、确认操作

  • 普通优先级:收据生成、新用户注册流程

  • 低优先级:数据摘要处理、营销相关任务

这样即使在高流量情况下,也会确保那些可能会阻塞用户操作的邮件能够被快速处理。

7. 还有哪些内容应该不在请求路径中?

如果某个任务需要调用第三方网络API,执行时间可能超过几百毫秒,或者需要重试机制,那么就应该将其从请求路径中移除。同样地,那些即使失败也不会影响主要业务流程的任务,以及那些会消耗大量CPU资源的任务(如图像或PDF生成),也应该被排除在请求路径之外。

以下是一些属于这类任务的常见例子:

  • 发送邮件和短信

  • 触发Webhook通知

  • 搜索索引构建

  • 生成缩略图

  • 创建发票PDF文件

  • CRM数据同步

  • 人工智能文本转录或摘要生成

  • 生成耗时的报告

你的API应该负责确认用户的操作意图并保持相关状态,而那些耗时较长的任务则应该由专门的处理程序来执行。

8. 实用性检查清单

在发布任何会发送邮件的功能之前,请先思考以下问题:

  1. 用户是否必须收到这些邮件才能完成操作?

  2. 如果邮件服务提供商出现故障,会不会影响这个功能的正常运行?

  3. 如果在数据库写入操作之后发送邮件失败,我们该如何恢复数据?

  4. 如果同一个任务被执行了两次,用户是否会收到重复的消息?

  5. 一次性密码和密码重置邮件的处理速度是否比普通邮件更快?

  6. 我们能否通过日志、指标或仪表盘来查看那些失败的任务?

  7. 当队列长度或失败率突然增加时,系统是否会发出警报?

如果你无法回答这些问题,那么这个设计还不适合投入生产环境。

结论

在API请求中包含邮件发送功能,会让你应用程序的响应速度和错误率受到你无法控制的第三方系统的影响。在演示环境中这看起来很简单,但在实际生产环境中却会带来很多问题。

更合理的处理方式其实很简单:

  • 完成业务逻辑的处理

  • 将邮件发送任务放入队列中等待执行

  • 尽快返回响应结果

  • 让专门的处理程序来负责邮件的发送、重试及失败情况的报告

采用这种设计模式,你的API会运行得更快,邮件发送也会更加可靠,而且那些可能导致故障的因素也更容易被理解和解决。

一旦你开始将邮件发送任务与其他业务逻辑分开处理,你就会发现:凡是用户不需要等待才能完成的操作,都不应该包含在请求路径中。

相关文章

技术实践

如何使用Hono和Zod构建类型安全的API

如果你之前曾经开发过 Node.js API,那么你就应该了解这种麻烦:TypeScript 中定义的类型与运行时验证的结果不一致,而 OpenAPI 文档的内容也与这两者都不同。 有时有人会在接口中添加新的字段,但相应的模式文件却从未得到更新。因此,文档内容会一直保持过时的状态,直到有客户端提交错误报告为止。这种情况下,既不会出现编译错误,也不会有测试失败的情况——这三个信息来源就这样出现了分歧。 在本教程中,你将学习如何使用 Hono 和 Zod 将这些问题统一起来。你会学到我在实际开发中使用的那些模式,包括我在维护的开源视频处理工具包 ClipForge 中所采用的方案。这些方法能够确保

阅读全文
技术实践

如何利用vLLM来扩展大语言模型在AI智能体中的应用范围

在本教程中,我将向您展示如何使用vLLM来优化大型语言模型的推理性能,从而提升AI代理的工作效率。我会帮助您理解大型语言模型推理的原理,分析为什么AI代理的任务会引发GPU调度和内存压力问题,并探讨vLLM是如何被设计出来以提高处理能力的。 接下来,我们将运行一台本地的vLLM服务器,并通过其兼容OpenAI的API,使用AI代理与它进行连接。 目录 背景知识 先决条件 什么是大型语言模型推理? 大型语言模型推理如何利用CPU和GPU 为什么AI代理的任务难以处理 vLLM是如何为AI代理任务提供支持的 设计动机与架构 步骤1:安装vLLM 步骤2:启动vLLM服务器 步骤3:将AI代理连接到

阅读全文
技术实践

OpenAI详细介绍了GPT-Live的架构,该架构专为实现连续状态下的语音交互而设计。

OpenAI最近发布了一份关于GPT-Live的技术说明文档。其中详细介绍了他们是如何设计这个系统的——该系统能够在保持连续语音交互的同时,将那些对延迟敏感的媒体处理任务与其他应用程序逻辑分开来处理。实时通信流程中包含了媒体数据传输机制以及推理模块;而授权管理、工具使用、数据持久化等功能则是在异步远程过程调用框架下运行的。 作者:Eran Stiller

阅读全文
技术实践

如何使用Python构建一个人工智能文件分析工具

如果你曾经打开过一份30页的PDF文件,然后心想“我绝对不可能读完这一切”,那么你就已经理解了为什么文件分析人工智能工具会非常有用。 想象一下,当你上传一篇研究论文、简历、CSV文件、商业报告或PDF文档后,只需简单地问这样一个问题: “其中最重要的发现是什么?” 人工智能工具无需你手动浏览整个文件,就能理解文件的内容,并回答相关问题。 在本教程中,我们正是要构建这样的工具。我们将使用Python编写一个适合初学者的 AI文件分析工具 ,它能够: 从你的电脑中接收文件 将文件上传到人工智能模型中 读取文件的内容 理解自然语言提出的问题 分析文件 给出有用的答案 处理各种类型的问题,而无需我们为

阅读全文