微信公众号:内核小王子 关注可了解更多关于数据库,JVM内核相关的知识; 若是你有任何疑问也能够加我pigpdong[^1]java
通常来讲,影响数据库最大的性能问题有两个,一个是对数据库的操做,一个是数据库中的数据太大,对于前者咱们能够借助缓存来减小一部分读操做,针对一些复杂的报表分析和搜索能够交给hadoop和elasticsearch,对于后者,咱们就只能分库分表,读写分离。mysql
互联网行业随着业务的复杂化,大多数应用都会经历数据的垂直分区,一个复杂的流程会按照领域拆分红不一样的服务,每一个服务中心都拥有本身独立的数据库,拆分后服务共享,业务更清晰,系统也更容易扩展,同时减小了单库数据库链接数的压力,也在必定程度上提升了单表大数据量下索引查询的效率,固然业务隔离,也能够避免一个业务把数据库拖死致使全部业务都死掉,咱们将这种按照业务维度,把一个库拆分为多个不一样的库的方式叫作垂直拆分。git
垂直拆分也包含针对长表(属性不少)作冷热分离的拆分,例如,在商品系统设计中,一个商品的生产商,供销商,以及特有属性,这些字段变化频率低,查询次数多,叫作冷数据,而商品的份额,关注量等相似的统计信息变化频率较高,叫作活跃数据或者热数据,在MYSQL中,冷数据查询多更新少,适合用MyISAM存储引擎,而热数据更新比较频繁适合用InnoDB,这也是垂直拆分的一种.github
当单表数据量随着业务发展继续膨胀,在MYSQL中当数据量达到千万级时,就须要考虑进行水平拆分了,这样数据就分散到不一样的表上,单表的索引大小获得控制,能够提高查询性能,当数据库的实例吞吐量达到性能瓶颈后,咱们须要水平扩展数据库的实例,让多个数据库实例分担请求,这种根据分片算法,将一个库拆分红多个同样结构的库,将多个表拆分红多个结构相同的表就叫作水平拆分。redis
数据拆分也有不少缺点,数据分散,数据库的Join操做变得更加复杂,分片后数据的事务一致性很难保证,同时数据的扩容和维护难度增长,拆分规则也可能致使某个业务须要同时查询全部的表而后进行聚合,若是须要排序和函数计算则更加复杂,因此不到万不得已能够先没必要拆分。算法
根据分库分表方案中实施切片逻辑的层次不一样,咱们将分库分表的实现方案分红如下3种spring
这种方式将分片规则直接放在应用层,虽然侵入了业务,开发人员不只既须要实现业务逻辑也须要实现分库分表的配置的开发,可是实现起来简单,适合快速上线,经过编码方式也更容易实现跨表遍历的状况,后期故障也更容易定位,大多数公司都会在业务早期采用此种方式过渡,后期分表需求增多,则会寻求中间件来解决,如下代码为铜板街早期订单表在DAO层将分片信息以参数形式传到mybatis的mapper文件中的实现方案。sql
@Override
public OrderDO findByPrimaryKey(String orderNo) {
Assert.hasLength(orderNo, "订单号不能为空");
Map<String, Object> map = new HashMap<String, Object>(3);
map.put("tableSuffix", orderRouter.routeTableByOrderNo(orderNo));
map.put("dbSuffix", orderRouter.routeDbByOrderNo(orderNo));
map.put("orderNo", orderNo);
Object obj = getSqlSession().selectOne("NEW_ORDER.FIND_BY_PRIMARYKEY", map);
if (obj != null && obj instanceof OrderDO) {
return (OrderDO) obj;
}
return null;
}
复制代码
这种方式经过扩展第三方ORM框架,将分片规则和路由机制嵌入到ORM框架中,如hibernate和mybatis,也能够基于spring jdbctemplate来实现,目前实现方案较少。数据库
这种方式比较常见,对业务侵入低,经过定制JDBC协议,针对业务逻辑层提供与JDBC一致的接口,让开发人员没必要要关心分库分表的具体实现,分库分表在JDBC内部搞定,对业务层透明。目前流行的ShardingJDBC,TDDL便采用了这种方案。这种方案须要开发人员熟悉JDBC协议,研发成本较低,适合大多数中型企业缓存
此种分片方式,是在应用层和数据库层增长一个代理,把分片的路由规则配置在代理层,代理层提供与JDBC兼容的接口给应用层,开发人员不用关心分片逻辑实现,只须要在代理层配置便可。增长代理服务器,须要解决代理的单点问题增长硬件成本,同时全部的数据库请求增长了一层网络传输影响性能,固然维护也须要更资深的专家,目前采用这种方式的框架有cobar和mycat.
分片后,若是查询的标准是根据分片的字段,则根据切片算法,能够路由到对应的表进行查询,若是查询条件中不包含分片的字段,则须要将全部的表都扫描一遍而后在进行合并,因此在设计分片的时候咱们通常会选择一个查询频率较高的字段做为分片的依据,后续的分片算法会基于该字段的值进行,例如根据建立时间字段取对应的年份,每一年一张表,取电话号码里面的最后一位进行分表等,这个分片的字段咱们通常会根据查询频率来选择,例如在互金行业,用户的持仓数据,咱们通常选择用户id进行分表,而用户的交易订单也会选择用户id进行分表,可是若是咱们要查询某个供应商下在某段时间内的全部订单就须要遍历全部的表,因此有时候咱们可能会须要根据多个字段同时进行分片,数据进行冗余存储。
分片规则必须保证路由到每张物理表的数据量大体相同,否则上线后某一张表的数据膨胀的特别快,而其余表数据相对不多,这样就失去了分表的意义,后期数据迁移也有很高的复杂度,经过分片字段定位到对应的数据库和物理表有哪些算法呢?(咱们将分表后在数据库上物理存储的表名叫物理表,如trade_order_01,trade_order_02,将未进行切分前的表名称做逻辑表如trade_order)大体能够有如下分类
分库和分表算法须要保证不相关,上线前必定要用线上数据作预测,例如分库算法用 用户id%64 分64个库 分表算法也用 用户id%64 分64张表,总计 64 * 64 张表,最终数据都将落在 如下 64张表中 00库00表,01库01表... 63库63表, 其余 64 * 63张表则没有数据。这里能够推荐一个算法,分库用 用户ID/64 % 64 , 分表用 用户ID%64 测试1亿笔用户id发现分布均匀.
在分库分表前须要规划好业务增加量,以预备多大的空间,计算分表后能够支持按某种数据增加速度能够维持多久。
客户端须要定制JDBC协议,在拿到待执行的sql后,解析sql,根据查询条件判断是否存在分片字段,若是存在,再根据分片算法获取到对应的数据库实例和物理表名,重写sql,而后找到对应的数据库datasource并获取物理链接,执行sql,将结果集进行合并筛选后返回,若是没有分片字段,则须要查询全部的表,注意,即便存在分片字段,可是分片字段在一个范围内,可能也须要查询多个表,针对select之外的sql若是没有传分片字段建议直接抛出异常。
咱们先回顾下一个完整的经过JDBC执行一条查询sql的流程,其实druid也是在jdbc上作加强来作监控的,因此咱们也能够适当参考druid的实现
@Test
public void testQ() throws SQLException,NamingException{
Context context = new InitialContext();
DataSource dataSource = (DataSource)context.lookup("java:comp/env/jdbc/myDataSource");
Connection connection = dataSource.getConnection();
PreparedStatement preparedStatement = connection.prepareStatement("select * from busi_order where id = ?");
preparedStatement.setString(1,"1");
ResultSet resultSet = preparedStatement.executeQuery();
while(resultSet.next()){
String orderNo = resultSet.getString("order_no");
System.out.println(orderNo);
}
preparedStatement.close();
connection.close();
}
复制代码
sql解析通常借助druid框架里面的SQLStatementParser类,解析好的数据都在SQLStatement 中,固然有条件的能够本身研究SQL解析,不过可能工做量有点大。
分片算法,主要经过一个表达式,从分片字段对应的值获取到分片结果,能够提供简单地EL表达式,就能够实现从值中截取某一段做为分表数据,也能够提供通用的一致性哈希算法的实现,应用方只须要在xml或者注解中配置便可,如下为一致性哈希在铜板街的实现
/**
* 最大真实节点数
*/
private int max;
/**
* 真实节点的数量
*/
private int current;
private int[] bucket;
private Set suffixSet;
public void init() {
bucket = new int[max];
suffixSet = new TreeSet();
int length = max / current;
int lengthIndex = 0;
int suffix = 0;
for (int i = 0; i < max; i++) {
bucket[i] = suffix;
lengthIndex ++;
suffixSet.add(suffix);
if (lengthIndex == length){
lengthIndex = 0;
suffix = i + 1;
}
}
}
public VirtualModFunction(int max, int current){
this.current = current;
this.max = max;
this.init();
}
@Override
public Integer execute(String columnValue, Map<String, Object> extension) {
return bucket[((Long) (Long.valueOf(columnValue) % max)).intValue()];
}
复制代码
这里也能够顺带作一下读写分离,配置一些读操做路由到哪一个实例,写操做路由到哪一个实例,而且作到负载均衡,对应用层透明
若是须要在多个物理表上执行查询,则须要对结果集进行合并处理,此处须要注意返回是一个迭代器resultset
1.统计类 针对sum count ,max,min 只须要将每一个结果集的返回结果在作一个max和min,count和sum直接相加便可,针对avg须要经过上面改写的sql获取sum和count 而后相除计算平均值
2.排序类 大部分的排序都伴随着limit限制查询条数,例如返回结果须要查询最近的2000条记录,而且根据建立时间倒序排序,根据路由结果须要查询全部的物理表,假设是4张表,若是此时4张表的数据没有时间上的排序关系,则须要每张表都查询2000条记录,而且按照建立时间倒序排列,如今要作的就是从4个已经排序好的链表,每一个链表最多2000条数据,从新排序,选择2000条时间最近的,咱们能够经过插入排序的算法,每次分别从每一个链表中取出时间最大的一个,在新的结果集里找到位置并插入,直到结果集中存在2000条记录,可是这里可能存在一个问题,若是某一个链表的数据广泛比其余链表数据偏大,这样每一个链表取500条数据确定排序不许确,因此咱们还须要保证当前全部链表中剩下的数据的最大值比新结果集中的数据小。 而实际上业务层的需求可能并非仅仅取出2000条数据,而是要遍历全部的数据,这种要遍历全部数据集的状况,建议在业务层控制一张表一张表的遍历,若是每次都要去每张表中查询在排序严重影响效率,若是在应用层控制,咱们在后面在聊。
3.聚合类 group by 应用层须要尽可能避免这种操做,这些需求最好能交给搜索引擎和数据分析平台进行,可是做为一个中间件,对于group by 这种咱们常常须要统计数据的类型仍是应该尽可能支持的,目前的作法是 和统计类处理相似,针对各个子集进行合并处理。
以上流程基本能够实现一个简易版本的数据库分库分表中间件,为了让咱们的中间件更方便开发者使用,为平常工做提供更多地遍历性,咱们还能够从如下几点作优化
针对哪些表须要进行分片,分片规则等,这些须要定制化的配置,咱们能够在程序里面手工编码,可是这样业务层又耦合了分表的逻辑,咱们能够借助spring的配置文件,直接将xml里的内容映射成对应的bean实例。
1.咱们首先要设计好对应的配置文件的格式,有哪些节点,每一个节点包含哪些属性,而后设计本身命名空间,和对应的XSD校验文件,XSD文件放在META-INF下.
2.编写 NamespaceHandlerSupport 类,注册每一个节点元素对应的解析器
public class BaymaxNamespaceHandler extends NamespaceHandlerSupport {
//com.alibaba.dubbo.config.spring.schema.DubboNamespaceHandler
@Override
public void init() {
registerBeanDefinitionParser("table", new BaymaxBeanDefinitionParser(TableConfig.class, false));
registerBeanDefinitionParser("context", new BaymaxBeanDefinitionParser(BaymaxSpringContext.class, false));
registerBeanDefinitionParser("process", new BaymaxBeanDefinitionParser(ColumnProcess.class, false));
}
}
复制代码
3.在META-INF文件中增长配置文件 spring.handlers 中配置 spring遇到某个namespace下的节点后 经过哪一个解析器解析,最终返回配置实例
http\://baymax.tongbanjie.com/schema/baymax-3.0=com.tongbanjie.baymax.spring.BaymaxNamespaceHandler
复制代码
4.在META-INF文件中增长配置文件 spring.schema 中配置 spring遇到某个namespace下的节点后 经过哪一个XSD文件进行校验
http\://baymax.tongbanjie.com/schema/baymax-3.0.xsd=META-INF/baymax-3.0.xsd
复制代码
5.能够借助ListableBeanFactory的getBeansOfType(Class clazz) 来获取某个class类型的全部实例,从而得到全部的配置信息
固然也能够经过自定义注解进行申明,这种方式咱们能够借助BeanPostProcessor的时候判断类上是否包含指定的注解,可是这种方式比较笨重,并且所加注解的类必须在spring容器管理中,也能够借助 ClassPathScanningCandidateComponentProvider 和AnnotationTypeFilter 实现,或者直接经过 classloader扫描指定的包路径。
因为框架自己经过定制JDBC协议实现,虽然最终执行sql的是经过原生JDBC,可是对上层应用透明,同时也对上层基于JDBC实现的事物透明,spring的事物管理器能够直接使用,
咱们考虑下如下问题,若是咱们针对多张表在一个线程池内并发的区执行sql,而后在合并结果,这是否会影响spring的事物管理器?
首先,spring的声明式事物是经过aop在切面作加强,事物开始先获取connection并设置setAutocommit为fasle,事物结束调用connection进行commit或者rollback,经过threadlocal保存事物上下文和所使用的connection,来保证事物内多个sql共用一个connection操做, 可是若是咱们在解析sql后发现要执行多条sql语句,咱们经过线程池并发执行,而后等全部的结果返回后进行合并,(这里先不考虑,多个sql可能须要在不一样的数据库实例上执行),虽然经过线程池将致使threadlocal失效,可是咱们在threadlocal维护的是咱们本身定制的connection,并非原生的JDBC里的connection,并且这里并发执行并不会让事物处理器没办法判断是否全部的线程都已经结束,而后进行commit或者rollback,由于这里的线程池是在咱们定制的connection执行sql过程当中运用的,确定会等到全部线程处理结束后而且合并数据集才会返回。因此在本地事物层面,经过定制化JDBC能够作到对上层事物透明.
若是咱们进行了分库,同一个表可能在多个数据库实例里,这种若是要对不一样实例里的表进行更新,那么将没法在使用本地事物,这里咱们不在讨论分布式事物的实现,因为二阶段提交的各类缺点,目前不多有公司会基于二阶段作分布式事物,因此咱们的中间件也能够根据本身的具体业务考虑是否要实现XA,目前铜板街大部分分布式事物需求都是经过基于TCC的事物补偿作的,这种方式对业务幂等要求较高,同时要基于业务层实现回滚逻辑。
为何要提供一个发号器,咱们在单表的时候,可能会用到数据库的自增ID,但当分红多表后,每一个表都进行单独的ID自增,这样一个逻辑表内的ID就会出现重复。
咱们能够提供一个基于逻辑表自增的主键ID获取方式,若是没有分库,只分表,能够在数据库中增长一个表维护每张逻辑表对应的自增ID,每次须要获取ID的时候都先查询这个标当前的ID而后加一返回,而后在写入数据库,为了并发获取的状况,咱们能够采用乐观锁,相似于CAS,update的时候传人之前的ID,若是被人修改过则从新获取,固然咱们也能够一次性获取一批ID例如一次获取100个,等这100个用完了在从新获取,为了不这100个还没用完,程序正常或非正常退出,在获取这100个值的时候就将数据库经过CAS更新为已经获取了100个值之和的值
不推荐用UUID,无序,太长占内存影响索引效果,不携带任何业务含义
借助ZOOKEEPER 的zone的版本号来作序列号,
借助REDIS的INCR命令,进行自增,每台redis设置不一样的初始值,可是设置相同的歩长。
A:1,6,11,16,21
B:2,7,12,17,22
C:3,8,13,18,23
D:4,9,14,19,24
E:5,10,15,20,25
复制代码
snowflake算法:其核心思想是:使用41bit做为毫秒数,10bit做为机器的ID(5个bit是数据中心,5个bit的机器ID),12bit做为毫秒内的流水号(意味着每一个节点在每毫秒能够产生 4096 个 ID)
铜板街目前所使用的订单号规则:
15位时间戳,4位自增序列,2位区分订单类型,7位机器ID,2位分库后缀,2位分表后缀 共32位
7位机器ID 经过IP来获取
15位时间戳精确到毫秒,4位自增序列,意味着单JVM1毫秒能够生成9999个订单
最后4位能够方便的根据订单号定位到物理表,这里须要注意分库分表若是是根据一致性哈希算法,这个地方最好存最大值, 例如 用户id % 64 取余 最多能够分64张表,而目前可能用不到这么多,每相邻4个数字分配到一张表,共16张表,既 userID % 64 / 4 * 4 ,而这个地方存储 userID % 64 便可,没必要存最终分表的结果,这种方式方便后续作扩容,可能分表的结果变动了,可是订单号却没法进行变动
@Override
public String routeDbByUserId(String userId) {
Assert.hasLength(userId, "用户ID不能为空");
Integer userIdInteger = null;
try {
userIdInteger = Integer.parseInt(userId);
} catch (Exception ex) {
logger.error("解析用户ID为整数失败" + userId, ex);
throw new RuntimeException("解析用户ID为整数失败");
}
//根据路由规则肯定,具体在哪一个库哪一个表 例如根据分库公式最终结果在0到63之间 若是要分两个库 mod为32 分1个库mod为64 分16个库 mod为4
//规律为 64 = mod * (最终的分库数或分表数)
int mod = orderSplitConfig.getDbSegment();
Integer dbSuffixInt = userIdInteger / 64 % 64 / mod * mod ;
return StringUtils.leftPad(String.valueOf(dbSuffixInt), 2, '0');
}
@Override
public String routeTableByUserId(String userId) {
Assert.hasLength(userId, "用户ID不能为空");
Integer userIdInteger = null;
try {
userIdInteger = Integer.parseInt(userId);
} catch (Exception ex) {
logger.error("解析用户ID为整数失败" + userId, ex);
throw new RuntimeException("解析用户ID为整数失败");
}
//根据路由规则肯定,具体在哪一个库哪一个表 例如根据分表公式最终结果在0到63之间 若是要分两个库 mod为32 分1个库mod为64 分16个库 mod为4
//规律为 64 = mod * (最终的分库数或分表数)
int mod = orderSplitConfig.getTableSegment();
Integer tableSuffixInt = userIdInteger % 64 / mod * mod;
return StringUtils.leftPad( String.valueOf(tableSuffixInt), 2, '0');
}
复制代码
若是业务需求是遍历全部知足条件的数据,而不是只是为了取某种条件下前面一批数据,这种建议在应用层实现,一张表一张表的遍历,每次查询结果返回下一次查询的起始位置和物理表名,查询的时候建议根据 大于或小于某一个ID进行分页,不要limit500,500这种,如下为铜板街的实现方式
public List<T> select(String tableName, SelectorParam selectorParam, E realQueryParam) {
List<T> list = new ArrayList<T>();
// 定位到某张表
String suffix = partitionManager.getCurrentSuffix(tableName, selectorParam.getLocationNo());
int originalSize = selectorParam.getLimit();
while (true) {
List<T> ts = this.queryByParam(realQueryParam, selectorParam, suffix);
if (!CollectionUtils.isEmpty(ts)) {
list.addAll(ts);
}
if (list.size() == originalSize) {
break;
}
suffix = partitionManager.getNextSuffix(tableName, suffix);
if (StringUtils.isEmpty(suffix)) {
break;
}
// 查询下一张表 不须要定位单号 并且也只须要查剩下的size便可
selectorParam.setLimit(originalSize - list.size());
selectorParam.setLocationNo(null);
}
return list;
}
复制代码
1.监控能够借助druid,也能够在定制的jdbc层本身作埋点,将数据以报表的形式进行展现,也能够针对特定的监控指标进行配置,例如执行次数,执行时间大于某个指定时间
2.管理控制台,因为目前配置是在应用层,固然也能够把配置独立出来放在独立的服务器上,因为分片配置基本上没法在线修改,每次修改可能都伴随着数据迁移,因此基本上只能作展现,可是分表后咱们在测试环境执行sql去进行逻辑查询的时候,传统的sql工具没法帮忙作到自动路由,这样咱们每次查询可能都须要手工计算下分片结果,或者要连续写好几个sql以后在聚合,经过这个管理控制台咱们就能够直接根据逻辑表名写sql,这样咱们在测试环境或者在线上核对数据的时候,就提升了效率
3.扩容工具,笨办法只能先从老表查询在insert到新表,等到新表数据彻底同步完后,在切换到新的切片规则,因此咱们设计分片算法的时候,须要考虑到后面扩容,例如一致性哈希就须要迁移一半的数据(扩容一倍的话) 数据迁移若是出现故障,那将是个灾难,若是咱们要在不停机的状况下完成扩容,能够经过配置文件按如下流程来
以上流程适用于,订单这种历史数据在达到终态后将不会在被修改,若是历史数据也可能被修改,则可能须要停机,或者经过canel进行数据同步