天天看點

.Net/C#分庫分表高性能O(1)瀑布流分頁

.Net/C#分庫分表高性能O(1)瀑布流分頁

架構介紹

依照慣例首先介紹本期主角:

ShardingCore

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

dotnet下唯一一款全自動分表,多字段分表架構,擁有高性能,零依賴、零學習成本、零業務代碼入侵,并且支援讀寫分離動态分表分庫,同一種路由可以完全自定義的新星元件架構

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

項目位址

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

背景

在大資料量下針對app端的瀑布流頁面分頁的優化實戰,有大量的資料,前端需要以瀑布流的形式展示出來,我們最簡單的就是以使用者釋出的文章為例,假設我們有大量的文章文章被,需求需要按文章的釋出時間倒序展示給使用者看,那麼在手機端我們一般都是以下拉重新整理,上拉加載的形式去展示,那麼我們一般會有以下集中寫法。

正常分頁操作

select count(*) from article
select * from article order by publish_time desc limit 0,20
           

這個操作是一般我們的正常分頁操作,先進行total然後進行分頁擷取,這種做法的好處是支援任意規則的分頁,缺點就是需要查詢兩次,一次count一次limit當然後期資料量實在太大可以隻需要第一次count,但是也有一個問題就是如果資料量一直在變化會出現下一次分頁中還會有上一次的部分資料,因為資料在不斷地新增,你的分頁沒跟上釋出的速度那麼就會有這個情況發送.

瀑布流分頁

除了上述正常分頁操作外,我們針對特定順序的分頁也可以進行特定的分頁方式來實作高性能,因為基于大前提我們是大數量下的瀑布流,我們的文章假設是以雪花id作為主鍵,那麼我們的分頁可以這麼寫

select * from article where id<last_id order by publish_time desc limit 0,20
           

首先我們來分析一下,這個語句是利用了插入的資料分布是順序和你需要查詢的排序一直來實作的,又因為id不會重複并且雪花id的順序和時間是一緻的都是同向的是以可以利用這種方式來進行排序,limit每次不需要跳過任何數目,直接擷取需要的數目即可,隻需要傳遞上一次的查詢結果的id即可,這個方式彌補了上述正常分頁帶來的問題,并且擁有非常高的性能,但是缺點也顯而易見,不支援跳頁,不支援任意排序,是以這個方式目前來說非常适合前端app的瀑布流排序。

分片下的實作

首先分片下需要實作這個功能我們需要有id支援分片,并且publish_time按時間分表,兩者缺一不可。

原理

假設文章表article我們是以publish_time作為分片字段,假設按天分表,那麼我們會擁有如下的表

article_20220101、article_20220102、article_20220103、article_20220104、article_20220105、article_20220106......

雪花id輔助分片

因為

雪花id

可以反解析出時間,是以我們對雪花id的

=

,

>=

,

>

,

<=

,

<

,

contains

的操作都是可以進行輔助分片進行縮小分片範圍

假設我們的

雪花id

解析出來是2021-01-05 11:11:11,那麼針對這個

雪花id

<

小于操作我們可以等價于

x < 2021-01-05 11:11:11

,那麼如果我問你這下我們需要查詢的表有哪些,很明顯 [article_20220101、article_20220102、article_20220103、article_20220104、article_20220105],除了20220106外我們都需要查詢。

union all分片模式

如果你使用union all的分片模式那麼通常會将20220101-20220105的所有的表進行union all然後機械能過濾,那麼優點可想而知:簡單,連接配接數消耗僅1個,sql語句支援的多,缺點也顯而易見,優化起來後期是個很大的問題,并且跨庫下的使用有問題

select * from 
(select * from article_20220101 union all select * from article_20220102 union all select * from article_20220103....) t
 where id<last_id order by publish_time desc limit 0,20
           

流式分片,順序查詢

如果你是流式分片模式進行聚合通常我們會将20220101-20220105的所有的表進行并行的分别查詢,然後針對每個查詢的結果集進行優先級隊列的排序後擷取,優點:語句簡單便于優化,性能可控,支援分庫,缺點:實作複雜,連接配接數消耗多

