如果你要做的是支持「数亿级」的点赞功能,那应该采用:图存储加KV。点赞关系存成图里的一条边,点赞数交给KV做自增计数。
我最近刚好在做这块的调研,看了一下抖音、快手、小红书公开的一些技术资料。这三家做点赞,做到最后,都不再是用一张表来记录谁赞了什么,而是把每一次点赞看成一条从「用户指向内容」的连线。用户是一个点,视频、笔记也是一个点,你点一次赞,两个点之间就多一条连线。
这种连线在图论里叫「边」,存这些点和边的系统叫「图存储」,这里的图是图论的图,跟图片没关系。
- 你赞没赞过这条内容,就查这条边在不在;
- 这条内容的点赞总数,就数一下这类边有几条;
- 你的点赞列表,从你这个点出发把这类边,遍历一遍就可以了。
真的是精妙呀,不愧是大厂,我自己是想不到可以用这种做法的,佩服的很。
当然这种量级的点赞功能,我肯定是没做过的。我当年做过的是普通体量的点赞,一张MySQL表就能扛住的那种。
因此这篇内容是我从这三家公开的资料整理出来的,加上我自己的理解,仅供参考。
资料出处都放在文末,你可以逐条核对,我说错的地方欢迎指出来。
我们得先说说需求
点赞看起来就是点一下,数字加一。但真没这么简单的,一个社区产品的点赞至少要做六件事:
| 点赞和取消点赞 | 点一下亮,再点一下灭 |
| 内容的点赞总数 | 按钮旁边那个数字 |
| 当前用户是否赞过 | 按钮是亮的还是灰的 |
| 用户点过赞的内容列表 | 个人主页的「我喜欢的」 |
| 作者的总获赞数 | 作者主页的数字 |
| 重复点击不能重复计数 | 双击、重试、网络重发都不能多加 |
前五件(除了获赞总数)其实都是同一件事的不同视角,用户和内容之间的一条关系。点赞是建立这条关系,取消是删掉它,是否赞过是查它在不在,内容点赞数是数这条内容有多少条这样的关系,用户点赞列表是数这个用户有多少条。
再说量级。日活几亿的产品,每个用户每天刷几十上百条内容,每条内容展示时都要带上点赞数和点赞状态,读的请求量轻松到每秒千万级。点赞动作本身没那么多,但会集中在少数热门内容上。
字节跳动的公开文章里给过一个比例,他们图存储场景的读流量是写流量的近百倍。读多写少,请求集中在热点上,这是点赞系统的两个基本的但是非常重要的点。
普通体量怎么做,我当年自己做过的那种
我做过社区产品,点赞就是一张MySQL表搞定的:
| id | 主键 |
| user_id | 谁点的 |
| content_id | 点了哪条内容 |
| content_type | 内容类型(视频、评论、笔记) |
| status | 有效还是已取消 |
| create_time | 点赞时间 |
user_id、content_id、content_type三个字段加联合唯一索引,重复点赞在数据库层面就插不进去。点赞数直接放在内容表加一个like_count字段,点赞时开一个事务,插记录、更新计数,一起提交。
这套做法在体量不大的情况下,不会出什么事。
那什么时候扛不住?就在两个地方:
- 一是热门内容。几十万人给同一条内容点赞,like_count这一行就是热点行,所有更新都排队抢这一行的行锁,数据库线程堆在这一个内容上。
- 二是「这个用户点赞过没有」这个查询。点赞表涨到几千万行以后,即使走索引,并发一上来也吃力,而且这个查询是每次刷到内容都要发的,量极大。
到这一步,常规做法是把缓存立起来:计数放Redis,是否赞过放Redis的Set,数据库改成异步更新。这也是大多数公司点赞功能的应付手法。
但是如果量级再往上抬几个数量级,到抖音、快手、小红书这个体量,他们的做法变了,不再有点赞表这个概念。
抖音、快手、小红书三家是怎么做的
前面说,点赞的前几个需求本质是一条「用户和内容之间的关系」。这三家,他们都把这句话落到了存储模型上:点赞不再是表里的一行记录,而是图里的一条边。
这是完全完全不同的建模了,每一个动作都对应一个图操作:
- 点赞是加一条边,取消是删一条边;
- 点赞数是数有几条点赞的边指向这条内容;
- 是否赞过是查两个点之间这条边在不在;
- 点赞列表是从用户这个点出发把点赞的边遍历一遍;
- 作者获赞数是他所有内容收到的点赞边加在一起。
一个模型,把六件事全串起来了。
下面我们逐家说一下,每家我都只讲他们公开资料里明确写了的。
字节跳动:ByteGraph加Abase
字节跳动基础架构团队的官方文章里,把公司所有业务数据归成三类:用户和用户的关系(关注、好友),内容(视频、文章、广告),用户和内容的联系(点赞、评论、转发、点击广告)。这三类数据关联在一起,天然就是图。
他们2018年从抖音的社交关系问题入手,自研了图数据库ByteGraph,现在支持头条、抖音、TikTok、西瓜等几乎全公司产品线。公开文章里的规模数据:百亿点、万亿边,最大集群QPS到数千万,读流量是写流量的近百倍,90%的查询是图上二度以内的查询。文章还提到一个特点,图符合幂律分布,少量大V的粉丝有几千万,这就是后面要说的超级节点问题。
计数这块,字节另有自研的KV存储Abase承接。Abase兼容Redis协议,官方文章里有一句很关键:
String类型支持的IncrBy,是字节线上使用最为广泛的数据模型。点赞数这种「加个一、减个一」的场景,正好就是IncrBy的典型用法。 注意,官方资料没有明说过点赞计数一定跑在Abase上,关系进图存储、计数进KV这个分工,是我自己对着两个系统的定位推出来的,是我的推断。
快手:KGraph
快手平台研发部的张世航在QCon2021上海的演讲里,讲了他们自研的图平台KGraph。演讲里有句话直接点了题:一个代表人的节点,指向一个代表视频的节点,边的类型是点赞。
KGraph的规模:存储的边超过10万亿条,对外提供单机2000万QPS的吞吐,演讲里给的线上数据是一个12台机器的集群扛1.3亿QPS,平均延迟600微秒,p999延迟小于1毫秒。
快手碰到的具体问题也很有代表性:超级节点。快手的一些官方账号,有几个亿的出边。一条边的列表打一个KV存,几亿条边根本塞不下。他们的解法是边少的时候打包存一个KV,边多到超过阈值,就切成多个KV,组织成类似B树的结构,两种形态可以动态转换,靠调节分裂合并的阈值来平衡读和写的放大。
演讲里还提了一句KGraph之前的样子:这些关系数据是存在每个服务器内存里的图结构,内存容量有限,服务启动要先加载图数据,启动很慢。你看,快手也不是一上来就是图数据库,也是从「能跑就行」的做法被量级一步步逼着演进的。
小红书:REDtao
小红书基础架构存储组在官方公众号小红书技术REDtech上发的文章里提到:用户与笔记之间可能存在「拥有(发布)、点赞、收藏」三种关系,同时还存在对应的反向关系「被点赞、被收藏」。
这些社交图谱数据有万亿条边,以前全部存在MySQL里。文章里给了一个数字:即使只有百万QPS的规模,MySQL的CPU使用率也已经到了55%。DAU再往上爆发,靠扩MySQL既贵又危险,2021年他们开始自研图存储REDtao,2022年初完成了全部万亿边数据的在线迁移,没有出一起事故。
REDtao的架构分三层:接入层、自研的分布式图缓存、MySQL分库分表做持久层。注意一个设计,它的缓存层和持久层是解耦的,可以各自独立扩缩容,MySQL变成一个可以插拔替换的持久存储。读请求先过缓存,命中率90%以上,打不到缓存才查MySQL。上线之后,MySQL的QPS降了70%还多。
超级节点小红书也有,解法不一样:缓存里每个关系只保留最新的1000条边。依据是社交数据有很强的时间局部性,最近的关系最可能被读到,老数据少人看,回源查一次也没关系。
为什么三家都用了图
三家各做各的系统,最后选型一样,我不觉得是互相抄,是数据形态触发大家都这么玩。
点赞数据有三个特点:天然是关系,用户连着内容;读写比悬殊,读是写的几十上百倍;幂律分布,少数热门内容集中了大部分点赞。
图模型刚好三点全对上:关系是它的原生表达,不用拿表去模拟;数边、查边、遍历边都是一度操作,读得快;超级节点是图领域研究了很多年的老问题,有成熟的解法。
点赞系统的复杂度不在点赞本身,在于它同时是一个计数系统和一个关系系统。
小项目里这两个问题叠在一张表里看不出来,量级一上来,就得把关系和计数拆开,各自找最合适的存储。三家给出的答案,就是图存储加KV。
自己落地一个点赞功能,完整方案
看完三家,回到现实:如果今天要你自己做一个能扛大流量的点赞功能,怎么做?
我不建议一上来就上图存储,那是几亿日活才需要的形态。下面这套是我整理的方案,Redis加MySQL,日活不是很恐怖的,也够用了。
整体架构
请求进来,过网关到点赞服务。点赞服务前面挡两层缓存:进程内的本地缓存,再加Redis。写请求在Redis里完成核心动作后直接返回,落MySQL的事交给消息队列异步做。读请求按本地缓存、Redis、MySQL的顺序查,命中就返回。
写路径:Lua脚本加异步落库
写路径有两个关键决定。
第一个,判断、写记录、计数这三步必须原子。不原子会出什么事?用户快点点两下,两个请求都查到「没赞过」,都执行写入,计数加了两次。Redis的解法是Lua脚本,整个脚本在Redis里原子执行,别的命令插不进来。
第二个,写完Redis就返回,不等数据库。点赞是典型的弱一致场景,用户只关心按钮亮了没有,计数晚几百毫秒根本没人察觉。把最重的落库挪到异步,写延迟才能压到毫秒级。
核心入口在LikeService:
public void like(Long userId, Long contentId, Integer contentType) {
// Lua脚本原子完成:判断是否已赞、写入点赞记录、计数加一
Long result = redisTemplate.execute(LIKE_SCRIPT,
Lists.newArrayList(recordKey(contentId), countKey(contentId)),
String.valueOf(userId));
// 返回0表示之前已赞过,幂等处理,直接返回
if (result == 0L) {
return;
}
// 缓存写成功即视为点赞成功,落库交给消息队列异步完成
likeMqProducer.send(new LikeEvent(userId, contentId, contentType, LIKE));
}
那段Lua脚本是全文最关键的几行:
— 已赞过直接返回0,接口幂等的第一道防线
if redis.call('SISMEMBER', KEYS[1], ARGV[1]) == 1 then
return 0
end
redis.call('SADD', KEYS[1], ARGV[1])
redis.call('INCR', KEYS[2])
return 1
取消点赞是同一套逻辑的镜像:先判断在不在记录里,在就删记录、计数减一,不在就幂等返回。脚本结构一样,把SISMEMBER的判断反过来,SADD换SREM,INCR换DECR,不单独贴了。
落库:批次处理,不逐条写
消费端拿到点赞事件,最容易想到的写法是来一条写一条。热门内容这么写会把数据库打穿:一条视频一秒一万个赞,就是一秒一万次对同一行计数的更新,行锁排队排到超时。
做法是用批次。同一个内容的点赞事件在消费端聚合几秒,合并成一次「计数加N」的更新,点赞记录用批量插入:
public void onMessage(List<LikeEvent> events) {
// 同一内容的多次点赞先合并,每条内容只落一次计数更新
Map<Long, LongAdder> counter = new HashMap<>();
events.forEach(e -> counter
.computeIfAbsent(e.getContentId(), k -> new LongAdder())
.increment());
// 点赞记录批量插入,重复消息由数据库唯一索引兜底
likeRecordMapper.insertBatch(events);
// 一万次点赞合并成一次更新,热点行的锁竞争降几个量级
counter.forEach((contentId, adder) ->
contentMapper.incrLikeCount(contentId, adder.intValue()));
}
消息队列可能重复投递,消费也可能重试,幂等不能只靠Redis那一次判断。数据库层兜底靠like_record表的唯一索引:(content_id, content_type, user_id)联合唯一,重复插入直接失败,失败即代表处理过。表按content_id分库分表,让同一条内容的写落在固定的片上,攒批合并的效果才不会被分散掉。
按内容分片只解决了内容维度的查询,用户的点赞列表是另一个维度,按用户查就得广播到所有片。实际做法是写两份,一份按内容分、一份按用户分,拿空间换查询。这也就是图存储里正向边、反向边各存一份的思路,ByteGraph和REDtao的公开资料里都明确提了反向边,快手那篇说的是出边入边分别存储,说法不同,做法一样。
读路径:三级缓存
读的并发比写高两个量级,扛读靠三层:进程内本地缓存、Redis、MySQL,按顺序查,命中就返回。
public long getLikeCount(Long contentId) {
// 先查本地缓存,只放判定过的热点内容,过期时间几秒
Long count = hotLocalCache.getIfPresent(contentId);
if (count != null) {
return count;
}
// 再查Redis,没命中才回源数据库并回填缓存
String value = redisTemplate.opsForValue().get(countKey(contentId));
return value != null ? Long.parseLong(value) : loadFromDb(contentId);
}
本地缓存不是全量放,只放判定过的热点Key,过期时间给几秒。这个场景宁可几秒钟内数字不太准,也不能让Redis被单个热点Key打穿。点赞数差几个,用户感知不到;缓存被打穿回源到数据库,就是事故。快手的KRPC框架里也能看到同样的思路,全局缓存之上再叠一层线程本地缓存,专门对付热点读。
「是否赞过」走同一条链路,查的是Redis里那条内容的点赞集合。集合有个坑要防:一条几亿人点赞的内容,Set会变成大Key。参考小红书REDtao的思路,缓存里只保留最近的一批,比如最新一万条,老记录回源数据库查,因为看点赞状态的请求基本都来自最近的浏览。
列几张你可以直接收藏的表格

