> 文章列表 > 从主键生成策略深度挖掘ShardingSphere分库分表最佳实践

从主键生成策略深度挖掘ShardingSphere分库分表最佳实践

从主键生成策略深度挖掘ShardingSphere分库分表最佳实践

文章目录

    • 一、分布式主键要考虑哪些问题?
    • 二、主要的主键生成策略
    • 1、数据库策略
    • 2、应用单独生成
    • 3、第三方服务统一生成
    • 4、与第三方结合的segment策略
  • 三、定制雪花算法
    • 1、如影随形的时钟回拨问题
    • 2、用主键生成策略优化分配工作进程位
    • 3、从序列号字段定制雪花算法的连续性
    • 4、根据雪花算法扩展基因分片

从主键生成策略深度挖掘ShardingSphere分库分表最佳实践

-- 楼兰

​ 主键生成策略,这是一个小问题,但却是一个大世界。很多人开发几十年都没有去想过这个问题,觉得有框架拿来用就是了。但是其实他一点都不简单。正好ShardingSphere5.x版本新集成了一个新的主键生成框架,CosId。这里就借着了解CosId的过程,来聊一下分布式主键生成策略应该如何设计。

一、分布式主键要考虑哪些问题?

​ 主键是对数据的唯一标识。主键非常重要,尤其当需要用来控制重要数据的生命周期时,主键通常都是标识数据的关键。但是,其实主键并不只是唯一这么简单。

​ 主键除了要标识数据的唯一性之外,其实也是一个挺纠结的东西。在业务层面,我们通常会要求主键与业务不直接相关,这样主键才能够承载更多的,更负载,更频繁变化的业务数据。例如对于订单,要区分订单的唯一性,那么下单时间就是一个天然最好的标识。这里暂不考虑并发的问题。简单假设,只要时间足够精确,那么下单时间是可以保证唯一性的。如果用订单的下单时间这样带有明显业务属性的内容来当做主键,那么早期电商业务非常少的时候没有什么问题。但是随着订单业务越来越频繁,为了继续保证区分每一条订单,就会要求对下单时间的区分越来越精确。当电商逐渐演变成现代超大规模,超高并发的场景,以时间作为主键,迟早会无法满足。以其他业务字段来区分,通常也迟早会表现出受业务的制约,影响业务的演变。所以,在设计主键时,最好的方式是使用一个与业务都不相关的字段来作为主键。这样,不管业务如何变化,都可以使用主键来控制数据的生命周期。

​ 但是,另外一个方面,我们通常又会要求主键包含一部分的业务属性,这样可以加速对数据的检索。还是以订单为例,如果我们采用一个与时间完全无关的字段作为主键,当我们需要频繁的统计昨天的订单时,就只能把所有订单都查询出来,然后再按照下单时间字段进行过滤。这样很明显,效率会很低。但是如果我们能够将下单时间作为主键的一部分,例如,以下单时间作为订单的开头部分。那么,我们就可以通过主键前面的下单时间部分,快速检索出一定时间范围内的订单主键,然后再根据主键去获取这一部分订单数据就可以了。这样要查的数据少了,效率自然就能提高了。

​ 所以,对于主键,一方面,要求他与业务不直接相关。这就要求分配主键的服务要足够稳定,足够快速。不能说我辛辛苦苦把业务给弄完了,然后等着分配主键的时候,还要等半天,甚至等不到。这个要求看似简单,但其实在现在经常讨论的高并发、分布式场景下,一点都不简单。另一方面,要求他能够包含某一些业务特性。这就要求分配主键的服务能够进行一定程度的扩展。

​ 主键能够进行扩展的作用,一方面是能够用来帮助区分唯一性。例如,如果下单操作,真的出现了并发,在同一个时刻,有多个下单操作,那么可以在下单时间的基础上,增加下单客户ID的字段,共同来构成主键。这样,就算整个电商系统,在某一时刻可以产生很多订单,但是,一个客户,还是只能一个一个下订单。如果一个客户真的在某一时刻同时下多个订单,那么你就有可能要去考虑风控了。而另外一方面也可以用来帮助提高主键的安全性,让别人无法通过规律猜出主键来。比如身份证就是一个例子。要是随随便便就能猜到别人的身份证号码,那天下将是一个什么样子?

二、主要的主键生成策略

