天天看點

分庫分表之曆史表如何選擇最佳分片路由規則

分庫分表之曆史表如何選擇最佳分片路由規則

前言

先别急着關閉,我相信這篇文章應該是所有講分表分庫下的人都沒有和你們講過的一種分片模式,外面的文章基本上都是教你如何從零開始分片,現在我将講解的是如何從1+開始分片

項目位址

  • github位址 https://github.com/dotnetcore/sharding-core
  • gitee位址 https://gitee.com/dotnetchina/sharding-core

背景

首先我相信很多人使用分表分庫一定有這麼一個情況,就是目前我們的系統有一張表可能會非常的龐大,然後希望通過分片技術将其進行水準拆分,但是如何拆分或者說如何拆分可以保證讓目前的資料性能達到最優解,是一個很值得探讨的問題。

這邊簡單舉一個例子,譬如我們的訂單表,目前我們的訂單表可能已經達到一定的數量級了比如百萬或者千萬級别了,可能光是簡單的查詢性能是很高的,但是新增訂單可能就沒這麼樂觀了,随着索引的增多新增的數目也會不斷地變慢,不僅僅是查詢一個次元迫使你選擇分表。

基于這個簡單的案例我們來延伸一下如何水準拆分成為目前最關鍵的一個問題。

按月份表

這邊我們如果将訂單表按月進行水準分表那麼我們可以了解到哪怕是随着時間的推移,資料庫的瓶頸也會慢慢的變成容量的瓶頸了而不僅僅是單表的上限了。

假設我們這邊的訂單是從2016年開始的,一直到2022年3月我們發現訂單表可以分成近70張表,而且針對分片我們有個天然的優勢就是按時間分片可以擁有順序查詢這一特性,是以說這麼來分片将是一個比較完美的實作

但是随着系統的運作我們發現這種分片方式雖然看着比較完美,但是存在一個很嚴重的問題就是資料的分布不均勻,因為可能系統剛上線那段時間我們的系統使用量并不是那麼多,導緻了系統内部的訂單數量不會那麼的多,是以雖然我們把訂單表按月來分了,但是之前的曆史資料因為使用量的原因導緻按月分表的每張表裡面可能擁有的資料很少很少。

導緻了分片在各個表中的資料分布極其不均勻。會造成很多不必要的跨表聚合問題,那麼我們希望的方案是什麼呢?

多元度分片

什麼是多元度分片

  • 2018年及以前的資料我們将其歸集到Order_History表中
  • 2019到2021年份的我們按年分表
  • 2022年開始的資料我們按月分表

通過上述緯度分片我們保證了各個分片表之間的資料都是區域平均,并且不會産生過多的跨分片聚合。

時間分片遇到的問題

随着系統的不斷更新疊代,我們的系統也慢慢地拆分成了多個微服務,在各個微服務之間針對訂單的調用我們将會傳遞一個訂單id作為各個微服務之間互動的手段。

但是也是因為這種方式,讓我們認識到分片如果按時間來配置設定那麼微服務之間互動的id那麼如果不是雪花id那麼最好是帶時間的或者說可以反解析出建立時間的。

但是因為訂單曆史原因導緻2022年之前的訂單全部采用的是guid那種無序的id,分表後我們将無法通過無序的guid來進行分片路由的指定,沒辦法用多字段分片輔助路由這個特性了。

針對這個問題我們該如何解決呢?

引入redis來輔助分片

雖然我們沒辦法通過曆史訂單id,guid來進行路由的輔助,但是我們可以借助第三方高速緩存來實作亂序id在分片環境下的輔助路由。

具體我們的實作原理是什麼呢

  • 采用訂單id進行輔助路由
  • 将曆史資料全部導入到redis,redis隻需要存儲id和時間即可
  • 程式利用輔助路由來實作亂序guid進行實際分片輔助

直接進入實戰

第一步安裝依賴

# ShardingCore核心架構 版本6.4.2.4+
PM> Install-Package ShardingCore
# 資料庫驅動這邊選擇的是mysql的社群驅動 efcore6最新版本即可
PM> Install-Package Pomelo.EntityFrameworkCore.MySql
# redis驅動
PM> Install-Package CSRedisCore
           