| 起步 | MySQL一张点赞表,内容表加计数列 | 热门内容的计数行成热点行,点赞表到几千万行 | 计数和点赞状态进Redis |
| 增长 | Redis承接读写,消息队列异步落库 | 落库行锁竞争,消息堆积 | 消费端攒批合并,唯一索引兜底幂等 |
| 爆发 | 本地缓存加Redis加分库分表 | 数据量到千亿万亿级,存储和机器成本失控 | 关系和计数拆开,分别找专业存储 |
| 大厂 | 独立互动服务,自研图存储加KV | 超级节点、跨地域、极致成本 | 点赞建模为图的一条边 |
三家做法对照:
| 字节(抖音) | ByteGraph图数据库,计数由Abase承接 | 用户点指向内容点的点赞边 | 百亿点、万亿边,最大集群数千万QPS | 字节跳动基础架构官方文章 |
| 快手 | KGraph图平台 | 人节点指向视频节点的点赞边 | 边超10万亿条,单机2000万QPS | 张世航QCon2021演讲 |
| 小红书 | REDtao图存储 | 用户与笔记之间的点赞关系边 | 万亿条边,缓存命中率90%以上 | 小红书技术REDtech官方文章 |
关键问题速查:
| 幂等怎么做 | Redis里Lua脚本原子判断,数据库唯一索引兜底 |
| 一致性怎么保 | 接受最终一致,写缓存即返回,消息队列加唯一索引保证不丢不重 |
| 热点内容怎么办 | 读靠本地缓存挡,写靠攒批合并削 |
| 超级节点怎么办 | 参考快手拆成多KV组B树,或参考小红书只缓存最新一批 |
| 降级怎么兜 | 缓存挂了回源数据库限流读,极端时返回默认状态保主流程 |
小结
方案都是量级逼出来的。
快手最早是每台服务器内存里放一份图,小红书最早就是老老实实的MySQL,谁都不是一上来就奔着万亿边去的。看大厂资料,有价值的不是抄他们现在的答案,是看懂他们被什么问题逼着一步步改。热点行、超级节点、读写比,这些问题在小体量时都以温和的形式出现过,识别出来,就知道自己的系统走到哪一步了。
另一个想说的,三家都用图存储,不代表你的项目该上。架构这行最怕的就是,拿别人的终局当自己的开局。
参考的内容
- 字节跳动自研万亿级图数据库 & 图计算实践
- Abase2:字节跳动新一代高可用 NoSQL 数据库
- 快手单机千万 QPS 的分布式图数据库 KGraph 的实践
- 拯救爆表的 MySQL:小红书万亿级存储系统自研与迁移之路支持「数亿级」点赞功能方案调研
网硕互联帮助中心



评论前必须登录!
注册