分庫分表的框架如何設計自動路由

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(歡迎技術支持提供您寶貴的意見)

個人郵箱:[email protected]