面试官:设计一个消息队列!八年 Java 开发拆解核心模块 + 避坑思路

210 阅读10分钟

面试官:设计一个消息队列!八年 Java 开发拆解核心模块 + 避坑思路

作为一名写了八年 Java 的开发者,我对消息队列的感情很复杂:刚入行时觉得它是 “高深架构”,后来天天用 RabbitMQ、Kafka 却没深究原理,直到三年前接手一个老项目 —— 因第三方 MQ 不稳定导致订单丢失,被迫自己撸了个轻量版消息队列救急。

这篇文章不从源码解析入手,而是站在 “从零设计” 的角度,聊聊一个可用的消息队列该包含哪些核心模块、为什么要这么设计,以及关键代码怎么写。内容偏实战,适合想搞懂消息队列底层逻辑的开发者。

一、先想清楚:业务到底需要消息队列解决什么问题?

别上来就堆 “高可用”“分布式”,先明确业务需求。八年里我遇到的消息队列场景,核心需求就三个:

1. 解耦:让服务之间 “互不依赖”

最典型的是电商下单流程:用户支付成功后,需要通知库存系统扣减库存、物流系统创建订单、积分系统加积分。如果直接在支付接口里调用这三个系统的 API,一旦某系统挂了,整个支付流程就卡壳。

用消息队列后,支付系统只需要往队列里发一条 “支付成功” 的消息,其他系统自己去队列里取消息处理 —— 就算物流系统宕机,支付流程也能正常完成,物流恢复后再消费消息即可。

2. 削峰:扛住突发流量

秒杀场景最明显:一秒内突然涌入 10 万请求,直接打到数据库肯定崩。这时候让请求先进入消息队列,后端系统按自己的处理能力(比如每秒 2000 条)慢慢消费,避免 “流量洪峰” 冲垮系统。

3. 异步:减少接口响应时间

用户注册时,除了创建账号,可能还要发欢迎短信、推送新人福利、初始化用户配置。如果同步做这些事,接口响应时间可能从 50ms 变成 500ms。

用消息队列异步处理:注册接口只负责创建账号,然后发一条 “用户注册成功” 的消息,其他操作让消费者异步处理,接口响应时间能压回 100ms 以内。

二、核心设计思路:一个消息队列的 “最小可行版本” 该有哪些模块?

抛开 Kafka 的分区、RabbitMQ 的交换机这些复杂概念,一个能用的消息队列,核心模块其实很简单:生产者、消费者、Broker(队列服务器)、消息存储、投递机制

1. 消息(Message):传递的数据载体

至少要包含:

  • 唯一标识(messageId):用于去重、确认消息是否被消费
  • 主题(topic):消息的分类(比如 “order_pay”“user_register”),消费者按主题订阅
  • 内容(body):实际传递的数据(JSON 字符串居多)
  • 时间戳(timestamp):创建时间,用于过期判断
  • 状态(status):是否被消费、是否投递失败等

2. Broker:队列的 “服务器”

核心职责:接收生产者的消息、存储消息、把消息投递给消费者。可以理解为 “消息的中转站 + 仓库”。

3. 生产者(Producer):发消息的一端

负责把消息发送到 Broker,需要解决:

  • 怎么连接 Broker(TCP/HTTP?初期用 TCP 更高效)
  • 消息发送失败了怎么办(重试机制)

4. 消费者(Consumer):收消息的一端

负责从 Broker 拉取或接收消息,需要解决:

  • 怎么告诉 Broker “消息已处理完成”(ACK 机制,避免消息丢失)
  • 处理失败了怎么办(重试次数限制,避免死循环)

5. 消息存储:Broker 怎么存消息?

两种常见方式:

  • 内存存储(如 ConcurrentHashMap):快,但服务重启消息会丢,适合非核心场景
  • 磁盘存储(文件 / 数据库):持久化,但性能稍差,适合核心业务(如订单消息)

6. 投递机制:消息怎么从 Broker 到消费者?

  • 推(Push):Broker 主动把消息推给消费者(需要消费者提供回调接口)
  • 拉(Pull):消费者主动去 Broker 拉消息(更灵活,消费者可控制节奏)

三、核心代码实现:用 Java 撸一个极简版消息队列

我们实现一个 “能跑通” 的最小版本,包含上述核心模块,代码尽量简洁(实际项目中可基于此扩展)。

1. 定义消息结构(Message)

import lombok.AllArgsConstructor;
import lombok.Data;
import java.util.UUID;

