Skip to content

点赞 / 收藏 / 播放量

社交互动模块(internal/interaction)实现点赞、收藏、播放量三种高频计数,核心思路是**「Redis 实时计数 + 异步批量落库」**,读写链路均不触碰 DB。

整体架构

text
┌────────────── 写路径(实时) ──────────────┐
│ 点赞/收藏:Lua 原子 toggle(Set 去重)        │
│ 播放:INCR + 更新榜单                        │
│ 同时 RPUSH 事件到 pending 队列              │
└──────────────────────┬─────────────────────┘

┌────────────── Flusher(异步落库)───────────┐
│ 每 5s 或满 200 条:LRANGE 取出事件          │
│ 事务批量写 DB(videos 冗余列 + 明细表)       │
│ 幂等:snowflake 事件 ID 去重                │
└──────────────────────┬─────────────────────┘

┌────────────── 读路径(实时) ──────────────┐
│ 三计数:Pipeline SCARD×2 + GET              │
│ 热门榜单:ZSet ZREVRANGE                    │
└────────────────────────────────────────────┘

数据模型

存储Key 结构用途
Redis Setinteraction:like:{videoID}点赞用户去重集合
Redis Setinteraction:fav:{videoID}收藏用户去重集合
Redis Stringinteraction:play:{videoID}播放量(INCR)
Redis ZSetinteraction:hot:like / interaction:hot:play热门榜单(score = 计数)
Redis Listinteraction:pending待落库事件队列
PostgreSQLvideo_likes / video_favorites / video_play_logs + videos 冗余计数列持久化

写路径:Lua 原子操作

点赞 toggle

lua
-- toggleLikeScript:原子「判断 → 切换 → 更新榜单 → 推事件」
if redis.call('SISMEMBER', KEYS[1], ARGV[1]) == 1 then
    -- 已赞 → 取消
    redis.call('SREM', KEYS[1], ARGV[1])
    redis.call('ZINCRBY', KEYS[2], -1, ARGV[2])   -- 榜单 -1
    redis.call('RPUSH', KEYS[3], ARGV[4])          -- unlike 事件
    return {0, redis.call('SCARD', KEYS[1])}
else
    -- 未赞 → 点赞
    redis.call('SADD', KEYS[1], ARGV[1])
    redis.call('ZINCRBY', KEYS[2], 1, ARGV[2])     -- 榜单 +1
    redis.call('RPUSH', KEYS[3], ARGV[3])          -- like 事件
    return {1, redis.call('SCARD', KEYS[1])}
end
  • 一个 Lua 脚本内完成「查询是否已赞 + 切换 + 榜单更新 + 事件入队」,天然原子,无竞态;
  • 返回 {是否已赞, 最新点赞数},接口可直接展示;
  • 收藏 toggle 同理(不参与榜单)。

播放 +1

lua
-- playScript
redis.call('INCR', KEYS[1])                        -- 播放量 +1
redis.call('ZINCRBY', KEYS[2], 1, ARGV[1])         -- 播放榜 +1
redis.call('RPUSH', KEYS[3], ARGV[2])              -- play 事件
return redis.call('GET', KEYS[1])

读路径:Pipeline 批量

go
// Counts:一次 RTT 批量读取多个视频的三计数
pipe := s.rdb.Pipeline()
for _, id := range videoIDs {
    likeCmds = append(likeCmds, pipe.SCard(ctx, likeKey(id)))
    favCmds  = append(favCmds,  pipe.SCard(ctx, favKey(id)))
    playCmds = append(playCmds, pipe.Get(ctx, playKey(id)))
}
pipe.Exec(ctx)
  • 列表页批量查询用 Pipeline 合并 RTT,避免 N+1;
  • IsLiked / IsFavoritedSIsMember 判断当前用户状态。

异步落库(Flusher)

go
// internal/interaction/flusher.go
func (f *Flusher) Run(ctx context.Context) {
    ticker := time.NewTicker(flushInterval)   // 默认 5s
    for range ticker.C {
        events := f.drain()                    // LRANGE + LTRIM 取出事件
        if len(events) >= flushBatch {         // 或达到批量阈值
            f.flush(events)                    // 事务批量写库
        }
    }
}
  • 事件带 snowflake ID,落库时唯一索引去重 → 幂等,Flusher 崩溃重启不会重复计数;
  • videos 表冗余 like_count / favorite_count / play_count 列(展示直接读),明细表(video_likes 等)做持久化审计;
  • 落库节奏可配:flush_interval(5s)/ flush_batch(200 条),批量越大 DB 压力越小、展示延迟越高。

热门榜单

  • Redis ZSet interaction:hot:like / interaction:hot:play,score = 计数;
  • 点赞/播放操作时 ZINCRBY 增量更新;
  • 榜单容量 leaderboard_size(默认 50),读取时 ZREVRANGE 0 N 取 TopN。

配置

toml
[social]
enabled = true
flush_interval = 5       # 落库间隔(秒)
flush_batch = 200        # 批量落库阈值
leaderboard_size = 50    # 榜单容量

演进方向(roadmap)

  • 计数 Redis 持久化与重启重建(防止计数丢失);
  • 播放上报 IP 限流(防匿名刷量);
  • 计数明细表定时归档(大表治理)。

基于 MIT License 发布