☰
ASP.NET Core MVC + SQL Server 商城开发:模型设计、扣库存与避坑实践
2026/10/8 4:24:11 网站建设 项目流程

简介:这是一份基于ASP.NET Core MVC与SQL Server 2012的商城系统项目源码,面向.NET学习者、在校学生和初级开发人员,适合作为课程设计、毕业设计或电商项目实战的入门参考。项目完整展示了商城常见业务闭环,从用户登录、商品浏览、加入购物车、下单结算,到订单管理,每一步都可在代码中对应查看。资源包为RAR压缩格式,共413个文件,容量约31.31MB。文件构成较为完整,包含C#源代码35个、Razor视图页面17个、JavaScript脚本18个、CSS样式24个,以及DLL依赖库56个、JSON配置14个;另有大量JPG、WebP、PNG商品图片与界面素材,并附带SQL Server数据库文件、运行日志和项目缓存,方便整体还原项目开发环境。项目包含首页、商品列表、商品详情、购物车、下单页面、订单管理和登录等模块,后端基于SQL Server 2012,结合依赖注入、Razor视图与jQuery交互等典型写法,适合边读边改,为后续扩展成完整商城系统打下基础。目前已有1075人学习下载,适合需要一套可运行、可参考的ASP.NET Core MVC商城示例来提升实际开发能力。

1. 为什么「ASP.NET Core MVC + SQL Server + 商城系统」这个组合值得做

你在搜「ASP.NET Core MVC + SQL Server + 商城系统」时,通常不是来学语法的,而是手里已经有一个要交付的商城:商品展示、购物车、下单扣库存、订单查询,工期往往按周算。这个组合在 .NET 技术栈里属于最不容易翻车的一条正路:MVC 把页面和逻辑分开,SQL Server 负责库存和订单这类不能出错的数据,EF Core 在两者之间做映射。它的好处是开发链路短,从建表到页面跑通不需要引入额外中间件,适合中小团队、内部商城和外包交付项目。别一上来就规划微服务,先把商品、购物车、订单这一条闭环跑通,比什么都重要。

2. 数据模型与分层设计:先落好三张核心表,再写控制器

做商城的实际顺序和直觉相反:不是先画页面,而是先定表和模型。页面随时能调,订单表一旦上线就改不起。商品(Product)、购物车项(CartItem)、订单(Order)这三张表把「逛」和「买」两条链路分开:逛看 Product,买读 CartItem,下单写 Order 并扣 Product.Stock。我见过不少从视图开始做的项目,最后控制器里塞满了临时改数据的 SQL,问题往往出在模型没定边界,而不是页面难看。

2.1 商城的最小四层与存储选型:为什么用 EF Core 而不是手写 ADO.NET

一个能按期交付的商城项目,我一般只拆四层:Controllers 接 HTTP 请求、Services 放业务规则、Repositories 或直接 DbContext 访问数据、Views 只做渲染。四层不是给老板看的架构图,而是为了你改需求时少改一套代码。商品价格、库存、运费规则属于业务层;谁在什么时候把库存扣了,属于数据层。两者混在一个控制器里,调试 Session 和扣库存问题时,你会同时面对页面和数据库两个黑匣子。

数据访问默认用 Entity Framework Core,而不是手写 ADO.NET,原因很实际:第一,商城页面大多数是「按条件查列表、点开看详情、提交后写订单」,CRUD 占八成,EF Core 能让你少写一半样板代码;第二,EF Core 的参数化查询天然防 SQL 注入,新手不容易把字符串拼进 WHERE;第三,模型改字段后用迁移脚本同步数据库,比拿着 SSMS 手工改表可回滚。至于性能担心,EF Core 的查询是延迟执行,配合 AsNoTracking、分页和原生 SQL 出口,已经够撑起中小商城的日常流量。

如果你团队里有熟手坚持用 Dapper,也不是不行。只是这个标题下的常规路线是 EF Core,而且后续无论是分页、关联查询还是迁移,EF Core 的坑更少,教程也更多。选型的底线是:不要在控制器里直接拼 SQL 字符串去更新库存,这个问题我们到第 4 章专门讲。

2.2 Product、CartItem、Order:三个核心实体的最少字段

先把三个实体的代码写出来,这是整个商城的骨架。字段能减就减,能不加就不加,订单表尤其如此。

