专注于互联网--专注于架构

最新标签
网站地图
文章索引
Rss订阅

首页 »PHP教程 » 又拍网架构中的分库设计 »正文

又拍网架构中的分库设计

来源: 发布时间:星期五, 2010年7月16日 浏览:21次 评论:0
  又拍网是个照片分享社区从2005年6月至今积累了260万用户1.1亿张照片目前日访问量为200多万5年发展历程里经历过许多起伏也积累了些经验在这篇文章里我要介绍些我们在技术上积累

  又拍网和大多数Web2.0站点构建于大量开源软件Software的上包括MySQL、PHP、nginx、Python、memcached、redis、Solr、Hadoop和RabbitMQ等等又拍网服务器端开发语言主要是PHP和Python其中PHP用于编写Web逻辑(通过HTTP和用户直接打交道) 而Python则主要用于开发内部服务和后台任务在客户端则使用了大量Javascript 这里要感谢下MooTools这个JS框架它使得我们很享受前端开发过程 另外我们把图片处理过程从PHP进程里独立出来变成个服务这个服务基于nginx但是是作为nginx个模块而开放REST API



  查看原图(大图)

  图1:开发语言

  由于PHP单线程模型我们把耗时较久运算和I/O操作从HTTP请求周期中分离出来 交给由Python实现任务进程来完成以保证请求响应速度这些任务主要包括:邮件发送、数据索引、数据聚合和好友动态推送(稍候会有介绍)等等通常这些任务由用户触发并且用户个行为可能会触发多种任务执行 比如用户上传了张新照片我们需要更新索引也需要向他朋友推送条新动态PHP通过消息队列(我们用是RabbitMQ)来触发任务执行



  查看原图(大图)

  图2:PHP和Python协作

  数据库向是网站WebSite架构中最具挑战性瓶颈通常出现在这里又拍网照片数据量很大数据库也几度出现严重压力问题 因此这里我主要介绍下又拍网在分库设计这方面些尝试

  分库设计

  和很多使用MySQL2.0站点又拍网MySQL集群经历了从最初个主库个从库、到个主库多个从库、 然后到多个主库多个从库个发展过程



  查看原图(大图)

  图3:数据库进化过程

  最初是由台主库和台从库组成当时从库只用作备份和容灾当主库出现故障时从库就手动变成主库般情况下从库不作读写操作(同步除外)随着压力增加我们加上了memcached当时只用其缓存Cache单行数据 但是单行数据缓存Cache并不能很好地解决压力问题单行数据查询通常很快所以我们把些实时性要求不高Query放到从库去执行后面又通过添加多个从库来分流查询压力不过随着数据量增加主库写压力也越来越大

  在参考了些相关产品和其它网站WebSite做法后我们决定进行数据库拆分也就是将数据存放到区别数据库服务器中般可以按两个纬度来拆分数据:

  垂直拆分:是指按功能模块拆分比如可以将群组相关表和照片相关表存放在区别数据库中这种方式多个数据库的间表结构区别

  水平拆分:而水平拆分是将同个表数据进行分块保存到区别数据库中这些数据库中表结构完全相同

  拆分方式

  般都会先进行垂直拆分这种方式拆分方式实现起来比较简单根据表名访问区别数据库就可以了但是垂直拆分方式并不能彻底解决所有压力问题另外也要看应用类型是否合适这种拆分方式如果合适也能很好起到分散数据库压力作用比如对于豆瓣我觉得比较适合采用垂直拆分 豆瓣各核心业务/模块(书籍、电影、音乐)相对独立数据增加速度也比较平稳区别又拍网核心业务对象是用户上传照片而照片数据增加速度随着用户量增加越来越快压力基本上都在照片表上显然垂直拆分并不能从根本上解决我们问题所以我们采用水平拆分方式

  拆分规则

  水平拆分实现起来相对复杂我们要先确定个拆分规则也就是按什么条件将数据进行切分般2.0网站WebSite都以用户为中心数据基本都跟随用户比如用户照片、朋友和评论等等因此个比较自然选择是根据用户来切分每个用户都对应个数据库访问某个用户数据时 我们要先确定他/她所对应数据库然后连接到该数据库进行实际数据读写

  那么如何样对应用户和数据库呢?我们有这些选择:

  按算法对应

  最简单算法是按用户ID奇偶性来对应将奇数ID用户对应到数据库A而偶数ID用户则对应到数据库B这个思路方法最大问题是只能分成两个库个算法是按用户ID所在区间对应比如ID在0-10000的间用户对应到数据库A ID在10000-20000这个范围对应到数据库B以此类推按算法分实现起来比较方便也比较高效但是不能满足后续伸缩性要求如果需要增加数据库节点必需调整算法或移动很大数据集 比较难做到在不停止服务前提下进行扩充数据库节点

  按索引/映射表对应

  这种思路方法是指建立个索引表保存每个用户ID和数据库ID对应关系每次读写用户数据时先从这个表获取对应数据库新用户注册后在所有可用数据库中随机挑选个为其建立索引这种思路方法比较灵活有很好伸缩性个缺点是增加了次数据库访问所以性能上没有按算法对应好

  比较的后我们采用是索引表方式我们愿意为其灵活性损失些性能更何况我们还有memcached 索引数据基本不会改变缘故缓存Cache命中率非常高所以能很大程度上减少了性能损失



  查看原图(大图)

  图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-AID为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表主键呢(标识行数据)?可以不设啊不幸是有时候(框架、缓存Cache等原因)必需设置那么可以以 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-BNode-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框架里了开发人员基本上不需要被这些繁琐事情困扰下面是使用我们框架进行照片数据读写些例子:

