Skip to content

如何选择合适的分表键,路由规则及分片数

junior_xin edited this page Dec 5, 2018 · 3 revisions

1 分表键的选择

什么是分表键

分表键即分库/分表字段,zebra里面叫做维度,是在水平拆分过程中用于生成拆分规则的数据表字段。Zebra 根据分表键的值将数据表水平拆分到每个物理分库中。

数据表拆分的首要原则,就是要尽可能找到数据表中的数据在业务逻辑上的主体,并确定大部分(或核心的)数据库操作都是围绕这个主体的数据进行,然后可使用该主体对应的字段作为分表键,进行分库分表。

业务逻辑上的主体,通常与业务的应用场景相关,下面的一些典型应用场景都有明确的业务逻辑主体,可用于分表键:

  • 面向用户的互联网应用,都是围绕用户维度来做各种操作,那么业务逻辑主体就是用户,可使用用户对应的字段作为分表键;

  • 侧重于卖家的电商应用,都是围绕卖家维度来进行各种操作,那么业务逻辑主体就是卖家,可使用卖家对应的字段作为分表键;

以此类推,其它类型的应用场景,大多也能找到合适的业务逻辑主体作为分表键的选择。

如果确实找不到合适的业务逻辑主体作为分表键,那么可以考虑下面的方法来选择分表键:

  • 根据数据分布和访问的均衡度来考虑分表键,尽量将数据表中的数据相对均匀地分布在不同的物理分库/分表中,适用于大量分析型查询的应用场景(查询并发度大部分能维持为1);

  • 按照数字(字符串)类型与时间类型字段相结合作为分表键,进行分库和分表,适用于日志检索类的应用场景。

注意:无论选择什么拆分键,采用何种拆分策略,都要注意拆分值是否存在热点的问题,尽量规避热点数据来选择拆分键。

注意:不一定需要拿数据库主键当做分表键,也可以拿其他业务值当分表键。拿主键当分表键的好处是可以散列均衡,减少热点问题。

多个分表键如何处理

大部分场景下,一张表的查询条件比较单一,只需要一个分表键即可;但是有的时候,业务必须要有多个分表键,没有办法归一成一个。此时一般有四种处理方式:

名词定义:

  • 主分表键=主维度,在主维度上,数据能够增删改查;

  • 辅助分表键=辅维度,在辅助维度上,只能进行数据查询

在主维度上全表扫描

由于SQL中没有主维度,所以在对辅助维度进行查询时,只能在所有的主维度的表进行查询一遍,然后聚合。目前zebra的并发粒度是在数据库级别的,也就是说如果分了4个库,32张表,最终会以4个线程去并发查询32张表,最终把结果合并输出。

适用场景:辅助维度的查询请求的量很小,并且是运营查询,对性能要求不高 多维度数据进行冗余同步

主维度的数据,通过binlog的方式,同步到辅助维度一份。那么在查询辅助维度时,会落到辅助维度的数据上进行查询。

适用场景:辅助维度的查询请求的量也很可观,不能直接使用第一种全表扫描的方式

二维巧妙归一维

辅助维度其实有的时候也是主维度,比如在订单表Order中,OrderID和UserID其实是一一对应的,Order表的主维度是UserID,OrderID是辅助维度,但是由于OrderID其中的6位和UserID完全一致,也就是说,在OrderID中会把UserID打进去。

在路由的时候,如果SQL中带有UserID,那么直接拿UserID进行Hash取模路由;如果SQL中带有的OrderID维度,那么取出OrderID中的6位UserID进行Hash取模路由,结果是一致的。

适用场景:辅助维度和主维度其实可以通过将主维度和辅助维度的值进行信息共享

建立索引表

对于辅助维度可以建一张辅助维度和主维度的映射表。

举例来说,表A有两个维度,主维度a,辅助维度b,目前只有主维度的一份数据。

此时,如果有SQL: select * from A where b = ?过来,那么势必会在主维度上进行全表扫描。

那么建一张新表B_A_Index,里面就只有两个字段,a和b的值,这张表可以分表,也可以不分表,建议分表这张表的主维度就是b。

所以可以先查:select a from B_A_Index where b = ?,获得到a的值,然后 查询 select * from A where a = 查询到的值 and b = ? 进行查询。

试用场景:主副维度是一一对应的。优势是,无需数据冗余,只需要冗余一份索引数据。缺点是,需要业务进行略微的改造。

2 分片数的选择

zebra 中的水平拆分有两个层次:分库和分表。 表数目决策

一般情况下,建议单个物理分表的容量不超过1000万行数据。通常可以预估2到5年的数据增长量,用估算出的总数据量除以总的物理分库数,再除以建议的最大数据量1000万,即可得出每个物理分库上需要创建的物理分表数:

  • (未来3到5年内总共的记录行数) / 单张表建议记录行数 (单张表建议记录行数 = 1000万)

