分库分表的框架如何设计自动路由

ShardingCore

ShardingCore 易用、简单、高性能、普适性,是一款扩展针对efcore生态下的分表分库的扩展解决方案,支持efcore2+的所有版本,支持efcore2+的所有数据库、支持自定义路由、动态路由、高性能分页、读写分离的一款组件,如果你喜欢这组件或者这个组件对你有帮助请点击下发star让更多的.neter可以看到使用



目前ShardingCore已经支持.net 6.0,因为ShardingCore的整个项目架构仅依赖efcore和efcore.relational基本上可以说是"零依赖"其他解析都是自行实现不依赖三方框架。

众所周知.net框架下没有一款好的自动分表分库组件(ShardingCore除外),基本上看了一圈分表分库的框架都说支持都说可以自动分表分库,看了代码都是半自动甚至全手动,那么今天我就来讲讲应该如何设计一个真·自动分表/分库的框架。(别说什么封装一下好了,那你倒是封装啊)

那么如何设计才可以让用户的查询可以无感知路由对应的分表分库呢,而不是满屏的指定查询哪些表,指定路由可以有,但不应该作为查询,真正的自动分表分库路由应该是通过where条件进行过滤然后将数据路由到对应的表,接下来我将用简单易懂的方式来讲解如何设计一个字段路由的框架来实现分表自动化。

现状

目前看来好像.NET环境下真的没有几个人做到了这点,稍微好一点的也就是碰到了真自动分表的脚,连膝盖都没打到。所以打算开一篇博客来讲讲,顺便讲讲ShardingCore 的原理.

分表的定义

首先因为业务的不同所以大部分人设计的分表可能都有写区别,但是因为基本的部分情况下大致都是相同的,这个相同比如取模,那么肯定是00,01....99或者时间那么肯定是2020,2021....2030等等都是相似的。

简单的取模分表

那么我们现在假设我们是按基数取模比如按5那么我们可以取出设置订单表为order_00,order_01,order_02,order_03,order_04我们将订单表分成4张表。

分表名称 分表字段 分表方式 所有表后缀
order Id 模4且左补齐2位 '00','01','02','03','04'

我们现在定义我们的查询条件 select * from order where Id='12345',通过条件我们可以解析出有用的信息有哪些

select * from order where Id='12345'

route parse engine=parse=>得到如下结果

Key Value
表名 order
字段 Id
条件判断符 =
条件 '12345'
条件连接符

所以我们可以通过得知字段id和字符串“12345”进行等于符号的比较,所以我们可以先对“12345”进行hash取值比如“12345”.HashCode()等于9,那么9%5=4,我们对4往左补‘0’得到结果“04”,所以我们可以得出结论:

select * from order where Id='12345' ==select * from order_04 where Id='12345'

目前为止一个简单的而取模分表路由我们已经知道大致的流程了,得出如下结论

  1. order表是否是分表的
    2.where后的Id是否是分表字段
    3.分表字段进行条件过滤可否转成表后缀

复杂一点的取模分表

总所周知取模分表的好处是可以最大化数据均匀,且相对实现简单,但是也有很多问题,比如后期迁移数据扩大表的时候为了最小化迁移数据必须成倍增加表,但是哪怕成倍增加了最小迁移量也是50%。
当然这只是取模分表的一个优缺点并不是本次的重点。接下来我们将sql改写一下 select * from order where Id='12345' or Id='54321'通过这次转变我们可以获取到哪些信息呢

Key Value
表名 order
字段 Id
条件判断符 =
条件 '12345' 和 '54321'
条件连接符 or

那么这种情况下我们该如何进行分表路由呢,首先我们可以通过得知字段id和字符串“12345”进行等于符号的比较,所以我们可以先对“12345”进行hash取值比如“12345”.HashCode()等于9,那么9%5=4,我们对4往左补‘0’得到结果“04”,然后我们可以通过字段id和字符串“54321”进行等于符号的比较,所以我们可以先对“54321”进行hash取值比如“54321”.HashCode()等于8,那么8%5=3,我们对3往左补‘0’得到结果“03”又因为条件连接符号是or所以我们要的是['03','04']所以 select * from order where Id='12345' or Id='54321'会被改写成 select * from order_03 where Id='12345' or Id='54321' + select * from order_04 where Id='12345' or Id='54321'两条sql的聚合结果,
如果是and的情况下就是既要走order_03又要走order_04所以结果就是空,那么我们可以得出如下结论

  1. order表是否是分表的
    2.where后的Id是否是分表字段
    3.分表字段进行条件过滤可否转成表后缀
    3.多个表后缀如何筛选

