Loading

分布式推荐系统架构:Gorse 的水平扩展实践

当用户量突破百万、千万,单机推荐系统如何扛住压力?本文深入 Gorse 的分布式架构,解析水平扩展的工程实践。

目录


为什么需要分布式?

单机的极限

场景:电商推荐系统

用户数:1000万
商品数:100万
每日活跃:100万
推荐请求:10000 QPS

单机性能:
- CPU:计算推荐 100 QPS
- 内存:存储模型 32GB
- 磁盘:存储数据 1TB

问题:
1. QPS 不够(100 vs 10000)
2. 内存不够(32GB vs 需要 100GB)
3. 单点故障(挂了全挂)

分布式的价值

水平扩展(Scale Out):
┌─────────┐      ┌─────────┐      ┌─────────┐
│ Server1 │  +   │ Server2 │  +   │ Server3 │
│ 100 QPS │      │ 100 QPS │      │ 100 QPS │
└─────────┘      └─────────┘      └─────────┘
                 ↓
            300 QPS(3倍)

垂直扩展(Scale Up):
┌─────────┐      ┌───────────┐
│  32GB   │  →   │   128GB   │
│  8核    │      │   32核    │
└─────────┘      └───────────┘
              ↑
         4倍成本!

为什么选择水平扩展?

维度 水平扩展 垂直扩展
成本 ✅ 线性增长 ❌ 指数增长
上限 ✅ 无限制 ❌ 有上限
容错 ✅ 高可用 ❌ 单点故障
灵活性 ✅ 按需扩展 ❌ 停机升级

Gorse 的分布式架构

整体架构图

                    ┌─────────────┐
                    │   用户请求   │
                    └──────┬──────┘
                           │
              ┌────────────┴────────────┐
              │     Load Balancer       │  ← Nginx/HAProxy
              └────────────┬────────────┘
                           │
         ┌─────────────────┼─────────────────┐
         │                 │                 │
    ┌────▼────┐       ┌────▼────┐      ┌────▼────┐
    │ Server1 │       │ Server2 │      │ Server3 │  ← 提供 API
    │ :8087   │       │ :8087   │      │ :8087   │
    └────┬────┘       └────┬────┘      └────┬────┘
         │                 │                 │
         └─────────────────┼─────────────────┘
                           │ gRPC
                     ┌─────▼─────┐
                     │   Master  │  ← 训练模型、调度任务
                     │   :8086   │
                     └─────┬─────┘
                           │ gRPC
         ┌─────────────────┼─────────────────┐
         │                 │                 │
    ┌────▼────┐       ┌────▼────┐      ┌────▼────┐
    │ Worker1 │       │ Worker2 │      │ Worker3 │  ← 计算推荐
    │ :8089   │       │ :8090   │      │ :8091   │
    └────┬────┘       └────┬────┘      └────┬────┘
         │                 │                 │
         └─────────────────┼─────────────────┘
                           │
                  ┌────────┴────────┐
                  │                 │
            ┌─────▼─────┐     ┌─────▼─────┐
            │   MySQL   │     │   Redis   │  ← 存储
            │  :3306    │     │  :6379    │
            └───────────┘     └───────────┘

节点分工

Master(1个)

职责:
1. 大脑中枢
   - 模型训练(单机即可)
   - 任务调度
   - 配置管理

2. 不处理用户请求
   - 专注于离线任务

3. 单点但可接受
   - Master 挂了不影响推荐服务
   - 只是暂时无法训练新模型

Worker(N个)

职责:
1. 并行计算
   - 每个 Worker 处理部分用户
   - 使用一致性哈希分配

2. 无状态
   - 可以随时增减
   - 自动负载均衡

3. 水平扩展
   - 用户量大 → 加 Worker
   - 线性提升性能

Server(N个)

职责:
1. API 网关
   - 处理 HTTP 请求
   - 返回推荐结果

2. 完全无状态
   - 从 Redis 读缓存
   - 可以无限扩展

