Files
xingrin/docs/redis-stream-queue-design.md
2026-01-30 13:01:02 +08:00

575 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Redis Stream 队列方案设计文档
## 概述
本文档描述了使用 Redis Stream 作为消息队列来优化大规模数据写入的方案设计。
## 背景
### 当前问题
在扫描大量 Endpoint 数据(几十万条)时,当前的 HTTP 批量写入方案存在以下问题:
1. **性能瓶颈**50 万 Endpoint每个 15 KB需要 83-166 分钟
2. **数据库 I/O 压力**20 个 Worker 同时写入导致数据库 I/O 满载
3. **Worker 阻塞风险**:如果使用批量写入 + 背压机制Worker 会阻塞等待
### 方案目标
- 性能提升 10 倍83 分钟 → 8 分钟)
- Worker 永不阻塞(扫描速度稳定)
- 数据不丢失(持久化保证)
- 无需部署新组件(利用现有 Redis
## 架构设计
### 整体架构
```
Worker 扫描 → Redis Stream → Server 消费 → PostgreSQL
```
### 数据流
1. **Worker 端**:扫描到 Endpoint → 发布到 Redis Stream
2. **Redis Stream**:缓冲消息(持久化到磁盘)
3. **Server 端**:单线程消费 → 批量写入数据库
### 关键特性
- **解耦**Worker 和数据库完全解耦
- **背压**Server 控制消费速度,保护数据库
- **持久化**Redis AOF 保证数据不丢失
- **扩展性**:支持多 Worker 并发写入
## Redis Stream 配置
### 启用 AOF 持久化
```conf
# redis.conf
appendonly yes
appendfsync everysec # 每秒同步一次(平衡性能和安全)
```
**效果**
- 数据持久化到磁盘
- Redis 崩溃最多丢失 1 秒数据
- 性能影响小
### 内存配置
```conf
# redis.conf
maxmemory 2gb
maxmemory-policy allkeys-lru # 内存不足时淘汰最少使用的 key
```
## 实现方案
### 1. Worker 端:发布到 Redis Stream
#### 代码结构
```
worker/internal/queue/
├── redis_publisher.go # Redis 发布者
└── types.go # 数据类型定义
```
#### 核心实现
```go
// worker/internal/queue/redis_publisher.go
package queue
import (
"context"
"encoding/json"
"fmt"
"github.com/redis/go-redis/v9"
)
type RedisPublisher struct {
client *redis.Client
}
func NewRedisPublisher(redisURL string) (*RedisPublisher, error) {
opt, err := redis.ParseURL(redisURL)
if err != nil {
return nil, err
}
client := redis.NewClient(opt)
// 测试连接
if err := client.Ping(context.Background()).Err(); err != nil {
return nil, err
}
return &RedisPublisher{client: client}, nil
}
// PublishEndpoint 发布 Endpoint 到 Redis Stream
func (p *RedisPublisher) PublishEndpoint(ctx context.Context, scanID int, endpoint Endpoint) error {
data, err := json.Marshal(endpoint)
if err != nil {
return err
}
streamName := fmt.Sprintf("endpoints:%d", scanID)
return p.client.XAdd(ctx, &redis.XAddArgs{
Stream: streamName,
MaxLen: 1000000, // 最多保留 100 万条消息(防止内存溢出)
Approx: true, // 使用近似裁剪(性能更好)
Values: map[string]interface{}{
"data": data,
},
}).Err()
}
// Close 关闭连接
func (p *RedisPublisher) Close() error {
return p.client.Close()
}
```
#### 使用示例
```go
// Worker 扫描流程
func (w *Worker) ScanEndpoints(ctx context.Context, scanID int) error {
// 初始化 Redis 发布者
publisher, err := queue.NewRedisPublisher(os.Getenv("REDIS_URL"))
if err != nil {
return err
}
defer publisher.Close()
// 扫描 Endpoint
for endpoint := range w.scan() {
// 发布到 Redis Stream非阻塞超快
if err := publisher.PublishEndpoint(ctx, scanID, endpoint); err != nil {
log.Printf("Failed to publish endpoint: %v", err)
// 可以选择重试或记录错误
}
}
return nil
}
```
### 2. Server 端:消费 Redis Stream
#### 代码结构
```
server/internal/queue/
├── redis_consumer.go # Redis 消费者
├── batch_writer.go # 批量写入器
└── types.go # 数据类型定义
```
#### 核心实现
```go
// server/internal/queue/redis_consumer.go
package queue
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/redis/go-redis/v9"
"github.com/yyhuni/lunafox/server/internal/repository"
)
type EndpointConsumer struct {
client *redis.Client
repository *repository.EndpointRepository
}
func NewEndpointConsumer(redisURL string, repo *repository.EndpointRepository) (*EndpointConsumer, error) {
opt, err := redis.ParseURL(redisURL)
if err != nil {
return nil, err
}
client := redis.NewClient(opt)
return &EndpointConsumer{
client: client,
repository: repo,
}, nil
}
// Start 启动消费者(单线程,控制写入速度)
func (c *EndpointConsumer) Start(ctx context.Context, scanID int) error {
streamName := fmt.Sprintf("endpoints:%d", scanID)
groupName := "endpoint-consumers"
consumerName := fmt.Sprintf("server-%d", time.Now().Unix())
// 创建消费者组(如果不存在)
c.client.XGroupCreateMkStream(ctx, streamName, groupName, "0")
// 批量写入器(每 5000 条批量写入)
batchWriter := NewBatchWriter(c.repository, 5000)
defer batchWriter.Flush()
for {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
// 读取消息(批量)
streams, err := c.client.XReadGroup(ctx, &redis.XReadGroupArgs{
Group: groupName,
Consumer: consumerName,
Streams: []string{streamName, ">"},
Count: 100, // 每次读取 100 条
Block: 1000, // 阻塞 1 秒
}).Result()
if err != nil {
if err == redis.Nil {
continue // 没有新消息
}
return err
}
// 处理消息
for _, stream := range streams {
for _, message := range stream.Messages {
// 解析消息
var endpoint Endpoint
if err := json.Unmarshal([]byte(message.Values["data"].(string)), &endpoint); err != nil {
// 记录错误,继续处理下一条
continue
}
// 添加到批量写入器
if err := batchWriter.Add(endpoint); err != nil {
return err
}
// 确认消息ACK
c.client.XAck(ctx, streamName, groupName, message.ID)
}
}
// 定期 Flush
if batchWriter.ShouldFlush() {
if err := batchWriter.Flush(); err != nil {
return err
}
}
}
}
// Close 关闭连接
func (c *EndpointConsumer) Close() error {
return c.client.Close()
}
```
#### 批量写入器
```go
// server/internal/queue/batch_writer.go
package queue
import (
"sync"
"github.com/yyhuni/lunafox/server/internal/model"
"github.com/yyhuni/lunafox/server/internal/repository"
)
type BatchWriter struct {
repository *repository.EndpointRepository
buffer []model.Endpoint
batchSize int
mu sync.Mutex
}
func NewBatchWriter(repo *repository.EndpointRepository, batchSize int) *BatchWriter {
return &BatchWriter{
repository: repo,
batchSize: batchSize,
buffer: make([]model.Endpoint, 0, batchSize),
}
}
// Add 添加到缓冲区
func (w *BatchWriter) Add(endpoint model.Endpoint) error {
w.mu.Lock()
w.buffer = append(w.buffer, endpoint)
shouldFlush := len(w.buffer) >= w.batchSize
w.mu.Unlock()
if shouldFlush {
return w.Flush()
}
return nil
}
// ShouldFlush 是否应该 Flush
func (w *BatchWriter) ShouldFlush() bool {
w.mu.Lock()
defer w.mu.Unlock()
return len(w.buffer) >= w.batchSize
}
// Flush 批量写入数据库
func (w *BatchWriter) Flush() error {
w.mu.Lock()
if len(w.buffer) == 0 {
w.mu.Unlock()
return nil
}
// 复制缓冲区
toWrite := make([]model.Endpoint, len(w.buffer))
copy(toWrite, w.buffer)
w.buffer = w.buffer[:0]
w.mu.Unlock()
// 批量写入(使用现有的 BulkUpsert 方法)
_, err := w.repository.BulkUpsert(toWrite)
return err
}
```
### 3. Server 启动消费者
```go
// server/internal/app/app.go
func Run(ctx context.Context, cfg config.Config) error {
// ... 现有代码
// 启动 Redis 消费者(后台运行)
consumer, err := queue.NewEndpointConsumer(cfg.RedisURL, endpointRepo)
if err != nil {
return err
}
go func() {
// 消费所有活跃的扫描任务
for {
// 获取活跃的扫描任务
scans := scanRepo.GetActiveScans()
for _, scan := range scans {
go consumer.Start(ctx, scan.ID)
}
time.Sleep(10 * time.Second)
}
}()
// ... 现有代码
}
```
## 性能对比
### 50 万 Endpoint每个 15 KB
| 方案 | 写入速度 | 总时间 | 内存占用 | Worker 阻塞 |
|------|---------|--------|---------|-----------|
| **当前HTTP 批量)** | 100 条/秒 | 83 分钟 | 1.5 MB | 否 |
| **Redis Stream** | 1000 条/秒 | 8 分钟 | 75 MB | 否 |
**提升****10 倍性能!**
## 资源消耗
### Redis 资源消耗
| 项目 | 消耗 |
|------|------|
| 内存 | ~500 MB缓冲 100 万条消息) |
| CPU | ~10%(序列化/反序列化) |
| 磁盘 | ~7.5 GBAOF 持久化) |
| 带宽 | ~50 MB/s |
### Server 资源消耗
| 项目 | 消耗 |
|------|------|
| 内存 | 75 MB批量写入缓冲 |
| CPU | 30%(反序列化 + 数据库写入) |
| 数据库连接 | 1 个(单线程消费) |
## 可靠性保证
### 数据不丢失
1. **Redis AOF 持久化**:每秒同步到磁盘,最多丢失 1 秒数据
2. **消息确认机制**Server 处理成功后才 ACK
3. **自动重试**:未 ACK 的消息会自动重新入队
### 故障恢复
| 故障场景 | 恢复机制 |
|---------|---------|
| Worker 崩溃 | 消息已发送到 Redis不影响 |
| Redis 崩溃 | AOF 恢复,最多丢失 1 秒数据 |
| Server 崩溃 | 未 ACK 的消息重新入队 |
| 数据库崩溃 | 消息保留在 Redis恢复后继续消费 |
## 扩展性
### 多 Worker 支持
- Redis Stream 原生支持多个生产者
- 无需额外配置
### 多 Server 消费者
```go
// 启动多个消费者(负载均衡)
for i := 0; i < 3; i++ {
go consumer.Start(ctx, scanID)
}
```
Redis Stream 的消费者组会自动分配消息,实现负载均衡。
## 监控和运维
### 监控指标
```go
// 获取队列长度
func (c *EndpointConsumer) GetQueueLength(ctx context.Context, scanID int) (int64, error) {
streamName := fmt.Sprintf("endpoints:%d", scanID)
return c.client.XLen(ctx, streamName).Result()
}
// 获取消费者组信息
func (c *EndpointConsumer) GetConsumerGroupInfo(ctx context.Context, scanID int) ([]redis.XInfoGroup, error) {
streamName := fmt.Sprintf("endpoints:%d", scanID)
return c.client.XInfoGroups(ctx, streamName).Result()
}
```
### 清理策略
```go
// 扫描完成后清理 Stream
func (c *EndpointConsumer) CleanupStream(ctx context.Context, scanID int) error {
streamName := fmt.Sprintf("endpoints:%d", scanID)
return c.client.Del(ctx, streamName).Err()
}
```
## 配置建议
### Redis 配置
```conf
# redis.conf
# 持久化
appendonly yes
appendfsync everysec
# 内存
maxmemory 2gb
maxmemory-policy allkeys-lru
# 性能
tcp-backlog 511
timeout 0
tcp-keepalive 300
```
### 环境变量
```bash
# Worker 端
REDIS_URL=redis://localhost:6379/0
# Server 端
REDIS_URL=redis://localhost:6379/0
```
## 迁移步骤
### 阶段 1准备1 天)
1. 启用 Redis AOF 持久化
2. 实现 Worker 端 Redis 发布者
3. 实现 Server 端 Redis 消费者
### 阶段 2测试2 天)
1. 单元测试
2. 集成测试
3. 性能测试(模拟 50 万数据)
### 阶段 3灰度发布3 天)
1. 10% 流量使用 Redis Stream
2. 50% 流量使用 Redis Stream
3. 100% 流量使用 Redis Stream
### 阶段 4清理1 天)
1. 移除旧的 HTTP 批量写入代码
2. 更新文档
## 风险和缓解
### 风险 1Redis 内存溢出
**缓解**
- 设置 `maxmemory` 限制
- 使用 `MaxLen` 限制 Stream 长度
- 监控 Redis 内存使用
### 风险 2消息积压
**缓解**
- 增加 Server 消费者数量
- 优化数据库写入性能
- 监控队列长度
### 风险 3数据丢失
**缓解**
- 启用 AOF 持久化
- 使用消息确认机制
- 定期备份 Redis
## 总结
### 优势
- ✅ 性能提升 10 倍
- ✅ Worker 永不阻塞
- ✅ 数据不丢失AOF 持久化)
- ✅ 无需部署新组件(利用现有 Redis
- ✅ 架构简单,易于维护
### 适用场景
- 数据量 > 10 万
- 已有 Redis
- 需要高性能写入
- 不需要复杂的消息路由
### 不适用场景
- 数据量 < 10 万(当前方案足够)
- 需要复杂的消息路由(考虑 RabbitMQ
- 数据量 > 1000 万(考虑 Kafka
## 参考资料
- [Redis Stream 官方文档](https://redis.io/docs/data-types/streams/)
- [Redis 持久化](https://redis.io/docs/management/persistence/)
- [go-redis 文档](https://redis.uptrace.dev/)