select * from article_20220101 where id<last_id order by publish_time desc limit 0,20
select * from article_20220102where id<last_id order by publish_time desc limit 0,20
select * from article_20220103 where id<last_id order by publish_time desc limit 0,20
......
           

流式分片下的優化

目前

ShardingCore

采用的是流式聚合+union all,當且僅當使用者手動3調用

UseUnionAllMerge

時會将分片sql轉成union all 聚合。

針對上述瀑布流的分頁

ShardingCore

是這麼操作的

  • 确定分片表的順序,也就是因為分片字段是

    publish_time

    ,又因為排序字段是

    publish_time

    是以分片表其實是有順序的,也就是[article_20220105、article_20220104、article_20220103、article_20220102、article_20220101],

    因為我們是開啟n個并發線程是以這個排序可能沒有意義,但是如果我們是僅開啟設定單個連接配接并發的時候,程式将現在通過

    id<last_id

    進行表篩選,之後依次從大到小進行擷取直到滿足skip+take也就是0+20=20條資料後,進行直接抛棄剩餘查詢傳回結果,那麼本次查詢基本上就是和單表查詢一樣,因為基本上最多跨兩張表基本可以滿足要求(具體場景不一定)
  • 說明:假設

    last_id

    反解析出來的結果是2022-01-04 05:05:05那麼可以基本上排除

    article_20220105

    ,判斷并發連接配接數如果是1那麼直接查詢

    article_20220104

    ,如果不滿足繼續查詢

    article_20220103

    ,直到查詢結果為20條如果并發連接配接數是2那麼查詢

    [article_20220104、article_20220103]

    如果不滿足繼續下面兩張表直到擷取到結果為20條資料,是以我們可以很清晰的了解其工作原理并且來優化

說明

  • 通過上述優化可以保證流式聚合查詢在順序查詢下的高性能O(1)
  • 通過上述優化可以保證用戶端分片擁有最小化連接配接數控制
  • 設定合理的主鍵可以有效的解決我們在大資料分片下的性能優化

實踐

ShardingCore

目前針對分片查詢進行了不斷地優化和盡可能的無業務代碼入侵來實作高性能分片查詢聚合。

接下來我将為大家展示一款dotnet下唯一一款全自動路由、多字段分片、無代碼入侵、高性能順序查詢的架構在傳統資料庫領域下的分片功能,如果你使用過我相信你一定會愛上他。

第一步:安裝依賴

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

第二步添加對象和上下文

有很多朋友問我一定需要使用fluentapi來使用

ShardingCore

嗎,隻是個人喜好,這邊我才用dbset+attribute來實作

//文章表
    [Table(nameof(Article))]
    public class Article
    {
        [MaxLength(128)]
        [Key]
        public string Id { get; set; }
        [MaxLength(128)]
        [Required]
        public string Title { get; set; }
        [MaxLength(256)]
        [Required]
        public string Content { get; set; }
        
        public DateTime PublishTime { get; set; }
    }
//dbcontext
    public class MyDbContext:AbstractShardingDbContext,IShardingTableDbContext
    {
        public MyDbContext(DbContextOptions<MyDbContext> options) : base(options)
        {
//請勿添加會導緻efcore 的model提前加載的方法如Database.xxxx
        }

        public IRouteTail RouteTail { get; set; }
        
        public DbSet<Article> Articles { get; set; }
    }
           

第三步:添加文章路由