第二步添加訂單表和資料庫上下文

添加訂單表

public class Order
    {
        public string Id { get; set; }
        public string Title { get; set; }
        public string Description { get; set; }
        public OrderStatusEnum OrderStatus { get; set; }
        public DateTime? PayTime { get; set; }
        public DateTime CreateTime { get; set; }
    }

    public enum OrderStatusEnum
    {
        NoPay=1,
        Paid=1<<1
    }
           

添加資料庫上下文和

Order

對象的資料庫映射

public class MyDbContext:AbstractShardingDbContext,IShardingTableDbContext
    {
        public MyDbContext(DbContextOptions<MyDbContext> options) : base(options)
        {
            
        }

        public IRouteTail RouteTail { get; set; }

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            base.OnModelCreating(modelBuilder);
            modelBuilder.Entity<Order>(builder =>
            {
                builder.HasKey(o => o.Id);
                builder.Property(o => o.Id).HasMaxLength(50).IsRequired().IsUnicode(false);
                builder.Property(o => o.Title).HasMaxLength(50).IsRequired();
                builder.Property(o => o.Description).HasMaxLength(255).IsRequired();
                builder.Property(o => o.OrderStatus).HasConversion<int>();
                builder.ToTable(nameof(Order));
            });
        }
    }
           

第三步添加按建立時間按月路由

public class OrderRoute:AbstractSimpleShardingMonthKeyDateTimeVirtualTableRoute<Order>
    {
        public override void Configure(EntityMetadataTableBuilder<Order> builder)
        {
            builder.ShardingProperty(o => o.CreateTime);
        }

        public override bool AutoCreateTableByTime()
        {
            return true;
        }

        public override DateTime GetBeginTime()
        {
            return new DateTime(2016, 1, 1);
        }
    }
           

第四步初始化配置和資料

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
ILoggerFactory efLogger = LoggerFactory.Create(builder =>
{
    builder.AddFilter((category, level) => category == DbLoggerCategory.Database.Command.Name && level == LogLevel.Information).AddConsole();
});
builder.Services.AddControllers();
builder.Services.AddShardingDbContext<MyDbContext>()
    .AddEntityConfig(o =>
    {
        o.CreateShardingTableOnStart = true;
        o.EnsureCreatedWithOutShardingTable = true;
        o.AddShardingTableRoute<OrderRoute>();
    })
    .AddConfig(o =>
    {
        o.ConfigId = "c1";
        o.UseShardingQuery((conStr, b) =>
        {
            b.UseMySql(conStr, new MySqlServerVersion(new Version())).UseLoggerFactory(efLogger);
        });
        o.UseShardingTransaction((conn, b) =>
        {
            b.UseMySql(conn, new MySqlServerVersion(new Version())).UseLoggerFactory(efLogger);
        });
        o.AddDefaultDataSource("ds0", "server=127.0.0.1;port=3306;database=ShardingHistoryDB;userid=root;password=root;");
        o.ReplaceTableEnsureManager(sp => new MySqlTableEnsureManager<MyDbContext>());
    }).EnsureConfig();

var app = builder.Build();

app.Services.GetRequiredService<IShardingBootstrapper>().Start();
using (var scope = app.Services.CreateScope())
{
    var myDbContext = scope.ServiceProvider.GetRequiredService<MyDbContext>();
    if (!myDbContext.Set<Order>().Any())
    {
        List<Order> orders = new List<Order>();
        var order2016s = createOrders(2016,50);
        var order2017s = createOrders(2017,100);
        var order2018s = createOrders(2018,200);
        var order2019s = createOrders(2019,300);
        var order2020s = createOrders(2020,300);
        var order2021s = createOrders(2021,300);
        var order2022s = createOrders(2022,90);
        orders.AddRange(order2016s);
        orders.AddRange(order2017s);
        orders.AddRange(order2018s);
        orders.AddRange(order2019s);
        orders.AddRange(order2020s);
        orders.AddRange(order2021s);
        orders.AddRange(order2022s);
        
        myDbContext.AddRange(orders);
        myDbContext.SaveChanges();
    }
}
app.MapControllers();

