分庫分表如何進行極致的優化

薛家明發表於2022-02-16

分庫分表下極致的優化

題外話

這邊說一句題外話,就是ShardingCore目前已經正式加入 NCC 開源組織了,也是希望框架和社群能發展的越來越好,希望為更多.netter提供解決方案和開源元件

介紹

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

dotnet下唯一一款全自動分表,多欄位分表框架,擁有高效能,零依賴、零學習成本、零業務程式碼入侵,並且支援讀寫分離動態分表分庫,同一種路由可以完全自定義的新星元件框架

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

專案地址

本次優化點

直奔主題來講下本次的極致優化具體是優化了什麼,簡單說就是CircuitBreakerFastFail.

斷路器CircuitBreaker

我們假設這麼一個場景,現在我們有一個訂單order表,訂單會按照月份進行分片,那麼訂單表會有如下幾個order_202201order_202202order_202203order_202204order_202205,假設我們有5張表。
首先我們來看一條普通的語句

select * from order where id='xxx' limit 1

這是一條普通的不能在普通的sql了,查詢第一條id是xxx的訂單,
那麼他在分表下面會如何執行

//開啟5個執行緒併發查詢
select * from order_202201 where id='xxx' limit 1
select * from order_202202 where id='xxx' limit 1
select * from order_202203 where id='xxx' limit 1
select * from order_202204 where id='xxx' limit 1
select * from order_202205 where id='xxx' limit 1
//查詢出來的結果在記憶體中進行聚合成一個list集合
//然後在對這個list集合進行第一條的獲取
list.Where(o=>o is not null).FirstOrDefault()

這個操作我相信很多同學都是可以瞭解的,稍微熟悉點分表分庫的同學應該都知道這是基本操作了,但是這個操作看似高效(時間上)但是在連線數上而言並不是那麼的高效,因為同一時間需要開打的連線數將由5個

那麼在這個背景下ShardingCore參考ShardingSphere 提供了更加友好的連線控制和記憶體聚合模式ConnectionMode

這個張圖上我們可以清晰的看到不同的資料庫直接才用了一個併發限制,比如設定的是2,那麼在相同庫裡面的查詢將是每2個一組,進行查詢,這樣可以控制在同一個資料庫下的連線數,進而解決了客戶端連線模式下的連線數消耗猛烈的一個弊端。

//開啟5個執行緒併發查詢
{
  //並行
  select * from order_202201 where id='xxx' limit 1
  select * from order_202202 where id='xxx' limit 1
}
  //序列
{
  //並行
  select * from order_202203 where id='xxx' limit 1
  select * from order_202204 where id='xxx' limit 1
}
  //序列
{
  select * from order_202205 where id='xxx' limit 1
}
//查詢出來的結果在記憶體中進行聚合成一個list集合
//然後在對這個list集合進行第一條的獲取
list.Where(o=>o is not null).FirstOrDefault()

到目前為止這邊已經對分片的查詢優化到了一個新的高度。但是雖然我們優化了連線數的處理,但是就查詢速度而言基本上是沒有之前的那麼快,可以說和你分組的組數成線性增加時間的消耗。
所以到此為止ShardingCore又再一次進化出了全新的翅膀CircuitBreaker斷路器,我們繼續往下看

我們現在的sql是

select * from order where id='xxx' limit 1

那麼如果我們針對這個sql進行優化呢,譬如

select * from order where id='xxx' order by create_time desc limit 1

同樣是查詢第一條,新增了一個order排序那麼情況就會大大的不一樣,首先我們來觀察我們的分片查詢

//開啟5個執行緒併發查詢
--  select * from order_202201 where id='xxx' order by create_time desc  limit 1
--  select * from order_202202 where id='xxx' order by create_time desc  limit 1
--  select * from order_202203 where id='xxx' order by create_time desc  limit 1
--  select * from order_202204 where id='xxx' order by create_time desc  limit 1
--  select * from order_202205 where id='xxx' order by create_time desc  limit 1
-- 拋棄上述寫法

  select * from order_202205 where id='xxx' order by create_time desc  limit 1
  select * from order_202204 where id='xxx' order by create_time desc  limit 1
  select * from order_202203 where id='xxx' order by create_time desc  limit 1
  select * from order_202202 where id='xxx' order by create_time desc  limit 1
  select * from order_202201 where id='xxx' order by create_time desc  limit 1

如果在連線模式下那麼他們將會是2個一組,那麼我們在查詢第一組的結果後是否就可以直接拋棄掉下面的所有查詢,也就是我們只需要查詢

  select * from order_202205 where id='xxx' order by create_time desc  limit 1
  select * from order_202204 where id='xxx' order by create_time desc  limit 1

只要他們是有返回一個以上的資料那麼本次分片查詢將會被終止,ShardingCore目前的大殺器,本來年前已經開發完成了,奈何太懶只是釋出了版本並沒有相關的說明和使用方法

CircuitBreaker