public class Product { public int Id { get; set; } public string Name { get; set; } = string.Empty; public string? Description { get; set; } public decimal Price { get; set; } public int Stock { get; set; } public string? Category { get; set; } public bool IsOnSale { get; set; } public byte[] RowVersion { get; set; } = Array.Empty<byte>(); } public class CartItem { public int Id { get; set; } public string UserKey { get; set; } = string.Empty; // 未登录时用临时标识 public int ProductId { get; set; } public Product? Product { get; set; } public int Quantity { get; set; } } public class Order { public int Id { get; set; } public string UserKey { get; set; } = string.Empty; public decimal Total { get; set; } public string Status { get; set; } = "Pending"; // Pending/Paid/Shipped/Cancelled public DateTime CreatedAt { get; set; } }

注意几个字段的用意。Price 用 decimal 而不是 float,金额精度不能靠浮点数碰运气,这在 SQL Server 那边也要一致。Stock 用 int,别用 double,库存只有整数。Order.Status 用字符串而不是 int,查数据库时「Pending」比「0」可读,后续加状态枚举也方便。UserKey 是我个人习惯:未登录用户用 Guid 放在 Cookie 里,登录后换成用户 Id,这样购物车不至于强迫用户先注册才能加购。

接着是 DbContext 和连接字符串,这是模型和 SQL Server 之间的桥:

public class ShopDbContext : DbContext { public ShopDbContext(DbContextOptions<ShopDbContext> options) : base(options) { } public DbSet<Product> Products => Set<Product>(); public DbSet<CartItem> CartItems => Set<CartItem>(); public DbSet<Order> Orders => Set<Order>(); }
{ "ConnectionStrings": { "Shop": "Server=localhost;Database=ShopDb;User Id=shop_user;Password=你的密码;TrustServerCertificate=True;MultipleActiveResultSets=True" } }

连接字符串里有三个参数值得解释。TrustServerCertificate=True 是给本地开发和自签证书用的,生产环境换成正式证书后应设为 False 或去掉,否则会有证书信任风险。MultipleActiveResultSets=True 允许同一个连接上同时打开多个结果集,页面里一边遍历商品一边查关联数据时能省事,但性能敏感接口一般不依赖它。密码用占位符写了,实际项目里别硬编码在 appsettings.json,应该放到 user secrets 或环境变量里。

2.3 用 Fluent API 把数据库规则固化:精度、唯一索引与行版本

实体定义了还不够,有些规则必须落到数据库层面代码里,防止换个人写代码就漏掉。我用 Fluent API 在 OnModelCreating 里集中声明,比散落各处的 Data Annotation 好维护:

protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity<Product>(entity => { entity.ToTable("Products"); entity.Property(p => p.Name).IsRequired().HasMaxLength(200); entity.Property(p => p.Price).HasColumnType("decimal(18,2)"); entity.HasIndex(p => p.Category); entity.Property(p => p.RowVersion).IsRowVersion(); }); modelBuilder.Entity<CartItem>(entity => { entity.ToTable("CartItems"); entity.HasIndex(ci => new { ci.UserKey, ci.ProductId }).IsUnique(); }); }

这段配置解决三件事。decimal(18,2) 保证金额在数据库里也是两位小数,不会出现 EF 映射成 decimal(18,0) 导致价格被截断成整数的问题。HasIndex 给 Category 加上普通索引,商品列表按分类筛选时不用全表扫描。CartItems 的联合唯一索引保证同一个用户同一个商品在购物车表里只有一行,重复加购只是把 Quantity 加一,而不是插两条脏数据。

RowVersion 是 SQL Server 的行版本列,每次有 UPDATE 语句改到这行,值会自动变。这是做并发控制的后手:扣库存时拿着旧版本号去更新,影响行数为 0 就说明数据被人改过,可以立刻重试或报错。EF Core 里的 HasRowVersion 配置会和 SQL Server 的 rowversion 类型对应,建表时自动生成。

写完后执行迁移,命令不长但必须记住:

dotnet ef migrations add InitShopSchema dotnet ef database update

第一条命令把当前模型和数据库的差异生成迁移文件,第二条命令把迁移真正应用到 SQL Server。你可以在迁移文件里检查生成的 SQL 是否符合预期,别直接闭眼 update。我见过有人把 Price 配成 decimal(18,2),迁移脚本里却因为数据库已有表而只改了列名没改精度,最后对账差了三分钱,这种问题查起来非常痛苦。

3. 控制器与视图:把商品列表、购物车和下单串成可点击的流程