表的数量不宜过多,涉及到聚合查询或者分表键在多个表上的SQL语句,就会并发到更多的表上进行查询。举个例子,分了4个表和分了2个表两种情况,一种需要并发到4表上执行,一种只需要并发到2张表上执行,显然后者效率更高。

表的数目不宜过少,少的坏处在于一旦容量不够就又要扩容了,而分库分表的库想要扩容是比较麻烦的。一般建议一次分够。

建议表的数目是2的幂次个数,方便未来可能的迁移。

库数目决策

  • 计算公式:按照存储容量来计算 = (3到5年内的存储容量)/ 单个库建议存储容量 (单个库建议存储容量 <300G以内)

DBA的操作,一般情况下,会把若干个分库放到一台实例上去。未来一旦容量不够,要发生迁移,通常是对数据库进行迁移。所以库的数目才是最终决定容量大小。

最差情况,所有的分库都共享数据库机器。最优情况,每个分库都独占一台数据库机器。一般建议一个数据库机器上存放8个数据库分库。

3 分表策略的选择

分表方式 解释 优点 缺点 试用场景
Hash 拿分表键的值Hash取模进行路由。最常用的分表方式。
  • 数据量散列均衡,每个表的数据量大致相同。
  • 请求压力散列均衡,不存在访问热点
一旦现有的表数据量需要再次扩容时,需要涉及到数据移动,比较麻烦。所以一般建议是一次性分够。 在线服务。一般均以UserID或者ShopID等进行hash。
Range 拿分表键按照ID范围进行路由,比如id在1-10000的在第一个表中,10001-20000的在第二个表中,依次类推。这种情况下,分表键只能是数值类型。
  • 数据量可控,可以均衡,也可以不均衡
  • 扩容比较方便,因为如果ID范围不够了,只需要调整规则,然后建好新表即可。
无法解决热点问题,如果某一段数据访问QPS特别高,就会落到单表上进行操作。 离线服务。
时间 拿分表键按照时间范围进行路由,比如时间在1月的在第一个表中,在2月的在第二个表中,依次类推。这种情况下,分表键只能是时间类型。 扩容比较方便,因为如果时间范围不够了,只需要调整规则,然后建好新表即可。
  • 数据量不可控,有可能单表数据量特别大,有可能单表数据量特别小
  • 无法解决热点问题,如果某一段数据访问QPS特别高,就会落到单表上进行操作。
离线服务。比如线下运营使用的表、日志表等等

先查看一下Zebra分库分表接入指南,熟悉配置的意义

1.按照UserID进行Hash分表,根据UserID进行查询

XML格式

<?xml version="1.0" encoding="UTF-8"?>
<router-rule>  
<table-shard-rule table="tb" generatedPK="id">    
	<shard-dimension dbRule="#uid#.toInteger()%32" dbIndexes="user_test[0-31]"       
  	tbRule="#uid#.toInteger().intdiv(32) %32" tbSuffix="alldb:[0,1023]"      
  	isMaster="true">    
  </shard-dimension>  
  </table-shard-rule>
</router-rule>

tb表的uid维度一共分了32个库,分别是user_test0到user_test31。一共分了1024张表,表名分表是tb0到tb1023,平均分到了32个库中,每个库32张表。

2.按照UserID进行Hash分表,根据UserID和ShopID进行查询

XML格式

<?xml version="1.0" encoding="UTF-8"?>
<router-rule>
	<table-shard-rule table="tb" generatedPK="OrderID">
		<shard-dimension dbRule="#uid#.toInteger()%10000%32" dbIndexes="user_test[0-31]" 
			tbRule="#uid#.toInteger().intdiv(32) %32" tbSuffix="alldb:[0,1023]"
			isMaster="true">
		</shard-dimension>
		<shard-dimension dbRule="#bid# == null ? SKIP : (#bid#.toInteger()%16)" dbIndexes="user_tmp_test[0-15]" 
			tbRule="#bid#.toInteger().intdiv(16) %16" tbSuffix="alldb:[0,255]"
            needSync="true"
			isMaster="false">
		</shard-dimension>
	</table-shard-rule>
</router-rule>

tb表的bid维度的数据是根据uid的数据重新通过binlog同步了一份。该份数据分了16个库,分别是user_tmp_test0到user_tmp_test15。一共分了256张表,每个库16张表,表名分表是tb0到tb255。

其中zebra会负责根据该配置,把uid维度的数据自动的同步到bid维度,背后是通过binlog方式,所以会有needSync的属性。

