《.NET 5.0 背锅案》第7集-大结局:捉拿真凶 StackExchange.Redis.Extensions 归案

随着第5集的播出,随着案情的突破,《.NET 5.0 背锅案》演变为《博客园技术团队甩锅记》,拍片不成却自曝家丑,此次对咱们是一次深入的教训。html

在此次甩锅丢丑过程当中,咱们过于自信,咱们的博客系统身经百战,咱们使用的开源 redis 客户端 StackExchange.Redis 更是身经千战,虽然 .NET 3.1 版与 .NET 5.0 版相差100多个 commit,但都是业务代码,咱们没能耐写出这么大的 bug,惟一不是颇有信心就是咱们维护的 memcached 客户端 EnyimMemcachedCore,当确认 EnyimMemcachedCore 无罪后,咱们信心满满地让刚出道的 .NET 5.0 继续背锅,结果甩锅不成反丢丑。git

当剧情由“锅儿甩甩”发展为“本身的锅本身背”,咱们已无路可退。望着那看不到边的100多个commit(gitlab compare不支持显示这么多的commit),咱们依然抑制不住甩锅的冲动,再次验证了那句话——“恶习难改”,咱们将甩锅的目光瞄向了 redis 客户端,这段时间博客系统中非业务层面代码的最大变化就是引入了 redis 缓存,并打算逐步用 redis 取代 memcached,以前一直没有怀疑 redis 缓存部分,是由于不出故障的 .NET Core 3.1 版与出故障的 .NET 5.0 版都使用了 redis 缓存。github

如今 redis 客户端荣幸地入选为咱们的首选甩锅对象,即便不怀疑它,也要给它找找茬。咱们的目光首先锁定 StackExchange.Redis,当看到它身上的 Star 4.5k,迅速地移开了目光,这是大佬,这是前辈,此锅怎么也不能甩给它,否则又会闹出大笑话。就在这时,大佬身旁的助理 ——StackExchange.Redis.Extensions —— 让咱们眼前一亮,Star 386——甩锅的好对象,并且咱们的代码中都是经过这个助理和大佬 StackExchange.Redis 打交道的。redis

public class BlogPostService : IBlogPostService
{
    private readonly IRedisDatabase _redis;
    // ...
}

这时,咱们忽然想到一句俗话“助理强,则大佬强”,立马意识到以前咱们直觉地认为“大佬强,则助理不会差”是个误区,首先应该怀疑的是助理,而不是大佬。进一步分析发现 StackExchange.Redis.Extensions 助理是咱们当前知道的博客系统中高并发战斗经验最少的,它最应该成为嫌疑犯,而不是甩锅的对象,虽然从外表看(Extensions命名)它应该不会作出带来高并发问题这么出格的事情。docker

当即以闪电般的速度赶到助理所在的城市 github ,潜入 StackExchange.Redis.Extensions 仓库侦查。缓存

经过 IRedisDatabase 接口找到对应的实现类 RedisDatabase,发现了下面的代码:多线程

public IDatabase Database
{
    get
    {
        var db = connectionPoolManager.GetConnection().GetDatabase(dbNumber);

        if (!string.IsNullOrWhiteSpace(keyPrefix))
            return db.WithKeyPrefix(keyPrefix);

        return db;
    }
}

StackExchange.Redis.Extensions 在本身管理着 redis 链接池,这但是高并发事故(尤为是程序启动时)最容易发生的高危地段啊,这须要很强很强的助理啊,Extensions 助理能搞定吗?这时电脑屏幕上“出现了”满屏的问号???并发

继续追查,看看 GetConnection 方法的实现 RedisCacheConnectionPoolManager.GetConnection:异步

public IConnectionMultiplexer GetConnection()
{
    this.EmitConnections();

    var loadedLazies = this.connections.Where(lazy => lazy.IsValueCreated);

    if (loadedLazies.Count() == this.connections.Count)
        return (ConnectionMultiplexer)this.connections.OrderBy(x => x.Value.TotalOutstanding()).First().Value;

    return (ConnectionMultiplexer)this.connections.First(lazy => !lazy.IsValueCreated).Value;
}