​ 接下来考虑如何生成靠谱的主键呢?常用的策略有很多,大体可以分为几类。

1、数据库策略

​ 在单数据库场景下,主键可以很简单。可以把主键扔给数据库,让他自己生成主键。比如MySQL的自增主键。

​ 优点很明显。应用层使用简单,都不用考虑主键问题了,因此不会有主键稳定性的问题。以现代数据库的设计,自增主键的性能通常也比较高。另外,也不存在并发问题。应用不管部署多少个服务,主键都不会冲突。

​ 但是坏处也同样明显。数据库自增主键不利于扩展。而且按照之前的分析,这类主键的规律太过明显,安全性也不是很高。在内部系统中使用问题不大,但是暴露在互联网环境就非常危险了。另外,在分库分表场景下,依靠数据库自增生成主键也非常不灵活。例如两台数据库服务,虽然可以定制出 让第一台数据库生成奇数序列,第二台数据库生成偶数序列 的方式让主键不冲突,但是由于每个数据库并不知道整个数据库集群的工作情况,所以如果数据库集群要扩缩容,所有的主键就都需要重新调整。

2、应用单独生成

​ 既然数据库不靠谱,那就由应用自己生成。这一类算法有很多,比如UUID、NANOID、SnowFlake雪花算法等。

​ 与数据库自增方案相比,应用自己生成主键的优点就比较明显。简单实用,比如UUID,用JDK自带的工具生成就行,而SNOWFLAKE,按他的规则自行组合就行了。另外主键很容易进行扩展。应用可以根据自己的需求随意组合生成主键。

​ 但是缺点也非常明显。首先,算法不能太复杂。太复杂的算法会消耗应用程序的计算资源和内存空间,在高并发场景下会给应用带来很大的负担。然后,并发问题很难处理。既要考虑单进程下的多线程并发安全问题,又要防止分布式场景下多进程之间的主键冲突问题,对主键生成算法的要求其实是比较高的。所以,这一类算法虽然看起来挺自由,但是可供选择的算法其实并不多。要自己设计一个即高效,又靠谱的出来,那就更难了。

​ 并且,如果与某一些具体的数据库产品结合使用,那么可能还会有一些定制化的需求。比如,如果使用我们最熟悉的MySQL数据库,通常还会要求主键能够趋势递增。因为MySQL的InnoDB引擎底层使用B+树进行数据存储,趋势递增的主键可以最大限度减少B+树的页裂变。所以,像UUID、NANOID这一类无序的字符串型主键,相比就没有SNOWFLAKE雪花算法这类趋势递增的数字型主键性能高。

3、第三方服务统一生成

​ 还一种典型的思路是借助第三方服务来生成主键。 比较典型的工具有Redis,Zookeeper,还有MongoDB。

  • Redis

使用incr指令,就可以成成严格递增的数字序列。配合lua脚本,也比较容易防并发。

  • Zookeeper

比较原生的方法是使用Zookeeper的序列化节点。Zookeeper在创建序列化节点时,会在节点名称后面增加一个严格递增的数字序列。

另一种方法,在apache提供的Zookeeper客户端Curator中,提供了DistributedAtomicInteger,DistributedAtomicLong等工具,可以用来生成分布式递增的ID。

  • MongoDB

比较原生的方法是使用MongoDB的ObjectID。MongoDB中每插入一条记录,就会给这条记录分配一个objectid。

另外还有一种方法,就是使用MongdoDB的统计功能。mongodb可以对一个表中的记录直接统计最大值。这样,就可以每次都把一个Collection中某一个属性的最大值maxValue统计出来。然后这个maxValue+1就作为返回的主键。然后再往Group中插入一个属性为maxValue+1的记录。循环往复,就可以一直获得严格递增的数字序列。这种方案其实针对其他存储也可以用,但是通常只有配合MongoDB比较高的性能,才能用得比较好。

​ 这些方案成本比较低,使用时也比较灵活。应用拿到这些ID后,还是可以自由发挥进行扩展的,因此也都是不错的主键生成工具。

​ 但是他们的缺点也很明显。这些原生的方式大都不是为了分布式主键场景而设计的,所以,如果要保证高效以及稳定,在使用这些工具时,还是需要非常谨慎。

4、与第三方结合的segment策略

