最早知道这部电影是在机核里看到了它的预告片,预告片里那浓浓的 80 年代画风和对白立马击中了我。如果你平日里是一个生活里的恶趣味爱好者,一个喜欢钻研一些看起来无用东西的人,亦或是一个经历过 80 年代的人,或者恰巧是「跟宇宙结婚」节目的忠实听户,那我强烈推荐你看看这部电影。
这部电影属于伪纪录片风格,导演孔大山 2015 年的另一部作品《法制未来时》可能更加为人所知,这是一个不到 10 分钟的短片,你可以通过这个片子提前感受一下导演的风格。
导演已经在很多场合公开表示过电影参考了《西游记》的人物关系和剧情内核,电影的英文名《Journey to the West》其实也提示得相当明显了。虽然是一部伪纪录片风格电影,但加上《西游记》的故事内核,让这部电影又有了一些公路片的味道。我们着迷于主人公唐志军追寻「答案」的过程,希望这个故事永远不要结束,但是电影毕竟需要一个结尾,不管你是否喜欢,电影都需要让主人公找到他心目中的那个答案。在表面看似荒诞的剧情中,《宇宙探索编辑部》给我带来的感受更多是一种与当今时代格格不入的凄凉,或许每个时代都有这样的事情在不断重演,这注定是属于一小部分人的宿命。
除了饰演唐志军的杨皓宇以外,令我印象深刻的还有饰演那日苏的蒋奇明,刚好我是先看的《漫长的季节》,因此对于能在这部电影中看到蒋奇明感觉甚是惊喜。
最后如果你对这部电影的一些幕后故事感兴趣,推荐你听一下「散场通道」播客对导演孔大山的一次访谈(点击这里收听)。
这部电影和《宇宙探索编辑部》还有些渊源,两部电影都参加过「平遥国际电影展」,也都获得了当年的「费穆荣誉最佳影片」,只不过《宇宙探索编辑部》是 2021 年获的奖,而《河边的错误》是 2023 年。
《河边的错误》改编自余华的同名小说,由于电影完全采用 16mm 胶片拍摄,因此在网上会看到类似「画面模糊、虚焦」的差评,但我在观看的过程中倒没有这方面的困扰,可能是由于我不是在大银幕观看的原因。
这部电影或许很容易被归类为「文艺片」,在影片气质上的确也有很浓烈的艺术片风格,特别是后半段对于主人公梦境的一些意识流表现。但是这部电影又不像很多传统文艺片那样没有明显的主线剧情,从影片开始到结尾其实都贯穿了一个悬疑案件,作为刑警队队长的主人公也在一步步带着观众去探寻「真相」。但我觉得「悬疑」并不是这部电影想要表达的核心(或者可能余华的原著也是如此?),更多是想展现大时代背景下不同人物的困境,某种意义上这部电影更像一个群像戏,「悬疑」只是吸引观众往下观看的一个引子。所以按照这样的理解,那其实所谓的「案件真相」也就变得不那么重要了。如此看来,《漫长的季节》其实也是这种类型的影片,只不过《河边的错误》的表达更加隐晦,这可能也是电影和剧集的区别。
最后如果你对这部电影的一些幕后故事感兴趣,推荐你听一下「散场通道」播客对本片摄影指导程马的一次访谈(点击这里收听)。听完这个访谈以后最让我震惊的不是胶片拍摄有多难,而是本片在后期剪辑时几乎一刀没剪,这可能也是一种实力的体现吧。另外本片导演魏书钧 2021 年的另一部电影《永安镇故事集》我其实也挺感兴趣,无奈暂时没有资源和渠道可以观看。
看电影名你可能已经猜到了,这部影片正是根据阿加莎・克里斯蒂著名的同名小说改编。和《河边的错误》这种「假悬疑片」不同,阿加莎的故事是一定不能被提前剧透的!因此如果你还没有看过这个故事,强烈推荐你先看看其它版本的《东方快车谋杀案》(或者直接看小说)。这部小说已经被多次改编成了电影,我最早看的是 1974 版,虽然看年代感觉会是一个很老的片子,但其实 1974 版可能是最好看的版本,导演是著名的西德尼・吕美特(代表作包括《十二怒汉》、《热天午后》)。
回到这部日本版《东方快车谋杀案》,和之前欧美国家拍摄的版本有什么区别呢?表面上最大的区别肯定是演员从欧美人换成了亚洲人,但这部影片的编剧「三谷幸喜」才是促使我有兴趣观看的原因。三谷幸喜是日本著名的编剧和导演,他的影片通常会归类为喜剧,但喜剧可能也只是他进行自我表达的一个外壳。如果你从来没有看过三谷幸喜的作品,那我推荐你先看看他的几个代表作品,比如《有顶天酒店》、《广播时间》、《魔幻时刻》、《大空港 2013》、《笑之大学》。
这部三谷幸喜改编版的《东方快车谋杀案》分为上下两部分(影片里称为「第一夜」和「第二夜」),上部是对阿加莎・克里斯蒂小说的完美还原,只不过故事和人物设定换成了更符合日本时代背景的风格,而下部则是三谷幸喜完全原创的内容,这也是为什么即使我已经知道了案件真相也能继续津津有味观看的原因。这部影片肯定算不上三谷幸喜最好的作品,但对于喜欢他的故事风格的观众来说会是一个看完以后足够满足的诚意之作。
这是一个属于普通人的平凡故事,没有大开大合,没有强烈的剧情反转,有的只是对于生活的平实描绘。虽然以女性为主角难免会被贴上「女性主义」的标签,但我觉得这部影片对于所有性别的人来说都能获得一些感悟。
影片的设定其实很简单,女主角是一个很喜欢拳击的聋哑人,而故事则围绕着她平常训练的拳馆徐徐展开。可能看到拳击会让人误以为这是一个体育题材的热血励志电影,但事实上这并不是一部讲述一个小人物如何成长并一步一步蜕变的「老套」剧情。反倒对我来说,这是一部温情的电影,女主角虽然不能通过言语表达,这反而让观众更加注意观察她的一举一动,进而体会到那些不经意间流露出的细腻情感。不刻意煽情是我觉得这部影片的优点,很多时候这种「不刻意」会比强行说教带给观众的感受更加强烈。
如果你对这部影片感兴趣,推荐你在一个安静的时刻和环境里观看。
其实很早就在豆瓣上标记了这部电影,但直到 2023 年才看与下面要推荐的「年度 Top 5 剧集」里的其中一部有关。演员阵容很强大,菅田将晖、有村架纯、小田切让、户田惠子、小林薰,甚至押井守都来客串了一把。编剧是著名的日剧编剧坂元裕二(代表作《东京爱情故事》、《最完美的离婚》、《四重奏》),而配乐是著名的音乐家大友良英。
我会把某一类爱情电影归类为「纯爱电影」,看完这类电影以后会让人产生强烈的「恋爱真美好」的感觉,而《花束般的恋爱》正是这样的电影,很多年前看的《和莎莫的 500 天》其实也属于这类电影。但相比《和莎莫的 500 天》,《花束般的恋爱》显然要更残酷一些。恋爱里有的不只是两个人轻松愉快在一起的时光,还有更多恋人们在恋爱之前根本不会考虑的事情发生。恋爱到婚姻是一个漫长的过程,可能年轻时还会把这两个概念等同,但随着年龄增长会逐渐认识到二者之间存在着巨大的鸿沟,只有成功跨越鸿沟的恋人才能长相厮守。
但无论如何,不管你的年纪多大,不管你是单身还是已经成家,我都推荐你看看这部电影,毕竟每个人的内心都一定怀有那份美好。
如果你喜欢看纯爱类型的影片,那我还推荐你看看 2022 年满岛光主演的日剧《初恋》。为了减少重复的题材,这部剧没有进入我的「年度 Top 5 剧集」,但依然非常推荐。
这是我 2023 年唯一一部在豆瓣上打了 5 星的剧集,即使已经看完过了很久,现在想来这部剧带给我的也还是很多的感动。
这部剧我是在叶子的推荐下才入的坑,剧集围绕 3 个梦想从事短剧行业的年轻人展开,每一集都是基于 1 个他们的短剧开始,然后讲述很多短剧背后的人物故事,这里面有友情,有爱情,有亲情。虽然看起来似乎是有点「老套」的日式剧情,但就像《惠子,凝视》的「不刻意煽情」一样,这部剧集也不是说教式地给观众灌输情绪,编剧以一种很巧妙的方式讲述了一个个故事,直到最后一集以一种反高潮但同时又带有希望的设定结束。
前面也提到了,我也是因为看了这部剧集里菅田将晖和有村架纯的表演,才产生了去看《花束般的恋爱》的想法。仲野太贺在这部剧集的表演也让人印象深刻(我最早是通过《我是大哥大》认识他的)。
这部剧完全是在听了重轻在机核的节目「《火线》导读」以后才去看的,这个剧集总共有 5 季,一口气补完需要不少时间,但好歹还是看完了。对我来说,5 季里并不是每一季都很精彩,不过总得来说《火线》是一部不容错过的作品。
《火线》的故事设定在美国的巴尔的摩(Baltimore),这个城市毒品盛行,白人和黑人的冲突不断,以抓捕头号毒贩为目标而临时组成的调查组把几个主角聚在了一起,故事由此而展开。当然等你看完整个 5 季以后会发现《火线》想要讲述的不仅仅是警察抓捕毒贩这么简单(如果你想看这类型的剧集,我更推荐看《毒枭》),它更多反映的是巴尔的摩这个城市,从富人到贫民,从政客到警察,从码头工人到法官,从学生到老师,从毒虫到毒贩,从街头侠客到新闻记者,所有人共同组成了巴尔的摩。在我看来,编剧大卫・西蒙(David Simon)作为一个在《巴尔的摩太阳报》工作了 12 年的记者,他的野心是想要借着缉毒这条线索,为观众完整展现他眼中的巴尔的摩。
可能正是由于大卫・西蒙的这个野心,《火线》与常规的美剧或者警匪片相当不同,没有紧张刺激的剧情,也不会故意制造悬念从而吸引观众一集一集往下看。对于习惯了普通美剧节奏的人来说,《火线》甚至会有点沉闷,你可能要看到第一季后半段才会开始对这个剧集感兴趣,这或许也是为什么《火线》第一季在豆瓣上虽然有着 9.4 的高分,但是只有不到 3 万人评价的原因(相比之下《权利的游戏》第一季有超过 40 万人评价)。
《重启人生》可能是 2023 年最出圈的日剧,没有之一(在豆瓣上有接近 28 万人评价,和《半泽直树》差不多)。对于日剧老粉来说,这部剧里可以看到很多熟面孔,而对于新粉来说在如此强大的演员阵容下也能看个热闹。
故事剧情应该也不用我做太多介绍了,说几个让我印象深刻和有趣的点(注意涉及关键剧透,介意请勿看):
除了正片之外,还有 1 个 9 集的《重启人生 番外篇》,每一集讲述了 1 个配角的小故事,也很值得观看。
《最后生还者》最初是顽皮狗(Naughty Dog)工作室 2013 年在 PlayStation 3(PS 3)平台上独占发行的游戏,后来相继发布了 PS 4 和 PS 5 平台的重制版。2020 年在 PS 4 平台上发布了第一代的续作《最后生还者 Part II》,PS 5 重制版也即将在今年 1 月发布。
如果要评选 PS 平台独占游戏的前三名,那《最后生还者》肯定会有一个位置,这也是我在 PS 4 平台接触的第二个游戏(第一个是《神秘海域》,同样来自顽皮狗工作室)。虽然这是一个动作冒险游戏,但最吸引我的还是整个游戏的故事设定,因此《最后生还者》是一个一定不能被剧透的游戏(所以如果你先看了剧集可能会影响游戏的游玩体验)。
2020 年 HBO 宣布将游戏改编成剧集,由《最后生还者》游戏的编剧尼尔・德拉柯曼(Neil Druckmann)和《切尔诺贝利》剧集的编剧克雷格・麦辛(Craig Mazin)共同担任编剧。作为游戏里最重要的两个角色乔尔(Joel)和艾莉(Ellie),在公布扮演者是佩德罗・帕斯卡(Pedro Pascal)和贝拉・拉姆齐(Bella Ramsey)以后,作为游戏玩家其实是相当失望的。虽然我很喜欢佩德罗在《毒枭》中的表演,但还是觉得这两位演员和游戏里的人物气质差得比较远,特别是艾莉的扮演者贝拉・拉姆齐,真的是一点都不像。而且基于过往的经验,游戏改编的影视通常都会扑街,所以对于这个剧集其实也没有抱太大期待,只希望能尽量还原游戏就够了。
然而等到剧集上线第一集以后,看完这集的我只能称赞 HBO 万岁。剧集对于游戏场景的还原度只能用逼真来形容,而之前最失望的选角在看完演员的表演以后不得不说「真香」。作为游戏玩家的彩蛋之一,之前在游戏中作为几个主要角色动作捕捉和配音的演员也参与了剧集的拍摄,例如乔尔和艾莉在游戏中的演员特罗伊・贝克(Troy Baker)和艾什莉・约翰逊(Ashley Johnson)在剧集中分别客串了两个角色。而游戏中的配乐也同样作为了剧集的配乐,当听到片头曲时一定会让每一个玩家兴奋不已。
目前 HBO 已经续订了第二季,由于第一季已经把《最后生还者 Part I》游戏的故事讲完,所以第二季应该会拍摄《最后生还者 Part II》的剧情,剧集预计会在 2025 年上线。
正如这部剧的剧名,剧集讲述了一个不想结婚(甚至是痛斥婚姻),一心沉迷于工作和自我世界的大龄单身男性的故事。男主角由阿部宽饰演,由于我在看这部剧之前刚刚看过同样由阿部宽主演的另一部日剧《龙樱》,在这两部剧里两个角色的风格形成了强烈反差,让人印象深刻。
看这部剧时,总是让我想起另一部日剧《最完美的离婚》。一个是不想结婚,一个是最终离婚,两部剧中以男性视角展现出的对于恋爱、婚姻的看法有些许共通之处。当然或许会有人觉得阿部宽饰演的这个男主角有些极端,在现实生活中不太可能出现,但我觉得编剧可能恰恰想在 2006 年这个时间点反映一些日本社会的现状。在一个已经高度发展的社会里,年轻人可能反而没有动力像父辈那样奋斗和前进,没有结婚的欲望,即使结婚了也不想生小孩,这才造成了日本的「少子化」现象,新生儿数也在逐年下降。这个话题其实放到当下的中国也是非常适合的。
就像《龙樱》在上线 16 年后推出了《龙樱 2》一样,这部剧也在上线 13 年时推出了续作《还是不能结婚的男人》。续作还是同样的编剧,同样的导演,同样的男主角,不过女主角们已经换成了新的演员。对于喜欢看第一部的剧迷来说,这个续作也是值得观看的。就我个人而言,我还是更喜欢第一部的演员们,特别是夏川结衣,虽然在这部剧中她和阿部宽最终没有组建家庭,但是在是枝裕和的《步履不停》里两人还是顺利成为了银幕伴侣,算是对粉丝的某种慰藉吧。
2023 年我也看了不少纪录片,有一段时间还特地找了不少 Netflix 的纪录片来看,我觉得很多时候真实影像的力量相比虚构故事可能会大很多。由于纪录片和虚构影视的差别,所以这里单独列举了一些我觉得值得观看的纪录片。
这是一部所有人都应该看的纪录片,我知道很多人可能会给这部纪录片贴上「女权」的标签,但在我看来事件主角伊藤诗织只是在追求一个人的基本权利,这也绝对不是只有日本社会才有的问题,我相信类似伊藤诗织的故事会不断在全世界发生,任何性别的人都可能承受和她一样的痛苦,仅仅是因为她敢于站出来发声而已。虽然有某种观点是一个好的纪录片不应该带有明显的倾向,但在看这部纪录片的过程中我几乎不可能做到共情「施害者」。
从 2017 年 9 月提起民事诉讼,到 2022 年 7 月最高法院最终判决伊藤诗织胜诉,将近 5 年的诉讼期,伊藤诗织战斗到了最后,也获得了应有的公正,但是对于施害者山口敬之的惩罚仅仅是赔偿 332 万日元(约 16 万人民币)而已,不得不让人叹息。更加讽刺的是,山口敬之也提起了一系列针对诽谤他个人名誉的诉讼,他还因此获得了 189 万日元(约 94000 元人民币)的赔偿。
因为这部纪录片,我又去看了伊藤诗织的著作《黑箱:日本之耻》。相比纪录片,书籍包含了更多的信息,还意外发现伊藤诗织和日本著名记者清水洁其实也有过接触。我是通过《桶川跟踪狂杀人事件》和《足利女童连续失踪事件》这两本书了解到了清水洁其人,对于像他这样的良心记者只能报以莫大的敬意,强烈推荐阅读。
这是叶子推荐看的纪录片,记录了 2022 年环法自行车赛中几支车队的故事。对于以前只在 CCTV-5 的体育新闻里听说过环法自行车赛的我来说,这项运动只能用陌生来形容,根本不知道比赛规则,也一个车手都不认识。但就像所有体育竞技一样,总是能给人带来振奋和激励,即使是门外汉的我也能看得血脉偾张。这时候比赛规则似乎就显得不那么重要了,透过屏幕观看的我似乎也已经置身法国的小镇或者山谷间,为每一个选手呐喊助威,甚至让我产生了以后一定要去现场看一次的愿望。
看完这个纪录片的时间是 2023 年 7 月 21 日,而 7 月 23 日恰好是 2023 年环法自行车赛的最后一个赛段,意犹未尽的我们继续观看了最后两个赛段(20 和 21 赛段)的直播。最让我印象深刻的并不是头号种子们的较量,而是第 20 赛段法国人蒂博・皮诺(Thibaut Pinot)在他退役前的最后一次环法比赛中拼尽全力,当他往山顶攀登时路两旁的家乡人民那山呼海啸般的喝彩,皮诺就像《出埃及记》里的摩西分开红海一样,用他的奋力前进开启了一条通往山顶的道路。此时的我们已经几乎热泪盈眶,也许这就是体育的精神吧。
世界上最高的山峰是什么?大家肯定都知道是珠穆朗玛峰(Mount Everest,也叫圣母峰),海拔 8848 米,无数人心目中此生一定要去征服的山峰。那如果问世界上最难攀登的山峰是什么呢?作为攀岩以及登山的门外汉,我一直也以为是珠峰,直到看了这部纪录片。
梅鲁峰(Meru Peak)和珠峰一样,也属于喜马拉雅山脉,位于印度北部。光看海拔,梅鲁峰其实不算高,最高处(南峰)只有 6660 米。但它的中央山峰(海拔 6310 米)被认为是喜马拉雅山脉中可能最难攀登的山峰,特别是那段被登山者称为「鲨鱼鳍(Shark’s Fin)」的路线。而这个纪录片就是对人类历史上首次成功攀登梅鲁峰中央山峰的完整记录。
成功登顶的 3 个人:康拉德・安克(Conrad Anker)、金国威(Jimmy Chin)、雷纳・奥斯托克(Renan Ozturk),都是职业的登山家。其中金国威可能在影迷中最为人熟知,他是 2019 年奥斯卡最佳纪录长片《徒手攀岩(Free Solo)》的导演之一,另一个导演伊丽莎白・柴・瓦沙瑞莉(Elizabeth Chai Vasarhelyi)是他的太太,他俩共同执导了不少热门纪录片,而本片《攀登梅鲁峰》也是一部夫妻共同合作的作品。当然金国威在本片中的身份也比以往他拍摄的影片更为特殊,他不仅要承担记录整个过程的任务,更为重要的他是登山小队中的一员。
攀登梅鲁峰的过程艰险而变幻莫测,这种被称为「阿尔卑斯式(alpine style)」的登山方式显著区别于更为大众熟知的「探险式(expedition style)」,后者对于攀登者的专业要求要小很多,更多是对体力和耐力的考验,典型的代表就是攀登珠峰。「阿尔卑斯式」需要攀登者自己携带所有装备,路线上没有固定的绳索和营地,并且需要精通各种攀岩、攀冰技术。金国威曾在采访中说 99.9999% 的人在看完这部纪录片以后估计都不会想要尝试亲自攀登梅鲁峰,只有极小比例、真正硬核的登山者才会有兴趣。
如果你看完这部纪录片仍然意犹未尽,那我推荐你再去看看《徒手攀岩》(如果你还没看过的话),以及由康拉德・安克主演、金国威参与拍摄的另一部纪录片《最狂野的梦想:征服珠峰(The Wildest Dream)》。
另一个有趣的话题是,Meru 还可以被翻译为「须弥山」,而须弥山是佛教、印度教、耆那教中最高的神山,代表宇宙的中心。
这是一个在第一代 Xbox 发布 20 周年之际,介绍 Xbox 如何诞生,并一步一步发展的纪录剧集。总共 6 集,每 1 集都有一个相对独立的主题。前 3 集都聚焦在第一代 Xbox 的诞生故事,第 4 集聚焦在《光环(Halo)》和 Xbox 360,第 5 集聚焦在 Xbox 360 主机著名的「死亡红环(Red Ring of Death)」事件(国内玩家社区俗称「三红」),第 6 集除了自嘲失败的第三代主机 Xbox One 以外,就基本是对现在微软游戏部门 CEO 菲尔・斯宾塞(Phil Spencer)的赞誉。
对我个人而言前 3 集是最有趣的,谁也没想到促使 Xbox 诞生的起点是索尼的 PlayStation 2。1999 年当索尼公开宣布 PS 2 主机时,市场宣传的定位已经从单纯的游戏主机变成了每个家庭必备的娱乐中枢,似乎有了 PS 2 以后人们的娱乐生活就不再依赖 PC 了,而 PS 2 的这个定位已经开始对微软的核心市场造成威胁。
但 Xbox 并不是一个自上而下由领导驱动的项目,反而由 4 个来自 DirectX 团队,在公司内名不见经传的工程师发起。DirectX 是 Windows 平台的多媒体 API 集合(主要是游戏相关),帮助开发者屏蔽底层硬件,以一种统一标准的接口快速开发游戏。DirectX 项目最初其实也是由 3 个工程师发起,早期的项目代号是「曼哈顿计划(Manhattan Project)」,他们用这个二战时的同名项目来展示想要用 PC 游戏取代以日本公司为代表的主机游戏的野心。而 Xbox 项目的 4 个发起者似乎继承了 DirectX 团队「叛逆」的基因,选择迎难而上,挑战横梗在微软公司内部的各个阻碍,并最终说服了比尔・盖茨和史蒂夫・鲍尔默启动这个投入巨大,但关乎微软公司未来的项目。
对于游戏玩家来说,还可以从这个纪录片中获取到一些当年的行业趣闻。比如最初微软并不想自己制造硬件,毕竟软件才是他们的强项,因此他们四处寻找硬件厂商合作但都被拒绝,最后甚至还想通过收购任天堂来达成目标,结果可想而知,任天堂坚定否决了微软的这个提议。因此微软被迫选择自己制造 Xbox 的硬件。
1969 年 7 月 21 日 02:56(UTC 时间),美国宇航员尼尔・阿姆斯特朗(Neil Armstrong)成为首个登陆月球的人类,这对于美国政府的「阿波罗计划(Apollo program)」来说是一个历史性的时刻,也成功兑现了约翰・F・肯尼迪(John F. Kennedy)1961 年在国会上做出的承诺。从阿姆斯特朗执行的阿波罗 11 号任务开始,随后的 5 次阿波罗任务也都成功将宇航员送上了月球。到 1972 年阿波罗计划终止,整个计划一共有 12 名宇航员在月球上行走,而这也是人类历史上至今为止仅有的登月人数。
2019 年是阿波罗 11 号任务的 50 周年纪念,在 NASA 和美国国家档案馆的帮助下,导演托德・道格拉斯・米勒(Todd Douglas Miller)发现了长期被遗忘的记录阿波罗 11 号任务的 70mm 胶片,以及超过 11000 小时从模拟录音转换过来的数字音频。就像发现沉睡多年的宝藏一样,米勒和他的团队将这些胶片全部数字化,对音频进行修复,将视频和音频同步,并最终剪辑成了这个时长 93 分钟的纪录片。米勒将所有观众带回到了 1969 年,以亲历者的视角重新经历了这段伟大的历史。
阿波罗计划终止的 45 年后,2017 年美国政府再次联合欧洲航天局、德国航空航天中心、日本航天局等 6 家机构,发起了新的登月计划「阿尔忒弥斯计划(Artemis program)」。如果一切顺利,人类将在 2025 年再次登月,或许这将开启人类探索太空的下一个篇章。
延伸观看:2022 年的纪录片《回到太空(Return to Space)》,导演是《攀登梅鲁峰》的金国威夫妇,讲述了 SpaceX 公司的一些故事,而 SpaceX 的星舰(Starship)是阿尔忒弥斯计划的重要组成部分。
这篇文章最初发表在 JuiceFS 官方博客,点击这里查看原文。
当提到文件系统,大部分人都很陌生。但我们每个人几乎每天都会使用到文件系统,比如大家打开 Windows、macOS 或者 Linux,不管是用资源管理器还是 Finder,都是在和文件系统打交道。如果大家有自己动手装过操作系统的话,第一次安装的时候一定会有一个步骤就是要格式化磁盘,格式化的时候就需要选择磁盘需要用哪个文件系统。
维基百科上的关于文件系统的定义是:
In computing, file system is a method and data structure that the operating system uses to control how data is stored and retrieved.
总结一下,文件系统管理的是某种物理存储介质(如磁盘、SSD、CD、磁带等)上的数据。在文件系统中最基础的概念就是文件和目录,所有的数据都会对应一个文件,通过目录以树形结构来管理和组织这些数据。基于文件和目录的组织结构,可以进行一些更高级的配置,比如给文件配置权限、统计文件的大小、修改时间、限制文件系统的容量上限等。
以下罗列了一些在不同操作系统中比较常见的文件系统:
上图是 Linux 内核的架构,左边 Virtual file system 区域,也就是虚拟文件系统简称 VFS。它的作用是为了帮助 Linux 去适配不同的文件系统而设计的,VFS 提供了通用的文件系统接口,不同的文件系统实现需要去适配这些接口。
日常使用 Linux 的时候,所有的系统调用请求都会先到达 VFS,然后才会由 VFS 向下请求实际使用的文件系统。文件系统的设计者需要遵守 VFS 的接口协议来设计文件系统,接口是共享的,但是文件系统具体实现是不同的,每个文件系统都可以有自己的实现方式。文件系统再往下是存储介质,会根据不同的存储介质再去组织存储的数据形式。
上图是一次写操作的请求流程,在 Linux 里写文件,其实就是一次 write()
系统调用。当你调用 write()
操作请求的时候,它会先到达 VFS,再由 VFS 去调用文件系统,最后再由文件系统去把实际的数据写到本地的存储介质。
上图是一个目录树的结构,在文件系统里面,所有数据的组织形式都是这样一棵树的结构,从最上面的根节点往下,有不同的目录和不同的文件。这颗树的深度是不确定的,相当于目录的深度是不确定的,是由每个用户来决定的,树的叶子节点就是每一个文件。
最右边的 inode 就是每个文件系统内部的数据结构。这个 inode 有可能是一个目录,也有可能是一个普通的文件。 Inode 里面会包含关于文件的一些元信息,比如创建时间、创建者、属于哪个组以及权限信息、文件大小等。此外每个 inode 里面还会有一些指针或者索引指向实际物理存储介质上的数据块。
以上就是实际去访问一个单机文件系统时,可能会涉及到的一些数据结构和流程。作为一个引子,让大家对于文件系统有一个比较直观的认识。
单机的文件系统已经能够满足我们大部分使用场景的需求,管理很多日常需要存储的数据。但是随着时代的发展以及数据的爆发增长,对于数据存储的需求也是在不断的增长,分布式文件系统应运而生。
上面列了一些大家相对比较熟悉或者使用比较多的分布式文件系统,这里面有开源的文件系统,也有公司内部使用的闭源产品。从这张图可以看到一个非常集中的时间点,2000 年左右有一大批的分布式系统诞生,这些分布式文件系统至今在我们日常工作中或多或少还是会接触到。在 2000 年之前也有各种各样的共享存储、并行文件系统、分布式文件系统,但基本上都是基于一些专用的且比较昂贵的硬件来构建的。
自 2003 年 Google 的 GFS(Google File System)论文公开发表以来,很大程度上影响了后面一大批分布式系统的设计理念和思想。GFS 证明了我们可以用相对廉价的通用计算机,来组建一个足够强大、可扩展、可靠的分布式存储,完全基于软件来定义一个文件系统,而不需要依赖很多专有或者高昂的硬件资源,才能去搭建一套分布式存储系统。
因此 GFS 很大程度上降低了分布文件系统的使用门槛,所以在后续的各个分布式文件系统上都可以或多或少看到 GFS 的影子。比如雅虎开源的 HDFS 它基本上就是按照 GFS 这篇论文来实现的,HDFS 也是目前大数据领域使用最广泛的存储系统。
上图第四列的「POSIX 兼容」表示这个分布式文件系统对 POSIX 标准的兼容性。POSIX(Portable Operating System Interface)是用于规范操作系统实现的一组标准,其中就包含与文件系统有关的标准。所谓 POSIX 兼容,就是满足这个标准里面定义的一个文件系统应该具备的所有特征,而不是只具备个别,比如 GFS,它虽然是一个开创性的分布式文件系统,但其实它并不是 POSIX 兼容的文件系统。
Google 当时在设计 GFS 时做了很多取舍,它舍弃掉了很多传统单机文件系统的特性,保留了对于当时 Google 搜索引擎场景需要的一些分布式存储的需求。所以严格上来说,GFS 并不是一个 POSIX 兼容的文件系统,但是它给了大家一个启发,还可以这样设计分布式文件系统。
接下来我会着重以几个相对有代表性的分布式文件系统架构为例,给大家介绍一下,如果要设计一个分布式文件系统,大概会需要哪些组件以及可能会遇到的一些问题。
首先还是以提到最多的 GFS 为例,虽然它在 2003 年就公布了,但它的设计我认为至今也是不过时的,有很多值得借鉴的地方。GFS 的主要组件可以分为三块,最左边的 GFS client 也就是它的客户端,然后就是中间的 GFS master 也就是它的元数据节点,最下面两块是 GFS chunkserver 就是数据实际存储的节点,master 和 chunkserver 之间是通过网络来通信,所以说它是一个分布式的文件系统。Chunkserver 可以随着数据量的增长不断地横向扩展。
其中 GFS 最核心的两块就是 master 和 chunkserver。我们要实现一个文件系统,不管是单机还是分布式,都需要去维护文件目录、属性、权限、链接等信息,这些信息是一个文件系统的元数据,这些元数据信息需要在中心节点 master 里面去保存。Master 也包含一个树状结构的元数据设计。
当要存储实际的应用数据时,最终会落到每一个 chunkserver 节点上,然后 chunkserver 会依赖本地操作系统的文件系统再去存储这些文件。
Chunkserver 和 master、client 之间互相会有连接,比如说 client 端发起一个请求的时候,需要先从 master 获取到当前文件的元数据信息,再去和 chunkserver 通信,然后再去获取实际的数据。在 GFS 里面所有的文件都是分块(chunk)存储,比如一个 1GB 的大文件,GFS 会按照一个固定的大小(64MB)对这个文件进行分块,分块了之后会分布到不同的 chunkserver 上,所以当你读同一个文件时其实有可能会涉及到和不同的 chunkserver 通信。
同时每个文件的 chunk 会有多个副本来保证数据的可靠性,比如某一个 chunkserver 挂了或者它的磁盘坏了,整个数据的安全性还是有保障的,可以通过副本的机制来帮助你保证数据的可靠性。这是一个很经典的分布式文件系统设计,现在再去看很多开源的分布式系统实现都或多或少有 GFS 的影子。
这里不得不提一下,GFS 的下一代产品: Colossus。由于 GFS 的架构设计存在明显的扩展性问题,所以 Google 内部基于 GFS 继续研发了 Colossus。Colossus 不仅为谷歌内部各种产品提供存储能力,还作为谷歌云服务的存储底座开放给公众使用。Colossus 在设计上增强了存储的可扩展性,提高了可用性,以处理大规模增长的数据需求。下面即将介绍的 Tectonic 也是对标 Colossus 的存储系统。篇幅关系,这篇博客不再展开介绍 Colossus,有兴趣的朋友可以阅读官方博客。
Tectonic 是 Meta(Facebook)内部目前最大的一个分布式文件系统。Tectonic 项目大概在 2014 年就开始做了(之前被叫做 Warm Storage),但直到 2021 年才公开发表论文来介绍整个分布式文件系统的架构设计。在研发 Tectonic 之前,Meta 公司内部主要使用 HDFS、Haystack 和 f4 来存储数据,HDFS 用在数仓场景(受限于单集群的存储容量,部署了数十个集群),Haystack 和 f4 用在非结构化数据存储场景。Tectonic 的定位即是在一个集群里满足这 3 种存储支撑的业务场景需求。
和 GFS 一样,Tectonic 也主要由三部分构成,分别是 Client Library、Metadata Store 和 Chunk Store。
Tectonic 比较创新的点在于它在 Metadata 这一层做了分层处理,以及存算分离的架构设计。从架构图可以看到 Metadata 分了三层:Name layer、File layer 和 Block layer。传统分布式文件系统会把所有的元数据都看作同一类数据,不会把它们显式区分。在 Tectonic 的设计中,Name layer 是与文件的名字或者目录结构有关的元数据,File layer 是跟当前文件本身的一些属性相关的数据,Block layer 是每一个数据块在 Chunk Store 位置的元数据。
Tectonic 之所以要做这样一个分层的设计是因为它是一个非常大规模的分布式文件系统,特别是在 Meta 这样的量级下(EB 级数据)。在这种规模下,对于 Metadata Store 的负载能力以及扩展性有着非常高的要求。
第二点创新在于元数据的存算分离设计,前面提到这三个 layer 其实是无状态的,可以根据业务负载去横向扩展。但是上图中的 Key-value Store 是一个有状态的存储,layer 和 Key-value Store 之间通过网络通信。
Key-value Store 并不完全是 Tectonic 自己研发的,而是用了 Meta 内部一个叫做 ZippyDB 的分布式 KV 存储来支持元数据的存储。ZippyDB 是基于 RocksDB 以及 Paxos 共识算法来实现的一个分布式 KV 存储。Tectonic 依赖 ZippyDB 的 KV 存储以及它提供的事务来保证整个文件系统元信息的一致性和原子性。
这里的事务功能是非常重要的一点,如果要实现一个大规模的分布式文件系统,势必要把 Metadata Store 做横向扩展。横向扩展之后就涉及数据分片,但是在文件系统里面有一个非常重要的语义是强一致性,比如重命名一个目录,目录里面会涉及到很多的子目录,这个时候要怎么去高效地重命名目录以及保证重命名过程中的一致性,是分布式文件系统设计中是一个非常重要的点,也是业界普遍认为的难点。
Tectonic 的实现方案就是依赖底层的 ZippyDB 的事务特性来保证当仅涉及单个分片的元数据时,文件系统操作一定是事务性以及强一致性的。但由于 ZippyDB 不支持跨分片的事务,因此在处理跨目录的元数据请求(比如将文件从一个目录移动到另一个目录)时 Tectonic 无法保证原子性。
在 Chunk Store 层 Tectonic 也有创新,上文提到 GFS 是通过多副本的方式来保证数据的可靠性和安全性。多副本最大的弊端在于它的存储成本,比如说你可能只存了1TB 的数据,但是传统来说会保留三个副本,那么至少需要 3TB 的空间来存储,这样使得存储成本成倍增长。对于小数量级的文件系统可能还好,但是对于像 Meta 这种 EB 级的文件系统,三副本的设计机制会带来非常高昂的成本,所以他们在 Chunk Store 层使用 EC(Erasure Code)也就是纠删码的方式去实现。通过这种方式可以只用大概 1.2~1.5 倍的冗余空间,就能够保证整个集群数据的可靠性和安全性,相比三副本的冗余机制节省了很大的存储成本。Tectonic 的 EC 设计细到可以针对每一个 chunk 进行配置,是非常灵活的。
同时 Tectonic 也支持多副本的方式,取决于上层业务需要什么样的存储形式。EC 不需要特别大的的空间就可以保证整体数据的可靠性,但是 EC 的缺点在于当数据损坏或丢失时重建数据的成本很高,需要额外消耗更多计算和 IO 资源。
通过论文我们得知目前 Meta 最大的 Tectonic 集群大概有四千台存储节点,总的容量大概有 1590PB,有 100 亿的文件量,这个文件量对于分布式文件系统来说,也是一个比较大的规模。在实践中,百亿级基本上可以满足目前绝大部分的使用场景。
再来看一下 Tectonic 中 layer 的设计,Name、File、Block 这三个 layer 实际对应到底层的 KV 存储里的数据结构如上图所示。比如说 Name layer 这一层是以目录 ID 作为 key 进行分片,File layer 是通过文件 ID 进行分片,Block layer 是通过块 ID 进行分片。
Tectonic 把分布式文件系统的元数据抽象成了一个简单的 KV 模型,这样可以非常好的去做横向扩展以及负载均衡,可以有效防止数据访问的热点问题。
JuiceFS 诞生于 2017 年,比 GFS 和 Tectonic 都要晚,相比前两个系统的诞生年代,外部环境已经发生了翻天覆地的变化。
首先硬件资源已经有了突飞猛进的发展,作为对比,当年 Google 机房的网络带宽只有 100Mbps(数据来源:The Google File System 论文),而现在 AWS 上机器的网络带宽已经能达到 100Gbps,是当年的 1000 倍!
其次云计算已经进入了主流市场,不管是公有云、私有云还是混合云,企业都已经迈入了「云时代」。而云时代为企业的基础设施架构带来了全新挑战,传统基于 IDC 环境设计的基础设施一旦想要上云,可能都会面临种种问题。如何最大程度上发挥云计算的优势是基础设施更好融入云环境的必要条件,固守陈规只会事倍功半。
同时,GFS 和 Tectonic 都是仅服务公司内部业务的系统,虽然规模很大,但需求相对单一。而 JuiceFS 定位于服务广大外部用户、满足多样化场景的需求,因而在架构设计上与这两个文件系统也大有不同。
基于这些变化和差异,我们再来看看 JuiceFS 的架构。同样的,JuiceFS 也是由 3 部分组成:元数据引擎、数据存储和客户端。虽然大体框架上类似,但其实每一部分的设计 JuiceFS 都有着一些不太一样的地方。
首先是数据存储这部分,相比 GFS 和 Tectonic 使用自研的数据存储服务,JuiceFS 在架构设计上顺应了云原生时代的特点,直接使用对象存储作为数据存储。前面看到 Tectonic 为了存储 EB 级的数据用了 4000 多台服务器,可想而知,如此大规模存储集群的运维成本也必然不小。对于普通用户来说,对象存储的好处是开箱即用、容量弹性,运维复杂度陡然下降。对象存储也支持 Tectonic 中使用的 EC 特性,因此存储成本相比一些多副本的分布式文件系统也能降低不少。
但是对象存储的缺点也很明显,例如不支持修改对象、元数据性能差、无法保证强一致性、随机读性能差等。这些问题都被 JuiceFS 设计的独立元数据引擎,Chunk、Slice、Block 三层数据架构设计,以及多级缓存解决了。
其次是元数据引擎,JuiceFS 可使用一些开源数据库作为元数据的底层存储。这一点和 Tectonic 很像,但 JuiceFS 更进了一步,不仅支持分布式 KV,还支持 Redis、关系型数据库等存储引擎,让用户可以灵活地根据自己的使用场景选择最适合的方案,这是基于 JuiceFS 定位为一款通用型文件系统所做出的架构设计。使用开源数据库的另一个好处是这些数据库在公有云上通常都有全托管服务,因此对于用户来说运维成本几乎为零。
前面提到 Tectonic 为了保证元数据的强一致性选择了 ZippyDB 这个支持事务的 KV 存储,但 Tectonic 也只能保证单分片元数据操作的事务性,而 JuiceFS 对于事务性有着更严格的要求,需要保证全局强一致性(即要求跨分片的事务性)。因此目前支持的所有数据库都必须具有单机或者分布式事务特性,否则是没有办法作为元数据引擎接入进来的(一个例子就是 Redis Cluster 不支持跨 slot 的事务)。基于可以横向扩展的元数据引擎(比如 TiKV),JuiceFS 目前已经能做到在单个文件系统中存储 200 多亿个文件,满足企业海量数据的存储需求。
上图是使用 KV 存储(比如 TiKV)作为 JuiceFS 元数据引擎时的数据结构设计,如果对比 Tectonic 的设计,既有相似之处也有一些大的差异。比如第一个 key,在 JuiceFS 的设计里没有对文件和目录进行区分,同时文件或目录的属性信息也没有放在 value 里,而是有一个单独的 key 用于存储属性信息(即第三个 key)。
第二个 key 用于存储数据对应的块 ID,由于 JuiceFS 基于对象存储,因此不需要像 Tectonic 那样存储具体的磁盘信息,只需要通过某种方式得到对象的 key 即可。在 JuiceFS 的存储格式中元数据分了 3 层:Chunk、Slice、Block,其中 Chunk 是固定的 64MiB 大小,所以第二个 key 中的 chunk_index 是可以通过文件大小、offset 以及 64MiB 直接计算得出。通过这个 key 获取到的 value 是一组 Slice 信息,其中包含 Slice 的 ID、长度等,结合这些信息就可以算出对象存储上的 key,最终实现读取或者写入数据。
最后有一点需要特别注意,为了减少执行分布式事务带来的开销,第三个 key 在设计上需要靠近前面两个 key,确保事务尽量在单个元数据引擎节点上完成。不过如果分布式事务无法避免,JuiceFS 底层的元数据引擎也支持(性能略有下降),确保元数据操作的原子性。
最后来看看客户端的设计。JuiceFS 和另外两个系统最大的区别就是这是一个同时支持多种标准访问方式的客户端,包括 POSIX、HDFS、S3、Kubernetes CSI 等。GFS 的客户端基本可以认为是一个非标准协议的客户端,不支持 POSIX 标准,只支持追加写,因此只能用在单一场景。Tectonic 的客户端和 GFS 差不多,也不支持 POSIX 标准,只支持追加写,但 Tectonic 采用了一种富客户端的设计,把很多功能都放在客户端这一边来实现,这样也使得客户端有着最大的灵活性。此外 JuiceFS 的客户端还提供了缓存加速特性,这对于云原生架构下的存储分离场景是非常有价值的。
文件系统诞生于上个世纪 60 年代,随着时代的发展,文件系统也在不断演进。一方面由于互联网的普及,数据规模爆发式增长,文件系统经历了从单机到分布式的架构升级,Google 和 Meta 这样的公司便是其中的引领者。
另一方面,云计算的诞生和流行推动着云上存储的发展,企业用云进行备份和存档已逐渐成为主流,一些在本地机房进行的高性能计算、大数据场景,也已经开始向云端迁移,这些对性能要求更高的场景给文件存储提出了新的挑战。JuiceFS 诞生于这样的时代背景,作为一款基于对象存储的分布式文件系统,JuiceFS 希望能够为更多不同规模的公司和更多样化的场景提供可扩展的文件存储方案。
]]>详细内容请访问:https://maybe.news/issues/sp-0。
]]>由于工作的原因,我的微信里已经累积了数量可观的微信群,微信好友数也噌噌往上涨,已经逼近 1000 大关1。日常工作很大一部分都是在微信中进行,因此很长一段时间我都是严重依赖微信客户端来工作的,准确说是桌面版的微信客户端,应该没人想用手机 app 来处理工作。但一直有几个问题困扰着我,这些问题其实在我之前在某家公司工作时也有过,当时这家公司的内部聊天工具也是微信2。具体来说有这么几个问题。
我对于微信的定位主要还是一个私人的聊天工具,因此我一直强烈反对用微信来工作。当年在知乎工作时就大力推广 Slack,虽然 Slack 在国内的体验一直不太好。就算不用 Slack,现在国内也已经有各种企业聊天工具可供选择。但为什么还是有很多人用微信来工作呢?很多时候不是不想选,而是没得选。我就属于这种情况,虽然公司内部以 Slack 沟通为主,但是与很多外部客户沟通就只能通过微信。
这个问题和上一个问题互相关联,本质上微信并不是为工作场景设计,因此它提供的很多功能是「多余」的,甚至是一种干扰,同时又「缺失」了很多在工作场景中需要的功能。举个例子,我希望把不同类型的沟通分组便于快速查找和消息隔离(比如客户群是一个分组,私聊是另一个分组),同时我还希望能够针对这些工作相关的聊天信息单独设置通知(比如在非工作时间关闭推送,因为大部分消息都是不需要立即回复的)。再比如一直被呼唤了很久的聊天历史记录及多设备消息同步,前几天还看到一个传闻说微信可能推出付费方案来帮你保存历史聊天记录4。
国内的大厂都有一个「坏毛病」,喜欢在一个应用里塞各种莫名其妙的功能,关于这一点已经在我之前的一篇文章中吐槽过。自认我不是一个自制力特别强的人,如果呈现太多难免分心,所谓眼不见心不烦,那些与工作沟通不相干的东西还是暂时不看比较好(或者说没必要非得在工作时间看)3。
用过 Slack 的人都知道,一个有开放 API、能够集成各种自动化组件的聊天工具将会变得非常强大,微信显然不是一个开放的应用,腾讯也不希望针对个人用户开放这样的功能。
作为一个依然怀揣 DIY 念想的中年人5,还是想靠一些外部工具来实现我的需求,如果没有现成的那就自己鼓捣一个。一个显而易见的事实是,微信官方肯定不提供 API,因此只能依赖民间力量。所幸经过不太长时间的搜索我发现了 Wechaty 这个开源项目,简单讲 Wechaty 做的事情就是提供由一群热心网友维护的非官方微信 API,当然现在 Wechaty 已经不仅限于支持微信,也支持很多其它聊天工具,但我关注的主要还是微信。
Wechaty 的一个核心概念是「Puppet」,你可以把它理解为一个代理,负责帮你和实际的聊天工具通信。同一个聊天工具可能有多种 Puppet 实现,比如微信,可以是基于微信网页版来实现,也可以是基于 Pad 版来,亦或是基于 Windows 版。因此你可以看到其实我们并不能完全脱离微信客户端,只不过通过把微信客户端封装起来用对程序更友好的方式呈现。目前我用的是基于微信网页版的 puppet。
需要注意的是,Wechaty 并不能让同一个微信帐号在多个客户端登录,也就是不能同时在 Wechaty 及常规的客户端中登录。另外由于微信的限制,不同的 puppet 实现可能存在功能差异,关于这一点后面会讲到。
既然有了 API,那就可以开开脑洞了。
首先所有微信消息都可以任意转发到任何聊天工具,因此我根据一些特定的关键词把所有与工作有关的消息都转发到了 Slack 上,再通过 Slack 中不同的频道来对消息进行分组管理。除了文本消息,微信还有一些其它类型的消息,如图片、文件,这些也可以转发到 Slack 上,只不过相比文本消息需要做一些特殊处理(如通过 Slack 的 files.upload
API 上传文件)。
一些特殊的字符或者内容也需要做特别处理,如微信表情、emoji、换行符、微信公众号文章,避免在 Slack 上看到一串 HTML 标签。微信还会自作主张把一些并不是 URL 的文本封装成 <a>
标签,导致一些显示问题,也需要单独处理。
接收到消息以后是否可以在 Slack 中直接回复呢6?当然可以,但这里就得依赖 Slack 的一些 API 来实现了,在经过一番研究和权衡以后我决定还是用最原始的 message
API 来实现,有点类似于早年的 IRC,通过某些关键词来触发机器人做特定的事情。举个栗子,当在一个 Slack thread 中发送以 re:
开头的消息时,就代表这是一条回复,会根据这个 thread 的父消息来判断具体回复给谁(某个人或者某个群)。也可以直接发送消息不依赖 Slack thread,我定义的语法是类似 to: [张三] XXX
这样的格式。这种方式相比直接回复消息稍微麻烦一些,但有一个好处是可以实现群发,也就是刚才命令中的接收者可以是多个。
如果想在回复的时候同时 @ 某个人呢?继续修改上面的命令,以 re:
为例,在后面可以加上你想要 @ 的人,也就是变成 re: @[张三]
这样的格式。
如果想发送图片或者文件呢?也没问题,Slack 的 file
对象包含很多信息,最主要的是 url_private
和 name
,有了这两个就可以把文件下载下来,然后通过 Wechaty 的 API 上传到微信。一个额外功能是 Slack 支持在一条消息中附带多个文件,也就能实现一次发送多个文件到微信。
怎么知道消息是否发送成功呢?感谢 Slack,每条消息都可以有 reaction,这些 reaction 其实就是 emoji,因此我们可以根据不同的发送状态添加不同的 emoji,比如发送成功就用一个绿色的勾,发送失败就用一个红色的叉。
至此基本的聊天功能其实已经实现了,剩下的就是一些高级功能,比如搜索联系人、搜索群、修改联系人的备注、修改群名称、邀请人入群、查看群成员等,这些也都可以通过 Wechaty 来实现。
甚至可以脑洞再开大一些,女儿的学校老师每天都会发一些当天的照片和视频到微信群里,这些内容我都想永久保存下来。常规的做法只能是通过微信客户端手动一个一个下载,如果是在手机上保存的,可能还需要再备份到远端的存储。有了 Wechaty 以后我可以直接将这些文件自动保存都家里的 NAS,并且可以根据日期自动归类整理。
说完能做什么,我们来说说不能做什么。首先最大的前提是任何功能都一定是限定在微信可以实现的假设之下的,常规微信客户端没有办法做到的你也不能做到,比如通过某种方式加一个好友并且在微信中隐藏。
其次某个功能是否能实现就取决于你用的具体是哪个 puppet,拿我用的微信网页版 puppet 为例,就没有办法通过群加好友,以及某个人通过了你的好友申请以后只能重启程序才能看到这个新好友。每次重启以后也不是所有微信群都能看到,只能看到近期活跃的群。
需要注意的是 Wechaty 社区还提供了一些 Puppet Service,这些服务都是必须付费才能使用的,好处是能实现某些免费 puppet 不能实现的功能。但是出于隐私安全7以及灵活性的考虑,我没有使用这些服务。
最后讲讲不要用来做什么,技术可以推动进步,也可以成为「害人」的工具。你的所有行为都不能违反微信对于一个正常用户的判定,否则你将会面临微信对你的惩处,请遵守一个普通用户应有的行为规范。
P.S. 最新一期 Maybe News 已经鸽了很久了,其实内容很早就已经确定下来,只是一直没有完工,在这里就不做承诺,只希望能够尽快完成。
]]>从本期开始 Maybe News 将会启用新的域名和网站:https://maybe.news,个人博客依然会继续更新,不过不会全文转载。如果你已经订阅过那么不需要做任何事情,也欢迎访问新站点进行订阅。
本期关键词:Tectonic、Redlock、Flexible Paxos、明天的盐。详细内容请访问:https://maybe.news/issues/10。
]]>首先讲讲论文阅读。我阅读论文的经历最早应该追溯到本科上学的时候,当时因为毕业设计的需要读了 Bigtable 的论文,并照猫画虎用 Go 写了一个现在只能称之为玩具的实现。后来工作中断断续续也会听说一些好的论文,但并没有系统地去阅读和整理。最近几年的一份工作因为和 AI 有关,才开始比较频繁地接触业界的论文,并有意识地收集一些我认为比较好的论文。收集的渠道其实可以有很多种,最直接的就是去看每年各个顶会的网站,上面都会列出今年已经接受的论文,类似 OSDI 这种会议还会直接附带 PDF 链接,节省了不少找论文的时间。另外每篇论文最后的「引用」其实也是一个发现宝藏的好场所,一般被好论文引用的论文也都不会差,如果你阅读的都是同一个领域的论文会发现有些论文是会被反复频繁引用的。The Morning Paper 是我最喜欢的博客之一,作者 Adrian Colyer 目前是 Accel 的合伙人,之前是 Pivotal、VMware 和 SpringSource 的 CTO,去年疫情期间停止更新了一段时间,后来恢复了每月更新 2~5 篇的节奏,不过现在又停更了(非常悲痛)。国内的话 D 神的「面向信仰编程」博客也是一个不错的地方,不过相比之下论文数就少了很多。
正如各位所看到的,我会固定在每一期 Maybe News 的开头推荐一篇我近期阅读的认为比较好的论文,我关注的领域也基本围绕在分布式系统、数据库、大数据、AI 这几块。这些论文有新有旧,毕竟逃下的课还是得补上的。每次阅读其实都会有很多疑问,如果有机会的话我会尽量联系上作者寻求答案(比如最新的第 9 期),或者去翻阅作者以前写的相关论文,如果是开源软件也会去社区或者代码里找找。阅读特别是精读一篇论文是需要非常专注的环境的,我现在比较习惯于在上下班的地铁上阅读,用的设备是一台 6 寸的 Kindle。可能会有人觉得看论文是不是最好用大屏,但根据我的体验 6 寸 Kindle 除了在滑动的时候卡一点以外其它都还好,并且这个尺寸和重量在便携性上是非常有优势的,如果换成一台 10 寸或者更大尺寸的平板就没有这么方便了。为了完成一篇论文的笔记,这篇论文会被至少阅读两遍,多的时候读上四五遍也是有可能的。
邮件订阅是我另一个比较重要的阅读来源,日常订阅了如 Golang Weekly、KubeWeekly、SRE Weekly、Data Science Weekly 等。这些内容我并不一定会立即查看,大部分时候它们都是呆在我的收件箱里1。一般我会定期去查看,如果看到感兴趣的内容会先放到 Instapaper 里(或者类似的 Read It Later 服务)。放到 Instapaper 里有几个好处:首先如果时间来不及可以不用立即都看完,以前我有一个习惯是没看完的浏览器 tab 就这样一直开着,时间长了会发现浏览器里保留了很多 tab,不仅影响机器性能查找的时候也很不方便;另外就是 Instapaper 可以作为一个统一的归档,便于搜索曾经看过的内容;最后我可以用任何我喜欢的阅读器在任何设备上阅读,现在我用的是 Reeder,支持 macOS 和 iOS,支持 RSS 订阅,也支持包括 Instapaper 在内的多种服务,这样很多碎片时间也可以用来阅读文章。不过 Reeder 有一个需要吐槽的地方在于不能记住文章的阅读位置,这样当我下次打开的时候有可能需要再重新滚动到上次阅读的地方。除此之外,Reeder 是一个体验非常棒的工具,我从最早的版本一直用到现在的 Reeder 5。
微信公众号是一个比较特别的存在,不知道从什么时候开始,大家都喜欢通过公众号来发布消息和文章。如果说 Web 2.0 的代表之一是博客,那 Web 3.0 可能就是微信公众号2?但我一直认为微信公众号是违背万维网3精神的,或者说整个移动互联网都是,也有人说万维网早就已经死了4。我曾经尝试过运营一个自己的公众号,基于种种原因目前已经废弃5。一些匪夷所思的限制使得在公众号里撰写和阅读文章变得极不愉快,比如不支持任何形式的外链、发布后不能编辑6、强制需要一个头图、每天推送次数限制、阅读过程中没有消息通知7、在桌面端没有一个好的阅读入口8、封闭的生态9。在我看来公众号并不适合深度阅读,更适合发布一些时效性的消息10,但确实有一些好的内容生产者选择了用公众号作为唯一的发布平台。因此很长一段时间关于怎么比较舒适地阅读公众号文章成了困扰我的问题11,直到最近在看一篇文章介绍如何自己搭建一个公众号的 RSS 服务时,知道了一些可以转成 RSS 的方法。本来也想照着弄一个,但是因为老年人比较懒,不太想 DIY,于是试用了文章中提到的付费服务 WeRSS。经过一段时间的体验,除了文章更新有一定延时12以及看不到评论以外,其它都没啥问题。目前我还是会继续用微信来阅读部分公众号,但基本仅限于时效类的消息,让微信回归聊天的本质而不是一个啥都往里塞的大杂烩。
Google Reader 虽然死了,但是 RSS 还活着。作为 Web 2.0 时代的「遗老」,我依然保留了用 RSS 的习惯。前面提到微信公众号文章目前我已经是通过 RSS 的方式来阅读,除此之外我还订阅了一些感兴趣的至今依然在更新的博客。但总的来说,用 RSS 的频率已经比以前少了很多。
自高中毕业以后,我阅读纸质书籍的频率就开始大幅降低(大学的专业书籍除外),以前保持的每月固定购买期刊杂志的习惯也没能得以延续,豆瓣的读书列表也还停留在 2018 年。归根结底可能还是没有养成定期读书的习惯13,相比之下我的另一个朋友在毕业多年以后依然保持着每年阅读一定数量的书籍。看了下家里书架上的藏书,基本包含这么几类:专业工具书、金融、装修、小说、漫画、吉他谱。最近在蹭老婆的漫画书看,是大友克洋的《阿基拉》,虽然已经看过动画,但感觉漫画的内容更加丰富。这里就不立什么 flag 了,有机会的话还是希望以后尽量多看书,把高中的习惯重新捡起来。
除了阅读,最近几年也养成了听播客的习惯,关于这个可以以后再说,如果你喜欢欢迎订阅我的博客。
]]>「Maybe News」是一个定期(或许不定期)分享一些可能是新闻的知识的系列文章,名字来源于我非常喜欢的一个国内的音乐厂牌「兵马司」(Maybe Mars)。你也可以通过邮件订阅它。
一致性协议是分布式系统领域的核心概念,在之前介绍如何设计一个分布式索引框架的一篇文章中已经简单梳理了一致性协议的不同类别以及一些经典的共识算法。今天要介绍的这篇论文并不是发明了一种新的一致性协议,而是对一致性协议进行了拆解和抽象(即论文标题中 Virtual Consensus 的含义),这个抽象可以简化分布式系统的开发和加快迭代速度。Delos 目前已经作为 Facebook 集群调度管理系统 Twine 的控制平面(control plane)在生产环境中稳定运行数年(Delos 是希腊 Cyclades 群岛中一个岛屿的名称,离 Paxos 岛不远)。本篇论文发表在 2020 年的 OSDI 会议,并获得了当年的最佳论文。
在介绍 Delos 系统之前先讲讲为什么要有 Virtual Consensus 这个概念。现有的分布式系统通常都是基于某一种一致性协议来开发的,比如 ZooKeeper 是基于 Zab、etcd 是基于 Raft、Chubby 是基于 Paxos。同时学术界对于一致性协议的研究并没有中止,不断会有新的协议出现(大部分可能都是基于 Paxos 进行改良)。因为这些系统的分布式协议层和状态复制层是紧耦合的,如果想要从一种一致性协议切换到另一种,基本上是需要重写系统的(当然很多人也喜欢重复造轮子)。时间回溯到 2017 年,当时的 Facebook 需要一个保证一致性、可用性、持久性的分布式系统作为他们的核心控制平面来存储元数据,这个系统要在 6-9 个月之内上线,并且支持持续的迭代。虽然当时已经有一些现成的系统可以选择,比如 ZooKeeper、ZippyDB、UDB(分布式 MySQL)、LogDevice,但这些系统要么不支持复杂的 API(比如 get、put、multi-get、multi-put、scan 等),要么可用性达不到要求。更重要的是每个系统都和某种一致性协议绑定,没法平滑过渡到新的协议。
为了解决上述问题, Delos 提出了 Virtual Consensus 这个概念(以下简称 VC),VC 的目标是可以平滑切换一致性协议(甚至同时使用多种协议),并把一致性协议中很多可以复用的逻辑抽出来,从而降低实现新协议的成本。 VC 把一致性协议分解为了控制平面和数据平面两部分,控制平面负责 leader 选举、配置参数、成员管理等,数据平面负责保证数据的顺序(ordering)以及持久性。在 VC 中控制平面称作「VirtualLog」,这是可以复用的部分;数据平面称作「Loglet」,Loglet 可以用不同的一致性协议(比如 Raft、Paxos)实现,也可以是对其它分布式系统(比如 ZooKeeper、HDFS)的封装。如同虚拟内存一样,VirtualLog 把多个物理的 Logets 映射到一个连续的虚拟的 VirtualLog 地址空间中。对 VirtualLog 的用户来说,这些 Loglets 的细节被 VirtualLog 隐藏了起来 ,看起来就像是一个连续的 log。
VirtualLog 由两部分组成:一个客户端层暴露访问 log 的 API,以及一个管理元数据的 MetaStore。这里的元数据是指从 VirtualLog 地址到 Loglet 的映射关系,多个 Loglets 组成了一个链(chain),类似一个单向链表。随着时间推移,链可能会发生变动(比如从 1 个 Loglet 变成 2 个),MetaStore 在保存每个链时也会有一个对应的版本号,每当需要更新链时需要提供一个更大的版本号,这个更新操作是一个类似 CAS(compare-and-swap)的过程,会和当前版本号进行比较,保证原子性,避免并发冲突。当需要修改链时就会触发「重新配置(reconfiguration)」流程,这个流程分为 3 步:密封(sealing)当前链、把 MetaStore 中的链修改为新的链、从 MetaStore 获取新的链。MetaStore 需要保证容错一致性(fault-tolerant consensus),也就是说得通过类似 Paxos 这样的一致性协议去实现,后面会讲到具体如何实现。
因为 VirtualLog 已经负责了控制平面的工作,Loglet 的职责就比较简单了,只需要保证数据的全局顺序性(total order)以及持久性,不需要承担 leader 选举、成员变更管理等工作,也不需要实现容错一致性,因此实现一个 Loglet 的成本非常低(相比实现一个完整的一致性协议)。但有一个要求必须满足,前面提到在重新配置时需要密封链,这个密封操作是通过发送 seal
命令给 Loglet 实现的,Loglet 负责更新本地的 seal 状态,seal 状态是一个布尔值,也就是说只会有两种取值。为了保证后续不会错误地把新数据添加到已经密封的链,Loglet 需要保证 seal
命令是高可用的(highly available),也就是说确保大多数(quorum)的 Loglet 都更新完成。
在了解完 VC 的概念以后来看看 Delos 是如何实现的。Delos 是一个基于 VC 思想实现的分布式存储系统,可以简单把它理解成类似 etcd、ZooKeeper 的系统。Delos 分为 3 层:API、Core 和 Loglet。API 层提供多种类型的接口,例如 table API、ZooKeeper API;Core 层除了包含 VirtualLog 以外,还有 Delos 自己的 runtime,以及基于 RocksDB 的本地存储;Loglet 层就是各种不同的 Loglet 实现。当 Delos 接收到读写事务(read-write transaction)时会先将数据追加(append)到 VirtualLog,VirtualLog 负责把数据发送给底层的 Loglet(具体 Loglet 怎么处理 append 请求后面会讲),然后 Delos 开始从 VirtualLog 同步(synchronize)日志,每当读到一个事务(这个事务既可能是当前 Delos 节点也可能是其它节点写的)就会执行这个操作并保存到本地的 RocksDB 中,最后当把当前 Delos 节点自己写入的最后一个事务执行完毕后就返回请求。对应的,Delos 接收到只读事务(read-only transaction)时,首先通过 VirtualLog 检查全局日志的末尾(tail)位置是多少,然后与 VirtualLog 同步日志直到达到上一步得到的位置(同步过程中如果遇到写事务就需要更新 RocksDB),最后在本地的 RocksDB 中执行这个读请求并返回结果。可以看到,当处理只读事务时 Delos 节点可能需要与底层 Loglet 同步日志,这显然会对读性能造成影响,为了优化这一步 Delos 会在与 VirtualLog 的一次同步请求中包含多个只读事务,而不是一次同步请求只处理一个事务,极端情况下如果需要同步的日志中不包含任何写事务那其实可以跳过这一步。
前面提到 VirtualLog 需要一个 MetaStore 来保存当前的链状态,Delos 最初是通过外部的 ZooKeeper 服务来作为 MetaStore。后来为了去掉对外部系统的依赖改为了在 Delos 服务中内嵌一个 MetaStore,多个 Delos 服务便构成了一个 MetaStore 集群,并通过 Paxos 来保证容错一致性。
Delos 目前已经实现了 5 种 Loglet:ZKLoglet 基于 ZooKeeper 实现,这是 Delos 最初上线时使用的 Loglet;LogDeviceLoglet 基于 LogDevice 实现;BackupLoglet 基于类似 HDFS 的系统实现,目的是作为日志的冷存;NativeLoglet 顾名思义是最原生的 Loglet,只包含最必要的功能,不依赖任何外部系统,也是目前 Delos 在使用的 Loglet(替代 ZKLoglet);最后是 StripedLoglet,这个 Loglet 其实是多个其它 Loglet 的组合,可以有多种组合方式。论文中详细介绍了后两种 Loglet。
NativeLoglet 由两个组件组成:LogServer 和 Sequencer,这些组件都是独立的服务,并且和 Delos 服务部署在一起(当然也可以分开)。LogServer 负责处理有关日志的各种请求,并将日志持久化到本地磁盘,LogServer 可以有多个,且需要为奇数个(因为涉及到类似投多数票的场景);Sequencer 只有 1 个,职责也只有一个,就是处理 append
请求,后面会详细介绍处理流程。
这里有几个概念需要先介绍一下。当说到一个命令被「本地提交(locally committed)」时表示的是一个 LogServer 已经将这个命令同步到了它的本地日志中。当说到一个命令被「全局提交(globally committed)」时表示这个命令已经被大多数的 LogServer 本地提交,并且这个命令之前的所有命令也都已经全局提交。「全局末尾(global tail)」表示最近一个还没有被全局提交的日志位置,NativeLoglet 的日志是没有空隙的,也就是说从位置 0 开始到全局末尾结束中间的每一个位置都有日志数据。同时每个组件(LogServer、Sequencer、Delos 服务等)都维护了一个 knownTail
变量,这个变量保存的是当前这个组件获取到的全局末尾的值,这些组件之间在交互时都会带上各自的 knownTail
,如果发现自己本地的值更小就更新到最新的值。
接下来讲一下 NativeLoglet 支持的几种请求。首先是 append
请求,Delos 服务会将请求发送给 Sequencer,Sequencer 会给每个命令分配一个位置(类似一个计数器),然后 Sequencer 负责转发命令给所有 LogServer,当大多数 LogServer 都正常返回时即表示请求成功。如果大多数 LogServer 都返回说已经密封了,请求会失败。除此之外的其它情况(比如超时、LogServer 请求失败等)Sequencer 都会不断重试直到请求成功或者 NativeLoglet 被密封,重试是幂等的,也就是说同样的命令一定会写入同样的位置。Sequencer 维护了一个请求队列,当收到大多数 LogServer 的返回时即认为这个命令已经全局提交,那些发送给还没有返回结果的 LogServer 的请求就可以忽略了,这样处理可以防止某些特别慢的 LogServer 影响整体性能,同时从这里也能看到每个 LogServer 保存的命令可能是不一样的,对于那些缺失的命令也不需要补上。
然后是 seal
请求。正如前面所介绍的,这个请求是用于密封 Loglet。任何客户端都可以发送这个请求给 LogServer,当大多数 LogServer 都正常返回时即表示请求成功,后续的 append
请求也会失败。需要注意的是当 LogServer 被密封以后,每个 LogServer 本地日志的末尾(tail)可能是不同的。
接着是 checkTail
请求。这个请求会返回全局末尾以及当前 NativeLoglet 的密封状态。任何客户端都可以发送这个请求给 LogServer,并等待大多数 LogServer 返回。一旦大多数 LogServer 返回,checkTail
请求会根据返回结果进行不同的后续处理,这些返回结果会有 3 种可能:全部密封、部分密封、全部未密封。「全部密封」即所有 LogServer 都已经处于密封状态,返回结果中同时也会包含每个 LogServer 的末尾位置,前面介绍 seal
请求已经提到不同 LogServer 密封后的末尾可能是不同的,当 checkTail
发现这种情况时会触发一个修复(repair)操作,对那些有漏掉命令的 LogServer 进行补全(通过从其它 LogServer 拷贝数据过来)。「部分密封」即返回结果中同时存在密封和未密封两种状态,针对这种情况客户端会先发送 seal
请求给 LogServer,然后再次发送 checkTail
,一般情况下都会得到全部密封的结果。「全部未密封」即所有 LogServer 都处于未密封状态,此时客户端会从所有 LogServer 返回的末尾位置中挑选最大的那个值,然后等待它自己的 knownTail
达到这个值(论文中并没有具体介绍客户端的 knownTail
是如何更新),如果等待过程中有 LogServer 被密封了就会变成「部分密封」的状态,此时就会按照刚才描述的流程进行处理。
最后是 readNext
请求。客户端已经可以通过 checkTail
获取到全局末尾,因此它会根据这个末尾位置首先请求本地的 LogServer(前面提到过 LogServer 和 Delos 服务是部署在一起的),如果本地的 LogServer 没有这个位置的数据,就会再发送请求给其它 LogServer。
以上就是 NativeLoglet 的设计,因为它不需要具备类似容错一致性这样的特性,Facebook 仅仅用了 4 个月就实现完成并部署到生产环境。当然 LogServer、Sequencer 都有可能出问题,这些错误的检测既有一些内部机制来保证,也有一些外部监控系统来触发。一旦发现问题就会进入重新配置流程,即密封现有 Loglet,创建新的 Loglet 并更新链。
StripedLoglet 是多个 Loglet 的组合,实际上只用了 300 行左右的代码就实现完成。Loglet 的组合可以有多种方式,比如为了避免单个 Sequencer 成为瓶颈,可以把相同的一组 LogServer 看作不同的 NativeLoglet 集群,区别在于不同集群的 Sequencer 不一样;再比如为了对 Loglet 进行分片,达到横向扩展的目的,可以用多组 LogServer,每组只存储部分日志。StripedLoglet 目前并没有在实际生产中使用,更多是作为评估阶段的一个方案。
到这里本篇论文中关于 VC 和 Delos 的介绍就差不多讲完了,但其实还有很多细节没有提到,例如 leader 如何选举、如果处理网络分区、集群成员如何变更、为什么要有密封操作、具体什么时候触发重新配置、如何实现一个 RaftLoglet。Delos 其实是建立在论文第一作者 Mahesh Balakrishnan 多年的研究之上,在来到 Facebook 之前他曾经在微软硅谷研究院以及 VMware 研究院工作,Delos 的很多思想来源于 2012 年的「From Paxos to CORFU: A Flash-Speed Shared Log」这篇论文(一个小八卦,这篇论文的第一作者 Dahlia Malkhi 也是学术界大牛,目前是 Diem Association 的 CTO),以及后续多篇论文。有兴趣的同学可以继续阅读这些论文,另外 OSDI 每篇论文都会有一个来自论文作者的演讲,可以点击这里查看视频。
最后特别感谢 Delos 作者之一 Chen Shen 对本文提出的细致及宝贵的修改意见。
在支持 table API 并成功在 Twine 上应用之后,Delos 的下一个目标是提供 ZooKeeper API,并替代 Facebook 内部使用 ZooKeeper 的场景。Delos 目前已经成为了一个构建分布式数据库的「平台」,基于这个平台实现不同的 API 即可满足不同的场景需求,大部分逻辑都可以复用,比如支持 table API 的数据库 DelosTable,支持 ZooKeeper API 的数据库 Zelos。把 Delos 平台化以后也能让不同团队更加专注于各自的目标,提高开发效率(软件工程第一准则「高内聚低耦合」)。不过理想归理想,现实归现实,在 Zelos 的开发过程中逐渐发现组件的边界并没有那么清晰,很多时候 Zelos 需要依赖对 Delos 平台核心进行修改,使得开发流程受到了很大阻碍。为了解决这个问题,Delos 团队发明了一个新的抽象叫做「log-structured protocol」(LSP?),一层叠加在 VirtualLog 之上的协议,每个协议都会包含一个引擎(engine),且可以像网络栈一样把不同协议进行组合。当应用在请求时,会自上而下经过不同的协议,每一层协议也能带上自己的协议头(header),最终到达 VirtualLog。这个设计有点类似于把 Delos runtime 插件化,不同团队可以根据自己的需求开发不同的插件,这些插件也可以同时被不同团队共享。
在第 7 期介绍过 Horovod 背后的 Ring Allreduce 算法,从 v0.20.0 开始 Horovod 从框架上原生支持了模型训练弹性伸缩。不管是基于 PS 还是 Ring Allreduce 架构,弹性伸缩一直是分布式模型训练领域的热门问题,特别是对于一个 AI 平台来说,弹性伸缩能最大程度提高集群的资源利用率。当然为了实现弹性并不是说能够动态扩缩容就好了,框架也要做到足够好的容错性,才能不对训练效果造成影响。这篇 RFC 详细介绍了 Elastic Horovod 的设计背景及架构。目前阿里云容器团队开源的 et-operator 已经支持在 K8s 中使用 Elastic Horovod,通过 TrainingJob
、ScaleIn
和 ScaleOut
这 3 个 CRD 来动态控制训练集群的规模,不过 et-operator 暂时还不支持自动伸缩,社区有一个 PR 正在解决这个问题。
OpenAI 团队在这篇文章中分享了他们是如何将 K8s 扩展到 7500 个节点,在 2018 年其实他们已经分享过扩展到 2500 个节点的经验。需要注意的是,OpenAI 的 K8s 集群主要服务于 AI 任务,每个 pod 会独占一个 node 的所有资源,类似资源碎片、bin-packing 这样的问题都不存在,调度器并不会成为瓶颈,因此他们的经验不一定适用于所有场景。几个有趣的地方:当集群规模到达 7500 节点时,API server 占用大约 70GB 的内存;尽量避免任何 DaemonSet 与 API server 进行交互,如果有需要可以借助一个中间的缓存服务;kube-prometheus 收集了很多有用的数据,但是其中有很多对于 OpenAI 来说都是没用的,因此他们选择通过 P8s rules 来丢弃一些指标;P8s 经常性出现 OOM,最终定位到问题出在 Grafana 和 P8s 之间的交互,解决方法是 patch P8s;Gang scheduling 用到了 K8s 1.15 以后支持的 scheduling framework 以及其中由阿里云和腾讯贡献的 coscheduling 插件(第 2 期也有做介绍)。
本篇又名「微软产品经理教你如何上班摸鱼做音乐」,Late Troubles 是 Snapline 乐队主唱陈曦的个人计划,相比 Snapline 的音乐风格,Late Troubles 更加温和。目前陈曦已经移居西雅图,作为一个音乐人、一个父亲、一个上班族,他在这个访谈中表达了自己作为异乡异客的思考、关于家庭的思考、关于音乐制作的思考、疫情对生活的影响等等内容,可能就像 Late Troubles 的音乐一样,这篇访谈让我觉得平实及坦诚,任何光鲜的外表下都会有不尽相同的故事,希望有一天能在国内看到 Late Troubles 的现场演出。
]]>「Maybe News」是一个定期(或许不定期)分享一些可能是新闻的知识的系列文章,名字来源于我非常喜欢的一个国内的音乐厂牌「兵马司」(Maybe Mars)。你也可以通过邮件订阅它。
2003 年的 SOSP 会议上作为一家刚成立 5 年的创业公司,Google 发表了这篇影响深远的论文。论文的第一作者 Sanjay Ghemawat 相比他的同事 Jeff Dean 可能不太为外界所知,但看过他的履历以后就会发现早在 DEC 工作期间他就已经与 Jeff Dean 共事,当 Jeff Dean 在 1999 年加入 Google 后不久 Sanjay Ghemawat 也随即加入,并一起研发了 Google File System(以下简称 GFS)、MapReduce、Bigtable、Spanner、TensorFlow 这些每一个都鼎鼎大名的系统,是当之无愧的 Google 元老。
18 年后的今天再来回顾这篇论文依然能发现很多值得借鉴的地方,作为 GFS 最著名的开源实现,HDFS 近年来虽然已经有了很多自己的改进,但核心架构依然沿用的是这篇论文的思想。让我们回到十几年前,去探求为什么 Google 当时要研发这样一个分布式文件系统。
GFS shares many of the same goals as previous distributed file systems such as performance, scalability, reliability, and availability. However, its design has been driven by key observations of our application workloads and technological environment, both current and anticipated, that reflect a marked departure from some earlier file system design assumptions.
论文开篇的第一段话已经很好地概括了 GFS 设计的初衷,这是一个完全基于 Google 业务特点设计的系统。回想一下 Google 的业务是什么?搜索引擎。搜索引擎依靠的是爬虫抓取大量数据,通过用户输入的关键词在这个庞大的数据库中检索,最后通过 Google 独有的排序算法把搜索结果展示给用户。GFS 面对的业务场景有下面几个特点:
Google 当时已经部署了多个 GFS 集群,最大的一个集群有超过 1000 个存储节点以及超过 300TB 的磁盘,同时被数百个客户端访问。
论文的第二章节详细介绍了 GFS 的设计假设,除了前面提到的 3 个以外还包括:
像传统的文件系统一样,GFS 提供包括创建、删除、打开、关闭、读取、写入这样的接口,但是 GFS 并不提供 POSIX 这样的标准 API。文件通过目录结构组织,可以通过路径名来标识某一个文件。除此之外,GFS 还提供快照(snapshot)和原子追加写(record append)功能。
在介绍完 GFS 的设计背景以及假设以后,接下来是详细的 GFS 架构讲解。GFS 服务端由一个 master 和多个 chunkserver 组成,通过特定的 client 库(实现了 GFS 的文件系统 API)与应用集成。GFS 是非常经典的分布式系统架构,影响了后来很多系统的设计。
Master 负责维护整个文件系统的元数据(metadata),包括命名空间(namespace)、访问控制(access control)信息、文件到 chunk 的映射以及每一个 chunk 的具体位置(location)。命名空间可以理解为目录结构、文件名等信息。除此之外,master 还承担一些系统级的活动,例如 chunk 的租约(lease)管理、垃圾回收无效 chunk、在不同 chunkserver 之间迁移 chunk。Master 会周期性地与每一个 chunkserver 进行心跳通信,心跳信息中同时还会包含 master 下发的指令以及 chunkserver 上报的状态。元数据都是保存在 master 的内存中,因此 master 的操作都非常快。每个 chunk 的元数据大约会占用 64 字节内存空间,每个文件的命名空间信息也是占用 64 字节左右(因为 master 针对文件名进行了前缀压缩),相对来说内存的开销是很小的,随着文件数的增加对 master 节点进行纵向扩展即可。比较重要的元数据信息(比如命名空间、文件到 chunk 的映射)还会同步持久化操作日志(operation log)到 master 的本地磁盘以及复制到远端机器,保证系统的可靠性,避免元数据丢失。当操作日志增长到一定大小,master 会生成一个检查点(checkpoint)用于加快状态恢复,检查点文件是一个类似 B 树的结构,可以不经过解析映射到内存直接查询。每个 chunk 的具体位置不会被持久化,master 每次启动时会通过请求所有 chunkserver 来获取这些信息。最初设计时其实考虑过持久化 chunk 的位置信息,但是后来发现在 chunkserver 拓扑经常变化(比如宕机、扩缩容)的情况下如何保持 master 和 chunkserver 之间的数据同步是一个难题。此外 GFS 还提供仅用于只读场景的影子(shadow)master,影子 master 的数据不是实时同步,因此不保证是最新的数据。
采用单 master 的架构极大地简化了 GFS 的设计(也成为了之后被人诟病的因素),master 作为掌握全局信息的唯一入口,必须确保最小程度影响读写操作,否则就会变成整个系统的瓶颈。因此 GFS 的设计是 client 读写数据永远不会经过 master。实现方式很简单,client 请求 master 获取到具体需要通信(不管是读还是写)的 chunkserver 列表,把这个列表缓存在本地,之后就直接请求 chunkserver。
Chunkserver 这个名字的来历其实是因为 GFS 把文件分割成了多个固定大小的 chunk。每个 chunk 的大小是 64MiB,相比传统文件系统的块(block)大小大了很多(比如 ext4 默认的块大小是 4KiB),同时 master 会为每一个 chunk 分配一个全局唯一的 64 位 ID。Chunkserver 除了将 chunk 存储到本地磁盘上,还会复制到其它 chunkserver,GFS 默认会存储 3 个副本,当然用户也可以为不同的目录指定不同的复制等级。为什么 GFS 会选择 64MiB 这么大的 chunk 大小呢?论文中列举了几个原因:
每个 chunk 以及它的副本有两种角色:主副本(primary replica)和从副本(secondary replica),主副本只有 1 个,其它的都是从副本,至于具体哪个是主副本是由 master 决定的。master 会授权一个租约(lease)给主副本,租约的初始超时时间是 60 秒,但是只要 chunk 还在被修改,主副本可以无限续租,master 也可以随时废除租约。基于主从副本和租约的概念,数据写入 GFS 的流程是:
原子追加写(record append)的流程大体上和上面介绍的一样,区别在于第 4 步时主副本会检查写入以后是否会超过最后一个 chunk 的大小(64MiB),如果没超过就追加到后面,如果超过了会把最后一个 chunk 填充(pad)满,并回复 client 重试。
快照(snapshot)功能基于 copy-on-write 实现,master 通过仅仅复制元数据的方式能够在短时间内完成快照的创建。当 client 需要修改快照数据时,master 会通知所有 chunkserver 本地复制对应的 chunk,新的修改会在复制后的 chunk 上进行。
限于本期的篇幅,还有很多 GFS 的特性没有介绍,例如命名空间管理与锁、副本放置策略(placement policy)、chunk 重新复制(re-replication)、数据均衡(rebalancing)、垃圾回收、高可用等。最后是一个彩蛋,如果你仔细看论文最后的感谢名单,会发现一个熟悉的名字(当然不是 Jeff Dean)。
自从 GFS 的论文发布以来,Google 的数据已经增长了好几个数量级,很显然 GFS 的架构已经无法支撑如此大规模的数据存储。那 Google 下一代的文件存储是什么呢?答案就是 Colossus。这个神秘的项目直到目前为止都没有在公开场合被全面正式地介绍过,我们只能通过很多碎片的信息来拼凑出它的模样,上面链接中的内容即是通过这些信息整理出来的。一些有趣的信息是:元数据服务(Curators)基于 Bigtable;相比 GFS 至少可以横向扩展 100 倍;GFS 依然存在,只不过是用来存储文件系统元数据的元数据(metametadata);Colossus 可以基于另一个 Colossus 构建,就像俄罗斯套娃一样无限嵌套(让我想到了分形);存储数据的服务叫做 D server;默认使用 Reed-Solomon 编码存储数据,也就是通常所说的纠删码(erasure code)。建议配合这个 2017 年的 slide 以及这篇中文博客一起阅读。
流式计算这几年应该算是红到发紫?看看 Flink 社区的发展便可知晓。不过本期要介绍的不是流式计算,而是流式存储。说到与流式计算有关的存储,首先想到的可能是 Kafka,作为实时数据流的消息总线,Kafka 承担着非常重要的角色。但是 Kafka 也不是完美的,它的诞生其实比流式计算更早。Kafka 2011 年开源,Spark v0.7.0 2013 年发布开始支持 streaming,Flink v0.7.0 2014 年发布开始支持 streaming(跟 Spark 是同一个版本号不知是否是巧合)。因此 Kafka 的很多设计并不是针对流式计算场景优化。比如 topic partition 这个概念,本质上是为了提高读或者写的并发,但是 partition 本身是一个静态配置,并不能做到动态伸缩。再比如 Kafka 的数据存储,目前只支持内存和本地磁盘两种,消费新数据都是从内存,如果是旧数据就可能读磁盘,但是 Kafka 集群的存储容量上限毕竟还是受限于磁盘空间,在流式计算越来越重以及云计算大行其道的今天集群运维是一个难题(某些公司已经自研了 Kafka on HDFS 的方案,比如快手)。Pravega 便这样应运而生,这是一个来自戴尔的开源项目,一些设计亮点是动态 partition 以及自动数据分层(Apache BookKeeper + HDFS)。
在数据库领域 ACID 和 MVCC 已经不是什么新鲜的概念,但是文件系统领域似乎还是一个属于比较「早期」的阶段,虽然过去已经有类似 ZFS、Btrfs 这样创新的设计,但它们并不是广泛被大众了解以及使用的技术。特别是当云计算以及 S3 这样的「傻瓜」方案出现后,人们似乎已经习惯了开箱即用的产品。数据湖(data lake)这个词汇不知道从什么时候开始流行,对象存储的角色变得越来越重(至少云厂商是这样希望的?)。人们对这个「万能」的存储有着越来越多的期望,但是对象存储并不是万能的。为了解决对象存储的各种问题(这里不赘述具体问题)或者说填补它的一些缺失,越来越多基于对象存储的项目诞生。lakeFS 即是其中一个,lakeFS 希望通过提供类似 Git 的体验来管理对象存储中的数据,并且保证 ACID。比如创建一个数据的「分支」即可实现多版本管理。lakeFS 的开发团队来自以色列(公司官网挺有意思),项目使用 Go 语言实现。一些类似的项目还有 DVC、Quilt 以及 Hanger。
在第 6 期 Maybe News 曾经介绍过 Facebook 的 Cosco,一个给 Hive/Spark 使用的 remote shuffle service 实现。本期介绍的 Magnet 来自 LinkedIn,也是一个 shuffle service。跟 Cosco 的区别在于 Magnet 不是存算分离架构,不依赖外部存储,核心思想是 mapper 把 shuffle 数据先写到本地的 shuffle 服务,然后这些 shuffle 数据会根据某种负载均衡算法推到远端的 shuffle 服务上,远端 shuffle 服务会定期合并(merge)数据,最后 reducer 从远端 shuffle 服务读取数据。这里的「远端」其实是一个相对的概念,有可能 reducer 跟 shuffle 服务在同一个节点上,那就不需要发送 RPC 请求而是直接读取本地磁盘的数据。更多技术细节可以参考 VLDB 2020 的论文,另外 LinkedIn 的工程师也在积极将 Magnet 贡献给 Spark 社区,目前已经合入了几个 PR,具体请参考 SPARK-30602。
王益目前是蚂蚁集团研究员,同时也是开源项目 SQLFlow 和 ElasticDL 的负责人(这两个项目也很有意思,有兴趣的同学可以去了解了解)。这里介绍的 Go+ 是七牛创始人许式伟发起的开源项目,从 Go+ 的 slogan「the language for data science」就能看出项目的设计初衷。如果说目前什么编程语言在数据科学和机器学习领域最受欢迎,那可能就是 Python 了。但是 Python 的语言特性决定了它可能并不是最适合的,Go+ 依托 Go 语言作为基础,很好地弥补了 Python 的缺失。推荐对机器学习感兴趣的同学看看这篇文章,其中提到的一些八卦历史也很有趣。
]]>本篇是 2021 年 1 月 30 日 Kylin Meetup 的直播回顾,主要介绍 JuiceFS 如何优化 Kylin 4.0 的存储性能。完整 slide 请点击这里查看。
大家好,我是来自 Juicedata 的架构师高昌健,今天给大家分享的主题是「如何使用 JuiceFS 优化 Kylin 4.0 的存储性能」。
首先看一下今天分享的提纲,主要分为几个部分。首先对 Kylin 4.0 以及它在云上面临的挑战进行简单介绍,然后详细介绍一下 JuiceFS 是什么以及为什么我们要在 Kylin 中使用 JuiceFS,最后是一些 benchmark。
我们先来了解一下 Kylin 4.0 的架构。这是 4.0 的架构图,Kylin 4.0 摒弃了之前基于 HBase 的架构,改为使用 Spark 作为构建以及查询引擎,并且使用 Parquet 作为 Cube 的存储格式直接写到 HDFS 或者对象存储上。Kylin 4.0 的架构不仅性能上有大幅提升,也更加贴合现在云原生的部署方式。
这里想重点讲一下有关存储这块儿的改动,新的存储方式帮助 Kylin 真正实现了存储与计算分离的架构。传统大数据的架构都是存储与计算耦合的,也就是说没有办法单独对存储或者计算资源进行扩缩容。但是在云上使用对象存储替代 HDFS 作为大数据平台的底层存储已经越来越成为主流,也就需要上层计算组件的架构更加灵活。
但是我们也看到云上的对象存储或多或少都存在一些问题,也就为 Kylin 4.0 在云上部署和使用带来了一些挑战。这里我们来看一下对象存储的具体问题。首先很多时候大家会误以为对象存储是完全等价于 HDFS 的,但其实不是,在很多维度上对象存储和 HDFS 都是不一样的。
比如一致性模型,HDFS 是保证强一致性的文件系统,但是对象存储往往是最终一致性,也就是说当你往对象存储中写入新的数据以后并不能立即看到,有可能需要等待一段时间,并且这个等待时间对于用户来说是不可控的,最终一致性会给大数据平台的计算任务带来很多的不确定性,影响任务的稳定性。
然后是元数据操作性能。在大数据场景常见的一些元数据操作,比如 list 是列举某个目录有什么文件、rename 是重命名、delete 是删除,这些元数据操作在对象存储上性能都是不太好的,本质上是因为对象存储是一个 K/V 存储,没法高效实现刚才提到的这些元数据操作。这里比较明显的操作是 rename,因为很多对象存储都不支持重命名,因此真正底层实现的时候都是通过先拷贝原始数据到新的路径,再删除原始数据的方式来实现。这种实现在大数据这种大批量处理数据的场景对性能的影响会比较明显。
然后是数据本地性。Hadoop 因为是存储计算耦合的架构,因此当计算任务被调度时会尽量把计算任务分配到数据节点上,这样就可以提升数据读取的性能。但是对象存储并不提供这样的能力,所有数据都必须通过网络获取,这对于对象存储的带宽有很高的要求,也会对计算的性能造成影响。
对象存储还有一些隐性的点可能不一定被大家关注。对于一个 bucket 中的每个路径其实都有最大的 API 请求频率限制,如果计算任务发送的请求超过了这个限制,就会报错,当然我们可以通过重试或者降低请求并发的方式来一定程度上缓解这个问题,但都是治标不治本,并且也会给业务带来很多不便。对象存储的 API 请求也不是免费的,大数据场景很容易产生大量的请求,这些都会带来一些成本。
Hadoop 兼容性也会成为一个问题,Hadoop 生态具有繁多的组件,不同云厂商的对象存储也可能都会有一些差异,导致上层组件在接入时不一定能完全兼容,甚至出现组件无法正常使用的情况。
针对刚才提到的这些问题,因此我们开发了 JuiceFS 这样一个项目。这里简单介绍一下 JuiceFS。JuiceFS 是一个开源的云原生分布式文件系统,从今年 1 月初开源以来已经在 GitHub 上积累了超过 2700 个 star。JuiceFS 创新性地基于 Redis 和对象存储构建,同时提供传统文件系统的诸多特性。
例如强一致性,利用独立的元数据管理以及 Redis 的事务特性,JuiceFS 能够确保数据的强一致性。目前 JuiceFS 已经支持市面上几乎所有的对象存储,不管是公有云上的,还是开源对象存储系统,因此对于机房用户来说也是非常友好的。
除了兼容 HDFS 接口以外,JuiceFS 还提供了标准的 POSIX 接口,这是目前 HDFS 以及对象存储都不支持或者支持得不好的一块儿。借助 FUSE 你可以像使用本地文件系统一样把 JuiceFS 挂载到机器上,再通过标准的命令行工具读写数据。此外 JuiceFS 还支持 S3、NFS、Samba 等协议,多种协议的支持使得只用 JuiceFS 一个文件系统就可以同时满足多种业务场景,而不用在不同存储之间重复拷贝数据。
用户访问 JuiceFS 是通过特定的客户端,这个客户端本身也是支持多系统的,包括 Linux、macOS、Windows。在 CPU 架构支持上,除了 x86 以外也支持现在应用广泛的 ARM 平台。
JuiceFS 也提供 K8s CSI 驱动,也就是说在 K8s 平台上你可以很方便地把 JuiceFS 挂载到容器里,这个挂载的 volume 是支持多个容器同时读写的(ReadWriteMany)。同时你也不用担心挂载的 volume 它的一个生命周期,都是由 JuiceFS 的 CSI 驱动来管理,自动地创建、销毁。JuiceFS 的 CSI 驱动很好地满足了容器平台共享存储的需求。
JuiceFS 也具备数据缓存的能力,可以把远端的数据,也就是对象存储的数据缓存到本地,这个后面在介绍 benchmark 的时候会再详细说明数据缓存的能力。最下面是 JuiceFS 的 GitHub 链接。
这是 JuiceFS 的架构图。结合刚刚讲到的,JuiceFS 在最底层依赖的是对象存储来作为最基本的数据块存储。JuiceFS 不是原封不动地把数据存储到对象存储上,所有通过 JuiceFS 写入的数据默认会按照 4MiB 一个块来分块,例如写入一个 100MiB 或者 1GiB 的文件会按照 4MiB 这个粒度来切分,切分以后再存储到对象存储上。之所以这样设计很大程度上是因为希望对大文件进行小的分块可以提升读写的吞吐和性能,相比直接一个大文件读取或者写入的话,拆分成小的块之后读写的性能提升还是蛮明显的。
然后在图的左边可以看到是一个 Redis,这里是把 Redis 作为元数据的存储,相当于所有文件系统的元数据都会存到 Redis 中。JuiceFS 依赖 Redis 的事务特性保证所有元数据的操作的原子性,也就是保证元数据的强一致性。
然后右边是 JuiceFS 的客户端。JuiceFS 已经提供了多种客户端,比如 FUSE 是通过挂载来提供 POSIX 接口;Java SDK 是在 Hadoop 环境中使用,我们近期也已经将 Hadoop SDK 开源出来;最右边的是 S3 Gateway,通过这个 gateway 可以对上层提供 S3 兼容的接口。可以看到 JuiceFS 提供了多协议的客户端,这些客户端底层都是共享了同一个实现,也就是图上的 Client Core。除了 Java SDK 以外,客户端整体上都是用 Go 语言实现,Java SDK 底层也是通过 JNI 去调用 Go 语言的接口。
前面提到了数据缓存,意思就是说当从对象存储读取数据时,client 端会自动地把数据缓存到本地配置的某个缓存路径,JuiceFS 也会自动地管理缓存空间,比如说当缓存盘满了之后应该怎么去失效、怎么去维护缓存数据的生命周期,以及保证缓存数据与对象存储之间的一致性。这些都是由 JuiceFS client 端提供的特性。
下面一个问题就是为什么 Kylin 要和 JuiceFS 一起使用呢?经过前面的介绍,大家可能也或多或少能够想到一些,本质上希望通过 JuiceFS 来解决刚才提到的对象存储的各种问题。所以具体来说 JuiceFS 能给 Kylin 带来的收益有这么几个。
首先是强一致性,也就是相比对象存储的最终一致性来说 JuiceFS 是一个强一致性的文件系统。然后是高性能,JuiceFS 不管是在元数据还是数据的读写上都有很好的性能表现。元数据是基于 Redis 的,Redis 因为是全内存的 K/V 存储,所以元数据操作的性能是非常高的,同时 JuiceFS 在 Redis 之上也做了一些优化。数据的读写 JuiceFS 默认是按照 4MiB 的块来分块存储,在读写上也有蛮多的性能提升。
然后数据本地性也是 JuiceFS 能够带来的一个好处,通过缓存可以把数据缓存到计算节点上,虽然计算节点的缓存空间不一定特别大,但一定程度上也提供了数据本地性。这块儿的具体实现是通过 Java SDK 提供了缓存数据的 location 给上层的调度器(比如 YARN),使得调度器能够知道缓存数据当前是在哪一个计算节点上,可以在调度时把相应的计算任务调度到具有缓存数据的节点上,也就一定程度上实现了数据本地性。即使调度失败了,也有一些进一步的优化,例如在不同计算节点之间组成一个分布式缓存系统,也就是节点互相之间是能够读取对方的缓存数据,对数据读取进行优化,而不是完全依赖底层的对象存储的吞吐和性能。
JuiceFS 同时也是完整兼容 Hadoop 生态的,不管是对于 Hadoop 2.x 和 Hadoop 3.x 这种大版本的变化,还是对于某个 Hadoop 发行版的所有组件都是完全兼容的。因为 JuiceFS 本身提供的就是一个标准的 HDFS 接口,对于上层的组件来说基本上是透明的,也没有任何侵入,就可以当作是 HDFS 来使用。
TCO 低也是 JuiceFS 的一个好处。前面提到对象存储的 API 请求是收费的,这些 API 请求中涵盖了很多元数据的请求, 当使用 JuiceFS 之后这些请求会直接发给元数据服务(也就是 Redis),也就不存在元数据请求的费用。数据的请求依靠比如缓存这样的特性很大程度上减少对对象存储的依赖,整体上来说成本都能有一定优化。
JuiceFS 还支持一些 HDFS 或者对象存储可能不提供的功能。比如快照,快照的意思是可以针对某一个目录创建一个 snapshot,看起来好像是对数据进行了一次拷贝,但底层的实现其实是不存在拷贝的。本质上是一个 copy-on-write 的原理,在创建快照时只是对元数据进行了拷贝,底层是共享的同一份数据,并没有拷贝对象存储上数据。对你对快照进行修改时,就会将原始数据进行拷贝,然后存储修改后的数据。这个特性对于很多场景是非常有帮助的,比如测试、多版本管理。
符号链接在操作系统中是一个常见的功能,通过符号链接可以将一个文件指向另一个文件的路径。在 JuiceFS 上也实现了这个功能,不仅可以将 JuiceFS 的路径映射到另一个路径,也可以链接到任何 JuiceFS 支持的存储上(比如 HDFS、对象存储)。通过 JuiceFS 实现一个统一的文件系统命名空间管理,可以在这一个命名空间中看到多种存储的数据。这其实是一个蛮有用的特性,比如我们对数据进行迁移时是非常有帮助的(可以参考之前的一篇文章)。
本身 JuiceFS 是一个开源的项目,但同时我们也提供一个云上全托管的版本,会将元数据服务进行统一管理,也有一个 web 的控制台可以让大家很方便地使用。这是 JuiceFS 商业版提供的一个功能。
然后分享一下之前做的一个 benchmark 结果。先看一下测试环境,这里用了标准的 10GB 大小的 TPC-H 作为数据集,用了 1 台 master,3 台 worker,大概是这样的配置。对比的是 Kylin on OSS 和 Kylin on JuiceFS 的性能。
这是最终 benchmark 的对比图,黄色的是 OSS,绿色的是 JuiceFS,图上展示的是 TPC-H 每一个查询的执行时间,这个时间越低越好。
总结一下刚刚提到的测试。因为 Kylin 需要提前构建 cube,我们在测试时遇到 Kylin on OSS 这个方案构建 cube 直接失败了,导致没有办法在 OSS 上构建 cube。最终是通过把 Kylin on JuiceFS 构建完的 cube 拷贝到 OSS 上完成的测试。总的查询时间上 JuiceFS 快 38% 左右,然后看单个查询的执行时间 JuiceFS 最多能够快 85%,平均下来也能快 46% 左右。
最后想展望一下未来 JuiceFS 的一些新特性。JuiceFS 已经在性能上进行了很多优化,前面的 benchmark 也能看到,但其实也有很多细粒度的点我们可以优化。比如说查询预读,可以从 JucieFS 的角度预先知道某个查询接下来会读取的文件,也就可以自动地在后台进行一些预读的处理,进一步加速查询的效率。刚刚提到的 P2P 分布式缓存特性也会是接下来在开源版本的 Hadoop SDK 去优化的一个点。最后 JuiceFS 会提供一个 profiling 的工具,这个工具是为了帮助用户更快更简便地调优,发现性能热点,从而针对性地对读写进行优化。
今天的分享就到这里,感谢大家。
]]>「Maybe News」是一个定期(或许不定期)分享一些可能是新闻的知识的系列文章,名字来源于我非常喜欢的一个国内的音乐厂牌「兵马司」(Maybe Mars)。你也可以通过邮件订阅它。
14 年前,Amazon 发布了 EC2(Elastic Compute Cloud)和 S3(Simple Storage Service)这两个划时代的产品,从此「云计算」这个词开始进入大众的视野,经过十几年的发展已经逐渐被大众所认知与接受。「云」意味着近乎无限的资源,EC2 为用户提供了计算资源,S3 为用户提供了存储资源。传统基于 Hadoop 的大数据平台是将这两种资源绑定在一起的,而迁移到云端以后非常自然地会想到将存储资源转到类似 S3 的对象存储中,从而真正实现存储计算分离的架构,能够更加弹性地管理计算和存储这两种天生异构的资源,既大幅节约了成本还省去了运维 HDFS 集群的各种烦恼。
作为 Spark 的发明者,Databricks 这家商业公司的很多客户同时也是 AWS 的客户,因此有着非常丰富的在大数据场景使用 S3 的经验。这些经验暴露了 S3(或者类似的对象存储)作为 HDFS 替代者的种种缺陷。
对象存储(object store)的用户可以创建很多 bucket,每个 bucket 中存储了很多对象(object),每个对象都会有一个唯一的 key 作为标识。因此对象存储本质上是一个 K/V 存储,这一点非常重要,因为通常的认知都会将对象存储等同于文件系统(file system)。对象存储中的「目录」其实是通过 key 的前缀模拟出来的,虽然对象存储提供类似 LIST 目录这样的 API,底层实现却是遍历相同前缀的对象,这个操作在文件系统中是 O(1) 的时间复杂度,但在对象存储中是 O(n)。更加严重的情况是,S3 的 LIST API 每次请求最多返回 1000 个 key,单次请求延时通常为几十到几百毫秒,因此当处理超大规模的数据集时单单花在遍历上的时间就可能是分钟级。重命名对象或者目录也是一样,文件系统是一个原子操作,对象存储是先拷贝到新路径,再删除原路径的对象,代价非常高。
另一个对象存储严重的问题是一致性模型,S3 的一致性模型是最终一致性。当某个客户端上传了一个新的对象以后,其它客户端并不一定保证能立即 LIST 或者读取这个对象。当一个对象被更新或者删除以后也会发生同样的现象,即使是负责写入的这个客户端自己也有可能遇到。S3 能确保的一致性是 read-after-write,也就是说 PUT 请求产生以后的 GET 请求是保证一定能返回正确数据的。
论文概述了目前大数据存储的 3 种方案:分区目录、自定义存储引擎、元数据在对象存储中,下面分别介绍。
分区目录顾名思义就是将数据按照某些属性进行分区,比如日期。这是大数据领域非常普遍的做法,好处是可以根据分区过滤不需要的数据,也就能减少 LIST 请求的数量。这个方案并没有解决前面提到的对象存储的问题,因此缺点也很明显:不支持跨多个对象的原子操作、最终一致性、低性能、不支持多版本和审计日志。
自定义存储引擎的意思是在云上实现一个独立的元数据服务,类似 Snowflake、JuiceFS 的做法。对象存储只是被当作一个无限容量的块存储,一切元数据操作都依赖这个单独的元数据服务。这个方案的挑战是:
元数据在对象存储中是 Databricks 提倡的方案,即今天介绍的 Delta Lake。这个方案和前一个的本质区别是不存在一个中心化的元数据服务,元数据是通过「日志」的形式直接存放在对象存储中。从目录结构上来看,Delta Lake 定义了一种特殊的存储格式,例如对于更新或者删除的数据会产生很多小的 delta 文件。这一点上其实跟 Hive 实现 ACID 的设计很像,后者在 2013 年就已经开始开发,而 Delta Lake 项目是 2016 年启动,很难说有没有借鉴的成分。更加类似 Delta Lake 的是另外两个项目:Apache Hudi 和 Apache Iceberg,关于这几个项目的异同后面会有一个更详细的介绍,Databricks 目前宣传的一些 Delta Lake 独有的特性(比如 Z-order clustering)其实并没有开源。
Delta Lake 的思想其实很好理解,本质上是把所有操作都通过日志的形式记录下来,当读取时需要重放这些日志来得到最新的数据状态,最终实现 ACID 的语义。优化的点在于怎么加速整个流程,比如定期合并日志为一个 checkpoint、索引最新的 checkpoint 等。这种把日志作为元数据的设计解决了前面提到的对象存储最终一致性的问题,即只依赖日志来确定具体读取的文件,而不是简单通过 LIST 一个目录。但是从论文描述的场景来看还是有可能因为最终一致性踩坑(因为依然会用到 LIST API),至于这个概率有多大就不知道了,因此我对于是否能根本性解决一致性问题存疑。
写数据的时候有一个地方需要特别注意,日志文件的文件名是递增且全局唯一的 ID,因为写入存在并发,所以需要在这一步保证操作的原子性。根据不同的对象存储有不同的解决方案:
LogStore
这个接口实现一个类似 Databricks 提供的协调服务。因为依赖了一个中心化的服务(虽然只是在写数据时),也一定程度上破坏了 Delta Lake 宣扬的去中心化思想。由于日志中记录了所有的历史操作,并且数据和日志都是不可变的(immutable),因此 Delta Lake 可以很轻松实现时间旅行(Time Travel)功能,也就是重现某个历史时刻的数据状态。Delta Lake 通过类似 TIMESTAMP AS OF
这种语法的 SQL 可以让用户指定读取某个时间的数据,不过这个 SQL 语法目前在开源版本中还不支持。
Delta Lake 也可以很好地跟流式计算进行结合,不管是生产者还是消费者都可以利用 Delta Lake 的 API 来实现流式写和读数据。当然毕竟因为是 Databricks 开发的产品,目前结合得最好的肯定是 Spark Structured Streaming。你问支持 Flink 吗?至少 Databricks 员工的回答是还在计划中,短期内估计没戏。
最后是性能评测部分。首先评测的是 LIST 大量文件的场景,通过对同一张表进行不同程度地分区来模拟不同量级的文件,评测的引擎是 Hive、Presto、Databricks Runtime(企业版 Spark,以下简称 DR),其中 Hive 和 Presto 读取的数据格式是 Parquet,DR 读取的格式是 Parquet 和 Delta Lake。Hive 在有 1 万个分区时总时间已经超过 1 小时;Presto 稍好一些在 10 万个分区时才超过 1 小时;DR + Parquet 在 10 万个分区时的耗时是 450 秒(得益于并发执行 LIST 请求);DR + Delta Lake 在 1 百万分区时的耗时才 108 秒,如果启用了本地缓存可以进一步缩短到 17 秒,可以看出来优化效果非常明显。这个结果也基本符合预期,毕竟 Delta Lake 主要目标之一就是优化 list 的性能(以及一致性),对象存储在元数据性能上肯定没有优势。
接下来是更接近真实场景的 TPC-DS 测试,数据集大小是 1 TB,测试结果取的是 3 次运行时间的平均值。最后的数据是 Presto + Parquet 耗时 3.76 小时,社区版 Spark + Parquet 耗时 1.44 小时,DR + Parquet 耗时 0.99 小时,DR + Delta Lake 耗时 0.93 小时。DR + Parquet 相比社区版 Spark 快的主要原因是 DR 做了很多运行时和执行计划的优化,相比之下 DR + Delta Lake 并没有比直接读取 Parquet 提升太多,论文中的解释是 TPC-DS 的表分区都不大,不能完全体现 Delta Lake 的优势。
总结一下,Delta Lake 的思想其实并不复杂,也是工业界为了解决对象存储诸多问题的一种尝试,虽然并不能完全解决(比如原子重命名和删除)。在大数据存储上实现 ACID 这一点对于构建实时数仓至关重要,Delta Lake 通过一种简单统一的方式实现了这个需求,而不用像传统的 Lambda 架构一样再单独部署一套存储系统(比如 HBase、Kudu)。但现在流式计算领域的风头已经从 Spark 逐渐转向了 Flink,像 Delta Lake 这种只对 Spark 支持的技术在某种程度上也会限制它的普及,相比之下 Iceberg 和 Hudi 似乎更有竞争力。
前面介绍了 Delta Lake,算是 Databricks 今年一个重量级的开源产品,但其实真正的杀手锏并没有开放出来,也就是这里要介绍的 Delta Engine。简单介绍这是一个在 Delta Lake 之上,基于 Spark 3.0 的计算引擎。Delta Engine 主要包含 3 部分:原生执行引擎(Native Execution Engine),查询优化器(Query Optimizer)以及缓存(Caching)。这个视频重点介绍了原生执行引擎,这个引擎的代号是 Photon,它使用 C++ 编写,并且实现了目前在 OLAP 领域很火的向量化(vectorization)功能,感兴趣的同学强烈建议阅读 MonetDB/X100: Hyper-Pipelining Query Execution 这篇论文,Databricks 厉害的地方在于是跟论文作者 Peter Boncz 一起合作设计。在 30 TB 的 TPC-DS 测试中,Photon 带来了 3.3 倍的性能提升。关于查询优化器以及缓存功能的介绍可以参考 Delta Engine 的文档。
Iceberg 和 Hudi 是另外两个会经常拿来跟 Delta Lake 做比较的对象,Iceberg 是 Netflix 开源,而 Hudi 是 Uber 开源。它们之间有着诸多相似之处,又有着很多截然不同的设计思想。这个视频来自腾讯云数据湖团队的陈俊杰,比较系统地对比了这 3 种技术。相对来说 Iceberg 的设计是这 3 个里面最中立的,不跟某种特定的格式和引擎绑定,这也是腾讯选择 Iceberg 的原因之一,具体可以看「为什么腾讯看好 Apache Iceberg?」这篇文章。
深度学习的核心之一是 SGD(Stochastic Gradient Descent),通过把数据集拆分成若干小的集合(mini-batch),再基于这些小集合反复进行前向传播(forward propagation)和反向传播(backpropagation)计算,不断获取新的梯度(gradient)和权重(weight)。分布式训练本质上要解决的问题就是怎么让多机计算的效率线性提升,即所谓的「线性加速比」,理论值当然是 100%,但是实际情况往往差了很多。传统的同步 SGD 在每一轮计算完以后需要把所有梯度汇总,再重新计算新的权重,类似一个 MapReduce 的过程,此时 reducer 需要等待所有 mapper 计算完成,计算性能会随着 mapper 数量的增加而线性下降。怎么解决这个问题呢?这篇 2017 年的旧文介绍的便是影响至今的 Ring Allreduce 算法,作者 Andrew Gibiansky 之前在百度硅谷 AI 实验室工作,后来联合创办了语音合成公司 Voicery(不过悲剧地发现这家公司今年 10 月份已经关了)。基于 Andrew Gibiansky 的成果,Uber 开源了目前公认的 Ring Allreduce 标准框架 Horovod。
推荐系统是一直都是机器学习一个重要的应用领域,如果你不了解什么是推荐系统可以看我之前写的一篇简介。使用 TensorFlow 可以很方便地训练一个推荐系统模型,不管是召回模型还是排序模型。现在 TensorFlow 官方将这个流程进一步简化,推出了 TensorFlow Recommenders(TFRS)库,旨在让训练、评估、serving 推荐系统模型更加容易,并且融合一些 Google 自己的经验,对于初学者来说会是一个好的入门指南。
]]>这篇文章最初发表在 JuiceFS 官方博客,点击这里查看原文。
环球易购创建于 2007 年,致力于打造惠通全球的 B2C 跨境电商新零售生态,2014 年通过与百圆裤业并购完成上市,上市公司「跨境通(SZ002640)」是 A 股上市跨境电商第一股。经过多年的努力,在海外市场建立了广阔的销售网络,得到了美国、欧洲等多国客户的广泛认可,公司业务多年来一直保持着 100% 的增长速度。
环球易购提供面向全球的跨境电商服务,选择 AWS 作为云服务商。基于 EC2 和 EBS 自建 CDH 集群,计算引擎使用了 Hive 和 Spark。当时的环球易购大数据平台面临这么几个问题:
因此希望能够在降低 HDFS 存储成本的同时,不会在性能上造成太大损失。说到降低成本那么很自然地会联想到 S3,S3 在提供高达 11 个 9 的数据持久性的同时也能够做到足够低廉的存储成本。但是大数据集群存储由 HDFS 迁移到 S3 是唯一选择么?迁移和使用中会遇到哪些问题呢?这些我们在后面都会详细介绍,不过首先来看看为什么 EBS 自建的 HDFS 集群成本很高。
EBS 是一种易于使用的高性能数据块存储服务,通过挂载到 EC2 上来提供近乎无限容量的存储空间。为了保证 EBS 上数据的可用性,所有数据都会自动在同一可用区内进行复制,防止数据丢失。
HDFS 是目前大数据领域最常使用的分布式文件系统,每个文件由一系列的数据块组成。同样的,为了保证数据的可用性,HDFS 默认会将这些数据块自动复制到集群中的多个节点上,例如当设置副本数为 3 时同一数据块在集群中将会有 3 份拷贝。
通过以上介绍可以看到 EBS 和 HDFS 都会通过复制数据来保证可用性,区别在于 EBS 是只针对每块存储卷(即磁盘)的数据进行复制,而 HDFS 是针对整个集群的数据。这种双重冗余的机制其实有些多余,也变相增加了存储成本。同时 HDFS 的多副本特性使得集群的实际可用容量会小很多,例如当副本数为 3 时实际可用容量其实只有总磁盘空间大小的 1/3,再加上通常会在集群空间到达一定水位时就进行扩容,这会进一步压缩可用容量。基于以上原因,在云上通过 EBS 自建 HDFS 集群的存储成本通常会高达¥1000/TB/月。
Hadoop 社区版默认已经支持从 S3 读写数据,即通常所说的「S3A」。但是如果你去看 S3A 的官方文档,会在最开始看到几个大大的警告,里面列举了一些类 S3 的对象存储都会存在的问题。
S3 的一致性模型是最终一致性,也就是说当创建了一个新文件以后,并不一定能立即看到它;当对一个文件执行删除或者更新操作后,有可能还是会读到旧的数据。这些一致性问题会导致程序崩溃,比如常见的 java.io.FileNotFoundException
,也可能导致错误的计算结果,更麻烦的是这种错误很难发现。我们在测试过程中就因为 S3 的一致性问题使得执行 DistCp 任务频繁报错,导致数据迁移受到严重影响。
S3 中的「目录」其实是通过对象名称的前缀模拟出来的,因此它并不等价于通常我们在 HDFS 中见到的目录。例如当遍历一个目录时,S3 的实现是搜索具有相同前缀的对象。这会导致几个比较严重的问题:
S3 的认证模型是在 S3 服务内部基于 IAM 实现的,这区别于传统的文件系统。因此当通过 Hadoop 访问 S3 时会看到文件的 owner 和 group 会随着当前用户的身份而动态变化,文件的权限都是 666,而目录的权限都是 777。这种与 HDFS 大相径庭的认证模型会使得权限管理复杂化,并且也显得不够通用,只能限定在 AWS 内使用。
JuiceFS 基于对象存储实现了一个强一致性的分布式文件系统,一方面保持了 S3 弹性伸缩无限容量,99.999999999% 的数据持久性安全特性,另一方面前面提到的 S3 的种种「问题」都能完美解决。同时 JuiceFS 完整兼容 Hadoop 生态的各种组件,对于用户来说可以做到无缝接入。认证模型上 JuiceFS 遵循与 HDFS 类似的 user/group 权限控制方式,保证数据的安全性,也能对接 Hadoop 生态中常用的如 Kerberos、Ranger、Sentry 这些组件。更加重要的是,相比环球易购现有的基于 EBS 的存储方案,使用 JuiceFS 以后每 TB 每月的存储成本将会至少节省 70%。
存储成本大幅下降的同时,性能表现又如何呢?下面分享一下相关的测试结果。
测试环境是 AWS 上自建的 CDH 集群,CDH 版本为 5.8.5。测试的计算引擎包括 Hive 和 Spark,数据格式包括纯文本和 ORC,使用 TPC-DS 20G 和 100G 这两个规模的数据集。对比的存储系统有 S3A、HDFS 及 JuiceFS。
这里以创建 store_sales
这个分区表为例
这里以修复 store_sales
这个表的分区为例
这里以读取 store_sales
这个分区表并插入临时表为例
分别使用 Spark 测试了 20G 和 100G 这两个数据集,取 TPC-DS 前 10 个查询,数据格式为纯文本。
分别使用 Spark 测试了 20G 和 100G 这两个数据集,取 TPC-DS 前 10 个查询,数据格式为 ORC。
对于建表和修复表分区这样的操作,因为依赖对底层元数据的频繁访问(例如遍历目录),JuiceFS 的性能大幅领先于 S3A,最多有 60 倍的性能提升。
在写入数据的场景,JuiceFS 的性能相对于 S3A 有 5 倍的提升。这对于 ETL 类型的任务来说非常重要,通常 ETL 任务都会涉及多个临时表的生成和销毁,这个过程会产生大量的元数据操作(例如重命名、删除)。
当读取类似 ORC 这种列式存储格式的数据时,区别于纯文本文件的顺序读取模式,列式存储格式会产生很多随机访问,JuiceFS 的性能再次大幅领先 S3A,最高可达 63 倍。同时相比于 HDFS,JuiceFS 也能有最多 2 倍的性能提升。
环球易购的大数据平台经过长期的发展已经积攒大量的数据和业务,怎么从现有方案迁移到新的方案也是评估新方案是否合适的重要因素。在这方面,JuiceFS 提供了多种数据迁移方式:
ln -s hdfs://dir /jfs/hdfs_dir
这行命令可以创建一个指向 HDFS 的符号链接。基于这种方式,可以将历史数据直接链接到 JuiceFS 中,然后通过统一的 JuiceFS 命名空间访问其它所有 Hadoop 文件系统。结合测试结果以及综合成本分析,全面对比了 HDFS、S3 和 JuiceFS 的方案,环球易购认为 JuiceFS 相比另外两个方案有显著的性能和成本优势,决定用 JuiceFS 替换自建的 HDFS。这些优势具体体现为以下 3 个方面:
首先,JuiceFS 可以实现从 HDFS 的平滑迁移,对上游的计算引擎可以做到全面兼容,对现有的权限管理体系可以保持一致,同时性能上没有任何下降。这几点对数据平台的迁移可以说是至关重要的,没有这样的基础,数据平台的迁移将是一场耗时耗力的战役。而有了这样的基础,客户只用不到一个月的时间就完成了业务和数据的迁移。
第二,在成本方面,「云上自建 HDFS 的痛点」一节中已经有过说明,基于 EBS 自建 HDFS 单独计算磁盘成本就大约有¥1000/TB/月,而 JuiceFS 仅为 27%。这还不是 TCO 成本,TCO 还应该包括 HDFS 所消耗的 CPU、内存、运维管理投入的人力成本,按经验值来说至少翻倍。而 JuiceFS 客户使用全托管服务,没有任何运维管理的投入。这样从 TCO 角度看,可以节省近 90% 的成本。
最后,也是最重要的一点。大数据平台的存储引擎从 HDFS 换成 JuiceFS 后,整个平台就实现了存储计算分离,在「为什么说存储和计算分离的架构才是未来?」一文中详细分析了存储计算耦合的痛点,以及业界的一些实践。现在 JuiceFS 作为完全兼容 HDFS 的云原生文件系统,已经是 基于 Hadoop 生态构建的大数据平台的完美存储方案。存储计算分离是大数据平台弹性伸缩的基础,这一步的改造对环球易购数据平台的架构设计来说也有着重要的意义,接下来环球易购的数据团队将深入到集群弹性伸缩、工作负载混合部署等研究和实践中。
]]>「Maybe News」是一个定期(或许不定期)分享一些可能是新闻的知识的系列文章,名字来源于我非常喜欢的一个国内的音乐厂牌「兵马司」(Maybe Mars)。你也可以通过邮件订阅它。
Presto 是 Facebook 2012 年开始开发并于 2013 年开源的分布式查询引擎。和第 3 期介绍的 Kudu 一样,主要应用在 OLAP 场景,但跟 Kudu 不一样的地方是,Presto 仅仅是一个查询引擎,并不负责数据存储。这也是论文标题「SQL on Everything」的含义,这里的「Everything」指代的即是任意类型的存储,比如 HDFS、MySQL 等。
论文开篇先总结了 Presto 几个值得关注的特点:
接下来介绍 Facebook 目前使用 Presto 的几个主要场景:
LIMIT
的限制整个查询就可以提前终止。以上这些场景可能除了 ETL 以外,也是目前很多公司使用 Presto 的主要场景,总的来说主要还是应用在交互式查询上。
然后是 Presto 整体的架构介绍,集群分为两种类型的节点:coordinator 和 worker。Coordinator 节点负责解析、规划以及优化查询,通常只会有 1 个。Worker 节点负责处理查询请求,根据集群规模可以横向扩展。
当客户端通过 HTTP 请求将 SQL 发送给 coordinator 时,经过解析和分析,coordinator 会生成一个分布式执行计划(distributed execution plan)。这个执行计划由多个 stage 连接而成,类似一个 DAG 的形式。因为这是一个分布式执行计划,stage 会被分发到不同的 worker,因此 stage 之间需要通过 shuffle 来交换数据。每个 stage 内部由多个 task 组成,一个 task 可以被看作一个处理单元(processing unit)。Task 内又由多个 pipeline 构成,一个 pipeline 内包含一系列的 operator。到这里,operator 已经是最小的处理单位,通常只负责某一类单一计算任务。
Coordinator 很大一部分工作是负责调度,调度分为三个维度:stage、task 和 split。Stage 调度决定 stage 的执行顺序;task 调度决定多少任务需要被调度以及应该分配给哪些 worker;split 调度决定 split 会被分配给哪些任务(关于 split 这个概念后面会详细介绍)。
调度 stage 分为两种策略:all-at-once 和 phased。All-at-once 很好理解就是所有 stage 并行执行,这个策略可以最大化执行效率,适合时延敏感的场景(如交互式分析)。而 phased 策略就是只并行执行那些强关联的组件,整体任务分阶段执行,这个策略可以有效降低内存占用,适合 ETL 场景。
当 stage 调度成功,coordinator 即会开始分配 task。任务调度器将 stage 分为两类:leaf 和 intermediate。Leaf stage 负责从连接器中读取数据,intermediate stage 负责处理来自其它 stage 的中间结果。对于 leaf stage,任务调度器会根据如网络拓扑、数据本地性这些因素来决定应该把 task 分配给哪些 worker 节点,这个过程依赖连接器实现的 Data Layout API。如果没有任何限制,Presto 倾向于把 leaf stage 的任务分散到整个集群,以加快数据读取效率。Intermediate stage 的任务可以被分配到任意节点上,但是调度器仍然需要决定当前每个 stage 有多少任务需要被调度,且这个任务数是可以在运行时动态调整的。
当 leaf stage 的任务分配好以后,这个 worker 节点便可以开始接收来自 coordinator 分配的 split。Split 是对底层数据的逻辑封装,例如底层存储是 HDFS,那一个 split 通常包含的信息有文件路径、文件偏移等。Leaf stage 的任务必须至少分配一个 split 才能开始运行,而 intermediate stage 的任务是一直可运行的。Split 的创建由连接器负责,并且懒分配给 leaf stage 的任务,也就是说并不会等到所有 split 都创建完毕。这样做有几个好处:
介绍完了 coordinator 的工作接下来就是 worker。前面已经提到最小的执行单位是 operator,operator 负责处理输入数据,同时输出处理完的数据。Operator 输入输出的数据单元叫做 page,一个 page 是连接器将 split 中的多行数据转为列式存储以后产生的数据结构。Shuffle 也是 worker 的主要工作之一,区别于传统的 Hadoop 组件,Presto 是基于全内存的 shuffle 实现,这也是 Presto 性能更优的原因之一。Shuffle 的数据会暂存在内存缓冲区(buffer)中,简单理解 map 端的缓冲区为输出缓冲区,reduce 端的为输入缓冲区。这两个缓冲区都是有容量限制的,会根据数据消费的速率动态调整生产速率,确保整体任务的稳定性以及多租户之间的公平性。当输出缓冲区容量持续偏高时,Presto 会减少可消费的 split 数量。输入缓冲区这端会有一个类似 TCP 滑动窗口的策略动态控制上游的生产速率。
回顾开篇总结的 Presto 特点,其中很重要的一个是自适应的多租户场景,上一段落介绍 shuffle 缓冲区的时候其实已经涉及到部分针对性的优化。本质上资源管理需要考虑的就是 CPU 和内存这两种资源,Presto 分别都有不同的解决方案。
CPU 调度场景每个 split 都会有一个允许在一个线程上一秒内执行的最大 quanta,当 quanta 超出时这个 split 将会被放回队列释放线程给其它 split。当输出缓冲区满(下游消费慢)、输入缓冲区空(上游生产慢)或者集群内存紧张时,即使 quanta 没使用完调度器也会强行切换任务。这个基于 quanta 的调度策略使得 Presto 能够最大化 CPU 资源的利用率。当线程被释放应该如何挑选下一个运行的任务呢?Presto 建立了一个 5 级的反馈队列(feedback queue),每个等级都分配了一个可配置的 CPU 时间比例。随着一个任务使用的 CPU 时间不断累积,这个任务会移动到更高等级的队列。也就是说 Presto 倾向于优先执行那些「快」的任务,因为用户期望轻的查询尽快完成,而对于那些重的查询所需的时间不太敏感。
内存管理是一个比 CPU 更复杂的场景。Presto 将内存分为两种类别:用户(user)和系统(system),并分别维护不同的内存池。引擎对用户内存和总内存(用户 + 系统)都有不同的限制,超过全局(所有 worker 聚合以后)或者单节点内存限制的任务将会被强行杀掉。虽然有全局的内存限制,但是为了满足并行执行多个任务的需求通常还是会超卖(overcommit)内存,即使真的出现部分节点内存耗尽的情况,Presto 也提供了两种机制去确保整体集群的稳定性。这两种机制分别是:spilling 和预留内存池(reserved pool)。Spilling 其实就是在节点内存耗尽时按照任务的执行时间升序排列,依次把内存中的状态写到本地磁盘。不过 Facebook 内部并没有开启这个特性,因为集群资源(TB 级的内存)足够支撑用户的使用场景,全内存计算也更加能保证查询的执行时间。如果没有开启 spilling 特性,那 Presto 将会采用预留内存池的策略。这个策略的大意是把内存池分为通用(general)和预留(reserved)两种,当一个 worker 节点的通用内存池耗尽时将会把这个节点上占用最多内存的查询「晋升」到预留内存池,整个集群同一时间只允许一个查询晋升。后续的内存申请会优先满足这个晋升的查询,直到它执行完毕。这个策略当然会影响整体集群的效率,因此用户也可以选择直接杀掉查询。
最后是容错(fault tolerance)。作为一个多租户的分布式系统,优良的容错性是一个必不可少的需求。但是遗憾的是在这一点上 Presto 做得并不好,coordinator 依然是单点(一个题外话,Starburst Data 这家提供商业 Presto 版本的公司支持 coordinator HA),worker 宕机将会导致所有运行在这个节点上的查询失败(社区有一些 issue 但是目前没有进展),Presto 非常依赖客户端自己去重试。Facebook 内部是通过外部的编排系统来确保集群的可用性,对于交互式分析和 ETL 场景有 standby 的 coordinator,A/B 测试和开发者/广告主分析场景部署了多活(multiple active)集群。监控系统将会识别不可用的节点自动从集群中移除,并在之后再重新加入集群。
在开发 Presto 的过程中,作者也总结了一些工程上的经验:
最后是一个八卦。Presto 最早是由一批 Facebook 的员工开发,2018 年这批员工中的部分核心离职,全职建设 Presto 开源社区。2019 年 1 月 31 日成立「Presto Software Foundation」,并在 GitHub 上创建了新的组织 PrestoSQL。有趣的是在 2019 年 9 月 23 日 Facebook 联合多家公司成立了一个新的基金会叫「Presto Foundation」,在 GitHub 上的组织叫 PrestoDB。按照 Presto 作者的说法,他们在成立 Presto Software Foundation 之后其实是有邀请过 Facebook 加入的,但是显然对方拒绝了这个邀请。于是你会发现目前在开源社区有两个版本的 Presto,并且项目名是一样的,不过为了便于区分一般还是分别叫做 PrestoSQL 和 PrestoDB。前者背后的商业公司主要是 Starburst Data,这家公司的 3 个 CTO 同时也是 Presto 的原始作者(是的,这家公司有 3 个 CTO);后者背后的商业公司有 Facebook、Uber、Twitter、阿里巴巴、Alluxio 和 Ahana。为了不至于让用户混淆,Starburst Data 还在官网比较了这两个版本的 Presto。目前公有云厂商提供的产品中,AWS Athena、阿里云 DLA 都是基于 PrestoDB 开发的 serverless 产品,AWS EMR 两种 Presto 都支持,Google Dataproc 1.5、阿里云 EMR 3.25.0 以后默认集成的是 PrestoSQL,腾讯云 EMR 默认集成的是 PrestoDB。
要理解什么是 shuffle 就得先了解什么是 MapReduce,自从 2004 年 Google 那篇惊世骇俗的介绍 MapReduce 的论文发表以来,大数据的生态就被彻底改变了(并沿用至今)。基于这样一个简单的编程模型实现了各种复杂的计算逻辑,但也存在一些「问题」,shuffle 就是其中一个。当 map 任务完成以后,数据需要根据 partition 策略重新分配到不同的 reduce 任务中,这个过程即称为 shuffle。这篇文章详细介绍了 Spark 历史上各种 shuffle 方案是怎么实现的。
接上一篇文章,这是 Facebook 在 2018 年的 Spark+AI Summit 上的一个分享,介绍了他们实现的一个外部 shuffle 服务 Cosco,可以同时用于 Hive 和 Spark 任务。当时已经在 90%+ 的 Hive 任务上使用,并在生产环境运行 1 年以上,Spark 任务也在逐渐推广中。为什么要开发一个外部 shuffle 服务呢?Facebook 列举了一些他们当时面临的问题,比如单次 shuffle 需要交换的数据量级是 PiB 级,总共有 10 万个 mapper、1 万个 reducer,3 倍的写放大(shuffle 1 PiB 的数据实际要写 3 PiB 到磁盘),平均 IO 大小只有 200 KiB。这些都是促使他们开发 Cosco 的原因(当然不是所有公司都会遇到),另一个好处是 executor 变成了无状态,对于动态伸缩更加友好。如果对 Cosco 有兴趣还可以继续看一看他们在 2019 年的 Spark+AI Summit 上做的后续分享。
传统机器学习中的优化算法(例如 SGD)是将大规模数据集分布式运行在多个节点上,这需要低延时、高吞吐地读取训练数据,因此数据一般都是提前收集到一个中心化存储里。但是在某些场景并不适合这样做,不管是因为数据量太大不易收集,还是出于数据隐私的考虑。因此 Google 提出了联邦学习(Federated Learning)的概念,这个词源于发表在 2017 年 AISTATS 会议上的一篇论文 Communication-Efficient Learning of Deep Networks from Decentralized Data。联邦学习的大体思想就是在数据的生产端(例如你的手机)直接进行模型训练,经过汇总以后把对模型的更新数据发送到服务端,服务端再把其它客户端上传的更新数据一起汇总生成一个新的模型,最后下发这个新模型到所有客户端。可以看到整个过程中训练数据依然保留在客户端,并不需要上传。如果你在 Android 系统中使用 Gboard 这个 app,那其实你已经参与到联邦学习的过程中了,当然只会在当你的手机空闲并且连接电源和 Wi-Fi 的时候才会进行。
2003 年 Google 发表了 The Google File System 论文,就像前面提到的 MapReduce 一样,从此对业界产生了非常深远的影响。这篇博客梳理了 GlusterFS、CephFS、GFS、HDFS、MooseFS 和 JuiceFS 这几个分布式文件系统的架构设计。随着网络带宽的发展,在云计算和云原生的大趋势下,总的来说正逐步朝着存储计算分离的方向演进,这对于基础设施的架构也有着一定的要求。
]]>「Maybe News」是一个定期(或许不定期)分享一些可能是新闻的知识的系列文章,名字来源于我非常喜欢的一个国内的音乐厂牌「兵马司」(Maybe Mars)。你也可以通过邮件订阅它。
在第一期 Maybe News 中介绍了腾讯提出的解决 TensorFlow 中大规模稀疏特征模型训练的方案,本期的这篇论文来自 Google(准确说是 Google Smart Campaigns 团队)。作为发明 TensorFlow 的公司,Google 内部团队的设计思想值得借鉴。
这个系统被称之为 DynamicEmbedding(DE),名字简单直观,要解决的场景也是很多公司都遇到的如何动态维护 embedding。系统内部分为两个组件:DynamicEmbedding Master(DEM)和 DynamicEmbedding Worker(DEW),合起来叫做 DynamicEmbedding Service(DES)。DEM 负责处理所有客户端请求,包括 embedding 查找(lookup)、更新(update)等。DEW 负责 embedding 存储、梯度更新等,所有请求都来自 DEM。同时新增了几个 TensorFlow API,如 dynamic_embedding_lookup()
、compute_sampled_logits()
,这些 API 是整个系统的关键入口,任何模型在接入 DES 的时候都需要在特定的地方调用这些 API。以上设计看起来跟大部分公司的方案没有太大差别。
通过实现一个叫做 EmbeddingStore 的通用接口,DEW 后端支持对接多种类型的存储,例如 Protocol Buffers、GFS、Bigtable,比较巧妙地将大规模 embedding 存储时面临的扩展性和稳定性问题转移到了外部存储系统。当然因为多了一次网络请求是否会影响整体的训练效率这点有待商榷,论文中介绍 BigtableEmbedding 时提到会将数据同时存储到本地缓存和远端,猜测这里本地缓存的目的便是为了加速存储操作。
Embedding 更新这一步涉及到一些常用的梯度下降(gradient descent)算法,为了保持一致,DEW 内部实现了跟 TensorFlow 原生提供的优化器(optimizer)同样的逻辑,并且大部分代码是可以复用的。当训练数据时间跨度很大时(如数月或者数年),可能存在很多无效的特征或者一些需要特殊处理的特征。因此 DEW 在每次 embedding 更新的时候会同时统计这个 embedding 的更新频率,通过设定一个恰当的阈值来保证只有部分 embedding 会持久化到存储系统里,那些低频的数据便不会继续保存。除了统计频率这种方法,通过 bloom filter 也可以实现类似的效果。
Serving 的时候因为 embedding 都已经存储到了外部系统,所以 DEW 就没有必要存在了,只需要在本地部署 DEM 负责处理读请求。为了提升推理的性能,本地缓存肯定是少不了的,同时批量处理查询请求也是非常重要的。
实验评估阶段首先比较了和原生 TensorFlow 训练同样的模型、同样的超参是否会有指标上的差异,模型选择的是 Word2Vec,梯度下降算法选择的是 SGD、Adagrad 和 Momentum。从最终训练的 loss 上看几乎没有差别,说明 DE 系统不会对模型质量有影响。
接着测试了字典(dictionary)大小对模型精度(accuracy)的影响,理论上 DE 系统其实是不限定字典大小的,从实验的两个模型 Word2Vec 和 Sparse2Seq 上来看也的确是字典大小越大模型精度越高。
然后是评测模型训练时两个重要的系统指标:集群总的内存占用和每秒训练的 global steps(GSS)。分别测试了三个模型:Word2Vec,Image2Lable 和 Seq2Seq。在使用原生的 TensorFlow 时集群内存占用会随着 worker 数量的增大而显著增长(在 Word2Vec 模型中尤为明显),相比之下 DE 系统的内存占用只跟 embedding 的总大小有关,与 DEW 的数量无关。之所以有这样的差异也是因为原生的 TensorFlow 会在不同 worker 间重复存储 embedding 数据。GSS 的对比上两者的加速比都差不多,但是总体上 DE 还是会更优。
最后论文中详细介绍了 DE 在 Google Smart Campaigns 产品中的一个重要应用:给广告主自动推荐投放的关键词。这是一个叫做 Sparse2Label 的模型,输出即是推荐的关键词(label)。这个模型带来的最大变化是以前需要针对每一种语言训练一个单独的模型,而现在只需要一个模型即可。通过对比一些核心指标(如 CTR),DE 推荐的关键词都明显更好。整个模型也是随着时间不断增长的,截止 2020 年 2 月这个模型的参数量已经达到了 1249 亿个,如果每个参数按 4 字节算的话模型大小差不多为 465 GiB(其实比想象中小)。
另一个更难评估的指标是用户搜索的关键词(query)与广告投放的关键词之间的关联度,很多时候两者之间并不是完全匹配的。作者是通过人工评估 38 万个样本的方式来解决的,每个样本都会有 5 个人类进行打分,分数区间从 -100 到 100,越高越匹配,然后计算这 5 个分数的平均值作为这个样本的最终分数。大于等于 50 分的样本认为是好(good)的样本,小于等于 0 分的则认为是不好(bad)的样本,前者除以后者被称作 GB ratio,这个比率越大越好。每个推荐的关键词都会同时有一个置信值(也就是网页和关键词 embedding 之间的 cosine 距离),从评测结果上来看当这个置信值大于 0.7 时,不好的样本量将会显著减少。实际生产环境收集的数据也印证了 DE 系统推荐的关键词是 GB ratio 最高的。
总结一下 DE 系统解决了原生 TensorFlow 在大规模 embedding 模型训练时效率低下(甚至不可用)的问题,短期内这个系统估计也不会开源或者合并到上游。目前可以期待的还是腾讯的方案,他们已经提交了代码到 TensorFlow 社区。
在第二期 Maybe News 中曾经介绍过 Go 语言开发者关于泛型设计的一些思考,近期 Ian Lance Taylor 又和社区同步了一下最新进展。最大的变化就是去掉了 contract 这个新增的概念,改为复用 interface。同时创建了一个新的 playground,可以方便大家试验泛型代码。如果最新的这一版设计社区没有太大异议的话,乐观估计将会在 Go 1.17 加入泛型特性,也就是在 2021 年 8 月左右。当然最终实现这个目标还是有很多的不确定性,特别是当前疫情对于全球影响的情况下。
分布式计算在 AI 领域的需求一直都很强烈,但分布式计算不仅仅是将单机迁移到多机这样就足够了,还需要考虑如:易用性(降低用户从单机迁移的成本)、稳定性(自动容错)、弹性伸缩(和底层资源调度层配合)、线性加速(横向扩展多少机器就能带来多少性能提升)。Uber 和 OpenAI 共同开发的 Fiber 框架便是尝试解决以上问题的一个例子,从 Fiber 的论文能看到这个框架最初设计面向的是强化学习(Reinforcement Learning)场景,在这个领域有很多类似的框架,比如 Google 的 Dopamine、Facebook 的 ReAgent、UC Berkeley 的 Ray。自动容错和弹性伸缩这两个特性又让我联想到蚂蚁金服的 ElasticDL 和才云的 FTLib。
LinkedIn 分享了他们使用 NFS 进行数据库备份时遇到的性能问题,因为备份进程和数据库进程是一起部署的,因此这个问题还间接影响到了在线业务的稳定性。整个问题分析过程清晰易懂,还能顺便复习一下大学里学习的计算机网络和操作系统的一些知识。但问题的根源 NFS 服务的性能为什么这么差还是没有特别好的解决方案,可能在这个场景里 NFS 就不是特别好的选择吧。
Jupyter Notebooks 是当下数据科学家或者算法工程师日常工作非常重要的一个组件,交互式的界面加上即时的代码运行反馈极大地提升了开发效率。但是如果要将机器学习任务提交到集群中运行往往还得依靠类似 Kubeflow Pipelines 这种 DAG 管理及调度组件,Kubeflow Pipelines 有一套基于 Python API 的语法,因此用户需要再重新定义一个独立的 pipeline。有没有办法直接将 notebook 中已经验证过的代码自动转换成 pipeline 并提交到集群呢?Kale(Kubeflow Automated PipeLines Engine)即是为了解决这个问题而诞生,它是一个能够将 Jupyter Notebooks 自动转换为 Kubeflow Pipelines 的工具。在最新的 0.5 版本中 Kale 新增了对 Katib 的集成,后者是进行自动超参调优(Hyperparameter Tuning)和神经网络架构搜索(Neural Architecture Search)的组件。
Spark 3.0 为动态资源分配(dynamic resource allocation)新增了 shuffle tracking 特性(默认关闭),具体实现可以查看 SPARK-27963。当使用动态资源分配时用户需要预先设定诸如初始、最小和最大 executor 数量这样的参数,之后 Spark 运行时会根据当前任务排队时间和 executor 空闲时间这些指标去创建或者销毁 executor。对于有状态的 executor(如 shuffle 时存储到磁盘的数据、cache 到内存和磁盘的数据)会有一些特殊的策略防止错误回收资源,过去的做法是使用外部 shuffle 服务。开启 shuffle tracking 以后就不再依赖外部 shuffle 服务,而是设置一个 executor 持有 shuffle 数据的超时时间。过去 Spark 的 K8s 模式不支持外部 shuffle 服务,有了这个新的特性以后使得动态资源分配在 K8s 模式上成为可能。spark-on-k8s-operator 项目近期也支持了这个特性,可以直接通过 YAML 配置来开启。
本期最后推荐一张来自我的一个好朋友的唱片,Boiled Hippo 是一支北京的迷幻摇滚乐队,经过多年的演出积累终于在今年发行了乐队的第一张同名专辑。虽说是迷幻摇滚,但如果从旋律上讲绝对是非常「好听」的。如果你有兴趣购买实体唱片(黑胶、磁带、CD 都有),目前可以在北京的 fRUITYSPACE、fRUITYSHOP、独音唱片,上海的 Daily Vinyl,金华的 Wave 这几个地方购买。
]]>「Maybe News」是一个定期(或许不定期)分享一些可能是新闻的知识的系列文章,名字来源于我非常喜欢的一个国内的音乐厂牌「兵马司」(Maybe Mars)。你也可以通过邮件订阅它。
AliGraph 是阿里巴巴团队研发的 GNN(Graph Neural Network)分布式训练框架(虽然标题里是「平台」但感觉还算不上),论文发表在 KDD 2019 和 PVLDB 2019。
论文开篇便提出了当下 GNN 模型训练的 4 个挑战:
后面的篇章便是详细介绍 AliGraph 如何解决以上这 4 个问题。框架从上至下整体分为 3 层:算子(operator)、采样(sampling)、存储(storage)。算子层包含常见的 GNN 运算操作,采样层包含几种预设的采样算法,存储层主要关注如何高效对大规模图进行分布式存储。在这 3 层基础之上可以实现任意的 GNN 算法以及应用。
存储层因为是要解决一个图的分布式存储问题,因此首先要将图进行分割(partition)。AliGraph 内置了 4 种图分割算法:METIS、顶点切割和边切割、2D 分割、流式分割。这 4 种算法分别适用于不同的场景,METIS 适合处理稀疏(sparse)的图,顶点切割和边切割适合密集(dense)的图,2D 分割适合 worker 数量固定的场景,流式分割通常应用在边(edge)频繁更新的图。用户需要根据自己的需求选择恰当的分割算法,当然也可以通过插件的形式自己实现。
另一个存储层关心的问题是如何将图结构和属性(attribute)共同存储。这里讲的图结构即顶点和边的信息,这是最主要的图数据。同时每个顶点也会附加一些独特的属性,例如某个顶点表示一个用户,那附加在这个用户上面的属性就是类似性别、年龄、地理位置这样的信息。如果直接将属性信息和图结构一起存储会造成非常大的空间浪费,因为从全局角度看同一种类型的顶点的属性是高度重合的。并且属性与图结构的大小差异也非常明显,一个顶点 ID 通常占用 8 字节,但是属性信息的大小从 0.1KB 到 1KB 都有可能 。因此 AliGraph 选择将属性信息单独存储,通过两个单独的索引分别存储顶点和边的属性,而图结构中只存储属性索引的 ID。这样设计的好处自然是显著降低了存储所需的空间,但代价就是降低了查询性能,因为需要频繁访问索引来获取属性信息。AliGraph 选择增加一层 LRU 缓存的方式对查询性能进行优化。
存储层关心的最后一个问题也是跟查询性能有关。在图算法中一个顶点的邻居(neighbor)是非常重要的信息,邻居可以是直接(1 跳)的也可以是间接(多跳)的,由于图被分割以后本地只会存储直接的邻居,当需要访问间接邻居的时候就必须通过网络通信与其它存储节点进行交互,这里的网络通信代价在大规模图计算中是不容忽视的。解决思路也很直接,即在每个节点本地缓存顶点的间接邻居,但要缓存哪些顶点的邻居,要缓存几个邻居是需要仔细考量的问题。AliGraph 没有使用目前常见的一些缓存算法(如 LRU),而是提出了一种新的基于顶点重要性(importance)的算法来对间接邻居进行缓存。在有向图中计算一个顶点重要性的公式是 入邻居的个数 / 出邻居的个数
,注意这里的邻居个数同样可以是直接的或者间接的。当这个公式的计算结果大于某个用户自定义的阈值时即认为这是一个「重要」的顶点。从实际测试中得出的经验值是通常只需要计算两跳(hop)的邻居个数就够了,而阈值本身不是一个特别敏感的数值,设置在 0.2 左右是对于缓存成本和效果一个比较好的平衡。选出所有重要的顶点以后,最终会在所有包含这些顶点的节点上缓存 k 跳的出邻居(out-neighbor)。
GNN 算法通常可以总结为 3 个步骤:采样(sample)某个顶点的邻居,聚合(aggregate)这些采样后的顶点的 embedding,将聚合后的 embedding 与顶点自己的进行合并(combine)得到新的 embedding。这里可以看到采样是整个流程中的第一步,采样的效果也会直接影响后续计算的 embedding 结果。AliGraph 抽象了 3 类采样方法:遍历采样(traverse)、近邻采样(neighborhood)和负采样(negative)。遍历采样是从本地子图中获取数据;近邻采样对于 1 跳的邻居可以从本地存储中获取,多跳的邻居如果在缓存中就从缓存中获取否则就请求其它节点;负采样通常也是从本地挑选顶点,在某些特殊情况下有可能需要从其它节点挑选。
在采样完邻居顶点以后就是聚合这些顶点的 embedding,常用的聚合方法有:element-wise mean、max-pooling 和 LSTM。最后是将聚合后的 embedding 与顶点自己的进行合并,通常就是将这两个 embedding 进行求和。为了加速聚合和合并这两个算子的计算,AliGraph 应用了一个物化(materialization)中间向量的策略,即每个 mini-batch 中的所有顶点共享采样的顶点,同样的聚合和合并操作的中间结果也共享,这个策略会大幅降低计算成本。
在最后的评估环节用了两个来自淘宝的数据集,两个数据集之间只有大小的区别,大数据集是小数据集的 6 倍左右。大数据集的基础数据是:4.8 亿个用户顶点,968 万个商品顶点,65.8 亿条用户到商品的边,2.3 亿条商品到商品的边,用户平均有 27 个属性,商品平均有 32 个属性。当使用 200 个 worker(节点配置论文中没有说明)时大数据集只需要 5 分钟即可将整个图构建完毕,相比之下以往的一些方案可能需要耗费数小时。基于顶点重要性的缓存算法相比 LRU 这些传统算法也是明显更优。3 类采样方法的性能评估结果从几毫秒到几十毫秒不等,但最长也不超过 60 毫秒,并且采样性能与数据集大小不太相关。聚合和合并算子相比传统的实现也有一个数量级的性能提升,这主要得益于前面提到的物化策略。
AliGraph 目前已经开源(一部分?)但是换了一个名字叫做 graph-learn,跟大多数深度学习框架一样,底层使用 C++ 语言实现并提供 Python 语言的 API,目前支持 TensorFlow,未来会支持 PyTorch。有意思的是刚刚开源不久就有人提了一个 issue 希望能够跟另外几个流行的 GNN 框架进行比较,但是项目成员的回答比较含糊。
Uber 应该是除了 Google 以外很早选择在后端服务中大规模使用 Go 语言的公司之一,并贡献了很多著名的 Go 语言项目(如 zap、Jaeger)。早在 2017 年,Uber 的 Android 和 iOS 团队就已经只使用一个代码仓库进行开发,俗称 monorepo。实践 monorepo 最著名的公司应该还是 Google,有兴趣可以看看 Why Google Stores Billions of Lines of Code in a Single Repository 这篇文章。现在后端团队也开始采用 monorepo 来管理 Go 语言项目,但是和客户端团队的不同之处在于没有用 Buck 而是用 Bazel(前者是 Facebook 开源,后者是 Google 开源)。这篇文章介绍了在 monorepo 中将 Go 语言和 Bazel 结合遇到的一些问题。
恐怕大多数时候接触容器是从构建一个 Docker 镜像开始的,这一步往往也是最容易被忽视的。为什么我的镜像这么大?为什么每次拉取镜像都要从头开始?这些问题可能会随着使用时间越来越长逐渐浮现出来,要回答它们需要了解 Docker 镜像的一个核心概念「layer」,本质上你在 Dockerfile
里写的每一行命令都会生成一个 layer,一个镜像便是由很多 layer 构成。Layer 之间是有层级关系的,当拉取镜像时如果本地已经存在某个 layer 就不会重复拉取。在传统的 Linux 发行版中安装依赖时 Docker 是不知道具体有哪些文件被修改的,而 Nix 这个特殊的包管理器采用了不一样的设计思路使得安装依赖这件事情对于 Docker layer 缓存非常友好。衍生阅读推荐 Jérôme Petazzoni 写的关于如何减少镜像大小的系列文章。
Interface 是 Go 语言一个重要的特性,类似很多其它语言中的概念,接口定义好以后是需要通过 struct 来实现的。但不同之处又在于 struct 不需要显式声明实现了什么 interface,只要满足 interface 中定义的接口就行,这个关键设计使得 Go 语言的 interface 使用场景可以非常灵活。跟 struct 一样 interface 也允许嵌套,也就是可以在一个 interface 定义中嵌套另一个 interface。如果同时嵌套了多个 interface,并且这些 interface 之间有重复的接口在编译时是会报错的。实际开发过程中为了规避这个限制可能需要修改 interface 的定义,这对于开发者来说不太友好。上面这个提案允许开发者在不修改代码的情况下避开这个限制,目前这个功能已经在 Go 1.14 中发布。
不管是音乐创作还是音乐演奏,乐谱都是一个必不可少的东西。还记得刚学吉他那会儿非常热衷的一件事情就是去网上搜集各种歌曲的六线谱,这些乐谱的格式从最朴素的纯文本到高级的 Guitar Pro 格式都有。再后来开始学习扒歌,也面临把扒下来的谱子纪录下来的需求。虽然 Guitar Pro 很好但毕竟是一个收费软件,文件格式也是私有的。就像我更喜欢 Markdown 而不是直接用 Word 一样,一直希望能有一个类似的标记语言用于编写乐谱。VexTab 即是这样一个专门用于编写五线谱和六线谱的语言,也提供一个 JavaScript 库方便嵌入到网页中。有意思的是 VexTab 的作者同时也是 Google 的一名员工。
]]>「Maybe News」是一个定期(或许不定期)分享一些可能是新闻的知识的系列文章,名字来源于我非常喜欢的一个国内的音乐厂牌「兵马司」(Maybe Mars)。你也可以通过邮件订阅它。
OLAP(Online Analytical Processing)一直是大数据领域非常重要的应用场景,光有数据也不行,你得「分析」啊。自从有了 Hadoop,OLAP 的工具就一直在演变,从最早的裸写 MapReduce 任务,到 Pig、Hive、Presto、Impala、Druid、ClickHouse,以及今天要介绍的 Kudu。一个明显的趋势是 OLAP 引擎在逐步朝着「去 Hadoop 化」和「实时化」发展,当然这些项目里最新的也已经是 2016 年发布的了,接下来会怎么变化还是个未知数。
先讲讲为什么会有类似 Kudu 这样的项目诞生。传统的 OLAP 引擎因为是构建在 HDFS 上的,要想分析数据首先得将数据存储到 HDFS 上,而这个过程(通常叫做 ETL)往往是比较耗时以及复杂的。同时由于 HDFS 天生不支持随机读写,为了弥补这个「缺陷」,有了 HBase 这样的项目。但 HBase 对于 OLAP 场景是不够友好的,因此往往需要把数据从 HBase 再导入到 HDFS 中,这个过程也可能比较耗时,维护成本也比较高。因此 Kudu 的目标是实现一个即支持随机读写(主要是写),又针对大批量查询进行优化的存储引擎。这种时候 HDFS 就显得很累赘了,这也是为什么越来越多引擎选择不依赖 HDFS 的缘故(Kudu 官网也在 FAQ 中专门解释了为什么不用 HDFS)。当然并不是说 HDFS 就没用了,有很多数据还是非常静态的,对于实时性要求也不高,此时用 HDFS 是一种简单经济的选择。
这篇论文虽然介绍的是 Kudu 早期的一些设计思想,但基本上属于最核心的功能。跟很多分布式数据库一样,Kudu 也是受 Spanner 启发。系统架构上分为一个 Master 服务和若干 Tablet 服务。Master 负责维护元信息,包括 Tablet 节点和数据的。Tablet 服务则负责数据存储,每台节点上会有几十至数百个 tablet,每个 tablet 中包含了若干数据,最大可以达到几十 GB 的规模(这里你可以把 tablet 类比为很多别的系统中的 region 概念)。
跟很多关系数据库一样,Kudu 是有 table 的概念的。但跟很多 NoSQL 数据库不一样的地方是,强制用户必须显式定义 schema。Kudu 一个有意思的设计在于同时支持了 hash 和 range 这两种数据 partition 方法,而不像别的系统只支持其中一种(有关这两种 partition 的介绍可以看我之前的一篇文章)。这样设计的好处是即保留了 hash 的数据均匀分配特点,可以在一定程度上防止读写热点,又保留了 range 对于范围扫描的友好性。
Tablet 服务之间是通过 Raft 来进行数据复制,因此可以认为 Kudu 是一个保证强一致性的存储系统。值得注意的是 Kudu 的默认设置是 500 毫秒的心跳间隔以及 1.5 秒的选举超时,这个跟 Raft 论文推荐的时间相比长了不少(推荐的选举超时是 150~300 毫秒)。当集群扩容时,新节点将会首先进入 PRE_VOTER
状态,等到 log 追上以后再变成 VOTER
状态,这个设计也是 Raft 论文中建议的,不过论文中叫做 learner 或者 non-voting member。Master 服务虽然是单点设计(即状态不是分布式存储),但为了保障高可用也可以通过 Raft 实现多节点状态复制,只不过任意时间只能有一个节点工作。
Kudu 的数据存储引擎是完全自己设计的,没有直接用任何现有的引擎,虽然也能多少看出一些别的引擎的影子。关于这一点可以理解,OLAP 系统区别于 OLTP (Online Transactional Processing)系统的最大不同即在于数据存储的形式,简单理解后者是行式(row-oriented)存储,而前者是列式(column-oriented)存储。著名的 Parquet 就是广泛被用于 OLAP 场景的列式存储格式,Kudu 在实现上也复用了很多 Parquet 的代码。
每个 table 在存储级别会被分割为多个 RowSets,顾名思义每个 RowSets 是由很多行(row)组成,RowSets 之间不会有重复的数据,但主键的范围可能会交叉。
当有新的数据时会首先存储到内存中的 MemRowSets,底层实现是一个使用乐观锁(optimistic locking)的并发(concurrent)B 树。比较特别的一点是数据并不是一开始就按照列式进行存储,MemRowSets 中还是用的行式存储。当数据累积到一定程度 MemRowSets 就会持久化到磁盘上,称之为 DiskRowSets,每个 DiskRowSet 大小上限是 32MB。DiskRowSet 由两部分组成:基础数据(base data)和增量数据(delta stores)。
基础数据是列式格式,即每一列都单独连续存储,每一列内部又划分成了多个小的页(page),有一个 B 树根据行号索引了这些页。每一列可以由用户指定不同的编码(encoding)方法(如 dictionary encoding、bitshuffle、front coding),同时也可以使用通用的压缩算法对数据进行压缩(如 LZ4、gzip、bzip2),基于列的编码及数据压缩是列式存储非常大的一个特点。
增量数据也分为内存和磁盘两种形式。内存中的叫做 DeltaMemStores,这个跟 MemRowSets 的实现一样。磁盘中的叫做 DeltaFiles,是一个二进制类型的列块(column block)。不管是内存还是磁盘上的数据都会有一个额外的从 (row_offset, timestamp)
到 RowChangeList 的映射,row_offset
是某一行在一个 RowSet 中的偏移,RowChangList 是二进制编码以后的增量操作(如更新某一列、删除某一行)列表。同样的,DeltaMemStores 也会持久化到磁盘上变成 DeltaFiles。
这些增量数据会定期跟基础数据进行合并(compation),以防止过多的增量文件。同时 DiskRowSets 之间也会进行合并,目的是清理已经被删除的行以及减少 DiskRowSets 之间的主键交叉范围。
前面提到的将内存中的数据持久化到磁盘及对数据进行合并操作,都是由一组单独的后台任务来完成,但是什么时候执行什么操作是由一个调度器来控制的。有趣的是 Kudu 将调度器的逻辑抽象成了一个背包问题,只不过需要权衡的不是背包容量,而是 I/O 带宽。
Kudu 本身只提供编程语言级别的 API(如 Java、C++),而 OLAP 系统中常用的 SQL 需要配合其它项目来实现。Kudu 原生已经跟 Spark 和 Impala 集成,也就是说你可以在这两个系统中通过 SQL 来查询。
最后是性能评测。在 TPC-H 数据集上与 Parquet 进行对比测试,Kudu 平均有 31% 的性能提升,尽管如此 Kudu 团队认为随着 Parquet 的迭代这个差距可能会逐渐缩小。在与 Phoenix 的对比测试中也有 16~187 倍的性能提升。最后与 HBase 进行的 YCSB 测试是为了看看 Kudu 在 OLTP 场景的性能,虽然它本身并不是为 OLTP 场景而设计,结果上的确也是 HBase 表现更好,但 Kudu P99 6 毫秒的响应时间在某些时候也许可以代替 OLTP 系统。
Kudu 由 Cloudera 公司开发并于 2016 年正式发布,现已捐献给 Apache 基金会,整个系统使用 C++ 语言编写。
顺带说个题外话这两年炒得比较火的 HTAP(Hybrid Transactional/Analytical Processing),本质上是希望在一个引擎中同时适配 OLTP 和 OLAP 这两个场景。但在我看来就目前的技术现状这个愿景实现起来还是比较困难,软件工程界的名言「没有银弹」告诉我们不存在一个可以通吃的、完美的方案,因此 HTAP 目前更多还只是一个营销概念吧。
又又又又又一个受 Spanner 启发的分布式存储(Google 功德无量!Jeff Dean 万寿无疆!),这次的项目来自字节跳动。关键词:range 分割、Raft、RocksDB、MVCC、分布式事务、SQL 层,看看这些也基本能对整体设计猜个八九不离十了,比较有价值的信息是学习学习字节跳动在他们的实践中的一些经验。项目使用 C++ 语言编写,目前没有开源。
随着深度学习的蓬勃发展,GPU 共享逐渐成为了 K8s 社区的一个热门话题。目前 NVIDIA 官方提供的设备插件可以申请的最小资源粒度还是 1 个 GPU,但很多时候资源是浪费的。为了提升 GPU 的资源利用率,社区已经出现了多种解决方案,例如分别来自阿里云、腾讯云以及 AWS 的实现。现在 NVIDIA 官方终于在新一代的 Ampere 架构硬件上原生支持了共享,也就是标题中的 MIG(Multi-Instance GPUs)。这篇文档来自 NVIDIA 团队,首先介绍了当前是如何在 K8s 中管理 GPU 资源的,然后介绍了 MIG 的一些概念,最后提议了 4 个支持 MIG 的可能的解决方案。整体感觉 GPU 共享还是没有 CPU 灵活,不少地方设置了限制,但毕竟这是 NVIDIA 官方迈出的第一步。
Reddit 上一个有趣的讨论:如何阅读深度学习的论文?我们常常调侃机器学习就是在「炼丹」,没有人能解释为什么结果就是有效的,反正「it just works」。最高票的评论说你不需要接受论文中的每一个观点,只要把注意力集中在作者提供的证据并有选择性地调整你的想法就好了。正好前段时间前微软执行副总裁沈向洋博士做了一个主题名为「You are how you read」的演讲,主要内容就是一些阅读论文的经验(有趣的是沈博士在几年前还写过一篇叫做「You are what you write」的博客)。
TensorFlow 核心开发者、Google Brain 团队的 Derek Murray 宣布离开,这位大哥在 GitHub 和 Stack Overflow 上都很活跃,如果你经常浏览社区应该对他的头像不陌生。在这篇告别文中 Murray 提到了很多 Google 内部帮助工程师解决问题的工具,并详细介绍了近期对 TensorFlow 底层运行时进行的一项性能优化的过程,在部分评测中可以提升 10% 的推理性能。这个优化目前已经合入 master,并将在 TensorFlow 2.3 发布。
]]>「Maybe News」是一个定期(或许不定期)分享一些可能是新闻的知识的系列文章,名字来源于我非常喜欢的一个国内的音乐厂牌「兵马司」(Maybe Mars)。你也可以通过邮件订阅它。
终于有机会仔细阅读一遍 Raft 的论文,如果你还不了解 Raft 是什么可以看看我过去的一篇介绍分布式系统基础概念的文章。
Raft 为节点定义了三种状态:leader、follower 和 candidate(以及一个非正式状态 learner 或者叫做 non-voting member)。一个集群只会有 1 个 leader,其余节点都是 follower。Leader 负责处理所有的读写请求,如果请求 follower 会失败并告知客户端 leader 的地址。
每个节点都有一个自己的 log,log 中每个条目都有一个下标(index)。这个 log 基本算是 append-only 的,通常也需要持久化到可靠的存储上(例如磁盘)。当处理写请求时 leader 会首先更新自己的 log,然后通过 RPC 复制到其它节点,只要大多数(majority)节点更新成功 leader 就会认为这个请求已经 committed,此时会更新自己的状态机(state machine)并返回给客户端。如果 RPC 请求失败 leader 会不断重试直到成功。
如果出现异常,如 leader 宕机、网络故障等,就可能触发 leader 重新选举。选举过程是所有 follower 为 candidate 投票,只要获得多数票 candidate 就会升级为 leader。如果投票失败会继续新一轮选举,选举过程通常是毫秒级的。每一轮新的选举都会产生一个对应的 term(任期),Raft 在协议上保证了重新选举后的新 leader 一定是包含之前所有 term 已经 committed 的 log,这样就避免了新 leader 选举成功以后需要首先补上缺失的数据。
当集群需要伸缩时,leader 会首先将旧集群配置(configuration)和新集群配置合并到一起并通过 log 的形式复制到 follower。成功收到这个合并后配置的节点会用这个配置替代老的配置。一旦这个合并后的配置 committed,leader 就会创建一个只包含新配置的 log 继续复制到 follower。等到新的配置 committed,旧配置将不再生效,需要下线的节点也可以被安全关闭。
随着时间增长,log 的容量会越来越大,Raft 引入了快照(snapshot)机制,定期将 log 压缩到快照文件。这个快照文件同时也可以帮助新加入的节点快速补上缺失的数据。
总结一下 Raft 算法保证了以下几个属性始终成立:
更多有关 Raft 的信息可以查看它的官网,强烈建议初次接触一致性协议的朋友看看网站上的动画演示,非常有助于建立一个形象直观的认知。
作为前面介绍 Raft 的一篇衍生阅读,原始的 Raft 实现是将所有节点看作一个 group,这种设计在某些场景(例如集群规模很小)是可行的。但是当集群规模大到一定程度,或者类似 CockroachDB 和 TiKV 这种将数据划分为非常多的 range,多个 range 组成一个 Raft group 的场景(通常叫做 Multi-Raft),就会发现 Raft 的基础网络通信已经足以影响单节点的性能(比如过多的心跳请求)。因此社区已经针对这样的问题有了一些优化方案,比如 CockroachDB 的方案和 TiKV 的方案。这两个方案都很类似,基本思想是暂停那些不活跃的 Raft group 的网络通信,等到需要的时候再唤醒。
这篇文章是 Ian Lance Taylor 在 GopherCon 2019 演讲的文字版(文章中也附带了视频),主要介绍了目前 Go 的核心开发者关于泛型(generics)的一些思考。总的来说 Go 核心团队的设计思想还是保持 Go 语言一贯的简洁,不希望引入过多的概念和复杂性。大部分新增的语法特性都由提供泛型接口的开发者来学习,对于使用者来说和调用普通接口几乎没有区别。早在 2016 年社区就已经有了 #15292 这个关于泛型的讨论,并且还在持续更新中,目前已经有了 710 条评论,Ian Lance Taylor 也在其中积极回复。虽然这个 issue 打上了 Go2 的标签,但泛型特性是否能在 Go 语言的 2.0 版本中出现现在还是个未知数。
阿里云和微软在去年共同宣布了 Open Application Model(OAM),OAM 组织的核心成员同时也是前 CoreOS 团队成员以及 etcd、K8s Operator 的创造者。简单理解 OAM 就是希望将传统的 K8s YAML 配置抽象成两部分:开发者和运维,开发者的配置中只包含与业务最相关的内容,而运维的配置中则包含与运行环境相关的内容。本质上是希望将开发者和运维的界线分得更清楚,让不同的角色更专注于自己的领域。在我看来 OAM 的好处当然是降低了普通开发者接入 K8s 的门槛,所谓大道至简,但这种表面上的「简」背后隐藏的复杂性也是不能忽略的。理想情况是某个云服务商能够完全包办所有跟运维有关的事情,用户只需要负责业务开发就好了。但现状还是不管多小的公司都肯定会有专人在负责运维工作。很多年前 Google App Engine 刚诞生时让所有人都眼前一亮,都认为这才是软件开发的未来啊,但即使是 Google 也没能让这个趋势持续下去。最近几年这个趋势又开始回潮,只不过换了一个名字叫做「Serverless」,希望这一次能够持续下去,虽然还有很长的路要走。
自从 K8s 1.15 新增了 Scheduling Framework 以后,原生调度器的扩展性有了很大程度的增强。这个 KEP 来自阿里云团队,提出了基于 Scheduling Framework 来实现 coscheduling(或者叫做 gang scheduling)。Coscheduling 这个特性对于机器学习任务来说是非常重要的,一个任务通常包含多个 pod,只有当多个 pod 能够同时运行时这个任务才算是正常运行,如果只有部分 pod 可以运行其实是一种资源的浪费。因此 coscheduling 保证的就是一个任务必须满足一定数量的 pod 都能够被调度时才会实际分配资源。这个特性在 K8s 社区早有讨论,也诞生了一些相关联的项目,如 Volcano(前身是 kube-batch)。5 月初这个插件的第一版已经被 merge 到 scheduler-plugins 项目。
同样是与 K8s 相关的一个讨论,也同样来自阿里云团队。ResourceQuota
是 K8s 目前提供的一种限制某个 namespace 最大资源使用量的方式,但是在实际的多租户场景中,ResourceQuota
往往显得不够灵活。很多时候我们是希望给每个租户一个可以保证(guarantee)的最小资源量,以及一个超卖的最大资源量。当某个租户的资源比较空闲时,就允许其它租户临时租用。但是调度器也要保障这个租户有能力在必要的时候可以拿回这些被租用的资源,这通常是通过抢占(preemption)的方式来实现。这个提案就提出了扩展 ResourceQuota
来实现类似功能的想法。
这是一个系列文章,大部分内容都来自我过去在小红书发现 Feed 团队工作期间的实践和经验。在介绍的过程中我会尽量不掺杂过多的业务细节,而专注于这背后我个人一些浅薄的设计思想,希望你在阅读完这些文章以后能够直接或者间接地拓展到不同的场景。
前面几篇文章介绍的技术都是在单机上实现的,但如果做不到分布式那整个系统的扩展性将会受到非常大的限制。本篇文章将会围绕分布式这个话题讨论。
分布式存储很大一个目的是为了将数据分布到多个节点上,以突破单机的存储限制,实现水平扩展(horizontal scaling)。因此这就涉及到一个很重要的问题:要如何将数据分布到不同的节点上?可能的几种做法有:
方案 1 显然是最简单的,但也是最不可行的。这个方案有两个大问题:因为数据是随机分配的,因此在查询某一条数据时必须请求所有节点;同样因为随机分配的关系,不同节点之间的数据量可能是非常不均衡的。
方案 2 相比方案 1 稍微改进了一点,轮询的方式可以基本保证数据分布是均衡的,但是在查询时还是必须请求所有节点。
方案 3 基本解决了前面提到的两个问题,哈希算法通常是稳定的,也就是说通过某个 key 得到的哈希值是固定的。比如最简单的哈希算法取模运算,将 key 模上集群的节点数 key mod N
,就可以算出这个 key 应该分配的节点。不过取模运算虽然简单但也存在一些问题,最明显的就是当添加新节点或者删除老节点的时候会造成大量的数据重新分配(rebalance)。因此比较常见的改进方案是采用一致性哈希(consistent hashing),一致性哈希可以显著降低数据重新分配这个过程需要迁移的数据量。Amazon 的 Dynamo 便是采用一致性哈希进行数据分割的一个很好的例子,Cassandra 的官方文档里也介绍了类似的内容。但是一致性哈希也不是没有缺点,当集群节点数较少时还是有可能造成数据分布不均衡,因此 Dynamo 提出了通过增加虚拟节点(virtual node)的方法来解决这个问题,细节可以参考论文或者 Cassandra 的文档。
方案 4 也能实现稳定查询,例如将数据 key 的首字母限定在 a-z 这 26 个字母中,再将 a-z 等分为几个范围(range),那么就能根据 key 的首字母确定属于哪个范围。同时每个节点会包含 1 个或多个范围,便能将 key 分配到某个节点上。HBase 便是采用范围分割数据的一个案例,但是由于 HBase 不会预先为所有节点绑定范围,因此在实践中通常还要结合 pre-split 来避免数据都集中在少数节点中。因为每个范围都是连续的,所以方案 4 相比方案 3 的一个优势是对于范围扫描(range scan)的支持更好。
综合来看方案 3 和方案 4 都是可行的方案,它们也都有各自的一些优缺点,如何选择还得看具体的使用场景。
数据分布到多个节点上以后,虽然扩展性(scalability)得到了满足,但是随着节点数的增多,可用性(availability)的重要性会逐渐凸显出来。节点因为各种原因下线是非常普遍的,一旦节点下线那这台节点上的数据将无法访问。因此为了保障可用性,通常会通过冗余存储的方式来解决,也就是为每一份数据新增多个副本(replica),然后将副本分散到不同的节点上,只要还有至少 1 个副本存在那即使部分节点下线也能继续访问数据。为了实现多副本也有几种可能的方案:
方案 1 中节点组之间的数据因为是相互独立的,因此实现和维护相对来说都会比较简单,新增副本就只需要在每个节点组中新增节点即可。我们在数据库系统中经常见到主(master)从(slave)节点的概念,这里可以把 1 个主节点和多个从节点看作是一个节点组。
方案 2 是目前主流分布式存储的实现方案,在存储数据时通过某种算法选择多个副本节点,并时刻检查当前数据的副本数是否符合用户设定的值。这种方案因为在一个节点上同时包含了原始数据和副本,相比方案 1 节点的资源利用率会更高,但代价就是维护成本会有所提升。
不论是选择前面介绍的哪种方案都会涉及到一个问题:如何将原始数据同步到副本上?这里就必须提及在分布式系统中非常重要的一个概念「一致性(consistency)」1,所谓一致性就是用于描述分布式系统中不同实体间状态(state)一致程度的概念。一致性从强到弱大致可以分为以下 4 种类别:
一致性越强的算法对数据的一致要求也越高,当然实现成本也越高。线性一致性的代表有 Paxos 和 Raft,最终一致性的代表有 Dynamo2。为什么一致性如此重要呢?因为分布式系统天然存在的并发和延迟,要如何把一个集群的状态更新最终实现得看起来就像一台单机一样,这是一致性算法要解决的问题。
具体细分状态复制的实现方式有两种:一种是传统的 replicated state machine(或者叫做 active replication),另一种是 primary-backup(或者叫做 primary-copy、passive replication)。前者的代表有 Paxos 和 Raft,后者的代表有 Viewstamped Replication 和 Zab(ZooKeeper Atomic Broadcast)。有关这两种状态复制方案的区别可以看看 Raft 作者的博士毕业论文3和 Vive la Différence: Paxos vs. Viewstamped Replication vs. Zab 这篇论文。
因此回到最开始的那个问题「要如何将原始数据同步到副本」,这取决于你需要哪种程度的一致性,你甚至可以说我不需要一致性4。对于推荐系统的场景,线性一致性属于杀鸡用牛刀5,所以我们只要追求最终一致性就够了。
一个分布式系统必然是由多个节点构成的,那这些节点之间要如何互相感知呢?关于这个问题可以分为两类方案:中心化和去中心化。
所谓中心化就是存在一个(或一组)集中管理的服务,这个中心服务负责接收并存储集群所有节点上报的信息,以及反向分发这些信息,相当于一个集群的信息枢纽。在微服务领域有另外一个词用于表示类似的功能:服务注册与发现。常见的可以实现这种中心服务的开源组件有 ZooKeeper、etcd 和 Consul。
而去中心化顾名思义就是不存在一个中心服务,完全依靠集群内各个节点之间的通信来实现拓扑发现。最著名的去中心化协议恐怕就是 gossip 协议,这是一个可以实现点对点(P2P)通信的协议,很多开源系统里也使用到了 gossip,比如 Cassandra 和 Consul。
至于是中心化还是去中心化好那只能是见仁见智了,没有哪个方案是绝对完美的。
前面的「数据分割」小节已经介绍了如何将数据分布到不同的节点上,如果一个集群的节点数永远不变那这不会带来任何问题,但是如果存在新增或者删除节点的情况呢?不论是哈希还是范围分割的方法,都必须要重新分配数据,以保持集群节点间的数据均衡。为了不影响已有的节点,数据重新分配通常的实现都是在一个后台线程中执行,同时也要控制数据同步的带宽和速率。当数据重新分配这个过程完成以后就可以上线或者下线对应的节点。
当然数据重新分配也不一定就只是由集群节点伸缩触发的,某些系统也会实时地根据当前每个节点的负载而动态调整数据的分布,目的是为了避免出现热点导致整体系统的稳定性受影响。
综合前面介绍的所有内容,现在如果让你来设计分布式索引你会如何设计?这里提供一个我们的实现方案,但是请记住一定不存在一个完美的方案,任何架构设计都是权衡(trade-off)的结果。
简单总结上面这个方案的一些特点:
以上就是关于分布式的介绍,下一篇文章的内容会相对轻松一些,聊一聊所谓的端到端(end-to-end)用户体验。
]]>「Maybe News」是一个定期(或许不定期)分享一些可能是新闻的知识的系列文章,名字来源于我非常喜欢的一个国内的音乐厂牌「兵马司」(Maybe Mars)。你也可以通过邮件订阅它。
跟上次介绍的 FoundationDB Record Layer 一样,这篇来自京东团队的论文也是发表在 SIGMOD 2019,介绍了一个为大规模容器平台设计的分布式文件系统。
系统整体由 3 部分组成:元数据子系统(metadata subsystem)、数据子系统(data subsystem)、资源管理器(resource manager)。元数据子系统负责维护 inode 和 dentry(directory entry),数据子系统负责存储数据块,资源管理器负责处理客户端的各种文件操作请求以及维护前面两个子系统的状态。元数据子系统和数据子系统都是多 partition 的分布式系统,多个元数据和数据的 partition 逻辑上共同组成一个卷(volume),这个卷即是对客户端(容器)可见的存储单元并且可以被挂载,通过传统的 POSIX 接口访问。
因为上述 3 部分组件内部其实都是一个分布式系统,因此都用到了 Raft 作为一致性协议,资源管理器还用到了 RocksDB 作为本地持久化存储。稍微特殊的是数据子系统根据不同类型的写操作选择了不同的复制方案,论文里把这个叫做 Scenario-Aware Replication,具体讲就是顺序写操作(比如 append)用的是 primary-backup,而覆盖(overwrite)操作用的是 Raft。
系统的另一个亮点是基于资源利用率的 partition 分配策略,论文中叫做 Utilization-Based Placement。传统的 partition 分配策略通常是哈希,这种策略的优点是简单但是当扩缩容时必须进行 rebalance。CFS 的做法是元数据和数据子系统定期上报内存、磁盘使用率到资源管理器,当需要创建新的 partition 时根据资源利用率选择最低的那个节点,这样设计的好处是不再需要 rebalance。但是对于这种设计方案是否会造成数据不均衡表示存疑,论文中也没有做过多论述。
为了尽量减少客户端的网络交互,不让某个系统组件成为瓶颈,客户端会缓存元数据子系统、数据子系统和资源管理器的信息到本地,当执行文件操作时会优先读取本地缓存。当然某些组件(比如资源管理器)还是有可能在某一天成为瓶颈,但是基于京东的经验这件事情基本上不会发生。
在与 Ceph 的评测中,CFS 平均有 3 倍的 IOPS 提升,特别是多客户端和随机读写的场景。这很大程度上得益于元数据和数据节点分离的设计,且 CFS 的元数据是全内存存储,而 Ceph 并不是。
分布式文件系统一直都是比较重要的基础组件,在分布式数据库、大数据、机器学习领域有广泛应用。常见的分布式文件系统如 HDFS、Ceph,在如今这个全面推行容器化的时代越来越显得捉襟见肘。容器化一个很大的特点是快速扩缩容,传统的存储系统在这一点上是非常不友好的,因此才会有越来越多针对容器化场景的基础组件诞生(具体可以访问 CNCF 查看),这里介绍的 CFS 是一个例子,另一个类似的是 JuiceFS。
CFS 目前属于 CNCF 下的 sandbox 项目,且已经开源,使用 Go 语言编写。
推荐系统大规模稀疏特征分布式训练一直是工业界一件有挑战的事情,大公司内部自研的训练框架大多已经解决了这个问题,但是在开源社区问题仍然存在。TensorFlow 作为也许目前最流行的深度学习训练框架,社区里也早有相关的讨论(比如 #19324、#24539、#24915),但基本都以烂尾告终。最新的 RFC #237 来自腾讯,区别于现有的一些开源实现(比如阿里巴巴的 XDL、字节跳动的 BytePS、蚂蚁金服的 ElasticDL)完全自己重新造了一个 parameter server,腾讯的方案最大限度复用了 TensorFlow 现有的组件,对用户的代码侵入也最小。目前这个 RFC 还在讨论中,有兴趣可以订阅 PR。
机器学习模型训练由于依赖大量的数据作为输入,因此数据 I/O 的性能会直接影响模型训练的效率。有时间会发现计算设备的算力升级了,但是数据 I/O 跟不上了,反而拖慢了整个训练流程。阿里云团队分享的这篇文章便是他们在使用 Alluxio(试图)加速数据 I/O 的过程中的经验,虽然最后的优化结果性能指标其实也只是基本跟本地读取持平。
没啥好介绍的了,值得一读的一篇采访。文中有两个有趣的问题:
这两篇文章来自「竟然还能聊游戏」的机核,相对系统地介绍了「盯鞋(shoegaze)」这种音乐风格,作为目前可能是除了后朋克以外我最喜欢的音乐风格非常高兴能够有人科普,稍微欠缺的是文中没有提到任何中国的乐队。
]]>这是一个系列文章,大部分内容都来自我过去在小红书发现 Feed 团队工作期间的实践和经验。在介绍的过程中我会尽量不掺杂过多的业务细节,而专注于这背后我个人一些浅薄的设计思想,希望你在阅读完这些文章以后能够直接或者间接地拓展到不同的场景。
上一篇文章介绍了如何实现正排索引和二级索引,但要创建索引也得先有数据才行,本篇将会介绍数据是如何更新的。
所谓「全量索引(full index)」就是指需要索引的数据的全集,通常全量索引的数据量都是一个比较大的量级1,离线构建一次全量索引的时间成本也比较高,因此更新频率不会特别频繁2。全量索引的更新很简单,一般就是覆盖线上已经存在的那份旧的全量索引,当然这个更新流程不会是直接替换,而是先把新的数据加载好再进行替换,也就是说在更新的过程中需要保证内存中能够同时存放两份数据。
全量索引有几个比较严重的问题:
解决思路其实也很直接,既然需要更新的数据是少数,那每次索引更新就只更新这部分数据好了,这也就是下一章节要着重介绍的内容。
与「全量索引」一起经常被提及的另一个词就是「增量索引(incremental index)」,顾名思义增量索引是只针对增量数据构建的集合,因此索引的数据量也会小非常多,自然更新频率就可以很快了。构建增量索引并不是一件特别复杂的事情,只需要有办法获取到最近一段时间有变化的内容就行3。但是构建好的增量索引要如何更新到线上是一个值得认真思考的问题,有两种方案可以选择:
第 1 种方案如果是新增的内容比较简单,在倒排索引和正排索引中插入新的条目即可。但如果是旧的内容被更新或者删除,那就需要在这两种索引中找到对应的条目并全部更新或者删除。直接原地更新或者删除对于倒排索引来说因为需要扫描整个索引条目列表,时间复杂度会随着列表长度以及增量更新的数据量线性增长;对于正排索引来说堆外内存不可避免会产生空间碎片,必须定期清理碎片以免造成空间浪费。
第 2 种方案创建索引的逻辑跟全量索引是一样的,只不过是针对增量数据。但是此时相当于就存在了多个倒排索引和正排索引,查询逻辑应该怎样实现呢?由于正排索引是一一映射,因此如果有多个相同 primary key 的索引,那在查询时选择最新的那个索引即可。查询倒排索引稍微复杂一点,同一个倒排索引 key 可能在多个索引中都存在,查询时需要同时从这些索引中遍历,最终选取出 top N 的条目4。遍历时除了用户提供的过滤器以外,还需要过滤那些已经被删除的条目,这可以通过一个全局的已删除条目集合来实现。随着增量索引数量的增多,不同索引间冗余的数据会变得越来越多,浪费存储空间的同时也会增加查询的时间复杂度。因此我们需要不定期合并这些索引,去除那些重复或者被删除的条目。
我们最终选择了方案 2,因为整体上更倾向于把存储的数据结构设计成 append-only 的模式,简化底层存储的实现5。熟悉数据库系统设计的朋友可能已经发现方案 2 同现在流行的 LevelDB、RocksDB 有一些相似的地方,事实上我们在设计时也的确借鉴了它们的部分思想。这两者底层都是 LSM tree 的数据结构,简单介绍 LSM tree 就是将数据分为多个 level,每个 level 的数据都是只读的且可能存在冗余,不同 level 之间会通过压缩(compaction)来去掉这些冗余。下图是增量索引的设计示意图。
我们限定最大的 level 数(即增量索引数),如果超过这个限定值就会触发合并。大部分情况下都会是增量索引之间进行合并,但如果合并之后的大小已经超过全量索引大小的某个比例,就会触发 1 次同全量索引的合并。
有了增量索引之后索引的更新频率最快可以控制在分钟级,相比全量索引动辄小时级甚至天级的频率已经快了不少。索引更新更快也意味着内容可以更快地被用户消费,促进了整个社区的信息流动。
以上就是本篇要介绍的全部内容,简单回顾一下:
注意过这个系列文章标题的朋友可能很好奇讲了这么久为啥感觉跟分布式一点儿关系都没有,的确前面几篇文章都是在重点介绍索引相关的技术,下一篇文章将会开始聊聊分布式这个话题,敬请期待。
前言:从这一期开始这个系列将会有一个正式的名字「Maybe News」,名字来源于我非常喜欢的一个国内的音乐厂牌「兵马司」(Maybe Mars)。本身我分享的内容也很有可能是一些旧闻,只不过对于我来说是还未了解的知识罢了。上一期的名字还是维持原样就不做修改。你也可以通过邮件订阅这个系列的文章。
这篇论文由微软亚洲研究院与中科大共同发表在 WWW 2020 会议上,提出了一种新的表示物品向量的方法,大幅降低存储向量所需空间的同时还显著提升了召回效果。一个直观的数据:LightRec 将 1 千亿 256 维双精度向量的内存占用从 9.5 GB 降到了 337 MB,这是非常惊人的!现在工业界常用的 nmslib 和 Faiss 都无法实现如此高的压缩比,因此很多时候都需要借助分布式存储来满足业务场景,如果真的如论文中所描述的一样那单机存储在未来很长一段时间来说都是完全足够的。
这里简单解释一下为什么向量召回对于当下的推荐系统如此重要,传统的召回是基于倒排索引的方式,正如我在之前的一篇文章中介绍的那样,召回与模型优化目标之间的差异较大导致召回效果始终较差。自从 Learning Deep Structured Semantic Models for Web Search using Clickthrough Data 这篇论文(同样也是由微软研究院发表)提出 DSSM(Deep Structured Semantic Models)以后,将召回与 DNN 进行结合,显著提升了召回的效果,在很多公司的实践中也的确论证了 DSSM 是一个非常有效的召回方式。DSSM 的核心是分别为物品和用户生成向量,再通过 ANN(Approximate Nearest Neighbors)查询相似向量从而实现召回。因此向量的存储和查询效率决定了在线请求的效果和性能,如何平衡向量索引的空间占用和召回效果是非常重要的。
微软研究院的微信公众号有一篇简短的针对这篇论文的中文版介绍,有兴趣也可以先看这篇文章。
Google 近期开源了新的 TensorFlow 运行时 TFRT(TensorFlow Runtime),这是一个介于上层用户代码和底层设备之间的执行环境。项目的愿景是实现一个统一的、可扩展的、性能首屈一指(best-in-class)的,同时可跨越多种领域硬件(domain specific hardware)的运行时。未来 TFRT 会成为 TensorFlow 默认的运行时,目前还在集成中。从 ResNet-50 的 inference 测试结果上看平均提升了 28% 的性能。
虽然这是一篇产品推广软文(在文章最后一节),但是文章中普及的关于 DevOps 与机器学习之间的关系还是非常有价值的。很多人可能以为机器学习就只是模型算法而已,诚然这是学术研究的基石,但是要真正把机器学习应用到工业界光有算法是远远不够的。Google 著名的 Hidden Technical Debt in Machine Learning Systems 论文已经论述了那些隐藏在模型背后的往往被人忽略的技术,模型规模越大需要付出的工程努力也是越大的(所以很多时候大公司才需要自己造轮子)。作为衍生阅读也可以同时看看 How LinkedIn, Uber, Lyft, Airbnb and Netflix are Solving Data Management and Discovery for Machine Learning Solutions 这篇文章。
Dave Cheney 继续科普 Go 的一些实现细节,这次的主题是编译器如何实现 mid-stack inlining。所谓 mid-stack inlining 就是将那些调用了其它函数的函数变成 inline,相对的还有 leaf inlining,即不调用任何其它函数。有兴趣了解 leaf inlining 的可以看 Dave Cheney 的上一篇文章。
Uber 介绍了他们在微服务领域实践的一个经验「多租户」,简单讲就是让请求链路上的所有组件和系统都能够感知「租户」这个概念,比如租户可以分为生产环境和测试环境。Uber 列举了两个应用场景:集成测试和 Canary 部署,这两个场景都依赖生产环境的请求,有了租户的概念就可以自动进行请求路由和数据隔离。愿景其实挺美好,但「代价」也是不容忽视,前面讲了要让所有组件和系统都感知就非常依赖基础组件的统一,要解决这个问题很多时候并不单纯是一个技术问题。如何做好不同环境的数据隔离也是一个难题,关于这一点文章并没有做特别详细的介绍。
]]>FoundationDB 2015 年被 Apple 收购并于 2018 年开源,作为 Apple 为数不多的开源项目受到广泛关注。简单介绍 FoundationDB 是一个基于 Paxos 的分布式 KV 存储,底层存储结构是 B-tree(是的,并不是 LSM tree),定位上跟 Google 的 Spanner 非常相似。这篇论文发表在 SIGMOD 2019,介绍的是基于 FoundationDB 的 record-oriented 结构化存储框架(也已经开源)。Layer 是 FoundationDB 一个很有特色的概念,在最基本的 KV 上无限扩展更加复杂的数据模型。这个框架整体上有几个亮点:
目前已经被应用在 CloudKit,替代旧的 Cassandra + Solr 架构(旧架构也有一篇论文介绍)。CloudKit 作为一个庞大的存储服务供所有 Apple 生态的应用和用户使用,这也就是论文标题中 Multi-Tenant 的含义。
Incident 管理一直是 DevOps 领域比较热门的话题,Netflix 开源了他们自己的 incident 管理工具 Dispatch,更早之前 LinkedIn 也开源过类似的东西。
大众对于 DeepMind 的认知恐怕就是下下围棋、打打星际,最近又搞起了雅达利的游戏,可以说是把强化学习玩儿出花儿了。最新一代的 Agent57 已经可以在全部 57 个游戏里战胜人类玩家。
Graph Neural Networks(GNN)最近几年已经火得不行,Amazon 也开源了相关的框架 DGL。这篇文章以一种简单的示意图的形式介绍什么是 GNN,帮助不了解 GNN 的人建立一个简单的认知。
Delve 是一个 Go 语言的 debugger,Go 官方也推荐优先考虑使用它而不是 GDB。这篇文章简单介绍了 Delve 的基本功能,其实跟 GDB 的使用方式很类似,但是 Delve 的亮点在于可以理解 Go 语言的语义以及调试 goroutine。
Fiber 是(又)一个 Go 语言的 HTTP 框架,设计上很大程度受了 Node.js 中非常流行的 Express 启发(API 非常相似)。得益于底层使用的 fasthttp 库,在 Fiber 自己的评测中超越了很多市面上现有的框架。
]]>这是一个系列文章,大部分内容都来自我过去在小红书发现 Feed 团队工作期间的实践和经验。在介绍的过程中我会尽量不掺杂过多的业务细节,而专注于这背后我个人一些浅薄的设计思想,希望你在阅读完这些文章以后能够直接或者间接地拓展到不同的场景。
上一篇文章介绍了如何定义 schema、查询 API 以及怎样实现倒排索引,本篇将会着重介绍另一种重要的索引类型「正排索引」,以及跟正排索引密切相关的「二级索引」。
正排索引是主键(primary key)到条目的一一映射,在推荐系统中使用正排索引的场景是获取模型计算所需的原始特征(raw feature)。为什么说是原始特征呢?因为这些数据还需要经过特征提取(feature extraction)以后才能作为最终输入给模型的参数,特征提取不在本系列文章的讨论范畴。
这里先简单讲讲什么是特征。最早我们提到机器学习的时候讲过模型是首先经过离线训练产生,然后用于在线预测去预估用户的喜好。在离线训练阶段算法工程师需要先从训练数据1人工筛选出一批对于当前想要训练的模型有意义、有价值的、可衡量的属性,这个过程叫做「特征工程(feature engineering)」。特征工程考验的是一个算法工程师对于业务数据的理解、经验、数据敏感度以及统计分析能力,很多时候还需要结合大量的 A/B 实验才行。近年来深度学习的兴起已经将特征工程的复杂度降低不少,但特征工程依然是一个非常重要的步骤。这些被筛选出来的属性就是特征,举个直观的例子下面这些都可以作为模型特征使用:用户的地理位置、用户性别、用户看过的内容总曝光/点击/赞/评论的次数等。
正排索引中的条目存储的就是大量的原始特征,这些特征也是在 schema 中定义,基本上 schema 中除了跟倒排索引有关的字段其它都属于特征。因此可以看到正排索引条目的大小是远大于倒排索引条目的。为了保证查询的性能我们依然选择了将正排索引存储在内存中,但是这会带来一个问题,因为正排索引占用的空间可能会很大,我们也是明确知道这些数据是需要常驻在内存中的,对于类似 Java 这种带有 GC 的语言来说这部分数据反而会增加垃圾回收器的压力。这些数据会长期存储在 old generation 中,不仅浪费空间也降低了 GC 的性能。那么我们的目标便是尽量不要让这部分数据对 GC 造成太大的影响,最好是对 GC 不可见的,毕竟「眼不见心不烦」。
一种比较常见的解决方案是「堆外内存(off-heap)」2。所谓堆外内存就是通过某些特殊的 API 分配独立的内存空间,且这个内存空间对于 GC 是不可见的,当然也就不会影响 GC。听起来这个方法似乎很简单直接,但凡事有好也有坏,绕开 GC 的副作用是你需要自己管理这块儿内存,如何高效地使用堆外内存是一个比较关键的问题。HBase 的 BucketCache
3是一个值得参考的实现,我们在调研阶段也仔细研究过 BucketCache
的设计,但最终没有直接照搬,有一个非常重要的原因:BucketCache
是为了解决之前 BlockCache
这种 on-heap 缓存方案造成的 GC 性能问题而诞生,本质上也还是一个缓存,既然是缓存就必定要考虑缓存数据的驱逐,因此 BucketCache
的设计方案中包含如何释放内存以及合并 bucket 的逻辑。但是正排索引不是缓存,也就不存在驱逐数据的问题4,BucketCache
中的这部分设计对于我们的场景来说其实是多余的,如果完全照搬反而是在系统中引入了一个不必要的复杂组件,增加维护成本5。因此我们最终借鉴了一部分 BucketCache
的设计思想同时再结合推荐系统的业务特点实现了一个只读版本的堆外内存,下图是具体的实现方案。
上图中最左边蓝色的部分是一个 hash map,key 是正排索引的主键,value 是一个包含与堆外内存地址有关的元信息对象。这个元信息对象中主要有 3 个成员变量:
java.nio.ByteBuffer
的缩写,是 Java 中创建堆外内存的底层 API。在应用的初始化阶段我们会提前申请一块儿大的物理内存空间作为堆外内存,假设这块儿内存的大小是 10GB,在其中会按照一个固定的大小(默认是 10MB)再分割成多个小的 bucket。BB Index 即是某个 bucket 的索引。上图中蓝色和黄色的部分还是存储在堆内,只有绿色部分属于堆外。写入数据的流程就是根据索引条目的长度找到空闲的 bucket,然后通过 ByteBuffer.put()
方法将数据存放到堆外内存,并在 hash map 中新增相应的元信息。读取数据的流程是首先查找元信息,然后通过 HBase 中封装的 UnsafeAccess.copy()
方法将数据从堆外拷贝到堆内。当然数据拷贝出来以后并不能直接使用,因为这还只是序列化后的字节流,还需要经过反序列化步骤6。
二级索引的概念在很多数据库系统中也存在,特别是分布式数据库,通常主键用来查询分片的位置,而二级索引用来在某个具体的分片中查询特定的字段。
在推荐系统场景中二级索引的功能类似,只不过不是因为这是一个分布式系统,而是为了查询某些特殊的特征。在传统的机器学习模型中有一类特征是非常重要的,那便是内容在不同维度的统计值。举个例子,我们不仅会统计一篇笔记的总曝光数,还会统计这篇笔记在不同城市、不同性别、不同类型设备的曝光数,这里的城市、性别、设备类型就是维度。并且这些维度是允许交叉的,也就会产生非常多的维度组合。所有这些维度组合起来的统计值是一个大的集合,每次查询时并不需要这个集合中的所有值,而是根据当前用户的画像选取与这个用户相符的值。
如果每次查询时都把所有值从堆外内存中拷贝出来显然是很浪费的,因此我们需要一种方法直接从堆外内存中查询部分值,这就是二级索引的作用。二级索引的字段在 schema 中会标记 secondary_key
属性,上一篇文章中示例的字段类型是 [BreakdownStats]
,那这个 BreakdownStats
的定义是什么呢?如下所示:
table BreakdownStats {
key:SecondaryKey (id: 0);
value:NoteEngagementStats (id: 1);
}
上面 key
字段的数据类型是 SecondaryKey
,这是一个由框架预定义的类型,具体定义如下:
table SecondaryKey {
type:int (id: 0);
value:string (id: 1);
}
因此我们可以知道一个二级索引 key 由两部分组成:type
和 value
,type
是 key 的类型(通常是可枚举的),value
是具体的值(不可枚举)。继续拿前面的例子举例,「城市」是一种 key 的类型,「上海」是具体的值。于是查询流程相比前面介绍的区别之处在于,通过主键查找以后还需要通过二级索引 key 才能获取到元信息对象,相当于增加了一次 hash map 的查找。一个优化的细节点是在框架内部我们还将二级索引 key 映射到了一个整数,这样便可以将这个整数作为 hash map 的 key 来使用。于是通过刚才的流程便实现了直接获取某个维度(或者维度组合)的统计值的需求。
以上就是本篇要介绍的全部内容,简单回顾一下:
至此两种索引已经全部介绍完毕,下一篇文章将会围绕一个更加上层的问题「索引如何快速更新」进行讨论。数据不可能是一成不变的,更新的效率也会直接影响产品体验和业务指标。
BucketCache
的详细设计可以参考 HBASE-7404 这个 issue↩这是一个系列文章,大部分内容都来自我过去在小红书发现 Feed 团队工作期间的实践和经验。在介绍的过程中我会尽量不掺杂过多的业务细节,而专注于这背后我个人一些浅薄的设计思想,希望你在阅读完这些文章以后能够直接或者间接地拓展到不同的场景。
在上一篇文章中简单介绍了什么是推荐系统以及实现一个推荐系统的核心组件有哪些,文章最后引入了一个非常重要的概念「索引」,本篇将会首先从框架使用者的角度介绍如何定义索引,框架有哪些 API 可以使用以及从设计者的角度介绍如何实现一个简单的倒排索引。
在传统的数据库系统中,当我们提到 schema 时通常是指表(table)的逻辑定义,这个定义中会包含这些信息:表名、有哪些列(column)、列名、列的数据类型、主键(primary key)、索引名、索引的列等。非常类似的,在推荐系统中我们也需要这样的信息。框架的使用者需要首先定义好存储的数据实体,如实体名(表名)、实体有哪些字段(列)、字段的名称和数据类型、哪个字段是主键、哪些字段需要创建倒排索引。正如传统数据库系统中通过 SQL 来定义 shcema,我们也需要一种类似的 DDL1。经过一番调研和比较以后,我们选用了 FlatBuffers 作为定义 schema 的语言。同 Protocol Buffers(以下简称 PB)一样,FlatBuffers 也是 Google 开源的一种序列化协议,支持多种主流语言。为什么要选用 FlatBuffers 呢?FlatBuffers 的主页上列举了几个特点,我选取了几个最重要的翻译过来2,如果你熟悉 PB、Thrift 这一类 IDL 应该能很明显看出区别。
有兴趣进一步了解设计细节的朋友可以看看官网的 FlatBuffers Internals 文档,简单总结就是 FlatBuffers 通过一种特殊的序列化格式(针对更小的内存开销和访问性能设计)相比传统 IDL 更加高性能,同时又兼具传统 IDL 的大部分特性(语言无关、强类型、schema evolution)。当然 FlatBuffers 也不是没有缺点,最明显的一个问题就是为了实现高性能,FlatBuffers 的原始 API 对开发者及其不友好,手动编写序列化或者读取数据3的代码非常容易出错。不过好在这些问题都可以通过自动生成的代码和框架隐藏起来,不需要直接暴露给用户4。前面列举的几个特点为什么对于索引框架如此重要呢?笼统讲当然是为了高性能,不过后面介绍倒排索引的设计时会详细说明一些细节点。
说了这么多还是不知道具体的 schema 长什么样子,下面以一个实际的例子来说明。
table NoteInfo {
note_id:string (id: 0, primary_key);
...
note_gender:NoteGender (id: 29, index_attribute);
taxonomies:[KeyValueEntry] (id: 30, index_key);
...
breakdown_stats:[BreakdownStats] (id: 47, secondary_key);
}
上面是一个完整的索引实体定义,也就是小红书里用户创建的笔记(note)。每一行定义了实体中的字段名称、数据类型以及可选的属性标记。例如 note_id
这个字段是笔记的 ID,数据类型是 string
,id: 0
是字段在 FlatBuffers 中的唯一 ID,primary_key
表示这个字段是主键。类似的后面列举的几个字段也具有某些特殊含义,例如 NoteGender
是一个枚举值,index_attribute
表示这是一个索引属性;[KeyValueEntry]
是一个 KeyValueEntry
类型的数组,index_key
表示这是一个倒排索引;secondary_key
表示这是一个二级索引。可以看到语法上 FlatBuffers 跟传统 IDL 类似,某种意义上可能还略微简洁一些。定义里有些是 FlatBuffers 官方的语法(如 id: 0
),还有一些是我们扩展的(如 primary_key
)5。这里扩展性是非常有必要的,否则这个 IDL 就只能用于序列化而没法作为一种数据的逻辑定义语言来使用了。这些扩展的语法具体是什么意思之后的几篇文章会逐渐展开。
有了 schema 框架就可以理解索引的数据结构了,但是对于使用者来说其实更加关心的是如何「查询」数据。推荐系统的业务特点是一个读远大于写的场景,且在线请求中只会涉及读数据而不涉及写数据,即请求都是只读的。结合上一篇文章的介绍,使用者真正需要用到的 API 基本就是下面几种:
以 Java 语言为例,实际的 API 大概长这样:
QueryApi.queryByPrimaryKey(Object primaryKey)
QueryApi.queryByIndexKey(String indexKeyName, Object indexKey, long limit, Function<IndexPayload<?>, Boolean> filter)
QueryApi.queryBySecondaryKey(Object primaryKey, String secondaryKeyName, List<SecondaryKey> secondaryKeys)
第 1 个 API 通过主键查询正排索引;第 2 个 API 通过倒排索引的字段 key 来查询倒排索引,同时还限定了查询的索引条目数以及一个用户自定义的过滤器;第 3 个 API 通过主键和二级索引 key 查询二级索引。
当然除了以上列举的最基本的 API 以外我们还提供了一些额外的接口,例如为了优化批量查询性能的批量查询接口,为了监控和可视化的索引统计信息查询接口。
假设给你一份序列化好的索引数据,要怎么创建倒排索引呢?这里有几个关键的问题需要思考:
第 1 个和第 2 个问题结合前面介绍 schema 时的知识应该很容易解答,只要框架能够提前获取到数据的 schema6,就能对索引数据有一个全局的了解,并能够事先知道哪些字段需要创建倒排索引。
第 3 个问题需要通过 FlatBuffers 提供的反射 API 来解决7,配合 shcema 就能够从实际的数据中获取某个字段的值。还记得前面没有细讲的一个问题吗?为什么我们选用了 FlatBuffers 作为序列化协议,一个非常重要的原因就是无需反序列化即可访问序列化后的数据。在创建倒排索引时这个需求尤其强烈,一个完整的定义有可能包含几十甚至上百个字段,每个字段的大小都是不同的,但是这其中可能只有个位数的字段需要创建倒排索引,如果使用传统的 IDL 反序列化整个对象的时间和空间开销将会非常大,特别是对于有 GC 的语言来说8。因此在这一点上 FlatBuffers 基本完美解决了这个问题。
第 4 个问题思考的角度需要从查询性能出发,既然是索引那必然追求的是查询时间复杂度最小,那就没有比 O(1) 更小的复杂度了。能够实现 O(1) 查找的数据结构最常见的就是 hash map9,在不同语言中这都是非常基础的数据结构,基本不用操心是否需要自己从头开始实现。Hash map 的 key 就是倒排索引 key,value 就是索引的条目列表。而 value 应该用什么数据结构呢?倒排索引的 value 一定是有序的,且通常是倒序排列,最简单的场景用 array 其实就够了,如果需要动态增删那你可能会想到类似 skip list 这样的数据结构。这里有一个细节点需要注意,同一个条目是有可能同时出现在不同的倒排索引中的,因此做好对象复用是节省内存非常关键的点。
回答第 5 个问题前可以先回到介绍 schema 时举的例子,倒排索引的字段是一个特殊的数据结构 [KeyValueEntry]
,那么这个 KeyValueEntry
具体是什么呢?
table KeyValueEntry {
key:string (key);
value:double;
}
这是一个由用户自定义的数据结构,只有两个字段 key
和 value
,前者即是倒排索引 key,而后者即是倒排索引条目的 score,同一个倒排索引 key 下的条目列表将会根据这个 score 从大到小逆序排序。这个特殊的数据结构是框架约定俗成的,只要符合一定条件就可以作为倒排索引的字段类型。
最后一个问题是在推荐系统的业务场景中相当常见的需求,通常查询时会限定查询 top N 的条目,但是对于不同用户这个 top N 可能是不一样的。例如需要过滤掉每个用户历史上曾经有过曝光(impression)的条目,需要根据某些用户画像属性过滤条目等。出于节省内存的原因我们不可能将一个完整定义中的所有字段都直接存放在内存中10,因此限定了只有某些标记了特殊属性的字段才会存储在索引条目中,这也是前面示例中 index_attribute
这个标记的作用。因此一个完整的索引条目数据结构大概是这样(以 Java 语言为例):
public class IndexPayload<T extends Comparable<T>> implements Cloneable {
private final T primaryKey;
private final Object indexKey;
private final double score;
private final Map<String, Object> attributes;
public Object getAttribute(String attrName) {
return attributes.get(attrName);
}
}
上面的 attributes
成员变量即是索引属性,key 是标记了索引属性的字段名,value 是对应的值,可以通过 getAttribute()
方法查询这个值。前面介绍的 QueryApi.queryByIndexKey()
接口中有一个 filter
参数,数据类型是 Function<IndexPayload<?>, Boolean>
,也就是说这个参数是一个函数,输入参数的数据类型是 IndexPayload
,返回值的数据类型是 Boolean
。用户需要自己实现过滤器的逻辑,通过 IndexPayload
提供的接口来判断是否需要过滤当前条目。
以上就是本篇要介绍的全部内容,简单回顾一下:
下一篇文章依然是围绕索引来介绍,不过重点将会是正排索引,看似一个 hash map 即可解决的问题其实有很多玄机。
这是一个系列文章,大部分内容都来自我过去在小红书发现 Feed 团队工作期间的实践和经验。在介绍的过程中我会尽量不掺杂过多的业务细节1,而专注于这背后我个人一些浅薄的设计思想,希望你在阅读完这些文章以后能够直接或者间接地拓展到不同的场景。
在介绍什么是索引框架之前先了解一下我们当时面临的业务场景2,业界现在的 feed 流产品已经逐步从非个性化全面过渡到个性化,所谓的个性化 feed 其实就是基于机器学习的推荐系统。
先讲讲什么是推荐系统,用一个词概括就是「投其所好」。当你遇到一个跟你志趣相投的人时,那这个人感兴趣的东西很有可能也是你感兴趣的,这是「基于人」的维度进行推荐,微信的「朋友圈」就是这么一个简单的思路3。也有可能一个人他从来没见过你,你们也不互相认识,但是如果他能够知道你过去看过、喜欢过的东西,那他也很有可能可以推断出你未来感兴趣的东西,这是「基于历史行为」的维度进行推荐。我们可以通过制定一些人工的规则来实现推荐,但是用户的喜好是千奇百怪的4,有大量长尾的需求是人工规则无法覆盖的5。因此我们需要让计算机学习如何制定这些规则,这是对「机器学习」这个概念非常浅显的解释。一个完整的机器学习流程简单概括包含「离线」和「在线」两部分,离线部分是通过大量的用户数据来让计算机找寻其中的规律和共性,最终产出「模型」;在线部分是通过输入当前用户的数据给模型,让模型计算出一个预测值,这个预测值用来衡量我们想要推荐的内容是否符合这个用户的兴趣。这个系列的文章将会主要围绕在线部分,离线部分如果有机会会在以后的文章中介绍。
前面提到在线部分的核心逻辑是模型计算6,但在计算之前还有一个非常重要的工作是筛选候选集,通常叫做「召回(recall)」。所谓召回就是从一个很大的集合中通过一定的条件选取一个子集,为什么要有召回这一步呢?本质上是因为模型计算是一个非常耗费时间及资源的过程,如果每次用户请求都对整个集合中的条目进行计算,不仅浪费资源,所需的时间对于用户来说也是无法接受的7。大部分情况8下我们对于推荐系统一次请求的时间要求是控制在 100~200ms 左右,如果超过这个时间对业务指标一定会有负面影响。因此有针对性地进行召回就非常关键了,召回需要尽量确保筛选出来的候选集是符合当前用户兴趣的,但同时耗时又是非常短的9。总结一下一次推荐请求的流程如下图所示。
实现快速召回的关键是「索引(index)」,正如大部分数据库系统一样,索引是为了实现快速查找的重要组件。在推荐系统中主要有两类索引:正排索引(forward index)和倒排索引(inverted index)10。正排索引通常是用来通过一个主键(primary key)查询一个条目,是「一对一的映射」;倒排索引是用来通过跟条目关联的某些属性查询多个条目,是「一对多的映射」。举个实际的例子,小红书上用户发布的内容叫做「笔记」,每一篇笔记都会生成一个唯一的 ID,这个 ID 就是这篇笔记的主键,正排索引即是一个从笔记 ID 到笔记的映射。而每篇笔记都会有一些同笔记本身相关的属性,比如分类(category),一些常见的分类有:旅行、美妆、摄影、美食等。倒排索引即是一个从多个属性到多篇笔记的映射,如「旅行」分类可以映射到所有属于这个类别的笔记列表。对于召回来说主要依赖倒排索引,而正排索引将会在模型计算的前置步骤特征提取中用到11。
讲到这里也基本上把索引框架需要实现的功能介绍得差不多了,其实需求很简单:给定一个集合然后在这个集合上创建正排和倒排索引,并暴露相应的查询接口。下一篇将会详细介绍如何定义索引、框架的 API 应该有哪些以及如何实现一个简单的倒排索引。
]]>SOA(Service Oriented Architecture)是一个很「古老」的概念,而 microservices 似乎是这两年才开始流行起来的。很多人把 microservices 看作一个全新的概念(我们都是喜新厌旧的人),Martin Fowler 觉得它跟 SOA 差别非常大,Netflix 也把他们目前的架构称作 microservices architecture。但是有人站出来说 microservices 根本就是 SOA 很多年前已经提出的概念嘛,甚至还有一份相当冗长的标准文档,SOA 在互联网界的流行很大程度上可能也要归功于 Amazon。在 microservices(这个单词真的好长。。)这个名词流行之前,我对服务化的理解一直就是 SOA,不过我并不是想说 SOA 跟 microservices 是两个完全不同的东西,对于后者我的理解是它是 SOA 的一个 dialect,很多核心的思想还是来源于 SOA,只不过随着时代的发展必然会产生差异(也可以说是标准制定得太慢)。至于 microservices 的标准定义,我想目前应该没有,就连 Wikipedia 的条目也讲得不清不楚(还不如看前面提到的 Netflix 的文章,里面与 SOA 比较的文字我也觉得有待商榷),每个人、每个团队、每个公司都应该有自己的理解,后文提到的知乎目前的服务化架构姑且用 microservices 指代。
服务化的好处可能很多人都了解了,你可以在任何一篇相关文章中很轻易地找到关于服务化的各种优点,很多人选择服务化的时候也正是被这个「看起来」很美好的概念打动。一切模块都是天然解耦的,这简直就是软件工程的理想境界。但凡事有利必有弊,告诉你这个东西很好的人并不一定会告诉你背后隐含的一些注意事项(所以我特别欣赏那些可以把不管优点缺点都告诉你的开源项目)。文章开头提到的那篇文章就讲述了几个在实践过程中才会真正发现的「问题」,我也大概循着作者的思路,以及附上其它一些在工作中体会到的事情。
这里的成本包括人力和物力成本。先说说物力,在服务化之前,一个项目的所有代码应该都在一个代码仓库里,在部署的时候很自然地我们把代码 clone 下来,可能还会编译打包,最后把整个项目放到生产环境。采用服务化意味着你的项目可能会从一个变成几十个(曾经有新同事来了之后惊讶于知乎内部居然有这么多项目,其实里面有很多都是一个个小的服务),想象一下此时你的部署流程会变成什么样子?当然我们并不会每次部署都要把这几十个项目挨个部署一遍,但最坏情况下你需要关心的项目的确变多了。比如所有项目依赖的一个特殊的服务有变化,需要依赖方重启,这将会是一场「浩大」的工程,不同项目大部分情况下拥有不同的维护者,通知到所有人并且完成这件事情本身就变得比较困难(难度取决于团队大小,当然这个例子并不会是经常发生的事情)。
在 microservices 的思想里不同的服务应该拥有完全「独立」的资源,包括代码、机器、存储等,理论上每台机器应该只运行一个服务,存储也应该只供这一个服务读写。如果再考虑不同服务的负载和高可用,那么需要为每个服务分配 2 至多台机器。此时从运维角度上来看已经增加了「数量庞大」的机器,不过考虑到成本问题,我们可能会把服务都部署在虚拟机里,而单个存储实例也可能是被多个服务共享。但这只是物理机器的数目变少了,实际上需要管理的机器还是很多。不过现在越来越流行的容器(container)的概念也许是一个不错的解决方案,有效利用了集群的资源,同时还能做到自动伸缩(auto scaling,前提是你的服务必须是无状态的)。
有了这么多服务,找到它们变成了一件困难的事情。这个时候我们需要一个 proxy,它的功能很简单,帮你找到你想使用的服务,再高级一点的,也许还会帮你完成负载均衡。但有网络必有开销,即使是内网,何况还是单点,有一天你会发现某个服务的调用量已经大到无法忽视 proxy 带来的网络开销。于是我们把一个 proxy 变成多个,来分担压力。但维护这些 proxy 的信息其实也是一件麻烦事,服务的机器可能会调整,可能会有很多新的服务出现,对于运维来说是不容忽视的成本。也许你在某个地方看到了另一种方案,我们其实可以不需要 proxy,直连服务岂不更好?直连减少了多余的网络开销,同时也意味着你需要自己做负载均衡和高可用,以及发现新的或者死掉的服务。其实很多人已经想到了一个解决的办法:服务发现(service discovery)。这是一个已经很成熟的方案,你甚至可以找到很多开源实现,这里有一篇文章比较详细地介绍、对比了服务发现相关的技术。当然这些开源实现各有利弊,也许最终你会选择自己开发。但服务发现终归引入了一个新的概念,意味着你需要单独为它部署、配置、管理,也许还会与你的代码耦合。
另一个必须关心的事情就是监控,当然你说监控本来就是运维需要做的事情,但无形中增加了这么多机器监控肯定值得关注。并且监控不仅仅是指服务是否正常运行,还包括服务的请求量、负载、响应时间,这些都不是现成的,需要额外统计。
然后就是人力成本。前面提到的架构已经比服务化之前复杂了许多,这也许就不是一个人能完成的事情。还有很多组件并不一定是现成的,于是运维同学还需要具备一定的开发能力,DevOps 这个称谓其实是一个蛮高的要求。伴随而来的就是招人的标准也得提高,考虑到我们是家小公司,技术团队规模也不会太大,必须在招人上做出取舍。
有了服务之后接口变成了一件很重要的事情。我们需要制定一些接口规范,讲究一点的可能还会要求命名风格;需要考虑接口的粒度,不能过细,尽量通用;不能让接口的使用者对服务内部产生太大影响,比如调用一个非常消耗服务资源的接口,这时服务的开发者就需要对接口参数进行必要的检验;最重要的,接口一旦发布,之后的任何改动都必须向后兼容。Protocol Buffers 就是一个很好的例子,因为是强类型,所以接口参数验证可以很方便地完成,Google 还给出了一套更新接口的准则,例如新增的参数必须是 optional
或者 repeated
,不能删除 required
参数。但有时候难免会做出不兼容的改动或者发布了新的接口,这时就需要告知所有服务的调用者。但你会发现找到服务是一个难题,找到服务的调用者其实也是一个难题。糙一点的可能就是发邮件给所有人或者通过经验来逐一排查,智能一点的就得在服务的框架里做些统计,自动生成服务的调用关系图。总之接口是一个你不可避免需要考虑的问题。
软件工程一个比较重要的思想就是要避免重复代码,有这样一句耳熟能详的话:当你第二次写下同样的代码的时候就得思考是否可以抽象出一段新的代码。在一个项目里这件事很容易,可以是封装好一些函数、mixin 或者类。服务化之后有好几种方案可以选择:
每一个其实都有优缺点,挨个说一下。抽象新服务有滥用服务化的嫌疑,并且新的服务意味着更多的网络开销,多个服务也跟这个新服务显式地绑在了一起,稳定性有待商榷。封装库少了刚才提到的不稳定因素,但同时带来了维护成本,只要维护过库的同学应该都了解版本更新是一件很麻烦的事情,在迭代速度上肯定要逊于第一种方案。最后一种,嗯。。就像武侠小说中的锦囊一样,不到万不得已千万不要用。目前我们更倾向于第二种。
服务化打破了长久以来的三层架构(3-tier architecture),有人称之为四层架构。分层在软件工程里是一件好事,可以有效减少单层实现的复杂度,但同时也会给整个系统产生额外的代价。网络开销、网络的不稳定性、架构的容错性、消息的序列化和反序列化、不同服务之间负载的变化等等。四层架构里多了很重要的一层「服务层」,这一层内部的网络通信需要与上层隔离,客户端需要对服务的某些异常进行捕获,必要的时候重发请求,服务如何做到 graceful 部署,分布式事务(如果你真的需要事务),不要因为某个服务挂掉而导致整个系统宕机,序列化是采用二进制还是 JSON,序列化程序的性能如何,服务层内部又如何分层,如何避免循环调用。前面这些都必须考虑。
还有一个问题可能很多人刚开始并不一定会想到,那就是分布式系统的整体跟踪(tracing)。这是干嘛的?当一个问题出现时,你需要准确判断是哪一层出了问题,而不是靠猜或者逐一排查;当你需要优化整体性能时,你需要判断是哪次调用拖了后腿。早在 2010 年 Google 发表了 Dapper 的论文,之后 Twitter 开源了他们的实现 Zipkin,目前知乎也是在 Zipkin 的基础上针对我们自己的服务化框架定制了一套 tracing 系统。
系统架构的变化也会影响到开发者的某些设计,在以前这就是一些普通的函数调用,我们可以自由地控制调用的顺序、处理相关的异常,现在我们需要考虑到网络调用的因素,时序性也是一个问题,什么时候需要重试,什么时候又不行。某些问题是一个好的服务框架可以解决的,但某些不可以。
由于众所周知的原因,Python 的多线程并不是一个效率很高的方案(事实上多线程本身也不是一个很好的方案),于是异步大行其道。但是 Python 的异步毕竟不是语言级别的,虽然有很多实现,但都不是特别好用(Python 3 这货也不知道要何年何月才能普及)。某些异步实现也会带来编程习惯上的改变,在使用的时候需要特别注意,否则可能会遇到一些看似「莫名其妙」的 bug。异步也不是银弹,当一个服务既有异步请求又有同步请求的时候,异步请求的性能反而会因为同步请求变差,因此一个服务最好是完完全全的异步。
这可能是比较容易忽视的一块,毕竟是给自己用的东西,不好用也许还可以忍忍,但我觉得这反而是最影响开发效率的环节。当一个项目的运行需要依赖十几个、几十个服务的时候,开发、测试就成了一个难题。我们也许可以分别在开发环境和测试环境将这些服务部署好,但是维护这些服务的可用性和稳定性会变成新的问题。比较理想的情况是项目的开发和测试不依赖任何第三方服务,从单元测试的角度上来讲你也只需要测试自己代码的逻辑就够了,这也许就得从服务框架的角度入手,不管开发还是测试都要保证接口正常调用,必要的时候把接口 mock 掉(但不能滥用 mock),不过集成测试还是不可避免大量服务的依赖。
知乎从一开始就没有使用 Thrift 这样现成的 RPC 框架,而是基于 Protocol Buffers 自己搞了一个,后来又有了 JSON 序列化的框架,也逐渐从 Python 版扩展到 Node.js、Java 等语言。对于使用 HTTP 协议或者现成框架的团队来说可能这不是什么问题,但对于我们来说几乎是从零开始。凡事有利有弊,但知乎技术团队还是更倾向于简单的解决方案,服务化是一个生态,每个组件都需要完成自己的工作,框架可能是其中的胶水,把各个组件连接起来。2015 年我们会继续完善最新一版的 RPC 框架,同时还有整个服务化的生态。
这是一个比较有趣的话题,我也是在看 Martin Fowler 的文章时才第一次了解到。引用一段著名的理论——Conway's Law(这个理论因《The Mythical Man-Month》而得名):
Any organization that designs a system (defined broadly) will produce a design whose structure is a copy of the organization's communication structure.
Conway's Law http://www.melconway.com/Home/Conways_Law.html
翻译过来就是:一个组织设计的系统(广义的指代,并不一定指软件系统)往往就是公司管理组织结构的翻版。在大公司通常是这样的,设计师、产品经理、工程师、测试、运维分别属于不同的团队,但是他们又共同负责一个产品,于是这个产品可能就变成产品经理想好需求,设计师负责界面交互,弄好之后交给工程师,工程师弄好之后交给测试,测试通过最后交给运维部署,软件架构上每个角色负责的东西都是独立的。而 microservices 更加强调小团队,每个团队的成员可以承担多种角色(感觉跟敏捷开发好像),microservices 往往又跟 CI 和 CD 联系紧密,因此这样的组织结构能够更加契合新的软件架构。前段时间知乎内部也有关于这个话题的讨论,最后觉得软件架构和组织结构其实是相互影响的,任何一方不契合另外一方,都会造成这两个的融合。
写了这么多,并不是想说服务化有多么可怕,相反,在我看来在如今移动互联网的时代,microservices 架构是一个趋势,但每个团队应该根据自己当前的需要合理选择服务化架构,这个架构可以很复杂也可以很简单,没有必要完全相同,适合的就是最好的,这绝对是软件工程第一准则。
这篇文章基本上是对《Hadoop: The Definitive Guide, 4th Edition》第 4 章的转述,版权归作者所有。
YARN 提供了三种任务调度策略:FIFO Scheduler,Capacity Scheduler 和 Fair Scheduler,下面会分别详细介绍。
顾名思义,FIFO Scheduler 就是将所有 application 按照提交顺序来执行,这些 application 都放在一个队列里,只有在执行完一个之后,才会继续执行下一个。
这种调度策略很容易理解,但缺点也很明显。耗时的长任务会导致后提交的任务一直处于等待状态,如果这个集群是多人共享的,显然不太合理。因此 YARN 提供了另外两种调度策略,更加适合共享集群。下图是 FIFO Scheduler 执行过程的示意图:
既然需要多人共享,那 Capacity Scheduler 就为每个人分配一个队列,每个队列占用的集群资源是固定的,但是可以不同,队列内部还是采用 FIFO 调度的策略。下图是 Capacity Scheduler 执行过程的示意图:
可以看到,队列 A 和 B 享有独立的资源,但是 A 所占的资源比重更多。如果任务在被执行的时候,集群恰好有空闲资源,比如队列 B 为空,那么调度器就可能分配更多的资源给队列 A,以更好地利用空闲资源。这种处理方式被叫做「queue elasticity」(弹性队列)。
但是弹性队列也有一些副作用,如果此时队列 B 有了新任务,之前被队列 A 占用的资源并不会立即释放,只能等到队列 A 的任务执行完。为了防止某个队列过多占用集群资源,YARN 提供了一个设置可以控制某个队列能够占用的最大资源。但这其实又是跟弹性队列冲突的,因此这里有一个权衡的问题,这个最大值设为多少需要不断试验和尝试。
Capacity Scheduler 的队列是支持层级关系的,即有子队列的概念。下面是一个示例配置文件:
<?xml version="1.0"?>
<configuration>
<property>
<name>yarn.scheduler.capacity.root.queues</name>
<value>prod,dev</value>
</property>
<property>
<name>yarn.scheduler.capacity.root.dev.queues</name>
<value>eng,science</value>
</property>
<property>
<name>yarn.scheduler.capacity.root.prod.capacity</name>
<value>40</value>
</property>
<property>
<name>yarn.scheduler.capacity.root.dev.capacity</name>
<value>60</value>
</property>
<property>
<name>yarn.scheduler.capacity.root.dev.maximum-capacity</name>
<value>75</value>
</property>
<property>
<name>yarn.scheduler.capacity.root.dev.eng.capacity</name>
<value>50</value>
</property>
<property>
<name>yarn.scheduler.capacity.root.dev.science.capacity</name>
<value>50</value>
</property>
</configuration>
所有队列的根队列叫做 root
,这里一共有两个队列:dev
和 prod
,dev
队列之下又有两个子队列:eng
和 science
。dev
和 prod
分别占用了 60% 和 40% 的资源比重,同时限制了 dev
队列能够伸缩到的最大资源比重是 75%,换句话说,prod
队列至少能有 25% 的资源分配。eng
和 science
队列各占 50%,但因为没有设置最大值,所以有可能出现某个队列占用整个父队列资源的情况。
除了设置队列层级关系和资源分配比重之外,Capacity Scheduler 还提供了诸如控制每个用户或者任务最大占用资源、同时执行的最大任务数,以及队列的 ACL 等配置,详细请参考官方文档。
分配好了队列,要怎么控制任务在指定队列执行呢?如果是 MapReduce 程序,那么可以通过 mapreduce.job.queuename
来设置执行队列,默认情况是在 default
队列执行。注意指定的队列名不需要包含父队列,即不能写成 root.dev.eng
,而应该写 eng
。
Fair Scheduler 试图为每个任务均匀分配资源,比如当前只有任务 1 在执行,那么它拥有整个集群资源,此时任务 2 被提交,那任务 1 和任务 2 将平分集群资源,以此类推。
当然 Fair Scheduler 也支持队列的概念,下图是执行过程的示意图:
队列 A 首先执行任务,任务 1 拥有整个集群资源,随后队列 B 增加任务 2,这两个队列均分资源,接着任务 3 被提交到队列 B,但这并不会影响队列 A,任务 3 将会跟任务 2 一起均分资源。
设置 yarn.resourcemanager.scheduler.class
为 org.apache.hadoop.yarn.server.resourcemanager.scheduler.fair.FairScheduler
(在 yarn-site.xml
),如果你使用的是 CDH,那默认就是 Fair Scheduler(事实上,CDH 也不支持 Capacity Scheduler)。
Fair Scheduler 通过 fair-scheduler.xml
文件来进行各种设置,这个文件的位置可以通过 yarn.scheduler.fair.allocation.file
属性来控制(在 yarn-site.xml
)。如果没有这个文件,Fair Scheduler 采取的策略将是:每个任务都放在以当前用户命名的队列中,如果这个队列不存在,将会自动创建。
Fair Scheduler 也支持显式定义队列,就像 Capacity Scheduler 那样,下面是示例文件:
<?xml version="1.0"?>
<allocations>
<defaultQueueSchedulingPolicy>fair</defaultQueueSchedulingPolicy>
<queue name="prod">
<weight>40</weight>
<schedulingPolicy>fifo</schedulingPolicy>
</queue>
<queue name="dev">
<weight>60</weight>
<queue name="eng" />
<queue name="science" />
</queue>
<queuePlacementPolicy>
<rule name="specified" create="false" />
<rule name="primaryGroup" create="false" />
<rule name="default" queue="dev.eng" />
</queuePlacementPolicy>
</allocations>
这里自定义了两个队列:prod
和 dev
,权重比是 40:60,也就是说不采用均分的策略。每个队列可以有不同的调度策略,默认都是 fair
,此外还有 FIFO、Dominant Resource Fairness(drf
,后面会讲到)。详细的配置信息可以查看官方文档。
不同于 Capacity Scheduler,Fair Scheduler 是通过规则来决定放置的队列,即前面配置文件中的 queuePlacementPolicy
设置。第一个规则 specified
代表如果任务自己指定了队列,就放置到这个队列,如果没有指定,或者指定的队列不存在,就采用下一条规则。primaryGroup
规则的意思是试图将任务放置到当前用户的主要 Unix 组,如果这个队列不存在则继续下一条规则。default
规则会匹配所有任务,示例文件的意思是放置到 dev.eng
队列中。
queuePlacementPolicy
可以省略,如果不设置,那么默认的规则如下:
<queuePlacementPolicy>
<rule name="specified" />
<rule name="user" />
</queuePlacementPolicy>
也就是说除非显式指定队列,那么将会使用当前用户名作为队列,并且如果队列不存在将会自动创建。
当一个任务被提交到一个空队列,但是集群不太空闲的时候,这个任务不会被立即执行,需要等待其它任务执行完毕让出资源。为了等待时间更加可控,Fair Scheduler 支持「中断」(preemption)。
中断的意思是调度器会通过强行结束 container 执行的方式来释放资源,在满足某些条件的情况下。注意中断是以牺牲集群性能为代价的一种做法,因为被强行结束的 container 需要重新执行。
通过设置 yarn.scheduler.fair.preemption
为 true
来开启中断(在 yarn-site.xml
),同时还需要设置另外两个超时属性中的至少一个(在 fair-scheduler.xml
),超时的单位都是秒。
defaultMinSharePreemptionTimeout
或 minSharePreemptionTimeout
:如果一个队列等待当前设置的超时时间之后还是没有分配到应该分配的最小资源,那么调度器就会去中断其它 container。defaultFairSharePreemptionTimeout
或 fairSharePreemptionTimeout
:如果一个队列等待当前设置的超时时间之后还是没有分配到应该分配的资源的一半以上,那么调度器就会去中断其它 container。defaultFairSharePreemptionThreshold
或 fairSharePreemptionThreshold
可以用来调节阈值,默认是 0.5。以上三种调度都遵从 locality 原则。在一个繁忙的集群里,当一个任务请求一个节点的时候有很大概率这个节点正被其它 container 占用,比较显而易见的做法可能是立即寻找同一机柜里的其它节点。但是经过实际观察,如果稍微等待一段时间(秒级),分配到当前请求节点的概率将显著增加。这种策略叫做「延迟调度」(delay scheduling),Capacity Scheduler 和 Fair Scheduler 都支持这种策略。
每一个 node manager 会定期发送心跳给 resource manager,这其中就包含了该 node manager 正在运行的 container 数量以及可以分配给新 container 的资源。当采用延迟调度策略时,调度器并不会立即使用收集到的信息,而会等待一段时间,以达到遵从 locality 的目的。
Capacity Scheduler 的延迟调度通过 yarn.scheduler.capacity.node-locality-delay
来配置,这是一个正整数,假设是 n,表示调度器将会放弃前 n 条心跳信息。
Fair Scheduler 的延迟调度通过 yarn.scheduler.fair.locality.threshold.node
来设置,这是一个 0~1 之间的浮点数,例如是 0.5,表示调度器将会等待超过一半的节点发送心跳信息之后再决定。
如果只有一种资源类型需要调度,例如内存,那资源容量的概念将会很简单,比如均分资源,就代表均分内存。但是如果有多种资源类型,例如再加上 CPU,事情就变得复杂了。如果一个任务需要很多的 CPU,但是很少的内存,而另一个任务需要很少的 CPU,很多的内存,这两个任务要如何比较呢?
Dominant Resource Fairness(DRF)就是用来干这种事情的,下面举例说明是什么意思。
假设一个集群总共有 100 个 CPU,10 TB 内存。任务 A 需要 2 个 CPU,300 GB 内存。任务 B 需要 6 个 CPU,100 GB 内存。那么 A 所需资源占集群的比重是 2% 和 3%,因为内存的比重更大,那么就可以以 3% 这个比重来整体衡量 A。同理,比较之后 B 的最终比重是 6%。因此任务 B 需要两倍于任务 A 的资源(6% 比 3%),如果是均分(fair)策略,那么 B 的 container 数量将会是 A 的一半。
DRF 没有默认使用,因此在计算资源的时候只考虑了内存,而忽略了 CPU。Capacity Scheduler 需要设置 yarn.scheduler.capacity.resource-calculator
为 org.apache.hadoop.yarn.util.resource.DominantResourceCalculator
(在 capacity-scheduler.xml
);Fair Scheduler 需要设置 defaultQueueSchedulingPolicy
为 drf
。
FIFO Scheduler 显然不适用于生产环境;Capacity Scheduler 概念简单,但缺乏灵活性;Fair Scheduler 最复杂,但具有足够的灵活性以及更好的资源利用率。
]]>曾经对香港的印象就是便宜的苹果电脑和遍地的茶餐厅,竟忘记了这是一个靠海的岛屿。作为一个在西部长大的孩子,对于海总是有很多憧憬。从小到大见过很多地方的海,有浑浊的,有碧蓝的,有挤满游客的,也有波涛汹涌的。其实海不一定就是蓝色的,只是人们习惯性地把自己的愿望加诸在别的东西身上,所以如果某一天你见到了不是蓝色的海,请不要抱怨它。
听说厂里要组织去香港培训帆船的时候很兴奋,想象在海上漂泊一周,应该会遇到很多有趣的事吧,虽然对于帆船其实毫无概念。照例准备好各种东西,通行证、睡袋、手套、薄外套这些,翻箱倒柜居然找出了以前用过的八达通。同事帮忙买了香港的上网卡,想到以前去只能蹭酒店 Wi-Fi 的窘境。
恍然来到香港,跟同事会合,一路在港铁上打趣,穿越拥挤的街道,坐在街边的茶餐厅看店员交谈,排着长队上太平山,俯瞰星光点点的维港,再来一份糖水,第一天的香港,还是老样子。
为了准时到达跟教练约好的地点,第二天起了个大早,幸好还有时间品尝热粥。见面的地点是香港帆船俱乐部的会客厅,墙上一张很大的地图绘制着香港岛屿周围的海域,以及各种不了解含义的符号,后来教练有介绍上面大部分的标识。教练是个苏格兰人,我们习惯称他 Cameron,接下来的五天我们一点一点了解着这个中年男人,互相交谈,互相倾听。
帆船在英文里叫做 yacht,如果你查字典的话会发现还有一个含义是游艇,可能在大多数人的印象中只有游艇,一个象征有钱人的东西。但真正的帆船运动远不是点燃发动机,操控船舵那么简单。也许这五天对我来说最大的意义就是了解到世界上还有这样一种运动,需要丰富的知识和经验,需要团队协作,需要良好的体力,还需要极大的热情。
上船的第一天对我来说应该是不太好的,在讲完必要的安全须知之后,我们正式从铜锣湾起航,一点一点远离维港。在驶到相对宁静的海湾之后,开始学习船员落水后的救援措施。首先发现落水的人需要大喊一声,扔下救生器具,同时死死盯住落水者。这是很关键的一步,Cameron 讲到在海上其实很难发现那里有一个人,尤其是人浮在水面上的部分很有限,而救生器具除了帮助落水者以外,其实还有标记的作用。这时船长需要选择一个逆风的路线逐渐靠近落水者,之所以要逆风是为了尽量控制船的行进,千万不能顺风,对于帆船来说顺风就是噩梦。最后其他的船员要负责捞起落水者,而船长始终由第一个发现的人指挥方向,因为只有他知道落水者的实际位置,以及跟船之间的距离,这需要对指挥者的充分信任。如此练习几次之后,我们向着更广阔的海域驶去。
「天气不错,让我们起帆吧。」 Cameron 说道。虽然这艘船有发动机,但是只要天气合适 Cameron 都更愿意使用帆来航行,「这会让你不断思考需要怎样控制船,注意风的变化,而且也更环保。」我们的帆船一共有两个帆,分别叫做 mainsail 和 genoa。首先需要升起 mainsail,也就是主帆,是个体力活,但也需要技巧和配合。帆船上的很多工作都需要很好的体力,但看似简单的步骤如果掌握了技巧会让你轻松很多。接下来是 genoa,这是一个在主帆前面的帆,比主帆大很多,根据不同的风向,我们需要控制它的大小。此时的我已经开始晕船,这真是一种不太好的感觉,于是接下来的练习我也基本上没有参与,静静躺在船上随着波浪起伏。在太阳下山前我们赶到了浅水湾,今晚将会在这里过夜,但不会靠岸。趁着夕阳我拍下了文章开头的那张照片,浅水湾真是一个适合停靠的地方,夜晚看着对面灯火辉煌的楼宇,安静的海面,时而波动。
第二天 Cameron 告诉了我们一个不幸的消息,今天似乎没风,这意味着原计划的航行只能作罢。「但是没关系,即使没风我们也可以做很多其它的练习。」Cameron 不放过任何练习的机会,他告诉我们虽然我们报名的课程里没有,但是他很愿意教授我们更多帆船的知识。既然没风,那就练习怎么使用发动机吧。如何原地转弯,如何快速调头,如何掌舵,如何观察水的流向,如何将船固定在港口的浮标上,这些都要一遍一遍地练习。午餐之后,幸运的我们又迎来了风。「让我们起帆出海吧!」这可能是 Cameron 最喜欢说的一句话,这个男人对于大海总是有着极大的热情。依旧是类似昨天的练习,但要更熟练,更迅速。
帆船依靠风来航行,因此对于不同风向,帆船的方向以及帆的角度和大小都至关重要。通常情况下正对风左右各 45 度角的区域是无法航行的,称为「no-go zone」,这是一个绝对不能进入的区域,否则就会失去动力。有时我们需要转向,此时风向也会从船的一边变到另外一边,因此帆的角度也要同时变化,这个过程叫做 tacking 或者 jibing。你需要不断观察风向,以及船与风的夹角,因为风向随时可能会变化。而对于帆船来说最好的位置是航向与风向呈 90 度角。「要怎么判断风向呢?」「用你的脸去感觉」虽然我们有风向仪,但 Cameron 更喜欢原始的方法,他总是说你得学会在仪器坏了的情况继续航行。
第三天我们迎来了距离最远的一次航行,从深水湾到西贡,意味着我们将有夜航的课程。夜航是一种完全不同的体验,你无法看清海面,也没有明显的参照物,因此船上的灯光显得尤为重要。到了晚上任何船只都会分别在左舷和右舷亮起红色和绿色的灯,这样就能够很方便地判断某一艘船与你的方位。远处的灯塔指引着你的方向,也提醒你这里可能会有礁石,不同的灯塔会有不同颜色、不同形式的灯光,便于区分。夜航带来了更多挑战,也更加危险,不过 Cameron 说道虽然帆船跟飞机有很多相似的地方,但最大的好处是如果遇到紧急情况帆船可以立即停下来。
之后的两天是对于前几天的复习,很快五天就这样过去了,想到周一我们还对帆船一无所知,如今已是可以控制它航行的船员了。我想我以后也许不会继续参与这项运动,但帆船运动的精神会一直伴随着我,至于 Cameron 的传奇经历,只能等以后再写了。
]]>有了这个想法之后我先去找找看是否有类似的软件,但已有的剪贴板管理工具都没有这样的功能。于是决定自己动手做,因为没有开发 Mac app 的经验,首先想到的就是利用 Automator 来实现,可惜 Automator 不支持后台运行。经过搜索 StackExchange 上的一个问题给了我思路:用 AppleScript 来做。
打开 AppleScript Editor,输入以下代码,代码大意是每隔 1 秒判断剪贴板内容是否为 URL,如果是就在浏览器中打开。
property oldValue : missing value
on idle
local newValue
set newValue to the clipboard
if oldValue is not equal to newValue then
try
if newValue starts with "http://" or newValue starts with "https://" then
do shell script "open " & newValue
end if
end try
set oldValue to newValue
end if
return 1
end idle
保存,「File Format」选「Application」,勾选「Stay open after run handler」。
AppleScript 程序运行时会在 Dock 上显示一个图标,我们需要隐藏这个图标。
增加一个新的 key「Application is background only」,value 为「YES」。
在 System Preferences → Users & Groups → Login Items 中添加刚才创建的 app,并设置为 hide 模式。
]]>Provisioning 是 Vagrant 一个很棒的特性,可以通过工具来自动配置和管理虚拟机。目前支持的有 Puppet 和 Chef,这两个都是著名的配置管理工具,其中 Google、Twitter、GitHub 在用 Puppet,Facebook 在用 Chef,知乎目前用的是 Puppet。正好这次两个都了解了一点,可以简单比较一下。
从安装方式来说,因为都是基于 Ruby 的工具,所以都可以通过 gem
来安装,从这一点上来说还是很方便的(话说对于 Mac 用户,千万别用官方提供的烂方法)。Puppet 的命令行工具就叫 puppet
,而 Chef 的叫做 knife
,这倒是跟 Chef 本身名字很搭。初学工具,肯定要看官方文档,在这一点上我觉得 Puppet 做得更好,至少还有一个像模像样的 Learning Puppet 系列,由浅入深,循序渐进,基本上看完就可以对 Puppet 有个大概的了解和使用。而 Chef 就只扔给你一个不知道该从哪看起的页面,作为初学者表示很难入门。
Puppet 可以将一系列的配置文件打包成一个 module 供人下载,Chef 对应的则叫做 cookbook,这两者都提供了网站用于集中放置社区贡献的包,分别是 Puppet Forge 和 Opscode Community(不得不吐槽,这两个网站都很糙)。对于 module、cookbook 的安装及管理 Chef 略胜一筹,Puppet 的命令行工具可以很方便地安装 module,但是如果需要安装的包比较多,就只能通过自己写脚本来自动处理。而 Chef 有一个很好用的工具 Librarian-Chef,只需要定义好所有依赖包,并放到 Cheffile 中,就可以通过 librarian-chef
命令来安装和管理。
Puppet 在易用性,社区质量和包的扩展性上来说要比 Chef 略优,能查到的文档资料也更多一点,最终我选择了 Puppet,这里是我的适用于 Phabricator 的配置文件,对 Chef 有兴趣的同学也可以看这个示例配置。
]]>redis-cli monitor | grep '"set" "alist"'
孩子,还记得我讲过的怎样遇见你母亲的故事吗?那是一个明媚的午后,记忆中的阳光总是很灿烂。当那个女生出现时,时间仿佛凝固,她没有注意到你,你知道这是一个需要你用一生去爱的女人。是的,一生。年轻人总是有无尽的诺言,但是诺言是沉重的,兑现诺言的过程是洗礼,也是炼狱,你们虽然彼此伤害,却靠得更近。
我对你的爷爷奶奶知之甚少,大部分是从旁人那里听说。他们小学是一个学校的,奶奶上学会经过爷爷的屋前。后来奶奶高中毕业后就开始教书,而爷爷则继续深造师范学校,传说他们从这时便已经在谈恋爱,分隔两地免不了很多的思念与痛苦,爷爷常常笑着说当年可是拒绝了很多女生的诱惑。爷爷毕业后回到了奶奶教书的学校,多年的长跑也终于有了结果。其实你还有一个姑姑,不过连我也没有见过。她是爷爷奶奶的第一个小孩,听人说长得很乖巧,但在十几岁时便由于生病去世了。爷爷奶奶教了一辈子学生,却不怎么跟我说起他们的故事,也许是不知如何表达。
我们都会老去,我们也曾年轻,你的困惑就是我们曾经的困惑,你的烦恼就是我们曾经的烦恼。如果你想倾诉,别忘了在远方还有你的母亲,还有我,不管发生什么,我们永远都是你最亲的人。我知道你曾经也恨过我们,但那不是真正的恨,我相信有那么一天我们能彼此释然。
到那时,你会了解,我们是如此深深地爱着你。
]]>在开启 responsive 后,小屏幕设备上显示 modal 时会变成一闪而过,然后浮动窗口就不见了。具体效果可以缩小浏览器尺寸,在这个页面的 Live demo 点击「Launch demo modal」看到。Issue #2130 专门讨论了这个问题,目前比较好的解决办法是使用这个插件,根据页面大小来动态调整 modal 的位置,不过貌似用了之后 modal 那个由上至下显示的动画就没有了。这个 issue 现在还处于开启状态,看来官方短期内是不会解决这个问题的。
responsive 模式下的 navbar 显示效果很赞,但是有一个很令人费解的事情,默认情况下所有 dropdown menu 都是展开的,对于使用多个菜单项,且子菜单条目很多的场景这是不能接受的。于是 Issue #3184 出现了,这次的方案比较 hack,需要修改 bootstrap-responsive.css,将 最新版 Bootstrap 已经修复了 dropdown menu 默认展开的问题,但是(总是有很多但是),在触屏设备上子菜单是选不中的。托 filod 同学的福,修改 .nav-collapse .dropdown-menu
里的 display: block;
注释掉。这时你会惊喜地发现 dropdown menu 默认折叠了,点击也能展开子菜单。bootstrap-dropdown.js
中的一段代码:
/* APPLY TO STANDARD DROPDOWN ELEMENTS
* =================================== */
$(document)
.on('click.dropdown.data-api touchstart.dropdown.data-api', clearMenus)
.on('click.dropdown touchstart.dropdown.data-api', '.dropdown form', function (e) { e.stopPropagation() })
.on('click.dropdown.data-api touchstart.dropdown.data-api' , toggle, Dropdown.prototype.toggle)
.on('keydown.dropdown.data-api touchstart.dropdown.data-api', toggle + ', [role=menu]' , Dropdown.prototype.keydown)
这里同时监听了 click 和 touchstart 事件,于是在触屏设备上先有 touchstart 将子菜单隐藏,再有 click 点击到隐藏后该位置的菜单项,因此你永远都不可能点到想点的子菜单。根本原因也是因为我们之前注释了 关于这个问题的讨论可以看 Issue #4550,不明白为什么官方一直不解决,我的修改可以见这个和这个 commit。display: block;
引起,改变了 Bootstrap 的使用场景,于是 JS 出现如此纰漏。解决方法便是不监听 touchstart 事件,虽然会造成些小问题,不过也算基本满足要求。这个 issue 官方明确表示不会采纳,不过还是希望以后有机会增加一个开关选项给用户。
@asynchronous
decorator 来实现异步请求,但使用的时候必须将 request handler 和 callback 分离开,tornado.gen
模块可以帮助我们在一个函数里完成这两个工作。下面是官方的一个例子:class GenAsyncHandler(RequestHandler):
@asynchronous
@gen.engine
def get(self):
http_client = AsyncHTTPClient()
response = yield gen.Task(http_client.fetch, "http://example.com")
do_something_with_response(response)
self.render("template.html")
这里用到了两个 decorator 稍显复杂,第一个 @asynchronous
会首先被执行,它的主要工作就是将 RequestHandler
的 _auto_finish
属性置为 false
,如下:
def asynchronous(method):
@functools.wraps(method)
def wrapper(self, *args, **kwargs):
if self.application._wsgi:
raise Exception("@asynchronous is not supported for WSGI apps")
self._auto_finish = False
with stack_context.ExceptionStackContext(
self._stack_context_handle_exception):
return method(self, *args, **kwargs)
return wrapper
接着就是最重要的 @gen.engine
,这里充分利用了 generator 的各种特性,首先来看 @gen.engine
的实现(我删减了部分代码以简化理解):
def engine(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
gen = func(*args, **kwargs)
if isinstance(gen, types.GeneratorType):
runner = Runner(gen)
runner.run()
return
return wrapper
局部变量 gen
代表第一段代码里的 get
函数,因为 get
包含了 yield
语句,因此成为了一个 generator。注意这里 get
并没有被执行,只是赋给了 gen
。接下来是运行 Runner
对象的 run
函数。在理解 run
之前需要知道 generator 是通过调用 next()
或者 send()
来启动,启动之后会在遇到 yield
的地方 hold 住,然后将 yield
后面的语句的返回值返回给调用者,generator 此时即处于暂停运行状态,所有上下文都会保存。再次调用 next()
或 send()
便会恢复 generator 的运行,如果不再遇到 yield
语句就会抛出 StopIteration
异常。在恢复运行的同时 yield
语句本身会有返回值,如果是通过调用 next()
来恢复的,那么返回值永远是 None
,而如果是通过 send()
则返回值取决于传给 send()
的参数。更多关于 generator 的说明请参考官方文档。
结合第一段的示例代码,可以想到 run
干的工作可能就是启动 generator,然后获得 gen.Task
对象并调用 http_client.fetch
函数,等回调回来之后恢复 generator 的运行,最后将回调的返回值通过 send()
赋给 response
。下面是我简化后的代码。
def run(self):
while True:
if not self.yield_point.is_ready():
return
next = self.yield_point.get_result()
try:
yielded = self.gen.send(next)
except StopIteration:
return
if isinstance(yielded, YieldPoint):
self.yield_point = yielded
self.yield_point.start(self)
第 3 行检查回调是否完成,第一次运行 run
总是会返回 True
。第 5 行获取回调的返回值,同样的第一次运行返回的是 None
。将 None
传给 send()
启动 generator,yielded
即是 gen.Task
对象,第 12 行调用 start
开始运行我们真正需要运行的函数,对应到示例代码就是 http_client.fetch
函数,同时将 Runner
的 result_callback
作为回调函数。如下:
def result_callback(self, key):
def inner(*args, **kwargs):
if kwargs or len(args) > 1:
result = Arguments(args, kwargs)
elif args:
result = args[0]
else:
result = None
self.results[key] = result
self.run()
return inner
在得到回调返回值之后再次调用 run
,通过 get_result
获取返回值,最后将返回值返回赋给 response
,继续 request handler 的代码流程。
「WarGames」是一部 1983 年上映的科幻电影,作为投资仅 1200 万美元的小制作,在当年赢得了近 8000 万的票房。故事发生在美苏冷战时期,那个年代的电影,多多少少都会跟核威慑有关。这两个国家不管谁先发射导弹,那第三次世界大战就会爆发。电影中 NORAD(北美防空司令部)使用了一台叫做 WOPR 的超级计算机进行战事控制,这台计算机特别的地方在于它能自己模拟战争,模拟的过程就像在玩一个游戏(game)。某一天,我们的男主角天才高中生无意中侵入了这台电脑,出于好奇和好玩,启动了核战争游戏。不料这场模拟战争误使 NORAD 以为苏联发动了袭击,一度差点引爆真正的核对抗。最后在男主角和 WOPR 创造者的共同努力下及时终止了这场「战争」。
电影中男主角的人物原型来自一位叫做 David Scott Lewis 的黑客,这位老兄之后一直在搞机器人,后来按照他自己的话来说是「sold my soul to the bigger corporations」,分别在三星、Microsoft 和 Oracle 工作一段时间之后,他来到了中国,跟清华大学合作。现在貌似又搞太阳能去了。WOPR 的创造者 Dr. Stephen Falken 的原型来自著名的物理学家 Steven Hawking(是不是名字也很像?),WarGames 最初的剧本就是根据 Hawking 来写的,曾经还打算塑造一个坐在轮椅上的天体物理学家,但因为太容易让人联想到另一部讲述冷战的电影「Dr. Strangelove」而作罢。其实 Dr. Stephen Falken 对于人工智能的深入研究,倒让我觉得更像是 Alan Turing。
WarGames 对于此后的黑客文化产生了深远的影响。它创造了「firewall」这个词汇(方校长表示感谢)。电影中男主角入侵 NORAD 时使用的技术衍生出了「wardialing」术语。wardialing 是指通过程序不断扫描电话号码来发现计算机 modem,早期的电话黑客即是使用的这种技术。著名的电话黑客(phone phreak)John Draper 因为通过 wardialing 免费打电话而闻名,后来他将这项技术教给了 Steve Jobs 和 Steve Wozniak(是的,没错,就是乔帮主),帮主他们还因此赚了不少钱(帮主果然是个好商人,从小就懂得怎么把技术转为商业利益)。John Draper 后来受雇于 Apple,但这是后话了。wardialing 之后又衍生出了 wardriving,通过扫描和收集 Wi-Fi 热点来进行攻击,之所以叫这个名字,是因为通常是在汽车里一边行驶,一边收集(这样说来 Google 的街景小车也算是 wardriving 了一把)。现在你可以在你的 iPhone 或者 Android 手机里装上一款 wardriving 软件试试看,我曾经试过,但是貌似效果不是很好。
还有一个人「深受」这部电影的影响,Kevin Mitnick,这个计算机安全界传说级的人物。当然不是说 Kevin 同学是因为这部电影走上的不归路,Kevin 同学搞入侵那会儿 WarGames 还没上映呢。Kevin 同学后来入狱时被拒绝接触任何电子设备,包括电话,是因为控诉律师相信他可以通过电话连接到 NORAD。据 Kevin 同学分析 WarGames 这部电影很大程度上使得公众相信这件事情是可以很容易办到的,其实他根本就没有入侵过 NORAD,通过电话来入侵也过于夸张。尽管如此,他还是被判单独监禁,从此传为计算机安全界的一段「佳话」⋯⋯
游戏界也从 WarGames 获益不少,1984 年同名游戏发布。2006 年一款叫做 DEFCON 的即时战略游戏发布,游戏画面与电影中 NORAD 指挥中心的大屏幕极为相似。DEFCON(defense readiness condition)是美国军方采用的警报等级,在 WarGames 电影中 NORAD 曾因模拟的苏联进攻一度将 DEFCON 等级提升到最高等级 1。著名的黑客大会 DEF CON 的名字也是来源于此。
或许 WarGames 对于电影史并没有太大的贡献,但却深深影响着那一代的 Geek 们。谨以此文献给那些逝去的先驱,RIP
]]>后来我从大猫同学那里听说了 CMake,CMake 使用 C++ 编写,原生支持跨平台,不需要像 Autotools 那样写一堆的配置文件,只需一个 CMakeLists.txt 文件即可。简洁的使用方式,强大的功能使得我立马对 CMake 情有独钟。在后来的使用过程中,虽然会遇到一些因为使用习惯带来的小困扰,但我对于 CMake 还是基本满意的。直到我发现了 GYP。
GYP(Generate Your Projects)是由 Chromium 团队开发的跨平台自动化项目构建工具,Chromium 便是通过 GYP 进行项目构建管理。为什么我要选择 GYP,而放弃 CMake 呢?功能上 GYP 和 CMake 很是相似,在我看来,它们的最大区别在于配置文件的编写方式和其中蕴含的思想。
编写 CMake 配置文件相比 Autotools 来说已经简化很多,一个最简单的配置文件只需要写上源文件及生成类型(可执行文件、静态库、动态库等)即可。对分支语句和循环语句的支持也使得 CMake 更加灵活。但是,CMake 最大的问题也是在这个配置文件,请看下面这个示例文件:
cmake_minimum_required(VERSION 2.8)
project(VP8 CXX)
add_definitions(-Wall)
cmake_policy(SET CMP0015 NEW)
include_directories("include")
link_directories("lib")
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY "../lib")
set(VP8SRC VP8Encoder.cpp VP8Decoder.cpp)
if(X86)
set(CMAKE_SYSTEM_NAME Darwin)
set(CMAKE_SYSTEM_PROCESSOR i386)
set(CMAKE_OSX_ARCHITECTURES "i386")
add_library(vp8 STATIC ${VP8SRC})
elseif(IPHONE)
if(SIMULATOR)
set(PLATFORM "iPhoneSimulator")
set(PROCESSOR i386)
set(ARCH "i386")
else()
set(PLATFORM "iPhoneOS")
set(PROCESSOR arm)
set(ARCH "armv7")
endif()
set(SDKVER "4.0")
set(DEVROOT "/Developer/Platforms/${PLATFORM}.platform/Developer")
set(SDKROOT "${DEVROOT}/SDKs/${PLATFORM}${SDKVER}.sdk")
set(CMAKE_OSX_SYSROOT "${SDKROOT}")
set(CMAKE_SYSTEM_NAME Generic)
set(CMAKE_SYSTEM_PROCESSOR ${PROCESSOR})
set(CMAKE_CXX_COMPILER "${DEVROOT}/usr/bin/g++")
set(CMAKE_OSX_ARCHITECTURES ${ARCH})
include_directories(SYSTEM "${SDKROOT}/usr/include")
link_directories(SYSTEM "${SDKROOT}/usr/lib")
add_definitions(-D_PHONE)
add_library(vp8-armv7-darwin STATIC ${VP8SRC})
endif()
你能一眼看出这个配置文件干了什么吗?其实这个配置文件想要产生的目标(target)只有一个,就是通过 ${VP8SRC}
编译生成的静态库,但因为加上了条件判断,及各种平台相关配置,使得这个配置文件看起来很是复杂。在我看来,编写 CMake 配置文件是一种线性思维,对于同一个目标的配置可能会零散分布在各个地方。而 GYP 则相当不同,GYP 的配置文件更多地强调模块化、结构化。看看下面这个示例文件:
{
'targets': [
{
'target_name': 'foo',
'type': '<(library)',
'dependencies': [
'bar',
],
'defines': [
'DEFINE_FOO',
'DEFINE_A_VALUE=value',
],
'include_dirs': [
'..',
],
'sources': [
'file1.cc',
'file2.cc',
],
'conditions': [
['OS=="linux"', {
'defines': [
'LINUX_DEFINE',
],
'include_dirs': [
'include/linux',
],
}],
['OS=="win"', {
'defines': [
'WINDOWS_SPECIFIC_DEFINE',
],
}, { # OS != "win",
'defines': [
'NON_WINDOWS_DEFINE',
],
}]
],
}
],
}
我们可以立马看出上面这个配置文件的输出目标只有一个,也就是 foo
,它是一个库文件(至于是静态的还是动态的这需要在生成项目时指定),它依赖的目标、宏定义、包含的头文件路径、源文件是什么,以及根据不同平台设定的不同配置等。这种定义配置文件的方式相比 CMake 来说,让我觉得更加舒服,也更加清晰,特别是当一个输出目标的配置越来越多时,使用 CMake 来管理可能会愈加混乱。
配置文件的编写方式是我区分 GYP 和 CMake 之间最大的不同点,当然 GYP 也有一些小细节值得注意,比如支持跨平台项目工程文件输出,Windows 平台默认是 Visual Studio,Linux 平台默认是 Makefile,Mac 平台默认是 Xcode,这个功能 CMake 也同样支持,只是缺少了 Xcode。Chromium 团队成员也撰文详细比较了 GYP 和 CMake 之间的优缺点,在开发 GYP 之前,他们也曾试图转到 SCons(这个我没用过,有经验的同学可以比较一下),但是失败了,于是 GYP 就诞生了。
当然 GYP 也不是没有缺点,相反,我觉得它的「缺点」一大堆:
RuntimeLibrary
配置,这不利于跨平台配置文件的编写,也无形中增加了编写复杂度。make clean
,唯一的方法就是将输出目录整个删除或者手动删除其中的某些文件。如果你已经打算尝试 GYP,那一定记得在生成项目工程文件时加上 --depth
参数,譬如:
gyp --depth=. foo.gyp
这也是一个从 Chromium 项目遗留下来的历史问题。
也许你根本用不上跨平台特性,但是 GYP 依然值得尝试。GYP 和 CMake 分别代表了两种迥异的「风格」,至于孰优孰劣,还得仁者见仁,智者见智。
]]>Yes。Octopress 具备一个博客应当具备的所有功能,文章、评论、页面、分享、RSS、搜索、Archives 等等。
No。正如 Octopress 网站介绍所说:A blogging framework for hackers,重点就在最后那个 hackers。没有了 WordPress 的后台界面,写博客需要的工具仅仅是 Ruby、Git、Markdown 和你喜爱的编辑器。如果你是一个 hacker,那你对这些工具不会陌生,相比 WordPress 蹩脚的后台页面,Octopress 提供的写作方式会让你非常喜爱。
博客最重要的功能就是写作,写作就像程序员编写代码,如果不能提供舒服的方式,那简直是一种自虐。事实已经证明 HTML 不是一种好的写作方式,因此 WordPress 这类博客提供了所见即所得编辑器,但对于喜欢精确掌控的 hacker 来说这还不够,于是类似于 Markdown 这样的标记语言逐渐在圈内盛行。这类标记语言最大的好处就是让作者不用关心文章的样式,而专注于文章的内容。这很重要,一篇文章的精髓在于文字,如果过多地被样式困扰,精力便会分散,也必然不会思考出更好的文字。类似的比较还有 Word 和 LaTeX,当然这种观点也是仁者见仁,智者见智。
Octopress 原生为我们提供了 Markdown 支持,需要写一篇新博客了?打开你喜欢的编辑器,使用 Markdown 语法开始书写即可。WordPress?虽然也可以添加 Markdown 支持,但不免显得蹩脚。
这个功能对程序员来说尤为重要,但至今没有博客提供原生支持,这也是最大的遗憾。Octopress 彻底颠覆了这种局面,语法高亮变得如此顺其自然。
#!/usr/bin/env python
# -*- coding: utf-8 -*-
# Batch rename utility.
# Copyright (C) <2011> xiaogaozi
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import os
import re
import sys
def usage():
"""Usage"""
print "Usage: rename.py expr files"
def main():
"""Main progress."""
if len(sys.argv) < 3:
usage()
sys.exit(1)
expr = sys.argv[1]
m = re.search(r'^[sy]/([^/]*)/([^/]*)/$', expr)
if m is None:
sys.stderr.write("expression incorrect\n")
sys.exit(1)
re1 = m.group(1)
re2 = m.group(2) # actually in substitute mode this portion is not regular expression
for oldfile in sys.argv[2:]:
d = os.path.dirname(oldfile)
oldname = os.path.basename(oldfile)
newname = ''
newfile = ''
if expr[0] == 's': # substitute
newname = re.sub(re1, re2, oldname)
newfile = os.path.join(d, newname)
os.rename(oldfile, newfile)
elif expr[0] == 'y': # transliterate
pass
print oldfile, '->', newfile
if __name__ == "__main__":
main()
程序员已经被版本控制惯坏了,只要能纳入版本库,就统统放进去。WordPress 拥有同样蹩脚的版本控制,显然不足以满足 hacker 的需求,Octopress 为我们提供了 Git 原生支持,一切一切都为你所控,放在你喜欢的版本控制库里即可。
Octopress 为我们提供了三种部署方式:GitHub Pages,Heroku,Rsync,在我看来,其实就两种:免费和收费。GitHub Pages 和 Heroku 都是免费使用,Rsync 则需要你拥有自己的虚拟主机。我选择了 Heroku,毕竟 GitHub Pages 本意是用来放项目介绍页面的,结果被强大的 hacker 们发掘来作为博客了⋯⋯ 我现在使用的是 GitHub Pages。
开始享受写作的乐趣吧~
]]>首先 SPDY 是一个应用层协议,它被创造出来的唯一目的就是让 Web 更快,更快,还是更快。Google 这家公司似乎很喜欢「快」这个东西,Chrome 从诞生到现在每次几乎必定宣传自己有多么得快,搞得大家已经产生了某种心理暗示。SPDY 诞生于 2009 年,其实这是对外公开发布的时间,开始研究的时间应该更早。众所周知,如今的 Web 是通过 HTTP 协议和 TCP 协议进行传输,但种种因素导致 HTTP 传输变得很慢:
既然 HTTP 有这么多缺点,那应该不止 Google 自己想要解决,其实是有的,本着不重复造轮子的原则 Google 列举了现有的一些改进方案:
但是 Google 同学觉得以上这些都还不够,它要追求更大程度的性能提升。考虑到 TCP 现在应用还很广泛,想替代也不是一天两天的事情,但 HTTP 就不一样了,它是应用层的!所以说有自家的浏览器就是好办,发明个应用层协议马上就可以上线。SPDY 在刚出来的时候 Google 还在说这并不是用来替代 HTTP 协议的,它只是一个中间协议,但看看最新的协议文档里面已经将 SPDY 分为了两层,其中一层被描述为 HTTP-like,大有取代 HTTP 的意图(Google 最近的一篇文章已经直呼 SPDY 为「a replacement for HTTP」)。可以想到 Google 已经将提议提交给 IETF,也许未来的某一天我们就不再使用 HTTP 协议了。SPDY 主要有以下一些特性:
这些改进到底能有多大提升?Google 给出的数据是 39%~55%,在丢包严重或高延迟环境下,SPDY 表现更加出色。
要支持 SPDY,除了客户端必须支持外,还要有相应的 Web 服务器。现在已经有 Java、Apache module、Python、Ruby、node.js 等各种实现。
最后,如果你正在使用 Chrome 浏览器,并且访问 Google 的网站,那你已经开始使用 SPDY 了,输入 chrome://net-internals/#spdy 还可以了解更加详细的信息。
]]>