.Net下你不得不看的分表分庫解決方案-多欄位分片

薛家明發表於2021-12-27

.Net下你不得不看的分表分庫解決方案-多欄位分片

介紹

本期主角:ShardingCore 一款ef-core下高效能、輕量級針對分表分庫讀寫分離的解決方案,具有零依賴、零學習成本、零業務程式碼入侵

dotnet下唯一一款全自動分表,多欄位分表框架,擁有高效能,零依賴、零學習成本、零業務程式碼入侵,並且支援讀寫分離動態分表分庫,同一種路由可以完全自定義的新星元件,通過本框架你不但可以學到很多分片的思想和技巧,並且更能學到Expression的奇思妙用

你的star和點贊是我堅持下去的最大動力,一起為.net生態提供更好的解決方案

專案地址

背景

直接開門見山,你有沒有這種情況你需要將一批資料用時間分片來進行儲存比如訂單表,訂單表的分片欄位是訂單的建立時間,並且id是雪花id訂單編號是帶時間資訊的編號,因為.net下的所有分片方案几乎都是隻支援單分片欄位,所以當我們不使用分片欄位查詢也就是訂單建立時間查詢的話會帶來全表查詢,導致效能下降,譬如我想用雪花id或者訂單編號進行查詢,但是帶來的卻是內部低效的結果,針對這種情況是否有一個好的解決方案呢,有但是需要侵入業務程式碼,根據雪花id或者訂單編號進行解析出對應的時間然後手動指定分片前提是框架支援手動指定.基於上述原因ShardingCore 帶來了全新版本 x.3.2.x+ 支援多欄位分片路由,並且擁有很完美的實現,廢話不多說我們直接開始吧!!!!!!!!!!!

原理

我們現在假定一個很簡單的場景,依然是訂單時間按月分片,查詢進行如下語句

          //這邊演示不使用雪花id因為雪花id很難在演示中展示所以使用訂單編號進行演示格式:yyyyMMddHHmmss+new Random().Next(0,10000).ToString().PadLeft(4,'0')
            var dateTime = new DateTime(2021, 11, 1);
            var order = await _myDbContext.Set<Order>().Where(o => o.OrderNo== 202112201900001111&&o.CreateTime< dateTime).FirstOrDefaultAsync();

上述語句OrderNo會查詢Order_202112這張表,然後時間索引會查詢......Order_202108、Order_202109、Order_202110,然後兩者取一個交集我們發現其實是沒有結果的,這個時候應該是返回預設值null或者直接報錯
這就是一個簡單的原理

直接開始

接下來我將用訂單編號和建立時間來為大演示,資料庫採用sqlserver(你也可以換成任意efcore支援的資料庫),其中編號格式yyyyMMddHHmmss+new Random().Next(0,10000).ToString().PadLeft(4,'0'),建立時間是DateTime格式並且建立時間按月分表,這邊不採用雪花id是因為雪花id的實現會根據workid和centerid的不一樣而出現不一樣的效果,接下來我們通過簡單的5步操作實現多欄位分片

新增依賴

首先我們新增兩個依賴,一個是ShardingCore一個EFCore.SqlServer

//請安裝最新版本目前x.3.2.x+,第一個版本號6代表efcore的版本號
Install-Package ShardingCore -Version 6.3.2

Install-Package Microsoft.EntityFrameworkCore.SqlServer -Version 6.0.1

建立一個訂單物件


    public class Order
    {
        public string Id { get; set; }
        public string OrderNo { get; set; }
        public string Name { get; set; }
        public DateTime CreateTime { get; set; }
    }

建立DbContext

這邊就簡單的建立了一個dbcontext,並且設定了一下order如何對映到資料庫,當然你可以採用attribute的方式而不是一定要fluentapi


    /// <summary>
    /// 如果需要支援分表必須要實現<see cref="IShardingTableDbContext"/>
    /// </summary>
    public class DefaultDbContext:AbstractShardingDbContext,IShardingTableDbContext
    {
        public DefaultDbContext(DbContextOptions options) : base(options)
        {
        }

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            base.OnModelCreating(modelBuilder);
            modelBuilder.Entity<Order>(o =>
            {
                o.HasKey(p => p.Id);
                o.Property(p => p.OrderNo).IsRequired().HasMaxLength(128).IsUnicode(false);
                o.Property(p => p.Name).IsRequired().HasMaxLength(128).IsUnicode(false);
                o.ToTable(nameof(Order));
            });
        }

        public IRouteTail RouteTail { get; set; }
    }

建立分片路由

