支持「数亿级」点赞功能方案调研 如果你要做的是支持「数亿级」的点赞功能那应该采用图存储加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年他们开始自研图存储REDtao2022年初完成了全部万亿边数据的在线迁移没有出一起事故。REDtao的架构分三层接入层、自研的分布式图缓存、MySQL分库分表做持久层。注意一个设计它的缓存层和持久层是解耦的可以各自独立扩缩容MySQL变成一个可以插拔替换的持久存储。读请求先过缓存命中率90%以上打不到缓存才查MySQL。上线之后MySQL的QPS降了70%还多。超级节点小红书也有解法不一样缓存里每个关系只保留最新的1000条边。依据是社交数据有很强的时间局部性最近的关系最可能被读到老数据少人看回源查一次也没关系。为什么三家都用了图三家各做各的系统最后选型一样我不觉得是互相抄是数据形态触发大家都这么玩。点赞数据有三个特点天然是关系用户连着内容读写比悬殊读是写的几十上百倍幂律分布少数热门内容集中了大部分点赞。图模型刚好三点全对上关系是它的原生表达不用拿表去模拟数边、查边、遍历边都是一度操作读得快超级节点是图领域研究了很多年的老问题有成熟的解法。点赞系统的复杂度不在点赞本身在于它同时是一个计数系统和一个关系系统。小项目里这两个问题叠在一张表里看不出来量级一上来就得把关系和计数拆开各自找最合适的存储。三家给出的答案就是图存储加KV。自己落地一个点赞功能完整方案看完三家回到现实如果今天要你自己做一个能扛大流量的点赞功能怎么做我不建议一上来就上图存储那是几亿日活才需要的形态。下面这套是我整理的方案Redis加MySQL日活不是很恐怖的也够用了。整体架构请求进来过网关到点赞服务。点赞服务前面挡两层缓存进程内的本地缓存再加Redis。写请求在Redis里完成核心动作后直接返回落MySQL的事交给消息队列异步做。读请求按本地缓存、Redis、MySQL的顺序查命中就返回。写路径Lua脚本加异步落库写路径有两个关键决定。第一个判断、写记录、计数这三步必须原子。不原子会出什么事用户快点点两下两个请求都查到「没赞过」都执行写入计数加了两次。Redis的解法是Lua脚本整个脚本在Redis里原子执行别的命令插不进来。第二个写完Redis就返回不等数据库。点赞是典型的弱一致场景用户只关心按钮亮了没有计数晚几百毫秒根本没人察觉。把最重的落库挪到异步写延迟才能压到毫秒级。核心入口在LikeServicepublicvoidlike(LonguserId,LongcontentId,IntegercontentType){// Lua脚本原子完成判断是否已赞、写入点赞记录、计数加一LongresultredisTemplate.execute(LIKE_SCRIPT,Lists.newArrayList(recordKey(contentId),countKey(contentId)),String.valueOf(userId));// 返回0表示之前已赞过幂等处理直接返回if(result0L){return;}// 缓存写成功即视为点赞成功落库交给消息队列异步完成likeMqProducer.send(newLikeEvent(userId,contentId,contentType,LIKE));}那段Lua脚本是全文最关键的几行-- 已赞过直接返回0接口幂等的第一道防线ifredis.call(SISMEMBER,KEYS[1],ARGV[1])1thenreturn0endredis.call(SADD,KEYS[1],ARGV[1])redis.call(INCR,KEYS[2])return1取消点赞是同一套逻辑的镜像先判断在不在记录里在就删记录、计数减一不在就幂等返回。脚本结构一样把SISMEMBER的判断反过来SADD换SREMINCR换DECR不单独贴了。落库批次处理不逐条写消费端拿到点赞事件最容易想到的写法是来一条写一条。热门内容这么写会把数据库打穿一条视频一秒一万个赞就是一秒一万次对同一行计数的更新行锁排队排到超时。做法是用批次。同一个内容的点赞事件在消费端聚合几秒合并成一次「计数加N」的更新点赞记录用批量插入publicvoidonMessage(ListLikeEventevents){// 同一内容的多次点赞先合并每条内容只落一次计数更新MapLong,LongAddercounternewHashMap();events.forEach(e-counter.computeIfAbsent(e.getContentId(),k-newLongAdder()).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按顺序查命中就返回。publiclonggetLikeCount(LongcontentId){// 先查本地缓存只放判定过的热点内容过期时间几秒LongcounthotLocalCache.getIfPresent(contentId);if(count!null){returncount;}// 再查Redis没命中才回源数据库并回填缓存StringvalueredisTemplate.opsForValue().get(countKey(contentId));returnvalue!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小红书万亿级存储系统自研与迁移之路支持「数亿级」点赞功能方案调研