这里居然用了 Lazy<T>,这样会形成启动时没法对链接池进行预热,会加重高并发问题。tcp

继续追查,看看更关键的 EmitConnections 方法实现:

private void EmitConnections()
{
    if (connections.Count >= this.redisConfiguration.PoolSize)
        return;

    for (var i = 0; i < this.redisConfiguration.PoolSize; i++)
    {
        this.EmitConnection();
    }
}

这里没有用锁,程序启动后,并发请求一进来,会有不少线程重复地建立链接,假如 PoolSize 是50,若是刚启动时有100个并发请求进来,就会试图建立5000个链接,这是个大问题,但实际状况没这么糟糕,因为使用了前面提到的 Lazy ,不会当即建立链接,因此不会带来大的的并发问题。

继续追,看看更更关键的 EmitConnection 方法:

private void EmitConnection()
{
    this.connections.Add(new Lazy<StateAwareConnection>(() =>
    {
        this.logger.LogDebug("Creating new Redis connection.");

        var multiplexer = ConnectionMultiplexer.Connect(redisConfiguration.ConfigurationOptions);

        if (this.redisConfiguration.ProfilingSessionProvider != null)
            multiplexer.RegisterProfiler(this.redisConfiguration.ProfilingSessionProvider);

        return new StateAwareConnection(multiplexer, logger);
    }));
}

当咱们看到 ConnectionMultiplexer.Connect 使用的是同步方法时,根据咱们在 EnyimMemcachedCore 遇到过的血的教训,咱们知道真凶找到了!

这个地方使用同步方法,在程序启动时,在链接池创建好以前,大量的并发请求进来,同步方法会阻塞线程,加上建立 tcp 链接是个耗时操做,这时会消耗不少线程,形成耗尽线程池中的线程紧缺,从而引起咱们在背锅案中遇到的故障。若是改成异步方法,好比这里改成 ConnectionMultiplexer.ConnectAsync,在进行建立 tcp 链接的IO操做时会释放当前线程,因此不会出现前述的问题。若是必定要使用同步方法,有一个缓解方法就是在预热阶段(程序启动时请求进来以前)建立好链接池。

StackExchange.Redis.Extensions 这个助理,扛着 StackExchange.Redis 的大旗,却犯了3错误:

  1. 使用 Lazy 形成没法预热链接池
  2. 没有使用锁或其余方式避免重复建立链接
  3. 没有使用 StackExchange.Redis 的异步方法 ConnectionMultiplexer.ConnectAsync

而第3个错误是最致命的,也是 .NET 5.0 背锅案的罪魁祸首。

昨天下午,咱们将真凶 StackExchange.Redis.Extensions 捉拿归案,并对其进行改造,改造代码见 https://github.com/imperugo/StackExchange.Redis.Extensions/pull/356

昨天晚上,咱们发布了升级到 StackExchange.Redis.Extensions 改造版的博客系统,发布过程当中稳稳的、妥妥的,发布后一切正常。

今天,咱们发布了《.NET 5.0 背锅案》第7集,宣布结案。

结案感言:

  • 咱们的错,咱们会好好反思,吸引教训。博客园技术团队也是刚刚从单兵做战阶段迈向团队协做规模做战阶段,咱们有不少不少东西须要学习,请你们谅解咱们在学习过程当中所犯的错误。
  • 助理强,则大佬强;生态强,则 .NET 强。仅仅有强大的 C# ,强大的 Visual Studio,强大的 runtime,强大的基础类库是不够的,还须要勇于分享问题,不怕 .NET 被黑被背锅的社区。.NET 的将来不是咱们但愿出来的,是咱们实际使用出来的,是咱们踩坑踩出来的。