3. 高可用
   - 任何一台挂了不影响服务
   - 负载均衡自动切换

一致性哈希:优雅的负载均衡

传统哈希的问题

场景:3个 Worker 处理 100万用户

// 简单哈希
func getWorker(userId string) int {
    hash := crc32.ChecksumIEEE([]byte(userId))
    return int(hash) % numWorkers
}

// 分配结果
alice   → hash % 3 = 0 → Worker0
bob     → hash % 3 = 1 → Worker1
charlie → hash % 3 = 2 → Worker2

问题:增加一个 Worker

// 现在 4 个 Worker
numWorkers = 4

alice   → hash % 4 = 2 → Worker2(原来是0)❌ 变了!
bob     → hash % 4 = 1 → Worker1 ✅ 没变
charlie → hash % 4 = 2 → Worker2(原来是2)✅ 没变

// 75% 的用户需要重新计算推荐!

一致性哈希的原理

核心思想:哈希环

      0 ──────────────────── 2^32-1
      │                        │
      └────────────────────────┘
           ↑ 首尾相连形成环

在环上放置节点:
    Worker0 = hash("worker0")
    Worker1 = hash("worker1")
    Worker2 = hash("worker2")

用户映射:
    alice 的 hash 值在环上
    ↓
    顺时针找到第一个 Worker

可视化

          0
          │
    W0 ──┐│
         ││
    alice││     ← alice 的 hash 落在这里
         ││       顺时针找到 W0
         ││
         │└── W1
    bob ──┘
         │
    W2 ──┘
         │
       2^32

Gorse 的实现

// 源码:worker/worker.go
import "github.com/lafikl/consistent"

type Worker struct {
    workerName string
    peers      []string
    consistent *consistent.Consistent
}

// 初始化一致性哈希
func (w *Worker) initConsistentHash() {
    w.consistent = consistent.New()
    w.consistent.NumberOfReplicas = 100  // 虚拟节点数
    
    // 添加所有 Worker
    for _, peer := range w.peers {
        w.consistent.Add(peer)
    }
}

// 判断用户是否由我处理
func (w *Worker) isMyUser(userId string) bool {
    worker, _ := w.consistent.Get(userId)
    return worker == w.workerName
}

// 过滤出我负责的用户
func (w *Worker) getMyUsers(allUsers []User) []User {
    myUsers := make([]User, 0)
    for _, user := range allUsers {
        if w.isMyUser(user.UserId) {
            myUsers = append(myUsers, user)
        }
    }
    return myUsers
}

虚拟节点的作用

没有虚拟节点

只有 3 个真实节点:
    W0 的负载:40%  ← 不均衡
    W1 的负载:35%
    W2 的负载:25%

有虚拟节点(每个节点 100 个)

总共 300 个虚拟节点:
    W0 的负载:33.2%  ← 接近理想值
    W1 的负载:33.5%
    W2 的负载:33.3%

实现

// 为每个 Worker 创建 100 个虚拟节点
for i := 0; i < 100; i++ {
    virtualNode := fmt.Sprintf("%s#%d", worker, i)
    hash := crc32.ChecksumIEEE([]byte(virtualNode))
    ring[hash] = worker  // 虚拟节点映射到真实节点
}

增加节点的影响

3 个 Worker → 4 个 Worker

传统哈希:
- 75% 的数据需要迁移

一致性哈希:
- 只有 25% 的数据需要迁移(3→4)
- 迁移量 = 1 / (N+1)

可视化

添加 Worker3 前:
    alice → W0
    bob   → W1
    charlie → W2

添加 Worker3 后:
    alice → W0   ✅ 不变
    bob   → W1   ✅ 不变
    charlie → W3 ❌ 从 W2 迁移到 W3
    david → W3

只有部分用户受影响!

Worker 协同机制

Worker 启动流程

