好大一个坑: EF Core 异步读取大字符串字段比同步慢100多倍

为君一笑 提交于 2019-11-28 05:12:47

非常抱歉,10:00~10:30 左右博客站点出现故障,给您带来麻烦了,请您谅解。

故障原因与博文中谈到的部署变更有关,但背后的问题变得非常复杂,复杂到我们都在怀疑与阿里云服务器 CPU 特性有关。

这篇博文本来准备 9:30 左右发布的,但发布博文时出现了 docker swarm 部署异常情况,切换到 docker-compose 部署后问题依旧,一直到 10:30 左右才恢复正常,继续发布这篇博文,在标题中加上了“翻车记”。

原先的博文正文开始:

周一向大家汇报车况之后,我们的 .NET Core 新车继续以 docker-compose 手动挡的驾驶方式行驶在信息高速公路上,即使昨天驶上了更快的高速(并发量更大的访问高峰),也没有翻车。经过这周3天访问高峰的考验,我们终于可以充满信心地宣布——我们度过了新车上路最艰难的磨合期,开新车的剧情从“翻车记”进入到了“行车记”。

翻车成为历史,行车正在进行时,但离我们的目标“飙车”还有很长的一段距离,“行车记”更多的是修车记,新车改造记。

目前这辆 .NET Core 新车有2个重大问题,一是油耗高(CPU消耗高),有时还会断油(CPU 100% 造成 502),二是手动挡驾驶实在太累。

针对油耗高问题,这两天我们从节能降耗角度对博客系统的 C# 代码进行了优化。

从日志中发现,有些特别长的 url 会造成 ASP.NET Core 内置的 url rewrite 中间件在正则处理时执行超时。

System.Text.RegularExpressions.RegexMatchTimeoutException: The RegEx engine has timed out while trying to match a pattern to an input string. This can occur for many reasons, including very large inputs or excessive backtracking caused by nested quantifiers, back-references and other factors.
   at System.Text.RegularExpressions.RegexRunner.DoCheckTimeout()
   at Go64(RegexRunner )
   at System.Text.RegularExpressions.RegexRunner.Scan(Regex regex, String text, Int32 textbeg, Int32 textend, Int32 textstart, Int32 prevlen, Boolean quick, TimeSpan timeout)
   at System.Text.RegularExpressions.Regex.Run(Boolean quick, Int32 prevlen, String input, Int32 beginning, Int32 length, Int32 startat)
   at System.Text.RegularExpressions.Regex.Match(String input, Int32 startat)
   at Microsoft.AspNetCore.Rewrite.UrlMatches.RegexMatch.Evaluate(String pattern, RewriteContext context)
   at Microsoft.AspNetCore.Rewrite.IISUrlRewrite.IISUrlRewriteRule.ApplyRule(RewriteContext context)
   at Microsoft.AspNetCore.Rewrite.RewriteMiddleware.Invoke(HttpContext context)

对于这个问题,我们采取的节能降耗措施是借助 AspNetCore.Rewrite 的机制检查 url 的长度,对超出长度限制的 url 直接返回 400 状态码。

public class UrlLengthLimitRule : IRule
{
    private readonly int _maxLength;
    private readonly int _statusCode;

    public UrlLengthLimitRule(int maxLength, int statusCode)
    {
        _maxLength = maxLength;
        _statusCode = statusCode;
    }

    public void ApplyRule(RewriteContext context)
    {
        var url = context.HttpContext.Request.GetDisplayUrl();
        if (url.Length > _maxLength)
        {
            context.HttpContext.Response.StatusCode = _statusCode;
            context.Result = RuleResult.EndResponse;
            context.Logger.LogWarning($"The Url is too long to proceed(length: {url.Length}): {url}");
        }
    }
}

为了节约每次请求时创建 DbContext 的开销,重新启用了 DbContextPool ,从省吃俭用的角度进一步降低油耗。

services.AddDbContextPool<CnblogsDbContext>(options =>
{
    options.UseSqlServer(Configuration.GetConnectionString("BlogDb"), builder =>
    {
        builder.UseRowNumberForPaging();
        builder.EnableRetryOnFailure(
            maxRetryCount: 3,
            maxRetryDelay: TimeSpan.FromSeconds(10),
            errorNumbersToAdd: new int[] { 2 });
    });
    options.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
});

限制了一个耗油大户,有些字符数特别多的博文内容(比如将图片以 base64string 保存在博文内容在)在正则处理时特别消耗 CPU ,而且 memcached 无法缓存(以后会改用 redis 缓存解决这个问题),对这些博文采取了限制措施。这是我们在迁移时自己给自己挖的坑,旧版中已经采取了措施,但在迁移时遗漏了。

