数据库一贯是网站架构中最具挑战性的,瓶颈一般出如今这里。又拍网的照片数据量很大,数据库也几度出现严重的压力问题。 所以,这里我主要介绍一下又拍网在分库设计这方面的一些尝试。 php
又拍网是一个照片分享社区,从2005年6月至今积累了260万用户,1.1亿张照片,目前的日访问量为200多万。5年的发展历程里经历过许多起伏,也积累了一些经验,在这篇文章里,我要介绍一些咱们在技术上的积累。 html
又拍网和大多数Web2.0站点同样,构建于大量开源软件之上,包括MySQL、PHP、nginx、Python、memcached、redis、Solr、Hadoop和RabbitMQ等等。又拍网的服务器端开发语言主要是PHP和Python,其中PHP用于编写Web逻辑(经过HTTP和用户直接打交道), 而Python则主要用于开发内部服务和后台任务。在客户端则使用了大量的Javascript, 这里要感谢一下MooTools这个JS框架,它使得咱们很享受前端开发过程。 另外,咱们把图片处理过程从PHP进程里独立出来变成一个服务。这个服务基于nginx,可是是做为nginx的一个模块而开放REST API。 前端
图1:开发语言 python
因为PHP的单线程模型,咱们把耗时较久的运算和I/O操做从HTTP请求周期中分离出来, 交给由Python实现的任务进程来完成,以保证请求响应速度。这些任务主要包括:邮件发送、数据索引、数据聚合和好友动态推送(稍候会有介绍)等等。一般这些任务由用户触发,而且,用户的一个行为可能会触发多种任务的执行。 好比,用户上传了一张新的照片,咱们须要更新索引,也须要向他的朋友推送一条新的动态。PHP经过消息队列(咱们用的是RabbitMQ)来触发任务执行。 mysql
图2:PHP和Python的协做 nginx
数据库一贯是网站架构中最具挑战性的,瓶颈一般出如今这里。又拍网的照片数据量很大,数据库也几度出现严重的压力问题。 所以,这里我主要介绍一下又拍网在分库设计这方面的一些尝试。 redis
和不少使用MySQL的2.0站点同样,又拍网的MySQL集群经历了从最初的一个主库一个从库、到一个主库多个从库、 而后到多个主库多个从库的一个发展过程。 算法
最初是由一台主库和一台从库组成,当时从库只用做备份和容灾,当主库出现故障时,从库就手动变成主库,通常状况下,从库不做读写操做(同步除外)。随着压力的增长,咱们加上了memcached,当时只用其缓存单行数据。 可是,单行数据的缓存并不能很好地解决压力问题,由于单行数据的查询一般很快。因此咱们把一些实时性要求不高的Query放到从库去执行。后面又经过添加多个从库来分流查询压力,不过随着数据量的增长,主库的写压力也愈来愈大。 sql
在参考了一些相关产品和其它网站的作法后,咱们决定进行数据库拆分。也就是将数据存放到不一样的数据库服务器中,通常能够按两个纬度来拆分数据: 数据库
垂直拆分:是指按功能模块拆分,好比能够将群组相关表和照片相关表存放在不一样的数据库中,这种方式多个数据库之间的表结构不一样。
水平拆分:而水平拆分是将同一个表的数据进行分块保存到不一样的数据库中,这些数据库中的表结构彻底相同。
通常都会先进行垂直拆分,由于这种方式拆分方式实现起来比较简单,根据表名访问不一样的数据库就能够了。可是垂直拆分方式并不能完全解决全部压力问题,另外,也要看应用类型是否合适这种拆分方式。若是合适的话,也能很好的起到分散数据库压力的做用。好比对于豆瓣我以为比较适合采用垂直拆分, 由于豆瓣的各核心业务/模块(书籍、电影、音乐)相对独立,数据的增长速度也比较平稳。不一样的是,又拍网的核心业务对象是用户上传的照片,而照片数据的增长速度随着用户量的增长愈来愈快。压力基本上都在照片表上,显然垂直拆分并不能从根本上解决咱们的问题,因此,咱们采用水平拆分的方式。
水平拆分实现起来相对复杂,咱们要先肯定一个拆分规则,也就是按什么条件将数据进行切分。 通常2.0网站都以用户为中心,数据基本都跟随用户,好比用户的照片、朋友和评论等等。所以一个比较天然的选择是根据用户来切分。每一个用户都对应一个数据库,访问某个用户的数据时, 咱们要先肯定他/她所对应的数据库,而后链接到该数据库进行实际的数据读写。
那么,怎么样对应用户和数据库呢?咱们有这些选择:
按算法对应
最简单的算法是按用户ID的奇偶性来对应,将奇数ID的用户对应到数据库A,而偶数ID的用户则对应到数据库B。这个方法的最大问题是,只能分红两个库。另外一个算法是按用户ID所在区间对应,好比ID在0-10000之间的用户对应到数据库A, ID在10000-20000这个范围的对应到数据库B,以此类推。按算法分实现起来比较方便,也比较高效,可是不能知足后续的伸缩性要求,若是须要增长数据库节点,必需调整算法或移动很大的数据集, 比较难作到在不中止服务的前提下进行扩充数据库节点。
按索引/映射表对应
这种方法是指创建一个索引表,保存每一个用户的ID和数据库ID的对应关系,每次读写用户数据时先从这个表获取对应数据库。新用户注册后,在全部可用的数据库中随机挑选一个为其创建索引。这种方法比较灵活,有很好的伸缩性。一个缺点是增长了一次数据库访问,因此性能上没有按算法对应好。
比较以后,咱们采用的是索引表的方式,咱们愿意为其灵活性损失一些性能,更况且咱们还有memcached, 由于索引数据基本不会改变的缘故,缓存命中率很是高。因此能很大程度上减小了性能损失。
图4:数据访问过程
索引表的方式可以比较方便地添加数据库节点,在增长节点时,只要将其添加到可用数据库列表里便可。 固然若是须要平衡各个节点的压力的话,仍是须要进行数据的迁移,可是这个时候的迁移是少许的,能够逐步进行。要迁移用户A的数据,首先要将其状态置为迁移数据中,这个状态的用户不能进行写操做,并在页面上进行提示。 而后将用户A的数据所有复制到新增长的节点上后,更新映射表,而后将用户A的状态置为正常,最后将原来对应的数据库上的数据删除。这个过程一般会在临晨进行,因此,因此不多会有用户碰到迁移数据中的状况。
固然,有些数据是不属于某个用户的,好比系统消息、配置等等,咱们把这些数据保存在一个全局库中。
分库会给你在应用的开发和部署上都带来不少麻烦。
不能执行跨库的关联查询
若是咱们须要查询的数据分布于不一样的数据库,咱们没办法经过JOIN的方式查询得到。好比要得到好友的最新照片,你不能保证全部好友的数据都在同一个数据库里。一个解决办法是经过屡次查询,再进行聚合的方式。咱们须要尽可能避免相似的需求。有些需求能够经过保存多份数据来解决,好比User-A和User-B的数据库分别是DB-1和DB-2, 当User-A评论了User-B的照片时,咱们会同时在DB-1和DB-2中保存这条评论信息,咱们首先在DB-2中的photo_comments表中插入一条新的记录,而后在DB-1中的user_comments表中插入一条新的记录。这两个表的结构以下图所示。这样咱们能够经过查询photo_comments表获得User-B的某张照片的全部评论, 也能够经过查询user_comments表得到User-A的全部评论。另外能够考虑使用全文检索工具来解决某些需求, 咱们使用Solr来提供全站标签检索和照片搜索服务。
图5:评论表结构
不能保证数据的一致/完整性
跨库的数据没有外键约束,也没有事务保证。好比上面的评论照片的例子, 极可能出现成功插入photo_comments表,可是插入user_comments表时却出错了。一个办法是在两个库上都开启事务,而后先插入photo_comments,再插入user_comments, 而后提交两个事务。这个办法也不能彻底保证这个操做的原子性。
全部查询必须提供数据库线索
好比要查看一张照片,仅凭一个照片ID是不够的,还必须提供上传这张照片的用户的ID(也就是数据库线索),才能找到它实际的存放位置。所以,咱们必须从新设计不少URL地址,而有些老的地址咱们又必须保证其仍然有效。咱们把照片地址改为/photos/{username}/{photo_id}/的形式,而后对于系统升级前上传的照片ID, 咱们又增长一张映射表,保存photo_id和user_id的对应关系。当访问老的照片地址时,咱们经过查询这张表得到用户信息, 而后再重定向到新的地址。
自增ID
若是要在节点数据库上使用自增字段,那么咱们就不能保证全局惟一。这倒不是很严重的问题,可是当节点之间的数据发生关系时,就会使得问题变得比较麻烦。咱们能够再来看看上面提到的评论的例子。若是photo_comments表中的comment_id的自增字段,当咱们在DB-2.photo_comments表插入新的评论时, 获得一个新的comment_id,假如值为101,而User-A的ID为1,那么咱们还须要在DB-1.user_comments表中插入(1, 101 ...)。 User-A是个很活跃的用户,他又评论了User-C的照片,而User-C的数据库是DB-3。 很巧的是这条新评论的ID也是101,这种状况很用可能发生。那么咱们又在DB-1.user_comments表中插入一行像这样(1, 101 ...)的数据。 那么咱们要怎么设置user_comments表的主键呢(标识一行数据)?能够不设啊,不幸的是有的时候(框架、缓存等缘由)必需设置。那么能够以user_id、 comment_id和photo_id为组合主键,可是photo_id也有可能同样(的确很巧)。看来只能再加上photo_owner_id了, 可是这个结果又让咱们实在有点没法接受,太复杂的组合键在写入时会带来必定的性能影响,这样的天然键看起来也很不天然。因此,咱们放弃了在节点上使用自增字段,想办法让这些ID变成全局惟一。为此增长了一个专门用来生成ID的数据库,这个库中的表结构都很简单,只有一个自增字段id。 当咱们要插入新的评论时,咱们先在ID库的photo_comments表里插入一条空的记录,以得到一个惟一的评论ID。 固然这些逻辑都已经封装在咱们的框架里了,对于开发人员是透明的。 为何不用其它方案呢,好比一些支持incr操做的Key-Value数据库。咱们仍是比较放心把数据放在MySQL里。 另外,咱们会按期清理ID库的数据,以保证获取新ID的效率。
咱们称前面提到的一个数据库节点为Shard,一个Shard由两个台物理服务器组成, 咱们称它们为Node-A和Node-B,Node-A和Node-B之间是配置成Master-Master相互复制的。 虽然是Master-Master的部署方式,可是同一时间咱们仍是只使用其中一个,缘由是复制的延迟问题, 固然在Web应用里,咱们能够在用户会话里放置一个A或B来保证同一用户一次会话里只访问一个数据库, 这样能够避免一些延迟问题。可是咱们的Python任务是没有任何状态的,不能保证和PHP应用读写相同的数据库。那么为何不配置成Master-Slave呢?咱们以为只用一台太浪费了,因此咱们在每台服务器上都建立多个逻辑数据库。 以下图所示,在Node-A和Node-B上咱们都创建了shard_001和shard_002两个逻辑数据库, Node-A上的shard_001和Node-B上的shard_001组成一个Shard,而同一时间只有一个逻辑数据库处于Active状态。 这个时候若是须要访问Shard-001的数据时,咱们链接的是Node-A上的shard_001, 而访问Shard-002的数据则是链接Node-B上的shard_002。以这种交叉的方式将压力分散到每台物理服务器上。 以Master-Master方式部署的另外一个好处是,咱们能够不中止服务的状况下进行表结构升级, 升级前先中止复制,升级Inactive的库,而后升级应用,再将已经升级好的数据库切换成Active状态, 原来的Active数据库切换成Inactive状态,而后升级它的表结构,最后恢复复制。 固然这个步骤不必定适合全部升级过程,若是表结构的更改会致使数据复制失败,那么仍是须要中止服务再升级的。
图6:数据库布局
前面提到过添加服务器时,为了保证负载的平衡,咱们须要迁移一部分数据到新的服务器上。为了不短时间内迁移的必要,咱们在实际部署的时候,每台机器上部署了8个逻辑数据库, 添加服务器后,咱们只要将这些逻辑数据库迁移到新服务器就能够了。最好是每次添加一倍的服务器, 而后将每台的1/2逻辑数据迁移到一台新服务器上,这样能很好的平衡负载。固然,最后到了每台上只有一个逻辑库时,迁移就没法避免了,不过那应该是比较久远的事情了。
咱们把分库逻辑都封装在咱们的PHP框架里了,开发人员基本上不须要被这些繁琐的事情困扰。下面是使用咱们的框架进行照片数据的读写的一些例子:
$Photos = new ShardedDBTable('Photos', 'yp_photos', 'user_id', array( 'photo_id' => array('type' => 'long', 'primary' => true, 'global_auto_increment' => true), 'user_id' => array('type' => 'long'), 'title' => array('type' => 'string'), 'posted_date' => array('type' => 'date'), )); $photo = $Photos->new_object(array('user_id' => 1, 'title' => 'Workforme')); $photo->insert(); // 加载ID为10001的照片,注意第一个参数为用户ID $photo = $Photos->load(1, 10001); // 更改照片属性 $photo->title = 'Database Sharding'; $photo->update(); // 删除照片 $photo->delete(); // 获取ID为1的用户在2010-06-01以后上传的照片 $photos = $Photos->fetch(array('user_id' => 1, 'posted_date__gt' => '2010-06-01')); ?> |
首先要定义一个ShardedDBTable对象,全部的API都是经过这个对象开放。第一个参数是对象类型名称, 若是这个名称已经存在,那么将返回以前定义的对象。你也能够经过get_table('Photos')这个函数来获取以前定义的Table对象。 第二个参数是对应的数据库表名,而第三个参数是数据库线索字段,你会发如今后面的全部API中所有须要指定这个字段的值。 第四个参数是字段定义,其中photo_id字段的global_auto_increment属性被置为true,这就是前面所说的全局自增ID, 只要指定了这个属性,框架会处理好ID的事情。
若是咱们要访问全局库中的数据,咱们须要定义一个DBTable对象。
<?php $Users = new DBTable('Users', 'yp_users', array( 'user_id' => array('type' => 'long', 'primary' => true, 'auto_increment' => true), 'username' => array('type' => 'string'), )); ?> |
DBTable是ShardedDBTable的父类,除了定义时参数有些不一样(DBTable不须要指定数据库线索字段),它们提供同样的API。
咱们的框架提供了缓存功能,对开发人员是透明的。
<?php $photo = $Photos->load(1, 10001); ?> |
好比上面的方法调用,框架先尝试以Photos-1-10001为Key在缓存中查找,未找到的话再执行数据库查询并放入缓存。当更改照片属性或删除照片时,框架负责从缓存中删除该照片。这种单个对象的缓存实现起来比较简单。稍微麻烦的是像下面这样的列表查询结果的缓存。
<?php $photos = $Photos->fetch(array('user_id' => 1, 'posted_date__gt' => '2010-06-01')); ?> |
咱们把这个查询分红两步,第一步先查出符合条件的照片ID,而后再根据照片ID分别查找具体的照片信息。 这么作能够更好的利用缓存。第一个查询的缓存Key为Photos-list-{shard_key}-{md5(查询条件SQL语句)}, Value是照片ID列表(逗号间隔)。其中shard_key为user_id的值1。目前来看,列表缓存也不麻烦。 可是若是用户修改了某张照片的上传时间呢,这个时候缓存中的数据就不必定符合条件了。因此,咱们须要一个机制来保证咱们不会从缓存中获得过时的列表数据。咱们为每张表设置了一个revision,当该表的数据发生变化时(调用insert/update/delete方法), 咱们就更新它的revision,因此咱们把列表的缓存Key改成Photos-list-{shard_key}-{md5(查询条件SQL语句)}-{revision}, 这样咱们就不会再获得过时列表了。
revision信息也是存放在缓存里的,Key为Photos-revision。这样作看起来不错,可是好像列表缓存的利用率不会过高。由于咱们是以整个数据类型的revision为缓存Key的后缀,显然这个revision更新的很是频繁,任何一个用户修改或上传了照片都会致使它的更新,哪怕那个用户根本不在咱们要查询的Shard里。要隔离用户的动做对其余用户的影响,咱们能够经过缩小revision的做用范围来达到这个目的。 因此revision的缓存Key变成Photos-{shard_key}-revision,这样的话当ID为1的用户修改了他的照片信息时, 只会更新Photos-1-revision这个Key所对应的revision。
由于全局库没有shard_key,因此修改了全局库中的表的一行数据,仍是会致使整个表的缓存失效。 可是大部分状况下,数据都是有区域范围的,好比咱们的帮助论坛的主题帖子, 帖子属于主题。修改了其中一个主题的一个帖子,不必使全部主题的帖子缓存都失效。 因此咱们在DBTable上增长了一个叫isolate_key的属性。
<?php $GLOBALS['Posts'] = new DBTable('Posts', 'yp_posts', array( 'topic_id' => array('type' => 'long', 'primary' => true), 'post_id' => array('type' => 'long', 'primary' => true, 'auto_increment' => true), 'author_id' => array('type' => 'long'), 'content' => array('type' => 'string'), 'posted_at' => array('type' => 'datetime'), 'modified_at' => array('type' => 'datetime'), 'modified_by' => array('type' => 'long'), ), 'topic_id'); ?> |
注意构造函数的最后一个参数topic_id就是指以字段topic_id做为isolate_key,它的做用和shard_key同样用于隔离revision的做用范围。
ShardedDBTable继承自DBTable,因此也能够指定isolate_key。 ShardedDBTable指定了isolate_key的话,可以更大幅度缩小revision的做用范围。 好比相册和照片的关联表yp_album_photos,当用户往他的其中一个相册里添加了新的照片时, 会致使其它相册的照片列表缓存也失效。若是我指定这张表的isolate_key为album_id的话, 咱们就把这种影响限制在了本相册内。
咱们的缓存分为两级,第一级只是一个PHP数组,有效范围是Request。而第二级是memcached。这么作的缘由是,不少数据在一个Request周期内须要加载屡次,这样能够减小memcached的网络请求。另外咱们的框架也会尽量的发送memcached的gets命令来获取数据, 从而减小网络请求。
这个架构使得咱们在很长一段时间内都没必要再为数据库压力所困扰。咱们的设计不少地方参考了netlog和flickr的实现,所以很是感谢他们将一些实现细节发布出来。
关于做者:
周兆兆(Zola,不是你熟知的那个),又拍网架构师。6年IT从业经验,不太专一于某项技术,对不少技术都感兴趣。