本文回顾了关于快手的核心数据对象“Photo”存储系统的一次巧妙降本增效的故事。通过充足细致的前期调研分析,以极少的人力投入取得了相当可观的收益。文中对有巨大UGC历史数据存量的平台型公司如何应对挑战有一些思考和总结。
Premature optimization is the root of all evil (or at least most of it) in programming. —— Donald Knuth
快手作为国民级短视频平台,历史上短视频的总量已达千亿级,每日新增短视频作品超过4000万。本文的主角是快手短视频在系统中的基础数据结构——Photo对象。为什么叫Photo而不是video?
快手的前身是2011年推出的“Gif快手”,当时的主要功能是将视频在手机上方便高效地转换为Gif动图。由于Gif兼具视频动效和图片的轻量特性,适合在微博等平台传播,因此早期将Gif动图命名为Photo并无不妥。随着产品转型为短视频和直播社区,核心数据结构的名称为沿用至今。
Photo对象是什么?
Photo对象是快手生态中短视频的核心数据结构,包含除音视频信息(通常在MB~GB级别)外的大部分属性信息,如标题、码率、封面、上传时间、位置、商业化信息等。截至2023年初,Photo对象已包含200多个属性,平均大小在3k~4k之间。本文的故事围绕Photo对象的存储优化展开。
为什么标题叫"巧渡金沙江"?
巧渡金沙江和强渡大渡河是在1935年4、5月间发生在中央红军长征途中的两场著名战斗,前者仅用了7条小船经7天7夜实现了红军的战略转移意图,后者经过激烈的浴血奋战获得胜利后成功打开了转移的通道,两者在长征史上具有同样重要的战略意义。本文对Photo对象的优化因其投入极小却取得不小的收益,故而借用巧渡金沙江的典故。
Photo对象存储在关系数据库中,在快手承载着有数以千计的上游服务调用,中间用缓存扛流量,缓存请求量QPS在亿级别,并且缓存回源DB的请求也高达百万次QPS。为了支撑如此庞大的数据库请求,我们采取了数据库分片加读写分离的多从库策略,从而将单个数据库分片从库的请求压力降低至每秒千次级别。然而,这种高可用性和扩展性的提升,是以数据存储冗余度成倍增加为代价的。在缓存与磁盘存储空间成本相差悬殊的背景下,如何平衡成本与性能成为了选择缓存空间规模的关键问题。快手Photo服务的缓存容量虽已达TB级别,但面对千亿级的历史冷数据存量,TB级的缓存资源仍然捉襟见肘,这一点集中体现在缓存命中率随时间的持续下降趋势上。在极高的请求量背景下,缓存命中率的每一个百分点下降都会转化为回源服务对DB请求量的显著增加。因此,我们追求的理想缓存命中率应尽可能接近100%。下图可以看出在22年时缓存平均命中率已经退化到了94%,这意味着着需要部署更多的回源服务和更高的DB请求量,而这一数值在2019年时曾维持在98%以上。
本次photo优化治理取得了较为显著的成效,具体体现以下三个方面:
1.存储空间大幅缩减:成功将快手核心数据资产Photo的存量数据缩减约25%,累计节省数百TB,有效降低了存储成本。
2.新增数据体积优化:优化后的新增Photo作品平均体积减少近1KB,结合每日数千万的上传量,每日新增DB空间减少数百GB。
3.缓存命中率提升:Photo缓存系统的命中率从95%提升至97%,在亿级QPS的规模下,每提升一个百分点意味着年化成本节省达百万级别,显著提升了系统效率并降低了运营成本。
通过对DB单分库的全量数据进行清洗,我们可观察到存储量的明显拐点。存量数据清洗后,DB存储总计下降了400T,清洗过程中还留下了许多“空洞”,随后被新的增量数据优先填补,这引出存储量曲线在一段时间内保持水平。此外,在增量优化逻辑上线后,新增Photo对象大小同样减少了约25%。
在快手这一大型内容平台上,爆款作品往往会在极短时间内获得海量的展现和播放,从而对热点Photo的请求量极高。与此同时,对于数月甚至数年前的冷数据,其请求则具有较大的随机性和不可预测性。Photo缓存常年处于满载状态,这意味着每有一条新数据写入缓存,就必须有一条旧数据被淘汰以腾出空间。热点数据的高请求量决定了必须依赖缓存来应对,而冷数据的随机访问则导致缓存中持续进行数据的换入换出。下图展示了历史上某日Photo缓存(单个Memcached集群)的请求量级。从读写请求的角度来看,下图绿色的读缓存QPS稳定在约千万级,而黄色的写缓存QPS则保持在百万级。这些写请求主要源于读缓存未命中后,服务回源DB并将数据回写至缓存所产生的结果。这种读写请求的分布清晰地反映了缓存系统在应对热点与冷数据访问时的动态平衡。
当请求获取Photo数据时,若命中缓存,则立即返回结果;若未命中,则会触发一次DB读请求并回写缓存后再返回。在千万QPS的缓存请求量级下,命中率每下降一个百分点,就意味着1%的缓存读取请求会转化为数十万的数据库读取QPS和缓存写入QPS。如果命中率下降4个百分点,数据库将额外承受超百万的读取请求QPS,这也进一步解释了为何数据库需要采用分库分表结合一主多从的设计架构。此外,快手的回源服务采用独立部署架构,缓存命中率每下降1个百分点,都会导致回源服务流量增加数十万QPS。因此,缓存命中率提升2个百分点,大致可减少60万的数据库读取QPS和回源服务QPS,相当于节省了3个数百TB级别的数据库从库以及数千个CPU核心资源。由此我们可以得出结论:对于Photo这种承载巨量QPS和数据规模的缓存系统而言,命中率每提升1个百分点,就能为上下游系统带来年化百万元级别的成本节约收益。这一优化不仅显著降低了数据库和回源服务的压力,也为整体系统的资源利用率带来了大幅提升。从下方两张图数据对比可以看出,本次治理确实显著提升了缓存命中率,涨幅约为2个百分点。具体来看,上图展示了治理期间缓存命中率变化趋势。红框内数据显示,日均命中率随时间呈现递增趋势,四周的时间上升达到了近1个百分点(图例中的-1d代表前一天,-4w代表4周前的同一天)。作为对比,下图呈现在未进行优化时的常规状态下缓存命中率走势,不难看出红框内的数据随时间呈现逐步下降的趋势。治理过程中数据(2023-1),红框内命中率随时间上升【从下往上看】
对照组数据(2024-8),红框内命中率随时间下降【从下往上看】
读者可能会好奇,为什么Photo的缓存命中率会随着时间的推移自然下降?这是因为平台每天新增的数千万上传作品会同步产生同样数量的新增对象存入DB。随着数据“与日俱增”,缓存的总容量却保持不变,导致“缓存容量/DB总数据量”这一比值自然随时间逐渐降低。那么,为什么在优化治理过程中命中率会有显著提升呢?这是因为优化措施使每个Photo对象的体积缩小,相同容量的缓存能够存储的Photo数量随之增加,从而提升了命中率。这一优化过程可以类比为“克服重力做功”,或者说系统外部的力量在对抗“系统的熵增”而做功。值得注意的是,下图展示2024年8月某日的平均命中率略高于治理期间的数据,这是因为2024年进行了一次缓存扩容,通过“硬拉升”的方式提升了命中率。事实上,随着历史数据的不断积累,类似的缓存扩容在过去几年中已经执行过多次。这也是快手这类拥有海量UGC内容增量的平台所面临的共同挑战。从下方的趋势图可以看出,命中率提升的过程从22年12月初持续到23年2月,在过亿QPS的访问量下,平均命中率从95%稳步提升至97%,这一成果实属不易。Photo库的表结构是一个典型的数据库实体表,包含约20个字段,主要由作品ID、作者ID、时间戳以及若干状态信息组成。其中,有一个JSON结构的扩展字段,存储各业务线的业务属性。这种设计的好处在于,当业务需要扩展时,无需频繁变更库表结构。然而,这种设计也并非没有弊端。如果缺乏有效管控,随着业务规模的扩大,扩展字段中的属性数量会迅速膨胀。到2023年,Photo表的扩展字段中已经包含了超过200个属性,这无疑增加了数据存储和处理的复杂性。统计时间 | 扩展字段内属性数 |
19年5月 | 约60个 |
20年5月 | 约120个 |
21年5月 | 约160个 |
22年3月 | 约210个 |
23年5月 | 约230个 |
从上表可以看出,从2019年到2023年,扩展字段内的属性数量呈现明显的增长趋势。但在2022年Q3之后,属性数量的增速得到了显著遏制,这主要得益于负责团队的同学着手施行了较严格的字段管控,业务字段需业务自己维护,非必要不允许进Photo表。经过统计分析,我们发现200多个属性中,TOP30的属性占据了总空间的93%,而前6个属性占据了总空间的一半以上。这些属性多为复合结构,也即json嵌套子结构,且多数值为0、false、null等默认值。这一发现让解决方案自然浮现——“把这些默认值拿掉可取得显著收益”。从下图可以看出,除了videoId属性外,其他属性都被不必要地存储在每个Photo对象中。无数个Photo对象叠加起来,这部分冗余存储成本累积到了惊人的地步。在JAVA生态下应对此类问题,只要在类中相应的字段上加写一条@JsonInclude(Include.NON_DEFAULT)的注解语句,即可避免将这些冗余信息存储到DB里了。随后在占存储大头的6个复合结构上添加同样的代码,成功地将一个Photo元数据对象的尺寸缩减了25%。虽然单个Photo对象仅缩减了约1KB,但在千亿量级的规模下,这一优化聚沙成塔,累计节省了数百TB的存储空间。经过测算,本次治理工作至今已累计节省了超千万元的成本,且这一收益仍在持续增加中。此外,不可忽略的是Photo对象缩小25%后,亿级QPS的流量在内外网传输时带宽需求显著降低。各请求上游服务处理的数据量也相应减少,甚至快手用户在手机上刷短视频时,每条视频的流量也减少了若干KB——尽管如今流量成本已经很低,但积少成多,依然带来了可观的收益。总之,本次优化的收益涵盖了多个环节,包括存储、缓存、回源服务、网络带宽、客户端流量以及所有在线和离线下游服务处理Photo时所节省的时间和空间等。坦率地讲,对于仍处于蓝海增长阶段的互联网大厂来说,如果没有特别大的成本压力,增量数据优化完成后,存量数据的清洗往往难以被视为高ROI的事项。坐等存量数据自然冷却,也能被动取得一定的效果。反而要是清洗数以千亿计的存量数据不仅耗时漫长,还蕴藏着不可忽视的稳定性风险。快手近两年着重加强稳定性意识的建设,在当前业界稳定性事件频发的背景下,决定是否清洗存量数据实属不易。两年前,我们以服务Owner的角色,秉持追求极致和最高标准的理念,做出了推进存量数据清洗的决定。这一决策的前提是通过审慎的正确性验证过程和持续2个月的放量观察阶段,同时同事间对清洗代码的每一行都进行了认真负责的评审。整个数据清洗过程使用了一台16核的容器实例,并开启了数百个线程进行数据库IO操作。除了确保数据正确性外,还需要特别关注稳定性风险。例如,单库清洗速度不能过快,否则可能导致从库同步时的binlog延迟,以及下游监听消费服务的过载风险。Photo库共有100个分库,每个分库中包含10张分表。在数据清洗的初期,我们像林黛玉初进大观园一样,“处处小心,时时注意”。从下图可以看出,第202分库的10张分表花费了数天时间,通过严格控制清洗速度逐步完成。在完成近10个分库的清洗后,我们逐渐通过库间并行提速推进,最终用了近两个月的时间完成了全部清洗工作。
在清洗前几个分库时,我们曾考虑通过缩表操作(alter table xxx engine=innodb)来量化治理成果。初次尝试时,惊喜地发现单个分库缩小了17GB,但后续尝试其他分库时效果却不尽如人意,部分分库甚至出现了表空间不降反增的情况。这一现象令人困惑,最终我们放弃了缩表操作。尽管如此,清洗过程中在库文件中产生的“空洞”为新插入的数据提供了填充空间,从而在下图中形成了明显的拐点。上方分库的变化曲线更为平滑,这是因为在初期的小心求证阶段,清洗速度控制得较低。令人欣慰的是,困扰数日的缩表后空间不降反增的诡异问题在一年后被快手的MySQL内核研发组同事定位到了原因,且向mysql官方提交了bug并得到官方的确认复现。大致原因和上面的DDL语句执行过程中的增量数据优先插入有关,由于存量数据的主键id一般都小于缩表期间新增的增量数据主键id,后面的存量数据插入临时影子表时可能会不断地引发页分裂并且挪移已插入的增量数据从而形成新的碎片。由于这部分浪费的比例和每行的尺寸大小有一定的相关性,单行在3~5kB时该不增反降现象会比较明显,photo的单行大小又恰好处于这个区间,所以就出现了数据清洗完后缩表时概率性的不降反增现象。关于该问题的详见描述有兴趣的读者可以参考快手KSQL团队提交给mysql的Bug。
注:Bug链接:https://bugs.mysql.com/bug.php?id=113733
结合本次治理经验,我们将经验泛化到一般情况,从道法器术四个角度谈谈我们的思考。1、业界对于庞大用户的存量历史数据有什么好的应对办法?(道)首先,要从商业模式角度思考。如何区分高价值用户数据和低价值数据是关键。高价值数据应通过能够维持其存储成本的商业模式运行,例如会员制、续费制等,使存储和传输成本不再完全由平台单方承担。这对于处于维护期的大型产品或对存储成本敏感的垂类小平台尤为重要,有效的商业模式是维持业务正常运转的必要保障。快手目前所处的短视频赛道仍处于较热阶段,加之公司也通过拥抱AI和探索新业态来努力做到不使用户去分担这部分成本 ,但不可否认的是,技术成本正在逐年快速上升的事实。其次,从技术架构选型和迭代来看。互联网行业“小步快跑”的特点决定了业务初期需要快速落地验证商业模式,留给工程师仔细思考和分析架构特征的时间有限。当业务成熟、系统稳定后,架构师需要抓住时机进行重构和架构升级,以排除稳定性风险并提升系统效率。在存储领域,每隔5-10年往往会有新的中间件出现,快手的工程团队一直在探索更稳定高效的存储架构,同时快手也成立了专门的架构治理团队,横向推动多个领域的平台迭代和演化。另一个切入点是数据结构和算法的优化。本文的巧妙之处在于以极低的人力成本取得了堪比架构迭代的收益。我们希望本文能对业务一线的工程师有所启发,他们通常是组织内最贴近数据结构和高频算法的人。如果能够孜孜不倦地钻研业务和代码,即使面对接手过来“祖传”的老代码和算法,稍作调整也可能挖掘出巨大的价值。虽然不是大规模的架构升级,却也是新质生产力的有力注解。2、高QPS高容量的缓存集群命中率提升有哪些手段?(法)从时间维度来看,缩短冷数据在缓存中的驻留时间是一个可行的方向。常用的LRU/LFU等缓存淘汰算法天然具备对冷热数据分层的能力,但如果缓存集群的单个节点特别大,冷数据从加载到淘汰的最小逐出时间可能会变得不可忽略。通过预先设定分级的过期时间,可以有效缩短这一最小逐出时间,但这需要对具体的数据使用场景和频率进行深入调研和分析。从空间维度来看,减少冷数据在缓存中的占用空间是一个有效的方法。以Photo为例,某些冷数据召回场景仅需要用到Photo对象中的某几个字节的属性,但却将整个几KB的Photo对象加载到缓存中,并经历完整的最小逐出时间周期,这降低了缓存的空间利用效率。快手直播在类似场景下,独立部署了一套“高频不可变属性字段最小集”的架构设计方案,针对大量调用冷数据的主调方进行定制优化,有效降低了冷数据的缓存空间占用规模,从而提升了缓存命中率。这种方法虽然会使接入流程复杂化,但从长期来看,收益是值得的。本文正是从空间维度提升命中率的一次巧妙尝试。3、扩展字段为什么用json存储, 用protobuf是不是更有空间效率? (器)Protobuf的编码效率远高于JSON,但在并发修改时容易相互覆盖,且MySQL对PB的支持不如JSON成熟。PB + MySQL的组合更适合结构性开销比例大且编辑频率低的场景。因此,尽管PB在空间效率上具有优势,但在高并发和频繁修改的场景下,JSON仍然是更稳妥的选择。4、Photo 的扩展字段这么大,可以在DB里压缩存储吗?(术)实际上,Photo数据库已经在进行压缩存储,但由于早期MySQL版本不支持列压缩,压缩字典只能局限在行内,压缩效率无法充分发挥。快手MySQL团队在新引入的8.0版本中支持了列压缩功能,在类似场景下已经能够实现更高的压缩效率。从架构选型的角度来看,初期也可以直接考虑使用HBase等具有原生列压缩能力的存储层,以进一步提升存储效率。自移动互联网和4G、5G网络普及以来,已过去十多年,许多公司正面临海量UGC历史数据的挑战。如何让历史数据变为资产而非技术负债,将会成为是一道日益凸显的考题。尽管技术的进步会涌现出更高效的架构和产品,但对于庞大体量的业务进行架构迁移和升级本身的成本和风险也不可小觑,这就给贴近业务的工程师在现有的架构体系下发掘系统"低效率"的逻辑并加以优化提供了价值空间,操之得当便可有效延长现有系统的生命周期。对于工程师来说,寻找系统中的“跑冒滴漏”可以像探矿一样充满乐趣。如果能被幸运之神眷顾,探到一座"金矿",不但能为公司带来可观收益,工程师个人也能收获满满的成就感。但在探矿和开采过程中也要牢记本文开头引用图灵奖得主Donald Knuth的名言 ——"过早的优化是万恶之源",优化时机选取也很重要,对线上系统的每一步优化操作都要抱有"战战兢兢,如临深渊,如履薄冰"的心态,要时刻保持对系统稳定性的敬畏之心。一个拥有庞大用户量和访问量的平台能稳定运行多年,离不开优秀的架构设计,快手的架构设计便是如此。然而,如同飞机和汽车,再优秀的系统在长期运行后也会面临“老化”和“熵增”问题——低效存储、无用请求增多、缓存命中率下降、延迟上升等,都在消耗系统的生命力。本文描述的正是对抗熵增的过程,其巧妙之处在于通过细致的调研分析,以极低的人力投入,取得了显著的收益。相比于跨部门大规模架构迭代的"强渡大渡河"来说,本文故事称得上是一次"巧渡金沙江"。有奖互动:再优秀的系统在长期运行后也会面临“老化”和“熵增”问题!你所在公司是如何治理此类问题的?目前取得那些成果或经验?欢迎评论留言。我们将选取1条优质评论,送出快手小六公仔1个(下图随机一款)。评论截止6月6日中午12点。
如果您在阅读这篇文章后深感其价值,恳请您慷慨点赞。您的每一次认可和鼓励,都将成为我们不断前行的动力!
还没有评论,来说两句吧...