第十九课实战:消息队列实战——SpringBoot 用户注册异步链路最小闭环(工程版)

场景:用户注册成功后,异步发送欢迎邮件

技术栈:Spring Boot 3.x + RabbitMQ

关键词:Producer / Consumer / Topic / Confirm / Retry / DLQ(死信队列)


1. 你最终要跑通的链路

复制代码
POST /api/register
→ UserService 写入用户(这里用内存模拟或数据库都行)
→ 发布事件 USER_REGISTERED
→ 立刻返回 success

RabbitMQ
→ MailConsumer 监听队列
→ 模拟发送邮件(可故意失败触发重试/死信)

2. 项目结构(建议这样放)

复制代码
mq-register-demo
├── src/main/java/com/example/demo
│   ├── DemoApplication.java
│   ├── config
│   │   ├── RabbitMQConfig.java
│   │   └── RabbitTemplateConfig.java
│   ├── controller
│   │   └── RegisterController.java
│   ├── domain
│   │   ├── event
│   │   │   └── UserRegisteredEvent.java
│   │   └── model
│   │       └── User.java
│   ├── service
│   │   ├── UserService.java
│   │   └── MailService.java
│   ├── mq
│   │   ├── producer
│   │   │   └── UserEventProducer.java
│   │   └── consumer
│   │       └── MailConsumer.java
│   └── util
│       └── Jsons.java
└── src/main/resources
    └── application.yml

3. 环境准备:RabbitMQ(本地 Docker)

复制代码
docker run -d --name rabbit \
  -p 5672:5672 -p 15672:15672 \
  rabbitmq:3-management
  • 管理台:http://localhost:15672
  • 默认账号密码:guest / guest

4. Maven 依赖(pom.xml)

XML 复制代码
<dependencies>

  <!-- Web 层:提供 REST API(Controller、JSON 入参/出参、内置 Tomcat 等运行时能力) -->
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
  </dependency>

  <!-- RabbitMQ / AMQP:Spring 对 MQ 的封装(生产/消费都靠它)
       - Producer:RabbitTemplate 发送消息
       - Consumer:@RabbitListener 监听队列
       - 连接/通道:ConnectionFactory、消息确认(confirm/return)、重试等 -->
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-amqp</artifactId>
  </dependency>

  <!-- 参数校验:用于校验 Controller 入参 DTO
       常用注解:@NotBlank、@NotNull、@Email、@Size 等
       目的:把"非法请求"挡在业务层之外 -->
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
  </dependency>

  <!-- JSON 序列化/反序列化(Java 对象 ↔ JSON)
       - Web:返回/接收 JSON 时使用
       - MQ:若配置 Jackson2JsonMessageConverter,消息体会用它做序列化
       备注:starter-web 通常已间接引入 Jackson,这里显式写出便于读者理解 -->
  <dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
  </dependency>

</dependencies>
  • 如果只做 Web 接口starter-web + validation 就够
  • 引入 MQ :必须加 starter-amqp
  • 做 JSON 消息体(跨语言友好) :通常会配 Jackson2JsonMessageConverter,Jackson 相关依赖就更关键

5. application.yml(写全一点)

XML 复制代码
server:
  port: 8080

spring:
  rabbitmq:
    host: localhost
    port: 5672
    username: guest
    password: guest

    # 生产者确认:消息是否到达交换机 / 是否路由到队列
    publisher-confirm-type: correlated
    publisher-returns: true

    listener:
      simple:
        acknowledge-mode: manual   # 手动ACK,便于演示可靠性
        retry:
          enabled: true
          initial-interval: 1000ms
          max-attempts: 3
          multiplier: 2.0
          max-interval: 5000ms

6. MQ 设计:交换机/队列/死信队列

我们定义:

  • 交换机:user.exchange(topic)
  • 业务队列:mail.queue
  • routingKey:user.registered
  • 死信交换机:dlx.exchange
  • 死信队列:mail.dlq

6.1 RabbitMQConfig.java

java 复制代码
package com.example.demo.config;

import org.springframework.amqp.core.*;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.Map;

@Configuration
public class RabbitMQConfig {

    public static final String USER_EXCHANGE = "user.exchange";
    public static final String USER_REGISTERED_KEY = "user.registered";
    public static final String MAIL_QUEUE = "mail.queue";

    public static final String DLX_EXCHANGE = "dlx.exchange";
    public static final String MAIL_DLQ = "mail.dlq";
    public static final String DLX_ROUTING_KEY = "dlx.mail";

    @Bean
    public TopicExchange userExchange() {
        return new TopicExchange(USER_EXCHANGE, true, false);
    }

    @Bean
    public DirectExchange dlxExchange() {
        return new DirectExchange(DLX_EXCHANGE, true, false);
    }