再将分表升级一下按时间

假设我们现在的订单是按月有 order_202105,order_202106,order_202107,order_202108,order_202109假设目前我们是这个5张表,订单通过字段time进行时间分表,

我们如果需要解析select * from order where time>'2021/06/05 00:00:00',首先我们还是通过程序进行解析提取关键字

Key Value
表名 order
字段 time
条件判断符 >
条件 '2021/06/05 00:00:00'
条件连接符

通过关键字提取解析我们可以知道应该是查询order_202106,order_202107,order_202108,order_2021093张表

让我们再次升级一点

我们如果需要解析select * from order where time>'2021/06/05 00:00:00' and time <'2021/08/05 00:00:00',首先我们还是通过程序进行解析提取关键字

Key Value
表名 order
字段 time
条件判断符 >、<
条件 '2021/06/05 00:00:00'、'2021/08/05 00:00:00'
条件连接符 and

我们在对现有的sql进行一下改造

select * from order where Id='12345' 改写成 select * from order where '12345' =Id
遇到这种情况下我们该如何对现有的表达式进行判断呢,这边肯定是需要用到一个转换就是:condition on right (条件在右)
那么我们遇到的=其实和实际没有区别,但是>,<如果相反会对结果有影响所以我们需要将对应的表达式进行反转,所以

condtion on right ?
= = =
!= != !=
>= >= <=
> > <
<= <= >=
< < >

如果条件在右侧那么我们不需要对条件判断符进行转换,如果不在右边那么就需要转换成对应的条件判断符来简化我们编写路有时候的逻辑判断

通过关键字提取解析我们可以知道应该是查询order_202106,order_202107,order_2021082张表

经过上述描述我们可以大致设计出一个构思,如何才能设计出一个分表路由

1.判断表是否分表
2.判断是否含义分表字段进行条件
3.分表字段是否可以缩小表范围
4.所有的操作都是通过筛选现有表后缀

在有以上的一些思路后作为dotnet开发人员我们可以考虑如何对orm进行改造了,当然您也可以选择对ado.net进行改造(相对难度更大一点)

基于表达式的分表

首先吹一波c#,拥有良好的表达式树的设计和优雅的linq语法,通过对表达式的解析我们可以将设计分成以下的几步

简单的获取表达式并且可以针对表达式进行转换


                var op = binaryExpression.NodeType switch
                {
                    ExpressionType.GreaterThan => conditionOnRight ? ShardingOperatorEnum.GreaterThan : ShardingOperatorEnum.LessThan,
                    ExpressionType.GreaterThanOrEqual => conditionOnRight ? ShardingOperatorEnum.GreaterThanOrEqual : ShardingOperatorEnum.LessThanOrEqual,
                    ExpressionType.LessThan => conditionOnRight ? ShardingOperatorEnum.LessThan : ShardingOperatorEnum.GreaterThan,
                    ExpressionType.LessThanOrEqual => conditionOnRight ? ShardingOperatorEnum.LessThanOrEqual : ShardingOperatorEnum.GreaterThanOrEqual,
                    ExpressionType.Equal => ShardingOperatorEnum.Equal,
                    ExpressionType.NotEqual => ShardingOperatorEnum.NotEqual,
                    _ => ShardingOperatorEnum.UnKnown
                };

1.过滤表后缀

var list=new List<string>(){"00","01"....};
var filterTails=list.Where(o=>Filter(o)).ToList();

其实对于路由而言我们要做的就是过滤出有效的后缀减少不必要的性能消耗