​ segment策略的基本思想就是应用依然从第三方服务中获取ID,但是不是每次获取一个ID,而是每次获取一段ID。然后在本地进行ID分发。等这一段ID分发完了,再去第三方服务中获取一段。

​ 例如,以最常用的数据库为例,我们可以设计一张这样的表:

在这里插入图片描述

​ biz_tag只是表示业务,用户服务和订单服务对应的都可能是一大批集群应用。max_id表示现在整个系统中已经分配的最大ID。step表示应用每次过来申请的ID数量。

​ 然后,当第一个订单应用过来申请ID时,就将max_id往前加一个step,变成2000。就表示这2000个ID就分配给这个订单应用了。然后这个订单应用就可以在内存中随意去分配[0,2000)这些ID。而第二个订单应用过来申请ID时,获得的就是[2000,4000)这一批订单应用。这样两个订单应用的ID可以保证不会冲突。

​ 这个策略中有一个最大的问题,就是申请ID是需要消耗网络资源的,在申请资源期间,应用就无法保持高可用了。所以有一种解决方案就是双Buffer写入。

在这里插入图片描述

​ 应用既然可以接收一段ID,那就可以再准备一个Buffer,接收另一段ID。当Buffer1的ID使用了10%后,就发起线程去请求ID,放到Buffer2中。等Buffer1中的ID用完了,应用就直接从Buffer2中分配ID。然后等Buffer2用到10%,再同样缓过来。通过双Buffer的交替使用,保证在应用申请ID期间,本地的JVM缓存中一直都是有ID可以分配的。

​ 没错,这就是美团Leaf的完整方案。

​ 他的好处比较明显。ID单调递增,在一定范围内,还可以保持严格递增。通过JVM本地进行号段缓存,性能也很高。

​ 但是这种方案也有几个明显的不足之处。

1、强依赖于DB。其实你可以想象,DB中最为核心的就是max_id和step两个字段而已。这两个字段其实可以往其他存储迁移。想用那个就用哪个不是更方便?这个想法现在不需要自己动手了,CosID已经实现了。数据库、Redis、Zookeeper、MongoDB,想用哪个就用哪个。程序员又找到了一个偷懒的理由。

2、10%的阈值不太灵活。如果应用中的业务非常频繁,分配ID非常快,10%有可能不够。而如果业务非常慢,10%又有点浪费,因为申请过来的ID,如果应用一停机,就浪费掉了。所以,其实可以添加一个动态控制功能,根据分配ID的频率,灵活调整这个阈值,保持本地缓存内的ID数量基本稳定。并且,这也可以用来定制限流方案。

3、延长本地缓存。不管你用哪种服务来充当号段分配器,还是会有一个问题。如果号段分配器挂了,本地应用就只能通过本地缓存撑一段时间。这时,是不是可以考虑多缓存几个号段,延长一下支撑的时间呢?

​ CosId也想到了,直接将双Buffer升级成了SegmentChain。用一个链表的方式可以灵活缓存更多的号段。然后更新号段也不再单独依靠10%的阈值,而是单独抽象出一个角色PrefetcherWatcher,以被动定时加主动通知的方式进行号段更新。

4、ID的安全性其实是不太高的。分配的ID在同一个号段内是连续的,之前分析过,这种规律过于明显的ID其实是不太安全的。在面向互联网使用的时候,还是需要自行进行一些打散的操作。比如下面会提到一种方法,将生成的主键作为雪花算法的工作机器位,再次计算生成主键。

​ 以上这几类可以认为是比较基础的分布式主键生成工具。以这些方案为基础,就诞生了很多其他的玩法。下面分享几种典型的思路。

三、定制雪花算法

​ 雪花算法是twitter公司开源的ID生成算法。他不需要依赖外部组件,算法简单,效率也高。也是实际企业开发过程中,用得最为广泛的一种分布式主键生成策略。

​ 雪花算法的基础思想是采用一个8字节的二进制序列来生成一个主键。为什么用8个字节?因为8字节正好就是一个Long类型的变量。即保持足够的区分度,又能比较自然的与业务结合。

在这里插入图片描述

​ 可以看到,SNOWFLAKE其实还是以41个bit的时间戳为主体,放在最高位。接下来10个bit位的工作进程位,是用来标识每一台机器的。但是实现时,是留给应用自行扩展的。后面12个bit的序列号则就是一个自增的序列位。