模型定好后,下一步是让页面能点。控制器的核心原则是薄:HTTP 参数解析、调用数据访问、返回视图,别的都别干。下单时的库存扣减和事务属于业务层,购物车的合并逻辑也属于业务层,控制器里出现超过十行的业务代码,就该考虑抽 Service 了。我见过把整个下单流程 200 行塞进一个 Action 的项目,后来加一个「优惠券分摊运费」的需求,改得头皮发麻。

3.1 控制器如何拆、依赖注入如何配

控制器按业务边界拆,而不是按页面拆。商品相关的列表、详情、搜索放 ProductController,购物车的加购、改数量放 CartController,订单的创建和查询放 OrderController,首页简单展示给 HomeController。这样拆的好处是路由清晰、职责单一,测试时不用为了测下单而去请求商品详情页。

依赖注入方面,Program.cs 里把 DbContext 和数据库服务配好:

var builder = WebApplication.CreateBuilder(args); builder.Services.AddDbContext<ShopDbContext>(options => options.UseSqlServer(builder.Configuration.GetConnectionString("Shop"))); builder.Services.AddControllersWithViews(); var app = builder.Build(); app.UseStaticFiles(); app.UseRouting(); app.MapControllerRoute( name: "default", pattern: "{controller=Home}/{action=Index}/{id?}"); app.Run();

AddDbContext 默认注册为 Scoped,也就是一次 HTTP 请求内拿到同一个 DbContext 实例,这对商城是合适的。页面里先查商品再查库存,两次查询共享同一个上下文,不会再产生「DbContext 已被释放」的报错。AddControllersWithViews 是 MVC 模式的总开关,没它路由和视图渲染都不工作。

注意默认路由模板{controller=Home}/{action=Index}/{id?},id 是可选参数。商城商品的详情地址会自然生成/Product/Detail/10,购物车结算地址会是/Cart/Checkout/10。大多数页面用这套约定路由就够了,不用每个 Action 都写特性路由,但下一节有个例外。

3.2 商品列表与分页搜索的 IQueryable 组合写法

商品列表是商城流量最大的页面,搜索、分类、分页都是绕不开的。这里的关键是在数据库里完成过滤和分页,不要先把全部商品 ToList 到内存再筛选。下面是一个标准的 ProductController.Index 写法:

public class ProductController : Controller { private readonly ShopDbContext _db; public ProductController(ShopDbContext db) { _db = db; } public async Task<IActionResult> Index(string? q, string? category, int page = 1, int pageSize = 12) { var query = _db.Products.AsNoTracking().Where(p => p.IsOnSale); if (!string.IsNullOrWhiteSpace(q)) { query = query.Where(p => p.Name.Contains(q) || (p.Description != null && p.Description.Contains(q))); } if (!string.IsNullOrWhiteSpace(category)) { query = query.Where(p => p.Category == category); } var total = await query.CountAsync(); var items = await query .OrderBy(p => p.Id) .Skip((page - 1) * pageSize) .Take(pageSize) .ToListAsync(); var viewModel = new ProductListViewModel { Products = items, Page = page, PageSize = pageSize, Total = total, Q = q, Category = category }; return View(viewModel); } }

这段代码有四个关键点。AsNoTracking() 表示这些实体只读不追踪,列表页没有写操作,省掉变更追踪的开销。CountAsync 和 ToListAsync 分开执行,先拿总数用于分页条,再拿当前页数据。Skip/Take 是分页的标准写法,pageSize 设为 12,商城列表一屏放 12 个商品比较合适,用户翻页压力小。Contains 在 SQL Server 里会被翻译成 LIKE,数据库列上有索引时还能用,但要注意 LIKE 的匹配开销,商品量上了十万级后要观察执行计划。

视图这边很简单:

<form method="get" asp-action="Index"> <input type="text" name="q" value="@Model.Q" placeholder="搜商品" /> <select name="category"> <option value="">全部分类</option> <option value="手机">手机</option> <option value="配件">配件</option> </select> <button type="submit">搜索</button> </form> <ul> @foreach (var product in Model.Products) { <li> <a asp-action="Detail" asp-route-id="@product.Id">@product.Name</a> <span>@product.Price.ToString("C")</span> </li> } </ul>

表单用 method="get" 而不是 post,搜索参数会拼到 URL 上,用户可以复制搜索结果链接,刷新页面也不会触发重新提交弹窗。asp-action 和 asp-route-id 是 Tag Helper 的写法,它会根据路由模板自动生成链接,你改了路由模板视图不用跟着改。

3.3 特殊路由的指定:Attribute Route 与一个路由被吞的现场

默认约定路由能覆盖九成页面,但有几种情况必须用特性路由:比如想让购物车结算地址更短更语义化,或者某个接口不需要 controller 前缀。ASP.NET Core 里给某个方法指定特殊路由很直接:

[HttpGet("cart/checkout/{orderId}")] public IActionResult Checkout(int orderId) { return View(); }

这和 Spring MVC 里的 @RequestMapping 是一个套路,把 HTTP 动词和路由模板直接写在方法上。好处是 URL 好看、和控制器名解耦,坏处是如果你在控制器类上写了[Route("shop")],那么这个控制器下所有方法的默认路由全被覆盖,没写特性路由的 Action 直接 404。

这个坑我踩过一次,血泪经验:给 ProductController 加了个类级特性路由[Route("shop")]想统一品牌前缀,结果原来的/Product/Index变成/shop/Index,首页导购链接全部失效,排查了半天才意识到是控制器级路由把约定路由挤掉了。所以我的习惯是:类上不用特性路由,只在个别的 Action 上做局部指定;如果团队确实要控制器级前缀,那每个 Action 都得配上完整的 Route 模板,别一半靠约定一半靠特性。

4. SQL Server 端设计:建表、索引与不会超卖的扣库存事务

模型和控制器都动起来后,真正的底线在数据库。商城系统里商品可以少展示几个,库存和订单绝对不能错。SQL Server 在这种场景下的角色不只是存储,而是最后一道数据守门员。下面这些建表脚本和事务写法,是照着 EF Core 迁移生成的手工等价版,目的是让你清楚数据库层面到底发生了什么。

4.1 Products 与 Orders 的建表脚本:约束和索引要落到 DDL

EF Core 的迁移能生成表,但手工看一眼 DDL 能让你知道字段约束和索引确实建上了。商品表和订单表的最小脚本如下:

CREATE TABLE dbo.Products ( Id INT IDENTITY(1,1) NOT NULL, Name NVARCHAR(200) NOT NULL, Description NVARCHAR(MAX) NULL, Price DECIMAL(18,2) NOT NULL, Stock INT NOT NULL, Category NVARCHAR(50) NULL, IsOnSale BIT NOT NULL CONSTRAINT DF_Products_IsOnSale DEFAULT(1), RowVersion ROWVERSION NOT NULL, CONSTRAINT PK_Products PRIMARY KEY CLUSTERED (Id), CONSTRAINT CK_Products_Stock_NonNegative CHECK (Stock >= 0) ); CREATE INDEX IX_Products_Category ON dbo.Products(Category);

这里的 CHECK 约束CK_Products_Stock_NonNegative值得多说一句:它保证 Stock 字段永远不能小于 0,一旦应用层的扣库存 SQL 写漏了条件,SQL Server 会直接拒绝更新而不是让库存变成负数。这是最后一道闸,应用层可以有自己的判断,但数据库防线必须存在。

订单表同样需要约束,Status 字段用 CHECK 限定取值范围,避免程序 bug 写入脏状态:

CREATE TABLE dbo.Orders ( Id INT IDENTITY(1,1) NOT NULL, UserKey NVARCHAR(64) NOT NULL, Total DECIMAL(18,2) NOT NULL, Status NVARCHAR(20) NOT NULL, CreatedAt DATETIME2 NOT NULL CONSTRAINT DF_Orders_CreatedAt DEFAULT(SYSUTCDATETIME()), CONSTRAINT PK_Orders PRIMARY KEY CLUSTERED (Id), CONSTRAINT CK_Orders_Status CHECK (Status IN ('Pending', 'Paid', 'Shipped', 'Cancelled')) );

主键默认是聚集索引,Id 自增列做聚集索引在商城场景下没问题,因为订单写入是递增的,页分裂少。Category 上的普通索引是非聚集索引,覆盖按分类筛选的列表查询。Order 表按用户查订单的场景也常见,可以加一个 UserKey 的索引,但订单表数据量上来之前不必过度设计。

4.2 扣库存不超卖:用一条 UPDATE 的条件更新完成并发控制

商城上线后第一个被骂的 bug 通常是超卖:明明库存只有 10 件,却同时卖出去 15 单。根因是很多团队写了「先查库存、再判断、后更新」的三步流程,两个并发请求同时读到库存 10,都判断足够,然后都把库存改成 9,最后库里剩 8 还是负的,完全看运气。