2.Filter我们可以大致归结为两类一类是and一类是or,就是说Filter的内部应该是对后缀tail的过滤组合比如 "00"or"01"、 "00" and "01",如何体现出"00"呢那么肯定是通过比较的那个值比如'12345'.HashCode().Convert2Tail().
通过比较的条件值转成数据库对应的后缀然后和现有后缀进行比较,如果一样就说明被选中了写成表达式就是existsTail=>existsTail==tail,传入现有list的后缀和计算出来的后缀比较如果一样就代表list的后缀需要被使用,这样我们的=符号的单个已经处理完了,如何处理针对or的语法呢,我们将之前的表达式用or来连接可以改写成existsTail=>(existsTailtail || existsTailtail1),所以Filter=existsTail=>(existsTailtail || existsTailtail1),
在简单取模分表里面

标题 内容
sql select * from order where Id='12345' or Id='54321‘
表达式 db.where(o=>o.Id"12345" || o.Id"54321")
后缀过滤 Filter=existsTail=>(existsTailtail || existsTailtail1)
结果 ["00"..."04"]分别代入Filter,tail是”04“,tail1是"03",所以我们可以得到["04"、”03“]两张表后缀
标题 内容
sql select * from order where time>'2021/06/05 00:00:00' and time <'2021/08/05 00:00:00'
表达式 db.where(o=>o.time>'2021/06/05 00:00:00' && o.time<'2021/08/05 00:00:00')
后缀过滤 Filter=existsTail=>(existsTail>=tail && existsTail<=tail1)
结果 ["202105"...."202109"]分别代入Filter,tail是”202106“,tail1是"202108",所以我们可以得到["202106"、"202107"、”202108“]三张表后缀

所以到这边我们基本可以把整个自动化路由设计完成了。条件直接是and那么多条件之间用and结合如果是or或者in那么用or来连接。
到这边分表路由的基本思路已经有了,既然思路已经有了那么正式切入正题。

自定义ShardingCore路由

首先我们先来看一下sharding-core给我们提供的默认取模路由

/// <summary>
    /// 分表字段为string的取模分表
    /// </summary>
    /// <typeparam name="T"></typeparam>
    public abstract class AbstractSimpleShardingModKeyStringVirtualTableRoute<T>: AbstractShardingOperatorVirtualTableRoute<T,string> where T:class
    {
        protected readonly int Mod;
        protected readonly int TailLength;
        protected readonly char PaddingChar;
        /// <summary>
        /// 
        /// </summary>
        /// <param name="tailLength">后缀长度</param>
        /// <param name="mod">取模被除数</param>
        /// <param name="paddingChar">当取模后不足tailLength左补什么参数</param>
        protected AbstractSimpleShardingModKeyStringVirtualTableRoute(int tailLength,int mod,char paddingChar='0')
        {
            if(tailLength<1)
                throw new ArgumentException($"{nameof(tailLength)} less than 1 ");
            if (mod < 1)
                throw new ArgumentException($"{nameof(mod)} less than 1 ");
            if (string.IsNullOrWhiteSpace(paddingChar.ToString()))
                throw new ArgumentException($"{nameof(paddingChar)} cant empty ");
            TailLength = tailLength;
            Mod = mod;
            PaddingChar = paddingChar;
        }
        /// <summary>
        /// 如何将shardingkey转成对应的tail
        /// </summary>
        /// <param name="shardingKey"></param>
        /// <returns></returns>
        public override string ShardingKeyToTail(object shardingKey)
        {
            var shardingKeyStr = ConvertToShardingKey(shardingKey);
            return Math.Abs(ShardingCoreHelper.GetStringHashCode(shardingKeyStr) % Mod).ToString().PadLeft(TailLength,PaddingChar);
        }
        /// <summary>
        /// 将shardingKey转成对应的字符串
        /// </summary>
        /// <param name="shardingKey"></param>
        /// <returns></returns>
        protected override string ConvertToShardingKey(object shardingKey)
        {
            return shardingKey.ToString();
        }
        /// <summary>
        /// 获取对应类型在数据库中的所有后缀
        /// </summary>
        /// <returns></returns>
        public override List<string> GetAllTails()
        {
            return Enumerable.Range(0, Mod).Select(o => o.ToString().PadLeft(TailLength, PaddingChar)).ToList();
        }
        /// <summary>
        /// 路由表达式如何路由到正确的表
        /// </summary>
        /// <param name="shardingKey"></param>
        /// <param name="shardingOperator"></param>
        /// <returns></returns>
        protected override Expression<Func<string, bool>> GetRouteToFilter(string shardingKey, ShardingOperatorEnum shardingOperator)
        {
            var t = ShardingKeyToTail(shardingKey);
            switch (shardingOperator)
            {
                case ShardingOperatorEnum.Equal: return tail => tail == t;
                default:
                {
#if DEBUG
                    Console.WriteLine($"shardingOperator is not equal scan all table tail");           
#endif
                    return tail => true;
                }
            }
        }
    }