@Data
@AllArgsConstructor
public class Message {
    private String messageId;  // 消息唯一ID
    private String topic;      // 消息主题
    private String body;       // 消息内容(JSON字符串)
    private long timestamp;    // 发送时间戳

    // 生成消息的静态方法(简化创建)
    public static Message create(String topic, String body) {
        return new Message(
            UUID.randomUUID().toString(),  // 用UUID生成唯一ID
            topic,
            body,
            System.currentTimeMillis()
        );
    }
}

2. 实现 Broker 核心逻辑

Broker 需要管理多个主题(topic),每个主题下有自己的消息队列和订阅的消费者。这里用内存存储(ConcurrentHashMap),简化实现:

import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.locks.ReentrantLock;

public class Broker {
    // 主题→消息队列(每个主题下有一个消息列表)
    private final Map<String, Queue<Message>> topicMessageQueues = new ConcurrentHashMap<>();
    // 主题→消费者列表(每个主题可以有多个消费者)
    private final Map<String, List<Consumer>> topicConsumers = new ConcurrentHashMap<>();
    // 分布式场景下需要锁,这里用可重入锁保证并发安全
    private final ReentrantLock lock = new ReentrantLock();

    // 1. 创建主题(如果不存在)
    public void createTopic(String topic) {
        topicMessageQueues.putIfAbsent(topic, new LinkedList<>());
        topicConsumers.putIfAbsent(topic, new ArrayList<>());
    }

    // 2. 生产者发送消息到Broker
    public void send(Message message) {
        String topic = message.getTopic();
        // 先检查主题是否存在,不存在则创建
        createTopic(topic);
        // 加锁保证消息入队的原子性
        lock.lock();
        try {
            topicMessageQueues.get(topic).add(message);
        } finally {
            lock.unlock();
        }
        // 消息入队后,通知消费者(推模式)
        notifyConsumers(topic);
    }

    // 3. 消费者订阅主题
    public void subscribe(String topic, Consumer consumer) {
        createTopic(topic);
        topicConsumers.get(topic).add(consumer);
    }

    // 4. 通知消费者有新消息(推模式核心)
    private void notifyConsumers(String topic) {
        Queue<Message> queue = topicMessageQueues.get(topic);
        List<Consumer> consumers = topicConsumers.get(topic);
        if (consumers.isEmpty()) {
            return; // 没有消费者,消息暂存队列
        }

        // 简单轮询:让消费者轮流处理消息(负载均衡的简单实现)
        Message message = queue.peek(); // 先拿消息,不删除
        if (message == null) {
            return;
        }

        // 轮询选择一个消费者
        Consumer consumer = consumers.get(queue.size() % consumers.size());
        // 调用消费者的处理方法
        boolean success = consumer.process(message);
        if (success) {
            // 处理成功,从队列移除消息(ACK机制的简化)
            queue.poll();
        } else {
            // 处理失败,后续可加重试逻辑(比如放到死信队列)
            System.out.println("消息处理失败,messageId: " + message.getMessageId());
        }
    }
}

3. 实现生产者(Producer)

生产者很简单,只需要持有 Broker 的引用,调用 send 方法:

public class Producer {
    private final Broker broker;

    public Producer(Broker broker) {
        this.broker = broker;
    }

    // 发送消息
    public void send(String topic, String body) {
        Message message = Message.create(topic, body);
        broker.send(message);
        System.out.println("生产者发送消息:" + message);
    }
}

4. 实现消费者(Consumer)

消费者需要定义处理消息的逻辑,处理成功返回 true(ACK),失败返回 false:

// 消费者接口(定义处理消息的方法)
@FunctionalInterface
public interface Consumer {
    boolean process(Message message);
}

// 示例:订单支付消息的消费者
public class OrderPayConsumer implements Consumer {
    @Override
    public boolean process(Message message) {
        try {
            // 模拟处理逻辑:解析消息、调用库存接口等
            System.out.println("订单支付消费者处理消息:" + message.getBody());
            // 处理成功,返回true(ACK)
            return true;
        } catch (Exception e) {
            // 处理失败,返回false(不ACK,消息会被重试)
            return false;
        }
    }
}

5. 测试:完整流程跑通

public class Main {
    public static void main(String[] args) {
        // 1. 创建Broker
        Broker broker = new Broker();

        // 2. 创建生产者
        Producer producer = new Producer(broker);

        // 3. 创建消费者并订阅主题
        Consumer orderConsumer = new OrderPayConsumer();
        broker.subscribe("order_pay", orderConsumer);

        // 4. 生产者发送消息
        producer.send("order_pay", "{"orderId":1001,"amount":99.9}");
        producer.send("order_pay", "{"orderId":1002,"amount":199.9}");
    }
}

