分布式推荐系统架构: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/月 |

浙公网安备 33010602011771号