.NET 8实战:从零构建外卖订餐系统,攻克并发扣库存与JWT鉴权
说句实话.NET Core 的教程资源里讲增删改查的太多了讲一个完整业务系统到底怎么做的太少了。我见过不少初学者EF Core 的 DbContext 用得挺熟但一提到外卖订餐系统这种带用户、带商家、带订单状态流转的项目就习惯性地往 Controller 里堆接口结果订单状态、库存、金额全散落在各处越写越乱。所以我一直觉得外卖订餐系统更像一座桥——它连接的是会写接口和能设计系统这两个阶段。这篇文章就记录一下我用 .NET Core 从零搭一个外卖订餐系统原型的全过程包括数据库建模、并发扣库存、JWT 鉴权、Redis 缓存和部署这五个进阶关键点给正在挑进阶项目的朋友一条能照着走的路。这个项目适合谁很简单已经能独立写出简单的 Web API知道依赖注入、EF Core 的基本用法但还没完整设计过一个多模块业务系统的人。你要是刚开始学 .NET连 Controller 和路由都还没分清建议先回去补基础你要是已经能处理高并发、分布式事务这类问题那这个项目对你来说又太轻了。它正好卡在中间——能让你把零碎知识缝合成一个整体又不会因为过度设计而劝退。1. 为什么拿外卖系统练手项目边界与学习曲线规划1.1 外卖系统到底在练什么能力选项目这件事我觉得比写代码本身更值得花时间。很多人练手喜欢做博客系统或者记账本不是说不行而是这些项目在业务逻辑上太线性了用户建一篇文章、存一条记录、展示一个列表完事。你练十遍练的也都是同一个能力——CRUD。外卖订餐系统不一样它天然带着几道绕不开的坎数据建模用户、商家、菜品、购物车、订单、订单明细表之间既有外键关联又有状态流转。并发问题两个人同时买同一道菜库存只有一份你怎么保证不超卖。接口安全用户只能看自己的订单商家只能改自己的菜品权限怎么控制。性能意识菜品列表是高频访问的数据每次下单都要实时查数据库吗工程化项目写完了要给别人演示怎么部署、怎么配环境、怎么处理异常日志。这些能力不是靠背语法能获得的必须在一个完整的业务上下文里反复摔打。我见过一个朋友代码能力不差但第一次被问到订单表为什么要拆主表和明细表时直接愣住了。他之前的练习项目里从来没有这种一对多的业务场景。这就是选错练习项目的结果——不是他不会是没练过。1.2 先定边界哪些功能必须做哪些可以砍掉进阶项目最容易犯的毛病是什么都想做。优惠券、会员等级、拼单、配送员轨迹、商家结算……你要是把这些全塞进第一个完整项目大概率还没做到订单那一步就已经放弃了。我第一次做的时候就栽在这上面光一个优惠券系统的表结构设计我折腾了三天最后发现满减规则和订单金额计算纠缠在一起代码根本收不住。后来我把功能重新切了一遍只留下最能练到核心能力的东西功能模块是否保留原因手机号注册登录保留没登录就没有用户上下文鉴权无从谈起商家列表与菜品列表保留最典型的分页查询和缓存场景购物车保留一次业务上的打包操作能练到事务边界订单提交与取消保留核心中的核心并发扣库存的主战场模拟支付保留引出订单状态机与幂等处理但不接真实支付通道配送地址管理保留简单的子资源 CRUD熟悉 RESTful 设计优惠券砍掉规则复杂和主流程耦合深不适合当第一个项目配送员实时定位砍掉涉及 WebSocket 或消息推送属于另一个话题商家结算对账砍掉涉及金额汇总和时间窗口适合以后单独练这个表格背后的原则很简单第一个完整项目目标是让主链路跑通而不是让功能目录变得好看。订单主链路——浏览菜品、加购物车、提交订单、支付、查看订单——只要这条链路是健壮的你练到的就已经覆盖了后端开发里最常用的 80% 技术点。1.3 技术栈选型的取舍逻辑定型之后是选型。当时我身边有不少人劝我上微服务说外卖系统嘛以后肯定要拆成用户服务、订单服务、商家服务。我直接否掉了这个方案。原因不是微服务不好而是对于一个练手项目微服务会立刻把你拖进服务发现、分布式事务、消息队列这些深水区——这已经不是初学者的进阶了是进阶者的进阶。我最终定的技术栈非常克制.NET 8 ASP.NET Core Web API单体应用但按功能模块分文件夹EF Core SQL Server也可以用 PostgreSQL练习阶段差别不大Redis只做缓存不硬上消息队列JWT做接口鉴权Docker本地起 Redis部署时起整个应用单体应用不代表代码可以乱写。我特意按模块划分了目录结构Controllers只放路由和参数校验业务逻辑下沉到Services数据访问收敛到Repositories。这样一来就算以后某个模块真的膨胀到需要拆出去边界也是清晰的。选型这件事最重要的不是选最流行的而是选你当前水平能驾驭、又能让你看到下一阶段方向的。2. 数据库设计用六张核心表稳住订单主链路2.1 从订单到订单明细主从表的拆分逻辑整个项目的表我最终控制在十几张以内但真正压住主链路的是摇头六张表用户表、商家表、菜品表、购物车项表、订单主表、订单明细表。前四张表的理解成本很低大部分人在练习里都做过真正容易想不明白的是为什么订单要拆成主表和明细表两张。直接看业务就知道了。一个订单有三个基本信息哪个用户下的、哪个商家的、总共多少钱、什么时候下的——这是订单头。同时一个订单里通常有多个菜品比如两份黄焖鸡、一份米饭、一杯酸梅汤每一行菜品的名称、价格、数量、小计这些不能塞进订单头表里。原因很简单字段数量对不上一个订单有一行菜也可能有五行走菜关系型数据库不允许一行里存一个长度不定的集合。金额快照的要求菜品表里的价格是可以变的但订单一旦创建订单明细里存的必须是当时下单时的价格不能等商家改完价再变。所以订单头表和订单明细表是一对多的主从关系。我在实践里的实体建模是这样写的public class Order { public long Id { get; set; } public string OrderNo { get; set; } public long UserId { get; set; } public long MerchantId { get; set; } public decimal TotalAmount { get; set; } public int Status { get; set; } // 0待支付 1已支付 2配送中 3已完成 4已取消 public DateTime CreatedAt { get; set; } public ListOrderItem Items { get; set; } } public class OrderItem { public long Id { get; set; } public long OrderId { get; set; } public long MealId { get; set; } public string MealName { get; set; } public decimal Price { get; set; } public int Quantity { get; set; } public Order Order { get; set; } }注意看OrderItem里我同时存了MealId和MealName、Price。MealId是为了业务上和菜品保持关联MealName和Price则是订单创建那一刻的快照。这一点是订单系统里非常经典的设计细节——如果只存MealId商家某天改菜名或者改价格用户翻历史订单时会看到黄焖鸡变成了黄焖鸡米饭、价格也变了这是绝对不能接受的。2.2 外键关系和级联删除的陷阱主从表牵出了外键关系外键关系又牵出了最容易踩坑的地方级联删除。EF Core 默认对必要关系Required的级联行为是Cascade意思是你删掉一个订单它会把订单明细一起删掉。听起来挺方便但在真实业务里这是灾难。想一想用户把已完成的订单删了连带订单明细一起删——这个逻辑业务上勉强说得通但要是你删商家表里的一个商家EF Core 默认会把该商家的所有菜品、所有订单、所有订单明细一路级联删干净。订单是财务数据物理删除是严重的业务事故。我的处理方案是两件事同时做所有关键外键显式配置成DeleteBehavior.Restrict不让 EF Core 自作主张级联删除。用户、商家、菜品这三张核心表做软删除用IsDeleted字段标记查询时全局过滤。Fluent API 配置里最重要的几行modelBuilder.EntityOrderItem() .HasOne(x x.Order) .WithMany(x x.Items) .HasForeignKey(x x.OrderId) .OnDelete(DeleteBehavior.Restrict); modelBuilder.EntityMeal() .HasQueryFilter(x !x.IsDeleted);HasQueryFilter是 EF Core 的全局查询过滤器设置之后所有查菜品的语句都会自动加上WHERE IsDeleted 0你不用在每个查询里手动补条件也避免了漏过滤导致软删数据泄漏到接口里。当时我漏写了这个过滤结果商家删除一个菜品后接口依然能查到排查了很久才发现是查询层忘了过滤。这个坑你提前知道能省掉好几个小时的定位时间。2.3 EF Core Code First 落库与迁移中的教训表结构设计好了接下来就是把实体变成真实的数据库表。我用 Code First 方式好处是实体定义就是模型定义改代码再生成迁移心智负担小。核心命令就两条dotnet ef migrations add InitSchema dotnet ef database update流程看着简单但我在这个环节踩过三个非常典型的坑。第一个是decimal字段精度。EF Core 默认把decimal映射成 SQL Server 的decimal(18,2)也就是一共 18 位、小数点后 2 位。听起来够用但如果你在计算金额时用到单价 × 数量或者涉及折扣分摊小数点后 2 位会积累舍入误差。我的做法是金额字段统一用decimal(18,2)但在服务层计算金额时用decimal.Round显式控制和舍入规则而不是放任每一步计算都自行舍入。第二个是索引缺失。订单表查询最频繁的条件是UserId Status和MerchantId Status如果不建索引数据量一旦上千条慢查询立刻出现。EF Core 里建索引很简单modelBuilder.EntityOrder() .HasIndex(x new { x.UserId, x.Status }); modelBuilder.EntityOrder() .HasIndex(x new { x.MerchantId, x.Status });第三个坑是迁移和种子数据的顺序。我在开发阶段往菜品表里手工插了不少测试数据后来有一次改实体字段重新生成迁移EF Core 要求我提供MealName非空字段的默认值否则迁移直接失败。后来我养成了一个习惯所有非空且有业务含义的字段宁可让它可空或者初始化赋值也不要让迁移在测试数据上卡住。练习项目规模小这个坑不致命但养成交替的习惯以后在团队协作里会少挨很多骂。3. 订单提交的核心难点并发扣库存与事务边界3.1 一个超卖现场复盘订单系统的核心动作是提交订单而提交订单最难的其实不是代码写不写得出来而是并发场景下会不会出逻辑错误。我先把一个经典错误放出来你看看是不是你第一次写扣库存时的样子// 错误示范先查库存再判断再更新 var meal await _db.Meals.FindAsync(mealId); if (meal.Stock quantity) { throw new BizException(库存不足); } meal.Stock - quantity; await _db.SaveChangesAsync();这段代码在单用户测试的时候毫无问题。但它不是一个原子操作而是读取-判断-写入三步。假设库存还有 1 份用户 A 和用户 B 同时发起了订单请求两个人读到的库存都是 1都通过了if判断然后都执行了Stock - 1最终库存变成 0 甚至 -1但订单成功创建了两笔。这就是超卖。我第一次压测发现这个问题时第一反应是加锁不就行了。这确实是一种解法但是锁怎么加、锁住什么范围、是进程内锁还是分布式锁全是后续问题。在练习项目阶段不需要一上来就上分布式锁因为分布式锁也要依赖 Redis而且锁粒度控制不好反而引入死锁风险。3.2 乐观锁与原子更新练习项目里最务实的方案我最终选用的方案是数据库层面的原子更新。不用先查再判断而是把库存是否足够这个条件直接写进 UPDATE 语句里var affected await _db.Database.ExecuteSqlRawAsync( UPDATE Meals SET Stock Stock - {0} WHERE Id {1} AND Stock {0}, quantity, mealId); if (affected 0) { throw new BizException(库存不足); }这条 SQL 的含义是只有当前库存 要扣的数量时才执行扣减如果受影响的行数是 0说明库存不足。它是原子的数据库在更新时会对这行数据加锁因此两个并发请求不会同时都成功。这个方案简单、可靠而且不需要引入额外的锁组件。当然原子更新不能解决所有问题。比如扣减库存成功了但订单创建失败了怎么办这时候你需要的是事务。我把扣库存、创建订单、清空购物车这三步放在同一个数据库事务里任何一个环节失败就全部回滚await using var transaction await _db.Database.BeginTransactionAsync(); try { // 1. 原子扣减所有菜品库存 foreach (var item in cartItems) { var rows await _db.Database.ExecuteSqlRawAsync( UPDATE Meals SET Stock Stock - {0} WHERE Id {1} AND Stock {0}, item.Quantity, item.MealId); if (rows 0) { throw new BizException($菜品 {item.MealName} 库存不足); } } // 2. 创建订单头和订单明细 var order BuildOrder(userId, cartItems); _db.Orders.Add(order); await _db.SaveChangesAsync(); // 3. 清空购物车 _db.CartItems.RemoveRange(cartItems); await _db.SaveChangesAsync(); await transaction.CommitAsync(); } catch { await transaction.RollbackAsync(); throw; }这里有一个技术细节我在练习时试过不同做法如果先创建订单再扣库存扣库存失败时订单会残留需要额外清理如果先扣库存再创建订单创建订单失败时库存能回滚。对比下来后者的心智负担更小因为库存扣了但订单没创建这种事在事务回滚后就不存在了而订单创建了但库存没扣掉却需要额外的补偿流程。事务的顺序会影响你后续写补偿逻辑的复杂度这是很多人忽略的。3.3 事务边界不是所有操作都得包在一个事务里说完事务紧接着就是事务边界问题。不少初学者容易走向另一个极端所有接口都用事务包起来。这是更大的坑。举个例子提交订单后要调支付接口。真实支付是需要请求外部服务的网络延迟根本不可控。如果你把创建订单 请求外部支付放在同一个数据库事务里那这个事务会锁住订单相关的行长达几秒甚至几十秒其它用户下单时会争抢锁资源系统吞吐量直线下降。而且外部调用失败时你根本没法回滚外部服务已经完成的操作。我的原则是数据库事务里只放数据库操作外部调用放在事务提交之后。订单创建完、事务提交成功了再异步去调支付或者模拟支付。支付结果不是通过接口同步返回的而是通过回调通知或者轮询来确认确认后再更新订单状态。这个先落库再异步确认外部结果的思路是订单系统和其它内部管理系统最大的差别之一。还有一个状态机的问题。订单状态我用一个int字段加常量来表示但没有允许随意赋值。我封装了一个OrderStatusHelper.CanTransit(from, to)方法比如待支付可以变成已支付和已取消但已完成不能再变成待支付。在一个练手项目里状态机的代码量不大但它逼着你把业务规则理清楚而不是让set方法随意改状态。这一步做完之后你会发现自己写的接口安全多了——不是指信息安全而是指业务逻辑不会自己打自己的脸。4. JWT 登录与接口鉴权把你是谁这件事做好4.1 为什么练习项目直接选 JWT 而不是 Session先说一下背景外卖订餐系统的客户端天然是多样化的可能是浏览器 H5、可能是小程序、也可能是手机 App。如果我用传统的 Cookie Session第一个要解决的是跨域携带 Cookie 的问题第二个要解决的是 Session 存储在哪——内存里重启就丢放 Redis 又要多维护一套基础设施。这不是说 Session 方案不好而是对练手项目来说它带来的额外复杂度并不值得。JWTJSON Web Token的思路完全不同。用户登录成功后服务器签一个 Token 发回给客户端客户端在后续请求的Authorization请求头里带上它。服务器不存 Token、不查会话只在每次请求到达时验签。Token 本身是自包含的用户 ID、角色、过期时间都写在里面验签通过就认账。在 .NET 8 里配置 JWT 认证的代码已经很成熟了我直接用了官方库builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options { options.TokenValidationParameters new TokenValidationParameters { ValidateIssuer true, ValidIssuer builder.Configuration[Jwt:Issuer], ValidateAudience true, ValidAudience builder.Configuration[Jwt:Audience], ValidateLifetime true, ValidateIssuerSigningKey true, IssuerSigningKey new SymmetricSecurityKey( Encoding.UTF8.GetBytes(builder.Configuration[Jwt:SignKey])) }; });签发 Token 的部分也很简单关键是往 Claims 里放进能代表用户身份的数据var claims new ListClaim { new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()), new Claim(ClaimTypes.Name, user.Nickname), new Claim(ClaimTypes.Role, user.Role) // 可以是 Customer / Merchant / Admin }; var token new JwtSecurityToken( issuer: _config[Jwt:Issuer], audience: _config[Jwt:Audience], claims: claims, expires: DateTime.UtcNow.AddHours(2), signingCredentials: new SigningCredentials( new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_config[Jwt:SignKey])), SecurityAlgorithms.HmacSha256) );这里我要特别提醒一个容易搞混的点JWT 里的 Claims 是明文可见的只是有签名保证不可篡改。不要往 Token 里塞手机号、身份证号之类的敏感数据。我见过有人把用户的明文密码也放进 Claims 里的这相当于把钥匙贴在保险柜外面。4.2 基于角色的权限控制落地方案身份认证解决了你是谁接下来是你能干什么也就是授权。我的系统里角色分三种顾客Customer、商家Merchant、管理员Admin。不同角色能访问的接口完全不同角色能做的事情典型接口Customer浏览菜品、管理购物车、提交订单、查看自己的订单GET /api/orders/mineMerchant管理自己的菜品、查看本店订单、更新订单出餐状态PUT /api/meals/{id}Admin管理商家、查看全站订单、上下架菜品GET /api/admin/orders实现方式就靠[Authorize]特性加上Roles参数[Authorize(Roles Merchant)] [HttpPut({id})] public async TaskIActionResult UpdateMeal(long id, UpdateMealRequest request) { // 业务校验 更新菜品 }这里有一个特别容易忽略的坑Roles 里的字符串必须和你嵌入 Token 的 Claim 类型完全一致。我在开发时遇到过一个奇怪的现象顾客登录后调用户接口正常但调商家接口永远返回 403。查了半天发现我签发 Token 时用的是ClaimTypes.Role它最终在 JWT 里对应的 Claim 名是role而配置授权时系统默认也是从role这个 Claim 读角色理论上不会出问题。真正的问题是我在某个过滤器里又手动改写了 Claims导致角色丢了。这种问题在练习阶段很难发现因为日志里只显示403 Forbidden不会告诉你角色为什么不匹配。建议你遇到 403 时先解码一下 Token 看看里头的 Claims 到底有哪些再往上追。4.3 令牌过期、刷新令牌与两个实用建议JWT 的一个天生短板是Token 签发后无法在服务器端主动作废只能等它自然过期。这就带来一个实际矛盾Token 有效期设短了用户每两个小时要重新登录一次体验差设长了Token 一旦泄露攻击者可以一直用下去。练习项目里不需要搞太复杂的刷新令牌机制但至少要理解问题所在。我采用的是一个简单的折中方案访问 Token 有效期 2 小时同时提供一个POST /api/auth/refresh接口接收一个刷新令牌RefreshToken。刷新令牌用更长有效期比如 7 天并且存到 Redis 里每次刷新时比对 Redis 里的记录过期或不存在就拒绝。这个方案的道理是长期凭证集中管理短期凭证无状态验证。如果用户主动退出或后台要封禁删掉 Redis 里的 RefreshToken 就能断掉续期之路而访问 Token 最多 2 小时就失效了。这个无状态 有状态兜底的混合模式在实际工作中非常常见练一练很值。还有两个和 JWT 相关的小问题我在演示项目时都遇到过一并写在下面Swagger 里调试接口要手动加 Token。可以在AddSwaggerGen时配置安全定义让 Swagger UI 出现一个Authorize按钮这样测试接口方便很多否则每个接口都要手工把 Token 粘到请求头里极其痛苦。服务器时钟问题。签发 Token 时我一开始用了本地时间DateTime.Now部署到服务器后才发现服务器时区和本地不一致导致 Token已过期或者尚未生效。统一改用DateTime.UtcNow之后问题彻底消失。这个问题很隐蔽但几乎每个人都会碰上一次。5. Redis 的两种用法热点缓存与管理端数据刷新5.1 菜品列表缓存缓存什么、失效怎么设计项目做完第一版全功能之后我压了一下接口性能。结果毫不意外接口响应时间最长的就是菜品列表接口因为每次打开首页都要实时查数据库、查出菜品还要联查商家信息。优化方向很明确——把高频读取、低频更新的数据放进 Redis 缓存。但加缓存不是简单地在代码里Set和Get就完了。我自己的缓存设计经历了三个版本不是说这个必须做得多高级而是每加一层都要知道自己在防什么问题。第一版是最简单的查询菜品列表前先查 Redis没有再查数据库查完写回 Redis。这版的问题很明显商家改了菜品信息后缓存还是旧数据用户能看到下架的菜。于是第二版我在商家改菜品、删菜品的接口里主动删掉对应的缓存 Key这就叫写后失效。这个版本已经能用了但商家的菜品跟着商家名一起被缓存时商家改个店名菜品缓存也得同时失效。最终我采用的 Key 设计是这样的缓存内容Key 约定失效策略单个菜品详情meal:{id}商家更新菜品时删除商家菜品列表meal:list:{merchantId}:{page}商家增删改菜品时删除前缀首页商家列表merchant:list:{page}管理员操作商家时删除商家更新菜品时不是只删一个 Key而是删掉所有关联的缓存前缀。Redis 支持按前缀扫描删除虽然生产环境不推荐大量使用KEYS命令但练习项目数据量小用SCAN就能优雅解决。我封装了一个方法传入meal:前缀把所有以它为开头的 Key 全部清掉这样无论缓存层有多少变体都不会出现漏删脏数据。5.2 订单号的生成与幂等性另一个我觉得值得用 Redis 的地方是订单号的生成。一开始我直接用数据库自增 ID 当订单号结果有两个问题一是自增 ID 会暴露订单量别人看到 ID 是 10000 就知道系统大概有多少订单了二是订单号通常要给人看、给客服查太短的自增数字不专业。后来我把订单号设计成时间戳 随机数var orderNo ${DateTime.Now:yyyyMMddHHmmss}{Random.Shared.Next(100000, 999999)};这个方案在单机下完全够用多机并发时随机数尾号有极小概率重复但配合数据库的唯一索引兜底重复时会报错重试问题不大。等到你真要面对大规模分布式订单生成时再去研究雪花算法也不迟。学习路径上不要跳级先解决眼前的问题再理解更复杂的方案是解决什么问题。订单号还牵扯另一个关键点幂等性。用户可能在网络波动时重复点击提交订单前端防了又防也拦不住用户手滑。后端必须在订单号这个环节把重复请求挡住。我的做法是给OrderNo字段建唯一索引插入时如果撞了唯一约束就抛异常返回订单已存在。这样即使同一请求被发送了两次数据库层面也能保证不会产生两条相同的订单。对了这里再补充一个前面提到过的模拟支付场景的幂等处理。支付回调往往会推送多次每次推送都要更新订单状态。如果回调处理不幂等一个订单可能被已支付状态覆盖了两次逻辑上没问题但如果你在状态更新时还要发通知、发消息就会重复触发。我的经验是状态更新使用条件更新——UPDATE Orders SET Status 1 WHERE Id id AND Status 0这样只有待支付订单能变成已支付重复回调来了也不会二次生效。5.3 本地环境没有 Redis 怎么办Redis 是独立服务初学者往往会在装 Redis这一步被绊住。Windows 上装 Redis 官方支持不太好Linux 上装又觉得麻烦。我建议直接上 Dockerdocker run -d --name demo-redis -p 6379:6379 redis:7-alpine一条命令就起来了。如果你的电脑连 Docker 也没有还有一个务实到极点的替代方案先定义好接口用内存字典模拟 Redis 的 Set、Get、Delete 行为。看起来有点土但它能帮你把业务逻辑先跑通等真正接 Redis 的时候只需要替换实现类调用方完全不用改。public interface ICacheService { Taskstring? GetAsync(string key); Task SetAsync(string key, string value, TimeSpan expiry); Task RemoveByPrefixAsync(string prefix); }本地用内存实现部署时换成 Redis 实现这种面向接口编程 实现可替换的思路比任何教程里反复强调依赖注入的概念都好懂。等你自己经历过一次换实现不影响上层之后你会真正理解抽象的价值而不是只会背依赖倒置原则这个名词。6. 从本地跑通到可演示发布部署中的几个隐藏问题6.1 配置管理与开发环境分离项目写完下一步自然是想部署到服务器上让手机也能访问。很多初学者会在这里首次遭遇环境差异暴击本地跑得好好的发布到 Linux 服务上就各种 500。绝大多数问题出在配置上。我的配置管理方式是标准的三个文件appsettings.json公共配置不含机密。appsettings.Development.json本地开发连本地数据库和 Redis。appsettings.Production.json生产环境连服务器上的数据库和 Redis。然后在启动时根据环境变量切换export ASPNETCORE_ENVIRONMENTProduction dotnet YourApp.dll这里最重要的一个原则是连接字符串和 JWT 签名密钥绝不能写死在大仓库里。练手项目没有团队协作的保密压力但养成这个习惯对你只有好处。我在部署时用环境变量覆盖配置{ ConnectionStrings: { Default: Serverlocalhost;DatabaseOrderDb;User Idsa;PasswordYourStrongPassword;TrustServerCertificateTrue } }在Program.cs里配置 EF Core 时我可以让环境变量优先覆盖appsettings.json里的同名配置项。这样数据库换一台服务器只需要改环境变量不用改代码也不用重新发布。6.2 部署时数据库迁移怎么执行开发阶段执行dotnet ef database update很顺手但生产环境不能每次手动跑迁移尤其等你以后要发布到多台服务器时。我在项目里做了一步简化程序启动时自动执行迁移。EF Core 提供了一个内置方法using (var scope app.Services.CreateScope()) { var db scope.ServiceProvider.GetRequiredServiceOrderDbContext(); db.Database.Migrate(); }自动迁移在小型项目里非常省事服务器拉取最新代码启动时自动把数据库结构升级到最新。但这个方案有个前提——不能在迁移过程中丢数据。所以我在开发阶段改字段时尽量用添加可空字段、添加新表这种方式避免在已有表上做破坏性变更。生产环境没人会等你手动跑 SQL程序自己完成迁移才是正路。这里还有一个隐藏问题如果程序启动时数据库还没就绪自动迁移会反复失败、反复重试。我的经验是不要只写一行Migrate()就完事最好加一个简单的重试循环或者干脆部署时先手动确认数据库连通再启动应用。这个确保基础设施就绪的步骤在后端工程里叫依赖健康检查属于运维的基本功练一次就懂了。6.3 全局异常处理与日志别等上了生产才后悔本地开发时异常会直接打到控制台跳出一大串堆栈你还能看得懂。部署到服务器后控制台没人看异常信息直达用户视角就变成了服务器内部错误——用户一脸懵你也一脸懵因为你根本不知道服务器发生了什么。我的做法分两层。第一层是全局异常处理中间件把异常统一转成 HTTP 响应业务异常返回 400 或 409系统异常返回 500并记录详细日志app.UseExceptionHandler(errorApp { errorApp.Run(async context { var exception context.Features.GetIExceptionHandlerFeature()?.Error; // 写日志 // 根据异常类型返回 JSON 错误信息 }); });第二层是结构化日志。我用 Serilog 把日志写到文件按天切割这样出了问题可以翻那天的日志看现场。工程上线第一晚我就靠日志定位了一个订单创建成功但支付状态没更新的异步问题——如果当时没有日志这个问题几乎无法排查因为它在特定时序下才偶尔复现。配置日志的最小可用方案Log.Logger new LoggerConfiguration() .WriteTo.Console() .WriteTo.File(logs/order-.log, rollingInterval: RollingInterval.Day, retainedFileCountLimit: 30) .CreateLogger();日志不是只给生产环境用的。从第一天写代码就把关键节点日志打上是排查问题的第一道防线。我接触过的很多初学者不爱写日志觉得反正兜底异常会返回错误但兜底只能告诉你出了问题不能告诉你为什么出问题。养成顺手打日志的习惯会让你少掉很多头发。结尾一个隐藏的小坑以及接下来可以往哪走最后再分享一个我部署完成后才发现的细节服务器时区问题。我的开发机是 UTC8默认的DateTime.Now一点问题没有但部署到一台 UTC 时区的 Linux 服务器后订单创建时间的显示直接差了 8 小时。后来我把所有的时间存储统一改成DateTime.UtcNow对外展示时再按用户时区转换。这个教训让我养成了一个习惯凡是数据库里要存的时间一律存 UTC凡是给用户看的时间一律在展示层转本地。这个规则贯穿了后面所有项目。这个外卖订餐系统原型一共花了我大概一个多月的业余时间从数据库建模到部署上线每写完一个模块我就跑一遍压测每发现一个问题就补一段日志。做完之后再看最初的代码确实很青涩但那种把一个完整系统从无到有跑通的成就感是任何教程练习都给不了的。如果你也想拿这个项目练手我的建议是先别急着加功能把基础主链路打磨扎实——订单并发、JWT 鉴权、缓存失效、日志可观测这四个点每吃透一个你的进阶之路就扎实一分。等这些真的内化了再去碰消息队列、容器编排、微服务拆分你会有完全不一样的底气。