238 lines
7.3 KiB
Markdown
238 lines
7.3 KiB
Markdown
---
|
||
tags:
|
||
- MQ
|
||
create time: 2026-05-24 19:52
|
||
---
|
||
|
||
# MQ 设计与实现
|
||
|
||
## 概述
|
||
|
||
理解 MQ 的最好方式是自己设计一个。本文串联前面所有知识,从零设计一个简化版 MQ,涵盖存储层、网络层、生产者、消费者、高可用等核心模块,帮助读者建立对 MQ 内部机制的完整认知。
|
||
|
||
## 正文
|
||
|
||
### 设计目标
|
||
|
||
在动手之前,先明确设计目标。一个实用的 MQ 需要平衡四个指标:
|
||
|
||
| 指标 | 目标 | 说明 |
|
||
|------|------|------|
|
||
| 高吞吐 | 10 万+ msg/s | 单节点能力,通过分区横向扩展 |
|
||
| 低延迟 | p99 < 10ms | 端到端的生产-消费延迟 |
|
||
| 消息不丢失 | At-Least-Once | 通过副本复制和 ACK 机制保证 |
|
||
| 发布订阅 | 支持多消费组 | 每个消费组独立消费全量消息 |
|
||
|
||
> [!question]
|
||
> 如果让你从零设计一个 MQ,你会优先保证哪个指标:吞吐、延迟,还是可靠性?为什么?
|
||
|
||
### 存储层设计
|
||
|
||
存储是 MQ 的基石。现代 MQ 几乎都采用 **Append-Only Log** 作为核心存储结构——消息只能追加写入,不能修改和删除(删除通过过期清理实现)。
|
||
|
||
为什么用 Log?三个原因:
|
||
1. **顺序写磁盘比随机写快 1000 倍**:即使是 SSD,顺序写的吞吐也远高于随机写。
|
||
2. **天然支持发布订阅**:Consumer 只需要记住读到哪个 Offset,每次从 Offset 位置顺序读即可。
|
||
3. **实现简单**:不需要复杂的 B+Tree 索引,追加写 + 文件分段就够了。
|
||
|
||
**分段存储(Segment)**:单个日志文件会无限增长,需要按大小或时间分段。每个 Segment 对应一个数据文件和一个索引文件。
|
||
|
||
**索引文件**:存储消息 Offset 到文件物理位置的映射。索引采用稀疏索引(Sparse Index),不是每条消息都建索引,而是每隔一定字节建一条。查找时先在索引中二分查找,再在文件中顺序扫描。
|
||
|
||
```go
|
||
// 简化版 Log 存储
|
||
type Segment struct {
|
||
baseOffset uint64
|
||
dataFile *os.File
|
||
indexFile *os.File
|
||
currentSize int64
|
||
maxBytes int64
|
||
}
|
||
|
||
// 追加写入消息
|
||
func (s *Segment) Append(offset uint64, data []byte) error {
|
||
// 写数据文件:[length][data]
|
||
buf := make([]byte, 4+len(data))
|
||
binary.BigEndian.PutUint32(buf[:4], uint32(len(data)))
|
||
copy(buf[4:], data)
|
||
|
||
pos, _ := s.dataFile.Seek(0, io.SeekCurrent)
|
||
s.dataFile.Write(buf)
|
||
|
||
// 写索引:[offset][position](稀疏索引,每 4KB 写一条)
|
||
if s.currentSize%4096 == 0 {
|
||
idxBuf := make([]byte, 16)
|
||
binary.BigEndian.PutUint64(idxBuf[:8], offset)
|
||
binary.BigEndian.PutUint64(idxBuf[8:], uint64(pos))
|
||
s.indexFile.Write(idxBuf)
|
||
}
|
||
|
||
s.currentSize += int64(len(buf))
|
||
return nil
|
||
}
|
||
|
||
// 按 Offset 读取消息
|
||
func (s *Segment) Read(offset uint64) ([]byte, error) {
|
||
// 1. 在索引文件中二分查找
|
||
position := s.findIndexPosition(offset)
|
||
// 2. 在数据文件中从 position 开始顺序读
|
||
s.dataFile.Seek(int64(position), io.SeekStart)
|
||
// 3. 读取 length + data
|
||
lenBuf := make([]byte, 4)
|
||
s.dataFile.Read(lenBuf)
|
||
length := binary.BigEndian.Uint32(lenBuf)
|
||
data := make([]byte, length)
|
||
s.dataFile.Read(data)
|
||
return data, nil
|
||
}
|
||
```
|
||
|
||
### 网络层设计
|
||
|
||
MQ 的网络层需要处理大量并发连接,**Reactor 模式**是最佳选择:
|
||
|
||
```mermaid
|
||
graph TB
|
||
subgraph "Reactor 网络模型"
|
||
A["Acceptor 线程"] -->|"接受连接"| EP["EventPoller - epoll/kqueue"]
|
||
EP -->|"可读事件"| W1["Worker 线程 1"]
|
||
EP -->|"可读事件"| W2["Worker 线程 2"]
|
||
EP -->|"可读事件"| W3["Worker 线程 3"]
|
||
W1 -->|"解析协议"| R["请求路由器"]
|
||
W2 -->|"解析协议"| R
|
||
W3 -->|"解析协议"| R
|
||
R -->|"Produce请求"| PH["ProduceHandler"]
|
||
R -->|"Fetch请求"| FH["FetchHandler"]
|
||
end
|
||
```
|
||
|
||
**协议编解码**:MQ 需要自定义二进制协议。一个简单的协议格式:
|
||
|
||
```
|
||
[4字节长度] [2字节请求类型] [4字节CorrelationID] [变长Body]
|
||
```
|
||
|
||
```go
|
||
// 简单的协议编解码
|
||
type Request struct {
|
||
RequestType uint16
|
||
CorrelationID uint32
|
||
Body []byte
|
||
}
|
||
|
||
func DecodeRequest(conn net.Conn) (*Request, error) {
|
||
// 读取长度
|
||
lenBuf := make([]byte, 4)
|
||
io.ReadFull(conn, lenBuf)
|
||
length := binary.BigEndian.Uint32(lenBuf)
|
||
|
||
// 读取完整请求
|
||
payload := make([]byte, length)
|
||
io.ReadFull(conn, payload)
|
||
|
||
return &Request{
|
||
RequestType: binary.BigEndian.Uint16(payload[:2]),
|
||
CorrelationID: binary.BigEndian.Uint32(payload[2:6]),
|
||
Body: payload[6:],
|
||
}, nil
|
||
}
|
||
```
|
||
|
||
### 生产者设计
|
||
|
||
Producer 的核心流程:
|
||
|
||
1. **序列化**:将业务对象转为字节数组。
|
||
2. **分区路由**:根据 Key 的哈希值选择目标 Partition。
|
||
3. **批量发送**:攒一批消息一起发送,减少网络往返。
|
||
4. **ACK 等待**:根据配置等待 Broker 确认(acks=0/1/all)。
|
||
|
||
```go
|
||
// 批量发送
|
||
type Producer struct {
|
||
buffer map[string][]*Message // key: topic-partition
|
||
batchSize int
|
||
linger time.Duration
|
||
mu sync.Mutex
|
||
}
|
||
|
||
func (p *Producer) Send(msg *Message) {
|
||
p.mu.Lock()
|
||
partition := hashKey(msg.Key) % p.partitionCount
|
||
key := fmt.Sprintf("%s-%d", msg.Topic, partition)
|
||
p.buffer[key] = append(p.buffer[key], msg)
|
||
|
||
if len(p.buffer[key]) >= p.batchSize {
|
||
msgs := p.buffer[key]
|
||
p.buffer[key] = nil
|
||
p.mu.Unlock()
|
||
p.flush(msgs) // 攒够一批,发送
|
||
return
|
||
}
|
||
p.mu.Unlock()
|
||
}
|
||
```
|
||
|
||
### 消费者设计
|
||
|
||
消费者的核心是 **Pull 模式 + Offset 管理**:
|
||
|
||
- **Pull 模式**:消费者主动从 Broker 拉取消息,而非 Broker 推送。这样消费者可以按自己的速率消费,天然支持背压。
|
||
- **Offset 管理**:每个消费组在每个 Partition 上维护一个 Offset,记录消费到的位置。
|
||
- **Consumer Group 协调**:同一组内的多个消费者通过 Rebalance 机制分配 Partition。
|
||
|
||
### 高可用设计
|
||
|
||
单节点 MQ 不够可靠,需要副本复制:
|
||
|
||
- **Leader-Follower**:每个 Partition 有一个 Leader 和多个 Follower。Leader 处理读写,Follower 同步数据。
|
||
- **Leader 选举**:Leader 宕机后,从 ISR 中选出新 Leader。基于 Epoch(任期)避免脑裂。
|
||
- **数据一致性**:通过 HW(High Watermark)机制,只有被所有 ISR 副本确认的消息才对外可见。
|
||
|
||
### 整体架构
|
||
|
||
```mermaid
|
||
graph TB
|
||
subgraph "Producer 集群"
|
||
P1["Producer 1"]
|
||
P2["Producer 2"]
|
||
end
|
||
subgraph "Broker 集群"
|
||
subgraph "Broker 1"
|
||
L1["Partition 0 Leader"]
|
||
F1["Partition 1 Follower"]
|
||
end
|
||
subgraph "Broker 2"
|
||
L2["Partition 1 Leader"]
|
||
F2["Partition 0 Follower"]
|
||
end
|
||
subgraph "存储层"
|
||
S1["Segment + Index"]
|
||
S2["Segment + Index"]
|
||
end
|
||
end
|
||
subgraph "Consumer Group A"
|
||
C1["Consumer 1 - P0"]
|
||
C2["Consumer 2 - P1"]
|
||
end
|
||
subgraph "Consumer Group B"
|
||
C3["Consumer 3 - P0+P1"]
|
||
end
|
||
P1 --> L1
|
||
P2 --> L2
|
||
L1 -->|"同步复制"| F2
|
||
L2 -->|"同步复制"| F1
|
||
L1 --> S1
|
||
L2 --> S2
|
||
L1 --> C1
|
||
L2 --> C2
|
||
L1 --> C3
|
||
L2 --> C3
|
||
```
|
||
|
||
## 关联笔记
|
||
|
||
- [[44-MQ-高可用架构]]
|
||
- [[48-MQ-客户端-SDK-最佳实践]]
|
||
- [[49-MQ-客户端连接管理]]
|