// 源码:worker/worker.go
func (w *Worker) Serve() {
    // 1. 连接 Master
    w.conn = grpc.Dial(w.masterAddress)
    w.masterClient = protocol.NewMasterClient(w.conn)
    
    // 2. 注册自己
    w.masterClient.GetMeta(&protocol.NodeInfo{
        NodeType: protocol.NodeType_Worker,
        Uuid:     w.workerName,
        Hostname: os.Hostname(),
    })
    
    // 3. 同步配置
    go w.Sync()  // 后台持续同步
    
    // 4. 拉取模型
    go w.PullModel()
    
    // 5. 开始工作
    go w.Recommend()
}

任务分配

Master 发现新用户

// Master 定期扫描数据库
newUsers := db.GetUsers()

// 通知所有 Worker
for _, worker := range workers {
    worker.NotifyNewUsers(newUsers)
}

Worker 处理

func (w *Worker) OnNewUsers(users []User) {
    // 只处理属于我的用户
    myUsers := w.filterMyUsers(users)
    
    // 并行计算推荐
    parallel.Parallel(len(myUsers), w.jobs, func(i int) {
        user := myUsers[i]
        recommendations := w.generateRecommendations(user)
        w.cacheClient.SetRecommendations(user.Id, recommendations)
    })
}

故障处理

Worker 挂了怎么办?

// Master 定期心跳检测
func (m *Master) healthCheck() {
    for _, worker := range m.workers {
        if !worker.Ping() {
            // Worker 挂了
            m.removeWorker(worker)
            
            // 一致性哈希自动重新分配
            // 该 Worker 的用户会分配给其他 Worker
        }
    }
}

示例

3 个 Worker:W0, W1, W2
用户分配:
    alice   → W0
    bob     → W1
    charlie → W2

W1 挂了:
    alice   → W0 ✅ 不变
    bob     → W0 或 W2(一致性哈希重新分配)
    charlie → W2 ✅ 不变

影响:
- 只有 bob 需要重新计算
- 系统继续运行

Server 的水平扩展

无状态设计

type Server struct {
    cacheClient cache.Database  // 连接 Redis
    masterClient protocol.MasterClient  // 连接 Master
}

// 处理推荐请求
func (s *Server) GetRecommendations(userId string, n int) []string {
    // 1. 从缓存读取(无状态)
    recommendations := s.cacheClient.GetRecommendations(userId, n)
    
    if len(recommendations) >= n {
        return recommendations[:n]
    }
    
    // 2. 缓存未命中,实时计算
    return s.computeRecommendations(userId, n)
}

为什么无状态很重要?

有状态:
┌────────────┐
│  Server1   │  ← 用户 alice 的 Session
│  [alice]   │
└────────────┘

问题:
- alice 的请求必须路由到 Server1
- Server1 挂了,alice 的 Session 丢失
- 无法随意增减 Server

无状态:
┌────────────┐  ┌────────────┐
│  Server1   │  │  Server2   │
│  (无状态)   │  │  (无状态)   │
└────────────┘  └────────────┘
        ↓              ↓
    ┌──────────────────────┐
    │       Redis          │  ← 共享存储
    └──────────────────────┘

好处:
- 任何请求可以发给任何 Server
- 增减 Server 不影响服务
- 高可用

负载均衡

Nginx 配置

upstream gorse_servers {
    # 轮询
    server 192.168.1.10:8087 weight=1;
    server 192.168.1.11:8087 weight=1;
    server 192.168.1.12:8087 weight=1;
    
    # 健康检查
    check interval=3000 rise=2 fall=3 timeout=1000;
}