    @Bean
    public Queue mailQueue() {
        // 绑定死信:消费失败/拒绝/过期等 → 进入 DLX
        return QueueBuilder.durable(MAIL_QUEUE)
                .withArguments(Map.of(
                        "x-dead-letter-exchange", DLX_EXCHANGE,
                        "x-dead-letter-routing-key", DLX_ROUTING_KEY
                ))
                .build();
    }

    @Bean
    public Queue mailDlq() {
        return QueueBuilder.durable(MAIL_DLQ).build();
    }

    @Bean
    public Binding mailBinding(Queue mailQueue, TopicExchange userExchange) {
        return BindingBuilder.bind(mailQueue).to(userExchange).with(USER_REGISTERED_KEY);
    }

    @Bean
    public Binding dlqBinding(Queue mailDlq, DirectExchange dlxExchange) {
        return BindingBuilder.bind(mailDlq).to(dlxExchange).with(DLX_ROUTING_KEY);
    }
}

7. 生产者可靠性:Confirm + Return

7.1 RabbitTemplateConfig.java

java 复制代码
package com.example.demo.config;

import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.context.annotation.Configuration;

@Configuration
public class RabbitTemplateConfig {

    public RabbitTemplateConfig(RabbitTemplate rabbitTemplate) {

        // confirm:消息是否到达交换机
        rabbitTemplate.setConfirmCallback((correlationData, ack, cause) -> {
            String id = correlationData == null ? "null" : correlationData.getId();
            if (ack) {
                System.out.println("[CONFIRM] OK id=" + id);
            } else {
                System.out.println("[CONFIRM] FAIL id=" + id + " cause=" + cause);
            }
        });

        // return:消息到达交换机,但无法路由到队列
        rabbitTemplate.setReturnsCallback(returned -> {
            System.out.println("[RETURN] message=" + returned.getMessage()
                    + " replyText=" + returned.getReplyText()
                    + " exchange=" + returned.getExchange()
                    + " routingKey=" + returned.getRoutingKey());
        });
    }
}

8. 事件模型:别只传 success

8.1 UserRegisteredEvent.java

java 复制代码
package com.example.demo.domain.event;

import java.time.Instant;

public class UserRegisteredEvent {
    public Long userId;
    public String email;
    public Instant occurredAt;

    public UserRegisteredEvent() {}

    public UserRegisteredEvent(Long userId, String email) {
        this.userId = userId;
        this.email = email;
        this.occurredAt = Instant.now();
    }
}

9. Producer:注册成功后发事件

9.1 UserEventProducer.java

java 复制代码
package com.example.demo.mq.producer;

import com.example.demo.config.RabbitMQConfig;
import com.example.demo.domain.event.UserRegisteredEvent;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.amqp.rabbit.support.CorrelationData;
import org.springframework.stereotype.Component;

import java.util.UUID;

@Component
public class UserEventProducer {

    private final RabbitTemplate rabbitTemplate;

    public UserEventProducer(RabbitTemplate rabbitTemplate) {
        this.rabbitTemplate = rabbitTemplate;
    }

    public void publishUserRegistered(UserRegisteredEvent event) {
        CorrelationData cd = new CorrelationData(UUID.randomUUID().toString());
        rabbitTemplate.convertAndSend(
                RabbitMQConfig.USER_EXCHANGE,
                RabbitMQConfig.USER_REGISTERED_KEY,
                event,
                cd
        );
    }
}

这里我们直接发 对象,Spring AMQP 默认会用序列化(你也可以切换为 JSON MessageConverter,生产更规范,后面我给你一段可选增强)。

10. Consumer:监听队列 + 手动 ACK + 失败进入死信

10.1 MailService.java(模拟发邮件)

java 复制代码
package com.example.demo.service;

import org.springframework.stereotype.Service;

@Service
public class MailService {

    public void sendWelcomeMail(String email) {
        // 模拟:你可以在这里接入真实邮件服务
        System.out.println("✅ 发送欢迎邮件给: " + email);

        // 演示失败:取消注释可触发重试/死信
        // if (email.contains("fail")) throw new RuntimeException("模拟邮件服务失败");
    }
}

10.2 MailConsumer.java

java 复制代码
package com.example.demo.mq.consumer;

import com.example.demo.config.RabbitMQConfig;
import com.example.demo.domain.event.UserRegisteredEvent;
import com.example.demo.service.MailService;
import com.rabbitmq.client.Channel;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.amqp.support.AmqpHeaders;
import org.springframework.messaging.handler.annotation.Header;
import org.springframework.stereotype.Component;

@Component
public class MailConsumer {

    private final MailService mailService;

    public MailConsumer(MailService mailService) {
        this.mailService = mailService;
    }

    @RabbitListener(queues = RabbitMQConfig.MAIL_QUEUE)
    public void onMessage(UserRegisteredEvent event,
                          Channel channel,
                          @Header(AmqpHeaders.DELIVERY_TAG) long tag) throws Exception {

        try {
            System.out.println("📩 [Consumer] 收到事件 userId=" + event.userId + " email=" + event.email);

            mailService.sendWelcomeMail(event.email);

            channel.basicAck(tag, false); // 成功:ACK
        } catch (Exception e) {
            System.out.println("❌ [Consumer] 处理失败:" + e.getMessage());

            // requeue=false:不重新入队,交给死信队列(DLQ)
            channel.basicNack(tag, false, false);
        }
    }
}