输出结果:

生产者发送消息:Message(messageId=xxx, topic=order_pay, body={"orderId":1001,"amount":99.9}, timestamp=xxx)
订单支付消费者处理消息:{"orderId":1001,"amount":99.9}
生产者发送消息:Message(messageId=xxx, topic=order_pay, body={"orderId":1002,"amount":199.9}, timestamp=xxx)
订单支付消费者处理消息:{"orderId":1002,"amount":199.9}

四、从 “能用” 到 “好用”:需要补充哪些关键特性?(八年踩坑总结)

上面的极简版只能算 “玩具”,要用于生产环境,必须解决这些问题:

1. 消息持久化:避免 Broker 重启后消息丢失

内存存储的致命问题是 “一重启就丢消息”。解决方案:定时把内存中的消息刷到磁盘(类似 Kafka 的日志文件)。

简单实现:用 Java NIO 把消息追加到文件, Broker 启动时再从文件加载消息:

// 简化的持久化工具类
public class MessageStore {
    private final String storePath;

    public MessageStore(String storePath) {
        this.storePath = storePath;
    }

    // 保存消息到文件
    public void save(Message message) {
        try (FileWriter writer = new FileWriter(storePath, true)) {
            writer.write(JSON.toJSONString(message) + "\n"); // 一行一条消息
        } catch (IOException e) {
            throw new RuntimeException("消息持久化失败", e);
        }
    }

    // 从文件加载消息
    public List<Message> load() {
        List<Message> messages = new ArrayList<>();
        // 读取文件并反序列化为Message对象(代码略)
        return messages;
    }
}

踩坑点:频繁刷盘影响性能,建议用 “批量刷盘 + 定时刷盘” 结合(比如攒够 100 条或 1 秒内没新消息,就刷一次盘)。

2. 消息重试:处理消费者失败的情况

消费者处理消息可能失败(比如数据库宕机),这时候消息不能从队列里删,需要重试。但不能无限重试(比如消息本身有问题,重试 100 次也没用)。

解决方案:给消息加重试次数,超过次数放入 “死信队列”

// 改造Message,增加重试次数
@Data
public class Message {
    // ...原有字段
    private int retryCount = 0;  // 已重试次数
    private int maxRetryCount = 3; // 最大重试次数
}

// Broker的notifyConsumers方法改造
private void notifyConsumers(String topic) {
    // ...省略部分代码
    if (success) {
        queue.poll(); // 成功则移除
    } else {
        message.setRetryCount(message.getRetryCount() + 1);
        if (message.getRetryCount() >= message.getMaxRetryCount()) {
            // 超过最大重试次数,放入死信队列
            deadLetterQueue.add(message);
            queue.poll();
        }
    }
}

3. 并发安全:避免多线程操作导致消息丢失

多个生产者同时发消息、多个消费者同时消费,可能导致消息队列被 “重复消费” 或 “漏消费”。

解决方案:用 ConcurrentHashMap+ReentrantLock 保证队列操作的原子性(前面的 Broker 代码已经加了锁),但要注意锁的粒度 —— 别把整个 Broker 加锁,只锁单个主题的队列,否则影响性能。

4. 消费者负载均衡:避免某台机器压力过大

如果一个主题有多个消费者实例(比如部署了 3 台服务器的订单服务),需要让消息均匀分配到各个实例,避免 “一台机器累死,其他机器闲死”。

解决方案:按消息 ID 哈希分片(类似 Kafka 的分区),同一个消息 ID 的消息始终分给同一个消费者:

// 简化的负载均衡逻辑(在notifyConsumers中)
int consumerIndex = Math.abs(message.getMessageId().hashCode()) % consumers.size();
Consumer consumer = consumers.get(consumerIndex);

五、最后:设计消息队列的 “务实原则”

八年开发经验告诉我:别一开始就追求 “完美架构”,先实现核心功能,再根据业务压力迭代

  • 非核心场景(如日志收集):用内存存储 + 简单重试,够用就行;

  • 核心场景(如订单支付):必须持久化 + 死信队列 + 重试机制;

  • 高并发场景:再考虑分区、集群、异步刷盘这些优化。

市面上成熟的消息队列(RabbitMQ/Kafka)已经解决了 99% 的问题,大多数时候没必要重复造轮子。但理解 “怎么设计一个消息队列” 的价值在于:当你遇到 MQ 的诡异问题时(比如消息丢失、重复消费),能快速定位到底是哪个模块出了问题 —— 这才是 “知其然更知其所以然” 的意义。