《.NET 5.0 背鍋案》第7集-大結局:捉拿真凶 StackExchange.Redis.Extensions 歸案

部落格園團隊發表於2020-11-20

隨著第5集的播出,隨著案情的突破,《.NET 5.0 背鍋案》演變為《部落格園技術團隊甩鍋記》,拍片不成卻自曝家醜,這次對我們是一次深刻的教訓。

在這次甩鍋丟醜過程中,我們過於自信,我們的部落格系統身經百戰,我們使用的開源 redis 客戶端 StackExchange.Redis 更是身經千戰,雖然 .NET 3.1 版與 .NET 5.0 版相差100多個 commit,但都是業務程式碼,我們沒能耐寫出這麼大的 bug,唯一不是很有信心就是我們維護的 memcached 客戶端 EnyimMemcachedCore,當確認 EnyimMemcachedCore 無罪後,我們信心滿滿地讓剛出道的 .NET 5.0 繼續背鍋,結果甩鍋不成反丟醜。

當劇情由“鍋兒甩甩”發展為“自己的鍋自己背”,我們已無路可退。望著那看不到邊的100多個commit(gitlab compare不支援顯示這麼多的commit),我們依然抑制不住甩鍋的衝動,再次驗證了那句話——“惡習難改”,我們將甩鍋的目光瞄向了 redis 客戶端,這段時間部落格系統中非業務層面程式碼的最大變化就是引入了 redis 快取,並打算逐步用 redis 取代 memcached,之前一直沒有懷疑 redis 快取部分,是因為不出故障的 .NET Core 3.1 版與出故障的 .NET 5.0 版都使用了 redis 快取。

現在 redis 客戶端榮幸地入選為我們的首選甩鍋物件,即使不懷疑它,也要給它找找茬。我們的目光首先鎖定 StackExchange.Redis,當看到它身上的 Star 4.5k,迅速地移開了目光,這是大佬,這是前輩,此鍋怎麼也不能甩給它,不然又會鬧出大笑話。就在這時,大佬身旁的助理 ——StackExchange.Redis.Extensions —— 讓我們眼前一亮,Star 386——甩鍋的好物件,而且我們的程式碼中都是通過這個助理和大佬 StackExchange.Redis 打交道的。

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

這時,我們突然想到一句俗話“助理強,則大佬強”,立馬意識到之前我們直覺地認為“大佬強,則助理不會差”是個誤區,首先應該懷疑的是助理,而不是大佬。進一步分析發現 StackExchange.Redis.Extensions 助理是我們當前知道的部落格系統中高併發戰鬥經驗最少的,它最應該成為嫌疑犯,而不是甩鍋的物件,雖然從外表看(Extensions命名)它應該不會做出帶來高併發問題這麼出格的事情。

立即以閃電般的速度趕到助理所在的城市 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>,這樣會造成啟動時無法對連線池進行預熱,會加劇高併發問題。

繼續追查,看看更關鍵的 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 的未來不是我們希望出來的,是我們實際使用出來的,是我們踩坑踩出來的。

相關文章