11. Controller:提供注册接口

11.1 User.java(简单模型)

java 复制代码
package com.example.demo.domain.model;

public class User {
    public Long id;
    public String email;

    public User(Long id, String email) {
        this.id = id;
        this.email = email;
    }
}

11.2 UserService.java(模拟"写库")

java 复制代码
package com.example.demo.service;

import com.example.demo.domain.model.User;
import org.springframework.stereotype.Service;

import java.util.concurrent.atomic.AtomicLong;

@Service
public class UserService {

    private final AtomicLong idGen = new AtomicLong(1000);

    public User register(String email) {
        // 这里可替换成:写 MySQL(事务)
        Long id = idGen.incrementAndGet();
        System.out.println("🧾 用户注册成功,写入用户表 userId=" + id + " email=" + email);
        return new User(id, email);
    }
}

11.3 RegisterController.java

java 复制代码
package com.example.demo.controller;

import com.example.demo.domain.event.UserRegisteredEvent;
import com.example.demo.domain.model.User;
import com.example.demo.mq.producer.UserEventProducer;
import com.example.demo.service.UserService;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api")
@Validated
public class RegisterController {

    private final UserService userService;
    private final UserEventProducer producer;

    public RegisterController(UserService userService, UserEventProducer producer) {
        this.userService = userService;
        this.producer = producer;
    }

    public static class RegisterReq {
        @NotBlank
        @Email
        public String email;
    }

    @PostMapping("/register")
    public String register(@RequestBody RegisterReq req) {
        User user = userService.register(req.email);

        // 发布事件(异步)
        producer.publishUserRegistered(new UserRegisteredEvent(user.id, user.email));

        // 立刻返回
        return "success";
    }
}

12. 启动 & 验证

12.1 启动服务

启动 Spring Boot。

12.2 调用接口

java 复制代码
curl -X POST http://localhost:8080/api/register \
  -H "Content-Type: application/json" \
  -d '{"email":"a@b.com"}'

你会看到:

  • Controller 立刻返回 success
  • Consumer 异步打印"发送欢迎邮件"

12.3 验证死信(可选)

把邮箱改成 fail@b.com,并在 MailService 打开模拟失败那行。

然后去 RabbitMQ 管理台看 mail.dlq 队列里是否有消息。

13. 这就是"最小闭环"的工程价值

你跑通的不是"发一条 MQ"这么简单,而是把系统做成了:

  • 主链路(注册)尽快返回
  • 旁路(邮件)异步解耦
  • 失败不丢(DLQ 接住)
  • 可观测(Confirm / Return / Consumer 日志)

14. 下一步怎么升级(你后续文章的方向)

如果你要从"最小闭环"升级成"生产可用",通常会加:

  1. Outbox(本地消息表):解决"写库成功但发 MQ 失败"的一致性问题
  2. 幂等 :消费者按 eventId 去重
  3. 重试策略:业务可重试、不可重试分别处理
  4. 延迟队列/补偿:和第20课"任务系统"衔接
  5. 统一事件规范:eventName、eventId、traceId、occurredAt

可选增强:改为 JSON 消息(更规范)

如果你希望消息体可读、跨语言友好,建议加一个 JSON MessageConverter:

java 复制代码
@Bean
public MessageConverter messageConverter() {
    return new Jackson2JsonMessageConverter();
}

然后 RabbitTemplate 和 Listener 都会走 JSON。

相关推荐
青云计划8 小时前
知光项目知文发布模块
java·后端·spring·mybatis
赶路人儿8 小时前
Jsoniter(java版本)使用介绍
java·开发语言
Victor3568 小时前
MongoDB(9)什么是MongoDB的副本集(Replica Set)?
后端
Victor3568 小时前
MongoDB(8)什么是聚合(Aggregation)?
后端
探路者继续奋斗8 小时前
IDD意图驱动开发之意图规格说明书
java·规格说明书·开发规范·意图驱动开发·idd
消失的旧时光-19439 小时前
第十九课:为什么要引入消息队列?——异步系统设计思想
java·开发语言
yeyeye1119 小时前
Spring Cloud Data Flow 简介
后端·spring·spring cloud
A懿轩A9 小时前
【Java 基础编程】Java 面向对象入门:类与对象、构造器、this 关键字,小白也能写 OOP
java·开发语言
Tony Bai10 小时前
告别 Flaky Tests:Go 官方拟引入 testing/nettest,重塑内存网络测试标准
开发语言·网络·后端·golang·php
乐观勇敢坚强的老彭10 小时前
c++寒假营day03
java·开发语言·c++