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

552 阅读7分钟

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


0. 背景与目标

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

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

    • 需要解决:

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

1. 技术栈与版本矩阵

组件版本说明
JDK21与 Spring Framework 6.x 匹配
Spring Boot3.5.3Northfields 世代
Spring Cloud2025.0.0Northfields 主版本
Spring Cloud Alibaba2023.0.3.3与 Northfields 适配版本
RocketMQClient 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.3Spring Boot 3.x 默认走 Jakarta API
FreeMarker2.3.x模板引擎

经验:Northfields 下建议直接采用 SCA 2023.0.3.3,Binder 与 RocketMQ 5.3.x 组合比较顺畅。


2. 业务架构与消息流

2.1 架构总览(Mermaid)

image.png

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 视项目而定。

<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
  • 支持两种 场景RESTAPIMQ
  • 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 userQQ 要求发件人与认证用户一致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. 总结与最佳实践清单

  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

<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)”,可以在此文的基础上继续演进。