0. 背景与目标
- 目标:在微服务消息中心中实现“发送邮件验证码”的异步化处理:
- Web 接口入参校验 → 发送 MailMessageSendEvent 到 RocketMQ → 消费者落库并真正调用邮件网关(QQ 邮箱)。
- 需要解决:
- 版本匹配与 Binder 正常工作;
- 邮箱模板加载与渲染;
- QQ 邮箱 SMTP 连接、鉴权、SSL 配置;
- MQ 至少一次投递导致的重复消费问题(幂等);
- 常见异常排查:表不存在、参数越界、SMTP 553、EOF、NPE 等。
1. 技术栈与版本矩阵
组件 |
版本 |
说明 |
JDK |
21 |
与 Spring Framework 6.x 匹配 |
Spring Boot |
3.5.3 |
Northfields 世代 |
Spring Cloud |
2025.0.0 |
Northfields 主版本 |
Spring Cloud Alibaba |
2023.0.3.3 |
与 Northfields 适配版本 |
RocketMQ |
Client 5.3.x |
与 SCA Binder 匹配 |
Spring Cloud Stream Binder RocketMQ |
来自 SCA 2023.0.3.3 |
采用 function style(StreamBridge + Consumer) |
MyBatis-Plus |
*. *. . |
数据访问 |
Jakarta Mail (Angus) |
2.0.3 |
Spring Boot 3.x 默认走 Jakarta API |
FreeMarker |
2.3.x |
模板引擎 |
经验:Northfields 下建议直接采用 SCA 2023.0.3.3,Binder 与 RocketMQ 5.3.x 组合比较顺畅。
2. 业务架构与消息流
2.1 架构总览(Mermaid)
2.2 调用时序(Mermaid Sequence)
3. Maven 依赖要点
仅列关键依赖与典型排除,完整 pom 视项目而定。
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>3.5.3</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<!-- Spring Cloud 2025 Northfields -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>2025.0.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<!-- Spring Cloud Alibaba 与 Northfields 适配版本 -->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-alibaba-dependencies</artifactId>
<version>2023.0.3.3</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<!-- Spring Web / Validation -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Stream + RocketMQ Binder 来自 SCA -->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-stream-rocketmq</artifactId>
</dependency>
<!-- MyBatis Plus -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>***.***.***.***</version>
</dependency>
<!-- 邮件(Jakarta Mail) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
<!-- FreeMarker -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-freemarker</artifactId>
</dependency>
<!-- 其它:Lombok、Hutool、Guava、Fastjson2 等按需引入 -->
</dependencies>
4. 核心配置(application.yml)
4.1 Stream & RocketMQ Binder
spring:
cloud:
stream:
function:
definition: mailSend
bindings:
# Consumer:读取邮件发送主题
mailSend-in-0:
destination: mail_send_topic
group: common_message-center_mail-send_tag
content-type: application/json
# Producer:发送主题(配合 StreamBridge 使用)
messageOutput-out-0:
destination: mail_send_topic
content-type: application/json
consumer:
instance-count: 3
concurrency: 5
rocketmq:
binder:
name-server: ***.***.***.***:****
bindings:
mailSend-in-0:
consumer:
subscription: MESSAGE_MAIL_SEND_TAG # TAG 过滤(Broker 侧)
push:
delayLevelWhenNextConsume: 0
suspendCurrentQueueTimeMillis: 1000
messageOutput-out-0:
producer:
sendType: Sync
sendMessageTimeout: 3000
retryTimesWhenSendFailed: 2
4.2 QQ 邮箱 SMTP 配置
QQ 推荐 465 SSL 或 587 STARTTLS。Boot 3.x 走 Jakarta Mail,常见属性如下。
spring:
mail:
host: smtp.qq.com
port: 465
protocol: smtp
username: ***@***
password: ******
default-encoding: UTF-8
properties:
mail.smtp.auth: true
mail.smtp.ssl.enable: true # 465 端口建议启用
mail.smtp.starttls.enable: false
mail.debug: true # 开启后可见 SMTP 交互日志
排错要点
553 Mail from must equal authorized user:发信地址必须与 username 一致,helper.setFrom() 要用同一账号。 Got bad greeting ... [EOF]:网络/端口/SSL 握手异常,确认 465 走 ssl.enable=true,或改用 587 + starttls.enable=true。
5. 生产者与消费者代码
5.1 生产者:StreamBridge 发送
@Slf4j
@Component
@AllArgsConstructor
public class MessageSendProduce {
private static final String MESSAGE_OUTPUT_BINDING = "messageOutput-out-0";
private final StreamBridge streamBridge;
public void mailMessageSend(MailMessageSendEvent event) {
String keys = UUID.randomUUID().toString();
Message<MailMessageSendEvent> message = MessageBuilder
.withPayload(event)
.setHeader(Headers.KEYS, keys)
.setHeader(Headers.TAGS, MessageRocketMQConstants.MESSAGE_MAIL_SEND_TAG)
.build();
long start = SystemClock.now();
boolean result = false;
try {
result = streamBridge.send(MESSAGE_OUTPUT_BINDING, message);
} finally {
log.info("邮箱消息发送,状态: {}, Keys: {}, 耗时: {} ms, Payload: {}",
result, keys, SystemClock.now() - start, JSON.toJSONString(event));
}
}
}
5.2 函数式消费者绑定
@Slf4j
@Configuration
@RequiredArgsConstructor
public class MessageFunctions {
private final MailMessageSendHandler handler;
@Bean
public Consumer<Message<MailMessageSendEvent>> mailSend() {
return msg -> handler.handle(msg.getPayload(), msg.getHeaders());
}
}
5.3 消费者 + 幂等
关键点:幂等仅解决 同一条 MQ 消息 的重复消费;拦不住“接口被重复调用而产生多条不同消息”。
@Slf4j
@Component
@RequiredArgsConstructor
public class MailMessageSendHandler {
private final MessageSendFacade messageSendFacade;
@Idempotent(
uniqueKeyPrefix = "mail_message_send:",
key = "#event.messageSendId", // 建议只用稳定业务键,不要拼 hashCode
type = IdempotentTypeEnum.SPEL,
scene = IdempotentSceneEnum.MQ,
keyTimeout = 600L
)
public void handle(MailMessageSendEvent event, Map<String, Object> headers) {
long start = System.currentTimeMillis();
try {
MessageSend messageSend = BeanUtil.toBean(event, MessageSend.class);
messageSendFacade.mailMessageSend(messageSend);
} finally {
log.info("Keys: {}, MsgId: {}, 耗时: {} ms, Message: {}",
headers.getOrDefault("rocketmq_KEYS", headers.get("KEYS")),
headers.getOrDefault("rocketmq_MESSAGE_ID", headers.get("MESSAGE_ID")),
System.currentTimeMillis() - start,
JSON.toJSONString(event));
}
}
}
6. 邮件发送实现与模板缓存
@Slf4j
@Component
@AllArgsConstructor
public class MailMessageProduceImpl implements ApplicationListener<ApplicationInitializingEvent>, MailMessageProduce {
private final MailTemplateMapper mailTemplateMapper;
private final JavaMailSender javaMailSender;
private final Configuration configuration; // FreeMarker
@SneakyThrows
@Override
public boolean send(MessageSend messageSend) {
try {
MailTemplateDO mailTemplateDO = mailTemplateMapper.selectOne(Wrappers
.lambdaQuery(MailTemplateDO.class)
.eq(MailTemplateDO::getTemplateId, messageSend.getTemplateId()));
MimeMessage mimeMessage = javaMailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(mimeMessage, true);
helper.setFrom(messageSend.getSender()); // 必须与 spring.mail.username 一致(QQ)
helper.setSubject(messageSend.getTitle());
if (StrUtil.isNotBlank(messageSend.getCc())) {
helper.setCc(messageSend.getCc().split(","));
}
if (StrUtil.isNotBlank(messageSend.getReceiver())) {
helper.setTo(messageSend.getReceiver().split(","));
}
Map<String, Object> model = Maps.newHashMap();
String[] templateParams = mailTemplateDO.getTemplateParam().split(",");
if (ArrayUtil.isNotEmpty(templateParams)) {
for (int i = 0; i < templateParams.length; i++) {
// 注意防止越界
Object val = (messageSend.getParamList().size() > i) ? messageSend.getParamList().get(i) : "";
model.put(templateParams[i], val);
}
}
String templateKey = messageSend.getTemplateId() + ".ftl";
Template template = Singleton.get(templateKey, () -> {
try { return configuration.getTemplate(templateKey); }
catch (IOException e) { throw new RuntimeException(e); }
});
String html = FreeMarkerTemplateUtils.processTemplateIntoString(template, model);
helper.setText(html, true);
javaMailSender.send(mimeMessage);
return true;
} catch (Throwable ex) {
log.error("邮件发送失败,Request: {}", JSONUtil.toJsonStr(messageSend), ex);
return false;
}
}
/** 预热模板缓存 */
@SneakyThrows
@Override
public void onApplicationEvent(ApplicationInitializingEvent event) {
Resource[] resources = new PathMatchingResourcePatternResolver()
.getResources(ResourceUtils.CLASSPATH_URL_PREFIX + "templates/*.ftl");
for (Resource resource : resources) {
String templateName = resource.getFilename();
Singleton.put(templateName, configuration.getTemplate(templateName));
}
}
}
7. 幂等实现原理梳理
7.1 注解定义
- @Idempotent 支持三种 验证类型:TOKEN / PARAM / SPEL;
- 支持两种 场景:RESTAPI 与 MQ;
- 在 MQ+SPEL 场景下,依靠 uniqueKeyPrefix + key 形成 Redis 防重复键。
7.2 AOP 切面与模板方法
@Aspect
public final class IdempotentAspect {
@Around("@annotation(pers.seekersferry.framework.idempotent.annotation.Idempotent)")
public Object idempotentHandler(ProceedingJoinPoint joinPoint) throws Throwable {
Idempotent idempotent = getIdempotent(joinPoint);
IdempotentExecuteHandler instance = IdempotentExecuteHandlerFactory.getInstance(idempotent.scene(), idempotent.type());
try {
instance.execute(joinPoint, idempotent); // 模板方法:先做前置幂等处理
return joinPoint.proceed(); // 通过则执行业务
} catch (RepeatConsumptionException ex) {
if (!ex.getError()) { return null; } // 非错误态重复,直接吞掉
throw ex; // 错误态则上抛
} finally {
instance.postProcessing(); // 成功后把状态写为 CONSUMED
IdempotentContext.clean();
}
}
}
7.3 MQ + SPEL 执行器
- buildWrapper() 解析 SpEL Key;
- handler() 用 Redis SETNX 写入 CONSUMING,失败判断为重复消费:
- 如之前状态为 CONSUMED,直接跳过;
- 如是异常导致的重复,可根据状态决定是否抛错重试;
- postProcessing():成功后把 Key 设置为 CONSUMED 并带过期时间;
- exceptionProcessing():异常时删除 Key(或标记为 ERROR,视业务需求)。
关键修复:不要使用 event.hashCode() 拼接 Key,反序列化后对象 hashCode() 不稳定,容易导致幂等失效。只用业务唯一键(如 messageSendId)。
7.4 这套幂等能解决什么?
- 能解决:同一条 MQ 消息在“至少一次投递”语义下被重复消费。消费者抛错触发重试、Broker 负载/重平衡造成重复拉取、生产者重试导致 topic 中出现重复消息等。
- 不能解决:接口被用户多次点击、重放攻击、或因业务重试导致的多次发送不同消息。
因此最佳实践是:接口层 + 消费层 双重幂等。
8. 接口层的幂等与频控建议
8.1 验证码发送:基于业务键的去重(推荐)
- Key:mail:vc:{receiver} 或 mail:send:{templateId}:{receiver};
- TTL:60s~300s;
- 流程:接口进入先 SETNX,失败返回“发送过于频繁”。
@Idempotent(
type = IdempotentTypeEnum.SPEL,
scene = IdempotentSceneEnum.RESTAPI,
key = "#cmd.templateId + ':' + #cmd.receiver",
keyTimeout = 300
)
public CommonResult<MessageSendRespDTO> sendMailMessage(@RequestBody @Valid MailSendCommand cmd) {
// 校验通过后再生成 messageSendId 并投递 MQ
}
或者直接用 Redis 操作封装一个 tryLockSend(templateId, receiver, ttl),语义更清晰。
8.2 数据库侧的兜底约束
- 在 send_record 上设计唯一索引(如 uniq(template_id, receiver, date_bucket)),防止同窗口重复插入;
- 或单独建“去重表”记录唯一键 + 过期时间,插入失败即判定重复。
8.3 限流
- 对 receiver 或 IP 做 QPS/滑窗限流,避免外部接口超限/拉黑。
9. 典型故障与解决
症状日志 |
根因 |
处理 |
553 Mail from must equal authorized user |
QQ 要求发件人与认证用户一致 |
helper.setFrom() 与 spring.mail.username 保持一致 |
Got bad greeting ... [EOF] |
SSL 握手/网络异常/端口不匹配 |
465 端口启用 mail.smtp.ssl.enable=true;或改 587 + starttls |
MQ repeated consumption 警告 |
同一消息被重复投递,拦截生效 |
属正常告警,可降级为 info;确认 Key 设计稳定即可 |
LogUtil 防空示例
public final class LogUtil {
public static Logger getLog(@Nullable ProceedingJoinPoint joinPoint, Class<?> fallback) {
if (joinPoint == null) return LoggerFactory.getLogger(fallback);
Signature sig = joinPoint.getSignature();
if (sig instanceof MethodSignature ms) {
return LoggerFactory.getLogger(ms.getDeclaringType());
}
return LoggerFactory.getLogger(fallback);
}
}
调用处传入 this.getClass() 作为兜底。
10. 最终效果验证(节选)
- 接口成功,生产者发送 OK;
- 消费者首投递成功发送邮件并落库:status=0(SUCCESS);
- 重复投递时出现:
[mail_message_send:********] MQ repeated consumption
- 直接跳过,不再发邮件、不再落库。
11. 总结与最佳实践清单
- 版本选型:Northfields + SCA 2023.0.3.3,RocketMQ Client 5.3.x,函数式编程模型配合 StreamBridge 最稳。
- 邮箱发送:QQ 465 端口启用 ssl.enable=true,from 必须等于 username;打开 mail.debug 便于排障。
- 模板参数:DB template_param 与 paramList 数量要匹配,代码里务必做越界保护。
- MQ 幂等 Key:只用稳定的业务键(如 messageSendId),不要使用 hashCode()。
- 双层幂等:
- 接口层:业务键/Redis SETNX + 合理 TTL;必要时叠加唯一索引或去重表;可做频控。
- 消费层:MQ + SPEL 幂等,首次 SETNX,成功后写 CONSUMED,重复投递直接忽略。
- 日志级别与可观测性:重复消费属常见场景,建议告警降噪;关键链路打印 Keys、MsgId、耗时、入参摘要,方便对账与排错。
12. 附录:验证码模板示例(FreeMarker)
resources/templates/userRegisterVerification.ftl
<html lang="zh-CN">
<body>
<div>
<p>亲爱的用户:</p>
<p>您好!本次验证码为:<b style="font-size: 32px; color:#2D7BFF;">${validCode!""}</b></p>
<p>为保障您的账户安全,请在 5 分钟内完成验证,验证码将自动失效。</p>
</div>
</body>
</html>
完。 如需把接口层幂等与频控抽成公共组件,或加上统一的“发送频率策略(按邮箱/按模板/按 IP)”,可以在此文的基础上继续演进。