​ 其核心思想就是将唯一值拼接成一个整体唯一值。首先从整体上来说,时间戳是一个最好的保证趋势递增的数字,所以时间戳自然是主体,放到最高位。但是如果有多个节点同时生成,那么就有可能产生相同的时间戳。怎么办?那就把机器ID给拼接上来。接下来如果在同一个进程中有多个线程同时生成,那么还是会产生相同的ID,怎么办?那就再加上一个严格递增的序列位。这样就整体保证了全局的唯一性。

​ 而在具体实现时,雪花算法实际上只是提供了一个思路,并没有提供现成的框架。比如ShardingSphere中的雪花算法就是这样生成的。

在这里插入图片描述

​ 这里面其实隐藏三个问题。

1、如影随形的时钟回拨问题

​ 雪花算法强依赖于时钟,而高精度的时钟是很难保持一致的。一方面,在分布式场景下,多个机器之间的时钟很难统一,这个倒是可以依赖于Ntpd这样的服务进行通知。但是在同一个机器上,由于时钟只能依赖内核的电信号维护,而电信号很难保持稳定,这也就造成操作系统上的时钟并不是准确的。在高并发场景下,获得的高精度时间戳,有时候会往前跳,有时候又会往回拨。一旦时钟往回拨,就有可能产生重复的ID,这就是时钟回拨问题。

​ 雪花算法其实并没有提供针对时钟回拨问题的标准解决方案,这其实也造成了一些小分歧。解决时钟回拨问题的基本思路都是应用自己记录上一次生成主键的时间戳,然后拿当前时间和上一次的时间进行比较。如果当前时间小于上一次的时间戳了,那就发生了时钟回拨。但是发现问题后怎么处理呢?ShardingSphere中默认的解决方式是让当前线程休眠一会。例如上图中waitToLerateTimeDifferenceIfNeed方法就是在处理时钟回拨问题。

在这里插入图片描述

​ 而当前版本ShardingSphere集成了COSID主键生成框架。框架中也包含了雪花算法。他发现时钟回拨,就直接抛出异常了。

在这里插入图片描述

​ 这里的重点不是想要讨论哪种处理时钟回拨问题更合理。而是可以看到,只要使用雪花算法,就埋下了时钟回拨这个雷,需要应用自行去处理。这对于一个没有标准实现的工具型算法来说,不得不说是一种遗憾。

​ 另外,这种传统的雪花算法通过本地保存时间戳的方式来判断是不是发生了时钟回拨,也只能保证本地时间戳是递增的。那么在多个服务组成的集群当中,就无法保证时间戳的统一了。虽然可以通过给每个服务配置不同的工作进程位来防止不同服务之间的主键冲突,但是,万一应用没有配呢?至少我就见过大部分的应用不会为了一个小小的雪花算法去单独考虑如何分配工作进程位。然后,当然也可以使用ntpd这样的时间同步服务把多个机器的时间同步一下,但是同样,不会有人为了一个小小的雪花算法就这么去做。

​ 有一种优化方案就是将时间戳也从本地扔到第三方服务上去,比如Zookeeper。这样多个服务就可以根据共同的时间戳往前推进,省却了时间同步的麻烦。美团的Leaf就是这么做的。但是,这样又是会给雪花算法增加强绑定,同时会降低效率。

​ 这似乎又变成了一个需要进行方案取舍、实现优化的头疼环节。就像整个分布式主键生成问题一样。

2、用主键生成策略优化分配工作进程位

​ 雪花算法中的第二个部分,工作进程位是用来区分不同机器的。他也是分布式场景下,保证ID不重复的很重要的字段。在雪花算法中,这个工作进程位是交由应用自己指定的。应用可以随意给每个服务分配一个工作进程。 这在小规模集群中是没有问题的。但是,在大规模集群中这就变得有点麻烦了。想想看,你要在一个一百台机器组成的大集群里,给每个机器分配一个不同的ID,这会是什么感觉?

​ 所以,这时有一种思路就是把这个MachineId当做一个短的主键,用其他方式来生成。而ShardingSpere新集成的CosId框架,甚至给这个MachineId,单独整理成了与主键生成策略一样的策略。抽象出了基于数据库、Redis、Zookeeper、MongoDB的一系列可插拔的组件。再加上CosID还提供了SpringBoot的starter插件,只需几个简单的配置,就可以完成MachineID的自动分配。