<?php 
  $Photos =  ShardedDBTable('Photos', 'yp_photos', 'user_id', .gif' />( 
        'photo_id'  => .gif' />('type' => 'long', 'primary' => true, 'global_auto_increment' => true), 
        'user_id'   => .gif' />('type' => 'long'), 
        'title'    => .gif' />('type' => ''), 
        'posted_date' => .gif' />('type' => 'date'), 
      )); 
 
  $photo = $Photos->_object(.gif' />('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(.gif' />('user_id' => 1, 'posted_date__gt' => '2010-06-01')); 
?> 


  首先要定义个ShardedDBTable对象所有API都是通过这个对象开放个参数是对象类型名称如果这个名称已经存在那么将返回的前定义对象你也可以通过get_table('Photos')这个来获取的前定义Table对象第 2个参数是对应数据库表名而第 3个参数是数据库线索字段你会发现在后面所有API中全部需要指定这个字段第 4个参数是字段定义其中photo_id字段global_auto_increment属性被置为true这就是前面所说全局自增ID只要指定了这个属性框架会处理好ID事情

  如果我们要访问全局库中数据我们需要定义个DBTable对象

<?php 
  $Users =  DBTable('Users', 'yp_users', .gif' />( 
        'user_id' => .gif' />('type' => 'long', 'primary' => true, 'auto_increment' => true), 
        'username' => .gif' />('type' => ''), 
      )); 
?> 


  DBTable是ShardedDBTable父类除了定义时参数有些区别(DBTable不需要指定数据库线索字段)它们提供API

  缓存Cache

  我们框架提供了缓存Cache功能对开发人员是透明

<?php 
  $photo = $Photos->load(1, 10001); 
?> 


  比如上面思路方法框架先尝试以Photos-1-10001为Key在缓存Cache中查找未找到话再执行数据库查询并放入缓存Cache当更改照片属性或删除照片时框架负责从缓存Cache中删除该照片这种单个对象缓存Cache实现起来比较简单稍微麻烦是像下面这样列表查询结果缓存Cache

<?php 
  $photos = $Photos->fetch(.gif' />('user_id' => 1, 'posted_date__gt' => '2010-06-01')); 
?> 


  我们把这个查询分成两步步先查出符合条件照片ID然后再根据照片ID分别查找具体照片信息这么做可以更好利用缓存Cache个查询缓存CacheKey为Photos-list-{shard_key}-{md5(查询条件SQL语句)} Value是照片ID列表(逗号间隔)其中shard_key为user_id值1目前来看列表缓存Cache也不麻烦但是如果用户修改了某张照片上传时间呢这个时候缓存Cache中数据就不定符合条件了所以我们需要个机制来保证我们不会从缓存Cache中得到过期列表数据我们为每张表设置了个revision当该表数据发生变化时(insert/update/delete思路方法)我们就更新它revision所以我们把列表缓存CacheKey改为Photos-list-{shard_key}-{md5(查询条件SQL语句)}-{revision} 这样我们就不会再得到过期列表了

  revision信息也是存放在缓存Cache里Key为Photos-revision这样做看起来不错但是好像列表缓存Cache利用率不会太高我们是以整个数据类型revision为缓存CacheKey后缀显然这个revision更新非常频繁任何个用户修改或上传了照片都会导致它更新哪怕那个用户根本不在我们要查询Shard里要隔离用户动作对其他用户影响我们可以通过缩小revision作用范围来达到这个目所以revision缓存CacheKey变成Photos-{shard_key}-revision这样话当ID为1用户修改了他照片信息时只会更新Photos-1-revision这个Key所对应revision

  全局库没有shard_key所以修改了全局库中行数据还是会导致整个表缓存Cache失效但是大部分情况下数据都是有区域范围比如我们帮助论坛主题帖子帖子属于主题修改了其中个主题个帖子没必要使所有主题帖子缓存Cache都失效所以我们在DBTable上增加了个叫isolate_key属性

标签:
0

相关文章

读者评论

发表评论

  • 昵称:
  • 内容: