本文将主要从后台总结分库分表,分库分表造成的后遗症,分表策略,以及一些注意事项。一、背景最近完成了数据库分表项目。本次拆分主要包括订单和优惠券两部分。这两个部分涵盖了整个集团所有子公司和子公司的所有业务条线。随着公司业务的快速发展,无论是存储需求还是读写性能都基本达到了警戒级别。订单是交易的核心,优惠券是营销的核心。这两部分基本上是整个平台最积极最核心的部分。为了支撑未来三到五年的快速发展,我们需要对数据进行拆分。数据库分表行业已经有很多成熟的解决方案,不再是高深的技术,基本上是一个纯工程化的过程,但是真正有机会真正去做的还是很少,所以做一个总结是非常有必要的.由于分库分表涉及的技术选型和方法多种多样,本文不对各种方法进行列举和总结,而是总结了我们在实现分库分表过程中的一些经验。从业务场景来看,我们主要做水平拆分和逻辑DB拆分。考虑到以后写库的瓶颈,我们可以直接将一组分表迁移到分库中。2.分库分表带来的序列分库分表会带来很多后遗症,会让整个系统架构变得更加复杂。区分好坏的关键是如何找到shardingkey。如果shardingkey刚好是业务维度的分界线,会直接提升性能和复杂度。否则会有各种脚手架支撑,系统也会变得复杂。比如订单系统中的用户__ID__、订单__type__、商家__ID__、渠道__ID__、批次__ID__、渠道__ID__、优惠券系统中的组织__ID__等,这些都是潜在的Shardingkey。如果有这样一个shardingkey以后处理路由会很方便,不然就需要一些大而全的索引表来处理OLAP查询。一旦分片,首先要面对的问题就是查询时排序和分页的问题。1.归并排序在数据库表中处理排序和分页更方便。分片后,会有多个数据源。这里我们将多数据源统称为分片。实现多分片排序和分页,需要收集每个分片的数据进行排序,需要归并排序算法。数据在每个分片中可以有序(输出是有序的),但整体是无序的。我们看一个简单的例子:shardnode1:{1,3,5,7,9}shardnode2:{2,4,6,8,10}这是两个用于奇偶分片的分片,我们假设分页参数设置为每页4项,当前页1,参数如下:pageParameter:pageSize:4,currentPage:1在最乐观的情况下,我们需要读取两个分片节点的前两个:shardnode1:{1,3}shardnode2:{2,4}排序后正好是{1,2,3,4},但是这种情况基本不会出现,假设分片节点数据如下:shardnode1:{7,9,11,13,15}shardnode2:{2,4,6,8,10,12,14}我们还是读取每个节点的前两个肯定是错的,因为最悲观的情况(也是最真实的情况)是排序后所有的数据都来自一个碎片。因此,我们需要读取每个节点的pageSize数据,以保证数据的正确性。这个例子只是假设我们的查询条件输出的数据是刚好相等的。真实的情况一定是各??种查询条件过滤后的数据集。这时候,数据一定不能这样排列。最真实的是***还是这种结构。以此类推,如果我们的currentPage:1000,会发生什么?我们需要每个分片节点读取__4000(1000*4=4000)__条数据进行排序,因为在最悲观的情况下,所有的数据可能都来自一个分片节点。如果这样翻页,处理排序和分页的机器内存肯定会爆,即使不爆,也一定会引发性能瓶颈。这个简单的例子用来说明分片后排序分页带来的实际问题,也有助于我们理解为什么分布式系统在做多节点排序分页时会有最大分页限制。2.深度分页性能问题面对这个问题,我们需要改变查询条件,重新分页。一个庞大的数据集会被以各种方式拆分,按组织、按时间、按渠道等,拆分在不同的数据源中。我们可以通过改变查询条件来顺利解决一般的深度分页问题,??但这种方案并不能解决所有的业务场景。比如我们有一个订单列表,来自C端用户的订单列表数据量不会很大,但是运营后台系统可能会面对整个平台所有的订单数据量,所以数据量会很大要大。改变查询条件有两种方式:第一种条件是显示设置,尽可能缩小查询范围。一般这个设置会优先考虑诸如时间范围、支付状态、发货状态等,可以通过多个条件叠加进行横向和纵向的筛选。一小部分数据集;第二个条件是隐式设置的,比如订单列表通常是按照订单创建时间排序的,那么当页面翻到限制条件的时候,我们就可以改变这个时间。ShardingNode1:OrderidCreatedateTime1000002018-01-1010:10:102000002018-01-1010:10:113000002018-01-1010:10:124000002018-01-1010:10:135000002018-01-2010:10600181818-01-2018-01-20117000002018-01-2010:10:12SHARDINDNODE2:OrderidCreatedatetime1100002018-01-1110:10:102200002018-01-1110:10:1132002018-018:10:124200002018-01-1110:13521818181818-0181818181818181818181818181818181818181818-018181818181818181818181818181818181818181818181818106200002018-01-2110:10:117200002018-01-2110:10:12我们假设上面是一个订单列表,orderID订单号你不用关心顺序。Sharding之后,所有的订单ID都会由发布者统一发布,多个集群的多个消费者可以同时获取,只是创建订单的速度不同,顺序性不复存在。基本上,上面两个Sharding节点的序号是交叉的。如果按照时间对节点1和节点2进行排序,需要交替获取数据。比如我们的查询条件和分页参数:wherecreateDateTime>'2018-01-1100:00:00'pageParameter:pageSize:5,currentPage:1得到的结果集是:orderIDcreateDateTime1000002018-01-1010:10:102000002018-01-1010:10:113000002018-01-1010:10:124000002018-01-1010:10:131100002018-01-1110:10:10前4条记录来自节点1,后一条来自节点2整个排序集is:Shardingnode1:orderIDcreateDateTime1000002018-01-1010:10:102000002018-01-1010:10:113000002018-01-1010:10:124000002018-01-1010:10:135000002018-01-2010:10:10Shardingnode2:orderIDcreateDateTime1100002018-01-1110:10:102200002018-01-1110:10:113200002018-01-1110:10:124200002018-01-1110:10:135200002018-01-2110:10:10一直这样翻页,需要点击节点1和节点2每翻一页就多得到5条数据。这里我们可以通过修改查询条件,将整个页面变成重新查询。wherecreateDateTime>'2018-01-1110:10:13'因为我们可以确定所有的数据都在'2018-01-1110:10:13'时间之前查询过,但是为什么时间不是从'2018-01-2110:10:10',因为要考虑并发情况,1s内会有多个订单进来。这种方法最容易实现,不需要外部计算来支持。这种方法的一个问题是,如果你想重新计算页面而不丢失数据,你需要保留原来的那条数据,这样你才能知道开始时间在哪里,这样你就会在下一页看到这个时间.但是从真正的深度分页场景来看可以忽略,因为很少有人会从一页到500页,而是直接跳到前几页。此时,不存在该问题。如果要精确控制这种偏差,需要记住区间,或者使用其他方式实现,比如全量查询表、Sharding索引表、***订单tps值等,辅助计算。(可以使用数据同步中间件建立单表多级索引和多表多维索引来辅助计算,我们使用的数据同步中间件有datax、yugong、otter、canal,可以解决完全同步和增量同步)。3.分表策略分表的方式有很多种,比如mod、rang、preSharding、自定义路由等。每种方法都有一定的侧重点。我们主要采用mod+preSharding的方式。这种方式带来的最大问题之一就是后期节点变更数据迁移的问题,可以参考一致性Hash算法的虚拟节点来解决。数据分表和CacheSharding有一些区别。缓存可以接受缓存未命中,缓存数据可以通过被动缓存来维护。但是数据库中没有selectmiss场景。在CacheSharding场景下,可以使用consistentHash来消除在减少和增加Sharding节点时相邻分片压力的问题。但是一旦数据库出现数据迁移,数据无法查询肯定是不能接受的。所以我们做了一个虚拟节点+真实节点的映射,方便以后数据的平滑迁移。physicsnode:node1node2node3node4virtualnode:node1node2node3.....node20nodemapping:virtualnode1~node5{physicsnode1}virtualnode6~node10{physicsnode2}virtualnode11~node15{physicsnode3}virtualnode16~node20{physicsnode4}为了减少迁移数据时rehash和延迟的成本以后,将哈希值保存在表中,直接查询,方便以后迁移,快速导入。Hashslice的2次方的问题,就在大家熟悉的HashMap中。为了减少冲突和提供一定的性能,将Hash桶的大小设置为2的n次方,然后采用Hash&(legnth-1)位与法进行计算。这种方式主要是高手们发现2二进制的n次方除高位为0外全为1,通过位可以快速反转二进制然后状态加1就是最终值。我们在分库的时候不需要参考这个原理。这个原理主要用于程序内部的Hash表。在外部,我们原本需要Hashmod来确定Sharding节点。取模会出现不均匀的问题。在此基础上,可以做一个自定义的奇偶路由,让两边的数据拉平。四、一些注意事项1、现有项目中集成Sharding-JDBC存在一些小问题。Sharding-JDBC不支持批量插入。如果项目中使用了大量的批量插入语句,需要修改,或者使用辅助hash计算物理表名,然后批量插入。2、原项目数据层采用Druid+MyBatis。集成Sharding-JDBC后,Sharding-JDBC对Druid进行了封装,所以一些Sharding-JDBC不支持的SQL语句基本无法通过。3、使用Springboot集成Sharding-JDBC时,需要在bean加载时设置IncrementIdGenerator,但是出现classloader问题。IIncrementIdGeneratorincrementIdGenerator=this.getIncrementIdGenerator(dataSource);ShardingRuleShardingRule=ShardingRuleConfiguration.build(dataSourceMap);((IdGenerator)ShardingRule.getDefaultKeyGenerator()).setIncrementIdGenerator(incrementIdGenerator);privateIncrementIdGeneratorgetIncrementIdGenerator(DataSourcedruidDataSource){...}后来发现Springboot的类加载器使用了restartclassloader,所以一直转换失败。去掉spring-boot-devtools包即可,restartclassloader用于热启动。4.dao.xml的逆向工程问题,很多我们使用MyBatis生成工具的数据库表在生成的时候都是物理表名,一旦使用Sharding-JDCB就会使用逻辑表名,所以生成工具需要提供选项来Set逻辑表名。5、为MyBatis提供的SqlSessionFactory需要在Druid的基础上与Sharding-JDCB进行封装。6、Sharding-JDBCDefaultkeyGenerator默认使用雪花算法,我们不能直接使用。我们需要根据datacenterid-workerid设置workerId段,配合Zookeeper。(snowflakeworkId10位十进制1023,dataCenterId5位十进制31,WorkId5位十进制31)7.由于我们是使用mysqlcom.mysql.jdbc.ReplicationDriver实现读写分离,方便处理读写分离很多。如果不使用,需要手动设置DatasourceHint来处理。8、使用MyBatisdaomapper时需要多张逻辑表,因为有些数据源数据表不需要使用Sharding,自定义ShardingStragety来处理分支逻辑。9、全局ID的几种方法:如果用Zookeeper做分布式ID,一定要注意session中重复的workidsexpired的可能性,锁住或者接受一定的并行度(序号保证空间一段时间).采用中心化发号服务,主DB采用预生成表+增量插件(实现了经典的取号器,InnoDB存储引擎中的TRX_SYS_TRX_ID_STORE事务号也是这样实现的)。定长issuer,业务规则issuer,这个需要业务上下文的issuer的实现需要预先配置好,然后每次请求都带上获取上下文来说明获取业务的类型。10.项目中有些地方使用了自增ID排序。数据表拆分后需要修改,因为ID大小顺序已经不存在了。根据最新的数据排序,需要修改ID排序,使用时间字段排序。