1、pom依赖

<dependencies><dependency><groupId>me.ahoo.cosid</groupId><artifactId>cosid-spring-boot-starter</artifactId><version>${cosid.version}</version></dependency><!-- 集成zookeeper时需要 --><dependency><groupId>me.ahoo.cosid</groupId><artifactId>cosid-zookeeper</artifactId><version>${cosid.version}</version></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter</artifactId><version>${spring.boot.version}</version></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><version>${spring.boot.version}</version></dependency><dependency><groupId>junit</groupId><artifactId>junit</artifactId><version>4.13.2</version><scope>test</scope></dependency></dependencies>

2、启动类里增加扫描cosid提供的配置类。

@SpringBootApplication
@EnableConfigurationProperties({MachineProperties.class})
@ComponentScans(value = {@ComponentScan("me.ahoo.cosid")})
public class DistIDApp {public static void main(String[] args) {SpringApplication.run(DistIDApp.class,args);}}

3、配置雪花算法主键生成器。

cosid.namespace=cosid-example-proxy
cosid.enabled=true
cosid.machine.enabled=true
#手动分配雪花算法机器位
#cosid.machine.distributor.manual.machine-id=1
#集成zookeeper分配雪花算法机器位
cosid.machine.distributor.type=zookeeper
cosid.zookeeper.enabled=true
cosid.zookeeper.connect-string=localhost:2181
# 使用雪花算法
cosid.snowflake.enabled=true
cosid.snowflake.provider.test.friendly=true
cosid.generator.enabled=true

3、应用中直接使用注入的IdGeneratorProvider分配ID。

@SpringBootTest
@RunWith(SpringRunner.class)
public class DistIDTest {//通过统一的ID分配器获取@Resourceprivate IdGeneratorProvider provider;//单独使用雪花算法分配。@Resourceprivate SnowflakeId snowflakeId;@Testpublic void getId(){System.out.println(provider.getShare().generate());System.out.println(snowflakeId.generate());}
}

3、从序列号字段定制雪花算法的连续性

​ 雪花算法生成的ID是不连续的,这很容易理解。但是很多时候,在进行分库分表时,我们还是希望雪花算法生成的ID能够保持某一种规律,这样在定制分库分表算法时,才可以比较好的定制数据分片算法。但是,很可惜,雪花算法给你埋着坑呢。

​ 我们可以做个小实验。例如,如果我想要将一个表的数据分到两个库中的两个表,共四个分片。这应该是分库分表中最为典型的一个场景了。

在这里插入图片描述

​ Course课程信息按照cid字段进行分片,那么分库的算法可以简单设置为按cid奇偶拆分,定制算法mKaTeX parse error: Expected '}', got 'EOF' at end of input: …拆分,算法定制为course_->{cid%2+1}。这个时候,所有的Course课程记录,实际上只能分配到m0.course_1和m2.course_2两个分片表中。这并不是我们期待的结果啊。我们是希望把数据分到四张表里。这时候怎么办?一种很自然的想法是调整分表的算法,让他按照4去轮询,定制分片算法 course_$->{((cid+1)%4).intdiv(2)+1}。 这样简单看起来是没有问题的。如果ID是连续递增的,那么这个算法就可以将数据均匀的分到四个分片中。

在这里插入图片描述

​ 但是,如果你用雪花算法的生成的一系列结果去实际尝试一下。会发现很奇怪的现象,库对2取模,很均匀的分配到了两个片。但是表是对4取模的,也均匀的分到了两个片。用这样的算法去实际分库分表,当然只能分到两个片。

在这里插入图片描述

​ 这是为什么呢?简单理解,就是雪花算法生成的结果不连续呗。但是为什么会体现出奇偶分配很均匀的情况呢?这就要深入到雪花算法的实现里了。

​ 因为雪花算法虽然最后规定了一个序列位,只有在lastMilliseconds == currentMilliseconds时才往上加1,否则就重置为-1。简单理解,就是只有在时间戳相同的时候,序列号才往上加1.如果时间戳不同,序列号就会从0开始往上叠加。再加上只有在lastMilliseconds == currentMilliseconds时,才会将currentMilliseconds推进到下一个时间点。这时,在单线程情况下,雪花算法生成的一系列ID的序列位就是有规律的0,1,0,1。

