关于后端:NetC分库分表高性能O1瀑布流分页

7次阅读

共计 12033 个字符,预计需要花费 31 分钟才能阅读完成。

框架介绍

按照常规首先介绍本期配角:ShardingCore 一款 ef-core 下高性能、轻量级针对分表分库读写拆散的解决方案,具备零依赖、零学习老本、零业务代码入侵

dotnet 下惟一一款全自动分表, 多字段分表框架, 领有高性能, 零依赖、零学习老本、零业务代码入侵, 并且反对读写拆散动静分表分库, 同一种路由能够齐全自定义的新星组件框架

你的 star 和点赞是我坚持下去的最大能源, 一起为.net 生态提供更好的解决方案

我的项目地址

  • github 地址 https://github.com/dotnetcore…
  • gitee 地址 https://gitee.com/dotnetchina…

背景

在大数据量下针对 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);
    }

运行程序

因为 07 表是没有的所以这次查问会查问 07 和 06 表, 之后咱们进行下一次分页传入上次 id

因为没有对 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 下你不得不看的分表分库解决方案 - 多字段分片

而后咱们持续查问看看后果


第三页也是如此

demo

DEMO

总结

以后框架尽管是一个很年老的框架, 然而我置信我对其在分片畛域的性能优化应该在.net 现有的所有框架下找不出第二个, 并且框架整个也反对 union all 聚合, 能够满足列入 group+first 的非凡语句的查问, 又有很高的性能, 一个不然而全自动分片而且还是高性能框架领有十分多的个性性能, 指标是榨干客户端分片的最初一点性能。

MAKE DOTNET GREAT AGAIN

最初的最初

身位一个 dotnet 程序员我置信在之前咱们的分片抉择计划除了 mycatshardingsphere-proxy外没有一个很好的分片抉择, 然而我置信通过ShardingCore 的原理解析, 你岂但能够理解到大数据下分片的知识点, 更加能够参加到其中或者自行实现一个, 我置信只有理解了分片的原理 dotnet 才会有更好的人才和将来, 咱们岂但须要优雅的封装, 更须要原理的是对原理理解。

我置信将来 dotnet 的生态会缓缓起来配上这近乎完满的语法

您的反对是开源作者能坚持下去的最大能源

  • Github ShardingCore
  • Gitee ShardingCore

    博客

QQ 群:771630778

集体 QQ:326308290(欢送技术支持提供您贵重的意见)

集体邮箱:326308290@qq.com

正文完
 0