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

薛家明發表於2021-11-09

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

相關文章