ASP.NET Core 中Session的分布式存储
前言
在ASP.NET Core中的Session该如何存储?本文介绍了Session信息在单个应用实例和多个应用实例中的不同存储方式,以及ASP.NET Core在Session数据安全方面做的一些努力。
TL;DR
在ASP.NET Core中使用分布式缓存存储会话时,仅仅将Session进行分布式存储还不够,由于ASP.NET Core的数据安全保护机制,还需要将Data Protection所使用的加密密钥也进行分布式存储,这样才能正确解密Cookies:
public class Startup
{
public Startup(IConfiguration configuration)
{
Configuration = configuration;
}
public IConfiguration Configuration { get; }
public void ConfigureServices(IServiceCollection services)
{
//将数据保护的秘钥存储为分布式,否则多个实例会有多个秘钥加密cookie值,导致即使读取到客户端的cookie也无法解密
services.AddDataProtection().PersistKeysToStackExchangeRedis(ConnectionMultiplexer.Connect($"localhost:6379,defaultDatabase=0"), "DataProtection-Keys");
//添加reidis作为分布式缓存
services.AddStackExchangeRedisCache(options =>
{
options.Configuration = $"localhost:6379,defaultDatabase=0";
});
//注册Session
services.AddSession(options =>
{
options.IdleTimeout = TimeSpan.FromSeconds(10);
});
}
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
app.UseRouting();
//使用Session中间件
app.UseSession();
app.UseEndpoints(endpoints =>
{
endpoints.MapDefaultControllerRoute();
endpoints.MapRazorPages();
});
}
}
Session管理
单应用实例的会话,直接保存在本机内存中,客户端请求时带上表明用户身份的Cookie,便能直接从实例内存中获取到Session数据,大概构造如下:

这种情况下我们只需要保证单应用实例的内存足够即可。但是单应用实例很难满足我们面对大量并发请求的高性能、高可用性要求,拓展多个应用实例几乎是必须的,那么考虑多实例的Session存储,由于我们Session是保存在每个应用自己的内存中,所以如果同一个会话的请求被另一个应用实例处理,则无法获取到Session信息。解决这个问题有两个方案:
- 继续保持
Session在应用实例内存存储,使用负载均衡器的Session跟踪功能,将同一个Session的请求总是转发到同一实例。 - 将
Session存储剥离出来,使用分布式缓存进行存储以保证多个应用实例能够共享Session会话。
对于第一种方案,毫无疑问是无需做任何应用层面的处理,只需要设置负载均衡器即可,能够快速的解决当前的问题。但是它的缺点也很明显,一方面增加负载均衡器的压力,能一方面,它会导致负载均衡器无法完全发挥出它均衡的功能:毕竟要保证同一个Session的请求总是要转发到同一个实例。这看起来不先进且应用的正常工作需要对负载均衡器有依赖和粘性,如果换个不支持会话跟踪的负载均衡器应用便无法多实例部署显然是难以接受的。
另一种方案则高明许多:使用分布式缓存对Session进行存储以便多应用实例能够共享:

这确实是个好的解决方案,但是它需要对应用进行一点小的改动,幸运的是我们是ASP.NET Core用户,它的Session存储技术已经支持分布式缓存方案:
引入nuget包Microsoft.Extensions.Caching.StackExchangeRedis
public class Startup
{
public Startup(IConfiguration configuration)
{
Configuration = configuration;
}
public IConfiguration Configuration { get; }
public void ConfigureServices(IServiceCollection services)
{
//添加reidis作为分布式缓存
services.AddStackExchangeRedisCache(options =>
{
options.Configuration = $"localhost:6379,defaultDatabase=0";
});
//注册Session
services.AddSession(options =>
{
options.IdleTimeout = TimeSpan.FromSeconds(10);
});
}
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
app.UseRouting();
//使用Session中间件
app.UseSession();
app.UseEndpoints(endpoints =>
{
endpoints.MapDefaultControllerRoute();
endpoints.MapRazorPages();
});
}
}
如你所见,我们只需要添加分布式缓存方案即可,ASP.NET Core框架在读写Session时会使用实现了IDistributedCache接口的分布式缓存实例,如果未配置则默认使用MemoryCache作为IDistributedCache实例,它虽然实现了IDistributedCache但它实实在在是个单实例内存缓存。
不出意外的话你按照上述代码配置后,会发现当多个应用实例时,Session还是无法正常工作,你会去你的缓存中查看Session数据确实被存入,加上日志后你还会发现Session依然是跟着某个具体应用实例相关联的:只有创建Session的实例才能够获取到Session数据,其它实例则无法获取到非自己的Session数据。那么问题在这:ASP.NET Core Data Protection。这是个啥玩意呢?简单来说ASP.NET Core为了数据安全做了很多努力,其中之一就是对Session的安全处理措施,ASP.NET Core使用了秘钥对Session信息进行了加密处理。问题关键在于这个密钥,它的存储不是分布式的,它可能会存储在%LOCALAPPDATA%\ASP.NET\DataProtection-Keys文件夹或内存中(具体的存储地址:Data Protection key management and lifetime in ASP.NET Core),总之它默认不在多个应用实例中共享,这就导致一个应用实例使用密钥加密的Session数据无法被另一个应用实例解密出数据。

当然提出这个问题肯定是有解决方案的,继续使用分布式存储方案对密钥进行存储也不失为一种优雅(使用Redis作为示例):
引入nuget包Microsoft.AspNetCore.DataProtection.StackExchangeRedis
public class Startup
{
public Startup(IConfiguration configuration)
{
Configuration = configuration;
}
public IConfiguration Configuration { get; }
public void ConfigureServices(IServiceCollection services)
{
//将数据保护的秘钥存储为分布式,否则多个实例会有多个秘钥加密cookie值,导致即使读取到客户端的cookie也无法解密
services.AddDataProtection().PersistKeysToStackExchangeRedis(ConnectionMultiplexer.Connect($"localhost:6379,defaultDatabase=0"), "DataProtection-Keys");
//添加reidis作为分布式缓存
services.AddStackExchangeRedisCache(options =>
{
options.Configuration = $"localhost:6379,defaultDatabase=0";
});
//注册Session
services.AddSession(options =>
{
options.IdleTimeout = TimeSpan.FromSeconds(10);
});
}
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
app.UseRouting();
//使用Session中间件
app.UseSession();
app.UseEndpoints(endpoints =>
{
endpoints.MapDefaultControllerRoute();
endpoints.MapRazorPages();
});
}
}

Reference
- Configure IDistributedCache and IDataProtection for session in ASP.NET Core
- Data Protection key management and lifetime in ASP.NET Core
- Configure ASP.NET Core Data Protection
- Session and state management in ASP.NET Core
- DataProtection permanently fails to recover when backing storage is purged
- Issue: Session Middleware with Redis Distributed Caching not working in web farm