一种可以避免数据迁移的分库分表scale-out扩容方式
目前绝大多数应用采取的两种分库分表规则
这两种方式有个本质的特点,就是离散性加周期性。
例如以一个表的主键对3取余数的方式分库或分表:
那么随着数据量的增大,每个表或库的数据量都是各自增长。当一个表或库的数据量增长到了一个极限,要加库或加表的时候,
介于这种分库分表算法的离散性,必需要做数据迁移才能完成。例如从3个扩展到5个的时候:
需要将原先以mod3分类的数据,重新以mod5分类,不可避免的带来数据迁移。每个表的数据都要被重新分配到多个新的表
相似的例子比如从dayofweek分的7个库/表,要扩张为以dayofmonth分的31张库/表,同样需要进行数据迁移。
数据迁移带来的问题是
如何在数据量扩张到现有库表极限,加库加表时避免数据迁移呢?
通常的数据增长往往是随着时间的推移增长的。随着业务的开展,时间的推移,数据量不断增加。(不随着时间增长的情况,
例如某天突然需要从另一个系统导入大量数据,这种情况完全可以由dba依据现有的分库分表规则来导入,因此不考虑这种问题。)
考虑到数据增长的特点,如果我们以代表时间增长的字段,按递增的范围分库,则可以避免数据迁移
例如,如果id是随着时间推移而增长的全局sequence,则可以以id的范围来分库:(全局sequence可以用tddl现在的方式也可以用ZooKeeper实现)
id在 0–100万在第一个库中,100-200万在第二个中,200-300万在第3个中 (用M代表百万数据)
或者以时间字段为例,比如一个字段表示记录的创建时间,以此字段的时间段分库gmt_create_time in range
这样的方式下,在数据量再增加达到前几个库/表的上限时,则继续水平增加库表,原先的数据就不需要迁移了
但是这样的方式会带来一个热点问题:当前的数据量达到某个库表的范围时,所有的插入操作,都集中在这个库/表了。
所以在满足基本业务功能的前提下,分库分表方案应该尽量避免的两个问题:
1. 数据迁移
2. 热点
如何既能避免数据迁移又能避免插入更新的热点问题呢?
结合离散分库/分表和连续分库/分表的优点,如果一定要写热点和新数据均匀分配在每个库,同时又保证易于水平扩展,可以考虑这样的模式:
阶段一:一个库DB0之内分4个表,id%4 :
阶段二:增加db1库,t2和t3整表搬迁到db1
阶段三:增加DB2和DB3库,t1整表搬迁到DB2,t3整表搬迁的DB3:
为了规则表达,通过内部名称映射或其他方式,我们将DB1和DB2的名称和位置互换得到下图:
dbRule: “DB” + (id % 4)
tbRule: “t” + (id % 4)
这样3个阶段的扩展方案中,每次次扩容只需要做一次停机发布,不需要做数据迁移。停机发布中只需要做整表搬迁。
这个相对于每个表中的数据重新分配来说,不管是开发做,还是DBA做都会简单很多。
如果更进一步数据库的设计和部署上能做到每个表一个硬盘,那么扩容的过程只要把原有机器的某一块硬盘拔下来,
插入到新的机器上,就完成整表搬迁了!可以大大缩短停机时间。
具体在mysql上可以以库为表。开始一个物理机上启动4个数据库实例,每次倍增机器,直接将库搬迁到新的机器上。
这样从始至终规则都不需要变化,一直都是:
dbRule: “DB” + (id % 4)
tbRule: “t” + (id % 4)
即逻辑上始终保持4库4表,每个表一个库。这种做法也是目前店铺线图片空间采用的做法。
上述方案有一个缺点,就是在从一个库到4个库的过程中,单表的数据量一直在增长。当单表的数据量超过一定范围时,可能会带来性能问题。比如索引的问题,历史数据清理的问题。
另外当开始预留的表个数用尽,到了4物理库每库1个表的阶段,再进行扩容的话,不可避免的要从表上下手。那么我们来考虑表内数据上限不增长的方案:
阶段一:一个数据库,两个表,rule0 = id % 2
分库规则dbRule: “DB0″
分表规则tbRule: “t” + (id % 2)
阶段二:当单库的数据量接近1千万,单表的数据量接近500万时,进行扩容(数据量只是举例,具体扩容量要根据数据库和实际压力状况决定):
增加一个数据库DB1,将DB0.t1整表迁移到新库DB1。
每个库各增加1个表,未来10M-20M的数据mod2分别写入这2个表:t0_1,t1_1:
分库规则dbRule:
“DB” + (id % 2)
分表规则tbRule:
if(id < 1千万){ return "t"+ (id % 2); //1千万之前的数据,仍然放在t0和t1表。t1表从DB0搬迁到DB1库 }else if(id < 2千万){ return "t"+ (id % 2) +"_1"; //1千万之后的数据,各放到两个库的两个表中: t0_1,t1_1 }else{ throw new IllegalArgumentException("id outof range[20000000]:" + id); }
这样10M以后的新生数据会均匀分布在DB0和DB1; 插入更新和查询热点仍然能够在每个库中均匀分布。
每个库中同时有老数据和不断增长的新数据。每表的数据仍然控制在500万以下。
阶段三:当两个库的容量接近上限继续水平扩展时,进行如下操作:
新增加两个库:DB2和DB3. 以id % 4分库。余数0、1、2、3分别对应DB的下标. t0和t1不变,
将DB0.t0_1整表迁移到DB2; 将DB1.t1_1整表迁移到DB3
20M-40M的数据mod4分为4个表:t0_2,t1_2,t2_2,t3_2,分别放到4个库中:
新的分库分表规则如下:
分库规则dbRule:
if(id < 2千万){ //2千万之前的数据,4个表分别放到4个库 if(id < 1千万){ return "db"+ (id % 2); //原t0表仍在db0, t1表仍在db1 }else{ return "db"+ ((id % 2) +2); //原t0_1表从db0搬迁到db2; t1_1表从db1搬迁到db3 } }else if(id < 4千万){ return "db"+ (id % 4); //超过2千万的数据,平均分到4个库 }else{ throw new IllegalArgumentException("id out of range. id:"+id); }
分表规则tbRule:
if(id < 2千万){ //2千万之前的数据,表规则和原先完全一样,参见阶段二 if(id < 1千万){ return "t"+ (id % 2); //1千万之前的数据,仍然放在t0和t1表 }else{ return "t"+ (id % 2) +"_1"; //1千万之后的数据,仍然放在t0_1和t1_1表 } }else if(id < 4千万){ return "t"+ (id % 4)+"_2"; //超过2千万的数据分为4个表t0_2,t1_2,t2_2,t3_2 }else{ throw new IllegalArgumentException("id out of range. id:"+id); }
随着时间的推移,当第一阶段的t0/t1,第二阶段的t0_1/t1_1逐渐成为历史数据,不再使用时,可以直接truncate掉整个表。省去了历史数据迁移的麻烦。
上述3个阶段的分库分表规则在TDDL2.x中已经全部支持,具体请咨询TDDL团队。
非倍数扩展:如果从上文的阶段二到阶段三不希望一下增加两个库呢?尝试如下方案:
新增库为DB2,t0、t1都放在DB0,
t0_1整表迁移到DB1
t1_1整表迁移到DB2
这时DB0退化为旧数据的读库和更新库。新增数据的热点均匀分布在DB1和DB2
4无法整除3,因此如果从4表2库扩展到3个库,不做行级别的迁移而又保证热点均匀分布看似无法完成。
当然如果不限制每库只有两个表,也可以如下实现:
小于10M的t0和t1都放到DB0,以mod2分为两个表,原数据不变
10M-20M的,以mod2分为两个表t0_1、t1_1,原数据不变,分别搬迁到DB1,和DB2
20M以上的以mod3平均分配到3个DB库的t_0、t_2、t_3表中
这样DB1包含最老的两个表,和最新的1/3数据。DB1和DB2都分表包含次新的两个旧表t0_1、t1_1和最新的1/3数据。
新旧数据读写都可达到均匀分布。
总而言之:
两种规则映射(函数):
离散映射和连续映射这两种相辅相成的映射规则,正好解决热点和迁移这一对相互矛盾的问题。
我们之前只运用了离散映射,引入连续映射规则后,两者结合,精心设计,
应该可以设计出满足避免热点和减少迁移之间任意权衡取舍的规则。
基于以上考量,分库分表规则的设计和配置,长远说来必须满足以下要求
主键冲突问题
分库分表的环境中,数据分布在不同的分片上,不能再借助数据库自增长特性直接生成,否则会造成不同分片上的数据表主键会重复。
添加数据>主键生成中心>分库决策中心>切换相应库>执行添加
事务问题
在执行分库分表之后,由于数据存储到了不同的库上,数据库事务管理出现了困难。
如果依赖数据库本身的分布式事务管理功能去执行事务,将付出高昂的性能代价;
如果由应用程序去协助控制,形成程序逻辑上的事务,又会造成编程方面的负担。
跨库跨表的join问题
在执行了分库分表之后,难以避免会将原本逻辑关联性很强的数据划分到不同的表、不同的库上。
这时,表的关联操作将受到限制,我们无法join位于不同分库的表,也无法join分表粒度不同的表,结果原本一次查询能够完成的业务,可能需要多次查询才能完成。
数据库Sharding的基本思想和切分策略
本文着重介绍sharding的基本思想和理论上的切分策略
一、基本思想
Sharding的基本思想就要把一个数据库切分成多个部分放到不同的数据库(server)上,从而缓解单一数据库的性能问题。不太严格的讲,对于海量数据的数据库,如果是因为表多而数据多,这时候适合使用垂直切分,即把关系紧密(比如同一模块)的表切分出来放在一个server上。如果表并不多,但每张表的数据非常多,这时候适合水平切分,即把表的数据按某种规则(比如按ID散列)切分到多个数据库(server)上。当然,现实中更多是这两种情况混杂在一起,这时候需要根据实际情况做出选择,也可能会综合使用垂直与水平切分,从而将原有数据库切分成类似矩阵一样可以无限扩充的数据库(server)阵列。下面分别详细地介绍一下垂直切分和水平切分.
垂直切分的最大特点就是规则简单,实施也更为方便,尤其适合各业务之间的耦合度非
常低,相互影响很小,业务逻辑非常清晰的系统。在这种系统中,可以很容易做到将不同业
务模块所使用的表分拆到不同的数据库中。根据不同的表来进行拆分,对应用程序的影响也
更小,拆分规则也会比较简单清晰。(这也就是所谓的”share nothing”)。
水平切分于垂直切分相比,相对来说稍微复杂一些。因为要将同一个表中的不同数据拆
分到不同的数据库中,对于应用程序来说,拆分规则本身就较根据表名来拆分更为复杂,后
期的数据维护也会更为复杂一些。
让我们从普遍的情况来考虑数据的切分:一方面,一个库的所有表通常不可能由某一张表全部串联起来,这句话暗含的意思是,水平切分几乎都是针对一小搓一小搓(实际上就是垂直切分出来的块)关系紧密的表进行的,而不可能是针对所有表进行的。另一方面,一些负载非常高的系统,即使仅仅只是单个表都无法通过单台数据库主机来承担其负载,这意味着单单是垂直切分也不能完全解决问明。因此多数系统会将垂直切分和水平切分联合使用,先对系统做垂直切分,再针对每一小搓表的情况选择性地做水平切分。从而将整个数据库切分成一个分布式矩阵。
二、切分策略
如前面所提到的,切分是按先垂直切分再水平切分的步骤进行的。垂直切分的结果正好为水平切分做好了铺垫。垂直切分的思路就是分析表间的聚合关系,把关系紧密的表放在一起。多数情况下可能是同一个模块,或者是同一“聚集”。这里的“聚集”正是领域驱动设计里所说的聚集。在垂直切分出的表聚集内,找出“根元素”(这里的“根元素”就是领域驱动设计里的“聚合根”),按“根元素”进行水平切分,也就是从“根元素”开始,把所有和它直接与间接关联的数据放入一个shard里。这样出现跨shard关联的可能性就非常的小。应用程序就不必打断既有的表间关联。比如:对于社交网站,几乎所有数据最终都会关联到某个用户上,基于用户进行切分就是最好的选择。再比如论坛系统,用户和论坛两个模块应该在垂直切分时被分在了两个shard里,对于论坛模块来说,Forum显然是聚合根,因此按Forum进行水平切分,把Forum里所有的帖子和回帖都随Forum放在一个shard里是很自然的。
对于共享数据数据,如果是只读的字典表,每个shard里维护一份应该是一个不错的选择,这样不必打断关联关系。如果是一般数据间的跨节点的关联,就必须打断。
需要特别说明的是:当同时进行垂直和水平切分时,切分策略会发生一些微妙的变化。比如:在只考虑垂直切分的时候,被划分到一起的表之间可以保持任意的关联关系,因此你可以按“功能模块”划分表格,但是一旦引入水平切分之后,表间关联关系就会受到很大的制约,通常只能允许一个主表(以该表ID进行散列的表)和其多个次表之间保留关联关系,也就是说:当同时进行垂直和水平切分时,在垂直方向上的切分将不再以“功能模块”进行划分,而是需要更加细粒度的垂直切分,而这个粒度与领域驱动设计中的“聚合”概念不谋而合,甚至可以说是完全一致,每个shard的主表正是一个聚合中的聚合根!这样切分下来你会发现数据库分被切分地过于分散了(shard的数量会比较多,但是shard里的表却不多),为了避免管理过多的数据源,充分利用每一个数据库服务器的资源,可以考虑将业务上相近,并且具有相近数据增长速率(主表数据量在同一数量级上)的两个或多个shard放到同一个数据源里,每个shard依然是独立的,它们有各自的主表,并使用各自主表ID进行散列,不同的只是它们的散列取模(即节点数量)必需是一致的。
1.事务问题:
解决事务问题目前有两种可行的方案:分布式事务和通过应用程序与数据库共同控制实现事务下面对两套方案进行一个简单的对比。
方案一:使用分布式事务
优点:交由数据库管理,简单有效
缺点:性能代价高,特别是shard越来越多时
方案二:由应用程序和数据库共同控制
原理:将一个跨多个数据库的分布式事务分拆成多个仅处
于单个数据库上面的小事务,并通过应用程序来总控
各个小事务。
优点:性能上有优势
缺点:需要应用程序在事务控制上做灵活设计。如果使用
了spring的事务管理,改动起来会面临一定的困难。
2.跨节点Join的问题
只要是进行切分,跨节点Join的问题是不可避免的。但是良好的设计和切分却可以减少此类情况的发生。解决这一问题的普遍做法是分两次查询实现。在第一次查询的结果集中找出关联数据的id,根据这些id发起第二次请求得到关联数据。
3.跨节点的count,order by,group by以及聚合函数问题
这些是一类问题,因为它们都需要基于全部数据集合进行计算。多数的代理都不会自动处理合并工作。解决方案:与解决跨节点join问题的类似,分别在各个节点上得到结果后在应用程序端进行合并。和join不同的是每个结点的查询可以并行执行,因此很多时候它的速度要比单一大表快很多。但如果结果集很大,对应用程序内存的消耗是一个问题。
相关阅读:
数据库分库分表(sharding)系列(二) 全局主键生成策略
第一部分:一些常见的主键生成策略
一旦数据库被切分到多个物理结点上,我们将不能再依赖数据库自身的主键生成机制。一方面,某个分区数据库自生成的ID无法保证在全局上是唯一的;另一方面,应用程序在插入数据之前需要先获得ID,以便进行SQL路由。目前几种可行的主键生成策略有:
1. UUID:使用UUID作主键是最简单的方案,但是缺点也是非常明显的。由于UUID非常的长,除占用大量存储空间外,最主要的问题是在索引上,在建立索引和基于索引进行查询时都存在性能问题。
2. 结合数据库维护一个Sequence表:此方案的思路也很简单,在数据库中建立一个Sequence表,表的结构类似于:
CREATE TABLE `SEQUENCE` (
`tablename` varchar(30) NOT NULL,
`nextid` bigint(20) NOT NULL,
PRIMARY KEY (`tablename`)
) ENGINE=InnoDB
每当需要为某个表的新纪录生成ID时就从Sequence表中取出对应表的nextid,并将nextid的值加1后更新到数据库中以备下次使用。此方案也较简单,但缺点同样明显:由于所有插入任何都需要访问该表,该表很容易成为系统性能瓶颈,同时它也存在单点问题,一旦该表数据库失效,整个应用程序将无法工作。有人提出使用Master-Slave进行主从同步,但这也只能解决单点问题,并不能解决读写比为1:1的访问压力问题。
除此之外,还有一些方案,像对每个数据库结点分区段划分ID,以及网上的一些ID生成算法,因为缺少可操作性和实践检验,本文并不推荐。实际上,接下来,我们要介绍的是Fickr使用的一种主键生成方案,这个方案是目前我所知道的最优秀的一个方案,并且经受了实践的检验,可以为大多数应用系统所借鉴。
第二部分:一种极为优秀的主键生成策略
flickr开发团队在2010年撰文介绍了flickr使用的一种主键生成测策略,同时表示该方案在flickr上的实际运行效果也非常令人满意,原文连接:Ticket Servers: Distributed Unique Primary Keys on the Cheap 这个方案是我目前知道的最好的方案,它与一般Sequence表方案有些类似,但却很好地解决了性能瓶颈和单点问题,是一种非常可靠而高效的全局主键生成方案。
图1. flickr采用的sharding主键生成方案示意图
flickr这一方案的整体思想是:建立两台以上的数据库ID生成服务器,每个服务器都有一张记录各表当前ID的Sequence表,但是Sequence中ID增长的步长是服务器的数量,起始值依次错开,这样相当于把ID的生成散列到了每个服务器节点上。例如:如果我们设置两台数据库ID生成服务器,那么就让一台的Sequence表的ID起始值为1,每次增长步长为2,另一台的Sequence表的ID起始值为2,每次增长步长也为2,那么结果就是奇数的ID都将从第一台服务器上生成,偶数的ID都从第二台服务器上生成,这样就将生成ID的压力均匀分散到两台服务器上,同时配合应用程序的控制,当一个服务器失效后,系统能自动切换到另一个服务器上获取ID,从而保证了系统的容错。
关于这个方案,有几点细节这里再说明一下:
1. flickr的数据库ID生成服务器是专用服务器,服务器上只有一个数据库,数据库中表都是用于生成Sequence的,这也是因为auto-increment-offset和auto-increment-increment这两个数据库变量是数据库实例级别的变量。
2. flickr的方案中表格中的stub字段只是一个char(1) NOT NULL存根字段,并非表名,因此,一般来说,一个Sequence表只有一条纪录,可以同时为多张表生成ID,如果需要表的ID是有连续的,需要为该表单独建立
Sequence表
。
3. 方案使用了mysql的LAST_INSERT_ID()函数,这也决定了Sequence表只能有一条记录。
4. 使用REPLACE INTO插入数据,这是很讨巧的作法,主要是希望利用mysql自身的机制生成ID,不仅是因为这样简单,更是因为我们需要ID按照我们设定的方式(初值和步长)来生成。
5. SELECT LAST_INSERT_ID()必须要于REPLACE INTO语句在同一个数据库连接下才能得到刚刚插入的新ID,否则返回的值总是0
6. 该方案中Sequence表使用的是MyISAM引擎,以获取更高的性能,注意:MyISAM引擎使用的是表级别的锁,MyISAM对表的读写是串行的,因此不必担心在并发时两次读取会得到同一个ID(另外,应该程序也不需要同步,每个请求的线程都会得到一个新的connection,不存在需要同步的共享资源)。经过实际对比测试,使用一样的Sequence表进行ID生成,MyISAM引擎要比InnoDB表现高出很多!
7. 可使用纯JDBC实现对Sequence表的操作,以便获得更高的效率,实验表明,即使只使用Spring JDBC性能也不及纯JDBC来得快!
实现该方案,应用程序同样需要做一些处理,主要是两方面的工作:
1. 自动均衡数据库ID生成服务器的访问
2. 确保在某个数据库ID生成服务器失效的情况下,能将请求转发到其他服务器上执行。
相关阅读:
随着数据量的增大,在数据库的扩展上通常遇到切分时保证键值的唯一性问题,遇到这种情况,通常有如下几种相对简单的解决方案:
1 UUID 这种方案的优点是实现和管理简单,缺点是占用空间大,查询效率低下。
2 Sequence Number 优点是实现和管理简单,确定是有性能瓶颈和单点问题。
3 不同的集群采用的起始点或者增长间隔不同 这种方案实现简单,但是后期管理麻烦。
除了上述解决方案之外其实还有很多简单可行的办法,但是通用性不太好,在各种解决方案的接触上,本人总结出一个实现和性能上都很好的解决方案,那就是采用时间戳加毫秒数再加随机数来解决,存储字段采用bigint。
下面给出php代码实现:
function ivan_fetch_unique_bigint_id()
{
$start_timestamp = 1238119411;
$ivan_len = 3;
$time = explode( ‘ ‘, microtime());
$id = ($time[1] – $start_timestamp) . sprintf(‘%06u’, substr($time[0], 2, 6));
if ($ivan_len > 0) {
$id .= substr(sprintf(‘%010u’, mt_rand()), 0, $ivan_len);
}
return $id;
}
取模测试均分性很好。