3.根据uid进行Hash分表,根据uid和bid进行查询

XML格式

<?xml version="1.0" encoding="UTF-8"?>
<router-rule>
	<table-shard-rule table="tb" generatedPK="OrderID">
		<shard-dimension dbRule="#uid#.toInteger()%32" dbIndexes="user_test[0-31]" 
			tbRule="(#UserID#.toInteger()%10000).intdiv(32) %32" tbSuffix="alldb:[0,1023]"
			isMaster="true">
		</shard-dimension>
		<shard-dimension dbRule="#bid#[13..16].toInteger() % 32" dbIndexes="user_test[0-31]" 
			tbRule="(#bid#[13..16].toInteger()).intdiv(32) %32" tbSuffix="alldb:[0,1023]"
			isMaster="false">
		</shard-dimension>
	</table-shard-rule>
</router-rule>

tb表的OrderID维度的数据和uid的数据是一致的,并没有冗余。但是由于bid中的13到16位就是uid,所以可以使用uid的数据进行查询。本质上,uid和bid肯定能一一对应,其实是一个维度。

4.根据UserID进行Hash分表,根据AddTime汇总大表供线下运营查询

XML格式

<?xml version="1.0" encoding="UTF-8"?>
<router-rule>
	<table-shard-rule table="Order" generatedPK="OrderID">
		<shard-dimension dbRule="#UserID#.toInteger()%10000%32" dbIndexes="order_test[0-31]" 
			tbRule="(#UserID#.toInteger()%10000).intdiv(32) %32" tbSuffix="alldb:[0,1023]"
			isMaster="true">
		</shard-dimension>
		<shard-dimension dbRule="#AddTime# == null ? SKIP : 0" dbIndexes="order_one" 
			tbRule="#AddTime# == null ? SKIP : 0" tbSuffix="alldb:[]"
            needSync="true"
			isMaster="false">
		</shard-dimension>
	</table-shard-rule>
</router-rule>

Order表的AddTime维度,其实是通过binlog的方式把UserID的数据汇总到了order_one这个库,表名为Order这个表。这个汇总的过程是自动的。needSync=true。

线上服务使用ShardDataSource,对UserID的数据进行增删改查。运营服务在访问运营库order_one时,无需使用ShardDataSource,直接使用GroupDataSource进行访问,仅做查询使用。

5.根据UserID的crc32值进行Hash分表

XML格式

<?xml version="1.0" encoding="UTF-8"?>
<router-rule>
	<table-shard-rule table="Order" generatedPK="OrderID">
		<shard-dimension dbRule="crc32(#UserID#.toInteger())%10000%32" dbIndexes="order_test[0-31]" 
			tbRule="(crc32(#UserID#.toInteger())%10000).intdiv(32) %32" tbSuffix="alldb:[0,1023]"
			isMaster="true">
		</shard-dimension>
	</table-shard-rule>
</router-rule>

crc32是目前zebra的一个内置函数。

内置HASH函数

  • crc32(Object value)
  • crc32(Object value,String encode)
  • md5(String value)

6.主维度根据UserID进行Hash分表,辅助维度根据AddTime进行时间分表

XML格式

<?xml version="1.0" encoding="UTF-8"?>
<router-rule>
	<table-shard-rule table="Order" generatedPK="OrderID">
		<shard-dimension dbRule="#UserID#.toInteger()%10000%32" dbIndexes="order_test[0-31]" 
			tbRule="(#UserID#.toInteger()%10000).intdiv(32) %32" tbSuffix="alldb:[0,1023]"
			isMaster="true">
		</shard-dimension>
		<shard-dimension dbRule="shardByMonth(#AddTime#,"yyyy-MM-dd", "2017-01-01","2027-01-31",3,0) % 4" dbIndexes="order_time[0-3]" 
			tbRule="shardByMonth(#AddTime#,"yyyy-MM-dd", "2017-01-01","2027-01-31",3,0).intdiv(4)% 10" tbSuffix="alldb:[0,39]"
            needSync="true"
			isMaster="false">
		</shard-dimension>
	</table-shard-rule>
</router-rule>

线上系统直接使用ShardDataSource使用UserID维度,线下运营系统也是用ShardDataSource,如果带了AddTime,都会落到AddTime维度的数据上进行查询。支持>,<,>=,<=,between and等范围查询;不支持Join。

内置时间函数

  • shardByMonth("#CreateTime",String timeFormat, String startTime, String end, int monthPerTable, int defaultTableIndex) 该函数是根据月份进行计算路由的。其中,startTime和endTime表明起始时间和结束时间,monthPerTable是指从起始时间开始几个月一张表,defaultTableIndex是指如果超出了起始时间和结束时间时到一张默认的表的Index。