public class ArticleRoute:AbstractSimpleShardingDayKeyDateTimeVirtualTableRoute<Article>
    {
        public override void Configure(EntityMetadataTableBuilder<Article> builder)
        {
            builder.ShardingProperty(o => o.PublishTime);
        }

        public override bool AutoCreateTableByTime()
        {
            return true;
        }

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

到目前為止基本上Article已經支援了按天分表

第四步:添加查詢配置,讓架構知道我們是順序分表且定義分表的順序

public class TailDayReverseComparer : IComparer<string>
    {
        public int Compare(string? x, string? y)
        {
            //程式預設使用的是正序也就是按時間正序排序我們需要使用倒序是以直接調用原生的比較器然後乘以負一即可
            return Comparer<string>.Default.Compare(x, y) * -1;
        }
    }
    //目前查詢滿足的複核條件必須是單個分片對象的查詢,可以join普通非分片表
    public class ArticleEntityQueryConfiguration:IEntityQueryConfiguration<Article>
    {
        public void Configure(EntityQueryBuilder<Article> builder)
        {
            //設定預設的架構針對Article的排序順序,這邊設定的是倒序
            builder.ShardingTailComparer(new TailDayReverseComparer());
            ////如下設定和上述是一樣的效果讓架構真對Article的字尾排序使用倒序
            //builder.ShardingTailComparer(Comparer<string>.Default, false);
            
            //簡單解釋一下下面這個配置的意思
            //第一個參數表名Article的哪個屬性是順序排序和Tail按天排序是一樣的這邊使用了PublishTime
            //第二個參數表示對屬性PublishTime asc時是否和上述配置的ShardingTailComparer一緻,true表示一緻,很明顯這邊是相反的因為預設已經設定了tail排序是倒序
            //第三個參數表示是否是Article屬性才可以,這邊設定的是名稱一樣也可以,因為考慮到匿名對象的select
            builder.AddOrder(o => o.PublishTime, false,SeqOrderMatchEnum.Owner|SeqOrderMatchEnum.Named);
            //這邊為了示範使用的id是簡單的時間格式化是以和時間的配置一樣
            builder.AddOrder(o => o.Id, false,SeqOrderMatchEnum.Owner|SeqOrderMatchEnum.Named);
            //這邊設定如果本次查詢預設沒有帶上述配置的order的時候才用何種排序手段
            //第一個參數表示是否和ShardingTailComparer配置的一樣,目前配置的是倒序,也就是從最近時間開始查詢,如果是false就是從最早的時間開始查詢
            //後面配置的是熔斷器,也就是複核熔斷條件的比如FirstOrDefault隻需要滿足一個就可以熔斷
            builder.AddDefaultSequenceQueryTrip(true, CircuitBreakerMethodNameEnum.Enumerator, CircuitBreakerMethodNameEnum.FirstOrDefault);

            //這邊配置的是當使用順序查詢配置的時候預設開啟的連接配接數限制是多少,startup一開始可以設定一個預設是目前cpu的線程數,這邊優化到隻需要一個線程即可,當然如果跨表那麼就是串行執行
            builder.AddConnectionsLimit(1, LimitMethodNameEnum.Enumerator, LimitMethodNameEnum.FirstOrDefault);
        }
    }
           

第五步:添加配置到路由

public class ArticleRoute:AbstractSimpleShardingDayKeyDateTimeVirtualTableRoute<Article>
    {
        //省略.....
        public override IEntityQueryConfiguration<Article> CreateEntityQueryConfiguration()
        {
            return new ArticleEntityQueryConfiguration();
        }
    }
           

第六步:startup配置

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<ArticleRoute>();
    })
    .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=ShardingWaterfallDB;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.Articles.Any())
    {
        List<Article> articles = new List<Article>();
        var beginTime = new DateTime(2022, 3, 1, 1, 1,1);
        for (int i = 0; i < 70; i++)
        {
            var article = new Article();
            article.Id = beginTime.ToString("yyyyMMddHHmmss");
            article.Title = "标題" + i;
            article.Content = "内容" + i;
            article.PublishTime = beginTime;
            articles.Add(article);
            beginTime= beginTime.AddHours(2).AddMinutes(3).AddSeconds(4);
        }
        myDbContext.AddRange(articles);
        myDbContext.SaveChanges();
    }
}
app.MapControllers();

app.Run();
           

第七步編寫查詢表達式

