- 第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 的未来不是我们希望出来的,是我们实际使用出来的,是我们踩坑踩出来的。