​ 是不是这样呢?验证一下就知道了。

在这里插入图片描述

​ 搞清楚问题后,解决的方案比较简单了。在这个场景下,最简单的一种修改方式,就是让sequence每次都加1呗。不管时间戳相不相同,都加1。然后只要不超过12bit的范围,这样对2取模,对4取模这些不就都能均匀分布了吗?

​ 例如简单的将雪花算法的生成逻辑做一下调整。

@Overridepublic synchronized Long generateKey() {long currentMilliseconds = timeService.getCurrentMillis();if (waitTolerateTimeDifferenceIfNeed(currentMilliseconds)) {currentMilliseconds = timeService.getCurrentMillis();}if (lastMilliseconds == currentMilliseconds) {
//            if (0L == (sequence = (sequence + 1) & SEQUENCE_MASK)) {currentMilliseconds = waitUntilNextTime(currentMilliseconds);
//            }} else {vibrateSequenceOffset();
//            sequence = sequenceOffset;//让sequence单调递增sequence = sequence >= SEQUENCE_MASK ? 0:sequence+1;//sequence = SEQUENCE_MASK==sequence&SEQUENCE_MASK ? 0 : sequence +1;}lastMilliseconds = currentMilliseconds;return ((currentMilliseconds - EPOCH) << TIMESTAMP_LEFT_SHIFT_BITS) | (getWorkerId() << WORKER_ID_LEFT_SHIFT_BITS) | sequence;}

​ 这样生成出来的雪花算法,就能保证序列号位基本上是连续的。数据也能正常分到四个分片了。

在这里插入图片描述

​ 最后,CosId的算法已经通过ShardingSphere的SPI扩展机制集成到了ShardingSphere当中,所以,自然也可以用ShardingSphere的SPI扩展机制做一些小小的微调。

4、根据雪花算法扩展基因分片法

​ 业务场景:用户表,是应用当中最为常见的一张表。如果要对用户表进行分库分表。那么很简单的就会按照userId进行取模分片,这时自然后续的查询也需要按照userId来查,才能比较高效的定位到某一个数据分片。否则的话,就要走全路由。如果分片数量比较多,这样全路由查询的性能消耗,几乎是不可接受的。

​ 现在问题来了。对于用户登录的场景,应该要怎么支持呢?在用户登录场景,只会传过来用户名,你甚至都不知道这个用户名是否存在。这样自然就无法定位在哪个分片了。这怎么查呢?难道就只能全路由查?去几十上百个分片表里都查一次?想想就觉得恐怖。

​ 这时可以用一种业界称为基因法的分片算法来解决这个问题。他的基础思想有点类似于雪花算法的序列部分。基础思想是在给用户分配userId时,就将用户名当中的某种序列信息插入到userId当中。从而保证userId和用户名可以按照某一种对应的规则分到同一个分片上。这样,在用户登录时,就可以根据用户名确定对应的用户信息只有可能分布在某一个数据分片当中。这样就只要去对应的分片上进行一次查询,就能查询到用户对应的信息。

在这里插入图片描述

​ 具体在实现时,可以参照雪花算法的实现。

​ 例如,在用户注册时,用户输入了一个username。然后,从主键分发器获得了一个原始的userid。

		//原始预备生成的用户名String username = "testroy";System.out.println("原始预备插入的用户名:"+username);//原始预备生成的唯一IDlong originId = 12394846L;System.out.println("原始预备插入的用户ID:"+originId);

​ 先确定一个MASK的长度,也就是工作序列号位用二进制表示的长度。例如确定为3。然后从用户名当中抽取出一个用三个bit表示的分片关键字,称为分片基因。如果用户名是一个数字,那么就直接按照雪花算法的处理方式就行了。如果是个字符串,那么就进行一次hash运算,转成一个数字。也就是创建一个用2进制表示全为1的3个bit组成的数组,然后与目标列进行相位与操作,这样就能够拿到对应数字的二进制表达的后三位。这个就称为分片基因。

		public static final int datasize = 3; //二进制基因片段的长度int mask = (int)(Math.pow(2,datasize) -1);//掩码,二进制表述为全部是1.  111long userGene = username.hashCode() & mask;//根据用户名查询时,获取到的分片结果:System.out.println("根据用户名获取到的分片基因:"+userGene);

​ 然后将这个基因片段与原始ID,按照二进制的方式组合在一起,生成一个包含了username的基因片段的新的userid。这个新的userId按照算法的话,一定是会与username保持一样的。

		//给ID添加用户名的基因片段后的新IDlong newId = (originId<<datasize)|userGene;System.out.println("添加分片基因后的用户ID2:"+newId);long newIdGene = newId & mask;System.out.println("根据用户ID2获取到的分片结果:"+newIdGene);

​ 未来进行取模分片时,只要是按照2,4,8这样的数字取模,那么这个userGene和newIdGene的分片结果一定是一样的。

        //按照新的用户ID对8取模进行数据分片long actualNode = newId % 8;System.out.println("用户信息实际保存的分片:"+actualNode);long userNode = (username.hashCode() & mask) % 8;System.out.println("根据用户名判断,用户信息可能的分片:"+userNode);

​ 整个示例整个到一起是这样的

public class GeneDemo2 {//二进制基因片段的长度public static final int datasize = 3;public static void main(String[] args) {//原始预备生成的用户名String username = "testroy";System.out.println("原始预备插入的用户名:"+username);//原始预备生成的唯一IDlong originId = 12394846L;System.out.println("原始预备插入的用户ID:"+originId);int mask = (int)(Math.pow(2,datasize) -1);//掩码,二进制表述为全部是1.  111long userGene = username.hashCode() & mask;//根据用户名查询时,获取到的分片结果:System.out.println("根据用户名获取到的分片基因:"+userGene);//给ID添加用户名的基因片段后的新ID  -- 将username.hashCode二进制左移三位,再添加用户名的分片结果。 这样保持了原始ID的唯一性。long newId = (originId<<datasize)|userGene;System.out.println("添加分片基因后的用户ID:"+newId);long newIdGene = newId & mask;System.out.println("新用户ID的分片基因"+newIdGene);//按照新的用户ID对8取模进行数据分片long actualNode = newId % 8;System.out.println("用户信息实际保存的分片:"+actualNode);long userNode = (username.hashCode() & mask) % 8;System.out.println("根据用户名判断,用户信息可能的分片:"+userNode);}
}

​ 执行结果是这样的:

原始预备插入的用户名:testroy
原始预备插入的用户ID:12394846
根据用户名获取到的分片基因:2
添加分片基因后的用户ID:99158770
新用户ID的分片基因2
用户信息实际保存的分片:2
根据用户名判断,用户信息可能的分片:2

​ 最后,整个基因法分片的方案就是这样设计:

​ 1、用户注册时,先从主键生成器获取一个唯一的用户ID。这是原始ID。

​ 2、按照上面示例的方式,从用户注册时输入的用户名中抽取分片基因。并将分片基因插入到原始ID中,生成一个新的用户ID。

​ 3、根据新的用户ID,将用户数据按照 2或4或8 的数量进行分片存储,保存到数据库中。这样保证了根据用户名和新的用户ID都是可以拿到相同的分片结果的,也就是数据实际存储的分片。

​ 4、用户登录时,根据用户输入的用户名,获取分片基因,并对数据分片数取模,这样就能获得这个用户名可能存在的用户分片。如果用户信息存在,就只可能保存在这一个分片里,不可能在其他分片。这样就只要到这一个用户分片上进行查询,就能获得用户的信息了。如果查不到,那就证明用户输入的用户名不存在,也不用去其他分片上确认了。

基因分片法总结

​ 从实现中也能看到,基因分片法还是有很多限制的

​ 1、分片数量和基因位数强绑定。 比如基因片段长度如果设置为3,那么数据的分片数就只能是2或4或8。当然,这其实也有一个好处,那就是如果数据集群从4扩展到8,那么用户数据的迁移量是最少的。只要迁移一半数据。如果基因片段设置更长,也意味着更大的扩展空间。

​ 2、基因分片法只能在主键中插入一个基因片段。如果还想要按照其他字段查询,就无法做到了。比如在登录场景,也有可能要根据用户的手机号码查询,这时,基因法就没有用了。这时,通常的解决方案就只有在插入用户信息的时候,单独维护一个从手机号码到所在分片的倒排索引。这个思想比较简单,但是具体实现时的麻烦会比较多。

​ 写在最后,你还觉得分库分表只是一个简单的框架就能解决的问题吗?