這邊我們採用訂單建立時間按月分表


    public class OrderVirtualRoute : AbstractSimpleShardingMonthKeyDateTimeVirtualTableRoute<Order>
    {
        /// <summary>
        /// 配置主分表欄位是CreateTime,額外分表欄位是OrderNo
        /// </summary>
        /// <param name="builder"></param>
        public override void Configure(EntityMetadataTableBuilder<Order> builder)
        {
            builder.ShardingProperty(o => o.CreateTime);
            builder.ShardingExtraProperty(o => o.OrderNo);
        }
        /// <summary>
        /// 是否要在程式執行期間自動建立每月的表
        /// </summary>
        /// <returns></returns>
        public override bool AutoCreateTableByTime()
        {
            return true;
        }
        /// <summary>
        /// 分表從何時起建立
        /// </summary>
        /// <returns></returns>
        public override DateTime GetBeginTime()
        {
            return new DateTime(2021, 9, 1);
        }
        /// <summary>
        /// 配置額外分片路由規則
        /// </summary>
        /// <param name="shardingKey"></param>
        /// <param name="shardingOperator"></param>
        /// <param name="shardingPropertyName"></param>
        /// <returns></returns>
        public override Expression<Func<string, bool>> GetExtraRouteFilter(object shardingKey, ShardingOperatorEnum shardingOperator, string shardingPropertyName)
        {
            switch (shardingPropertyName)
            {
                case nameof(Order.OrderNo): return GetOrderNoRouteFilter(shardingKey, shardingOperator);
                default: throw new NotImplementedException(shardingPropertyName);
            }
        }
        /// <summary>
        /// 訂單編號的路由
        /// </summary>
        /// <param name="shardingKey"></param>
        /// <param name="shardingOperator"></param>
        /// <returns></returns>
        private Expression<Func<string, bool>> GetOrderNoRouteFilter(object shardingKey,
            ShardingOperatorEnum shardingOperator)
        {
            //將分表欄位轉成訂單編號
            var orderNo = shardingKey?.ToString() ?? string.Empty;
            //判斷訂單編號是否是我們符合的格式
            if (!CheckOrderNo(orderNo, out var orderTime))
            {
                //如果格式不一樣就直接返回false那麼本次查詢因為是and連結的所以本次查詢不會經過任何路由,可以有效的防止惡意攻擊
                return tail => false;
            }

            //當前時間的tail
            var currentTail = TimeFormatToTail(orderTime);
            //因為是按月分表所以獲取下個月的時間判斷id是否是在臨界點建立的
            var nextMonthFirstDay = ShardingCoreHelper.GetNextMonthFirstDay(DateTime.Now);
            if (orderTime.AddSeconds(10) > nextMonthFirstDay)
            {
                var nextTail = TimeFormatToTail(nextMonthFirstDay);
                return DoOrderNoFilter(shardingOperator, orderTime, currentTail, nextTail);
            }
            //因為是按月分表所以獲取這個月月初的時間判斷id是否是在臨界點建立的
            if (orderTime.AddSeconds(-10) < ShardingCoreHelper.GetCurrentMonthFirstDay(DateTime.Now))
            {
                //上個月tail
                var previewTail = TimeFormatToTail(orderTime.AddSeconds(-10));

                return DoOrderNoFilter(shardingOperator, orderTime, previewTail, currentTail);
            }

            return DoOrderNoFilter(shardingOperator, orderTime, currentTail, currentTail);

        }

        private Expression<Func<string, bool>> DoOrderNoFilter(ShardingOperatorEnum shardingOperator, DateTime shardingKey, string minTail, string maxTail)
        {
            switch (shardingOperator)
            {
                case ShardingOperatorEnum.GreaterThan:
                case ShardingOperatorEnum.GreaterThanOrEqual:
                    {
                        return tail => String.Compare(tail, minTail, StringComparison.Ordinal) >= 0;
                    }

                case ShardingOperatorEnum.LessThan:
                    {
                        var currentMonth = ShardingCoreHelper.GetCurrentMonthFirstDay(shardingKey);
                        //處於臨界值 o=>o.time < [2021-01-01 00:00:00] 尾巴20210101不應該被返回
                        if (currentMonth == shardingKey)
                            return tail => String.Compare(tail, maxTail, StringComparison.Ordinal) < 0;
                        return tail => String.Compare(tail, maxTail, StringComparison.Ordinal) <= 0;
                    }
                case ShardingOperatorEnum.LessThanOrEqual:
                    return tail => String.Compare(tail, maxTail, StringComparison.Ordinal) <= 0;
                case ShardingOperatorEnum.Equal:
                    {
                        var isSame = minTail == maxTail;
                        if (isSame)
                        {
                            return tail => tail == minTail;
                        }
                        else
                        {
                            return tail => tail == minTail || tail == maxTail;
                        }
                    }
                default:
                    {
                        return tail => true;
                    }
            }
        }

        private bool CheckOrderNo(string orderNo, out DateTime orderTime)
        {
            //yyyyMMddHHmmss+new Random().Next(0,10000).ToString().PadLeft(4,'0')
            if (orderNo.Length == 18)
            {
                if (DateTime.TryParseExact(orderNo.Substring(0, 14), "yyyyMMddHHmmss", CultureInfo.InvariantCulture,
                        DateTimeStyles.None, out var parseDateTime))
                {
                    orderTime = parseDateTime;
                    return true;
                }
            }

            orderTime = DateTime.MinValue;
            return false;
        }
    }

這邊我來講解一下為什麼用額外欄位分片需要些這麼多程式碼呢,其實是這樣的因為你是用訂單建立時間CreateTime來進行分片的那麼CreateTimeOrderNo的賦值原理上說應該在系統裡面是不可能實現同一時間賦值的肯定有先後關係可能是幾微妙甚至幾飛秒,但是為了消除這種差異這邊採用了臨界點相容演算法來實現,讓我們來看下一下程式碼

var order=new Order()
//執行這邊生成出來的id是2021-11-30 23:59:59.999.999
order.OrderNo=DateTime.Now.ToString("yyyyMMddHHmmss")+"xxx";
//business code //具體執行時間不確定,哪怕沒有business code也沒有辦法保證兩者生成的時間一致,當然如果你可以做到一致完全不需要這麼複雜的編寫
............
//執行這邊生成出來的時間是2021-12-01 00:00:00.000.000
order.CreateTime=DateTime.Now;

當然系統裡面採用了前後新增10秒是一個比較保守的估算你可以採用前後一秒甚至幾百毫秒都是ok的,具體業務具體實現,因為大部分的建立時間可能是由框架在提交後才會生成而不是new Order的時候,當然也不排除這種情況,當然如果你只需要考慮equal一種情況可以只編寫equal的判斷而不需要全部情況都考慮

ShardingCore啟動配置

ILoggerFactory efLogger = LoggerFactory.Create(builder =>
{
    builder.AddFilter((category, level) => category == DbLoggerCategory.Database.Command.Name && level == LogLevel.Information).AddConsole();
});
var builder = WebApplication.CreateBuilder(args);

// Add services to the container.

builder.Services.AddControllers();
builder.Services.AddShardingDbContext<DefaultDbContext>((conStr,builder)=>builder
        .UseSqlServer(conStr)
        .UseLoggerFactory(efLogger)
    )
    .Begin(o =>
    {
        o.CreateShardingTableOnStart = true;
        o.EnsureCreatedWithOutShardingTable = true;
    }).AddShardingTransaction((connection, builder) =>
    {
        builder.UseSqlServer(connection).UseLoggerFactory(efLogger);
    }).AddDefaultDataSource("ds0","Data Source=localhost;Initial Catalog=ShardingMultiProperties;Integrated Security=True;")//如果你是sqlserve只需要修改這邊的連結字串即可
    .AddShardingTableRoute(op =>
    {
        op.AddShardingTableRoute<OrderVirtualRoute>();
    })
    .AddTableEnsureManager(sp=>new SqlServerTableEnsureManager<DefaultDbContext>())//告訴ShardingCore啟動時有哪些表
    .End();

var app = builder.Build();

// Configure the HTTP request pipeline.
app.Services.GetRequiredService<IShardingBootstrapper>().Start();

app.UseAuthorization();

app.MapControllers();

//額外新增一些種子資料
using (var serviceScope = app.Services.CreateScope())
{
    var defaultDbContext = serviceScope.ServiceProvider.GetService<DefaultDbContext>();
    if (!defaultDbContext.Set<Order>().Any())
    {
        var orders = new List<Order>(8);
        var beginTime = new DateTime(2021, 9, 5);
        for (int i = 0; i < 8; i++)
        {

            var orderNo = beginTime.ToString("yyyyMMddHHmmss") + i.ToString().PadLeft(4, '0');
            orders.Add(new Order()
            {
                Id = Guid.NewGuid().ToString("n"),
                CreateTime = beginTime,
                Name = $"Order" + i,
                OrderNo = orderNo
            });
            beginTime = beginTime.AddDays(1);
            if (i % 2 == 1)
            {
                beginTime = beginTime.AddMonths(1);
            }
        }
        defaultDbContext.AddRange(orders);
        defaultDbContext.SaveChanges();
    }
}
app.Run();

