- 第1集:驗證 .NET 5.0 正式版 docker 鏡像問題
- 第2集:碼中的小窟窿,背后的大坑,發現重要嫌犯 EnyimMemcachedCore
- 第3集-劇情反轉:EnyimMemcachedCore 無罪,.NET 5.0 繼續背鍋
- 第4集:一個.NET,兩手准備,一個issue,加倍關注
- 第5集-案情突破:都是我們的錯,讓 .NET 5.0 背鍋
- 第6集-案發現場回顧:故障情況下 Kubernetes 的部署表現
- 第7集-大結局:捉拿真凶 StackExchange.Redis.Extensions 歸案
隨着第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錯誤:
- 使用 Lazy 造成無法預熱連接池
- 沒有使用鎖或其他方式避免重復創建連接
- 沒有使用 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 的未來不是我們希望出來的,是我們實際使用出來的,是我們踩坑踩出來的。