app.Run();

List<Order> createOrders(int year,int count)
{
    var beginTime = new DateTime(year, 1, 1, 1, 1,1);
    var orders = Enumerable.Range(1,count)
        .Select((o, i) =>
        {
            var createTime = beginTime.AddDays(i);
            return new Order()
            {
                Id = year<2022?Guid.NewGuid().ToString("n"):$"{createTime:yyyyMMddHHmmss}",
                CreateTime = createTime,
                Title = year+"年訂單:" + i,
                Description = year+"年訂單較長的描述:" + i,
                OrderStatus = i % 7 == 0 ? OrderStatusEnum.NoPay : OrderStatusEnum.Paid,
                PayTime = i % 7 == 0 ? null : createTime.AddSeconds(new Random().Next(1, 300)),
            };
        }).ToList();
    return orders;
}
           

第五步開啟程式

SELECT table_name,table_rows FROM information_schema.tables
WHERE TABLE_SCHEMA = 'ShardingHistoryDB' ORDER BY TABLE_SCHEMA DESC;
           

通過上述sql語句我們可以查詢出對應表内有多少資料量

分庫分表之曆史表如何選擇最佳分片路由規則

通過截圖我們可以看到資料分布相對恨不均勻導緻很多表的資料過少(這邊是做了一個測試)

是以當我們進行查詢的時候,有很大的可能性會做落到無關表上,并且因為曆史原因導緻我們在2022年之前的資料訂單id都是采用的是guid,這讓我們無法通過guid來實作分表的輔助查詢。

優化資料表分布

因為上述原因我們這邊需要進行表資料的分布優化,具體我們采用的是現實将2018年包括2018年的資料全部存入一張叫做history的表,然後針對2019、2020、2021表進行按年分表,剩下的訂單按月分表

目前市面上很少有架構支援這麼複雜的訂單路由是以我們接下來就需要進行實作

第一步改寫路由

  • 改寫2018年之前的
  • 改寫近期按年分表
  • 剩下的按月分表
public class OrderRoute:AbstractSimpleShardingMonthKeyDateTimeVirtualTableRoute<Order>
    {
        public override void Configure(EntityMetadataTableBuilder<Order> builder)
        {
            builder.ShardingProperty(o => o.CreateTime);
        }

        public override bool AutoCreateTableByTime()
        {
            return true;
        }

        public override DateTime GetBeginTime()
        {
            return new DateTime(2016, 1, 1);
        }
        //系統啟動需要知道資料庫應該有哪些表
        public override List<string> GetAllTails()
        {
            var tails=new List<string>();
            tails.Add("History");
            tails.Add("2019");
            tails.Add("2020");
            tails.Add("2021");
           
            var beginTime = ShardingCoreHelper.GetCurrentMonthFirstDay(new DateTime(2022,1,1));
         
            //提前建立表
            var nowTimeStamp =ShardingCoreHelper.GetCurrentMonthFirstDay(DateTime.Now);
            if (beginTime > nowTimeStamp)
                throw new ArgumentException("begin time error");
            var currentTimeStamp = beginTime;
            while (currentTimeStamp <= nowTimeStamp)
            {
                var tail = ShardingKeyToTail(currentTimeStamp);
                tails.Add(tail);
                currentTimeStamp = ShardingCoreHelper.GetNextMonthFirstDay(currentTimeStamp);
            }
            return tails;
        }

        private static readonly DateTime historyTime = new DateTime(2019, 1, 1);
        private static readonly DateTime yearTime = new DateTime(2022, 1, 1);
        public override string ShardingKeyToTail(object shardingKey)
        {
            var dateTime = (DateTime)shardingKey;
            if (dateTime < historyTime)
            {
                return "History";
            }

            if (dateTime < yearTime)
            {
                return $"{dateTime:yyyy}";
            }
            return base.ShardingKeyToTail(shardingKey);
        }

        private static readonly HistoryMinComparer _historyMinComparer = new HistoryMinComparer();

        public override Expression<Func<string, bool>> GetRouteToFilter(DateTime shardingKey, ShardingOperatorEnum shardingOperator)
        {
            var t = TimeFormatToTail(shardingKey);
            switch (shardingOperator)
            {
                case ShardingOperatorEnum.GreaterThan:
                case ShardingOperatorEnum.GreaterThanOrEqual:
                    return tail => _historyMinComparer.Compare(tail, t) >= 0;
                case ShardingOperatorEnum.LessThan:
                {
                    // var currentMonth = ShardingCoreHelper.GetCurrentMonthFirstDay(shardingKey);
                    // //處于臨界值 o=>o.time < [2021-01-01 00:00:00] 尾巴20210101不應該被傳回
                    // if (currentMonth == shardingKey)
                    //     return tail => _historyMinComparer.Compare(tail, t) < 0;
                    return tail => _historyMinComparer.Compare(tail, t) <= 0;
                }
                case ShardingOperatorEnum.LessThanOrEqual:
                    return tail => _historyMinComparer.Compare(tail, t) <= 0;
                case ShardingOperatorEnum.Equal: return tail => tail == t;
                default:
                {
#if DEBUG
                    Console.WriteLine($"shardingOperator is not equal scan all table tail");
#endif
                    return tail => true;
                }
            }
        }
    }
    public class HistoryMinComparer:IComparer<string>
    {
        private const string History = "History";

        public int Compare(string? x, string? y)
        {
            if (!Object.Equals(x, y))
            {
                if (History.Equals(x))
                    return -1;
                if (History.Equals(y))
                    return 1;
            }
            return Comparer<string>.Default.Compare(x, y);
        }
    }
           

