点赞 / 收藏 / 播放量
社交互动模块(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 Set | interaction:like:{videoID} | 点赞用户去重集合 |
| Redis Set | interaction:fav:{videoID} | 收藏用户去重集合 |
| Redis String | interaction:play:{videoID} | 播放量(INCR) |
| Redis ZSet | interaction:hot:like / interaction:hot:play | 热门榜单(score = 计数) |
| Redis List | interaction:pending | 待落库事件队列 |
| PostgreSQL | video_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/IsFavorited用SIsMember判断当前用户状态。
异步落库(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 限流(防匿名刷量);
- 计数明细表定时归档(大表治理)。
