Files
VLoop/backend/internal/feed/service.go

546 lines
15 KiB
Go
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.
package feed
import (
"context"
"encoding/json"
rediscache "feedsystem_video_go/internal/middleware/redis"
"feedsystem_video_go/internal/video"
"fmt"
"log"
"strconv"
"sync"
"time"
"github.com/patrickmn/go-cache"
redis "github.com/redis/go-redis/v9"
"golang.org/x/sync/singleflight"
)
type FeedService struct {
repo *FeedRepository
likeRepo *video.LikeRepository
rediscache *rediscache.Client
localcache *cache.Cache
cacheTTL time.Duration
requestGroup singleflight.Group
}
type CachedFeedData struct {
PublicVideos []video.Video `json:"pubilc_videos"`
}
func NewFeedService(repo *FeedRepository, likeRepo *video.LikeRepository, rediscache *rediscache.Client) *FeedService {
return &FeedService{repo: repo, likeRepo: likeRepo, rediscache: rediscache, localcache: cache.New(3*time.Second, 5*time.Second), cacheTTL: 24 * time.Hour}
}
func (f *FeedService) GetVideoByIDs(ctx context.Context, videoIDs []uint) ([]*video.Video, error) {
// GetVideoByIDs 批量获取视频信息
// 采用 L1(本地缓存) -> L2(Redis) -> L3(MySQL) 三级架构
if len(videoIDs) == 0 {
return []*video.Video{}, nil
}
videoMap := make(map[uint]*video.Video)
//L1:本地缓存
var missedL1 []uint
for _, id := range videoIDs {
cacheKey := fmt.Sprintf("video:entity:%d", id)
if f.localcache != nil {
if v, found := f.localcache.Get(cacheKey); found {
if data, ok := v.(video.Video); ok {
videoMap[id] = &data
continue
}
}
}
// 记录未命中的 ID准备进入下一级缓存
missedL1 = append(missedL1, id)
}
if len(missedL1) == 0 {
return buildOrderedResult(videoIDs, videoMap), nil
}
//L2:redis
var missedL2 []uint
if len(missedL1) > 0 {
cacheKeys := make([]string, len(missedL1))
for i, id := range missedL1 {
cacheKeys[i] = fmt.Sprintf("video:entity:%d", id)
}
cacheCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
results, err := f.rediscache.MGet(cacheCtx, cacheKeys...).Result()
cancel()
if err == nil {
for i, res := range results {
id := missedL1[i]
if res != nil {
if str, ok := res.(string); ok {
var v video.Video
if err := json.Unmarshal([]byte(str), &v); err == nil {
videoMap[id] = &v
// 回写更新 L1 本地缓存
if f.localcache != nil {
f.localcache.Set(cacheKeys[i], v, 5*time.Second)
}
continue
}
}
}
missedL2 = append(missedL2, id)
}
} else {
// 如果 Redis 挂了或者超时了,全部降级到 L3
missedL2 = missedL1
log.Printf("L2 Redis MGet 失败,全部降级到 MySQL: %v", err)
}
}
if len(missedL2) == 0 {
return buildOrderedResult(videoIDs, videoMap), nil
}
//L3:MySQL
var wg sync.WaitGroup
var mu sync.Mutex
for _, id := range videoIDs {
wg.Add(1)
go func(videoID uint) {
defer wg.Done()
sfKey := fmt.Sprintf("sf:entity:%d", videoID)
v, err, _ := f.requestGroup.Do(sfKey, func() (interface{}, error) {
videoList, err := f.repo.GetByIDs(ctx, []uint{videoID})
if err != nil {
return nil, err
}
safeCopy := *videoList[0]
cachekey := fmt.Sprintf("video:entity:%d", safeCopy.ID)
if b, err := json.Marshal(safeCopy); err == nil {
//异步回写redis
go func(k string, b []byte) {
setCtx, setCancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer setCancel()
f.rediscache.SetBytes(setCtx, k, b, time.Hour)
}(cachekey, b)
}
return videoList[0], err
})
if err == nil && v != nil {
safeCopy := *(v.(*video.Video))
mu.Lock()
videoMap[id] = &safeCopy
mu.Unlock()
f.localcache.Set(fmt.Sprintf("video:entity:%d", safeCopy.ID), safeCopy, 5*time.Second)
}
}(id)
}
wg.Wait()
return buildOrderedResult(videoIDs, videoMap), nil
}
// 查询最新视频 (冷热分离 + 游标分页)
func (f *FeedService) ListLatest(ctx context.Context, limit int, latestBefore time.Time, viewerAccountID uint) (ListLatestResponse, error) {
// 获取 ZSET 中最老的一条数据
zsetTail, err := f.rediscache.ZRangeWithScores(ctx, "feed:global_timeline", 0, 0)
isZsetEmpty := len(zsetTail) == 0
if isZsetEmpty {
//全局静态锁:无视所有用户的不同时间戳游标
sfKey := "sf:fallback:global_timeline_rebuild"
v, err, _ := f.requestGroup.Do(sfKey, func() (interface{}, error) {
// 无视游标,直接去 MySQL 捞最新的 1000 条
dbVideos, err := f.repo.ListLatest(ctx, 1000, time.Time{})
if err != nil {
return nil, err
}
if len(dbVideos) == 0 {
return "EMPTY_DB", nil // 防无限递归
}
// 重建 ZSET
bgCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
var zElements []redis.Z
for _, vid := range dbVideos {
zElements = append(zElements, redis.Z{
Score: float64(vid.CreateTime.UnixMilli()),
Member: fmt.Sprintf("%d", vid.ID),
})
}
f.rediscache.ZAdd(bgCtx, "feed:global_timeline", zElements...)
return "SUCCESS", nil
})
if err != nil {
return ListLatestResponse{}, err
}
if v == "EMPTY_DB" {
return ListLatestResponse{HasMore: false}, nil
}
// 让所有被阻塞的请求重新查一遍
return f.ListLatest(ctx, limit, latestBefore, viewerAccountID)
}
watermark := int64(zsetTail[0].Score)
reqTime := time.Now().UnixMilli()
if !latestBefore.IsZero() {
reqTime = latestBefore.UnixMilli()
}
var baseVideos []*video.Video
if reqTime <= watermark {
//冷数据降级查库
// 针对个别用户的防并发(此时可以用时间戳做锁,因为冷尾流量极小)
sfKey := fmt.Sprintf("sf:cold:listLatest:%d:%d", limit, reqTime)
v, err, _ := f.requestGroup.Do(sfKey, func() (interface{}, error) {
return f.repo.ListLatest(ctx, limit, latestBefore)
})
if err != nil {
return ListLatestResponse{}, err
}
baseVideos = v.([]*video.Video)
// 不回写 ZSET防止冷数据污染热点时间线
} else {
// 热数据直接查redis
maxScore := "+inf"
if !latestBefore.IsZero() {
maxScore = fmt.Sprintf("%d", reqTime-1) // 防重复
}
videoIDsStr, err := f.rediscache.ZRevRangeByScore(ctx, "feed:global_timeline", maxScore, "-inf", 0, int64(limit))
if err != nil {
return ListLatestResponse{}, err
}
var videoIDs []uint
for _, idStr := range videoIDsStr {
if id, err := strconv.ParseUint(idStr, 10, 64); err == nil {
videoIDs = append(videoIDs, uint(id))
}
}
if len(videoIDs) > 0 {
baseVideos, err = f.GetVideoByIDs(ctx, videoIDs)
if err != nil {
return ListLatestResponse{}, err
}
}
// 刚好击穿了冷热边界
if len(baseVideos) < limit {
remainLimit := limit - len(baseVideos) // 计算还差几个
var coldCursor time.Time
if len(baseVideos) > 0 {
coldCursor = baseVideos[len(baseVideos)-1].CreateTime
} else {
coldCursor = latestBefore
}
sfKey := fmt.Sprintf("sf:stitch:listLatest:%d:%d", remainLimit, coldCursor.UnixMilli())
v, err, _ := f.requestGroup.Do(sfKey, func() (interface{}, error) {
return f.repo.ListLatest(ctx, remainLimit, coldCursor)
})
if err == nil {
coldVideos := v.([]*video.Video)
baseVideos = append(baseVideos, coldVideos...)
}
}
}
var nextTime int64
if len(baseVideos) > 0 {
// 将本页最后一条视频的时间作为下一次请求的游标
nextTime = baseVideos[len(baseVideos)-1].CreateTime.UnixMilli()
}
var hasMore bool
if reqTime <= watermark {
hasMore = len(baseVideos) == limit
} else {
hasMore = true
}
feedVideos, err := f.buildFeedVideos(ctx, baseVideos, viewerAccountID)
if err != nil {
return ListLatestResponse{}, err
}
return ListLatestResponse{
VideoList: feedVideos,
NextTime: nextTime,
HasMore: hasMore,
}, nil
}
// 按照点赞数查询视频
func (f *FeedService) ListLikesCount(ctx context.Context, limit int, cursor *LikesCountCursor, viewerAccountID uint) (ListLikesCountResponse, error) {
videos, err := f.repo.ListLikesCountWithCursor(ctx, limit, cursor)
if err != nil {
return ListLikesCountResponse{}, err
}
hasMore := len(videos) == limit
feedVideos, err := f.buildFeedVideos(ctx, videos, viewerAccountID)
if err != nil {
return ListLikesCountResponse{}, err
}
resp := ListLikesCountResponse{
VideoList: feedVideos,
HasMore: hasMore,
}
if len(videos) > 0 {
last := videos[len(videos)-1]
nextLikesCountBefore := last.LikesCount
nextIDBefore := last.ID
resp.NextLikesCountBefore = &nextLikesCountBefore
resp.NextIDBefore = &nextIDBefore
}
return resp, nil
}
// 按照关注列表查询视频
func (f *FeedService) ListByFollowing(ctx context.Context, limit int, latestBefore time.Time, viewerAccountID uint) (ListByFollowingResponse, error) {
doListByFollowingFromDB := func() (ListByFollowingResponse, error) {
videos, err := f.repo.ListByFollowing(ctx, limit, viewerAccountID, latestBefore)
if err != nil {
return ListByFollowingResponse{}, err
}
var nextTime int64
if len(videos) > 0 {
nextTime = videos[len(videos)-1].CreateTime.Unix()
} else {
nextTime = 0
}
hasMore := len(videos) == limit
feedVideos, err := f.buildFeedVideos(ctx, videos, viewerAccountID)
if err != nil {
return ListByFollowingResponse{}, err
}
resp := ListByFollowingResponse{
VideoList: feedVideos,
NextTime: nextTime,
HasMore: hasMore,
}
return resp, nil
}
var cacheKey string
if viewerAccountID != 0 && f.rediscache != nil {
before := int64(0)
if !latestBefore.IsZero() {
before = latestBefore.Unix()
}
cacheKey = fmt.Sprintf("feed:listByFollowing:limit=%d:accountID=%d:before=%d", limit, viewerAccountID, before)
cacheCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
defer cancel()
b, err := f.rediscache.GetBytes(cacheCtx, cacheKey)
if err == nil {
var cached ListByFollowingResponse
if err := json.Unmarshal(b, &cached); err == nil {
return cached, nil
}
} else if rediscache.IsMiss(err) { // 缓存未命中
lockKey := "lock:" + cacheKey
// 缓存未命中,尝试加锁
token, locked, _ := f.rediscache.Lock(cacheCtx, lockKey, 500*time.Millisecond)
if locked {
defer func() { _ = f.rediscache.Unlock(context.Background(), lockKey, token) }()
if b, err := f.rediscache.GetBytes(cacheCtx, cacheKey); err == nil {
var cached ListByFollowingResponse
if err := json.Unmarshal(b, &cached); err == nil {
return cached, nil
}
} else { // 缓存未命中,从数据库中查询
resp, err := doListByFollowingFromDB()
if err != nil {
return ListByFollowingResponse{}, err
}
if b, err := json.Marshal(resp); err == nil {
_ = f.rediscache.SetBytes(cacheCtx, cacheKey, b, f.cacheTTL)
}
return resp, nil
}
} else {
for i := 0; i < 5; i++ {
time.Sleep(20 * time.Millisecond)
if b, err := f.rediscache.GetBytes(cacheCtx, cacheKey); err == nil {
var cached ListByFollowingResponse
if err := json.Unmarshal(b, &cached); err == nil {
return cached, nil
}
}
}
}
}
}
resp, err := doListByFollowingFromDB()
if err != nil {
return ListByFollowingResponse{}, err
}
if cacheKey != "" {
if b, err := json.Marshal(resp); err == nil {
cacheCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
defer cancel()
_ = f.rediscache.SetBytes(cacheCtx, cacheKey, b, f.cacheTTL)
}
}
return resp, nil
}
func (f *FeedService) ListByPopularity(ctx context.Context, limit int, reqAsOf int64, offset int, viewerAccountID uint, latestPopularity int64, latestBefore time.Time, latestIDBefore uint) (ListByPopularityResponse, error) {
// Redis 热榜稳定分页as_of + offset
if f.rediscache != nil {
asOf := time.Now().UTC().Truncate(time.Minute)
if reqAsOf > 0 {
asOf = time.Unix(reqAsOf, 0).UTC().Truncate(time.Minute)
}
const win = 60
keys := make([]string, 0, win)
for i := 0; i < win; i++ {
keys = append(keys, "hot:video:1m:"+asOf.Add(-time.Duration(i)*time.Minute).Format("200601021504"))
}
dest := "hot:video:merge:1m:" + asOf.Format("200601021504") // 快照key同一个as_of页内复用
opCtx, cancel := context.WithTimeout(ctx, 80*time.Millisecond)
defer cancel()
exists, _ := f.rediscache.Exists(opCtx, dest)
if !exists {
_ = f.rediscache.ZUnionStore(opCtx, dest, keys, "SUM")
_ = f.rediscache.Expire(opCtx, dest, 2*time.Minute) // 给翻页留时间
}
start := int64(offset)
stop := start + int64(limit) - 1
members, err := f.rediscache.ZRevRange(opCtx, dest, start, stop)
if err == nil && len(members) == 0 {
if offset > 0 {
return ListByPopularityResponse{
VideoList: []FeedVideoItem{},
AsOf: asOf.Unix(),
NextOffset: offset,
HasMore: false,
}, nil
}
}
if err == nil && len(members) > 0 {
ids := make([]uint, 0, len(members))
for _, m := range members {
u, err := strconv.ParseUint(m, 10, 64)
if err == nil && u > 0 {
ids = append(ids, uint(u))
}
}
videos, err := f.repo.GetByIDs(ctx, ids)
if err == nil {
byID := make(map[uint]*video.Video, len(videos))
for _, v := range videos {
byID[v.ID] = v
}
ordered := make([]*video.Video, 0, len(ids))
for _, id := range ids {
if v := byID[id]; v != nil {
ordered = append(ordered, v)
}
}
items, err := f.buildFeedVideos(ctx, ordered, viewerAccountID)
if err != nil {
return ListByPopularityResponse{}, err
}
resp := ListByPopularityResponse{
VideoList: items,
AsOf: asOf.Unix(),
NextOffset: offset + len(items),
HasMore: len(items) == limit,
}
if len(ordered) > 0 {
last := ordered[len(ordered)-1]
nextPopularity := last.Popularity
nextBefore := last.CreateTime
nextID := last.ID
resp.NextLatestPopularity = &nextPopularity
resp.NextLatestBefore = &nextBefore
resp.NextLatestIDBefore = &nextID
}
return resp, nil
}
}
}
videos, err := f.repo.ListByPopularity(ctx, limit, latestPopularity, latestBefore, latestIDBefore)
if err != nil {
return ListByPopularityResponse{}, err
}
items, err := f.buildFeedVideos(ctx, videos, viewerAccountID)
if err != nil {
return ListByPopularityResponse{}, err
}
resp := ListByPopularityResponse{
VideoList: items,
AsOf: 0,
NextOffset: 0,
HasMore: len(items) == limit,
}
if len(videos) > 0 {
last := videos[len(videos)-1]
nextPopularity := last.Popularity
nextBefore := last.CreateTime
nextID := last.ID
resp.NextLatestPopularity = &nextPopularity
resp.NextLatestBefore = &nextBefore
resp.NextLatestIDBefore = &nextID
}
return resp, nil
}
func (f *FeedService) buildFeedVideos(ctx context.Context, videos []*video.Video, viewerAccountID uint) ([]FeedVideoItem, error) {
feedVideos := make([]FeedVideoItem, 0, len(videos))
videoIDs := make([]uint, len(videos))
for i, v := range videos {
videoIDs[i] = v.ID
}
likedMap, err := f.likeRepo.BatchGetLiked(ctx, videoIDs, viewerAccountID)
if err != nil {
return nil, err
}
for _, video := range videos {
feedVideos = append(feedVideos, FeedVideoItem{
ID: video.ID,
Author: FeedAuthor{ID: video.AuthorID, Username: video.Username},
Title: video.Title,
Description: video.Description,
PlayURL: video.PlayURL,
CoverURL: video.CoverURL,
CreateTime: video.CreateTime.Unix(),
LikesCount: video.LikesCount,
IsLiked: likedMap[video.ID],
})
}
return feedVideos, nil
}
func buildOrderedResult(orderedIDs []uint, dataMap map[uint]*video.Video) []*video.Video {
var res []*video.Video
for _, id := range orderedIDs {
res = append(res, dataMap[id])
}
return res
}