斷路器,它具有類似拉閘中斷操作的功能,這邊簡單說下linq操作下的部分方法的斷路器點在哪裡

方法名 是否支援中斷操作 中斷條件
First 支援 按順序查詢到第一個時就可以放棄其餘查詢
FirstOrDefault 支援 按順序查詢到第一個時就可以放棄其餘查詢
Last 支援 按順序倒敘查詢到第一個時就可以放棄其餘查詢
LastOrDefault 支援 按順序倒敘查詢到第一個時就可以放棄其餘查詢
Single 支援 查詢到兩個時就可以放棄,因為元素個數大於1個了需要拋錯
SingleOrDefault 支援 查詢到兩個時就可以放棄,因為元素個數大於1個了需要拋錯
Any 支援 查詢一個結果true就可以放棄其餘查詢
All 支援 查詢到一個結果fasle就可以放棄其餘查詢
Contains 支援 查詢一個結果true就可以放棄其餘查詢
Count 不支援 --
LongCount 不支援 --
Max 支援 按順序最後一條並且查詢最大欄位是分片順序同欄位是,max的屬性只需要查詢一條記錄
Min 支援 按順序第一條並且查詢最小欄位是分片順序同欄位,min的屬性只需要查詢一條記錄
Average 不支援 --
Sum 不支援 --

這邊其實只有三個操作是任何狀態下都可以支援中斷,其餘操作需要在額外條件順序查詢的情況下才可以,並且我們本次查詢分片涉及到過多的字尾表那麼效能和資源的利用將會大大提升

查詢配置

廢話不多說我們開始以mysql作為本次案例(不要問我為什麼不用SqlServer,因為寫文章的時候我是mac電腦),這邊我們建立一個專案新建一個訂單按月分表

新建專案

安裝依賴

新增訂單表和訂單表對映


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

    public class OrderMap : IEntityTypeConfiguration<Order>
    {
        public void Configure(EntityTypeBuilder<Order> builder)
        {
            builder.HasKey(o => o.Id);
            builder.Property(o => o.Id).HasMaxLength(32).IsUnicode(false);
            builder.Property(o => o.Name).HasMaxLength(255);
            builder.ToTable(nameof(Order));
        }
    }

新增DbContext

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

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            base.OnModelCreating(modelBuilder);
            modelBuilder.ApplyConfiguration(new OrderMap());
        }

        public IRouteTail RouteTail { get; set; }
    }

新增訂單分片路由

從5月份開始按建立時間建表

    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(2021, 5, 1);
        }
    }

啟動配置

簡單的配置啟動建立表和庫,並且新增種子資料


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

app.Services.GetRequiredService<IShardingBootstrapper>().Start();
using (var scope=app.Services.CreateScope())
{
    var shardingDbContext = scope.ServiceProvider.GetRequiredService<ShardingDbContext>();
    if (!shardingDbContext.Set<Order>().Any())
    {
        var begin = new DateTime(2021, 5, 2);
        List<Order> orders = new List<Order>(8);
        for (int i = 0; i < 8; i++)
        {
            orders.Add(new Order()
            {
                Id = i.ToString(),
                Name = $"{begin:yyyy-MM-dd HH:mm:ss}",
                Createtime = begin
            });
            begin = begin.AddMonths(1);
        }
        shardingDbContext.AddRange(orders);
        shardingDbContext.SaveChanges();
    }
}
app.UseAuthorization();
app.MapControllers();
app.Run();

這邊預設連線模式的分組是Environment.ProcessorCount

編寫查詢


沒有配置的情況下那麼這個查詢將是十分糟糕

接下來我們將配置Order的查詢

    public class OrderQueryConfiguration:IEntityQueryConfiguration<Order>
    {
        public void Configure(EntityQueryBuilder<Order> builder)
        {
            //202105,202106...是預設的順序,false表示使用反向排序,就是如果存在分片那麼分片的tail將進行反向排序202202,202201,202112,202111....
            builder.ShardingTailComparer(Comparer<string>.Default, false);
            //order by createTime asc的順序和分片ShardingTailComparer一樣那麼就用true
            //但是目前ShardingTailComparer是倒序所以order by createTime asc需要和他一樣必須要是倒序,倒序就是false
            builder.AddOrder(o => o.CreateTime,false);
            //配置當不存在Order的時候如果我是FirstOrDefault那麼將採用和ShardingTailComparer相反的排序執行因為是false
            //預設從最早的表開始查詢
            builder.AddDefaultSequenceQueryTrip(false, CircuitBreakerMethodNameEnum.FirstOrDefault);
            ////預設從最近表開始查詢
            //builder.AddDefaultSequenceQueryTrip(true, CircuitBreakerMethodNameEnum.FirstOrDefault);
            //內部配置單表查詢的FirstOrDefault connections limit限制為1
            builder.AddConnectionsLimit(1, LimitMethodNameEnum.FirstOrDefault);
        }
    }

    public class OrderRoute:AbstractSimpleShardingMonthKeyDateTimeVirtualTableRoute<Order>
    {
        //......
        //配置路由才用這個物件查詢
        public override IEntityQueryConfiguration<Order> CreateEntityQueryConfiguration()
        {
            return new OrderQueryConfiguration();
        }
    }


