Loading

深入 Gorse 推荐系统:数据结构与存储层设计剖析

作为后端工程师,我在学习 Gorse 推荐系统源码时,发现了很多精妙的设计。本文将从工程角度解析 Gorse 的核心数据结构和存储层,分享我的学习心得。

目录


为什么研究 Gorse?

在接触推荐系统之前,我一直好奇:电商、短视频、新闻 APP 是如何做到"千人千面"的推荐?

Gorse 是一个用 Go 语言编写的开源推荐系统引擎,它的代码简洁清晰,非常适合学习。更重要的是,它展示了如何用工程的方式解决推荐系统的性能挑战。

本文面向

  • 🎯 后端工程师想了解推荐系统
  • 📚 算法小白想从工程角度理解推荐
  • 💻 想学习 Go 语言高性能编程的开发者

核心挑战:从字符串到索引的映射

问题引入

假设你要做一个简单的推荐系统:

// 用户-物品交互数据
userItems := map[string][]string{
    "alice": {"movie1", "movie2", "movie3"},
    "bob":   {"movie2", "movie4"},
    // ... 100万用户
}

这样存储有什么问题?

性能瓶颈

场景:计算推荐需要遍历 100万用户 × 100万物品

用字符串作为 key:
- 哈希计算:O(len(string))
- 字符串比较:O(len(string))
- 内存占用:每个字符串 ~20 字节

用整数索引:
- 数组访问:O(1)
- 整数比较:O(1)
- 内存占用:int32 = 4 字节

性能差异

字符串查找:100 万次 × 50 ns = 50 ms
整数访问:  100 万次 × 5 ns  = 5 ms

快了 10 倍!

FreqDict:高效的双向映射

设计思路

Gorse 使用 FreqDict 实现 ID ↔ 索引 的双向映射:

type FreqDict struct {
    idToIndex   map[string]int32   // ID → 索引
    indexToId   []string            // 索引 → ID
    frequencies []int               // 频率统计
}

为什么需要双向映射?

场景1:给定用户ID,找到他的数据
"alice" → idToIndex["alice"] = 0 → 访问 userFeedback[0]

场景2:算法计算出索引0,需要知道对应的用户
索引 0 → indexToId[0] = "alice"

场景3:统计最活跃的用户
frequencies[0] = 100, frequencies[1] = 50
→ 用户0(alice)最活跃

源码解析

添加新 ID

func (dict *FreqDict) Add(id string) int32 {
    // 检查是否已存在
    if index, exist := dict.idToIndex[id]; exist {
        dict.frequencies[index]++  // 增加频率
        return index
    }
    
    // 分配新索引
    index := int32(len(dict.indexToId))
    dict.idToIndex[id] = index
    dict.indexToId = append(dict.indexToId, id)
    dict.frequencies = append(dict.frequencies, 1)
    
    return index
}

查询操作

// ID → 索引:O(1) 哈希查找
func (dict *FreqDict) Id(id string) int32 {
    if index, exist := dict.idToIndex[id]; exist {
        return index
    }
    return -1
}

// 索引 → ID:O(1) 数组访问
func (dict *FreqDict) Key(index int32) string {
    if index >= 0 && index < int32(len(dict.indexToId)) {
        return dict.indexToId[index]
    }
    return ""
}

为什么用 int32 而不是 int?

// int32 vs int64 内存对比
100万用户 × int32(4字节) = 4MB
100万用户 × int64(8字节) = 8MB

节省 50% 内存!

// int32 的限制
最大值:2^31 - 1 = 2,147,483,647
足够表示 21 亿用户/物品

工程权衡

  • ✅ 节省内存(推荐系统需要大量索引数组)
  • ✅ CPU 缓存友好(更小的数据结构)
  • ⚠️ 限制:不能超过 21 亿用户(但实际够用)

Dataset:稀疏矩阵的智慧

传统方式的问题

用二维数组表示用户-物品交互:

// 密集矩阵
matrix := make([][]bool, 1000000)  // 100万用户
for i := range matrix {
    matrix[i] = make([]bool, 1000000)  // 100万物品
}

// 内存占用
1M × 1M × 1字节 = 1TB 内存!

但实际情况

推荐系统的稀疏性:
- 用户通常只交互过 < 1% 的物品
- 99% 的矩阵是空的

1TB × 1% = 10GB(实际有数据)
1TB × 99% = 990GB(浪费!)

Gorse 的稀疏存储

type Dataset struct {
    UserIndex    *FreqDict      // 用户字典
    ItemIndex    *FreqDict      // 物品字典
    UserFeedback [][]int32      // 用户 → 物品列表
    ItemFeedback [][]int32      // 物品 → 用户列表
}

数据结构可视化

