数据库水平切分及问题
前面一篇文章说到,当遇到数据存储层的高并发的时候,会首先想到读写分离,同时高并发有可能意味着数据量大,大量的查询或更新操作集中在一张大表中,锁的频繁使用,会导致访问速度的下降,而且数据量可能超过了单机的容量,所以我们想到了分库分表。
但是在分库分表之前,我还是想多说几句,除非使用那些透明的分库分表方案,否则分库分表是一个大工程。 所以在分库分表前,我建议尽可能先升级数据库的硬件,SSD/NVMe硬盘 + 大容量内存基本可以满足一个小型互联网公司大部分的应用, 对于中型互联网公司需要使用到分库分表的场景也不会太多,用户,订单,交易可能会用到的。但是即使是这些场景,现在也有了不同的解决方案,在以前,大部分的应用还是基于web的,如果一些操作需要用到用户校验,就要进行登录,对数据库操作比较频繁,随着移动互联网的兴起以及Nosql的出现,如今大部分的应用都迁移到了App端,基本都是通过accessToken+NoSql来进行用户校验的,大家可以想想你有多久没有进行过登录操作了。对于订单交易,通过冷热数据分离,也可以解决大部分场景。
数据库切分分为水平切分,垂直切分, 垂直切分一般在拆分系统的时候使用,这里不再赘述,下面主要说数据的水平切分。
水平切分方式
1. 只分表:在一个数据库下面,分成10张表,表名 user_0 ,user_1, user_2.....
数据集中在一台服务器上,当单机性能瓶颈的时候,后续扩展困难。
2. 只分库,分成10个库,每个库一张表, 表名都是一样的。 db0-user db1-user db2-user......
出现跨库,没有办法使用事务。不过在分布式系统中,一般不会使用事务,保证数据最终一致性即可
3. 分表分库,10X10这种方式。 先分10个库, 每个库10张表。
确定了表切分的方式,接下来就是要根据一定的规则把数据落入到指定表中,这里介绍两种形式,
取模:即找到某个字段,这个字段通过取模后的值尽可能平均,根据字段取模的方式决定数据落在哪张表,比如,我们订单根据用户Id来决定落在哪张表。
日期:这种方法在最开始的时候,并没有决定要分多少张表,只是指定日期的数据落到指定的表中,比如用户注册,在2017-5-30日注册,则可以将数据落入user_201705,这张表中。
这两种分表方法各有优劣,按照取模分,每张表的数据可以尽可能的平均,但是如果后面表扩展则比较麻烦,按照日期来分,虽然可以解决表扩展问题,但是数据有可能不平均,比如在5月,举办了促销,结果那个月注册的人很多。
当数据开始写数据库,Id就是要考虑的,这里有几个解决方案:
1. Id自增,步进=分表数。比如分了3张表,每张表的起始Id不一样,并且自增的幅度=表数量,这样Id就是1,4,7 2,5,8 3,6,9 ,但是如果以后分表数增加,Id的步进还要变。
2. 单独的表,用于记录自增,可以单独一张表,里面有一列,记录的是当前的id, 可以获取下一个Id, 只是这样每次都要访问这张表,会有性能瓶颈。
3. Guid
4. snowflake算法
关于Id有几个注意的地方,如果你的Id有可能出现在页面上, 比如订单Id, 用户Id ,尽量不要使用自增的,因为别人可以根据这些Id,看出你现在的用户规模,订单量。通过在两天中相同时间下单,可以看出你的交易量,如果权限没有控制好,通过遍历可以查出所有的信息。
我们这里以用户表为例,看把用户信息写入数据库的一些方法,以及这些方法的一些问题。
当一个新用户注册,并没有Id,可以通过分布式Id获取到一个Id ,并对这个Id取模,得到具体的表,然后将Id连同用户信息写入表。 后面如果要根据Id得到用户信息,只要对Id采用相同的方法,即可查到对应的信息。
但是上面的方法没有覆盖到一个场景,用户登录都是通过用户名登录的,怎么查呢,这个时候,一般的方法就是并行查所有的表,好一点的方法就是,在注册的时候,额外建立一个映射,是username->表名的映射,当用户登录的时候,可以根据这个映射找到对应的表名,然后根据表名,再插到具体的用户,这个映射可以放到数据库中,也可以放到redis/mongodb中。
上面的方法可以解决问题,但是需要引入映射,如果不想要映射, 这个时候可以采用复合Id , 我们使用用户名进行取模,算出具体的表索引,然后通过分布式Id+表索引作为新的Id(NewId)插入到表中,这样我们对用户名进行计算可以得到表索引,通过对NewId进行计算也可以得到表索引(NewId包含了分布式Id和表索引,通过分离,可以得到表索引)。
我们上面没有考虑到扩展,比如现在我们有10张表,过段时间,发现10张表的存储也快满了, 这个时候就要再扩展10张表,变成20张表,那取模算法也要改,这个时候再对以前的NewId进行计算可能就得不到准备的表了,所以在NewId中,我们还要包含一个算法版本号。
当所有的操作都在一个数据库的时候, 可以很方便的使用事务,比如Java下spring的声明式事务,但是当出现跨库操作,事务的使用就不怎么方便了,对于互联网公司,大多数系统都是分布式的,会选择柔性事物。
随着业务的发展,数据量增多,我们需要再次对表进行扩展,扩展方式如下:
1. 需要迁移数据
1. 首次扩展:假如我们要开始分库分表,原来有一个库一张表,现在要扩展为10个,可以新建9个slave库,等9个slave库数据都和主库都同步了,然后修改路由算法。
2. 扩容后再扩容:比如现在3个表,要改为5个表,采用Hash算法,当数据扩容,则整体数据都要做迁移。 新增和删除都要做。 技巧:选择2个倍数,可以只做新表新增和删除,这个可能需要用到双写。
3. 划分树形组,比如,之前一个数据库,不需要Hash,现在新增9个,同步数据,完成后,修改路由算法(321%10),再删除数据,如果10个数据库不够,则再次扩容,原来的10个库,每个库各自再扩展9个库(可根据实际需要扩展),同步数据,方法和上面第一种类似,然后通过 321%10 321/10%10, 算出具体的数据库。如果不够,可以用同样的方式扩容。
2. 不迁移数据
1. 根据时间定义,比如一个月一张表。
2. 定义mapping关系,可以放入到分布式缓存中,写入的时候,写入缓存,读取的时候读取缓存, 如果缓存失效,全量读取。
3. 增量和Hash同时使用,定义策略,1-1000W一张组,1000-3000W一张组,组内,根据Hash避免热点数据。 新数据都会到新的数据库中。
4. 生成的ID具有自描述性。 ID+DBIndex,比如Id为321,Hash命中到第一张表,变为32101,最后两位表示具体的数据库索引,老的数据库也会写数据,如果我们的表增多,则修改hash算法,只命中到新表。
目前分库分表的解决方案主要集中在这几层:
1. dao层,在dao层根据指定的分表键决定要操作那个数据库。
2. ORM层
2. JDBC层,比较有名的是sharding-jdbc.
3. 代理层,比如mycat.
分库分表是一个大工程, 如何分, ID怎么取, 事物怎么做都是要考虑 的。另外大家不要用操作单表的思维去操作多表,有些人一接触分表就去考虑gourp by 怎么做, join怎么做。 从我接触的一些分表来看,如果牵扯到分表,操作都比较简单,基本在执行sql前都已经确定了要操作哪张表。 当然如果真的遇到要join的操作或者没有办法确定数据在哪个表中,像sharding-jdbc,mycat这种透明分表分库的都会帮你处理。