帶配置的Order

現在我們將預設的配置修改回正確

//不合適因為一般而言我們肯定是查詢最新的所以應該和ShardingComparer一樣都是倒序查詢
//builder.AddDefaultSequenceQueryTrip(false, CircuitBreakerMethodNameEnum.FirstOrDefault);
builder.AddDefaultSequenceQueryTrip(true, CircuitBreakerMethodNameEnum.FirstOrDefault);


當然如果你希望本次查詢不使用配置的連線數限制可以進行如下操作

_shardingDbContext.Set<Order>().UseConnectionMode(2).Where(o=>o.Id=="7").FirstOrDefaultAsync();

結論:當我們配置了預設分片表應該以何種順序進行分片聚合時,如果相應的查詢方法也進行了配置那麼將這種查詢視為順序查詢,
所有的順序查詢都符合上述表格模式,遇到對應的將直接進行熔斷,不在進行後續的處理直接返回,保證高效能和防止無意義的查詢。

快速失敗FastFail

顧名思義就是快速失敗,但是很多小夥伴可能不清楚這個快速失敗的意思,失敗就是失敗了為什麼有快速失敗一說,因為ShardingCore內部的本質是將一個sql語句進行才分N條然後並行執行


-- 普通sql

select * from order where id='1' or id='2'

-- 分片sql
select * from order_1 where id='1' or id='2'
select * from order_2 where id='1' or id='2'
-- 分別對這兩個sql進行並行執行

在正常情況下程式是沒有什麼問題的,但是由於程式是並行查詢後迭代聚合所以會帶來一個問題,就是假設執行order_1的執行緒掛掉了,那麼Task.WhenAll會一致等待所有執行緒完成,然後丟擲響應的錯誤,
那麼這在很多情況下等於其餘執行緒都在多無意義的操作,各自管各自。


        static async Task Main(string[] args)
        {
            try
            {
                await Task.WhenAll(DoSomething1(), DoSomething2());
                Console.WriteLine("execute success");
            }
            catch 
            {
                Console.WriteLine("error");
            }

            Console.ReadLine();
        }

        static async Task<int> DoSomething1()
        {
            for (int i = 0; i < 10; i++)
            {
                if (i == 2)
                    throw new Exception("111");
                await Task.Delay(1000);
                Console.WriteLine("DoSomething1"+i);
            }

            return 1;
        }
        static async Task<int> DoSomething2()
        {
            for (int i = 0; i < 10; i++)
            {
                await Task.Delay(1000);
                Console.WriteLine("DoSomething2"+i);
            }
            return 1;
        }

程式碼很簡單就是Task.WhenAll的時候執行兩個委託方法,然後讓其中一個快速拋異常的情況下看看是否馬上返回

結果是TaskWhenAll哪怕出現異常也需要等待所有的執行緒完成任務,這會在某些情況下浪費不必要的效能,所以這邊ShardingCore參考資料採用了FastFail版本的


        public static Task WhenAllFailFast(params Task[] tasks)
        {
            if (tasks is null || tasks.Length == 0) return Task.CompletedTask;

            // defensive copy.
            var defensive = tasks.Clone() as Task[];

            var tcs = new TaskCompletionSource();
            var remaining = defensive.Length;

            Action<Task> check = t =>
            {
                switch (t.Status)
                {
                    case TaskStatus.Faulted:
                        // we 'try' as some other task may beat us to the punch.
                        tcs.TrySetException(t.Exception.InnerException);
                        break;
                    case TaskStatus.Canceled:
                        // we 'try' as some other task may beat us to the punch.
                        tcs.TrySetCanceled();
                        break;
                    default:

                        // we can safely set here as no other task remains to run.
                        if (Interlocked.Decrement(ref remaining) == 0)
                        {
                            // get the results into an array.
                            tcs.SetResult();
                        }
                        break;
                }
            };

            foreach (var task in defensive)
            {
                task.ContinueWith(check, default, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default);
            }

            return tcs.Task;
        }


採用failfast後當前主執行緒會直接在錯誤時返回,其餘執行緒還是繼續執行,需要自行進行canceltoken.cancel或者通過共享變數來取消執行

總結

ShardngCore目前還在不斷努力成長中,也希望各位多多包涵可以在使用中多多提出響應的意見和建議

參考資料

https://stackoverflow.com/questions/57313252/how-can-i-await-an-array-of-tasks-and-stop-waiting-on-first-exception

下期預告

下一篇我們將講解如何讓流式聚合支援更多的sql查詢,如何將不支援的sql降級為union all

分表分庫元件求贊求star

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


部落格

QQ群:771630778

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

個人郵箱:326308290@qq.com

相關文章