跳到主内容

从 2k 到 10 万 TPS:Redis 点赞系统避坑实录 ​

一、问题背景:点赞为什么不能直接写数据库 ​

点赞看似是最简单的业务:点一下、计数加一、再点取消。但在内容平台的真实流量里,它有三个致命特征:写放大极严重(一条爆款内容瞬时百万点赞)、必须严格去重(同一用户重复点赞不能重复计数)、强关联热度榜(点赞数直接决定 feed 流排序)。

如果直接 UPDATE articles SET like_count = like_count + 1,单行记录会成为数据库热点行,行锁串行化后 TPS 通常只能到两三千,数据库连接池很快被打满。更糟的是,应用层「先 SELECT 判断是否点赞、再 INSERT」的写法在并发下会产生重复记录。

正确的架构思路是把高频写全部挡在 Redis,数据库只做最终持久化:

客户端点赞请求Redis:Set 去重 + 计数ZSet 热度榜Stream/消息队列削峰MySQL 批量异步落库

二、方案对比:四种去重数据结构怎么选 ​

方案去重能力内存占用(1 亿关系)能否查「我赞过谁」适用场景
MySQL 唯一索引强高(含索引约 8GB)能低频、强一致
Redis String 位图强约 12MB(按用户 ID 连续)不能直接反查单内容点赞判断
Redis Set强约 5GB能(SINTER/SISMEMBER)通用点赞关系
Redis HyperLogLog弱(有误差)极低不能仅 UV 估算

点赞业务需要同时满足「判断某用户是否赞过」「列出点赞用户」「与热度榜联动」,Set 是综合最优解。位图虽然省内存,但要求用户 ID 数字连续且无法方便地遍历成员;HLL 则有 0.81% 误差,不能用于精确计数。

三、实操步骤:Set 去重 + ZSet 热度榜 ​

3.1 Key 设计规范 ​

统一采用 业务:实体类型:实体ID:字段 的命名,便于集群按 hashtag 定位与批量清理:

bash
# 某条内容的点赞用户集合(去重核心)
like:article:10086:users

# 某条内容的点赞计数(冗余计数,避免每次 SCARD)
like:article:10086:count

# 全站内容热度榜,score 为点赞数,member 为内容 ID
rank:article:hot

3.2 点赞与取消:必须用 Lua 保证原子性 ​

「SADD 判断返回值 + 计数 +1 + ZSet 加分」这三步如果拆成多条命令,在客户端超时重试或并发切换时会出现计数与集合不一致。应当用 Lua 脚本一次执行:

bash
# KEYS[1]=点赞用户集合 KEYS[2]=计数器 KEYS[3]=热度榜 KEYS[4]=内容ID
# ARGV[1]=用户ID
redis-cli --eval like.lua like:article:10086:users like:article:10086:count rank:article:hot 10086 , 9527
lua
-- like.lua:返回 1 表示点赞成功,0 表示已点过(幂等)
local added = redis.call('SADD', KEYS[1], ARGV[1])
if added == 1 then
  redis.call('INCR', KEYS[2])
  redis.call('ZINCRBY', KEYS[3], 1, KEYS[4])
end
return added

取消点赞同理,用 SREM 的返回值决定是否 DECR 与 ZINCRBY -1。把两个脚本通过 SCRIPT LOAD 预加载,生产环境用 EVALSHA 调用,可把单次点赞的网络往返压到一个 RTT。

3.3 查询接口 ​

bash
# 用户 9527 是否赞过该内容
SISMEMBER like:article:10086:users 9527

# 当前点赞数(直接读计数器,不要 SCARD 大集合)
GET like:article:10086:count

# 热度榜 Top 20,带分数
ZREVRANGE rank:article:hot 0 19 WITHSCORES

# 我赞过的内容:维护反向集合 user:{uid}:liked,用 SINTER 做共同点赞
SINTER like:article:10086:users like:article:10087:users

3.4 异步落库:Stream 消费组批量刷盘 ​

Redis 宕机丢失点赞是不可接受的,因此每次点赞额外写一条 Stream,由消费者批量聚合后写库,而不是每条都 UPDATE:

bash
# 生产端:写入点赞流水(* 表示自动生成 ID)
XADD like:stream * aid 10086 uid 9527 action 1

# 消费端:创建消费组,从最早消息开始
XGROUP CREATE like:stream like-saver 0-0 MKSTREAM

# 一次读取 500 条,阻塞 1 秒
XREADGROUP GROUP like-saver consumer-1 COUNT 500 BLOCK 1000 STREAMS like:stream >

消费者拿到一批后,在内存按 aid 聚合计数,用一条 SQL 完成多行更新,再 XACK:

sql
-- 批量 upsert,配合「点赞关系表唯一索引 (aid, uid)」
INSERT INTO article_like_stat(aid, like_count) VALUES (10086, 320), (10087, 58)
ON DUPLICATE KEY UPDATE like_count = VALUES(like_count);

批量化后数据库实际 TPS 需求从 10 万降到两三千,配合 Pipeline 与连接复用,单集群 Redis 可稳定承载 10 万级写入 TPS。

四、避坑总结:三个真实故障点 ​

坑 1:用 SCARD 当实时计数,热点 Key 被打满 CPU。 现象是点赞高峰期 Redis 单分片 CPU 100%,慢日志里全是对百万级成员集合的 SCARD。修正方法是冗余 String 计数器,点赞时随 Lua 一起 INCR,查询只走 GET;SCARD 仅作为离线对账手段。

坑 2:应用层先查后写,并发下计数翻倍。 典型错误代码逻辑是「SISMEMBER 返回 0 就 SADD 并 INCR」,两次命令之间用户快速连点,两个线程都判定未点赞,计数加了两次。修正方法是上文的 Lua 脚本,以 SADD/SREM 的返回值作为唯一判据,绝不在客户端做判断分支。

坑 3:消息处理完业务却忘记 XACK,重启后重复落库。 表现为 Stream 积压(XPENDING 列表持续增长)或消费者重平衡后计数被多加。修正方法是严格按「批量写库成功 → 再 XACK」的顺序,落库 SQL 必须幂等(用 upsert 覆盖绝对值而非 count = count + n),并对死信消息用 XAUTOCLAIM 转移后人工对账。

额外建议: 大 Key 要按内容 ID 分桶(如 like:article:10086:users:{0..15}),避免单集合过大导致迁移超时;Redis 持久化至少开启 AOF appendfsync everysec,并在每天低峰用 SCARD 与数据库做一次双向对账,补偿极端情况下的秒级丢失。这样一套「内存去重 + 原子计数 + 队列削峰 + 批量落库」的组合拳,才是点赞系统扛住流量洪峰的完整答案。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。