原文链接: http://bret.appspot.com/entry/how-friendfeed-uses-mysql
本文链接: http://virest.org/how-friendfeed-uses-mysql-cn.html
著者: Bret Taylor
译者: khan.chan
背景
在FriendFeed我们使用MySQL存储所有的数据. 用户的不断增长也让我们的数据库增长不少, 我们现在存储超过2.5亿个条目以及 其他一部分从评论和”likes”的朋友名单.
由于我们的数据库的增长, 我们试图处理可扩展的问题. 我们做些实质性的事,比如使用MySQL Slavers和memcached去增加吞吐量和分区数据库以提高写入的吞吐量. 然而,当我们不断增长,扩展我们现有的设置去适应更多的流量还不如去增加个新功能.
特别是,使架构更改或者增加索引到一个数据库将马上导致一个小时内超过 10-20万行完全锁在数据库. 删除旧索引只需要尽可能多的时间,不删除将影响性能, 因为数据库将在每一次INSERT继续读取和写入到这些未使用块. 有复杂的运作步骤让你绕过这些问题(像设置新索引在一个Slave,然后交换slave和master),但是这些步骤都极易出差错,新增特征时必须做架构/索引更改(they implicitly discouraged our adding features that would require schema/index changes). 由于我们的数据库分区,MySQL一些类似JOIN我们从未应用过, 所以我们决定寻找外部的RDBMS.
存在大量的项目,旨在解决这个额为难题的数据存储模式让其身轻如燕(比如CouchDB). 不过,他们似乎没有被广泛适用于大型网站, 在测试中,也无一项目可满足我们的需求.
经过一番考虑,我们坚决实施实现一个”schema-less”存储系统而不是用个新的存储系统.我们也好奇别的大型网站如何处理此类问题,我们想到了我们 所做的设计工作可能是有益的其他开发人员.
概况
我们的数据存储非结构化的属性(例如JSON对象或Python字典). 唯一要求存储的实体有id属性,一个16字节的UUID。实体的其他部分不透明,这样我们可以简单的通过增加新属性改变”schema”.
我们将索引保存在分开的MySQL表里来索引这些实体. 如需要索引实体的3个属性,那就需要3张表, 如果想停用一个索引,只需要代码里停止写入这个表,甚至于删除这个表. 如果希望增加一个新索引,可为这个索引建新表,运行一个进程异步迁移索引而不破坏我们的在线服务.
结果是,我们比以前有了 更多的表,但是增加和删除索引非常容易。我们大量地优化生成新索引的过程,因此它可以不中断站点快速创建索引。我们可以在白天而不是周末才存储新的属性和 索引,而且也不需要切换MySQL master和slave服务器.
详情
在MySQL中我们的实体存储在像这样的表中:
CREATE TABLE entities (
added_id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
id BINARY(16) NOT NULL,
updated TIMESTAMP NOT NULL,
body MEDIUMBLOB,
UNIQUE KEY (id),
KEY (updated)
) ENGINE=InnoDB;
该added_id
列 是存在,因为InnoDB的存储数据行身在主键顺序. AUTO_INCREMENT 主键确保新实体在老实体后被写入硬盘,实体机构的zlib压缩存储是python字典的 pickled序列化zlib压缩形式.
索引时存储在单独的表,要穿件一个新的索引, 我们将创建一个新的表来存储属性,例如,一个典型的实体在FriendFeed可能是这样子的:
{ "id": "71f0c4d2291844cca2df6f486e96e37c",
我们要索引user_id属性,可创建一个页面,显示所有该用户贴的内容, 索引表看起来像这样的:
"user_id": "f48b0440ca0c4f66991c4d5f6a078eaf",
"feed_id": "f48b0440ca0c4f66991c4d5f6a078eaf",
"title": "We just launched a new backend system for FriendFeed!",
"link": "http://friendfeed.com/e/71f0c4d2-2918-44cc-a2df-6f486e96e37c","published": 1235697046, "updated": 1235697046, }
CREATE TABLE index_user_id (
user_id BINARY(16) NOT NULL,
entity_id BINARY(16) NOT NULL UNIQUE,
PRIMARY KEY (user_id, entity_id)
) ENGINE=InnoDB;
我们的数据存储自动维护索引, 启动一个类似结构的数据存储实例(python):
user_id_index = friendfeed.datastore.Index(
table="index_user_id", properties=["user_id"],
shard_on="user_id") datastore =
friendfeed.datastore.DataStore( mysql_shards=["127.0.0.1:3306",
"127.0.0.1:3307"], indexes=[user_id_index]) new_entity =
{ "id": binascii.a2b_hex("71f0c4d2291844cca2df6f486e96e37c"),
"user_id": binascii.a2b_hex("f48b0440ca0c4f66991c4d5f6a078eaf"),
"feed_id": binascii.a2b_hex("f48b0440ca0c4f66991c4d5f6a078eaf"),
"title": u"We just launched a new backend system for FriendFeed!",
"link": u"http://friendfeed.com/e/71f0c4d2-2918-44cc-a2df-6f486e96e37c",
"published": 1235697046, "updated": 1235697046,
} datastore.put(new_entity) entity =
datastore.get(binascii.a2b_hex("71f0c4d2291844cca2df6f486e96e37c"))
entity = user_id_index.get_all
(datastore, user_id=binascii.a2b_hex("f48b0440ca0c4f66991c4d5f6a078eaf"))
Index类 寻找所有实体中的user_id属性, 并自动保持该指数在index_user_id表.
由于我们的数据库是分区的,该shard_on参数用于确定分区的索引存储在哪个分区.
你可以检索索引使用索引实例(user_id_index.get_all), 数据存储代码在index_user_id表和entities表间作”join”操作, 先在所有数据分区索引index_user_id表,获取实体ID的列表,再从entities表中获取这些实体ID.
添加一个新索引,例如,在link属性创建一个新表:
CREATE TABLE index_link
( link VARCHAR(735) NOT NULL,
entity_id BINARY(16) NOT NULL UNIQUE,
PRIMARY KEY (link, entity_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
我们讲改变我们的数据存储的初始化代码,来包含这个索引:
user_id_index = friendfeed.datastore.Index(
table="index_user_id", properties=["user_id"],
shard_on="user_id")
link_index = friendfeed.datastore.Index( table="index_link",
properties=["link"], shard_on="link") datastore =
friendfeed.datastore.DataStore( mysql_shards=["127.0.0.1:3306",
"127.0.0.1:3307"], indexes=[user_id_index, link_index])
也可异步的操作生成(哪怕在线服务下):
./rundatastorecleaner.py --index=index_link
一致性和原子性因为我们的数据库是分区 的,一个实体的索引可以存储在不同的分区,一致性是个问题。
写入所有索引表前进程崩溃了会发生什么?
建立一个交易协议是最吸引到雄心勃勃的FriendFeed的工程师,但我们希望尽可能保持系统
简单,所以决定放宽限制:
. entities表中的属性包满足范式
. 索引可能不反映实际实体的值
因此,我们写入一个新的实体需要如下步骤:
1. 写入实体到entities表,使用InnoDB的ACID属性
2. 向所有分区上的索引表写入索引
当我们从索引表读的时候,我们知道 他们可能不精确。为了确保我们不返回非法的实体,我们使用索引表来确定要读取哪个实体,但是重新应用查询过滤条件:
1. 基于查询从所有索引表中读取entity_id
2. 从entities表中读取指定ID的实体
3. 在代码中根据实际的属性值过滤不符合查询条件的实体
为了确保索引能够被最终修复,"Cleaner"进程持续地运行,清除旧的和非法的索引.
它先 清除最近更新的实体,这样索引中的不一致可以非常快的被修复性能我们已经在这个新系统上优化了相当多的主要指标, 结果也非常让人高兴.
过去一个月FriendFeed PV数值:
特别是,我们的系统延迟非常稳定,哪怕是在繁忙时段.
比较一个星期前的: