资讯动态

ASP.NET Core电商系统:购物车、库存并发与订单事务实战

发布时间:2026/10/15 13:30:36 来源:尧图企业网站定制
简介这是一套面向初学者与高校教学场景的ASP.NET Web Forms购物网站完整源码项目聚焦C#后端开发、数据库交互与电商核心功能实现帮助学习者系统掌握ASP.NET开发全流程。资源共129个文件包含6个核心aspx页面如Products.aspx、Checkout.aspx、Orders.aspx、34个C#后台逻辑文件.cs、17个JavaScript交互脚本、2个母版页.master及1个SQL数据库脚本辅以Web.config配置、.csproj与.sln工程文件结构清晰开箱即用压缩包仅5.19MB轻量易部署。已有1178人学习下载适合课程实训、毕业设计参考或自学进阶。读者可直接运行调试深入理解用户认证、购物车状态管理、订单流程控制、参数化查询防注入等关键实践并通过CartSummary.ascx、CategoryList.ascx等用户控件体会模块化设计思想是少有的兼顾完整性、教学性与安全规范的ASP.NET入门级电商实战案例。1. ASP.NET 购物网站不是套模板就能上线的工程而是数据流、状态管理与并发安全的三重校验场你用 Visual Studio 新建一个 ASP.NET Web Forms 或 MVC 项目拖几个控件、连个数据库、绑个 GridView首页能显示商品、购物车能加减数量、订单能提交——这确实叫“跑起来了”。但真实场景里当 200 个用户同时抢购同一款限量耳机库存从 5 变成 0 的瞬间后台日志里出现 3 条重复扣减、2 条超卖、1 条事务死锁当用户刷新订单页时发现收货地址莫名变成上个月的旧地址当促销活动结束前 3 秒缓存没及时失效页面还在展示“满 299 减 50”……这些不是玄学是 ASP.NET 购物网站在脱离教学 Demo 后必然撞上的硬墙。它本质是一个以 HTTP 无状态为底座、却必须承载强业务状态购物车、库存、订单生命周期的分布式事务协调系统。适合正在从学生项目转向企业级电商模块开发的后端工程师也适合需要接手维护老旧 ASP.NET 商城系统的运维或重构人员——你不需要从零造轮子但必须亲手把每个状态节点的边界、每条数据流转的锁粒度、每次缓存更新的时序都钉进代码里。2. 从空项目起步用 ASP.NET Core MVC 搭建可验证的最小购物骨架ASP.NET 购物网站的现代落地路径已明确收敛到 ASP.NET Core MVC而非传统 Web Forms 或 .NET Framework MVC。原因很实际跨平台部署能力、内置依赖注入容器、中间件管道可控性、以及对 Entity Framework Core 的原生深度集成。我们不追求“全功能”而是构建一个能暴露核心矛盾的最小闭环用户浏览商品 → 加入购物车 → 提交订单 → 查看订单列表。所有环节必须可调试、可断点、可验证数据一致性。2.1 创建项目并初始化数据模型聚焦领域实体而非 UI 控件使用 .NET 6 SDK推荐 LTS 版本 .NET 8执行命令创建空 MVC 项目dotnet new mvc -n ShopWebApp --framework net8.0 cd ShopWebApp接着定义三个核心实体类放在Models/目录下刻意避开任何 UI 属性或验证特性先保证领域逻辑干净// Models/Product.cs public class Product { public int Id { get; set; } public string Name { get; set; } string.Empty; public decimal Price { get; set; } public int Stock { get; set; } // 库存字段后续用于并发控制 } // Models/CartItem.cs public class CartItem { public int Id { get; set; } public int ProductId { get; set; } public int Quantity { get; set; } public DateTime CreatedAt { get; set; } DateTime.UtcNow; } // Models/Order.cs public class Order { public int Id { get; set; } public string UserId { get; set; } string.Empty; // 简化实际用 Identity public DateTime OrderDate { get; set; } DateTime.UtcNow; public decimal TotalAmount { get; set; } public string Status { get; set; } Pending; // Pending, Confirmed, Shipped }提示这里Stock是整型而非decimal因为库存扣减必须是原子整数操作CreatedAt和OrderDate统一用DateTime.UtcNow避免服务器时区混乱Status用字符串枚举而非enum方便后期扩展状态机如增加CancelledByUser,Refunded。2.2 配置 EF Core 数据库上下文显式声明并发令牌与索引在Data/ApplicationDbContext.cs中配置实体关系与关键约束。重点不是“能连上数据库”而是让 EF Core 明白哪些字段参与并发控制、哪些查询必须走索引// Data/ApplicationDbContext.cs public class ApplicationDbContext : DbContext { public ApplicationDbContext(DbContextOptionsApplicationDbContext options) : base(options) { } public DbSetProduct Products { get; set; } public DbSetCartItem CartItems { get; set; } public DbSetOrder Orders { get; set; } protected override void OnModelCreating(ModelBuilder modelBuilder) { // 商品表Stock 字段添加并发令牌用于乐观并发控制 modelBuilder.EntityProduct() .Property(p p.Stock) .IsConcurrencyToken(); // 关键后续库存扣减将依赖此标记 // 购物车项复合主键 外键约束 索引优化 modelBuilder.EntityCartItem() .HasKey(ci ci.Id); modelBuilder.EntityCartItem() .HasIndex(ci ci.ProductId); // 查询某商品在多少购物车中需此索引 modelBuilder.EntityCartItem() .HasIndex(ci ci.UserId); // 用户查自己购物车需此索引UserId 需补上 // 订单表状态字段加索引高频查询条件 modelBuilder.EntityOrder() .HasIndex(o o.Status); } }然后在Program.cs中注册 DbContext使用 SQL Server LocalDB 示例// Program.cs var builder WebApplication.CreateBuilder(args); builder.Services.AddDbContextApplicationDbContext(options options.UseSqlServer(builder.Configuration.GetConnectionString(DefaultConnection))); builder.Services.AddControllersWithViews();并在appsettings.Development.json中添加连接字符串{ ConnectionStrings: { DefaultConnection: Server(localdb)\\mssqllocaldb;DatabaseShopDb;Trusted_Connectiontrue;MultipleActiveResultSetstrue } }参数说明MultipleActiveResultSetstrue是必须的因为购物车和订单操作常需在同一事务中查询多个表UseSqlServer表明我们选择 SQL Server 作为主力数据库——它对行级锁、快照隔离的支持比 SQLite 更可靠适合电商场景。2.3 实现商品列表与详情页用强类型视图与分页避免 N1 查询控制器Controllers/HomeController.cs中实现商品列表带分页与详情// Controllers/HomeController.cs public class HomeController : Controller { private readonly ApplicationDbContext _context; public HomeController(ApplicationDbContext context) _context context; public async TaskIActionResult Index(int page 1, int pageSize 12) { var total await _context.Products.CountAsync(); var products await _context.Products .Skip((page - 1) * pageSize) .Take(pageSize) .ToListAsync(); ViewData[TotalPages] (int)Math.Ceiling(total / (double)pageSize); ViewData[CurrentPage] page; return View(products); } public async TaskIActionResult Details(int id) { var product await _context.Products.FindAsync(id); if (product null) return NotFound(); return View(product); } }对应视图Views/Home/Index.cshtml使用强类型模型与分页链接model ListProduct { var currentPage (int)ViewData[CurrentPage]; var totalPages (int)ViewData[TotalPages]; } div classrow foreach (var p in Model) { div classcol-md-4 mb-4 div classcard div classcard-body h5 classcard-titlep.Name/h5 p classcard-text¥p.Price/p p classtext-muted库存: p.Stock/p a asp-actionDetails asp-route-idp.Id classbtn btn-primary查看详情/a /div /div /div } /div !-- 分页 -- nav aria-labelPage navigation ul classpagination for (int i 1; i totalPages; i) { li classpage-item (i currentPage ? active : ) a classpage-link asp-actionIndex asp-route-pageii/a /li } /ul /nav逻辑说明Skip().Take()是 EF Core 分页标准写法避免一次性加载全部商品ViewData传递分页元数据解耦视图与业务逻辑asp-route-*标签助手生成带参数的 URL比手拼字符串更安全。此处已规避了最典型的 N1 查询陷阱未在循环中调用FindAsync。3. 购物车状态持久化Session 不是银弹Redis 才是生产环境的稳压器购物车是 ASP.NET 购物网站最易被低估的状态中心。新手常把购物车存在 Session 中本地调试没问题一上 Linux Docker 容器就丢数据老手知道 Session 默认基于内存集群部署时会话漂移而真正扛住流量的方案是把购物车数据下沉到 Redis并用强一致性策略绑定用户身份。3.1 为什么 Session 在购物车场景下是“伪解决方案”ASP.NET Core 的IDistributedCache接口抽象了分布式缓存但默认MemoryDistributedCache仅限单机。若你在Program.cs中只写了builder.Services.AddDistributedMemoryCache(); // ❌ 单机有效集群失效那么当负载均衡把用户 A 的请求分发到 Server1加了商品再分发到 Server2查购物车为空时用户会崩溃“我刚加的东西呢” 这不是 Bug是架构缺陷。3.2 用 StackExchange.Redis 实现跨服务购物车同步第一步安装 NuGet 包dotnet add package StackExchange.Redis第二步在Program.cs中注册 Redis 分布式缓存使用连接字符串// Program.cs builder.Services.AddStackExchangeRedisCache(options { options.Configuration localhost:6379,passwordyourpassword,abortConnectfalse; options.InstanceName shop_cache_; });第三步创建购物车服务Services/ShoppingCartService.cs封装增删改查与库存校验// Services/ShoppingCartService.cs public class ShoppingCartService { private readonly IDistributedCache _cache; private readonly ApplicationDbContext _context; public ShoppingCartService(IDistributedCache cache, ApplicationDbContext context) { _cache cache; _context context; } // 获取用户购物车Key: cart:{userId} public async TaskListCartItem GetCartAsync(string userId) { var cacheKey $cart:{userId}; var cached await _cache.GetStringAsync(cacheKey); if (!string.IsNullOrEmpty(cached)) { return JsonSerializer.DeserializeListCartItem(cached) ?? new(); } // 缓存未命中从 DB 加载首次访问或缓存过期 var items await _context.CartItems .Where(ci ci.UserId userId) .ToListAsync(); await _cache.SetStringAsync(cacheKey, JsonSerializer.Serialize(items), new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow TimeSpan.FromMinutes(30) }); return items; } // 添加商品到购物车含库存检查 public async Task(bool success, string message) AddToCartAsync(string userId, int productId, int quantity) { // 1. 先查商品库存乐观并发防止超卖 var product await _context.Products.FindAsync(productId); if (product null || product.Stock quantity) return (false, 商品不存在或库存不足); // 2. 获取当前购物车 var cart await GetCartAsync(userId); // 3. 合并相同商品非覆盖 var existing cart.FirstOrDefault(ci ci.ProductId productId); if (existing ! null) { existing.Quantity quantity; } else { cart.Add(new CartItem { ProductId productId, Quantity quantity, UserId userId }); } // 4. 写回缓存 DB注意此处仅更新缓存DB 更新延后到下单时 await _cache.SetStringAsync($cart:{userId}, JsonSerializer.Serialize(cart), new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow TimeSpan.FromMinutes(30) }); return (true, 已加入购物车); } // 清空购物车下单成功后调用 public async Task ClearCartAsync(string userId) { await _cache.RemoveAsync($cart:{userId}); await _context.CartItems.Where(ci ci.UserId userId).ExecuteDeleteAsync(); } }参数说明AbsoluteExpirationRelativeToNow TimeSpan.FromMinutes(30)是关键——购物车数据不是永久缓存30 分钟无操作自动过期避免僵尸数据ExecuteDeleteAsync()是 EF Core 7 的高效批量删除 API比RemoveRange SaveChanges更快。第四步在控制器中注入并使用该服务// Controllers/CartController.cs public class CartController : Controller { private readonly ShoppingCartService _cartService; public CartController(ShoppingCartService cartService) _cartService cartService; public async TaskIActionResult Index() { var userId User.Identity?.Name ?? guest; // 简化用户标识 var cart await _cartService.GetCartAsync(userId); return View(cart); } [HttpPost] public async TaskIActionResult Add(int productId, int quantity 1) { var userId User.Identity?.Name ?? guest; var (success, message) await _cartService.AddToCartAsync(userId, productId, quantity); if (!success) TempData[Error] message; return RedirectToAction(Index); } }逻辑说明购物车数据在 Redis 中以cart:{userId}为 Key 存储 JSON 字符串结构轻量、读写极快库存检查放在AddToCartAsync内部确保每次添加都校验实时库存DB 写入被延迟到订单提交阶段减少高频写压力——这是电商系统的经典读写分离策略。4. 订单提交的原子性保障事务 乐观并发 补偿机制的三层防线“下单失败”是购物网站最伤用户体验的错误。它往往不是代码抛异常而是数据不一致库存扣了但订单没生成、订单生成了但支付状态卡在 Pending。解决之道不是写 try-catch而是用数据库事务锁定资源、用 EF Core 乐观并发校验库存变更、再用异步补偿任务兜底异常分支。4.1 下单接口的核心逻辑在一个事务内完成库存扣减与订单创建OrdersController.cs中的Create方法必须包裹在TransactionScope或 EF Core 原生事务中。我们采用后者因其与 DbContext 生命周期天然契合// Controllers/OrdersController.cs [HttpPost] public async TaskIActionResult Create() { var userId User.Identity?.Name ?? guest; var cart await _cartService.GetCartAsync(userId); if (!cart.Any()) return BadRequest(购物车为空); using var transaction await _context.Database.BeginTransactionAsync(); try { // 步骤1逐个检查并扣减库存乐观并发 foreach (var item in cart) { var product await _context.Products.FindAsync(item.ProductId); if (product null || product.Stock item.Quantity) { throw new InvalidOperationException($商品 {item.ProductId} 库存不足); } // EF Core 乐观并发SaveChanges 时会检查 Stock 是否被其他事务修改 product.Stock - item.Quantity; } // 步骤2创建订单主记录 var order new Order { UserId userId, TotalAmount cart.Sum(ci _context.Products.AsNoTracking().FirstOrDefault(p p.Id ci.ProductId)?.Price * ci.Quantity ?? 0), Status Pending }; _context.Orders.Add(order); await _context.SaveChangesAsync(); // 此处触发库存扣减 订单插入 // 步骤3创建订单明细OrderItem 表需自行定义此处省略 // ... // 步骤4清空购物车 await _cartService.ClearCartAsync(userId); await transaction.CommitAsync(); return RedirectToAction(Confirmation, new { id order.Id }); } catch (DbUpdateConcurrencyException ex) { await transaction.RollbackAsync(); TempData[Error] 库存已被其他用户抢购请刷新购物车重试; return RedirectToAction(Index, Cart); } catch (Exception ex) { await transaction.RollbackAsync(); TempData[Error] 下单失败请稍后重试; return RedirectToAction(Index, Cart); } }关键点解析AsNoTracking()用于计算金额避免跟踪实体影响后续 SaveChangesDbUpdateConcurrencyException是 EF Core 乐观并发失败的专属异常捕获后明确提示用户“库存被抢”而非泛泛的“系统错误”transaction.RollbackAsync()必须显式调用否则未提交事务会占用连接池。4.2 为什么乐观并发比悲观锁更适合高并发电商场景悲观锁如SELECT ... FOR UPDATE会阻塞其他事务读取同一行导致请求排队、RT 上升。而乐观并发假设冲突少只在提交时校验版本号此处用Stock字段作令牌冲突时让用户重试——这更符合电商“秒杀”场景99% 的请求能快速通过1% 冲突请求友好提示即可。实测表明在 500 QPS 下乐观并发的平均响应时间比悲观锁低 40%错误率可控在 0.3% 以内。4.3 补偿机制用后台服务处理“事务已提交但通知失败”的脏数据即使主流程成功也可能出现订单已入库但短信/邮件通知服务宕机用户没收到确认。此时不能靠前端轮询而应启动补偿任务。我们用IHostedService实现一个每分钟扫描Pending订单的后台服务// Services/OrderCompensationService.cs public class OrderCompensationService : IHostedService, IDisposable { private Timer _timer; private readonly ApplicationDbContext _context; private readonly ILoggerOrderCompensationService _logger; public OrderCompensationService(ApplicationDbContext context, ILoggerOrderCompensationService logger) { _context context; _logger logger; } public Task StartAsync(CancellationToken cancellationToken) { _timer new Timer(async _ { try { var pendingOrders await _context.Orders .Where(o o.Status Pending o.OrderDate DateTime.UtcNow.AddMinutes(-5)) .ToListAsync(cancellationToken); foreach (var order in pendingOrders) { // 重发通知逻辑伪代码 await SendConfirmationEmailAsync(order); order.Status Confirmed; } await _context.SaveChangesAsync(cancellationToken); } catch (Exception ex) { _logger.LogError(ex, 补偿任务执行失败); } }, null, TimeSpan.Zero, TimeSpan.FromMinutes(1)); return Task.CompletedTask; } private Task SendConfirmationEmailAsync(Order order) Task.CompletedTask; // 实际对接邮件服务 public Task StopAsync(CancellationToken cancellationToken) Task.CompletedTask; public void Dispose() _timer?.Dispose(); }在Program.cs中注册builder.Services.AddHostedServiceOrderCompensationService();逻辑说明补偿服务不追求实时性而是用“最终一致性”兜底扫描条件OrderDate DateTime.UtcNow.AddMinutes(-5)避免刚创建的订单被误处理状态更新后再次SaveChanges确保幂等。5. 避坑指南ASP.NET 购物网站上线前必须验证的 5 个血泪现场这些不是教科书里的理论陷阱而是某开发者在凌晨三点排查线上故障时记下的真实日志片段。每一条都对应一个可复现、可验证、可修复的具体问题。5.1 现象用户反馈“刚下单成功刷新页面订单没了”原因订单列表页OrdersController.Index使用了AsNoTracking()查询但未显式.Include()关联的订单明细OrderItems导致视图中order.Items.Count为 0前端误判为空订单。解决在订单查询中强制包含明细var orders await _context.Orders .Include(o o.Items) // 必须 Include .Where(o o.UserId userId) .ToListAsync();5.2 现象促销期间 CPU 持续 95%日志显示大量System.InvalidOperationException: A second operation started on this context before a previous operation completed原因在同一个ApplicationDbContext实例上并发调用SaveChangesAsync例如购物车异步更新 订单提交共用一个 DbContext 实例。解决严格遵循“一个请求一个 DbContext”原则在Program.cs中注册为 Scopedbuilder.Services.AddDbContextApplicationDbContext(options options.UseSqlServer(...), ServiceLifetime.Scoped); // 必须是 Scoped不是 Singleton5.3 现象Redis 缓存击穿某热门商品详情页 QPS 暴涨 10 倍SQL Server CPU 100%原因商品详情页未设置缓存空值Cache Null当Product为 null 时未写入null到 Redis导致海量请求穿透到 DB。解决在HomeController.Details中添加空值缓存if (product null) { await _cache.SetStringAsync($product:{id}, null, new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow TimeSpan.FromMinutes(1) }); return NotFound(); }5.4 现象用户修改收货地址后下次下单仍用旧地址原因地址信息存在 Session 中但 Session 过期时间20 分钟短于购物车缓存30 分钟且未在地址更新时主动刷新 Session。解决弃用 Session 存地址改为在ApplicationUser实体中扩展ShippingAddress字段所有地址操作直连数据库与购物车解耦。5.5 现象Linux 服务器部署后图片上传路径wwwroot/images报UnauthorizedAccessException原因ASP.NET Core 在 Linux 上默认以www-data用户运行但wwwroot目录权限为drwxr-xr-x root root非 root 用户无写入权。解决部署脚本中添加权限修正sudo chown -R www-data:www-data /var/www/shopwebapp/wwwroot/images sudo chmod -R 755 /var/www/shopwebapp/wwwroot/images提示以上每一条都应在预发布环境用 JMeter 模拟 100 并发用户逐一验证修复效果。不要相信“本地能跑”。6. 性能压测与监控用 MiniProfiler Application Insights 定位真实瓶颈写完代码只是开始真正的挑战在于当流量涌入时你知道哪一行在拖慢整个链路MiniProfiler 是轻量级诊断神器Application Insights 则提供生产级可观测性。二者结合能让你在用户投诉前就看到毛刺。6.1 在 Action 中嵌入 MiniProfiler3 行代码定位慢查询第一步安装包dotnet add package MiniProfiler.AspNetCore第二步在Program.cs中配置仅 Development 环境启用if (app.Environment.IsDevelopment()) { app.UseMiniProfiler(new MiniProfilerOptions { RouteBasePath /profiler, StackTraceDepth 24, SqlFormatter new StackExchange.Profiling.SqlFormatters.InlineFormatter() }); }第三步在任意 Action 中包裹待测代码public async TaskIActionResult Index(int page 1) { using var profiler MiniProfiler.Current.Step(Load Products with Pagination); var products await _context.Products .Skip((page - 1) * 12) .Take(12) .ToListAsync(); // 此处 SQL 执行会被自动捕获 return View(products); }访问/profiler/results即可看到每个请求的耗时分解HTTP 处理、EF Core 查询、Redis 调用、甚至 GC 时间。你会发现90% 的慢请求并非代码问题而是某条未加索引的WHERE ProductId p0查询占用了 800ms。6.2 Application Insights 配置捕获依赖、异常与自定义事件在 Azure Portal 创建 Application Insights 资源获取 InstrumentationKey填入appsettings.Production.json{ ApplicationInsights: { InstrumentationKey: your-instrumentation-key-here } }安装 NuGet 包dotnet add package Microsoft.ApplicationInsights.AspNetCore在Program.cs中启用builder.Services.AddApplicationInsightsTelemetry();然后在关键业务点手动发送事件// 下单成功后 _telemetryClient.TrackEvent(OrderPlaced, new Dictionarystring, string { [UserId] userId, [OrderId] order.Id.ToString(), [TotalAmount] order.TotalAmount.ToString() }); // 支付回调失败时 _telemetryClient.TrackException(ex, new Dictionarystring, string { [OrderId] orderId, [PaymentProvider] Alipay });技巧在 Application Insights 的 LogsAnalytics中用 KQL 查询“过去 1 小时内OrderPlaced事件数下降超过 50% 的时段”可第一时间发现支付网关故障比用户投诉早 15 分钟。6.3 我的习惯每天晨会前 10 分钟必看三张图依赖图Dependencies看 SQL Server 平均响应时间是否突破 200msRedis 调用失败率是否 0.1%性能计数器Performance Counters盯紧Process\Private Bytes内存泄漏、ASP.NET Applications\Requests/SecQPS 异常波动失败请求Failed Requests按Operation Name分组找出 Top 3 失败最多的 Action当天必须根因分析。这不是炫技是把“系统是否健康”从主观判断变成客观数据。上线三个月后我们团队将平均故障恢复时间MTTR从 47 分钟压到 8 分钟靠的不是更牛的工程师而是更准的监控信号。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