从设计 Subscriber 表开始,一步步实现博客邮件订阅系统,并深入介绍 EmailLog、统一邮件服务、异步队列以及生产环境优化方案。
很多人在开发个人博客时,都会希望增加一个『邮件订阅』功能。
例如:
用户输入邮箱 → 发布新文章 → 自动发送邮件通知。
看起来只是一个简单的功能,但如果从系统设计的角度来看,它其实涉及到了数据库设计、业务分层、日志记录、异步任务以及后期性能优化等多个方面。
本文将以我的博客系统为例,完整介绍这一套邮件订阅系统是如何一步一步设计出来的,以及后期如何逐步演进成一套适合生产环境的方案。
整个功能其实只有一句话:
当发布一篇新的文章时,自动通知所有已经订阅的用户。
整个流程可以抽象成:
发布文章 │ ▼ 查询所有订阅者 │ ▼ 发送邮件 │ ▼ 完成
如果订阅人数只有几个人,这样做几乎没有任何问题。
例如:
const subscribers = await prisma.subscriber.findMany() await Promise.all( subscribers.map((item) => sendSubscriptionEmail(...) ) )
但是,当订阅人数越来越多时,这个方案的问题就开始暴露出来。
邮件订阅首先需要保存订阅用户的信息,因此第一步就是设计订阅者模型。
model Subscriber { id String @id @default(cuid()) email String @unique name String? verified Boolean @default(false) active Boolean @default(true) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }
其中有几个字段值得重点说明。
邮箱必须唯一,避免重复订阅。
建议采用邮箱验证码完成验证之后再正式加入订阅列表,防止别人恶意填写他人的邮箱。
很多人会直接删除订阅数据,其实更推荐采用软删除:
active = false
这样既保留了历史数据,也方便以后重新恢复订阅。
如果项目越来越大,很容易出现下面这种情况:
await transporter.sendMail(...)
到处都是 SMTP 代码。
这样不仅重复,而且以后更换邮件服务时需要修改很多地方。
因此更推荐统一封装:
sendVerificationEmail() │ ▼ sendEmail() sendSubscriptionEmail() │ ▼ sendEmail() sendResetPasswordEmail() │ ▼ sendEmail()
所有邮件最终都交给 sendEmail() 处理。
sendEmail() 只负责:
业务层只需要关心发什么邮件,而不用关心怎么发送。
这也是典型的职责分离思想。
很多人一开始都会忽略日志。
但是随着系统运行,很快就会遇到这些问题:
因此增加 EmailLog 是非常有必要的。
model EmailLog { id String @id @default(cuid()) email String name String? subject String type EmailType status EmailStatus error String? messageId String? payload Json? sentAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }
邮件发送流程变成:
创建 EmailLog(PENDING) │ ▼ SMTP 发送 │ ├──────────────┐ ▼ ▼ SUCCESS FAILED
以后任何邮件都可以查询发送记录,而不是依赖服务器日志。
很多人会直接写:
sendEmail({ subject, html, })
更推荐继续封装:
sendSubscriptionEmail()
它负责:
例如:
sendSubscriptionEmail({ email, name, title, summary, url, coverImage, })
这样业务层根本不需要关心 HTML 模板。
很多人的第一版代码都是:
发布文章 │ ▼ 查询所有订阅者 │ ▼ Promise.all() │ ▼ 等待所有发送完成
如果只有十几个订阅用户,这没有问题。
但是如果有:
1000 人
甚至:
10000 人
那么问题就来了。
首先,请求会一直阻塞。
其次,SMTP 会限制发送频率。
最后,Promise.all() 会同时创建大量 Promise,占用大量内存。
因此发布文章接口会越来越慢。
对于个人博客来说,其实可以这样:
void notifySubscribers(blog)
而不是:
await notifySubscribers(blog)
发布接口立即返回,后台继续发送邮件。
这是最简单,也是成本最低的优化方式。
如果订阅人数达到几百甚至几千人,就应该引入发送队列。
新增:
model EmailQueue { id String @id @default(cuid()) type EmailType status EmailStatus payload Json retries Int createdAt DateTime @default(now()) }
发布文章时:
发布文章 │ ▼ 查询订阅者 │ ▼ createMany EmailQueue │ ▼ 立即返回
真正发送邮件由后台 Worker 完成:
Cron │ ▼ 读取20条待发送任务 │ ▼ sendEmail() │ ▼ SUCCESS / FAILED
即使有几千名订阅用户,发布文章接口依旧可以在几百毫秒内完成。
如果以后网站规模继续扩大,还可以接入专业消息队列。
例如:
整个流程将演变为:
Blog │ ▼ Queue │ ▼ Worker │ ▼ SMTP
不仅能够控制发送速度,还可以实现失败重试、限流、优先级、分布式发送等能力。
最终整个邮件系统可以整理成下面这样:
发布文章 │ ▼ notifySubscribers() │ ▼ sendSubscriptionEmail() │ ▼ sendEmail() │ ├───────────────┐ │ │ ▼ ▼ EmailLog SMTP Server │ ▼ SUCCESS / FAILED
未来如果加入 EmailQueue,则整个系统演变为:
Blog │ ▼ EmailQueue │ ▼ Worker │ ▼ sendEmail() │ ▼ EmailLog │ ▼ SMTP
整个系统职责划分十分清晰:
邮件订阅系统看起来只是一个很小的功能,但如果提前做好架构设计,后期几乎不需要推倒重来。
对于个人博客而言,完全可以按照下面的演进路线逐步升级:
这样既满足当前个人博客的需求,又为未来的扩展预留了足够的空间,也符合现代 Web 应用中常见的分层架构设计。
暂无评论,留下第一条声音吧。