隨着第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錯誤:
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集,宣佈結案。
結案感言: