在《Hypertable应用实践:比肩HBase》一文中,作者主要从Hypertable的系统层面介绍了如何改进和优化系统来更好地支持业务。本文则主要从业务层面介绍如何基于BigTable模型设计表格及导入/查询策略。
NoSQL和SQL数据库谁更重要的争论从来都没停止,甚至NoSQL中哪些软件更好的争论也很多。和大多数人的观点类似,我的看法是基础架构软件的使用取决于其应用场景和系统成熟度,也取决于研发/支撑团队的能力。
在互联网平台软件行业里,有一种论调“大者恒大”。那些大家普遍看好的平台软件,会有更多人聚在其周围,会有更多人贡献。从长远来看,使用这种软件可以得到更好的生态系统支持。
HBase和Hypertable是BigTable的两个开源产物。HBase的社区影响力比Hypertable大,使用HBase的公司也更多。不过在这里我却持相反的论调,因为所有使用开源软件的公司,内部都会对其进行一些定制和优化,内部维护的版本和社区版本有一定差别。就社区版本而言,由于为HBase贡献代码的人数众多,所以引入的Bug也相应会多;而Hypertable的主体开发主要由少数核心人员完成,并且目前Doug
Judd成立了专门的公司来维护,因此在稳定性上不比HBase差,性能上又超过HBase。这对于强调节约成本的互联网公司来说,是个不小的诱惑。
社区论坛
在社区论坛中,不同用户可以在不同讨论区中的不同主题下跟帖。假设考虑使用Hypertable作为论坛数据的底层存储。
建表策略
主表字段划分。在实现系统中,同一个帖子的标题、内容等数据存放在同一个字段(Column)中,而不是像传统数据库那样分别存放在不同的Column中,主要出于以下考虑。
- 由于Hypertable的数据存储是key-value方式,因此每个字段都要存放一份Row key和Column
name等,若字段数很多,存储开销较大。
- 除删除操作外,基本不存在单独修改一个字段的情况。而删除只需要打一个管理标记,如果增加一个单独的字段存放管理标记(flag字段),则可以完美支持删除操作。
- 几乎所有的查询操作都是查询整个帖子的内容,即使存在只查询部分字段的需求,也可以通过客户端过滤实现,所损失的仅仅是部分带宽。
索引表的主键设计。Hypertable只支持主键(row
key)索引,因此要加速根据其他字段内容进行的查询,只能通过创建索引表的方式。本系统中所有的索引表均采用index
value:date为Row key(例如userid:date),这样设计主要出于以下考虑。
- 既可以支持单纯按index value查询,如查询user id = 100发的所有帖子,只需指定
“100:” < row < “100;”;也可以附加时间段限制,如查询user id
= 100在北京时间2009年1月1日发的所有帖子:“100:1230796800” <= row
<= “100: 1230883200”。
- 防止一个index value下的帖子过多导致溢出。BigTable有一个限制,确保同一个Row
key下的数据不能超过某个阈值(例如200MB),而增加时间后缀可以有效规避这个限制。
- 对于简单的复合查询,如某个用户在某个讨论区中发的所有帖子,也可以通过简单的index value叠加建立索引表。但对于较复杂的查询,如某一组IP在某时间段内发在某几个讨论区中尚未被删除的帖子,使用简单的索引表就无能为力了。这种情况只能通过客户端逻辑对查询条件进行拆分,对查询结果进行过滤、求交集等操作来实现,很难做到实时。好在底层系统可以不考虑这类查询需求,交给上层Cache系统即可。
频繁读-删除-更新模式。在最初表格设计时,为了在BigTable现有功能框架上模拟按顶帖时间排序的功能,使用了一个ThreadByReplyTimeIndex表(key为forumid:最后回复时间按位取反:threadid,value为threadid)和一个ThreadInfo表(key为threadid,内容包括帖子数和最后回复时间等)。这样每次写入一个新帖子,就必须从ThreadInfo表读取本thread上一个post的回复时间,删除ThreadByReplyTimeIndex表中对应的那一行,然后重新插入一行。
要实现这种逻辑,必须每写一个帖子就flush一次,并且增加很多随机读请求。经测试,采用这种模式最初的写入速率很低,而且随着数据量增加,一次随机读的响应时间也随之增加,写入速率会很快下降。因此,如果需要对表格数据进行更新,或者涉及随机读,就要将该表格设置为内存表,提高访问效率。但in
memory模式非常耗费内存,原因如下。
- 为了支持稀疏表,Hypertable每个value是单独存的,而不是按行存,因此每个value都需要存一份key(包括Row
key、Column family、Column qualifier和Timestamp,最小开销16字节),再加上map数据结构的开销24字节,一个value至少有40字节额外开销,一个帖子就是40×13=520字节,比帖子的实际内容(平均300多字节)还多。
- 为了支持高并发,Hypertable采用MVCC(multi-version concurrency
control)模式存储key和value,即删改一个value只是追加了一个补丁,而不是在原值基础上修改。只有当Cell
Cache大小达到一定程度时才会清理多余的版本。
系统优化
数据更新压力。数据更新模式可能会对集群系统造成较大压力,假设BigTable的RangeServer每隔10秒会刷写一次CellCache,而线上流量实时更新时,BigTable每秒接收的数据量不会很大。
如果1秒接收350个命令包,平均每个消息大小约500字节(包含删除命令),那么每秒接收不到200KB的数据,10秒接收不到2MB的数据,造成了HDFS上写入很多小文件,再频繁地合并、删除这些小文件,则对HDFS造成较大压力。
如果刷写CellCache的间隔是5分钟,那么5分钟接收的60MB数据将会一次写到磁盘,再做合并、删除等操作的次数将会大大减少,这样HDFS的压力就会小得多。
数据导入压力。考虑多个进程并发地向集群导入历史数据,每个进程读取部分原始数据文件,实际执行中发现某个RangeServer的压力会增大,内存占用会在短时间内骤然增大,甚至出现内存耗尽的现象。
这里涉及BigTable系统两个方面的并行,一是Range间的并行,即多个导入的数据文件分别对应不同的Range;二是Range内的并行,即一个导入文件跨越多个Range。
BigTable模型中Range分裂时前半部分会被移走,该策略使得导入多个数据文件时,某台RangeServer总会接收各数据文件的流量,压力过大。为了防止压力集中,导入数据时原始文件按照PostID(Rowkey)的倒序排列,这使得每当Range分裂到其他RangeServer时,数据流量也会转移。
另外,社区的主表(POST)只有一个,Rowkey值是PostID。原始文件中每条记录的PostID都是顺序排列的,那么将原始文件的数据导入集群时,Range的负载会落到一个RangeServer上。
历史超链
需求描述
链接可以说是互联网最重要的“推荐”(或投票)形式,而推荐本质上是人的一种观念,是与时间相关的。将超链的产生和消亡历史存储下来,构建一个历史超链信息库,可以用于数据挖掘和分析,例如过期域名过滤、超链反作弊分析和用户等级区分等。
简单来看,超链状态有三种:新链接(new)、删除链接(del)和Anchor变化链接(mod)。状态转化如图1所示。
图1 历史超链信息库记录状态转化示意图
超链历史存储模块每天例行运行,超链变化信息归并到历史数据库。读取库数据时,可能只访问最新版本
,也可能访问所有历史版本记录。比如统计记录状态经常变化到del的频度,可以发现死链。总的来说,可以将历史库的应用需求列为如下三点。
- 需要统计出链(To)的变化,可能要统计所有出链,而不只是一个入链(From)下的。
- 需要统计最新的To。
- 如果From和To是分开存储的,最好把#号后面的那些字段(Anchor)和From一起存储,因为这些字段也是有用的。
对需求2来说,可将From作为key,该页面将From对应的多个To作为value,每次更新数据时以From为单位。Hypertable支持多版本存储,在扫描时指定max_versions为1就能获得最新版本的To。
对于需求3,同样可以将From作为key,To为Column,其他字段作为1个或多个Column。
对于需求1,现有系统在历史库中保留了每条记录(To)的状态,可扫描记录的所有版本,获得链接的变化(状态)序列。新库中每条记录的导入都需要在历史库中做若干次查询,这种随机查询的性能很低(几毫秒到几百毫秒),直接影响到新库记录的导入速率。历史超链信息库的初始数据量超过数百TB,每天产生几TB的新库量。
假设每条记录大小为0.1KB(已结构化),即使每天24小时不间断插入,每秒也需要近几十万次的插入速率,即每次插入前的查询需要小于2微秒,这显然做不到。
为了减少或避免插入记录时的查询动作,我们需要找到另一种方法来表示页面(From)链接(To)的变化序列。
建表策略
方案一
从图1来看,链接的状态变化是无序的,因此我们不能通过有序的序列来表示它,最直接的还是通过记录本身的存储方式来表示,以便使用超链库的应用程序能够通过记录本身的存储方式转化(间接)得到链接的变化序列。
一个方案是稀疏表,如图2所示,可以将From作为表的Rowkey,将多个To作为多个Column。假设追加导入3次后,形成图2中的表格,那么使用超链库的应用可以通过扫描From行Ton列的所有版本得到Ton链接的变化序列,即new→mod;同理,To1为new;To2为new→delete;Tok为new→
delete。
方案二
图2示意的设计方式可以满足需求3中对于链接历史状态变化序列的需求。不过这种方式存在以下一些新的问题。
图2 历史链接库稀疏表示意图1
- 与table Schema相关的额外操作。每次插入时需要去查询Schema。如果是新链接插入,还需要修改Schema。
- 链接状态变化的表示。图2只示意了通过稀疏表的空闲位置和有效位置来共同表示链接状态的变化,但Hypertable插入下条记录时:csdn.net$cloud.csdn.net/a/20120315/313137.html不会在空白列自动添加字段,它的存储方式是Rowkey+Column+value。
针对这些问题,有以下几种解决办法。
- 在每次插入时,在相应的空列处插入特殊标记$。这种做法的弊端是增大额外存储(Rowkey+Column+$),把稀疏表变成了稠密表,因为超链库的记录是大key。
- 不在稀疏表的空白处做标记。在应用使用超链库时,扫描列的同时扫描行,再做交(Join)运算,这样能够发现相应的“空格”位置。但这种方式使得只扫描一列的开销变得很大。
- 单独设置一个静态值,保存插入超链历史库的记录数。每次插入时,将这个记录数也写到表格中。假设写到每一列中,结果如图3所示。
图3 历史超链库稀疏表示意图2
通过将版本信息(记录计数)写入表格,使用超链库的应用能够读出版本序列。比如读cloud.csdn.net/a/20120315/313137.html,会得到版本变化序列0、2、3,它就表示了链接的状态变化,不连续的版本数表示了del和new的变化,连续的版本数表示了mod的变化。
不过这种方案的不足之处在于,插入计数是针对每个页面(From)的,即插入程序需要维护每个页面的计数,量级可能是百亿或千亿,每次导入都要先查询页面的计数。这些信息的存储将超过TB级,每次导入时这次额外的查询将比较费时。
方案三
为了避免这次计数查询,还需要一些额外信息,可从Hypertable的应用方面挖掘一些有效信息。已知每个页面的记录(From→To)每天有一条或者没有,那么我们可以利用时间戳。为表格新增一列,用来记录时间戳,通过其获得页面链接的状态变化序列。
应用程序在使用超链信息库时,需要扫描两列,一个是cloud.csdn.net/a/20120315/313137.html列(数据列),一个是TS列(时间列),如图4所示。
图4 历史超链库稀疏表示意图3
(value Day3)、(value Day1)是数据列,Day4、Day3、Day1、Day0是时间列。假设Day2那天没有这个页面的相关链接记录插入。首先将数据列和时间列按照时间一一对应起来,即Pair3<(null
Day4), Day4>,Pair2<(value Day3), Day3>,Pair1<(value
Day1), Day1>和Pair0<(null Day0), Day0>。
由于Pair0和Pair1时间按天连续,并且Ton列的value值从null→value,因此表示该链接(ton)的状态变为null→new。由于Pair1和Pair2时间按天不连续(Day2未导入该页面相关的链接信息),对这种情况,依照按天连续处理,并且此时cloud.csdn.net/a/20120315/313137.html列的值从value→value,表示页面链接在这三天间的变化是new→mod。Pair2和Pair3时间按天连续,并且且cloud.csdn.net/a/20120315/313137.html列的值从value→null,表示页面链接的变化是mod→del。
通过这个额外附加的时间列,我们能够在应用超链库时较方便地获取页面的状态变化,付出的代价是为超链表增加了1个稠密列。
表格的数目(是否所有记录存储一张表)是个问题,一种方案是整个库一张大表,From作为表的Row
key,To作为表的各个列。假设超链库中每个页面大概有100个To链接,如果页面超过百亿,那么表格需要创建百亿(1010)量级的列,虽然在逻辑上Hypertable是支持的。
另一种方案是每个页面(From)对应一个表,这样每张表的列数不超过100,但表格数目会超过百亿。从Root
Range推算,假设Range阈值是1GB,每条Range的记录按照100字节计算,它能记录107个Meta
Range,每个表至少一个Meta Range,即只能表示107个表格,距离1010差距较大,因此第二种方案不适用。
当然,折中考虑,可以先将From(Row key)做哈希计算,再划分到各个表格,从而能够减少表格的列数。
假设将From集合划分成1万多个子集,每个子集对应一张表格,以From为key,每个To占一列。即便是这样划分之后,每个链接库表格的列数也会是106量级,这需要实际测试。
方案四
到此为止,我们通过方案三的设计能够满足超链库逻辑方面的需求,但系统实现支持这种设计是个很大的挑战。
超链库表格Schema使用From作为Rowkey、To作为Column,这个设计的优势在于超链库应用程序的易用性,直接扫描所需的列即能获取相关页面链接的信息;其劣势在于超链库的文件数可能达到很大,尤其是Column和AccessGroup的对应方式,这有可能导致超链库产生上亿的文件。就目前所知,支持这个量级文件数的Hadoop集群至少需要数百个节点。
为了满足超链库的可用性,可以放弃易用性,即From作Rowkey的建表方案,选择From:To作为Rowkey,如图5所示。
图5 历史超链库稀疏表示意图4
超链库的Column Family包括两列,Anchor和Timestamp,Timestamp记录着该条记录的时间。列数的固定(小于10个)使得整个表产生的文件数目大大减少,也无须在插入记录时考虑表的Schema。
选择这种建表方案,超链库的易用性降低了。使用超链库的应用想要获取某个页面(From)某个链接(To)的状态变化,需要首先将某个页面(From)的所有Timestamp列扫描出来,然后进行排序,比如图5会排序成Day0、Day1、Day3、Day4的时间序列,然后又可以效仿方案三中的策略,将时间序列和数据列做Join,获得指定From和To的链接的状态变化。
小结
总之,BigTable的模型和传统RDBMS有所不同,基于BigTable进行开发,除了考虑规模问题,还需要注意随机读性能会影响业务逻辑。如果需要基于BigTable的复杂操作,最好在Hypertable之上的内存中进行处理,再将计算结果导入系统。 |