此文章是转载,对原文做了一些删减,原文:你分库分表的姿势对么?——详谈水平分库分表

一,好的分库分表方案

  1. 方案可持续性。分完后后期数据再次增大到需要拆分是否好操作。
  2. 数据倾斜问题。数据需要尽量均匀地落在各库各表里。

二,常见分库分表方案

  1. range分库分表
    根据数据范围进行切分,比如将订单数据按年份划分。这里简单列下它的缺点:
    A,数据热点问题
    B,交叉范围内数据的处理
  2. hash分库分表
    这种方案非常流行,但也有不少陷阱,下面先看几个有问题操作:

    常见错误案例一:非互质关系导致的数据偏斜问题
public static ShardCfg shard(String userId) {
    int hash = userId.hashCode();
    // 对库数量取余结果为库序号
    int dbIdx = Math.abs(hash % DB_CNT);
    // 对表数量取余结果为表序号
    int tblIdx = Math.abs(hash % TBL_CNT);
 
    return new ShardCfg(dbIdx, tblIdx);
}

这里用Hash值分别对分库数和分表数取余,得到库序号和表序号。其实稍微思索一下,我们就会发现,以10库100表为例,如果一个Hash值对100取余为0,那么它对10取余也必然为0。
这就意味着只有0库里面的0表才可能有数据,而其他库中的0表永远为空!
这会出现严重的数据倾斜问题!!
image.png

常见错误案例二:扩容难以持续

我们把10库100表看成总共1000个逻辑表,将求得的Hash值对1000取余,得到一个介于[0,999)中的数,然后再将这个数二次均分到每个库和每个表中,大概逻辑代码如下:

public static ShardCfg shard(String userId) {
        // ① 算Hash
        int hash = userId.hashCode();
        // ② 总分片数
        int sumSlot = DB_CNT * TBL_CNT;
        // ③ 分片序号
        int slot = Math.abs(hash % sumSlot);
        // ④ 计算库序号和表序号的错误案例
        int dbIdx = slot % DB_CNT ;
        int tblIdx = slot / DB_CNT ;
 
        return new ShardCfg(dbIdx, tblIdx);
    }

此方案可以避免数据倾斜问题,但它的分片序号是依赖总分片数得来的,后期再次进行扩容会存在扩容前后数据不在一个表里。
image.png
例如扩容前Hash为1986的数据应该存放在6库98表,但是翻倍扩容成20库100表后,它分配到了6库99表,表序号发生了偏移。这样的话,我们在后续在扩容的时候,不仅要基于库迁移数据,还要基于表迁移数据。
下面说一下正确的方案:

正确方案一:标准的二次分片法

上面的错误方案二在计算库表序号时,是以总库数为基础计算出来的,这里稍微改一下,以总表数为计算基础:

public static ShardCfg shard2(String userId) {
        // ① 算Hash
        int hash = userId.hashCode();
        // ② 总分片数
        int sumSlot = DB_CNT * TBL_CNT;
        // ③ 分片序号
        int slot = Math.abs(hash % sumSlot);
        // ④ 重新修改二次求值方案
        int dbIdx = slot / TBL_CNT ;
        int tblIdx = slot % TBL_CNT ;
 
        return new ShardCfg(dbIdx, tblIdx);
    }

通过翻倍扩容后,我们的表序号一定维持不变,库序号可能还是在原来库,也可能平移到了新库中(原库序号加上原分库数),完全符合我们需要的扩容持久性方案。
当然,这种方案也是缺点的,连续的分片键Hash值大概率会散落在相同的库中,某些业务可能容易存在库热点。

正确方案二:关系表冗余

将分片键对应库的关系通过关系表记录下来,也就是关系路由表。

public static ShardCfg shard(String userId) {
        int tblIdx = Math.abs(userId.hashCode() % TBL_CNT);
        // 从缓存获取
        Integer dbIdx = loadFromCache(userId);
        if (null == dbIdx) {
            // 从路由表获取
            dbIdx = loadFromRouteTable(userId);
            if (null != dbIdx) {
                // 保存到缓存
                saveRouteCache(userId, dbIdx);
            }
        }
        if (null == dbIdx) {
            // 此处可以自由实现计算库的逻辑
            dbIdx = selectRandomDbIdx();
            saveToRouteTable(userId, dbIdx);
            saveRouteCache(userId, dbIdx);
        }
 
        return new ShardCfg(dbIdx, tblIdx);
    }

该方案是通过常规的Hash算法计算表序号,而计算库序号时,则从路由表读取数据。它有个灵活的地方是如果发现某个库出现了数据倾斜可以设置权重来调整数据的分布。
另外一个好处点是后期也方便扩容。
不过却点也较明显:
访问时多了一层路由,会有一定的性能损耗。还需要考虑一个问题,这里是使用userID进行的关联,不会有特点大的空间占用,但如果是其它键比如文件的MD5摘要再使用这种方式就不太合适了。

正确方案三:剔除公因数法

如果是从N库1表升级到N库M表下,需要维护库序号不变的场景下可以考虑这种方案,这里库序号与表序号独立计算:

public static ShardCfg shard(String userId) {
        int dbIdx = Math.abs(userId.hashCode() % DB_CNT);
        // 计算表序号时先剔除掉公约数的影响
        int tblIdx = Math.abs((userId.hashCode() / TBL_CNT) % TBL_CNT);
        return new ShardCfg(dbIdx, tblIdx);
}

据之前作者说,这个方案的数据倾斜并不大。

正确方案四:一致性Hash法

设置一个配置,并将配置持久化,使用的时候进行加载
image.png

private TreeMap<Long, Integer> nodeTreeMap = new TreeMap<>();
 
@Override
public void afterPropertiesSet() {
    // 启动时加载分区配置
    List<HashCfg> cfgList = fetchCfgFromDb();
    for (HashCfg cfg : cfgList) {
        nodeTreeMap.put(cfg.endKey, cfg.nodeIdx);
    }
}
 
public ShardCfg shard(String userId) {
    int hash = userId.hashCode();
    int dbIdx = nodeTreeMap.tailMap((long) hash, false).firstEntry().getValue();
    int tblIdx = Math.abs(hash % 100);
    return new ShardCfg(dbIdx, tblIdx);
}

Range分表非常相似,Range分库分表方式针对分片键本身划分范围,而一致性Hash是针对分片键的Hash值进行范围配置。


步履不停
38 声望13 粉丝

好走的都是下坡路