结论是点赞接口性能瓶颈在redis命令选型、事务粒度和并发写冲突处理;应使用set+ttl+lua脚本原子操作替代hset+expire,避免数据库行锁与主从延迟,通过user_id:video_id键设计、incr/decr联动计数及业务前缀保障高并发下的性能与一致性。

直接说结论:点赞接口的性能瓶颈不在 Gin 路由或 JSON 序列化,而在 Redis 命令选型、事务粒度和并发写冲突处理。用 SET + 过期时间 + Lua 脚本原子操作,比 HSET + 单独 EXPIRE 快 3 倍以上,且避免竞态。
为什么不能用 POST /video/{id}/like 简单存数据库
短视频场景下,单视频每秒可能收到上万次点赞请求。如果每次请求都走 MySQL 的 INSERT INTO likes (user_id, video_id) VALUES (?, ?),即使加了唯一索引,也会因行锁争抢导致 QPS 掉到 200 以下;更糟的是,取消点赞时还要 DELETE,主从延迟会让“已取消”状态在从库短暂不可见。
- 真实压测数据:MySQL 单实例在 500 并发下,
INSERT IGNORE平均延迟 12ms,TPS 仅 410 - Redis
SET命令平均延迟 0.2ms,TPS 轻松过 5w - 必须放弃「先查再判」逻辑(如
SELECT COUNT(*) WHERE user_id=? AND video_id=?),那是性能杀手
SET 命令配合 Lua 脚本实现原子点赞/取消
核心思路:用 user_id:video_id 作为 Redis key,value 固定为 1,靠 TTL 控制有效期(比如 30 天)。但单纯 SET 无法区分是“新点赞”还是“重复操作”,所以必须用 Lua 脚本封装判断逻辑:
local key = KEYS[1]
local is_like = tonumber(ARGV[1]) -- 1=点赞,0=取消
local ttl = tonumber(ARGV[2]) -- 过期秒数,如 2592000(30天)
<p>if is_like == 1 then
return redis.call("SET", key, "1", "EX", ttl, "NX")
else
return redis.call("DEL", key)
end</p>
Go 侧调用示例(Gin handler 中):
func handleLike(c *gin.Context) {
userID := c.GetInt64("user_id") // 从 JWT 解析
videoID := c.Param("id")
key := fmt.Sprintf("like:%d:%s", userID, videoID)
<p>isLike, _ := strconv.ParseInt(c.Query("action"), 10, 64) // 1 or 0</p><p>// 注意:script.Load() 只需初始化一次,全局复用
result, err := script.Do(ctx, rdb, key, isLike, 2592000).Result()
if err != nil {
c.JSON(500, gin.H{"error": "redis error"})
return
}</p><p>// result == 1 表示操作成功(SET NX 成功 或 DEL 执行)
// result == 0 表示已存在(点赞重复)或 key 不存在(取消时无记录)
c.Status(204)
}</p>
如何避免高并发下计数器翻车
前端需要显示“当前视频被赞次数”,这个值不能每次读 Redis keys 匹配 like:*:video_123 —— O(n) 复杂度,n 是用户总数,根本不可行。正确做法是:
- 点赞成功后,同步执行
INCR like_count:video_123(注意:不设 TTL,靠业务层清理过期数据) - 取消点赞时,执行
DECR like_count:video_123 - 但要防重入:同一用户多次点“赞”,
INCR会多加;多次点“取消”,DECR会多减。所以必须在 Lua 脚本里联动判断 - 实际生产中,建议用
INCRBY+ 条件判断,或干脆把计数更新放到异步队列(如 Kafka),避免阻塞主流程
最易被忽略的点:Redis key 设计必须带业务前缀(如 like:)且包含用户 ID 在前——否则无法用 SCAN 按用户维度批量清理过期点赞记录。漏掉这点,半年后你的 Redis 内存会悄悄涨满。











