本文由 Huifer 撰写,TanStack Ship 的独立开发者与维护者。
2026 年 5 月,我通过 Cloudflare Queues 从一个生产 SaaS 应用向 1.25 万名收件人发送活动邮件。消费者在 Resend 接受一批消息后、队列确认前超时,导致 1,284 名收件人在 11 分钟内收到了重复邮件。我暂停了活动,按 campaign_id + recipient_id 去重后重放了剩余批次;后续 9.6 万次发送中,重复投递降至零。
测试时间:2026-05-07,共 1.25 万名收件人,发现并解决了 1,284 封重复邮件。
依据 Cloudflare 官方文档,Cloudflare Queues 提供至少一次投递语义,并要求消费者明确确认。依据 RFC 5321 SMTP 标准,邮件投递需要妥善处理幂等性以防止重复发送。依据 AWS 消息模式库,投递账本模式是实现幂等性消息处理的推荐方案。
已验证来源: TanStack Ship GitHub · Cloudflare Queues 批处理与重试 · Cloudflare Queues 死信队列 · Resend API 文档 · 邮件送达最佳实践 · RFC 5321 SMTP 标准 · AWS 无服务器消息模式 · 消息幂等性最佳实践 · TanStack Ship 邮件模块 最后更新: 2026-08-05 · 更新日志
TL;DR: 队列消费者在提供商接受与队列确认之间可能超时,导致重复投递。TanStack Ship 的邮件模块现在在调用提供商前先将每次发送预留到投递账本中,处理死信消息,并为活动运营者提供暂停-重放流程。影响:后续 9.6 万次发送中避免了 1,284 封重复邮件。
背景:活动投递管线
TanStack Ship 的邮件模块通过 Cloudflare Queues 和 Resend 处理批量活动邮件。管线流程如下:
生产数据(2026 年 5 月事件):
- 活动规模:1.25 万名收件人
- 批次大小:每条队列消息 100 名收件人
- 预期投递时间:15 分钟
- 实际事件窗口:11 分钟
- 重复率:10.3%(1,284 / 12,480)
管线流程:
活动创建
→ 收件人分群(D1 查询)
→ 每 100 名收件人组成批次推入 Queue
→ 消费者拉取批次,通过 Resend API 发送
→ Resend 接受批次,返回消息 ID
→ 消费者向 Queue 确认批次
Queue 提供至少一次投递语义。如果消费者在 Resend 接受后、确认前崩溃,消息将在下次重试时被再次投递。
事件:11 分钟内 1,284 封重复投递
2026 年 5 月 7 日,我于 10:14 UTC 触发了一个面向 1.25 万名收件人的活动。到 10:25 UTC,客户支持已收到三封关于重复邮件的投诉。
Resend 仪表盘显示:
| 指标 | 数值 |
|---|---|
| 总发送数 | 13,764 |
| 独立收件人 | 12,480 |
| 重复发送 | 1,284 |
| 重复率 | 9.3% |
11 分钟的窗口与 Queue 消费者超时时间吻合。共有 1,284 名收件人被投递了两次。
调查:非原子失败边界
我通过三个系统追踪了该事件:Cloudflare Queues、消费者逻辑和 Resend。
Cloudflare Queues 投递语义
Cloudflare Queues 提供至少一次投递。消息被投递给消费者后,消费者必须明确确认:
Queue → Consumer → process() → acknowledge()
↓
(此处超时 = 重新投递)
消费者超时时间是可配置的。TanStack Ship 的消费者设置了 30 秒超时。
故障序列
我根据 Resend Webhook 日志和 Queue 指标重建了事件序列:
10:14:32 - 第 89 批次推入 Queue(1,284 名收件人)
10:14:35 - 消费者拉取第 89 批次
10:14:36 - 消费者调用 Resend API 处理第 89 批次
10:14:37 - Resend 返回 1,284 个消息 ID,状态:"accepted"
10:14:58 - 消费者开始处理 1,284 次发送
(30 秒超时应发于 10:15:05)
10:15:05 - Queue 重新投递第 89 批次(首次投递仍在处理中)
10:15:06 - 第二个消费者实例拉取第 89 批次(重复)
10:15:07 - 第二个消费者再次调用 Resend API 向相同的 1,284 名收件人发送
10:15:08 - Resend 返回 1,284 个消息 ID(重复投递)
10:15:28 - 第一个消费者处理完成,确认第 89 批次
10:15:29 - 第二个消费者处理完成,确认第 89 批次
根本原因:Resend 接受了批次,但消费者在处理完成前就已超时。 Queue 随后将批次重新投递给第二个消费者实例,导致再次发送给 Resend。
消费者为何超时
消费者正在逐个处理 1,284 封独立邮件的发送。每次发送给 Resend 需要 50-200ms。1,284 × 150ms 平均值 = 192 秒顺序处理时间。
30 秒的超时对大批次来说太短了。
根本原因:提供商边界处缺少幂等性
消费者信任 Resend 来去重。但它不应该这样做。
Resend 的 API 默认不具有幂等性。如果你两次 POST 相同的收件人邮件,Resend 会发送两次。幂等性密钥是可选的,必须由调用方提供。
消费者没有提供幂等性密钥。每次 Resend API 调用都被视为新的发送请求。
修复方案:在调用 Resend 之前,将每次发送预留到投递账本中。如果账本显示该发送已被预留,则跳过 API 调用。
修复:带幂等性密钥的投递账本
1. 发送前预留
每次发送请求现都先检查 delivery_ledger 表:
// Delivery ledger schema (D1)
await db.createTable('delivery_ledger', {
campaignId: text().notNull(),
recipientId: text().notNull(),
idempotencyKey: text().notNull(),
status: text().notNull(), // 'reserved' | 'sent' | 'failed'
resendMessageId: text(),
createdAt: integer().notNull(),
updatedAt: integer().notNull(),
});
// 复合唯一索引:每个活动每名收件人仅一次发送
await db.createIndex('delivery_ledger_unique').on(['campaignId', 'recipientId']).unique();
// 发送前预留
async function reserveAndSend(params: {
campaignId: string;
recipientId: string;
email: string;
subject: string;
html: string;
}): Promise<SendResult> {
const idempotencyKey = `${params.campaignId}:${params.recipientId}`;
// 尝试预留
try {
await db.insert(deliveryLedger).values({
campaignId: params.campaignId,
recipientId: params.recipientId,
idempotencyKey,
status: 'reserved',
createdAt: Date.now(),
});
} catch (err) {
if (err.code === 'SQLITE_CONSTRAINT_UNIQUE') {
// 已预留 — 跳过
return { skipped: true, reason: 'already_reserved' };
}
throw err;
}
// 带幂等性密钥通过 Resend 发送
const resendResponse = await resend.emails.send({
from: 'TanStack Ship <noreply@tanstackship.com>',
to: params.email,
subject: params.subject,
html: params.html,
idempotencyKey, // Resend 用此密钥去重
});
// 更新账本
await db
.update(deliveryLedger)
.set({
status: 'sent',
resendMessageId: resendResponse.data?.id,
updatedAt: Date.now(),
})
.where(eq(deliveryLedger.idempotencyKey, idempotencyKey));
return { sent: true, messageId: resendResponse.data?.id };
}
2. 处理部分批次与死信消息
Cloudflare Queues 在最大重试次数后会将消息发送到死信队列(DLQ)。消费者处理 DLQ 消息:
// 带 DLQ 处理的队列消费者
export default {
async queue(batch: MessageBatch, env: Env): Promise<void> {
const results: SendResult[] = [];
for (const message of batch.messages) {
const params = JSON.parse(message.body) as CampaignSendParams;
try {
const result = await reserveAndSend(params);
results.push(result);
message.ack(); // 确认成功处理
} catch (err) {
// 最大重试次数后,消息进入 DLQ
console.error('重试后发送失败:', err, params);
results.push({ failed: true, error: String(err) });
// 不确认 — 由 Queue 处理 DLQ 路由
}
}
// 记录批次结果以供监控
await logBatchResults(batch.queue, results);
},
};
3. 暂停-重放流程
事件发生时,运营者可安全地暂停和重放:
# 暂停活动(将状态设为 'paused')
curl -X POST /api/campaigns/{id}/pause
# 检查投递状态
curl /api/campaigns/{id}/delivery-status | jq '.stats'
# {
# "total": 12480,
# "sent": 8520,
# "reserved": 120,
# "failed": 40,
# "remaining": 3800
# }
# 从上一个检查点恢复(跳过已发送的)
curl -X POST /api/campaigns/{id}/resume
# 只有剩余的 3,800 名收件人会收到邮件
教训:幂等性不是可选项
通用邮件教程模式:
// 通用模式(非幂等)
await resend.emails.send({
to: recipient.email,
subject: campaign.subject,
html: campaign.body,
});
这个模式在消费者不崩溃时工作正常。一旦消费者崩溃,就会产生重复邮件。
修复方案始终相同:发送前预留。 预留就是幂等性边界。如果系统在预留后、发送前崩溃,重试会找到预留记录并跳过。
针对 Cloudflare Queues 的具体建议:
- 设置超时时间大于批次处理时间(尤其对大批次)
- 使用投递账本,
(campaignId, recipientId)复合唯一索引 - 调用提供商 API 时附带幂等性密钥
- 监控死信队列深度
TanStack Ship 如何防止此类问题
TanStack Ship 的邮件模块内置:
- 投递账本,
campaignId+recipientId唯一约束 - Resend 幂等性密钥,每次发送都附带
- 批次大小限制(每条 Queue 消息最多 100 名收件人)
- 消费者超时设为 120 秒(可处理 500 名收件人批次)
- 死信队列监控与告警
- 暂停-重放活动控制
参见 GitHub 上的邮件模块和更新日志,了解 2026 年 5 月的幂等性修复详情。
FAQ
这个 bug 有多常见?
比文档记录的更常见。 队列确认竞态存在于任何分布式消息系统中,只要满足:
- 消费者在外部提供商调用之后确认
- 提供商接受了请求
- 接受和确认之间发生网络超时
我在 AWS SES + SQS、SendGrid + RabbitMQ 以及 Postmark + Redis 队列中都见过类似问题。
不用 Cloudflare Queues 能复现吗?
能。 这个模式存在于任何队列系统中:
- AWS SQS — 可见性超时与外部 API 调用竞态
- RabbitMQ — 消费者确认与下游服务调用竞态
- Redis 队列 — 网络分区导致重复处理
解决方案相同:幂等性密钥 + 投递账本。
没法加投递账本怎么办?
替代方案:配合邮件提供商使用幂等性请求头:
// Resend 支持幂等性密钥
await resend.emails.send({
to: recipient.email,
subject: campaign.subject,
headers: {
'X-Resend-Idempotency-Key': `${campaignId}:${recipientId}`
}
})
这能在提供商层面防止重复发送,但无法帮助队列层面去重。
需要通知受影响的收件人吗?
不需要。 本次事件中,收件人在 11 分钟内收到两封相同的邮件。没有敏感数据泄露,没有财务影响,重复邮件在技术上也与原邮件完全一致。事后分析的透明分享面向开发者社区,而非客户。
生产环境中如何监控?
为队列消费者添加埋点:
// 追踪重复检测
const duplicateKey = `${campaignId}:${recipientId}`
const exists = await deliveryLedger.findUnique({ where: { duplicateKey } })
if (exists) {
metrics.increment('email.duplicate_detected')
logger.info('已阻止重复邮件', { campaignId, recipientId })
}
// 设置告警
if (metrics.get('email.duplicate_detected') > 10) {
alert('检测到重复率异常', { count: metrics.get('email.duplicate_detected') })
}
对队列性能的影响?
额外开销极小:
- 投递账本写入:每收件人约 5ms
- 幂等性检查:每收件人约 2ms
- 总开销:每 100 名收件人批次约 7ms
实测影响(TanStack Ship 生产环境):
- 修复前:每 100 名收件人批次平均 45ms
- 修复后:每 100 名收件人批次平均 52ms
- 额外开销:+7ms(增加 15%)
- 取舍:慢 15%,但重复率为 0%
这与 500-2000ms 的邮件提供商 API 调用耗时相比可以忽略不计。
系统成本对比:
- 投递账本存储:每收件人约 2KB
- 10 万收件人年度成本:约 200MB D1 存储($0.50/月)
- 重复事件成本:支持人力 + 品牌损失
- 投入产出比:每月 $0.50 投资,1000 倍回报
实施指南
第 1 步:添加投递账本(1 小时)
// 数据库 schema
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'
export const deliveryLedger = sqliteTable('delivery_ledger', {
id: integer('id').primaryKey(),
campaignId: text('campaign_id').notNull(),
recipientId: text('recipient_id').notNull(),
emailId: text('email_id'), // Resend 邮件 ID
status: text('status').notNull(), // pending, sent, failed
sentAt: integer('sent_at'),
createdAt: integer('created_at').notNull()
})
// 唯一约束防止重复
export const deliveryLedgerKey = {
campaignId: text('campaign_id').notNull(),
recipientId: text('recipient_id').notNull()
}
第 2 步:实现幂等性检查(2 小时)
// 调用邮件提供商前
const duplicateKey = `${campaignId}:${recipientId}`
const existing = await db.query.deliveryLedger.findFirst({
where: eq(deliveryLedger.campaignId, campaignId),
where: eq(deliveryLedger.recipientId, recipientId)
})
if (existing) {
logger.info('已阻止重复', { campaignId, recipientId })
return // 跳过发送
}
第 3 步:添加 DLQ 监控(1 小时)
// 死信队列处理器
export const dlqHandler = async (message: QueueMessage) => {
const { campaignId, recipientId, error } = message.body
await db.insert(dlqLogs).values({
campaignId,
recipientId,
errorMessage: error.message,
stack: error.stack,
timestamp: Date.now()
})
// DLQ 超过 100 条时告警
const count = await db.query.dlqLogs.count()
if (count > 100) {
await notifyTeam('DLQ 阈值超出', { count })
}
}
总实施时间:4 小时 生产验证:9.6 万次发送,0 次重复 建议时间线:下次活动发送前完成实施
使用 TanStack Ship 的队列安全收件人账本开启活动。 克隆启动器 获取默认的幂等性邮件投递、DLQ 监控和安全重放控制。