一眼看过去其实发现只有4个方法,其中3个还比较好理解就是如何将分表值转成后缀:ShardingKeyToTail,如何将分标志转成字符串:ConvertToShardingKey,返回现有的所有的后缀:GetAllTails启动的时候需要判断并且创建表。
GetRouteToFilter最复杂的一个方法返回一个后缀与当前的分表值的比较表达式,可能很多人有疑惑为什么要用Expression,因为Expression有and和or可以有多重组合来满足我们的后缀过滤。对于取模而言我们只需要解析等于=这一种情况即可,其他情况下返回true,返回true的意思就是表示其他所有的后缀都要涉及到查询,因为你无法判断是否在其中,当然你也可以进行抛错,表示当前表的路由必须要指定不能出现没法判断的情况。

自定义分表

之前我这边讲过自定义分表下取模(哈希)这种模式的优点就是简单、数据分布均匀,但是缺点也很明显就是针对增加服务器后所需的数据迁移在最欢的情况下需要迁移全部数据,最好情况下也需要有一半数据被迁移,那么在这种情况下有没有一种类似哈希取模的简单、数据分布均匀,又不会在数据迁移的前提下动太多的数据呢,答案是有的,这个路由就是一致性哈希的简单实现版本。

一致性哈希

一致性哈希网上有很多教程,也有很多解释,就是防止增加服务器导致的现有缓存因为算法问题整体失效,而导致的缓存雪崩效应产生的一种算法,虽然网上有很多解析和例子但是由于实现过程可能并不是很简单,并且很多概念并不是一些初学者能看得懂的,所以这边其实有个简单的实现,基本上是个人都能看得懂的算法。

这个算法就是大数取模范围存储。就是在原先的哈希取模的上面进行再次分段来保证不会再增加服务器数目的情况下需要大范围的迁移数据,直接上代码

            var stringHashCode = ShardingCoreHelper.GetStringHashCode("123");
            var hashCode = stringHashCode % 10000;
            if (hashCode >= 0 && hashCode <= 3000)
            {
                return "A";
            }
            else if (hashCode >= 3001 && hashCode <= 6000)
            {
                return "B";
            }
            else if (hashCode >= 6001 && hashCode < 10000)
            {
                return "C";
            }
            else
                throw new InvalidOperationException($"cant calc hash route hash code:[{stringHashCode}]");

这应该是一个最最最简单的是个人都能看得懂的路由了,将hashcode进行取模10000,得到0-9999,将其分成[0-3000],[3001-6000],[6001-9999]三段的概率大概是3、3、4相对很平均,那么还是遇到了上面我们所说的一个问题,如果我们现在需要加一台服务器呢,首先修改路由

            var stringHashCode = ShardingCoreHelper.GetStringHashCode("123");
            var hashCode = stringHashCode % 10000;
            if (hashCode >= 0 && hashCode <= 3000)
            {
                return "A";
            }
            else if (hashCode >= 3001 && hashCode <= 6000)
            {
                return "B";
            }
            else if (hashCode >= 6001 && hashCode <= 8000)
            {
                return "D";
            }
            else if (hashCode >= 8001 && hashCode < 10000)
            {
                return "C";
            }
            else
                throw new InvalidOperationException($"cant calc hash route hash code:[{stringHashCode}]");

我们这边增加了一台服务器针对[6001-9999]分段进行了数据切分,并且将[8001-9999]区间内的表后缀没变,实际上我们仅仅只需要修改五分之一的数据那么就可以完美的做到数据迁移,并且均匀分布数据,后续如果需要再次增加一台只需要针对'A'或者'B'进行2分那么就可以逐步增加服务器,且数据迁移的数量随着服务器的增加响应的需要迁移的数据百分比逐步的减少,最坏的情况是增加一倍服务器需要迁移50%的数据,相比较之前的最好情况迁移50%的数据来说十分划算,而且路由规则简单易写是个人就能写出来。