這邊我們改寫路由将原先的按月分表改成2019年之前存入曆史,2022年之前按年之後按月來實作,并且針對表字尾實作了一個曆史記錄History最小的比較器

第二步從新跑一邊資料

删除原先的資料庫從新啟動程式

SELECT table_name,table_rows FROM information_schema.tables
WHERE TABLE_SCHEMA = 'ShardingHistoryDB' ORDER BY TABLE_SCHEMA DESC;
           
分庫分表之曆史表如何選擇最佳分片路由規則

針對這次優化我們發現我們大大的減少了資料庫表的分片數量,可以有效的提高資料分布在分片環境下的存儲。

第三步編寫查詢

編寫查詢控制器

分庫分表之曆史表如何選擇最佳分片路由規則

首先兩個按時間查詢複核預期

分庫分表之曆史表如何選擇最佳分片路由規則

因為id是guid曆史原因并且架構沒有對id配置輔助路由是以會進行全分片掃描

分庫分表之曆史表如何選擇最佳分片路由規則

出現這種情況會導緻程式系統穩定性不足,在分布式環境下查詢會變得很複雜

曆史GUID輔助分片

首先因為系統曆史原因導緻系統的訂單id使用的是亂序guid,亂序guid在程式中很難對時間分片進行優化,是以這邊采用引入三方架構redis,來實作,最新資料将采用雪花id(本次示範采用格式化時間)

第一步将曆史資料存入到redis,分别對應到具體表字尾

//....
RedisHelper.Initialization(new CSRedis.CSRedisClient("127.0.0.1:6379,defaultDatabase=0,poolsize=10,ssl=false,writeBuffer=10240"));

app.Services.GetRequiredService<IShardingBootstrapper>().Start();
using (var scope = app.Services.CreateScope())
{
    var myDbContext = scope.ServiceProvider.GetRequiredService<MyDbContext>();
    if (!myDbContext.Set<Order>().Any())
    {
        List<Order> orders = new List<Order>();
        //.....
        
        myDbContext.AddRange(orders);
        myDbContext.SaveChanges();
        var virtualTableManager = app.Services.GetRequiredService<IVirtualTableManager<MyDbContext>>();
        var virtualTable = virtualTableManager.GetVirtualTable(typeof(Order));
        foreach (var order in orders.Where(o=>o.CreateTime<new DateTime(2022,1,1)))
        {
            var physicTables = virtualTable.RouteTo(new ShardingTableRouteConfig(shardingKeyValue:order.CreateTime));
            var tail = physicTables[0].Tail;
            RedisHelper.Set(order.Id, tail);
        }
    }
}
app.MapControllers();

