一次从版本选择、依赖拉通、RocketMQ 消费、QQ 邮箱对接到幂等机制落地的完整踩坑记录。
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 视项目而定。
xml
<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
yaml
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,常见属性如下。
yaml
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
发送
java
@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 函数式消费者绑定
java
@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 消息 的重复消费;拦不住"接口被重复调用而产生多条不同消息"。
java
@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. 邮件发送实现与模板缓存
java
@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 切面与模板方法
java
@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
,失败返回"发送过于频繁"。
java
@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 防空示例
java
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)
; -
重复投递时出现:
bash[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
,重复投递直接忽略。
- 接口层 :业务键/Redis
-
日志级别与可观测性:重复消费属常见场景,建议告警降噪;关键链路打印 Keys、MsgId、耗时、入参摘要,方便对账与排错。
12. 附录:验证码模板示例(FreeMarker)
resources/templates/userRegisterVerification.ftl
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)",可以在此文的基础上继续演进。