Spring Boot 3.5 + Spring Cloud Stream:邮件发送与幂等实战

一次从版本选择、依赖拉通、RocketMQ 消费、QQ 邮箱对接到幂等机制落地的完整踩坑记录。


0. 背景与目标

  • 目标:在微服务消息中心中实现"发送邮件验证码"的异步化处理:

    • Web 接口入参校验 → 发送 MailMessageSendEvent 到 RocketMQ → 消费者落库并真正调用邮件网关(QQ 邮箱)。

    • 需要解决:

      1. 版本匹配与 Binder 正常工作;
      2. 邮箱模板加载与渲染;
      3. QQ 邮箱 SMTP 连接、鉴权、SSL 配置;
      4. MQ 至少一次投递 导致的重复消费问题(幂等);
      5. 常见异常排查:表不存在、参数越界、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 styleStreamBridge + 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)

sequenceDiagram autonumber participant U as 用户 participant API as MessageSendController participant S as SendMessageServiceImpl participant P as MessageSendProduce(StreamBridge) participant MQ as RocketMQ participant H as MailMessageSendHandler(@Idempotent) participant F as MessageSendFacade participant M as MailMessageProduceImpl(JavaMailSender) participant R as MessageSendRepository U->>API: POST /api/message/send/mail API->>S: mailMessageSend(cmd) S->>S: 生成 Snowflake messageSendId S->>P: StreamBridge.send(event) P->>MQ: 发送消息 mail_send_topic MQ-->>H: 投递消息 (至少一次) H->>H: 幂等检查 (Redis SETNX) alt 首次消费 H->>F: mailMessageSend(messageSend) F->>M: send(messageSend) M->>M: 查模板 / 渲染 / SMTP 发送 M-->>F: boolean sendResult F->>R: 保存 send_record(+extend) else 重复投递 H-->>H: 直接跳过,返回 null end H-->>MQ: ack

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
  • 支持两种 场景RESTAPIMQ
  • 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. 总结与最佳实践清单

  1. 版本选型 :Northfields + SCA 2023.0.3.3,RocketMQ Client 5.3.x,函数式编程模型配合 StreamBridge 最稳。

  2. 邮箱发送 :QQ 465 端口启用 ssl.enable=truefrom 必须等于 username;打开 mail.debug 便于排障。

  3. 模板参数 :DB template_paramparamList 数量要匹配,代码里务必做越界保护。

  4. MQ 幂等 Key :只用稳定的业务键(如 messageSendId),不要使用 hashCode()

  5. 双层幂等

    • 接口层 :业务键/Redis SETNX + 合理 TTL;必要时叠加唯一索引或去重表;可做频控。
    • 消费层MQ + SPEL 幂等,首次 SETNX,成功后写 CONSUMED,重复投递直接忽略。
  6. 日志级别与可观测性:重复消费属常见场景,建议告警降噪;关键链路打印 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)",可以在此文的基础上继续演进。

相关推荐
GEM的左耳返10 分钟前
Java面试全方位解析:从基础到AI的技术交锋
spring boot·微服务·java面试·互联网大厂·rag技术·ai面试·java技术栈
GEM的左耳返17 分钟前
互联网大厂Java面试:微服务与AI技术深度交锋
spring cloud·ai·微服务架构·java面试·rag技术
微笑听雨41 分钟前
Java 设计模式之单例模式(详细解析)
java·后端
微笑听雨41 分钟前
【Drools】(二)基于业务需求动态生成 DRL 规则文件:事实与动作定义详解
java·后端
猫猫的小茶馆1 小时前
【STM32】FreeRTOS 任务的删除(三)
java·linux·stm32·单片机·嵌入式硬件·mcu·51单片机
天天摸鱼的java工程师1 小时前
🔧 MySQL 索引的设计原则有哪些?【原理 + 业务场景实战】
java·后端·面试
空影学Java1 小时前
Day44 Java数组08 冒泡排序
java
追风少年浪子彦2 小时前
mybatis-plus实体类主键生成策略
java·数据库·spring·mybatis·mybatis-plus
GEM的左耳返2 小时前
Java面试实战:从基础到架构的全方位技术交锋
spring boot·微服务·云原生·java面试·技术解析·ai集成
创码小奇客2 小时前
Talos 使用全攻略:从基础到高阶,常见问题一网打尽
java·后端·架构