一、 为什么我的“购物车”在服务器之间“迷路”了?
想象一下这个场景:你正在一个电商网站购物,把几件心仪的商品加进了购物车。然后,你刷新了一下页面,或者点了下一步,突然发现——购物车空了!刚才选的东西全都不见了。
这很可能不是你的错,也不是网站代码有“毁灭性”的Bug,而是因为网站背后有多台服务器在为你服务(这就是负载均衡),而你的“购物车”信息(也就是会话Session)没有被正确地“保管”起来,导致它在你访问不同服务器时“迷路”了。
在传统的单服务器应用中,会话数据默认就存放在这台服务器的内存里,一切都很简单。但当我们为了应对高流量,部署了两台、三台甚至更多的服务器,并通过一个负载均衡器(比如Nginx、HAProxy或云服务商的LB)来分发用户请求时,问题就来了。用户A的第一个请求可能被发到服务器1,他的会话存在了服务器1上;但他的第二个请求,负载均衡器可能为了均衡压力,发给了服务器2,而服务器2的内存里根本没有用户A的会话数据,于是应用就认为这是一个新用户,会话就“丢”了。
在DotNetCore(现在也叫 .NET 5/6/7/8+)中,解决这个问题的核心思路就是:让会话数据不再依赖于单台服务器的内存,而是存放到一个所有服务器都能访问的“公共仓库”里。
二、 解决方案一:使用分布式缓存(推荐)
这是目前最主流、性能也较好的方案。它的原理是把会话数据存放到一个独立于Web服务器的、高性能的内存数据库中,比如Redis。所有的应用服务器都去同一个Redis里读写会话数据,这样无论用户的请求被分配到哪台服务器,都能找到正确的会话。
技术栈:.NET 6 + Redis
首先,我们需要安装必要的NuGet包:
# 在项目目录下执行
dotnet add package Microsoft.Extensions.Caching.StackExchangeRedis
dotnet add package Microsoft.AspNetCore.Session
然后,在 Program.cs 中进行配置:
// 技术栈:.NET 6 + Redis
// 引入必要的命名空间
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Configuration;
var builder = WebApplication.CreateBuilder(args);
// 1. 添加分布式Redis缓存服务
// “MyRedisConnection”是配置在appsettings.json中的连接字符串
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = builder.Configuration.GetConnectionString("MyRedisConnection");
// 可以为Session数据设置一个统一的前缀,方便在Redis中识别和管理
options.InstanceName = "MyAppSession_";
});
// 2. 添加会话(Session)服务,并配置其使用我们上一步注册的分布式缓存
builder.Services.AddSession(options =>
{
options.IdleTimeout = TimeSpan.FromMinutes(20); // 会话20分钟无活动则过期
options.Cookie.HttpOnly = true; // Cookie仅可通过HTTP访问,防止JS脚本窃取,增强安全性
options.Cookie.IsEssential = true; // 标记为必要Cookie,即使GDPR等隐私要求下也通常会被允许
});
// 3. 添加MVC或Razor Pages等服务(根据你的项目类型)
builder.Services.AddControllersWithViews(); // 如果是MVC项目
// 或 builder.Services.AddRazorPages(); // 如果是Razor Pages项目
var app = builder.Build();
// 配置HTTP请求管道
app.UseHttpsRedirection();
app.UseStaticFiles();
// 4. 非常重要:启用会话中间件
// 这个中间件必须在路由中间件(UseRouting)之前,在身份认证中间件(如UseAuthentication)之后(如果用了的话)使用。
app.UseSession();
app.UseRouting();
app.UseAuthorization();
app.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
app.Run();
配置文件 appsettings.json 中需要配置Redis连接:
{
"ConnectionStrings": {
"MyRedisConnection": "你的Redis服务器地址:端口,password=你的密码,connectTimeout=5000"
// 例如:"localhost:6379,password=123456" 或云服务商提供的连接串
}
}
现在,在你的控制器或页面模型中,就可以像使用普通Session一样操作了,但数据会自动存储在Redis中。
// 技术栈:.NET 6 + Redis
public class CartController : Controller
{
public IActionResult AddToCart(int productId)
{
// 从Session中获取当前购物车列表
// Session对象在控制器中可以直接使用
var cart = HttpContext.Session.Get<List<int>>("ShoppingCart") ?? new List<int>();
// 将商品ID加入购物车
if (!cart.Contains(productId))
{
cart.Add(productId);
}
// 将更新后的购物车列表存回Session
// 数据会自动通过我们配置的中间件,保存到Redis里
HttpContext.Session.Set("ShoppingCart", cart);
// 也可以设置一个字符串(这是Session最基础的用法)
HttpContext.Session.SetString("LastActionTime", DateTime.Now.ToString());
return RedirectToAction("Index");
}
public IActionResult ViewCart()
{
// 无论请求被负载均衡到哪台服务器,这里都能从Redis中正确读取到当前用户的购物车
var cart = HttpContext.Session.Get<List<int>>("ShoppingCart");
return View(cart);
}
}
// 为了方便地存取复杂对象(非字符串),我们需要一些扩展方法。
// 通常可以放在一个静态工具类中。
public static class SessionExtensions
{
public static void Set<T>(this ISession session, string key, T value)
{
// 使用System.Text.Json将对象序列化为JSON字符串,再转为字节数组存入
session.SetString(key, System.Text.Json.JsonSerializer.Serialize(value));
}
public static T? Get<T>(this ISession session, string key)
{
var value = session.GetString(key);
// 如果存在,则反序列化回对象;不存在则返回默认值
return value == null ? default : System.Text.Json.JsonSerializer.Deserialize<T>(value);
}
}
优点:
- 性能高: Redis是内存数据库,读写速度极快。
- 可靠性好: Redis支持持久化,即使重启数据也不会丢失(取决于配置)。
- 扩展性强: 独立的缓存服务器,不占用应用服务器资源,方便横向扩展。
- 功能丰富: Redis除了存Session,还可以做数据缓存、消息队列等,一物多用。
缺点与注意事项:
- 引入新依赖: 需要部署和维护Redis服务器或使用云服务,增加了架构复杂度。
- 网络延迟: 相比本地内存,多了一次网络IO,但通常影响微乎其微。
- 序列化: 存储对象时需要序列化(如JSON),要确保你的对象是可序列化的。
- 连接高可用: 生产环境建议使用Redis集群或哨兵模式,防止单点故障导致整个网站会话失效。
三、 解决方案二:使用数据库持久化会话
如果你不想引入Redis这类新组件,或者环境限制严格,也可以选择将Session存到已有的关系型数据库中,比如SQL Server。DotNetCore提供了相应的包来实现。
技术栈:.NET 6 + SQL Server
安装NuGet包:
dotnet add package Microsoft.AspNetCore.Session
dotnet add package Microsoft.Extensions.Caching.SqlServer
首先,需要创建一个数据库表来存储Session。你可以使用命令行工具生成SQL脚本:
# 这个工具可能需要安装:dotnet tool install --global dotnet-sql-cache
dotnet sql-cache create "Server=你的服务器;Database=YourDatabase;Trusted_Connection=True;" dbo AspNetSessions
执行生成的SQL脚本,会在数据库中创建一张 AspNetSessions 表。
然后在 Program.cs 中配置:
// 技术栈:.NET 6 + SQL Server
var builder = WebApplication.CreateBuilder(args);
// 1. 添加分布式SQL Server缓存服务
builder.Services.AddDistributedSqlServerCache(options =>
{
options.ConnectionString = builder.Configuration.GetConnectionString("DefaultConnection");
options.SchemaName = "dbo"; // 数据库架构名,默认dbo
options.TableName = "AspNetSessions"; // 我们刚刚创建的表名
// 可选的清理过期Session的后台任务时间间隔
options.ExpiredItemsDeletionInterval = TimeSpan.FromMinutes(30);
});
// 2. 添加会话服务,并关联到SQL缓存
builder.Services.AddSession(options =>
{
options.IdleTimeout = TimeSpan.FromMinutes(20);
options.Cookie.HttpOnly = true;
});
// ... 其余服务配置与Redis方案类似
var app = builder.Build();
// ... 管道配置,务必启用 UseSession
app.UseSession();
// ... 配置路由等
app.Run();
使用方式与Redis方案完全一样,只是底层存储从Redis换成了SQL Server。
优点:
- 无需新组件: 利用现有数据库,架构简单。
- 数据持久化: 数据安全地存在数据库中。
缺点与注意事项:
- 性能较低: 数据库的磁盘IO速度远低于内存,对高并发场景不友好,可能成为瓶颈。
- 增加数据库压力: 会话的频繁读写会给数据库带来额外负担。
- 需要维护表: 需要创建和维护专用的表。
- 清理机制: 依赖
ExpiredItemsDeletionInterval来清理过期数据,不是实时的。
四、 应用场景与方案选择
- 全新项目或追求高性能: 首选 Redis方案。特别是在微服务、云原生架构中,Redis几乎是标配,用它来管理会话顺理成章。
- 小型内部系统或资源受限: 如果流量不大,且已有SQL Server数据库,可以考虑 数据库方案 以简化部署。
- 绝对简单、无状态的应用: 其实还有第三条路——让应用变得无状态(Stateless)。这是最优雅的解决方案。不依赖服务器端的Session,而是将所有状态信息(如用户标识、购物车数据)存储在客户端(如加密的Cookie中),或者每次请求都从中心的业务数据库/缓存中查询。这完全避免了会话保持问题,也是现代API和SPA(单页应用)架构的常见做法。但对于包含复杂临时状态的传统Web应用,改造起来可能工作量较大。
文章总结
在负载均衡环境中,解决DotNetCore会话保持问题的关键在于将Session数据“转移”出单机内存。我们介绍了两种主流的“转移”方案:
- 使用分布式缓存(如Redis):性能优异,扩展性好,是生产环境的推荐选择。
- 使用数据库持久化:利用现有组件,适合小型或特定约束场景,但需注意性能影响。
无论选择哪种,配置步骤都清晰一致:注册对应的分布式缓存服务 -> 添加并配置Session服务 -> 在请求管道中启用Session中间件。之后,在代码中就可以透明地使用 HttpContext.Session 了。
最后,从架构演进的视角看,我们不妨多思考一下“无状态”设计。它虽然对前期设计要求更高,但能带来更好的扩展性和可靠性,是应对分布式环境更根本的解法。希望本文能帮助你顺利解决会话“迷路”的烦恼,让你的应用在集群中稳健地运行。
评论