正确做法是让数据库的 UPDATE 语句自己完成判断和更新,这是一个原子操作:

public async Task<IActionResult> Buy(int productId, int quantity) { var affected = await _db.Database .ExecuteSqlInterpolatedAsync( $@"UPDATE dbo.Products SET Stock = Stock - {quantity} WHERE Id = {productId} AND Stock >= {quantity}"); if (affected == 0) { return BadRequest("库存不足或商品已下架"); } // 库存扣减成功后,再创建订单,并把两步放进同一个事务 return RedirectToAction("Checkout", new { id = productId }); }

这条 UPDATE 的原理是条件更新:Stock >= quantity作为 WHERE 条件,SQL Server 执行时会锁定这一行,并发请求里只有一个能修改成功,其他请求的影响行数返回 0。用 affected 判断是否成功,比先查再改可靠得多。ExecuteSqlInterpolatedAsync 是 EF Core 提供的原生 SQL 出口,注意用的是插值语法而不是拼接字符串,参数会被自动参数化,不会引入 SQL 注入。

下单不能只扣库存,还要写订单表和订单明细。这两步必须包在同一个事务里,否则可能库存扣了、订单没建,钱货两空的投诉就来了:

await using var transaction = await _db.Database.BeginTransactionAsync(); try { // 执行上面的 UPDATE 扣库存 // 插入 Orders 表记录 // 插入 OrderItems 明细 await transaction.CommitAsync(); } catch { await transaction.RollbackAsync(); throw; }

事务的范围越小越好,只在扣库存和写订单这几步之间包事务,别把用户输入校验、图片上传这类几十毫秒的操作也塞进去,事务持有锁的时间越长,并发冲突概率越大。

4.3 连接字符串与登录方式:从 Windows 身份验证到 SQL 登录的取舍

连接字符串的选择直接影响你本地开发和上线的排错体验。本地开发常用 Windows 身份验证,调试方便,不用管密码策略:

Server=localhost\SQLEXPRESS;Database=ShopDb;Trusted_Connection=True;TrustServerCertificate=True

部署到服务器后,应用池身份往往不是 Windows 域账号,更常见的是创建一个最小权限的 SQL Server 登录账号给应用用。两种方式对比如下:

登录方式适用场景注意点
Windows 身份验证本地开发、内网单机连接字符串不带密码,但发布到其他机器要处理账号映射
SQL Server 登录开发、测试、生产均可需要启用混合验证模式,密码单独管理,定期轮换

企业里经常遇到「已成功与服务器建立连接,但是在登录前握手阶段失败」或用户名密码正确却登不上的情况,多数是 TCP/IP 协议没启用,或者 SQL Server 只开了 Windows 验证模式。这个坑我放到下一章的避坑清单里详细拆。

5. 上线前避坑清单:连接、路由、精度与并发五连坑

这部分是我整理的实际踩坑记录,每一条都是「现象→原因→解决」的结构。做商城项目时照着排查一遍,能省下不少凌晨改代码的时间。

5.1 连接故障:已成功与服务器建立连接,但是在登录前被断开

现象:连接字符串和账号密码都核对过,SSMS 能连上,但 ASP.NET Core 程序启动时报「已成功与服务器建立连接,但是在登录前握手阶段发生错误」,或者直接报 18456 登录失败。

原因:两个最常见。第一,SQL Server 网络配置里的 TCP/IP 协议没有启用,SSMS 通过共享内存或命名管道能连,但应用通过 TCP 的 1433 端口连不上。第二,服务器只开了 Windows 身份验证模式,SQL Server 登录账号完全不可用,程序用 User Id 和 Password 去连自然失败。

解决:打开 SQL Server 配置管理器,找到实例的「SQL Server 网络配置」,把 TCP/IP 设为启用,重启 SQL Server 服务;再用 SSMS 登录服务器,右键服务器属性,把身份验证模式切成「SQL Server 和 Windows 身份验证模式」。给应用账号重新设一个符合密码策略的密码,连接字符串保持稳定。这一套做完,绝大多数连接问题就消失了。

提示:改完 SQL Server 网络配置后必须重启实例,只改配置不重启,连接情况没有变化。重启会踢掉现有连接,选业务低峰期操作。

5.2 数据精度与路由遮蔽:decimal 被截断和特殊路由吞页面

现象一:商品价格在列表页显示正常,但提交订单后总金额差了几分钱,甚至某些商品价格变成了整数。

原因:数据库里的 Price 列被建成了 decimal(18,0),小数部分直接四舍五入没了。这通常是手写建表脚本时没写精度,或者 EF Core 迁移前数据库里已存在旧表,迁移只加了列没改精度。

解决:PRICE 列统一用 decimal(18,2),EF Core 一侧的 Fluent API 也要写成 HasColumnType("decimal(18,2)"),两边对齐后重新生成迁移脚本。上线前的验证方法是找几个价格带小数的商品,从前台下单走到对账,金额必须一分不差。

现象二:给某个方法加了特殊路由后,原来的商品列表页和详情页全部 404。

原因:控制器类上加了[Route("shop")]之类类级特性路由,导致该控制器下所有 Action 的默认约定路由全部失效,只有写了独立 Route 模板的方法才可访问。

解决:类上不要加特性路由,只在个别 Action 上用[HttpGet("cart/checkout/{orderId}")]这种写法。如果非要控制器级统一前缀,就必须给控制器下每一个 Action 都补充路由模板,这是义务不是选项。

5.3 并发超卖与会话丢失:库存变成负数和购物车被清空

现象:活动开始一小时内卖出了超过库存数量的订单,后台看到库存变成负数,或者数据库里 CHECK 约束直接把更新语句拒绝,前端用户看到报错。

原因:代码走了「先 SELECT Stock,再 if 判断,最后 UPDATE」的流程,并发过来后多个请求读到同一个旧库存,判断都通过,最后更新互相覆盖。

解决:把扣库存改成一条条件 UPDATE,也就是第 4 章写的SET Stock = Stock - @quantity WHERE Id = @productId AND Stock >= @quantity,用影响行数判断是否扣减成功,再把写订单包进同一事务。如果希望支持重试,可以再用 RowVersion 做版本校验,失败后重新读取库存重试一次。

现象:用户把商品加进购物车,过了一段时间再打开,购物车空了。尤其是重新发布站点或应用池回收之后。

原因:购物车数据存到了内存 Session 里,IIS 应用池回收或进程重启后内存清空,用户会话就丢了。

解决:商城系统里购物车应该持久化到数据库,用 CartItems 表保存 UserKey 和 ProductId,用户每次进页面实时读库。开发阶段用内存 Session 图省事可以,生产环境要么把 Session 存到 SQL Server,要么直接走数据库购物车,后者更符合商城的业务语义,用户换设备购物车也还在。

6. 本地跑通整套方案的最小路线与并发自测技巧

从空目录到一个能下单的商城,我习惯的本地验证路线是这样。先建项目,再补模型和配置,然后迁移数据库,最后跑一个并发脚本验证扣库存逻辑。

dotnet new mvc -o ShopDemo cd ShopDemo dotnet add package Microsoft.EntityFrameworkCore.SqlServer

写好第 2 章的实体、ShopDbContext 和连接字符串后,执行迁移命令,数据库里就有了 Products、CartItems、Orders 三张表。启动站点,访问商品列表页,手动加购一件商品,走完下单流程,这是功能层面的验收。

真正的并发自测用一个 PowerShell 脚本就能完成。假设商品 Id 为 1,初始库存 20 件,同时发 20 个请求各买 1 件:

1..20 | ForEach-Object -Parallel { Invoke-WebRequest -Uri "http://localhost:5000/Product/Buy?productId=1&quantity=1" -UseBasicParsing | Out-Null } -ThrottleLimit 10

跑完后查询库存:

SELECT Id, Stock FROM dbo.Products WHERE Id = 1;

如果扣库存逻辑正确,Stock 应该正好是 0。如果你用的是「先查再改」的老写法,结果大概率会出现负数,或者看到部分请求返回库存不足而实际库存还有很多剩余,这就是并发条件竞争的直接证据。这个自测脚本我每接一个商城项目都会跑一遍,成本只有几分钟,却能验证整套方案的底线。

我最早做商城时没做这个并发验证,上线第二天运营发来一张库存负数的截图,我当时还在怀疑是 UI 显示问题,查了一整天才意识到是扣库存 SQL 少写了Stock >= @quantity条件。那个晚上改完代码,我在项目里立了一个规矩:所有涉及库存、余额、优惠券扣减的接口,上线前必须过一遍并发脚本。希望你不用经历我那次熬夜,这篇文章里的连接配置、路由写法、事务边界和并发扣库存清单,能帮你在第一周就把这些坑填平,把精力留给真正的前端展示和用户体验。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询