app.Run();

           

第二步編寫路由多字段分表

public class OrderRoute:AbstractSimpleShardingMonthKeyDateTimeVirtualTableRoute<Order>
    {
        public override void Configure(EntityMetadataTableBuilder<Order> builder)
        {
            builder.ShardingProperty(o => o.CreateTime);
            builder.ShardingExtraProperty(o => o.Id);
        }

        //.....

        public override Expression<Func<string, bool>> GetExtraRouteFilter(object shardingKey, ShardingOperatorEnum shardingOperator, string shardingPropertyName)
        {
            if (shardingPropertyName == nameof(Order.Id))
            {
                return GetOrderNoRouteFilter(shardingKey, shardingOperator);
            }
            return base.GetExtraRouteFilter(shardingKey, shardingOperator, 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))
            {
                //如果格式不一樣就查詢redis
                var t = RedisHelper.Get(shardingKey.ToString());
                if (string.IsNullOrWhiteSpace(t))
                {
                    return tail => false;
                }
                return tail => tail==t;
            }

            //目前時間的tail
            var currentTail = TimeFormatToTail(orderTime);
            //因為是按月分表是以擷取下個月的時間判斷id是否是在臨界點建立的
            //var nextMonthFirstDay = ShardingCoreHelper.GetNextMonthFirstDay(DateTime.Now);//這個是錯誤的
            var nextMonthFirstDay = ShardingCoreHelper.GetNextMonthFirstDay(orderTime);
            if (orderTime.AddSeconds(10) > nextMonthFirstDay)
            {
                var nextTail = TimeFormatToTail(nextMonthFirstDay);
                return DoOrderNoFilter(shardingOperator, orderTime, currentTail, nextTail);
            }
            //因為是按月分表是以擷取這個月月初的時間判斷id是否是在臨界點建立的
            //if (orderTime.AddSeconds(-10) < ShardingCoreHelper.GetCurrentMonthFirstDay(DateTime.Now))//這個是錯誤的
            if (orderTime.AddSeconds(-10) < ShardingCoreHelper.GetCurrentMonthFirstDay(orderTime))
            {
                //上個月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.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
            if (orderNo.Length == 14)
            {
                if (DateTime.TryParseExact(orderNo, "yyyyMMddHHmmss", CultureInfo.InvariantCulture,
                        DateTimeStyles.None, out var parseDateTime))
                {
                    orderTime = parseDateTime;
                    return true;
                }
            }

            orderTime = DateTime.MinValue;
            return false;
        }
    }
   //....
           

省略了相同部分代碼,我們再次來嘗試看看

第三步運作

分庫分表之曆史表如何選擇最佳分片路由規則

因為雪花id是以不需要經過redis就可以直接解析出訂單資訊對應的所屬分片,非合法id通過redis來判斷是否是資料庫中存在的

demo

DEMO

總結

目前

ShardingCore

在分片領域基本上給出了非常多的解決方案可以使用,針對.net在分表分庫領域的缺失我相信會随着開源項目和更多使用的人群,來幫助.Net在未來走的更遠。

最後的最後

感謝部落格園-飯勺oO 提供的實踐方案

身位一個dotnet程式員我相信在之前我們的分片選擇方案除了

mycat

shardingsphere-proxy

外沒有一個很好的分片選擇,但是我相信通過

ShardingCore

的原了解析,你不但可以了解到大資料下分片的知識點,更加可以參與到其中或者自行實作一個,我相信隻有了解了分片的原理dotnet才會有更好的人才和未來,我們不但需要優雅的封裝,更需要原理的是對原理了解。

我相信未來dotnet的生态會慢慢起來配上這近乎完美的文法

您的支援是開源作者能堅持下去的最大動力

  • Github ShardingCore

部落格

QQ群:771630778

個人QQ:326308290(歡迎技術支援提供您寶貴的意見)

個人郵箱:[email protected]

繼續閱讀