问题背景
存储超长的列表这个问题,可以看做是很多实际工程问题的抽象,比如:
- 如何存储微博的评论。
- 如何存储贴吧中的帖子,以及帖子的评论。
- 如何存储抖音视频的评论。
这些问题有几个共同点:
- 每条数据本身尺寸比较小,基本都是一些文字、图片、链接等(图片也表示为链接)。
- 数据的量非常大,比如一条微博的评论可能有上百万或者千万,贴吧中火热的帖子回复数目也可能非常大。
- 这些数据都有某个共同的属性,比如一千万评论都是同一条微博下面的,一千万回复都是同一个帖子里面的。由于存在这样的共同属性,同时在读取的时候往往是按照这个属性来查询(比如获取一条微博下面的评论),我们可以将这些数据看做是一个列表。
基于这些特点,我把这个问题总结为:如何存储超长的列表。当然,我们不一定非得按照list的格式来存储这些数据,但是无论如何,从用户的角度来看,这都像是一个列表。
难点
如果只是把这些数据写入到数据库中,那并没有什么困难。难点主要在于:该如何存储才能高效的读取这些数据?以微博评论为例,如果一条微博下有1000w评论,在读取评论时,肯定不是一次把所有评论都获取到,而是进行类似翻页的查询(这种查询方式显示出评论是有顺序的)。那么,怎样存储这些评论数据才能使得查询有一个比较好的性能。
为了方便讨论,我们把这个问题进一步具体化:假设现在我需要搭建评论系统后台,已知微博的数目在百万级别,80%的微博评论数在几十条的水平,但是少部分微博的评论数会到几十万,几百万,甚至千万级别。现在我们来看看几种存储评论的方案。
方案一:mysql + 自增id主键 + 取余分布
我们将建立一张表来存储评论,表中每一条record就是一条评论。表的结构大致如下:
CREATE TABLE IF NOT EXISTS comment (
id BIGINT UNSIGNED PRIMARY KEY NOT NULL AUTO_INCREMENT,
content VARCHAR(512) NOT NULL,
weibo_id BIGINT UNSIGNED NOT NULL,
index wid (weibo_id)
);
使用自增id作为主键,同时添加weibo_id作为辅助索引。
由于总的评论数可能达到十亿级别,很明显需要进程分库分表(即使评论数更多,也是一样的思路,使用msyql存储这种量级的数据必须分库分表)。假设我们能接受单表最多千万条记录,那么可以建50个db,每个db一张表,使用某种分布方式(待讨论)让所有的评论均匀分布在这50张表中,差不多每条表的记录数都在几千万。
首先要考虑的问题是:如何分布数据到多个表中?如果我们使用随机或者roundrobin的方式来将评论分布到各个表中,那么当需要查询某条微博的评论时,由于不知道这个微博的评论在哪个表中,必须要对每个表发起查询请求,相当于一个查询扇出为50个查询mysql请求。这个代价太大,稍微来点并发请求数据库就扛不住了,是不可能接受的。
一个比较合适的选择是使用weibo_id来进行分片:对每一条评论,根据它所属的weibo id来决定将这条评论写入到哪个表中,比如说将weibo id 对 表的数目取余来得到一个表的序号,然后将评论数据写到这个表中(在不考虑扩容以及数据迁移时,这个分片的方法是OK的)。这样的话,每条微博的评论都在一张表中了,在查询的时候就可以只用查一次。
由于使用了自增id作为主键,插入的性能会非常好。但是查询时候没法使用主键,只能通过辅助索引weibo id来查。辅助索引在innodb中的实现是一个单独的b+树,通过weibo id可以在这个b+树中获取到需要查询的评论数据的主键id,但是这些主键id在聚集索引中的分布可能不是连续的,因为在插入数据时并不是相同weibo id的评论一起插入。这导致了从聚集索引中读取评论数据的动作是一个个离散的随机的读取,每一个读取都需要走一遍主键b+树的查询过程。这样查询的性能并不是说一定不能接受,但是如果可能,我们更应该把这个地方弄成连续的读取,查询一次b+树,得到一个位置,然后开始顺序的读取数据(当然不一定是连续的读取磁盘),这是一个可以改进的点。
除此之外还有两个问题:
- 我们已知存在评论数几千万的微博,对于这样的微博评论,可能会导致单个表的数目过大,引起性能问题。这不是我们想要的,因此需要改进这一点。
- 在微博评论这个场景下,获取的评论一般都是按照时间来排序的,这一点也应该考虑到。
方案二:mysql + 联合主键 + 协调分布服务
和方案一相同,我们仍然使用mysql分布分表的方式来存储数据,但是有几个改进:
- 使用weibo_id + timestamp 作为主键
- 引入一个协调服务,帮助记录数据分布以及数据迁移
数据表的定义更改如下:
CREATE TABLE IF NOT EXISTS comment (
timestamp BIGINT UNSIGNED NOT NULL,
content VARCHAR(512) NOT NULL,
weibo_id BIGINT UNSIGNED NOT NULL,
primary key (weibo_id, timestamp)
);
使用weibo_id + timestamp联合主键,这样做的好处在于:在插入数据时,相同wiebo id下的评论会放在一起(如果它们是连续插入的,那就更理想了,这会使得这些评论在物理位置上也挨的很近)。不过这种构建索引的方式也引入了一个小问题,那就是我们需要保证同一weibo_id下任何两条评论的timestamp不会重复,否则会导致插入数据失败。要解决这个问题有很多种方法,比如将时间精度取细一点,或者取当前秒数为timestamp,然后在后面增加一个随机数等等,这里不展开讲了。
第二个改进是,引入一个协调服务,它的功能是记录数据分布的情况,以及控制数据迁移过程。在方案一中,我们采用weibo_id 对数据表数取余的方式来决定一条评论应该落在哪个表中,这种方式能够work,但是伸缩性不够好,比如说现在数据库集群中新加入一个节点,那么之前的数据就没法访问了。在改进方案中,我们仍然优先将同一weibo id下的评论数存储在同一张表中,同时将评论数据分布信息记录在这个协调服务中,在读取的时候从协调服务中获取分布信息,再去相应的数据库中查询(当然不是每一次读取都需要和协调服务通信,应该是将分布信息缓存在客户程序这边,只有初次读取或者分布信息发生变更才需要和协调服务通信以获取最新信息。这个协调服务类似于etcd或者zookeeper这样的组件)
如何管理协调服务中的分布信息是一个比较复杂的问题,但是引入协调服务为我们的系统增加了相当的灵活性,它使得我们可以完成以下工作:
- 系统中的数据量增加,需要进行数据库扩容时,通过协调服务,可以保证旧的数据不丢失,并使得数据均匀分布在集群中。
- 当某个weibo id下的评论过多,比如超过2000w,我们可以把这个weibo id的评论拆分来存储到不同的表中,将分布信息记录在协调服务中。这就防止了单表数据量过大的情形。
方案二的主要缺陷在于引入协调服务带来的一些问题,包括:
- 协调服务成为系统的关键点,一旦协调服务故障,整个系统都没法正常运作
- 客户程序多了一层和协调服务交互的逻辑,增加了复杂性
- 协调服务本身的开发和维护难度
方案三:分布式数据库
为了使应用程序逻辑更简单,开发更轻松,我们也可以直接使用分布式数据库来存储评论数据。分布式数据库的优势在于它能够支持比较大的数据量,同时有自动伸缩能力,应用程序需要做的事情比较少,直接读写就足够了。由于评论数据的模型比较简单,我们直接使用kv类型的数据库,比如hbase,tikv这样的分布式数据库,这里以hbase为例。
hbase可以看做是一个巨大的有序map,它所存储的key-value是按照key的字母序来排列的,基于这个特点,我们可以以weibo_id+timestamp为key来存储评论,这样的话,相同weibo_id下面的评论就会存储在相邻的位置。同时,hbase按照key的range来将数据分布到各个存储节点上,这使得同一个weibo_id的评论极大可能都在同一个存储节点上(这里的存储节点其实是hbase的region server,底层的数据实际是在HDFS中,但是我们可以认为相邻的key极有可能最终还是在同一个HDFS的存储节点上)。
hbase以类似SST格式的文件来存储数据,每个region可能由多个SST文件组成,SST文件中的key虽然是有序排列,但是SST文件之间是没有顺序的。这意味着,在处理查询评论的请求时(类似于scan查询),需要同时打开多个SST文件,才能依次获取到顺序正确的评论。此外,上面也提到过,hbase底层使用HDFS来存储文件,那么多个SST文件可能散落在不同的HDFS存储节点上,这进一步降低了scan查询的效率。(相比之下,方案二中在b+树中查询后进行顺序读取可能效率更高)
总结
方案一写入的性能非常好,但是数据的存储方式导致读取效率不高。方案二则因为需要额外开发一个协调服务,使得实现难度比较大。方案三则是最容易实现的,性能可能比不上方案二,但是也未必不能满足需求,这需要进行实际的测试来确定。当然,最理想的方式是将每个列表都存储在内存中,这样写入和读取都会非常高效。但是在数据量很大的时候,这是做不到的。
上面对这个问题的定义可能比较理想,在实际的工程中,可能还同时需要满足其他一些需求。比如,评论不一定是按照时间来排序,可以是按照点赞数来排序,这就对存储有了新的要求。总之,对不同的需求,我们都需要仔细的考虑存储的选型,既要有理论分析,也要有实测的数据来支撑理论,才可能发现最合适业务的存储系统。存储系统一旦确定,要更改就会非常麻烦,因为它往往需要在服务不停止的情况下进行数据的复制和迁移,这个过程相当的复杂和耗时耗力。