用户维度:
UserIndex:    alice→0,  bob→1,  charlie→2
UserFeedback: [
    [1, 3],    // alice 喜欢 item1, item3
    [2, 4],    // bob 喜欢 item2, item4
    [1, 5]     // charlie 喜欢 item1, item5
]

物品维度:
ItemIndex:    item1→0,  item2→1, ...
ItemFeedback: [
    [0, 2],    // item1 被 user0, user2 喜欢
    [1],       // item2 被 user1 喜欢
    ...
]

为什么存储两份?

这是经典的空间换时间

// 场景1:给用户推荐(需要用户历史)
func Recommend(userId string) []string {
    userIdx := dataset.UserIndex.Id(userId)
    userItems := dataset.UserFeedback[userIdx]  // O(1) 访问
    // ... 基于历史推荐
}

// 场景2:计算物品相似度(需要物品的用户列表)
func ItemSimilarity(item1, item2 string) float64 {
    idx1 := dataset.ItemIndex.Id(item1)
    idx2 := dataset.ItemIndex.Id(item2)
    users1 := dataset.ItemFeedback[idx1]  // O(1) 访问
    users2 := dataset.ItemFeedback[idx2]  // O(1) 访问
    return jaccardSimilarity(users1, users2)
}

性能对比

只存储 UserFeedback 的情况:
- 给用户推荐:O(1) ✅
- 计算物品相似度:需要遍历所有用户找到交互过的 O(n) ❌

同时存储两份:
- 给用户推荐:O(1) ✅
- 计算物品相似度:O(1) ✅
- 代价:多占用一份内存(但稀疏存储占用很小)

内存占用实测

场景:100万用户,100万物品,1亿条交互

密集存储:1M × 1M = 1TB

稀疏存储:
- UserFeedback: 100万 × 100条 × 4字节 = 400MB
- ItemFeedback: 100万 × 100条 × 4字节 = 400MB
- 总计:800MB

节省:1TB / 800MB = 1250 倍!

存储层:优雅的接口抽象

设计模式:依赖倒置

Gorse 支持多种数据库(MySQL, PostgreSQL, MongoDB, Redis),如何实现?

❌ 传统方式(紧耦合)

func GetUser(userId string) User {
    if dbType == "mysql" {
        // MySQL 代码
    } else if dbType == "postgres" {
        // PostgreSQL 代码
    } else if dbType == "mongo" {
        // MongoDB 代码
    }
}

// 问题:
// 1. 每次支持新数据库都要改代码
// 2. 代码难以测试(依赖真实数据库)
// 3. 违反开闭原则

✅ Gorse 的方式(接口抽象)

// 定义接口
type Database interface {
    GetUser(userId string) (User, error)
    GetUsers(cursor string, n int) ([]User, string, error)
    InsertUser(user User) error
    DeleteUser(userId string) error
    // ... 其他方法
}

// MySQL 实现
type MySQLDatabase struct {
    db *sql.DB
}

func (m *MySQLDatabase) GetUser(userId string) (User, error) {
    // MySQL 具体实现
}

// Redis 实现
type RedisDatabase struct {
    client *redis.Client
}

func (r *RedisDatabase) GetUser(userId string) (User, error) {
    // Redis 具体实现
}

好处

// 高层代码不关心具体数据库
func RecommendService(db Database, userId string) []string {
    user, _ := db.GetUser(userId)  // 接口调用
    // ... 推荐逻辑
}

// 可以随意切换数据库
mysqlDB := NewMySQLDatabase(...)
redisDB := NewRedisDatabase(...)

RecommendService(mysqlDB, "alice")  // 用 MySQL
RecommendService(redisDB, "alice")  // 用 Redis

为什么分 DataStore 和 CacheStore?

Gorse 使用两个存储:

// DataStore:原始数据(MySQL/PostgreSQL)
- 用户信息
- 物品信息
- 反馈记录

// CacheStore:计算结果(Redis)
- 推荐结果
- 相似度矩阵
- 临时数据

设计理由

特性 DataStore CacheStore
数据类型 原始数据 计算结果
读写模式 写多读少 读多写少
一致性要求 强一致性 最终一致性
丢失影响 不可恢复 可重算
推荐数据库 MySQL/PG Redis

实战经验

// 错误做法:把推荐结果存 MySQL
// 问题:
// 1. MySQL 不擅长高并发读(每秒百万次查询)
// 2. 推荐结果会频繁更新(浪费写入性能)
// 3. 推荐结果可以重算(不需要强一致性)

// 正确做法:推荐结果存 Redis
// 优点:
// 1. Redis 擅长读(每秒 10 万+ QPS)
// 2. 数据过期自动清理(TTL)
// 3. 丢失了重算即可(不影响核心数据)

性能优化实战

优化1:批量加载

问题场景

// ❌ 慢方式:逐条查询
for _, userId := range users {
    feedbacks, _ := db.GetUserFeedback(userId)
    // 处理...
}

