
简介一套基于ASP.NET Core MVC与SQL Server 2012构建的商城系统完整源码面向初中级C#开发者以及正在学习企业级Web应用分层架构的学员。项目涵盖首页、商品列表、商品详情、购物车、下单页面、订单管理及登录等核心业务模块可帮助理解电商系统的典型数据流与后端实现方式。资源包共413个文件约31.31MB以C#源码cs、Razor视图cshtml、DTO/映射配置json、map以及SQL Server数据库文件mdf/ldf为主体同时包含大量jpg、webp、png等商品图片素材便于直接调试运行和二次开发。目前已有1075人学习下载适合用作毕业设计参考、商城项目脚手架或ASP.NET Core MVC实战练习。通过研究项目中的订单与购物车逻辑、数据库设计及页面交互可较快掌握从数据访问到界面渲染的完整串联方法。1. 从“增删改查”到真正的商城系统这套组合到底在解决什么一位在传统行业IT部门干了五年的朋友被老板拉去做经销商的在线下单商城需求听上去就是“几个页面加个购物车”。但真正动手后商品多规格、库存扣减、订单状态流转、SQL Server 并发锁每一步都能让项目返工。这篇文章要讲的就是用ASP.NET Core MVC SQL Server把这套商城系统从零搭起来的具体路径。它不教秒杀百万并发的夸张架构只解决最现实的问题中小团队、中小流量、老板要能上线能收款。这套组合适合的人很明确团队已经被 .NET 绑定、手里有现成 SQL Server 运维经验、或者刚从 .NET Framework / Web Forms 转过来的开发者。它不追求技术上的新鲜感但追求交付速度和踩坑可控。2. 先把骨架搭起来ASP.NET Core MVC SQL Server 的最小可运行工程2.1 为什么是这三件套选型逻辑与适用边界“商城系统”这个词在外包和招聘需求里出现频率很高但同一个词在不同团队里可能是三个完全不同的东西纯前台展示站、带购物车和支付的交易站、带进销存和会员的中台。ASP.NET Core MVC SQL Server 这套组合适合的是第二种和第三种也就是需要服务端渲染、需要事务一致性、需要和传统企业数据库做对接的场景。方案适用场景主要短板ASP.NET Core MVC服务端渲染页面、SEO 友好、和 EF Core 配合同步开发复杂前端交互要写不少 JS前后端耦合较紧Razor Pages表单驱动的管理后台、页面逻辑简单复杂路由和页面间传递数据不灵活Web API 前端分离小程序 / App / 多端共用一套接口首屏 SEO 弱需要 Node 或静态托管配合为什么不是 MySQL如果你在 Windows 环境里做企业项目SQL Server Management StudioSSMS的调试体验是最顺手的执行计划、死锁图、索引建议都是图形化界面直接看。下载 SQL Server 2019/2022 Developer 或 Express 版就能开始它们对开发和小流量部署完全够用。如果你是从 .NET Framework 时代转过来这套组合的学习曲线也最平缓。边界在哪如果你的团队全是前端 React/Node 背景后端没必要硬上 .NET如果你预估单量真到“秒杀”量级架构要另做设计靠 MVC 硬扛没意义。但中小商城、门户加交易这套组合确实是最省人的方案。2.2 用 dotnet CLI 建出一个能跑的 MVC 工程我习惯用命令行而不是 Visual Studio 的模板向导因为命令可控、可重复换电脑重来一遍也不会漏步骤。# 创建项目-n 是项目名-o 是输出目录 dotnet new mvc -n Shop.Web -o src/Shop.Web # 进入项目目录确认当前 SDK 版本 cd src/Shop.Web dotnet --version紧接着把 EF Core 的 SQL Server 包装上这是后续所有数据操作的基础dotnet add package Microsoft.EntityFrameworkCore.SqlServer dotnet add package Microsoft.EntityFrameworkCore.Tools dotnet add package Microsoft.EntityFrameworkCore.Design参数说明Microsoft.EntityFrameworkCore.SqlServer是核心驱动包Tools提供dotnet ef命令行能力Design是执行 migrations 时的运行时依赖。装完后如果提示找不到dotnet ef执行dotnet tool install --global dotnet-ef装全局工具即可。这时跑一下dotnet run看到模板自带的欢迎页说明 SDK 和项目模板没问题。这一步少做了后面连不上数据库时很难分清是代码问题还是环境问题。2.3 在 SQL Server 里把第一张业务表建出来项目能跑是一回事能把数据读出来才是真开始。我一般先手工建库不让 EF Core 第一次就跑全量迁移原因是让 DBA 或你自己先看清楚排序规则、文件增长参数再交给代码同步。-- 如果库不存在则创建 -- COLLATE 指定排序规则CHINESE_PRC_CI_AS 避免中文字段查询乱码 IF DB_ID(ShopDB) IS NULL CREATE DATABASE ShopDB COLLATE Chinese_PRC_CI_AS; GO USE ShopDB; GO -- 商品表先建一张最简的后续再扩展 SKU 和库存 CREATE TABLE dbo.Products ( Id INT IDENTITY(1,1) PRIMARY KEY, Name NVARCHAR(100) NOT NULL, Price DECIMAL(18,2) NOT NULL, Status TINYINT NOT NULL DEFAULT 1, -- 0 下架 1 上架 CreatedAt DATETIME2(7) NOT NULL DEFAULT SYSDATETIME() ); GO这里有几个关键点。NVARCHAR(100)必须用 N 开头的前缀类型否则中文会变问号DECIMAL(18,2)是金额字段的标配18 位总精度、2 位小数钱永远不要用FLOATStatus用TINYINT而不是BIT因为商品状态往往不止上下架两种后面加“草稿”“审核中”不需要改表结构DATETIME2(7)比老式DATETIME精度高推荐直接用。连接工具上如果你下载安装的是 SQL Server 默认实例服务器名填localhost如果装的是 Express 版实例名通常是.\SQLEXPRESS这一字之差会让新手卡半小时。2.4 配置连接字符串并注册 DbContext数据库建好后把连接字符串写进appsettings.json{ ConnectionStrings: { ShopDb: Serverlocalhost;DatabaseShopDB;Trusted_ConnectionTrue;TrustServerCertificateTrue; } }Trusted_ConnectionTrue表示用 Windows 身份验证登录开发机不需要配用户名密码。TrustServerCertificateTrue这个参数很关键本机开发时 SQL Server 的证书往往是自签名的不加这一项新版驱动会直接拒绝连接并报证书错误。然后在Program.cs里注册 DbContextvar builder WebApplication.CreateBuilder(args); // 注册 DbContext生命周期默认 Scoped一个请求一个上下文 builder.Services.AddDbContextShopDbContext(options options.UseSqlServer(builder.Configuration.GetConnectionString(ShopDb))); builder.Services.AddControllersWithViews(); var app builder.Build();逻辑说明AddDbContext默认生命周期是Scoped也就是每个 HTTP 请求范围内共享同一个 DbContext 实例。这个设计对 Web 应用是合理的——一个请求内多次查询共享上下文能拿到同一份状态跟踪但注意绝对不能注册成Singleton否则多请求并发操作同一个 DbContext 会直接抛异常。2.5 用控制器把商品列表页跑通往Models放一个最简实体然后在控制器里做第一版查询。// Models/Product.cs public class Product { public int Id { get; set; } public string Name { get; set; } string.Empty; public decimal Price { get; set; } public byte Status { get; set; } public DateTime CreatedAt { get; set; } } // Data/ShopDbContext.cs public class ShopDbContext : DbContext { public ShopDbContext(DbContextOptionsShopDbContext options) : base(options) { } public DbSetProduct Products { get; set; } null!; } // Controllers/ProductsController.cs public class ProductsController : Controller { private readonly ShopDbContext _db; public ProductsController(ShopDbContext db) _db db; public async TaskIActionResult Index() { var list await _db.Products .Where(p p.Status 1) .OrderByDescending(p p.CreatedAt) .ToListAsync(); return View(list); } }代码逻辑不复杂但有一个新手常犯的错用IEnumerableProduct接结果然后在 View 里循环时每条再查一次子表造成 N1 查询。这里直接用ToListAsync()一次性把数据拉回来后续章节会专门展开这个问题。View 里用foreach渲染商品卡片即可MVC 模板自带的Layout已经包含了 Bootstrap 样式不需要额外引前端库。model IEnumerableProduct div classrow foreach (var p in Model) { div classcol-md-4 h3p.Name/h3 pp.Price.ToString(C)/p /div } /div到这里一个最小可运行的 ASP.NET Core MVC SQL Server 商城骨架已经成型能建库、能连库、能读数据、能出页面。下一步是把它补成真正能商用的结构也就是商品、SKU、库存和订单这些核心域的建模。3. 商城核心域建模商品、SKU、库存与订单的数据库设计落地3.1 SPU 与 SKU 拆解商城商品为什么不能只有一张表第一次做商城的人最常见的翻车点把商品和规格放在同一张表。卖连衣裙红色 M 码和黑色 L 码要插两条记录价格、库存分别维护但名称、图片、详情全是重复的。等买家搜“连衣裙”出来 20 条长得几乎一样的记录改个标题要全表 UPDATE迟早数据不一致。正确做法是拆成 SPU 和 SKU 两层模型。买家看到的“商品详情”是 SPU加入购物车和结算时用的是 SKU。对比项SPU商品SKU货品举例连衣裙连衣裙 / 红色 / M 码价格展示用最低价实际结算价库存不直接扣减扣减的字段在这里图片主图、详情图可选规格图唯一标识商品 IdSkuCode 全局唯一这样的好处改商品名只需要改一条 SPU 记录每个 SKU 独立维护价格和库存后续加“尺码”“颜色”属性只需要改 SKU 的属性 JSON不动表结构。3.2 表结构从 Product 到 OrderItem 的完整建表 SQL商城核心表至少有五张商品 SPU、商品 SKU、库存表、订单头、订单明细。下面这套建表 SQL 是精简但完整的最小集。USE ShopDB; GO -- 商品主表SPU -- Status: 0草稿, 1上架, 2下架 CREATE TABLE dbo.Products ( Id INT IDENTITY(1,1) PRIMARY KEY, Name NVARCHAR(200) NOT NULL, CategoryId INT NOT NULL DEFAULT 0, MainImage NVARCHAR(500) NOT NULL DEFAULT , Description NVARCHAR(MAX) NOT NULL DEFAULT , Status TINYINT NOT NULL DEFAULT 0, CreatedAt DATETIME2(7) NOT NULL DEFAULT SYSDATETIME() ); GO -- SKU 表一个商品多个规格 -- SpecJson 存规格的 JSON例如 {颜色:红色,尺码:M} CREATE TABLE dbo.ProductSkus ( Id INT IDENTITY(1,1) PRIMARY KEY, ProductId INT NOT NULL FOREIGN KEY REFERENCES dbo.Products(Id), SkuCode NVARCHAR(64) NOT NULL, SpecJson NVARCHAR(500) NOT NULL DEFAULT {}, Price DECIMAL(18,2) NOT NULL, OriginalPrice DECIMAL(18,2) NOT NULL DEFAULT 0, Status TINYINT NOT NULL DEFAULT 1, CreatedAt DATETIME2(7) NOT NULL DEFAULT SYSDATETIME(), CONSTRAINT UQ_ProductSku_Code UNIQUE (SkuCode) ); GO -- 库存表单独拆出来方便做锁定库存、秒杀预占 -- RowVersion 是乐观锁字段后面并发章节会用到 CREATE TABLE dbo.Inventories ( SkuId INT NOT NULL PRIMARY KEY FOREIGN KEY REFERENCES dbo.ProductSkus(Id), AvailableStock INT NOT NULL DEFAULT 0, LockedStock INT NOT NULL DEFAULT 0, RowVersion ROWVERSION ); GO -- 订单头 CREATE TABLE dbo.Orders ( Id INT IDENTITY(1,1) PRIMARY KEY, OrderNo NVARCHAR(40) NOT NULL, UserId INT NOT NULL, TotalAmount DECIMAL(18,2) NOT NULL, Status TINYINT NOT NULL DEFAULT 0, -- 0待支付 1已支付 2已发货 3已完成 4已取消 CreatedAt DATETIME2(7) NOT NULL DEFAULT SYSDATETIME(), PaidAt DATETIME2(7) NULL, CONSTRAINT UQ_Order_No UNIQUE (OrderNo) ); GO -- 订单明细冗余存储商品名、规格、下单时价格 -- 为什么冗余因为商品改名、改价不能影响历史订单 CREATE TABLE dbo.OrderItems ( Id INT IDENTITY(1,1) PRIMARY KEY, OrderId INT NOT NULL FOREIGN KEY REFERENCES dbo.Orders(Id), SkuId INT NOT NULL, ProductId INT NOT NULL, ProductName NVARCHAR(200) NOT NULL, SkuSpecJson NVARCHAR(500) NOT NULL DEFAULT {}, UnitPrice DECIMAL(18,2) NOT NULL, Quantity INT NOT NULL, LineAmount DECIMAL(18,2) NOT NULL ); GO字段设计上几个值得说的点SKU 的Price才是实际结算价SPU 表里没有价格字段商品列表页展示的最低格用MIN(Sku.Price)聚合得到订单明细里的ProductName和SkuSpecJson是冗余字段这不是偷懒而是订单是历史事实商品改名改价后订单记录不能跟着变OrderNo必须做唯一约束这是后续幂等处理的基础。3.3 EF Core 实体映射与 Migration 生成数据库手写 SQL 建完表后要让 EF Core 认识这些表需要写对应的实体类和 DbContext 配置。// Models/Order.cs public class Order { public int Id { get; set; } public string OrderNo { get; set; } string.Empty; public int UserId { get; set; } public decimal TotalAmount { get; set; } public byte Status { get; set; } public DateTime CreatedAt { get; set; } public DateTime? PaidAt { get; set; } public ListOrderItem Items { get; set; } new(); } // Models/ProductSku.cs public class ProductSku { public int Id { get; set; } public int ProductId { get; set; } public string SkuCode { get; set; } string.Empty; public string SpecJson { get; set; } {}; public decimal Price { get; set; } public byte Status { get; set; } public Product? Product { get; set; } public Inventory? Inventory { get; set; } } // Data/ShopDbContext.cs 里追加 DbSet 和 Fluent 配置 protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.EntityProductSku(e { // 金额字段精度必须显式声明 e.Property(p p.Price).HasPrecision(18, 2); // SkuCode 唯一索引 e.HasIndex(p p.SkuCode).IsUnique(); }); modelBuilder.EntityOrder(e { e.Property(p p.TotalAmount).HasPrecision(18, 2); e.HasIndex(p new { p.UserId, p.CreatedAt }); }); modelBuilder.EntityOrderItem(e { e.HasIndex(p p.OrderId); }); }配置好实体后用 EF Core 迁移把剩余表结构同步到数据库。# 生成迁移文件InitShop 是迁移名称 dotnet ef migrations add InitShop # 执行迁移把未存在的表建出来 dotnet ef database update逻辑说明迁移文件会记录一个快照后续每次改实体都通过migrations add生成增量脚本database update只应用未执行的迁移。但注意如果按 2.3 节手工建过库首次执行InitShop会报“表已存在”。常见做法是删除手工库或用-Force生成空迁移把初始化 SQL 交给迁移脚本来管。我一般直接删掉手工库让迁移从零建全部表保证开发库和生产库结构完全一致。3.4 购物车用内存还是落库购物车是商城系统里的“看似简单”模块。三种方案各有取舍方案优点缺点Cookie 购物车匿名可用、零成本、不占数据库容量约 4KB改价风险换设备丢失数据库购物车登录后跨设备同步、可分析需要额外建表匿名用户要先合并Redis 购物车性能高、支持过期引入新组件运维复杂度上升对中小商城我建议 Cookie 起步登录后把购物车数据合并进数据库。Cookie 方案的核心是序列化购物车到 JSON然后加密存储// Services/CartService.cs public class CartService { private readonly IHttpContextAccessor _http; public CartService(IHttpContextAccessor http) _http http; public ListCartItem GetCart() { var cookie _http.HttpContext.Request.Cookies[cart]; if (string.IsNullOrEmpty(cookie)) return new ListCartItem(); // 实际项目这里要做解密和反序列化 return JsonSerializer.DeserializeListCartItem(cookie) ?? new(); } public void SaveCart(ListCartItem items) { var json JsonSerializer.Serialize(items); _http.HttpContext.Response.Cookies.Append(cart, json, new CookieOptions { Expires DateTimeOffset.Now.AddDays(7) }); } }Cookie 购物车的坑在于大小限制。单个 Cookie 上限约 4096 字节按 SKU Id 加数量来存几十项没问题但如果商品带赠品、优惠券、备注很快会溢出。所以购物车一旦超过二十项我会自动切换成数据库方案逻辑是匿名时用临时 UserId 写库登录后通过手机号或邮箱合并这个切换逻辑放到服务层控制器不感知。4. 订单提交与库存扣减事务、并发与 MVC 控制器的协同4.1 下单流程拆解五个动作必须在一个事务里一个最小可用的下单接口至少要完成五个动作校验购物车参数和价格、锁定库存、生成订单头和订单明细、清空购物车、跳转支付。这五个动作要么全部成功要么全部失败。最经典的翻车场景是扣了库存没生成订单用户收到支付成功通知但订单列表里没有记录或者生成了订单但库存没扣超卖后客服被投诉淹没。事务在这里是硬要求不是可选项。EF Core 的SaveChangesAsync默认自带事务但那只是保证单次保存内的操作原子。扣库存、写订单、清购物车是三次独立的数据库操作必须显式开启一个跨操作的事务。4.2 用 EF Core 的显式事务锁住库存先看核心代码[HttpPost] [ValidateAntiForgeryToken] public async TaskIActionResult SubmitOrder(OrderSubmitViewModel model) { var userId GetCurrentUserId(); // 从 Claims 里取 // 开启显式事务保证后续所有写操作要么全成要么全败 await using var transaction await _db.Database .BeginTransactionAsync(System.Data.IsolationLevel.ReadCommitted); try { foreach (var item in model.Items) { // 关键SQLUPDATE 自带行级锁并发请求会在这里排队 // WHERE 条件里带上 AvailableStock quantity天然防止超卖 var affected await _db.Database.ExecuteSqlInterpolatedAsync( $UPDATE dbo.Inventories SET AvailableStock AvailableStock - {item.Quantity} WHERE SkuId {item.SkuId} AND AvailableStock {item.Quantity}); if (affected 0) { await transaction.RollbackAsync(); return Json(new { ok false, msg $库存不足: {item.SkuId} }); } } // 生成订单头 var order new Order { OrderNo GenerateOrderNo(), UserId userId, TotalAmount model.TotalAmount, Status 0 }; _db.Orders.Add(order); await _db.SaveChangesAsync(); // 生成订单明细 foreach (var item in model.Items) { _db.OrderItems.Add(new OrderItem { OrderId order.Id, SkuId item.SkuId, ProductId item.ProductId, ProductName item.ProductName, SkuSpecJson item.SpecJson, UnitPrice item.UnitPrice, Quantity item.Quantity, LineAmount item.UnitPrice * item.Quantity }); } await _db.SaveChangesAsync(); // 清空购物车 await _db.Carts.Where(c c.UserId userId).ExecuteDeleteAsync(); await transaction.CommitAsync(); return Json(new { ok true, orderId order.Id }); } catch (Exception ex) { await transaction.RollbackAsync(); _logger.LogError(ex, 下单失败); return Json(new { ok false, msg 下单失败请稍后重试 }); } }这段代码的精华是那一句UPDATE ... WHERE AvailableStock quantity。它把“检查库存”和“扣减库存”合并成一条原子 SQLSQL Server 在执行 UPDATE 时会对命中的行加排他锁并发请求会排队后到的事务读到的是已扣减后的值因此不会超卖。ExecuteSqlInterpolatedAsync是参数化查询不是字符串拼接不用担心 SQL 注入。ExecuteDeleteAsync是 EF Core 7 以上提供的批量删除 API直接生成DELETE WHERE语句不会把购物车数据先加载到内存。注意BeginTransactionAsync传了IsolationLevel.ReadCommitted这是 SQL Server 默认级别读操作不会被写操作阻塞够用如果你的商城里同一个 SKU 的下单并发极高再考虑把隔离级别提到Serializable但代价是死锁概率上升不要盲目上。4.3 rowversion 乐观锁并发抢购场景的第二道防线UPDATE ... WHERE AvailableStock quantity能防住超卖但有一个场景防不了后台管理员在改库存比如盘点后把可用库存从 10 改到 8同时一个买家正在下单。这两个操作并发时管理员覆盖了买家的扣减结果或者反过来。解决方式是给库存表加rowversion字段EF Core 会在更新时自动把它拼进 WHERE 子句。// 并发修改库存先读取再尝试保存 var inventory await _db.Inventories.FirstOrDefaultAsync(x x.SkuId skuId); if (inventory is null) return NotFound(); // 拿到当前行的 RowVersion作为乐观锁凭据 var rowVersion inventory.RowVersion; inventory.AvailableStock - quantity; try { await _db.SaveChangesAsync(); } catch (DbUpdateConcurrencyException ex) { // 说明这行数据在我读取后被别人改过 // 此时只能重新读取最新值让用户确认后重试 var entry ex.Entries.Single(); await entry.ReloadAsync(); _logger.LogWarning(库存并发冲突: SkuId{SkuId}, skuId); return Conflict(商品库存信息已变化请刷新后重试); }逻辑说明rowversion是一个自动递增的二进制版本号每次行被更新都会变。EF Core 在SaveChangesAsync生成 UPDATE 时会自动带上WHERE RowVersion original如果影响行数为 0就抛DbUpdateConcurrencyException捕获后重新读取最新数据即可。但注意乐观锁是给“低频后台修改”用的不能替代扣库存的原子 UPDATE。下单主流程继续用 4.2 的原子更新后台调库存用乐观锁两边互补。4.4 失败补偿与幂等支付回调重复通知怎么处理支付回调是商城系统里最容易出事故的环节。微信、支付宝的支付结果通知会重试多次而且不保证只通知一次。如果回调处理没有幂等用户支付成功后第一次回调更新了订单状态第二次回调再处理时可能会生成两条发货记录或者把已发货订单又变成已支付。幂等的标准做法是拿外部交易号做唯一键public async TaskIActionResult PaymentCallback(PaymentNotifyModel model) { // 校验支付平台签名这是第一道安全关卡 if (!VerifySignature(model)) return BadRequest(invalid signature); // 重要以支付平台的交易号为幂等键 // 第一次进来插入记录第二次进来会因为唯一约束直接失败 var exists await _db.PaymentLogs .AnyAsync(p p.TransactionId model.TransactionId); if (exists) return Ok(duplicate); // 直接返回成功避免支付平台继续重试 // 开启事务更新订单状态 写入支付日志 // 这段要包在事务里保证两件事同时成功 ... }代码背后有两层保障。第一层是业务判断查PaymentLogs表里有没有这个TransactionId有就说明处理过。第二层是数据库约束给PaymentLogs.TransactionId加唯一索引并发重复回调时只有一条 INSERT 能成功另一条抛唯一约束异常。这两层缺一不可只靠业务判断在极端并发下也会被穿透。事务里先更新订单状态为“已支付”再插入支付日志。PaidAt字段在这个时机写入。完成后返回Ok支付平台收到非 5xx 响应就不会再重试。5. 商城上线路上的高频事故与排查清单从连接失败到日志爆炸5.1 SQL Server 登录失败与“已成功建立连接但在登录前握手失败”现象用 SSMS 或连接字符串登录时报错“已成功与服务器建立连接但是在登录过程中发生握手错误”provider: TCP Provider, error: 0 - 远程主机强迫关闭了一个现有的连接。或者报用户sa登录失败。原因分三类第一SQL Server 安装时选了“Windows 身份验证模式”没有开启混合模式SQL 账号完全登录不了第二sa账号被禁用第三客户端和服务器之间 TLS 协议版本不匹配老版本 SQL Server 遇到新驱动会直接掐断连接。解决用 Windows 身份验证先登录 SSMS执行下面的 SQL 开启混合模式并启用sa然后重启 SQL Server 服务。-- 1. 启用混合认证模式 USE master; GO EXEC xp_instance_regwrite NHKEY_LOCAL_MACHINE, NSoftware\Microsoft\MSSQLServer\MSSQLServer, NLoginMode, REG_DWORD, 2; GO -- 2. 启用 sa 账号并设置强密码 ALTER LOGIN sa ENABLE; GO ALTER LOGIN sa WITH PASSWORD YourStrongPassword!; GO -- 3. 重启服务后在注册表层面生效 -- 在服务管理器里重启 SQL Server 服务注意第 1 步直接改注册表比sp_configure更可靠因为 SQL Server 的认证模式就是存在注册表里。改完必须重启服务。5.2 EF Core N1商品列表慢到用户直接关页面现象商品列表接口数据量不到 100 条页面加载却要 3 秒。开启 SQL Profiler 一看执行了 101 条查询一条查商品然后逐条查每个商品的 SKU 或分类。原因EF Core 的延迟加载默认开启代码里访问了导航属性比如product.SkusEF 就给每条商品单独发一条查询。这就是经典的 N1 问题。解决预加载加拆分查询。var list await _db.Products .Include(p p.Skus) // 预加载 SKU生成 JOIN .AsSplitQuery() // 拆成多条 SQL避免笛卡尔膨胀 .Where(p p.Status 1) .OrderByDescending(p p.CreatedAt) .ToListAsync();逻辑说明Include让 EF Core 生成一条带 JOIN 的 SQL 一次性取回商品和 SKUAsSplitQuery会把 JOIN 拆成多条查询分别执行避免商品多 SKU 多时结果集笛卡尔膨胀。另外如果列表页只需要展示名称和价格不需要整个实体用投影更好var list await _db.Products .Where(p p.Status 1) .Select(p new ProductListItem { Id p.Id, Name p.Name, Price p.Skus.Min(s s.Price) }) .ToListAsync();投影生成的 SQL 是SELECT Id, Name FROM ...不加载不需要的列性能比Include更稳。5.3 SQL Server 日志文件无限增长与 FULL 恢复模式现象数据库的.mdf文件才 2GB旁边的.ldf日志文件已经膨胀到 50GB磁盘报警。原因SQL Server 默认恢复模式是 FULL每次事务都写完整日志而且日志文件只有在备份日志后才会截断。如果项目没有配置定期备份作业日志文件只会长不会缩。解决中小项目没有时间点恢复需求直接切到 SIMPLE 模式然后把日志文件收缩到合理大小。-- 1. 查看当前恢复模式 SELECT name, recovery_model_desc FROM sys.databases WHERE name ShopDB; -- 2. 切换为简单模式日志自动截断不再无限增长 ALTER DATABASE ShopDB SET RECOVERY SIMPLE; -- 3. 收缩日志文件到 100MB按需调整 DBCC SHRINKFILE (ShopDB_log, 100);注意DBCC SHRINKFILE需要指定逻辑文件名先执行SELECT file_id, name FROM sys.database_files WHERE type 1查出来。另外一旦切到 SIMPLE 模式你就失去了数据库级别的时间点恢复能力如果业务对数据安全要求高先把 FULL 备份和日志备份作业配好再考虑收缩。5.4 IIS 部署后接口 404、静态资源正常现象本地dotnet run一切正常发布到 IIS 后静态图片和 CSS 能加载但访问控制器页面全部 404或者页面出来了POST 提交表单报 404。原因IIS 上没装 .NET Runtime Hosting Bundle或者web.config里的托管管道设置有误。解决在服务器上安装对应版本的ASP.NET Core Runtime Hosting Bundle并确认发布目录里有web.config内容里包含hostingModelsystem.webServer handlers add nameaspNetCore path* verb* modulesAspNetCoreModuleV2 resourceTypeUnspecified / /handlers aspNetCore processPathdotnet arguments.\Shop.Web.dll stdoutLogEnabledfalse stdoutLogFile.\logs\stdout hostingModelinprocess / /system.webServerhostingModelinprocess是 ASP.NET Core 默认推荐模式性能更好。如果这里写成outofprocess而服务器上没装对应版本的 .NET 运行时也会 404。发布时从 Visual Studio 或dotnet publish生成的web.config一般没问题不要手动改坏。5.5 decimal 与 float 的精度事故现象订单金额有时候算出来是99.9900000001或者100.0000004用户对账对不上。原因某个金额字段用了float或double或者计算时没加m后缀导致隐式转换成 double。浮点数本质是二进制近似存金额必出精度问题。解决数据库统一DECIMAL(18,2)C# 统一decimal算钱时字面量加m后缀。decimal unitPrice 99.99m; // m 后缀是关键 decimal quantity 3m; decimal total unitPrice * quantity; // 结果是 299.97不会出现精度漂移注意Math.Round的银行家舍入问题默认MidpointRounding.ToEven会把 2.5 舍成 2、3.5 舍成 4。账单金额的舍入要显式指定decimal amount Math.Round(rawAmount, 2, MidpointRounding.AwayFromZero);这条踩坑记录看起来低级但它确实是商城上线后最常见的人工对账差异来源值得单独排查一次。6. 用日志、索引与性能追踪给商城系统做一次“体检”6.1 十分钟定位慢查询从 SET STATISTICS TIME 到执行计划商城系统上线后第一次被投诉“页面打开慢”不要急着加 Redis 缓存先用 SQL Server 给 DB 做一次体检。SET STATISTICS TIME ON; SET STATISTICS IO ON; -- 实际业务查询比如查某个订单的全部明细 SELECT oi.* FROM dbo.OrderItems oi WHERE oi.OrderId 12345;SET STATISTICS TIME ON会显示 CPU 时间和执行耗时毫秒SET STATISTICS IO ON会显示逻辑读次数。逻辑读超过几千一般是缺索引或回表严重。在 SSMS 里按CtrlL可以直接看图形化执行计划找那个开销最大的运算符通常是Table Scan或Key Lookup对应就是索引缺失。6.2 商城必建的三个索引索引不是越多越好但下面这三个是商城系统的高频路径必须建。-- 1. 订单按用户查并按创建时间倒序用于“我的订单”列表 CREATE INDEX IX_Orders_UserId_CreatedAt ON dbo.Orders (UserId, CreatedAt DESC); -- 2. 订单明细按订单查 CREATE INDEX IX_OrderItems_OrderId ON dbo.OrderItems (OrderId); -- 3. 商品列表按状态和创建时间 CREATE INDEX IX_Products_Status_CreatedAt ON dbo.Products (Status, CreatedAt DESC);如果要更进一步给OrderItems的索引加INCLUDE列把ProductName、UnitPrice包含进去让查询直接从索引覆盖结果不用回表CREATE INDEX IX_OrderItems_OrderId_Include ON dbo.OrderItems (OrderId) INCLUDE (ProductName, UnitPrice, Quantity);6.3 把体检变成日常请求日志与慢操作监控我对商城项目的最低监控要求是两条记录每次 HTTP 请求的耗时记录 EF Core 生成的慢 SQL。实现这两个需求不需要额外组件EF Core 自带日志钩子builder.Services.AddDbContextShopDbContext(options { options.UseSqlServer(builder.Configuration.GetConnectionString(ShopDb)); // 记录所有耗时超过 500ms 的 SQL options.LogTo( logger.LogWarning, LogLevel.Information, DbContextLoggerOptions.SingleLine | DbContextLoggerOptions.UtcTime) .WithFilter(l l DbLoggerCategory.Database.Command.Name l LogLevel.Information); });这样商城系统在正常运维期慢 SQL 会自然进入应用日志不需要每天盯着数据库看。另一个实用技巧是启用dotnet-counters监控内存和 GC 压力在流量上涨时能很快判断是数据库慢还是应用层内存问题。体检不是上线前做一次就完了商城是长期演进的系统每次大促前跑一遍慢查询、看一遍索引使用率、清一遍无用统计比临时加机器管用得多。我自己的习惯是每次版本发布后三天内每天花十分钟过一遍生产日志里的慢查询和异常堆栈顺手补索引或调整查询。养成这个习惯后商城上线后的夜间告警会少一大半希望帮到你。本文还有配套的精品资源点击获取