另一个节能降耗措施同样是针对博文内容,将从数据库中获取博文内容的代码由 EF Core + LINQ 改为 Dapper + 存储过程,以避开 好大一个坑: EF Core 异步读取大字符串字段比同步慢100多倍 。在执行 DbCommand.ExecuteReaderAsync 时,EF Core 使用的是 CommandBehavior.Default ,Dapper 使用的是 CommandBehavior.SequentialAccess 。在有些场景下使用 CommandBehavior.Default 查询很大的字符串,有严重的性能问题,不仅查询速度极慢,而且很耗 CPU (也有可能与使用的 SQL Server 版本有关),只要使用 EF Core ,就只能使用 CommandBehavior.Default ,EF Core 没有提供任何修改 CommandBehavior 的配置能力,所以换成 Dapper 也是无奈之举。

public async Task<string> GetByPostIdAsync(int postId)
{
    using (var conn = new SqlConnection(GlobalSettings.PostBodyConnectionString))
    {
        return await conn.QueryFirstOrDefaultAsync<string>(
            "[dbo].[Cnblogs_PostBody_Get]",
            new { postId },
            commandType: CommandType.StoredProcedure);
    }
}

对于手动挡驾驶太累问题,在这次改造过程中,我们采取一个被全园人都反对的举措,没有安装众星捧月的 k8s 高档自动驾驶系统,而是安装了小众的 docker swam 中档自动驾驶系统。这种“docker swarm 虐我千百遍,我待 docker swarm 如初恋”的情有独钟的傻劲,也许是受《try everything》这首歌的影响,我们还是想试试在优化后是否可以使用 docker swarm 自动驾驶系统在高速上正常开车(抗住访问高峰),先看看 docker swarm 究竟是弱不禁风,还是只是娇生惯养?

为了照顾 docker swarm 的娇生惯养,我们在代码中减少一处额外的 HttpClient 造成的 socket 连接开销。在新版博客系统中为了防止有些地方在迁移时遗漏了,我们在一个 middleware 中会跟踪所有 404 响应,并用 404 对应的 url 向旧版博客发请求,如果旧版响应是 200 ,就记录的日志中留待排查。在访问高峰,大量的 404 请求也会带来不少的 socket 连接开销。

Docker swarm 部署的 .NET Core 博客站点昨天晚上就已经上线观察了,但昨天是 docker swarm 与 docker-compose 混合部署,今天一大早已经全部换成 docker swarm 部署了,新车以由手动挡驾驶模式切换为 docker swarm 自动驾驶模式行驶,目前一切状况良好(9:10左右),就看今天上高速的情况了。

我们准备了备案,假如 docker swam 在访问高峰撑不住,随时可以切换到手动挡(docker-compose 部署随地待命)。

-----原先的博文正文结束-----

9:30 左右,刚准备发这篇博文时发现还没上高速才刚上快速路 docker swarm 就有点撑不住了(3台8核16G的阿里云服务器),赶紧向手动挡切换,立即向负载均衡添加了3台4核8G的 docker-compose 部署的阿里云服务器(这3台在向手动挡切换前就一直处于运行状态),6台服务器撑住了。

根据当时的情况,我们完全认为就是 docker swarm 的问题,是 docker swarm 弱不禁风,docker swarm 是一个低档的自动驾驶系统,无法用它在高速上开车。于是,我们进行进一步的切换,将处于关机状态的另外4台 docker-compose 服务器开起来加入负载均衡,将 docker swarm 的服务器摘下负载均衡并关机,这时负载均衡中有7台4核8G的 docker-compose 部署的服务器,按照前几天的情况看,完全可以撑住。但是,万万没有想到,从 10:00 左右开始,这7台竟然也撑不住,而且问题表现与之前 docker swarm 遇到的问题一样,部分服务器本机请求时快时慢,快的时候在10毫秒左右,慢的时候请求执行时间超过30秒,甚至超时。赶紧继续加服务器,但这时加服务器需要购买、启动、预热,虽然是脚本自动完成的,但也比较慢,加了服务器后,问题依旧,于是将一些出问题的服务器下线,但会有其他服务器又出现这个问题,即使新加的服务器也会出现这个问题,在一边加服务器一边将出问题的服务器下线的同时,将 docker swarm 集群的3台服务器也启动起来加入集群分担压力,但很快 docker swarm 集群中的服务器也出现了同样的问题。。。

10:30 左右,当达到某种我们所不知道的平衡点时,立即风平浪静,一切都回归正常,所有服务器本机器本机请求都飞快,包含 docker swarm 集群中的服务器。

现在问题变得格外复杂,回想之前的翻车与正常行驶的情况,从直觉判断中似乎感觉到了一点点新的蛛丝马迹,一个我们从没怀疑的点可能要纳入考虑范围 —— 阿里云服务器 CPU 的性格特点。接下来,我们会仔细分析一下,看能不能找到一点规律,按照比较阿里云服务器 CPU 性格特点的方式接入负载,看是否会避开这个问题。

再次抱歉,给大家带来这么大的麻烦,请谅解。这次故障我们万万没有想到,高速开车比我们想象的难很多,即使同样的部署,接入负载或者增加服务器的时间点不一样,也会有不一样的表现。

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!