// 100万用户 × 10ms/次 = 10,000秒 ≈ 3小时

优化方案

// ✅ 快方式:批量查询
allFeedbacks, _ := db.GetFeedbacksBatch(0, 100000)

// 1次查询 1秒 + 内存处理 5秒 = 6秒
// 快了 1666 倍!

源码实现

func (m *Master) loadDataset() (Datasets, error) {
    // 批量加载反馈数据
    cursor := ""
    batchSize := 10000
    
    for {
        feedbacks, nextCursor, err := m.DataClient.GetFeedbacks(
            cursor, batchSize,
        )
        if err != nil {
            return nil, err
        }
        
        // 内存中处理
        for _, feedback := range feedbacks {
            dataset.AddFeedback(feedback)
        }
        
        if nextCursor == "" {
            break  // 没有更多数据
        }
        cursor = nextCursor
    }
    
    return dataset, nil
}

优化2:游标分页

OFFSET 分页的问题

-- 查询第 10000 页
SELECT * FROM feedbacks LIMIT 10000, 100;

-- MySQL 需要:
-- 1. 扫描前 10000 行
-- 2. 丢弃前 10000 行
-- 3. 返回后 100 行

-- 页数越大越慢!

游标分页

-- 第一次查询
SELECT * FROM feedbacks WHERE id > 0 LIMIT 100;
-- 返回 id: 1-100,last_id = 100

-- 第二次查询
SELECT * FROM feedbacks WHERE id > 100 LIMIT 100;
-- 返回 id: 101-200,last_id = 200

-- 每次查询时间一样!

性能对比

场景:1000万条数据,每页 100 条

OFFSET 分页:
- 第1页:  10ms
- 第1000页:100ms
- 第10000页:1000ms

游标分页:
- 任何页:10ms(一样快)

Go 实现

type Database interface {
    GetFeedbacks(cursor string, n int) ([]Feedback, string, error)
    //                ↑ 上次的最后ID  ↑ 下次的起始ID
}

func (db *SQLDatabase) GetFeedbacks(cursor string, n int) ([]Feedback, string, error) {
    query := `
        SELECT * FROM feedbacks 
        WHERE id > ? 
        ORDER BY id 
        LIMIT ?
    `
    
    rows, _ := db.Query(query, cursor, n)
    
    feedbacks := make([]Feedback, 0)
    var lastId string
    
    for rows.Next() {
        var f Feedback
        rows.Scan(&f.Id, &f.UserId, &f.ItemId)
        feedbacks = append(feedbacks, f)
        lastId = f.Id
    }
    
    return feedbacks, lastId, nil
}

优化3:并发控制

读写锁的威力

// ❌ 普通 Mutex
type Store struct {
    mu   sync.Mutex
    data map[string]string
}

func (s *Store) Get(key string) string {
    s.mu.Lock()         // 读也要独占锁
    defer s.mu.Unlock()
    return s.data[key]
}

// 问题:100个并发读请求 → 串行执行 → 慢
// ✅ RWMutex
type Store struct {
    mu   sync.RWMutex
    data map[string]string
}

func (s *Store) Get(key string) string {
    s.mu.RLock()         // 读锁,可并发
    defer s.mu.RUnlock()
    return s.data[key]
}

func (s *Store) Set(key, value string) {
    s.mu.Lock()          // 写锁,独占
    defer s.mu.Unlock()
    s.data[key] = value
}

// 好处:多个读可以同时进行

性能测试

func BenchmarkMutex(b *testing.B) {
    store := &Store{data: make(map[string]string)}
    store.data["key"] = "value"
    
    b.RunParallel(func(pb *testing.PB) {
        for pb.Next() {
            store.Get("key")  // 读操作
        }
    })
}

// 结果:
// Mutex:    100万次/秒
// RWMutex:  1000万次/秒(10倍提升)

总结与思考

核心收获

通过学习 Gorse 的数据结构和存储层,我理解了:

1. 性能优化的本质

  • 用整数代替字符串(内存+速度)
  • 稀疏存储代替密集矩阵(节省 1000 倍内存)
  • 空间换时间(双向映射、批量加载)

2. 工程设计的智慧

  • 接口抽象(依赖倒置原则)
  • 读写分离(DataStore vs CacheStore)
  • 游标分页(比 OFFSET 快 100 倍)

3. Go 语言的优势

  • 简洁的接口设计
  • 强大的并发原语(RWMutex)
  • 高性能的内存管理

适用场景

这些技术不仅适用于推荐系统,还可以应用到:

  • 🔍 搜索引擎:倒排索引、相似度计算
  • 📊 数据分析:大规模数据聚合、TopK 查询
  • 💬 社交网络:关系图谱、好友推荐
  • 🎮 游戏系统:排行榜、匹配系统

参考资料

posted @ 2026-01-07 11:34  技术漫游  阅读(19)  评论(0)    收藏  举报