server {
    listen 80;
    server_name api.recommendation.com;
    
    location /api/ {
        proxy_pass http://gorse_servers;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

负载均衡策略

策略 描述 适用场景
轮询 依次分配 Server 性能一致
权重 按权重分配 Server 性能不同
最少连接 分配给连接数最少的 长连接场景
IP Hash 同一 IP 分配到同一 Server 需要 Session 粘性

数据一致性保证

最终一致性

场景:用户点击了物品

时间线:
T0: 用户点击 → Server 写入 MySQL
T1: Worker 从 MySQL 加载数据(可能还没加载到新数据)
T2: Worker 计算推荐(基于旧数据)
T3: Worker 写入 Redis
T4: 用户再次请求 → 看到推荐(基于旧数据)❌

T5: Worker 再次加载数据(加载到新数据)
T6: Worker 重新计算推荐
T7: Worker 更新 Redis
T8: 用户请求 → 看到新推荐 ✅

解决方案:快速路径 + 慢路径

// Server 端
func (s *Server) InsertFeedback(feedback Feedback) {
    // 1. 写入 MySQL(慢路径)
    s.dataClient.InsertFeedback(feedback)
    
    // 2. 快速更新:从推荐中移除已交互的
    s.cacheClient.RemoveFromRecommendation(
        feedback.UserId,
        feedback.ItemId,
    )
    
    // 3. 触发增量更新(可选)
    s.masterClient.TriggerIncrementalUpdate(feedback.UserId)
}

缓存穿透保护

// 问题:恶意请求大量不存在的用户
func (s *Server) GetRecommendations(userId string) []string {
    // 缓存未命中
    recs := s.cache.Get(userId)
    if recs == nil {
        // ❌ 每次都计算,压垮系统
        recs = s.compute(userId)
    }
    return recs
}

// 解决:布隆过滤器
type Server struct {
    bloomFilter *BloomFilter
}

func (s *Server) GetRecommendations(userId string) []string {
    // 1. 快速检查用户是否存在
    if !s.bloomFilter.Contains(userId) {
        return []string{}  // 用户不存在,直接返回
    }
    
    // 2. 查询缓存
    recs := s.cache.Get(userId)
    if recs == nil {
        recs = s.compute(userId)
    }
    return recs
}

缓存雪崩保护

// 问题:大量缓存同时过期
func (s *Server) SetRecommendations(userId string, recs []string) {
    // ❌ 所有缓存都是 1 小时过期
    s.cache.Set(userId, recs, 1*time.Hour)
}

// 解决:随机过期时间
func (s *Server) SetRecommendations(userId string, recs []string) {
    // 1小时 ± 5分钟
    ttl := time.Hour + time.Duration(rand.Intn(600))*time.Second
    s.cache.Set(userId, recs, ttl)
}

容错与高可用

服务降级

type Server struct {
    circuitBreaker *CircuitBreaker
}

func (s *Server) GetRecommendations(userId string) []string {
    // 1. 尝试从缓存获取
    recs, err := s.cache.Get(userId)
    if err == nil {
        return recs
    }
    
    // 2. 缓存失败,尝试实时计算
    if s.circuitBreaker.Allow() {
        recs, err = s.computeRealtime(userId)
        if err == nil {
            return recs
        }
        s.circuitBreaker.RecordFailure()
    }
    
    // 3. 实时计算失败,降级到热门推荐
    return s.getFallbackRecommendations()
}

// 降级策略
func (s *Server) getFallbackRecommendations() []string {
    // 返回热门物品(预先缓存)
    return s.cache.Get("popular_items")
}

熔断器

type CircuitBreaker struct {
    state          State  // Open/Closed/HalfOpen
    failureCount   int
    successCount   int
    failureThreshold int
    timeout        time.Duration
    lastFailTime   time.Time
}

func (cb *CircuitBreaker) Allow() bool {
    switch cb.state {
    case StateClosed:
        return true  // 正常状态,允许请求
        
    case StateOpen:
        // 熔断状态,检查是否到恢复时间
        if time.Since(cb.lastFailTime) > cb.timeout {
            cb.state = StateHalfOpen
            return true  // 尝试恢复
        }
        return false  // 拒绝请求
        
    case StateHalfOpen:
        return true  // 半开状态,允许部分请求
    }
}

func (cb *CircuitBreaker) RecordFailure() {
    cb.failureCount++
    cb.lastFailTime = time.Now()
    
    if cb.failureCount >= cb.failureThreshold {
        cb.state = StateOpen  // 打开熔断器
    }
}

func (cb *CircuitBreaker) RecordSuccess() {
    if cb.state == StateHalfOpen {
        cb.successCount++
        if cb.successCount >= 3 {
            cb.state = StateClosed  // 关闭熔断器,恢复正常
            cb.failureCount = 0
        }
    }
}

实战:搭建分布式集群

Docker Compose 配置

version: '3'

services:
  mysql:
    image: mysql:8
    environment:
      MYSQL_ROOT_PASSWORD: root_pass
      MYSQL_DATABASE: gorse
    ports:
      - "3306:3306"
    volumes:
      - mysql_data:/var/lib/mysql

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
    volumes:
      - redis_data:/data

  master:
    image: zhenghaoz/gorse-master
    ports:
      - "8086:8086"  # gRPC
      - "8088:8088"  # HTTP
    environment:
      GORSE_CACHE_STORE: redis://redis:6379
      GORSE_DATA_STORE: mysql://root:root_pass@tcp(mysql:3306)/gorse
    depends_on:
      - mysql
      - redis

  worker1:
    image: zhenghaoz/gorse-worker
    ports:
      - "8089:8089"
    command: --master-host master --master-port 8086
    depends_on:
      - master

  worker2:
    image: zhenghaoz/gorse-worker
    ports:
      - "8090:8090"
    command: --master-host master --master-port 8086 --http-port 8090
    depends_on:
      - master

  worker3:
    image: zhenghaoz/gorse-worker
    ports:
      - "8091:8091"
    command: --master-host master --master-port 8086 --http-port 8091
    depends_on:
      - master

  server1:
    image: zhenghaoz/gorse-server
    ports:
      - "8087:8087"
    command: --master-host master --master-port 8086
    depends_on:
      - master

  server2:
    image: zhenghaoz/gorse-server
    ports:
      - "8097:8097"
    command: --master-host master --master-port 8086 --http-port 8097
    depends_on:
      - master

  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf
    depends_on:
      - server1
      - server2

volumes:
  mysql_data:
  redis_data:

Nginx 配置

# nginx.conf
http {
    upstream gorse_api {
        server server1:8087;
        server server2:8097;
    }
    
    server {
        listen 80;
        
        location /api/ {
            proxy_pass http://gorse_api;
        }
    }
}

启动集群

# 启动所有服务
docker-compose up -d

# 查看服务状态
docker-compose ps

# 查看 Worker 日志
docker-compose logs -f worker1

# 扩展 Worker(增加到 5 个)
docker-compose up -d --scale worker=5

# 扩展 Server(增加到 3 个)
docker-compose up -d --scale server=3

验证

# 检查集群状态
curl http://localhost:8088/api/dashboard/cluster

# 返回:
{
  "master": "running",
  "workers": ["worker1", "worker2", "worker3"],
  "servers": ["server1", "server2"]
}

# 压力测试
ab -n 10000 -c 100 http://localhost/api/recommend/alice?n=10

# 结果:
# Requests per second: 5000 [#/sec]
# 单 Server: 500 QPS
# 2 Server: 1000 QPS
# 10 Server: 5000 QPS

总结

核心要点

分布式设计

  • Master/Worker/Server 三层架构
  • 各层独立扩展
  • 无状态设计

负载均衡

  • 一致性哈希
  • 虚拟节点
  • 最小化数据迁移

高可用

  • 服务降级
  • 熔断器
  • 故障自动恢复

数据一致性

  • 最终一致性
  • 快速路径 + 慢路径
  • 缓存保护

性能对比

配置 QPS 用户数 成本
单机 100 10万 ¥500/月
3节点 300 100万 ¥1500/月
10节点 1000 1000万 ¥5000/月
posted @ 2026-01-07 14:10  技术漫游  阅读(10)  评论(0)    收藏  举报