ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C#实战:ASP.NET Core打造高并发网上商城系统全解析

C#实战:ASP.NET Core打造高并发网上商城系统全解析 简介网上商城购物系统C#版是一套基于ASP.NET Web Forms的电商前后台源码适合C#初学者、课程设计者以及需要快速搭建购物原型的管理系统开发者。系统围绕商品浏览、购物车、订单处理等核心流程设计具备良好的人机界面与打印支持强调易维护、易操作运行稳定。压缩包共205个文件约1.51MB主要包含43个C#源文件、30个aspx页面、6个ascx用户控件、12个db数据库文件以及gif/jpg图片素材和css样式页面与逻辑分离便于按模块阅读和二次开发。已有676人学习下载借助该源码可掌握从数据库设计、业务层编码到Web界面渲染的完整链路也能作为毕业设计或商城类项目的改造基础。1. 项目概述与定位1.1 为什么选C#做网上商城作为一个写了多年C#的老开发我接到“网上商城购物系统”这个需求时第一反应是——选型太关键了。市面上做商城的方案很多Java有Spring Cloud那套PHP有ThinkPHPPython有DjangoNode.js有Express但如果你所在团队的技术栈本身就是.NET或者你个人想用一门类型安全、开发效率高、生态成熟的语言来做商城系统C#配ASP.NET Core基本就是最优解。C#在商城系统里的优势非常明显。首先强类型语言特性让整个购物车、订单、库存这类对数据准确性要求极高的模块在编译期就能揪出一大批低级错误。我见过不少用弱类型语言写的商城订单金额在运行时突然变成字符串或者库存数量被塞进一个带小数点的值这种问题在C#里几乎不会出现。其次ASP.NET Core的性能在TechEmpower的基准测试里常年排在前列处理几千个并发请求完全没有压力商城这种高并发读多写少的场景它很擅长。再一个就是生态EF Core做ORM、Serilog做日志、AutoMapper做对象映射、FluentValidation做参数校验社区里轮子足够多不需要什么都自己造。再说说适合谁来参考这份内容。如果你是刚学完C#语法基础想找项目练手但不知道从哪下手的初学者这篇内容能让你明白一个完整商城系统的骨架是怎么搭出来的。如果你是有一定经验、想转型或者接私活做商城项目的开发这篇文章里的架构设计、缓存策略、幂等处理、防超卖思路都是可以直接拿过去用的实战经验。我尽量把每个模块的思路、代码、坑都写清楚你照着一步步做是能真跑起来的。1.2 这个系统能做什么我最终做出来的这个C#版商城系统功能覆盖了从用户端到管理端的完整闭环。用户侧有账号注册登录、商品浏览与搜索、商品详情查看、加入购物车、下单购买、在线支付接了模拟支付方便测试、订单查询与取消。管理侧有商品信息的增删改查包括图片上传、上下架、库存管理、订单处理发货、完成、退款、用户管理、销售数据的简单统计。技术上后端用的是ASP.NET Core Web API EF Core SQL Server你也完全可以换成MySQL代码改动量很小前台页面我用了一套开源AdminLTE模板配合Razor视图你也可以用前后端分离的方式C#这边把API写好就完事。用户端我优先保证核心购物路径的流畅重点打磨了购物车和订单模块——这两个地方是业务最容易出错、面试官也最喜欢问的。2. 系统整体架构与核心技术选型2.1 分层架构设计思路做商城这种业务系统最重要的不是把功能堆上去而是让代码结构清晰、后续能维护。我采用的是经典的四层架构从上到下依次是表现层、业务层、数据访问层、数据库。这里要说明一点我用“表现层”而不是直接上微服务是因为商城系统的业务复杂度还没到需要拆服务的地步。单体架构把所有模块放在同一个进程里开发调试方便部署也简单一台服务器跑起来就行。等以后用户量大了再按商品、订单、用户去拆微服务也不迟C#这边的Ocelot、Consul都是现成的工具。项目结构长这样/src ├── XiaoChengXu.Web // ASP.NET Core MVC 前端表现层 ├── XiaoChengXu.Api // Web API 接口层给移动端/管理端用 ├── XiaoChengXu.Application // 业务逻辑层处理业务规则 ├── XiaoChengXu.Domain // 领域实体和核心业务模型 ├── XiaoChengXu.Infrastructure // 数据访问、仓储实现、EF Core DbContext └── XiaoChengXu.Common // 公共类库通用扩展方法、常量、工具类2.2 实体关系设计建表前先想清楚数据库设计是整个商城的根基表关系没理清的话后面写业务代码会非常痛苦。我花了两天时间把实体关系梳理清楚核心表就这几张用户表(User)用户ID、用户名、密码(Hash存储)、昵称、手机号、邮箱、创建时间等。商品表(Product)商品ID、商品名称、描述、价格、库存、主图URL、状态(上架/下架)、分类ID、创建时间等。商品分类表(Category)分类ID、分类名称、父级分类ID支持多级分类。购物车表(CartItem)购物车ID、用户ID、商品ID、购买数量、加入时间。订单表(Order)订单ID、订单号、用户ID、订单状态(待支付/已支付/已发货/已完成/已取消)、总金额、支付方式、收货人信息、创建时间、支付时间等。订单明细表(OrderItem)明细ID、订单ID、商品ID、商品名称快照、商品单价快照、购买数量、小计金额。库存流水表(InventoryLog)流水ID、商品ID、变更数量、变更类型(入库/出库/锁定/解锁)、关联订单号、操作时间。有一点我当时没考虑到导致后期返工——订单明细里没有保存商品名称和单价的快照。这意味着如果商品改价或改名历史订单显示就会跟着变这在真实商城里是绝对不允许的。后来我加了字段商品当前名和价格随时会变但订单里得还原用户下单那一刻的真实信息。2.3 核心NuGet包推荐我把我用的NuGet包整理成一张表都是有稳定版本、长期维护的选择包名用途说明Microsoft.EntityFrameworkCore.SqlServerORM数据访问也可以用Pomelo.EntityFrameworkCore.MySql来适配MySQLMicrosoft.AspNetCore.Authentication.JwtBearerJWT认证管理端API用JWT做无状态认证Swashbuckle.AspNetCore接口文档自动生成Swagger测试界面开发联调神器AutoMapper对象映射实体转DTO省下大量赋值代码FluentValidation参数校验用描述性代码替代满屏的if-else校验Serilog.AspNetCore日志记录结构化日志方便排查线上问题StackExchange.RedisRedis缓存与分布式锁热数据进行缓存下单时做防超卖处理3. 核心模块实现从零开始搭功能3.1 商品模块先把列表和详情做扎实商品列表是商城的门面用户进来第一眼看的就是它。我从一开始就打算用异步 缓存的方式来做不然数据量一上来数据库立刻成为瓶颈。商品列表的接口用的是标准的Async/Await模式配合EF Core的延迟加载和投影用DTO来返回数据而不是把整个实体都暴露出去public async TaskListProductDto GetPagedProductsAsync(int pageIndex, int pageSize, int? categoryId) { var query _context.Products .AsNoTracking() .Where(p p.Status ProductStatus.OnSale); if (categoryId.HasValue) { query query.Where(p p.CategoryId categoryId.Value); } var totalCount await query.CountAsync(); var products await query .OrderByDescending(p p.CreatedAt) .Skip((pageIndex - 1) * pageSize) .Take(pageSize) .Select(p new ProductDto { Id p.Id, Name p.Name, Price p.Price, MainImage p.MainImage, Stock p.Stock }) .ToListAsync(); return products; }商品详情的接口我会加上Redis缓存缓存key按照product:detail:{id}的格式缓存十分钟。十分钟对于商品价格来说算合理不会让用户感觉到数据太旧又能显著降低数据库压力。3.2 购物车设计后端存储而不是Cookie市面上有些简易商城把购物车数据塞在Cookie或者本地存储用户换台电脑购物车就没了体验很差。我更推荐的做法是存到后端数据库这样用户无论在哪个设备上登录购物车数据都能同步。购物车表设计成用户ID和商品ID的关联。加入购物车的逻辑要注意如果用户之前已经加过同一件商品应该直接累加数量而不是新插一条记录。我第一次做的时候没加唯一索引导致大量重复数据后来在CartItem表上加了(UserId, ProductId)的唯一索引同时在业务逻辑里用事务保证原子性public async TaskResult AddToCartAsync(int userId, int productId, int quantity) { // 校验商品是否存在且已上架 var product await _context.Products .AsNoTracking() .FirstOrDefaultAsync(p p.Id productId p.Status ProductStatus.OnSale); if (product null) return Result.Fail(商品不存在或已下架); // 基于唯一索引加锁防并发 await _semaphoreSlim.WaitAsync(); try { var cartItem await _context.CartItems .FirstOrDefaultAsync(c c.UserId userId c.ProductId productId); if (cartItem null) { _context.CartItems.Add(new CartItem { UserId userId, ProductId productId, Quantity quantity, CreatedAt DateTime.Now }); } else { cartItem.Quantity quantity; } await _context.SaveChangesAsync(); return Result.Ok(); } finally { _semaphoreSlim.Release(); } }在实际实践中购物车相关的并发冲突主要不是数量叠加问题而是库存量和用户操作顺序问题。建议在后端每次对购物车做增删改时都要校验当前商品状态与库存别等到下单支付完成后才去批量校验一张订单。这一步做好了后面订单各种纠纷能少很多。3.3 订单模块事务、幂等与状态机缺一不可订单模块是整个系统里最核心、最容易出大Bug的地方。我把它的核心要点拆成三块下单事务、幂等处理、状态机流转。首先是下单事务。从购物车生成订单涉及多个操作这些必须保证原子性——要么全部成功要么全部回滚。我在EF Core里用IDbContextTransaction开启显式事务将订单创建、订单明细写入、购物车清理、库存扣减全部包裹起来。我把上面的核心逻辑简化核心到这里展示await using var transaction await _context.Database.BeginTransactionAsync(); try { // 1. 从购物车拿商品并锁定 var selectedItems await _context.CartItems .Where(c c.UserId userId selectedCartItemIds.Contains(c.Id)) .Include(c c.Product) .ToListAsync(); if (!selectedItems.Any()) return Result.Fail(购物车为空或商品已失效); // 2. 计算总金额重新从数据库商品表读取价格绝不用前端传来的 decimal totalAmount 0; foreach (var item in selectedItems) { totalAmount item.Product.Price * item.Quantity; } // 3. 创建订单主记录 var order new Order { OrderNo GenerateOrderNo(), // 类似 202501151030001234 UserId userId, TotalAmount totalAmount, Status OrderStatus.PendingPayment, CreatedAt DateTime.Now }; _context.Orders.Add(order); await _context.SaveChangesAsync(); // 先拿到自增ID // 4. 批量写入订单明细 foreach (var item in selectedItems) { _context.OrderItems.Add(new OrderItem { OrderId order.Id, ProductId item.ProductId, ProductName item.Product.Name, UnitPrice item.Product.Price, Quantity item.Quantity, Subtotal item.Product.Price * item.Quantity }); } // 5. 清空已下单的购物车数据 _context.CartItems.RemoveRange(selectedItems); // 6. 扣减库存并检查扣减后库存非负 foreach (var item in selectedItems) { var product await _context.Products.FindAsync(item.ProductId); product.Stock - item.Quantity; if (product.Stock 0) { throw new InsufficientStockException($库存不足商品ID: {product.Id}); } // 记录库存流水 _context.InventoryLogs.Add(new InventoryLog { ProductId product.Id, ChangeQuantity -item.Quantity, ChangeType InventoryChangeType.Locked, OrderNo order.OrderNo, CreatedAt DateTime.Now }); } await _context.SaveChangesAsync(); await transaction.CommitAsync(); return Result.Ok(order.OrderNo); } catch (InsufficientStockException) { await transaction.RollbackAsync(); return Result.Fail(部分商品库存不足下单失败); } catch (Exception ex) { await transaction.RollbackAsync(); _logger.LogError(ex, 创建订单失败用户ID: {UserId}, userId); return Result.Fail(系统繁忙请稍后重试); }然后说幂等处理。用户如果网络抖动多点了几次下单按钮后端可能同时收到好几个相同内容的请求。如果没做幂等就会生成多笔重复订单。我的做法是在页面端生成一个唯一的idempotencyKeyGUID每次提交订单时带着它。后端在处理前先去Redis里查这个Key是否存在不存在就开始处理并在处理中写入Key处理完再更新状态如果Key已存在直接返回上次的处理结果。上面这段代码没有把幂等Key放进去实际项目中我会在Order表里加一个IdempotencyKey字段并建唯一索引数据库级别的约束才是最后一层最可靠的保障。最后说状态机。订单绝不能允许你随便从“待支付”改成“已完成”必须有清晰的状态流转规则。我用一个静态类管理订单状态定义好允许的流转路径public static class OrderStatusMachine { private static readonly DictionaryOrderStatus, OrderStatus[] _allowedTransitions new DictionaryOrderStatus, OrderStatus[] { { OrderStatus.PendingPayment, new[] { OrderStatus.Paid, OrderStatus.Cancelled } }, { OrderStatus.Paid, new[] { OrderStatus.Shipped, OrderStatus.Cancelled, OrderStatus.Refunding } }, { OrderStatus.Shipped, new[] { OrderStatus.Completed, OrderStatus.Refunding } }, { OrderStatus.Completed, Array.EmptyOrderStatus() }, { OrderStatus.Cancelled, Array.EmptyOrderStatus() }, { OrderStatus.Refunding, new[] { OrderStatus.Refunded, OrderStatus.Cancelled } }, }; public static bool CanTransition(OrderStatus current, OrderStatus target) { return _allowedTransitions.TryGetValue(current, out var allowed) allowed.Contains(target); } }所有改订单状态的入口都必须先调用CanTransition校验非法跳转直接拒绝并抛出领域异常。我当时就是没在意这个结果在测试时发现用户支付前还能取消订单但居然也可以在只有“待支付”状态下跑到“已发货”整个逻辑就乱了最后还是回到状态机这条路才把这块彻底理顺。3.4 库存扣减与防超卖一个说烂了但必须做对的点“防超卖”几乎是每个电商开发都会被问到的经典问题。所谓的超卖就是库存只剩1件但2个用户同时下单成功实际卖出去2件。我之前用过一个错误做法先把商品读出来判断库存大于0再减库存。这在单用户单线程下没问题一旦并发上来两个请求同时读到库存1都判断可以下单于是都执行减库存库存变成-1超卖就发生了。最稳妥的方案不是用分布式锁后来我发现性能损耗大、代码复杂而是用带条件的UPDATE语句在数据库层面做原子操作UPDATE Product SET Stock Stock - 1 WHERE Id productId AND Stock quantityEF Core里用ExecuteSqlInterpolatedAsync执行这条SQL然后检查受影响的行数。如果受影响行数为0说明库存被别的请求抢走了抛出库存不足异常回滚整个事务var affectedRows await _context.Database.ExecuteSqlInterpolatedAsync( $UPDATE Products SET Stock Stock - {quantity} WHERE Id {productId} AND Stock {quantity}); if (affectedRows 0) { throw new InsufficientStockException($商品ID: {productId} 库存不足); }这里要注意并发很高时数据库行锁可能成为瓶颈但单独一台数据库扛几千并发没问题对于商城系统前期完全够用。等量大了再上Redis预扣库存、加消息队列做异步削峰那是后话了。这笔订单防超卖方案成功之后我还做了一层“超时自动关闭订单”的兜底。用户下单后如果一直没付款订单会一直占着库存不放这对其他买家不公平。我写了一个后台任务每30秒扫一次待支付订单超过15分钟没付款的就自动取消同时把库存加回去这个任务用BackgroundService实现public class OrderTimeoutBackgroundService : BackgroundService { private readonly IServiceProvider _services; private readonly TimeSpan _timeout TimeSpan.FromMinutes(15); public OrderTimeoutBackgroundService(IServiceProvider services) { _services services; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { while (!stoppingToken.IsCancellationRequested) { try { using var scope _services.CreateScope(); var dbContext scope.ServiceProvider.GetRequiredServiceAppDbContext(); var logger scope.ServiceProvider.GetRequiredServiceILoggerOrderTimeoutBackgroundService(); var cutoffTime DateTime.Now.Add(-_timeout); var expiredOrders await dbContext.Orders .Where(o o.Status OrderStatus.PendingPayment o.CreatedAt cutoffTime) .ToListAsync(stoppingToken); foreach (var order in expiredOrders) { if (!OrderStatusMachine.CanTransition(order.Status, OrderStatus.Cancelled)) continue; await using var transaction await dbContext.Database.BeginTransactionAsync(stoppingToken); order.Status OrderStatus.Cancelled; order.CancelledAt DateTime.Now; order.CancelReason 超时未支付系统自动取消; // 释放库存 var orderItems await dbContext.OrderItems .Where(oi oi.OrderId order.Id) .ToListAsync(stoppingToken); foreach (var item in orderItems) { var product await dbContext.Products.FindAsync(item.ProductId); if (product ! null) { product.Stock item.Quantity; } } await dbContext.SaveChangesAsync(stoppingToken); await transaction.CommitAsync(stoppingToken); logger.LogInformation(订单 {OrderNo} 超时自动取消并释放库存, order.OrderNo); } } catch (Exception ex) { logger.LogError(ex, 执行订单超时任务时发生异常); } await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken); } } }用这种方式比“用户一直不取消订单就一直占库存”要优雅太多。3.5 扫码枪录入体验一个容易被忽略的功能之前看到热搜词里有“C#扫码枪触发事件”这让我想起在商城后台的商品管理中我特意做了一个扫码枪快速录入/查找商品的功能。很多小型商超或仓库场景里扫码枪的体验直接决定员工愿不愿意用你的系统。扫码枪的本质其实是一把USB键盘扫到条码后把字符快速发送到焦点所在的输入框并自动附带一个回车。这个特性我在开发后台时没有特别注意后来去现场调研才发现用户操作流程极为别扭——扫完商品还要手动处理焦点、回车逻辑。后端本身不涉及扫码枪驱动操作但前端可以用一段简单的JavaScript在网页上监听键盘事件把扫码枪输入当成键盘快速录入并且校验每次录入的时间间隔来判断是不是扫码枪的输入let barcodeBuffer ; let lastKeyTime 0; document.addEventListener(keydown, (e) { const currentTime Date.now(); // 判断输入间隔扫码枪的输入间隔通常在几分钟到几十毫秒之间远快于手动录入 if (currentTime - lastKeyTime 50) { barcodeBuffer ; } lastKeyTime currentTime; if (e.key Enter) { e.preventDefault(); if (barcodeBuffer.length 0) { handleScanResult(barcodeBuffer); } barcodeBuffer ; } else { barcodeBuffer e.key; } });这段JS在后台商品入库页很实用。用户扫一下条码系统自动查询商品如果存在就直接带出信息不存在就提示是否新增工作效率直线提升。这个细节虽然不算核心业务但对于B端用户体验是非常加分的一项。3.6 文件上传与图片处理商品图片上传也是商城系统的刚需。后台用Dropzone.js实现拖拽上传接口这边直接接收IFormFile存入wwwroot/uploads/products目录[HttpPost(upload)] public async TaskIActionResult UploadProductImage(IFormFile file) { if (file null || file.Length 0) { return BadRequest(请选择要上传的图片); } // 限制文件类型和大小 var allowedExtensions new[] { .jpg, .jpeg, .png, .webp }; var ext Path.GetExtension(file.FileName).ToLowerInvariant(); if (!allowedExtensions.Contains(ext)) { return BadRequest(不支持的图片格式); } if (file.Length 5 * 1024 * 1024) { return BadRequest(图片大小不能超过5MB); } // 使用GUID重命名避免文件名冲突和非法字符 var fileName ${Guid.NewGuid():N}{ext}; var uploadDir Path.Combine(Directory.GetCurrentDirectory(), wwwroot, uploads, products); if (!Directory.Exists(uploadDir)) { Directory.CreateDirectory(uploadDir); } var filePath Path.Combine(uploadDir, fileName); await using (var stream new FileStream(filePath, FileMode.Create)) { await file.CopyToAsync(stream); } var url $/uploads/products/{fileName}; return Ok(new { url }); }这里有个坑我之前吃过亏直接用了原始文件名存盘结果用户上传一个叫../../abc.jpg的文件路径穿越就让图片存到别的目录去了。用GUID重命名是最稳妥的既解决路径穿越问题也避免中文文件名编码带来的各种奇怪Bug。3.7 用户认证与授权用户端我用的是Cookie认证管理端API用的是JWT Bearer认证。Cookie认证在ASP.NET Core里配置非常简单builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) .AddCookie(options { options.LoginPath /Account/Login; options.AccessDeniedPath /Account/Denied; options.Cookie.Name XiaoChengXu.Auth; options.SlidingExpiration true; options.ExpireTimeSpan TimeSpan.FromHours(2); });密码存储是个老生常谈但总有人做错的事。绝对不能明文存密码也不能只用MD5因为彩虹表破解太容易了。我用了Rfc2898DeriveBytes也就是PBKDF2加随机盐的方式用PasswordHasherT这个内置类即可SHA256强度足够大多数场景var passwordHasher new PasswordHasherUser(); var hashedPassword passwordHasher.HashPassword(user, password);用户登录后将用户ID和用户名写入Claims方便后续在Controller里直接用User.Identity.Name拿到当前登录人信息。4. 性能优化与并发处理4.1 循环数据采集与UI刷新卡顿问题热搜词里有“C#循环数据采集和UI刷新卡顿”后台数据统计页面就遇到了类似问题。最开始我直接在UI线程上循环读取销售数据和商品数量数据量一上去界面直接卡死点哪里都没响应。原因是UI线程被长时间占用了消息泵无法处理用户输入。后来我优化成Task.Run放到后台线程计算再通过IProgressT回传UI线程来更新图表private async void LoadStatisticsButton_Click(object sender, EventArgs e) { btnLoad.Enabled false; try { var progress new Progressstring(msg { lblStatus.Text msg; // 安全地在UI线程上更新 }); await Task.Run(async () { progress.Report(开始统计订单数据...); await Task.Delay(100); // 模拟耗时统计 var orderCount await _dashboardService.GetOrderCountAsync(); progress.Report($订单总数: {orderCount}); // 模拟耗时统计 var totalSales await _dashboardService.GetTotalSalesAmountAsync(); progress.Report($销售总额: {totalSales} 元); // 更新图表数据 progress.Report(正在生成图表...); await Task.Delay(100); }); await LoadChartDataAsync(); // 最后一起刷新图表 } finally { btnLoad.Enabled true; } }核心心得是任何界面刷新操作都必须回到UI线程来执行如果界面刷新的频率想更流畅可以用定时器批量合并刷新或者用Channel实现生产者消费者去削峰不要一个数据来了就立刻画一次。4.2 高频数据用Redis缓存对于商城来说商品分类和热门商品列表属于读多写少的数据每次查数据库很浪费。我用Redis做了缓存配合HashSet存储商品ID列表再按需批量读取商品详情接口响应速度从平均180ms降到8ms左右。缓存一致性我用的是“先更新数据库再删除缓存”策略。为什么不先删缓存因为在高并发下如果你先删缓存另一个请求又读旧值把旧缓存写回去了数据库里的新值就永远没法被读到造成长期数据不一致。而先更新数据库再删缓存虽然删除那一下也可能失败但可以在删除失败时加一个短期的过期时间比如5分钟保证最终一致。4.3 高并发和UI卡顿踩坑复盘上有几个老生常谈但值得反复说的点不要用完DbContext就随手Dispose正确做法是依赖注入容器管理生命周期每次请求一个实例EF Core内部有连接池机制。大批量插入订单明细时不要逐条SaveChangesAsync用AddRange一次性提交性能差距能有几十倍。查询要加AsNoTracking()如果你只是展示数据而不做修改不跟踪实体可以减少大量内存开销。Socket通讯和工业级网口通讯在商城后台里不常见但如果你接的是扫码枪或电子秤设备核心原则是不要在业务线程里直接同步读写Socket用SocketAsyncEventArgs或Channel做异步缓冲才不会阻塞主流程。4.4 数据库索引调优我建完表后最容易被忽视的就是索引设计。在开发环境数据量小感觉不到问题一旦线上数据到了几十万没索引的查询能让你怀疑人生。我的索引设计经验订单表(User_Id)联合索引(Status, Created_At)联合索引用于定时扫单和超时自动关闭。商品表(Category_Id)索引(Status, Created_At)联合索引支撑列表页排序。购物车表(User_Id)主索引保证按用户查询购物车走索引。订单明细表(Order_Id)索引。库存流水表(Product_Id)索引。用SQL Server的话直接看执行计划就知道索引走没走。大约有70%的性能问题都是从“没建索引”或“索引没用上”延伸出来的。5. 部署与环境配置5.1 发布与部署步骤开发机上是Windows IIS跑的部署服务器我用的是Linux Nginx systemd也算验证一下跨平台能力。发布命令很简单dotnet publish -c Release -r linux-x64 --self-contained true -o ./publish--self-contained true意味着目标服务器不需要安装.NET Runtime所有东西都打包进去。这样部署最省心目录拷过去就能跑。缺点就是体积会大一些不过几百兆对服务器来说无所谓。服务器上创建一个systemd服务文件[Unit] DescriptionXiaoChengXu商城系统 [Service] WorkingDirectory/var/www/xiaochengxu ExecStart/var/www/xiaochengxu/XiaoChengXu.Web Restartalways RestartSec10 KillSignalSIGINT EnvironmentASPNETCORE_ENVIRONMENTProduction [Install] WantedBymulti-user.target配置完成后sudo systemctl enable xiaochengxu.service sudo systemctl start xiaochengxu.service sudo systemctl status xiaochengxu.service前端我用Nginx做反向代理顺便解决静态文件请求和HTTPS证书server { listen 80; server_name shop.example.com; location / { proxy_pass http://localhost:5000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection keep-alive; proxy_set_header Host $host; proxy_cache_bypass $http_upgrade; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } }需要说明的是shop.example.com只是部署示例域名我在本地测试时直接配一个内网机器的hosts就够用了。如果你的服务器有公网IP就把server_name改成你的真实域名再加一个certbot自动签HTTPS证书它的免费证书足够个人项目和小团队使用。5.2 日志与异常监控日志系统用Serilog配置成可以同时输出到控制台和滚动文件线上排查问题非常依赖它。我推荐在appsettings.json里配置日志级别开发环境配Information生产环境配Warning以上不然日志量太大会把磁盘撑爆。同时Serilog可以接阿里云日志服务或自己的时序数据库不过前期文件日志也够用。另外我做了全局异常处理中间件任何未捕获异常都返回统一的JSON格式不让默认错误页面泄露堆栈信息给用户。这个不只是安全问题也极大简化了前端处理异常的复杂度app.UseExceptionHandler(errorApp { errorApp.Run(async context { var exceptionHandlerPathFeature context.Features.GetIExceptionHandlerPathFeature(); var exception exceptionHandlerPathFeature?.Error; var logger context.RequestServices.GetRequiredServiceILoggerProgram(); logger.LogError(exception, 全局异常: {Path}, context.Request.Path); context.Response.StatusCode 500; context.Response.ContentType application/json; await context.Response.WriteAsJsonAsync(new { message 服务器开小差了请稍后重试 }); }); });6. 常见问题排查与避坑记录6.1 EF Core 查询慢有一段时间列表页很慢排查发现是因为Include加载了多层子实体导致生成的SQL join特别多且取了很多用不到的字段。解决方法是换成投影查询直接Select需要的DTO字段。能要三列就不要把整行50列全捞出来。另外如果只想取数量直接用AnyAsync()效率远高于Count() 0。6.2 并发下单导致库存变负数这个场景只在压测时暴露过。发生原因就是上面提到的“先读后改”的并发漏洞后来我用条件更新SQL解决了。补上这段之前我压测200个并发用户抢购同一件库存只有5件的商品库存直接变成负的数据惨不忍睹。6.3 用户登录状态丢失排查方式分为几层。先确认Cookie的Domain设置是否正确再确认是否设置了IsEssential true然后确认是否用HttpOnly。如果用的是自定义的Auth Cookie我看过的多数“登录后刷新就退出”案例最后都定位成Cookie没启动或加密密钥不一致导致解密失败。6.4 图片上传失败图片上传失败一般有两种原因。一种是文件太大IIS或Kestrel默认请求体大小限制30MB左右挡住了需要在Program.cs里调整MaxRequestBodySize。另一种是权限问题Linux下要确保运行应用的账户对wwwroot/uploads目录有写权限不然会静默失败或者报IO异常。6.5 部署后访问500如果本地运行正常部署到Linux就500八成是appsettings.Development.json和appsettings.Production.json差异的问题比如开发环境用SQL Server本机账号生产环境配置了错误的连接串。先去看systemd日志里有没有异常再检查环境变量设置。7. 写在最后的经验心得做完这个C#商城系统给我最大的感受是真正的难点不在某个技术点有多深而在于把一堆繁杂的细节串起来时如何保证不打架、不漏项。比如购物车、订单、库存这三者之间每个环节都有状态流转和一致性要求漏一个就可能造成脏数据。我个人的建议是如果你也想自己动手写一个类似的项目不要一上来就追求微服务、消息队列、分布式锁这些“看起来很高级”的技术。先把单体架构下的事务、幂等、状态机这些基本功做扎实把代码边界理清楚等真正遇到性能瓶颈时再引入Redis、Kafka这些外部组件也不迟。我见过太多初学者张口就要上RabbitMQ结果连下单事务都没写对那属于本末倒置。另外有一个细节可以扩展在真正上生产之前建议把支付回调接口改成支持“多支付渠道适配”的写法用一个IPaymentProvider接口隔离不同支付平台的差异。我最初只接了一种模拟支付后来要接真实渠道时不得不把回调验签逻辑从业务代码里拆出来比较费劲。如果你要接微信支付或支付宝从第一天就设计好这个抽象后面能省很多事。最后想说的是C#商城项目不一定要多庞大的功能才拿得出手。把用户认证、商品浏览、购物车、下单支付、订单管理这一条核心链路打磨得稳定顺滑已经算是一个很完整、很能体现工程能力的作品了。如果你正在用它学习、练手或者准备面试希望这些经验能帮你少踩几个坑。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进