极简版抖音项目(2) | 青训营笔记

146 阅读5分钟

这是我参与「第三届青训营 -后端场」笔记创作活动的第5篇笔记。本系列主要介绍本次青训营后端专场的项目大作业——极简版抖音,旨在通过切实的实践过程来熟悉 Go 开发中常用的知识点,如前后端交互、数据库、缓存等。承接上文,本文将主要介绍各个模块的设计以及背后思考。

项目地址:github.com/goldenBill/…

1. 模型介绍

1.1 users 模型

持久化层 MySQL 中,包含 user_id, name, password, created_at, ext_info,其中 user_id 作为主键。

缓存层 Redis 中,使用哈希结构 hset 记录用户的相关信息,(key: "user:"+user_id,field 包括 user_id, name, password, follow_count, follower_count, total_favorited, favorite_count, created_at)。

1.2 videos 模型

持久化层 MySQL 中,包含 video_id, title, play_name, cover_name, author_id, created_at, ext_ifo,其中 video_id 作为主键。同时建立非聚簇索引 author_id 和 created_at。

缓存层 Redis 中,

  • 使用有序集合 zset 记录所有发布过的视频,(key: “feed”,score: 视频创建的时间戳,value: video_id)。
  • 使用哈希结构 hset 记录视频的相关信息,(key: "Video:"+video_id,field 包括 author_id, title, play_name, cover_name, favorite_count, comment_count, created_at)。
  • 使用有序集合 zset 记录用户发布过的所有视频,(key: "Publish:"+user_id,score: 视频创建的时间戳,value: video_id)。

1.3 favorites 模型

持久化层 MySQL 中,包含 favorite_id, video_id, user_id, is_favorite, created_at, updated_at,其中 favorite_id 作为主键。同时建立非聚簇索引 (user_id, video_id) 和 video_id。

缓存层 Redis 中,有序集合 zset 记录用户历史点赞的所有视频(key: "favorite:"+user_id,score: 0 or 1,value: video_id),其中 score = 0 代表持久化层中已记录且取消点赞,score = 1 代表持久化层中已记录且点赞。

1.4 comments 模型

持久化层 MySQL 中,包含 comment_id, video_id, user_id, content, created_at, deleted_at,其中 comment_id 作为主键。同时建立非聚簇索引 (video_id, user_id)。

缓存层 Redis 中

  • 使用有序集合 zset 来记录视频发布过的所有评论,(key: "CommentsOfVideo:"+video_id,score: 评论创建的时间戳,value: comment_id)。
  • 使用哈希结构 hset 记录评论的相关信息,(key: "Comment:"+comment_id, field 包括 user_id, video_id, content, created_at)。

1.5 follows 模型

持久化层 MySQL 中,包含 follow_id, celebrity_id, follower_id, is_follow, created_at, updated_at,其中 follow_id 作为主键。同时建立非聚簇索引 (follower_id, celebrity_id) 和 celebrity_id。

缓存层 Redis 中

  • 使用有序集合 zset 来记录历史关注关系,其中粉丝集合(key: "celebrity:"+user_id,score: 0 or 1,value: user_id),关注集合(key: "follower:"+user_id,score: 0 or 1,value: user_id)。
  • 其中 score = 0 代表持久化层中已记录且取消关注,score = 1 代表持久化层中已记录且关注。

2. 技术亮点

我主要负责 users、favorites 和 follows 三个模型的设计和落地工作,本节也将主要介绍这三个模块的一些设计亮点。

Count 数据

在本项目中,count 计数数据作为最热点的数据,理应得到合理的设计以加快系统的处理响应速度。为此本项目,基于 count 计数数据的 ”每次修改仅加减一“ 的特性,采用 “先修改数据库再更新缓存” 的策略。

其中,基于响应结构体的特征,我们将 count 计数数据直接绑定到 video 和 user 的 field 中。在缓存更新时,我们只对已经存在于缓存的 video 和 user 结构体进行更新。因此在缓存更新前我们需要对结构体是否存在于缓存进行条件判断,这里我们使用Lua脚本的方式来保证 Redis 操作的原子性。

//定义 key
userRedis := fmt.Sprintf(UserPattern, userID)
lua := redis.NewScript(`
			if redis.call("Exists", KEYS[1]) > 0 then
				redis.call("HIncrBy", KEYS[1], "favorite_count", 1)
				redis.call("Expire", KEYS[1], ARGV[1])
				return true
			end
			return false
			`)
keys := []string{userRedis}
values := []interface{}{global.USER_INFO_EXPIRE.Seconds()}
_, err := lua.Run(global.CONTEXT, global.REDIS, keys, values).Bool()

favorite 和 follow 查询为空优化

当数据库查询为 favorite 和 follow 状态为空时(这往往是经常发生的),如果我们在缓存中不记录这种 ”数据库没有该信息“ 的状态,就会频繁地出现缓存穿透现象。为此,我们提出在 favorite 和 follow 在的缓存集合中引入占位符来记录这种状态。

//定义 key
//开启pipeline
_, err := global.REDIS.TxPipelined(global.CONTEXT, func(pipe redis.Pipeliner) error {
    //初始化
    pipe.ZAdd(global.CONTEXT, userFavoriteRedis, &redis.Z{Score: 2, Member: Header})
    //增加点赞关系
    //设置过期时间
    return nil
})

引入批处理操作

在数据查询操作中,需求往往是成批次的形式进行查询。如果不对这种情况进行优化,而是简单地逐一建立数据库连接后查询,这会严重影响到系统的处理性能。因此,我们对数据库所需查找的信息进行整合,来提高每次数据库连接所查询到的有效数据量。这里我们将缓存未命中的 video_id 打包为切片 notInCache,进而执行数据库的批量查询。

//缓存没有找到,数据库查询
var uniqueVideoList []VideoFavoriteCountAPI
result := global.DB.Model(&model.Favorite{}).Select("video_id", "COUNT(video_id) as favorite_count").Where("video_id in ? and is_favorite = ?", notInCache, true).Group("video_id").Find(&uniqueVideoList)

因为数据库查询所返回数据与输入的查询顺序无法保证一一对应,所以需要增加额外处理,将查询到的结果映射到预期返回结果中。

// 针对查询结果建立映射关系
mapVideoIDToFavoriteCount := make(map[uint64]int64, len(uniqueVideoList))
for _, each := range uniqueVideoList {
    mapVideoIDToFavoriteCount[each.VideoID] = each.FavoriteCount
}
scanner := 0
for idx, each := range favoriteCountList {
    if each == -1 {
        favoriteCountList[idx] = mapVideoIDToFavoriteCount[notInCache[scanner]]
        scanner++
    }
}
return favoriteCountList, nil

Sonyflake 算法

数据库的主键策略的选择往往有很多考量,我们这里采用改进的雪花算法——snoyflake 算法来生成主键。

我们首先考虑常见的两种方案的缺陷

  • 使用 自增 ID
    • 容易被第三方通过自增 ID 爬取到业务的增长信息,对数据库隐私造成影响
    • Auto_increment 锁机制会造成自增锁的抢夺,有一定的性能损耗
  • 使用 uuid
    • uuid 作为乱序序列,会严重影响到 innodb 新行的写入性能。由于写入是乱序的,innodb 不得不频繁的做页分裂操作,以便为新行分配空间,导致移动大量的数据。

snoyflake 算法可以有效地规避这两种方案所带来的缺陷。