【注1】本文中的案例均基于Mysql数据库,下文中的分库分表统指水平分库分表。 【注2】后文中提到到M库N表,均指共M个数据库,每个数据库共N个分表,即总表个数其实为M*N。
a、某个数据库实例中,部分表的数据很多,而其他表中的数据却寥寥无几,业务上的表现经常是延迟忽高忽低,飘忽不定。 b、数据库集群中,部分集群的磁盘使用增长特别块,而部分集群的磁盘增长却很缓慢。每个库的增长步调不一致,这种情况会给后续的扩容带来步调不一致,无法统一操作的问题。这边我们定义分库分表最大数据偏斜率为 :(数据量最大样本 - 数据量最小样本)/ 数据量最小样本。一般来说,如果我们的最大数据偏斜率在5%以内是可以接受的。
/**
* 通过年份分表
*
* @param orderId
* @return
*/
public static String rangeShardByYear(String orderId) {
int year = Integer.parseInt(orderId.substring(0, 4));
return "t_order_" + year;
}
通过数据的范围进行分库分表,该方案是最朴实的一种分库方案,它也可以和其他分库分表方案灵活结合使用。时下非常流行的分布式数据库:TiDB数据库,针对TiKV中数据的打散,也是基于Range的方式进行,将不同范围内的[StartKey,EndKey)分配到不同的Region上。
下面我们看看该方案的缺点:
这点非常容易被遗忘,尤其是稳定跑了几年没有迭代任务,或者人员又交替频繁的模块。
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表永远为空!
类似的我们还能推导到,0库里面的共100张表,只有10张表中(个位数为0的表序号)才可能有数据。这就带来了非常严重的数据偏斜问题,因为某些表中永远不可能有数据,最大数据偏斜率达到了无穷大。
那么很明显,该方案是一个未达到预期效果的错误方案。数据的散落情况大致示意图如下:
事实上,只要库数量和表数量非互质关系,都会出现某些表中无数据的问题。
证明如下:
那么是不是只要库数量和表数量互质就可用用这种分库分表方案呢?比如我用11库100表的方案,是不是就合理了呢?
答案是否定的,我们除了要考虑数据偏斜的问题,还需要考虑可持续性扩容的问题,一般这种Hash分库分表的方案后期的扩容方式都是通过翻倍扩容法,那11库翻倍后,和100又不再互质。
当然,如果分库数和分表数不仅互质,而且分表数为奇数(例如10库101表),则理论上可以使用该方案,但是我想大部分人可能都会觉得使用奇数的分表数比较奇怪吧。
常见错误案例二:扩容难以持续
如果避开了上述案例一的陷阱,那么我们又很容易一头扎进另一个陷阱,大概思路如下;
我们把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);
}
该方案确实很巧妙的解决了数据偏斜的问题,只要Hash值足够均匀,那么理论上分配序号也会足够平均,于是每个库和表中的数据量也能保持较均衡的状态。
但是该方案有个比较大的问题,那就是在计算表序号的时候,依赖了总库的数量,那么后续翻倍扩容法进行扩容时,会出现扩容前后数据不在同一个表中,从而无法实施。
如上图中,例如扩容前Hash为1986的数据应该存放在6库98表,但是翻倍扩容成20库100表后,它分配到了6库99表,表序号发生了偏移。这样的话,我们在后续在扩容的时候,不仅要基于库迁移数据,还要基于表迁移数据,非常麻烦且易错。
看完了上面的几种典型的错误案例,那么我们有哪些比较正确的方案呢?下面将结合一些实际场景案例介绍几种Hash分库分表的方案。
常用姿势一:标准的二次分片法
上述错误案例二中,整体思路完全正确,只是最后计算库序号和表序号的时候,使用了库数量作为影响表序号的因子,导致扩容时表序号偏移而无法进行。
事实上,我们只需要换种写法,就能得出一个比较大众化的分库分表方案。
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);
}
大家可以注意到,和错误案例二中的区别就是通过分配序号重新计算库序号和表序号的逻辑发生了变化。它的分配情况如下:
那为何使用这种方案就能够有很好的扩展持久性呢?我们进行一个简短的证明:
通过上面结论我们知道,通过翻倍扩容后,我们的表序号一定维持不变,库序号可能还是在原来库,也可能平移到了新库中(原库序号加上原分库数),完全符合我们需要的扩容持久性方案。
【方案缺点】
1、翻倍扩容法前期操作性高,但是后续如果分库数已经是大几十的时候,每次扩容都非常耗费资源。
2、连续的分片键Hash值大概率会散落在相同的库中,某些业务可能容易存在库热点(例如新生成的用户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算法计算表序号,而计算库序号时,则从路由表读取数据。因为在每次数据查询时,都需要读取路由表,故我们需要将分片键和库序号的对应关系记录同时维护在缓存中以提升性能。
上述实例中selectRandomDbIdx方法作用为生成该分片键对应的存储库序号,这边可以非常灵活的动态配置。例如可以为每个库指定一个权重,权重大的被选中的概率更高,权重配置成0则可以将关闭某些库的分配。当发现数据存在偏斜时,也可以调整权重使得各个库的使用量调整趋向接近。
该方案还有个优点,就是理论上后续进行扩容的时候,仅需要挂载上新的数据库节点,将权重配置成较大值即可,无需进行任何的数据迁移即可完成。
如下图所示:最开始我们为4个数据库分配了相同的权重,理论上落在每个库的数据概率均等。但是由于用户也有高频低频之分,可能某些库的数据增长会比较快。当挂载新的数据库节点后,我们灵活的调整了每个库的新权重。
该方案似乎解决了很多问题,那么它有没有什么不适合的场景呢?当然有,该方案在很多场景下其实并不太适合,以下举例说明。
a、每次读取数据需要访问路由表,虽然使用了缓存,但是还是有一定的性能损耗。
b、路由关系表的存储方面,有些场景并不合适。例如上述案例中用户id的规模大概是在10亿以内,我们用单库百表存储该关系表即可。但如果例如要用文件MD5摘要值作为分片键,因为样本集过大,无法为每个md5值都去指定关系(当然我们也可以使用md5前N位来存储关系)。
c、饥饿占位问题,如下详叙:
我们知道,该方案的特点是后续无需扩容,可以随时修改权重调整每个库的存储增长速度。但是这个愿景是比较缥缈,并且很难实施的,我们选取一个简单的业务场景考虑以下几个问题。
【业务场景】:以用户存放文件到云端的云盘业务为例,需要对用户的文件信息进行分库分表设计,有以下假定场景:
public static ShardCfg shard(String userId) {
int dbIdx = Math.abs(userId.substring(0, 4).hashCode() % DB_CNT );
int tblIdx = Math.abs(userId.hashCode() % TBL_CNT);
return new ShardCfg(dbIdx, tblIdx);
}
如上所示,我们计算库序号的时候做了部分改动,我们使用分片键的前四位作为Hash值来计算库序号。
这也是一种常用的方案,我们称为基因法,即使用原分片键中的某些基因(例如前四位)作为库的计算因子,而使用另外一些基因作为表的计算因子。该方案也是网上不少的实践方案或者是其变种,看起来非常巧妙的解决了问题,然而在实际生成过程中还是需要慎重。
笔者曾在云盘的空间模块的分库分表实践中采用了该方案,使用16库100表拆分数据,上线初期数据正常。然而当数据量级增长起来后,发现每个库的用户数量严重不均等,故猜测该方案存在一定的数据偏斜。
为了验证观点,进行如下测试,随机2亿个用户id(16位的随机字符串),针对不同的M库N表方案,重复若干次后求平均值得到结论如下:
8库100表
min=248305(dbIdx=2, tblIdx=64), max=251419(dbIdx=7, tblIdx=8), rate= 1.25% √
16库100表
min=95560(dbIdx=8, tblIdx=42), max=154476(dbIdx=0, tblIdx=87), rate= 61.65% ×
20库100表
min=98351(dbIdx=14, tblIdx=78), max=101228(dbIdx=6, tblIdx=71), rate= 2.93%
我们发现该方案中,分库数为16,分表数为100,数量最小行数仅为10W不到,但是最多的已经达到了15W+,最大数据偏斜率高达61%。按这个趋势发展下去,后期很可能出现一台数据库容量已经使用满,而另一台还剩下30%+的容量。
该方案并不是一定不行,而是我们在采用的时候,要综合分片键的样本规则,选取的分片键前缀位数,库数量,表数量,四个变量对最终的偏斜率都有影响。
例如上述例子中,如果不是16库100表,而是8库100表,或者20库100表,数据偏斜率都能降低到了5%以下的可接受范围。所以该方案的隐藏的"坑"较多,我们不仅要估算上线初期的偏斜率,还需要测算若干次翻倍扩容后的数据偏斜率。
例如你用着初期比较完美的8库100表的方案,后期扩容成16库100表的时候,麻烦就接踵而至。
常用姿势四:剔除公因数法
还是基于错误案例一启发,在很多场景下我们还是希望相邻的Hash能分到不同的库中。就像N库单表的时候,我们计算库序号一般直接用Hash值对库数量取余。
那么我们是不是可以有办法去除掉公因数的影响呢?下面为一个可以考虑的实现案例:
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);
}
经过测算,该方案的最大数据偏斜度也比较小,针对不少业务从N库1表升级到N库M表下,需要维护库序号不变的场景下可以考虑。
常用姿势五:一致性Hash法
一致性Hash算法也是一种比较流行的集群数据分区算法,比如RedisCluster即是通过一致性Hash算法,使用16384个虚拟槽节点进行每个分片数据的管理。关于一致性Hash的具体原理这边不再重复描述,读者可以自行翻阅资料。
这边详细介绍如何使用一致性Hash进行分库分表的设计。
我们通常会将每个实际节点的配置持久化在一个配置项或者是数据库中,应用启动时或者是进行切换操作的时候会去加载配置。配置一般包括一个[StartKey,Endkey)的左闭右开区间和一个数据库节点信息,例如:
示例代码:
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值进行范围配置。
正规的一致性Hash算法会引入虚拟节点,每个虚拟节点会指向一个真实的物理节点。这样设计方案主要是能够在加入新节点后的时候,可以有方案保证每个节点迁移的数据量级和迁移后每个节点的压力保持几乎均等。
但是用在分库分表上,一般大部分都只用实际节点,引入虚拟节点的案例不多,主要有以下原因:
a、应用程序需要花费额外的耗时和内存来加载虚拟节点的配置信息。如果虚拟节点较多,内存的占用也会有些不太乐观。 b、由于mysql有非常完善的主从复制方案,与其通过从各个虚拟节点中筛选需要迁移的范围数据进行迁移,不如通过从库升级方式处理后再删除冗余数据简单可控。 c、虚拟节点主要解决的痛点是节点数据搬迁过程中各个节点的负载不均衡问题,通过虚拟节点打散到各个节点中均摊压力进行处理。而作为OLTP数据库,我们很少需要突然将某个数据库下线,新增节点后一般也不会从0开始从其他节点搬迁数据,而是前置准备好大部分数据的方式,故一般来说没有必要引入虚拟节点来增加复杂度。
具体的流程大致如下:
①、时间点t1:为每个节点都新增从库,开启主从同步进行数据同步。
②、时间点t2:主从同步完成后,对主库进行禁写。
此处禁写主要是为了保证数据的正确性。若不进行禁写操作,在以下两个时间窗口期内将出现数据不一致的问题: a、断开主从后,若主库不禁写,主库若还有数据写入,这部分数据将无法同步到从库中。 b、应用集群识别到分库数翻倍的时间点无法严格一致,在某个时间点可能两台应用使用不同的分库数,运算到不同的库序号,导致错误写入。③、时间点t3:同步完全完成后,断开主从关系,理论上此时从库和主库有着完全一样的数据集。 ④、时间点t4:从库升级为集群节点,业务应用识别到新的分库数后,将应用新的路由算法。
一般情况下,我们将分库数的配置放到配置中心中,当上述三个步骤完成后,我们修改分库数进行翻倍,应用生效后,应用服务将使用新的配置。这里需要注意的是,业务应用接收到新的配置的时间点不一定一致,所以必定存在一个时间窗口期,该期间部分机器使用原分库数,部分节点使用新分库数。这也正是我们的禁写操作一定要在此步完成后才能放开的原因。⑤、时间点t5:确定所有的应用均接受到库总数的配置后,放开原主库的禁写操作,此时应用完全恢复服务。 ⑥、启动离线的定时任务,清除各库中的约一半冗余数据。
为了节省磁盘的使用率,我们可以选择离线定时任务清除冗余的数据。也可以在业务初期表结构设计的时候,将索引键的Hash值存为一个字段。那么以上述常用姿势四为例,我们离线的清除任务可以简单的通过sql即可实现(需要防止锁住全表,可以拆分成若干个id范围的子sql执行): delete from db0.tbl0 where hash_val mod 4 <> 0; delete from db1.tbl0 where hash_val mod 4 <> 1; delete from db2.tbl0 where hash_val mod 4 <> 2; delete from db3.tbl0 where hash_val mod 4 <> 3;具体的扩容步骤可参考下图:
总结:通过上述迁移方案可以看出,从时间点t2到t5时间窗口呢内,需要对数据库禁写,相当于是该时间范围内服务器是部分有损的,该阶段整体耗时差不多是在分钟级范围内。若业务可以接受,可以在业务低峰期进行该操作。
当然也会有不少应用无法容忍分钟级写入不可用,例如写操作远远大于读操作的应用,此时可以结合canel开源框架进行窗口期内数据双写操作以保证数据的一致性。
该方案主要借助于mysql强大完善的主从同步机制,能在事前提前准备好新的节点中大部分需要的数据,节省大量的人为数据迁移操作。
但是缺点也很明显,一是过程中整个服务可能需要以有损为代价,二是每次扩容均需要对库数量进行翻倍,会提前浪费不少的数据库资源。
主要步骤如下:
①、时间点t1:针对需要扩容的数据库节点增加从节点,开启主从同步进行数据同步。
②、时间点t2:完成主从同步后,对原主库进行禁写。
此处原因和翻倍扩容法类似,需要保证新的从库和原来主库中数据的一致性。③、时间点t3:同步完全完成后,断开主从关系,理论上此时从库和主库有着完全一样的数据集。 ④、时间点t4:修改一致性Hash范围的配置,并使应用服务重新读取并生效。 ⑤、时间点t5:确定所有的应用均接受到新的一致性Hash范围配置后,放开原主库的禁写操作,此时应用完全恢复服务。 ⑥、启动离线的定时任务,清除冗余数据。 可以看到,该方案和翻倍扩容法的方案比较类似,但是它更加灵活,可以根据当前集群每个节点的压力情况选择性扩容,而无需整个集群同时翻倍进行扩容。
作者:vivo平台产品开发团队-Han Lei
我有一个RubyonRails应用程序和一个具有以下结构的PostgreSQL数据库:classA只有几个A,而且增长缓慢(比如一个月5个)。每个A有数千个B,每个B有数万个C(因此每个A有数百万个C)。A是独立的,并且永远不会同时需要来自不同A的B和C(即在同一查询中)。我的问题是现在我只有几个A,ActiveRecord查询需要很长时间。当C的表有数千万行时,查询将永远无法进行。我正在考虑水平扩展数据库(即A的一张表,B的一张表和每个A的一张C的表)。但我不知道该怎么做。我猜这是一种分片,但我无法弄清楚如何动态创建数据库表并使用ActiveRecord访问数据(如果该表取决于我正在
有没有一种简单的方法可以获取集合中某个属性的平均值?例如,每个用户都有一个分数。给定一组用户(@users),您如何获得该组的平均分?有没有类似@users.average(:score)的东西?我想我在数据库字段中遇到过类似的东西,但我需要它来处理集合...... 最佳答案 对于你的问题,实际上可以这样做:@users.collect(&:score).sum.to_f/@users.lengthif@users.length>0早些时候我认为,@users.collect(&:score).average会起作用。对于数据库字段
2021年,游戏圈上演了一场精彩绝伦的抢人大战。在上海游戏圈,年薪百万的人越来越多了。据多名HR估算,在上海,过去一年TA、引擎、美术等稀缺岗位拟的薪资涨幅大概在20%-30%左右。某位圈内知名资深游戏猎头对此发出感叹:“50K的数值策划、角色原画;70K的技术美术;80K的技术总监...他们的年薪总包都接近百万,就连应届生入行的薪资也水涨船高,这要是放在以往都是不敢想象的”。以往含年薪、期权等的年总包收入上百万元,起码得是总监级别。如今工作五六年的人从广深跳到上海游戏公司,年薪能从50-70万跃上100万元,拿百万年薪的游戏从业者越来越多了上海游戏圈近年发展迅速,既有颇具发展潜力的中生代F4
我正在尝试用D3(https://cdnjs.cloudflare.com/ajax/libs/d3/3.5.17/d3.min.js)做一个SVG图形,其中条形宽度是手动定义的,并且有一个水平滚动条..我这里有一个工作fiddlehttps://jsfiddle.net/bikrantsharma/zw264tfc/12/我的比例是这样定义的varbarWidth=30,paddingFactor=2.2,responsiveDIVHeight=300,responsiveDIVWidth=tempData.length*barWidth*paddingFactor;varx=d3.
我一直在尝试使用d3.js为我的图表创建水平图例。我一直在努力使动态标签的x轴间距正确。问题是标签的宽度不一致,这里是fullexample这是我计算x位置的函数:functionlegendXPosition(data,position,avgFontWidth){if(position==0){return0;}else{varxPostiion=0;for(i=0;i有人对如何改进这个有什么建议吗? 最佳答案 我建议引用这个问题:SVGgettextelementwidth照原样渲染第一个图例条目。存储此条目,或分配ID,以便
我有一个看起来像这样的菜单:|Home|Options|Settings|Tools|Preferences|Edit|当手机有很多水平空间时这很好,但是当具有窄视口(viewport)的设备访问页面时,我希望菜单看起来像|Home|Options|Settings|+MORE+|点击“更多”菜单会在垂直下拉菜单中显示其他元素。我不想设置手动断点,因为我不知道各个菜单项在显示时会有多宽。我的菜单目前只有一组在水平布局的CSS是#menuul,#menuli{margin:0;padding:0;list-style:none;}#menuul{overflow:auto;}#menul
谁能告诉我如何实现如下图所示的效果。如何在选定的日期中选择特定的时间范围。请注意,选择跨越多天。这是多天的水平选择(而不是连续的选择)。我正在使用fullCalendarjquery库。 最佳答案 因此,除非您想大量修改FC,否则您需要做的事情不太可能。但是你可以做一些非常接近的事情:JSFiddle基本算法开启selectHelper这样FC就会尝试将其呈现为事件。在eventRender,停止帮助程序实际渲染。取而代之的是,将其开始日期和结束日期分成每天一个事件。呈现分块事件。分块和渲染//Chunksamultidayeven
我想使用Split.js用于创建我的特定布局,例如Split(['#a','#b','#c'],{gutterSize:20,cursor:'pointer'});Split(['#d','#e','#f'],{direction:'vertical',sizes:[15,70,15],gutterSize:20,cursor:'row-resize'});但上面的代码不起作用。谁能帮帮我?我如何使用Split.js创建布局,因为在他们的站点中不存在任何类似的示例?ClassicsampleinJSFiddleMySampleinJSFiddle 最佳答案
我有一个网页包含垂直和水平列出的100条数据。我有一个滚动条的CSS代码如下:.well{overflow:auto;}用户总是需要向下滚动才能找到水平滚动条。有没有办法阻止它,这样用户就不需要一直向下找到他的滚动条? 最佳答案 如果您的数据位于div中的block元素中,并且您希望用户不需要一直向下找到他的滚动条,那么给它超链接“href="divid"。当用户登陆该页面时来自任何链接http://exampl/#divid它将自动登陆到该div。 关于javascript-如何在不进
所以我一直在搞乱这个页面:https://tutorialzine.github.io/pwa-photobooth/基本上它的作用是激活您的网络摄像头并让您直接从流中拍摄快照,我为我的网络借用了它,但视频流被翻转了,我想镜像视频流以便感觉更好。注意:我是一个js新手,所以欢迎详细解释。这是代码,您可能必须使用Firefox而不是Chrome:$('.closecam').click(function(){$('.webcam__overlay').hide();}); $('.camera').click(function(){$('.webcam__overlay').show()