整個配置下來其實也就兩個地方需要配置還是相對比較簡單的,直接啟動開始我們的測試模式

測試

預設配置下的測試


        public async Task<IActionResult> Test1()
        { 
            //訂單名稱全表掃描
            Console.WriteLine("--------------Query Name Begin--------------");
            var order1 = await _defaultDbContext.Set<Order>().Where(o=>o.Name=="Order3").FirstOrDefaultAsync();
            Console.WriteLine("--------------Query Name End--------------");

            //訂單編號查詢 精確定位
            Console.WriteLine("--------------Query OrderNo Begin--------------");
            var order2 = await _defaultDbContext.Set<Order>().Where(o=>o.OrderNo== "202110080000000003").FirstOrDefaultAsync();
            Console.WriteLine("--------------Query OrderNo End--------------");

            //建立時間查詢 精確定位
            Console.WriteLine("--------------Query OrderCreateTime Begin--------------");
            var dateTime = new DateTime(2021,10,08);
            var order4 = await _defaultDbContext.Set<Order>().Where(o=>o.CreateTime== dateTime).FirstOrDefaultAsync();
            Console.WriteLine("--------------Query OrderCreateTime End--------------");

            //訂單編號in 精確定位
            Console.WriteLine("--------------Query OrderNo Contains Begin--------------");
            var orderNos = new string[] { "202110080000000003", "202111090000000004" };
            var order5 = await _defaultDbContext.Set<Order>().Where(o=> orderNos.Contains(o.OrderNo)).ToListAsync();
            Console.WriteLine("--------------Query OrderNo Contains End--------------");

            //訂單號和建立時間查詢 精確定位 無路由結果 拋錯或者返回default
            Console.WriteLine("--------------Query OrderNo None Begin--------------");
            var time = new DateTime(2021,11,1);
            var order6 = await _defaultDbContext.Set<Order>().Where(o=> o.OrderNo== "202110080000000003"&&o.CreateTime> time).FirstOrDefaultAsync();
            Console.WriteLine("--------------Query OrderNo None End--------------");
            
            //非正確格式訂單號 拋錯或者返回default防止擊穿資料庫
            Console.WriteLine("--------------Query OrderNo Not Check Begin--------------");
            var order3 = await _defaultDbContext.Set<Order>().Where(o => o.OrderNo == "a02110080000000003").FirstOrDefaultAsync();
            Console.WriteLine("--------------Query OrderNo Not Check End--------------");

            return Ok();
        }

測試結果

測試結果非常完美除了無法匹配路由的時候那麼我們該如何設定呢

測試無路由返回預設值

builder.Services.AddShardingDbContext<DefaultDbContext>(...)
    .Begin(o =>
    {
....
        o.ThrowIfQueryRouteNotMatch = false;//配置預設不丟擲異常
    })

我們再次來看下測試結果

為何我們測試是不經過資料庫直接查詢,原因就是在我們做各個屬性分片交集的時候返回了空那麼框架會選擇丟擲異常或者返回預設值兩種選項,並且我們在編寫路由的時候判斷格式不正確返回 return tail => false;直接讓所有的交集都是空所以不會進行一次無意義的資料庫查詢

總結

看到這邊你應該已經看到了本框架的強大之處,本框架不但可以實現多欄位分片還可以實現自定義分片,而不是單單按時間分片這麼簡單,我完全可以設定訂單從2021年後的訂單按月分片,2021年前的訂單按年分片,對於sharding-core而言這簡直輕而易舉,但是據我所知.Net下目前除了我沒有任何一款框架可以做到真正的全自動分片+多欄位分片,所以我們在設計框架分片的時候儘可能的將有用的資訊新增到一些無意義的欄位上比如Id可以有效的解決很多在大資料下發生的問題,你可以簡單理解為我加了一個索引並且附帶了額外列,我加了一個id並且帶了分表資訊在裡面,也可以完全設計出一款附帶分庫的屬性到id裡面使其可以支援分表分庫

最後的最後

demo地址 https://github.com/xuejmnet/MultiShardingProperties

您都看到這邊了確定不點個star或者贊嗎,一款.Net不得不學的分庫分表解決方案,簡單理解為sharding-jdbc在.net中的實現並且支援更多特性和更優秀的資料聚合,擁有原生效能的97%,並且無業務侵入性,支援未分片的所有efcore原生查詢

相關文章