那么我们如何在sharding-core里面编写这个路由规则呢


    public class OrderHashRangeVirtualTableRoute:AbstractShardingOperatorVirtualTableRoute<Order,string>
    {
        //如何将sharding key的value转换成对应的值
        protected override string ConvertToShardingKey(object shardingKey)
        {
            return shardingKey.ToString();
        }

        //如何将sharding key的value转换成对应的表后缀
        public override string ShardingKeyToTail(object shardingKey)
        {
            var stringHashCode = ShardingCoreHelper.GetStringHashCode("123");
            var hashCode = stringHashCode % 10000;
            if (hashCode >= 0 && hashCode <= 3000)
            {
                return "A";
            }
            else if (hashCode >= 3001 && hashCode <= 6000)
            {
                return "B";
            }
            else if (hashCode >= 6001 && hashCode <= 10000)
            {
                return "C";
            }
            else
                throw new InvalidOperationException($"cant calc hash route hash code:[{stringHashCode}]");
        }

        //返回目前已经有的所有Order表后缀
        public override List<string> GetAllTails()
        {
            return new List<string>()
            {
                "A", "B", "C"
            };
        }

        //如何过滤后缀(已经实现了condition on right)用户无需关心条件位置和如何解析条件逻辑判断,也不需要用户考虑and 还是or
        protected override Expression<Func<string, bool>> GetRouteToFilter(string shardingKey, ShardingOperatorEnum shardingOperator)
        {
            //因为hash路由仅支持等于所以仅仅只需要写等于的情况
            var t = ShardingKeyToTail(shardingKey);
            switch (shardingOperator)
            {
                case ShardingOperatorEnum.Equal: return tail => tail == t;
                default:
                {
                    return tail => true;
                }
            }
        }
    }

默认路由

ShardingCore 提供了一些列的分表路由并且有相应的索引支持

抽象abstract 路由规则 tail 索引
AbstractSimpleShardingModKeyIntVirtualTableRoute 取模 0,1,2... =,contains
AbstractSimpleShardingModKeyStringVirtualTableRoute 取模 0,1,2... =,contains
AbstractSimpleShardingDayKeyDateTimeVirtualTableRoute 按时间 yyyyMMdd >,>=,<,<=,=,contains
AbstractSimpleShardingDayKeyLongVirtualTableRoute 按时间戳 yyyyMMdd >,>=,<,<=,=,contains
AbstractSimpleShardingWeekKeyDateTimeVirtualTableRoute 按时间 yyyyMMdd_dd >,>=,<,<=,=,contains
AbstractSimpleShardingWeekKeyLongVirtualTableRoute 按时间戳 yyyyMMdd_dd >,>=,<,<=,=,contains
AbstractSimpleShardingMonthKeyDateTimeVirtualTableRoute 按时间 yyyyMM >,>=,<,<=,=,contains
AbstractSimpleShardingMonthKeyLongVirtualTableRoute 按时间戳 yyyyMM >,>=,<,<=,=,contains
AbstractSimpleShardingYearKeyDateTimeVirtualTableRoute 按时间 yyyy >,>=,<,<=,=,contains
AbstractSimpleShardingYearKeyLongVirtualTableRoute 按时间戳 yyyy >,>=,<,<=,=,contains

注:contains表示为o=>ids.contains(o.shardingkey)
注:使用默认的按时间分表的路由规则会让你重写一个GetBeginTime的方法这个方法必须使用静态值如:new DateTime(2021,1,1)不可以用动态值比如DateTime.Now因为每次重新启动都会调用该方法动态情况下会导致每次都不一致

总结

到目前未知我相信对于一般用户而言应该已经清楚了分表分库下的路由是如何实现并且清楚在 ShardingCore 中应该如何编写一个自定义的路由来实现分表分库的处理

分表分库组件求赞求star


博客

QQ群:771630778

个人QQ:326308290(欢迎技术支持提供您宝贵的意见)

个人邮箱:326308290@qq.com

posted @ 2021-11-09 16:01  薛家明  阅读(1500)  评论(1编辑  收藏  举报