public async Task<IActionResult> Waterfall([FromQuery] string lastId,[FromQuery]int take)
    {
        Console.WriteLine($"-----------開始查詢,lastId:[{lastId}],take:[{take}]-----------");
        var list = await _myDbContext.Articles.WhereIf(o => String.Compare(o.Id, lastId) < 0,!string.IsNullOrWhiteSpace(lastId)).Take(take)..OrderByDescending(o => o.PublishTime)ToListAsync();
        return Ok(list);
    }
           

運作程式

.Net/C#分庫分表高性能O(1)瀑布流分頁

因為07表是沒有的是以這次查詢會查詢07和06表,之後我們進行下一次分頁傳入上次id

.Net/C#分庫分表高性能O(1)瀑布流分頁

因為沒有對

Article.Id

進行分片路由的規則編寫是以沒辦法進行對id的過濾,那麼接下來我們配置

Id

的分片規則

首先針對

ArticleRoute

進行代碼編寫

public class ArticleRoute:AbstractSimpleShardingDayKeyDateTimeVirtualTableRoute<Article>
    {
        public override void Configure(EntityMetadataTableBuilder<Article> builder)
        {
            builder.ShardingProperty(o => o.PublishTime);
            builder.ShardingExtraProperty(o => o.Id);
        }

        public override bool AutoCreateTableByTime()
        {
            return true;
        }

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

        public override IEntityQueryConfiguration<Article> CreateEntityQueryConfiguration()
        {
            return new ArticleEntityQueryConfiguration();
        }

        public override Expression<Func<string, bool>> GetExtraRouteFilter(object shardingKey, ShardingOperatorEnum shardingOperator, string shardingPropertyName)
        {
            switch (shardingPropertyName)
            {
                case nameof(Article.Id): return GetArticleIdRouteFilter(shardingKey, shardingOperator);
            }

          return base.GetExtraRouteFilter(shardingKey, shardingOperator, shardingPropertyName);
        }
        /// <summary>
        /// 文章id的路由
        /// </summary>
        /// <param name="shardingKey"></param>
        /// <param name="shardingOperator"></param>
        /// <returns></returns>
        private Expression<Func<string, bool>> GetArticleIdRouteFilter(object shardingKey,
            ShardingOperatorEnum shardingOperator)
        {
            //将分表字段轉成訂單編号
            var id = shardingKey?.ToString() ?? string.Empty;
            //判斷訂單編号是否是我們符合的格式
            if (!CheckArticleId(id, out var orderTime))
            {
                //如果格式不一樣就直接傳回false那麼本次查詢因為是and連結的是以本次查詢不會經過任何路由,可以有效的防止惡意攻擊
                return tail => false;
            }

            //目前時間的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 DoArticleIdFilter(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 DoArticleIdFilter(shardingOperator, orderTime, previewTail, currentTail);
            }

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

        }

        private Expression<Func<string, bool>> DoArticleIdFilter(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 CheckArticleId(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進行多字段分片并且支援大于小于排序

以上是多字段分片的優化,詳情部落格可以點選這邊 .Net下你不得不看的分表分庫解決方案-多字段分片

然後我們繼續查詢看看結果

.Net/C#分庫分表高性能O(1)瀑布流分頁

第三頁也是如此

.Net/C#分庫分表高性能O(1)瀑布流分頁

demo

DEMO

總結

目前架構雖然是一個很年輕的架構,但是我相信我對其在分片領域的性能優化應該在.net現有的所有架構下找不出第二個,并且架構整個也支援union all聚合,可以滿足列入group+first的特殊語句的查詢,又有很高的性能,一個不但是全自動分片而且還是高性能架構擁有非常多的特性性能,目标是榨幹用戶端分片的最後一點性能。

MAKE DOTNET GREAT AGAIN

最後的最後

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

mycat

shardingsphere-proxy

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

ShardingCore

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

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

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

  • Github ShardingCore
  • Gitee ShardingCore

部落格

QQ群:771630778

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

個人郵箱:[email protected]

繼續閱讀