ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

.NET6 WebAPI集成Sqlserver与JWT实现登录及增删改查完整实战

2026/10/6 13:31:03 拓冰建站 浏览量
.NET6 WebAPI集成Sqlserver与JWT实现登录及增删改查完整实战 简介这是一份基于.NET 6构建的Web API完整示例项目面向正在学习ASP.NET Core与后端接口开发的中级开发者演示如何结合SQL Server数据库完成增删改查并接入JWT实现无状态身份验证与权限控制。项目内容覆盖RESTful API设计、EF Core/Dapper数据访问、Swagger接口文档、全局错误处理、安全防护以及数据库迁移等关键环节结构清晰代码分层明显适合作为企业级接口开发的入门与进阶参考资料。资源共包含665个文件以dll、cs、json、csproj、pdb等类型为主其中dll与pdb为编译产物可直接查看程序集信息cs为源码便于学习核心逻辑json涵盖配置与依赖声明rar压缩包整体大小约34.81MB。目前该资源已有1060人学习下载对于想快速上手.NET 6 Web API与JWT整合开发、并希望看到项目级文件组织和依赖关系的开发者来说是一份贴合实际、可直接研读的实践素材。1. 用 .NET6 WebAPI Sqlserver JWT 带登录的增删改查这套组合值不值得做接手外包项目或者企业内部工具的时候最常遇到的诉求就是「做个能登录的后台接口系统」。很多老团队还在用 Session 全局过滤器鉴权每次部署都要处理服务端状态也有团队图省事接口直接裸奔一个 [Authorize] 都不加。用 .NET6 WebAPI 配合 Sqlserver 做数据落地再用 JWT 做无状态令牌认证恰好能把这套闭环用一个项目骨架说清楚前端拿到 token 调接口后端验证令牌后执行增删改查数据全部落在 Sqlserver 里。这套组合适合正在从 .NET Framework 迁到 .NET6 的开发者也适合要快速交付中小型后台 API 的团队。这里不写教科书式的理论从建项目开始一步步把登录、CRUD 和部署跑通并把 Sqlserver 和 JWT 最常见的坑一并交代。2. 从空目录到能跑 Sqlserver 的 APIWebAPI 项目骨架与连接串配置2.1 创建 .NET6 WebAPI 项目一个命令出骨架两个开关要记牢很多第一次接触 .NET6 的人会疑惑为什么dotnet new webapi生成的代码里找不到 Controller 文件夹因为 .NET6 的模板默认是 Minimal APIApp 里只有几个MapGet不再是一整套路由 控制器的经典结构。要做带 Controller 的增删改查创建项目的时候就得把控制器开关打开。# 创建带 Controller 的 .NET6 WebAPI 项目 dotnet new webapi -n Demo.Api -f net6.0 --use-controllers cd Demo.Api dotnet run创建完直接dotnet run浏览器打开https://localhost:7000/swagger能看到自动生成的 API 文档页。这里的-f net6.0是锁定目标框架避免本机装了 .NET7/8 之后用默认版本生成出别的模板代码--use-controllers则明确告诉模板「我要传统的 Controller 结构不是 Minimal API」。如果你的模板版本过低没有这个参数也可以手动创建一个Controllers文件夹再写一个继承ControllerBase的类效果一样。生成后的Program.cs是最关键的地方。中间件注册顺序直接影响后面的 JWT 能不能生效很多人在这里翻过车UseAuthentication必须写在UseAuthorization之前写反了的话接口上的[Authorize]会直接放行登录形同虚设。var builder WebApplication.CreateBuilder(args); builder.Services.AddControllers(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); var app builder.Build(); if (app.Environment.IsDevelopment()) { app.UseSwagger(); app.UseSwaggerUI(); } app.UseAuthentication(); app.UseAuthorization(); app.MapControllers(); app.Run();代码逻辑不复杂但UseAuthentication和UseAuthorization的顺序是血泪经验。前一个中间件负责把请求里的 token 解析成用户身份后一个才负责判断这个身份能不能进接口。如果跳过第一步直接做第二步[Authorize]看到的是「匿名用户」照样返回 401。2.2 Sqlserver 连接串与 Dapper 上下文先让数据层能跑通连接串是数据层的第一道关卡。常见的写法是存到appsettings.json用GetConnectionString(Default)读取。Sqlserver 连接串字段不多但TrustServerCertificateTrue这一个选项最容易漏Sqlserver 2022 默认开启加密连接如果驱动版本不够新又没显式信任服务器证书第一次连库就会报 SSL 相关错误。{ ConnectionStrings: { Default: Server127.0.0.1,1433;DatabaseDemoDb;User Idsa;PasswordYourPassword;TrustServerCertificateTrue;EncryptTrue }, Logging: { LogLevel: { Default: Information, Microsoft.AspNetCore: Warning } }, AllowedHosts: * }参数说明Server后面如果是本机 Sqlserver 默认实例可以直接写127.0.0.1,1433端口号用逗号分隔不是冒号Database指向你要操作的库认证用sa账号还是 Windows 身份验证按部署环境定开发期用 SQL 账号省事。EncryptTrue在 Sqlserver 2022 的驱动下默认是强制的配合TrustServerCertificateTrue才能连上本地开发库。连接串准备好之后封装一个 Dapper 上下文统一对外提供连接对象。我一般会这样做保证仓储层拿到的IDbConnection是同一个配置来源切换数据库环境时只改一处。using System.Data; using Microsoft.Data.SqlClient; public class DapperContext { private readonly string _connectionString; public DapperContext(IConfiguration config) { _connectionString config.GetConnectionString(Default) ?? throw new InvalidOperationException(Connection string Default not found.); } public IDbConnection CreateConnection() new SqlConnection(_connectionString); }这里用 Dapper 而不是 EF Core原因很直接这个场景是增删改查SQL 可控实体映射简单Dapper 轻量又直白。EF Core 在复杂聚合查询和迁移上更强但如果只是几个表的基础 CRUDDapper 的QueryAsync和ExecuteAsync已经足够还少了一层状态跟踪。项目里顺手把DapperContext和后面的仓储接口注册进 DI 容器Controller 里就能直接取用。2.3 发布 WebAPI 项目dotnet publish 与部署时容易被忽略的两件事开发环境跑通了不代表部署环境也能跑。发布命令本身简单dotnet publish -c Release -o ./publish但发布完你会看到publish目录里有一大堆.dll和.json。这里有两个小事必须在上线前确认第一appsettings.json在发布时会自动复制过去但里面如果是本地的sa密码和连接串上线前要改成生产环境的值否则就是拿着开发库密码裸奔第二IIS 部署需要安装AspNetCoreModuleV2托管模块不然站点启动直接报 500.31 之类的错误。如果是 Linux Nginx则要确保Microsoft.Data.SqlClient能在对应运行时下正常连库Sqlserver 用 1433 端口时记得放开防火墙。3. JWT 令牌的签发与校验登录接口、401 和续签机制的关键点3.1 签发 Token密钥长度、Claim 设计和过期时间的设置JWT 的签发流程不复杂但自己写出来最容易踩坑的点有三处密钥长度不够、Claim 设计冗余、过期时间拍脑袋。先说密钥HS256 是对称签名算法签和验用的是同一把 Key这把 Key 如果短于 32 字节攻击者用字典就能爆破出密钥然后直接自己签发一个管理员令牌。常见做法是生成一个至少 64 字节的随机字符串放到环境变量里。Claim 里只放身份相关信息不要放密码或手机号之类的敏感数据因为 JWT 的 Payload 默认只是 Base64Url 编码不是加密的任何拿到 token 的人都能看到里面写了什么。using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using System.Text; using Microsoft.IdentityModel.Tokens; public class JwtService { private readonly string _key; private readonly string _issuer; private readonly string _audience; public JwtService(IConfiguration config) { _key config[Jwt:Key]; _issuer config[Jwt:Issuer]; _audience config[Jwt:Audience]; } public string GenerateToken(User user) { var claims new[] { new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()), new Claim(ClaimTypes.Name, user.UserName), new Claim(ClaimTypes.Role, user.Role ?? User) }; var key new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_key)); var creds new SigningCredentials(key, SecurityAlgorithms.HmacSha256); var token new JwtSecurityToken( issuer: _issuer, audience: _audience, claims: claims, expires: DateTime.UtcNow.AddHours(2), signingCredentials: creds); return new JwtSecurityTokenHandler().WriteToken(token); } }逻辑说明用户登录成功后调用GenerateToken把用户 Id、用户名、角色放进 Claim令牌时效 2 小时。这里的expires用的DateTime.UtcNow不要用本地时间否则服务器时区和其他时区的客户端对不上前端判断过期时间会提前或延后 8 个小时。参数上Issuer和Audience在后续校验时必须完全一致签发和校验各配各的话token 一验就挂。3.2 校验 TokenProgram.cs 里 JwtBearer 的参数清单签出来的 token 要让 WebAPI 认识得在Program.cs里接入 JwtBearer 认证中间件。这一步配置的TokenValidationParameters有很多细节常用的四个校验开关是 Issuer、Audience、IssuerSigningKey 和 Lifetime。前三项必须开启特别是ValidateIssuerSigningKey如果漏掉中间件可能不校验签名直接放行ValidateLifetime控制过期时间但要注意ClockSkew默认有 5 分钟容差也就是说 token 过期后 5 分钟内依然有效。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], ValidateIssuerSigningKey true, IssuerSigningKey new SymmetricSecurityKey( Encoding.UTF8.GetBytes(builder.Configuration[Jwt:Key])), ValidateLifetime true, ClockSkew TimeSpan.FromMinutes(5) }; });这段代码如果配好之后请求仍然 401先从三个方向排查token 是否真的带在了Authorization: Bearer xxx请求头里签发和校验用的 Key 是否一致ValidIssuer和ValidAudience是否与签发时完全相同。注意如果你把IssuerSigningKey直接写成new SymmetricSecurityKey(123456)这种短字符串运行时大概率会抛异常提示密钥太短因为默认的签名算法要求密钥长度匹配。3.3 登录接口与 Token 续签的两种常见做法登录接口的逻辑很简单接收用户名密码查库比对密码哈希成功后返回 token。密码比对建议用 BCrypt不要自己写 SHA256 加盐BCrypt 每次生成的哈希都带随机盐省去自己管盐的功夫。登录接口上要加[AllowAnonymous]否则客户端还没拿到 token 就被 401 拦在门外。[HttpPost(login)] [AllowAnonymous] public async TaskIActionResult Login(LoginRequest request) { var user await _repository.GetByUserNameAsync(request.UserName); if (user null || !BCrypt.Net.BCrypt.Verify(request.Password, user.PasswordHash)) return Unauthorized(new { message 用户名或密码错误 }); var token _jwtService.GenerateToken(user); return Ok(new { token, expiredAt DateTime.UtcNow.AddHours(2) }); }登录之后的第二个问题是 token 续签。JWT 无状态这点既是优点也是麻烦token 一旦签发服务端无法主动让它失效除非引入额外的黑名单机制。常见做法有两种。第一种是滑动过期客户端在 token 剩余有效期小于某个阈值时拿着旧 token 去换新 token服务端只校验旧 token 是否有效不校验它是否被吊销。这种方式代码量最小但安全性差一点——旧 token 在到期前不会失效真要踢人踢不掉。第二种是引入 RefreshToken登录时同时返回 access token 和 refresh tokenaccess token 有效期短refresh token 存数据库刷新接口校验 refresh token 后签发新的 access token并把这个 refresh token 标记为已用。表格对比一下两种方式方案access token 有效期踢人能力实现成本滑动过期可以设置较长弱旧 token 到期前仍可用只需一个刷新接口RefreshToken短比如 15 分钟强可以吊销 refresh token 后禁止再刷新需要额外存表和管理开发中小型项目时我更倾向 RefreshToken 方案虽然多了一张表但下线用户、修改密码后强制重登这些操作都能落地。4. 通用增删改查服务仓储基类、四个 Action 与 ValidationAttribute 校验4.1 通用 CRUD 服务的核心抽象做增删改查最容易犯的错是每个 Controller 里写一遍雷同的 SQL。实际我在项目里看到过大量UserService、OrderService里各写各的QueryAsync(SELECT * FROM ...)换张表就要从头再来。常见做法是抽一个通用仓储基类把分页、按 Id 查、新增、更新、删除这五类操作收编进去。这做法和你在 Java 端见过的 MyBatis-Plus 那套无状态 CRUD 服务是一个思路核心都是把数据库动作抽象成一组通用方法业务层只关心实体类型。public interface IBaseRepositoryT where T : class { TaskIEnumerableT GetPageAsync(int pageIndex, int pageSize, string orderBy); TaskT? GetByIdAsync(int id); Taskint AddAsync(T entity); Taskint UpdateAsync(T entity); Taskint DeleteAsync(int id); }实现类里需要传入表名和实体映射Dapper 在简单实体上可以直接用类名对应表名字段名一一对应。Dapper 的QueryAsyncT会把查出来的行映射到T对象的公开属性上属性名和列名不区分大小写但列名和属性名不一致时要用[Column(user_name)]这类特性手动指定这一步别偷懒否则调试时看到全空对象会怀疑人生。4.2 用户表的建表 SQL 与密码字段设计在建表之前先想清楚字段设计。用户表是最典型的 CRUD 示例同时也是最容易泄漏安全问题的表——密码字段如果存明文后面所有防线都是白搭。建表 SQL 里PasswordHash要留足长度BCrypt 生成的哈希长度固定 60 字符但未来如果换算法NVARCHAR(200)更从容。CreatedAt用DATETIME2精度高于DATETIME避免毫秒被截断。CREATE TABLE [dbo].[Users] ( [Id] INT IDENTITY(1,1) PRIMARY KEY, [UserName] NVARCHAR(50) NOT NULL, [PasswordHash] NVARCHAR(200) NOT NULL, [Role] NVARCHAR(20) NULL, [CreatedAt] DATETIME2 DEFAULT GETDATE() );执行完建表脚本后记得在 Sqlserver 图形化工具里刷新一下表列表。顺便说一句Sqlserver 的图形化工具建议用 SSMS免费且功能完整如果开发机装的是 Mac 或 Linux用 Azure Data Studio 也可以连接方式和 SSMS 几乎一致。4.3 Controller 的四个 Action从参数到返回值的统一写法Controller 层做的事情很薄接收参数、校验、调用仓储、返回结果。先写分页列表再写按 Id 查、新增、更新、删除。这里有一个细节删除接口用HttpDelete({id})前端如果用的是 axios 这类库要确认它支持 DELETE 请求带 URL 参数很多初学场景里前端抱怨接口 405其实就是 HTTP 方法对不上。[ApiController] [Route(api/[controller])] [Authorize] public class UserController : ControllerBase { private readonly IBaseRepositoryUser _repository; public UserController(IBaseRepositoryUser repository) { _repository repository; } [HttpGet] public async TaskIActionResult GetUsers([FromQuery] int pageIndex 1, [FromQuery] int pageSize 10) { var data await _repository.GetPageAsync(pageIndex, pageSize, Id DESC); return Ok(data); } [HttpGet({id})] public async TaskIActionResult GetUser(int id) { var user await _repository.GetByIdAsync(id); if (user null) return NotFound(); return Ok(user); } [HttpPost] public async TaskIActionResult AddUser(CreateUserRequest request) { if (!ModelState.IsValid) return BadRequest(ModelState); var user new User { UserName request.UserName, PasswordHash BCrypt.Net.BCrypt.HashPassword(request.Password), Role request.Role ?? User, CreatedAt DateTime.UtcNow }; var id await _repository.AddAsync(user); return CreatedAtAction(nameof(GetUser), new { id }, user); } [HttpPut({id})] public async TaskIActionResult UpdateUser(int id, UpdateUserRequest request) { var existing await _repository.GetByIdAsync(id); if (existing null) return NotFound(); existing.UserName request.UserName ?? existing.UserName; existing.Role request.Role ?? existing.Role; await _repository.UpdateAsync(existing); return NoContent(); } [HttpDelete({id})] public async TaskIActionResult DeleteUser(int id) { var existing await _repository.GetByIdAsync(id); if (existing null) return NotFound(); await _repository.DeleteAsync(id); return NoContent(); } }这一段代码里的[ApiController]会自动做模型状态校验请求参数不合法时直接返回 400不需要在每个 Action 里手动判断。CreatedAtAction的作用是返回 201 状态码并把新资源的 URL 放在响应头里这是 REST 风格里推荐的做法。注意新增和更新请求对象分开CreateUserRequest带密码字段UpdateUserRequest不带避免在更新场景里把密码也暴露出去。4.4 WebAPI ValidationAttribute入参校验不写成 if 堆叠很多项目里的参数校验是if (string.IsNullOrEmpty(...)) return BadRequest()这种写法一个接口下来十几个 if页面一旦复杂Action 开头就是一大坨判断。WebAPI 里用ValidationAttribute把这些校验移到模型类上框架在模型绑定阶段自动执行。public class PasswordStrengthAttribute : ValidationAttribute { public override bool IsValid(object? value) { var pwd value as string; if (string.IsNullOrEmpty(pwd) || pwd.Length 8) return false; return pwd.Any(char.IsLetter) pwd.Any(char.IsDigit); } }使用的时候在请求模型上标注public class CreateUserRequest { [Required(ErrorMessage 用户名不能为空)] [StringLength(50, MinimumLength 3)] public string UserName { get; set; } [Required] [PasswordStrength(ErrorMessage 密码必须至少8位且包含字母和数字)] public string Password { get; set; } [MaxLength(20)] public string? Role { get; set; } }参数说明ErrorMessage会原样写入ModelState前端拿到的 400 响应里errors字段就有这条中文提示。PasswordStrengthAttribute里的BindingFlags之类不用管ValidatableObject的这套继承机制是框架自带的。如果你发现校验没生效先检查控制器类上有没有[ApiController]没有它模型校验不会自动触发。5. Sqlserver 与 JWT 的避坑排查五个血泪记录5.1 Sqlserver 安装失败无法找到数据库引擎启动句柄现象安装 Sqlserver 2016/2017/2019 的过程中到「数据引擎配置」这一步报错提示无法找到数据库引擎启动句柄安装回滚服务列表里也看不到SQL Server (MSSQLSERVER)服务。原因最常见的是本机残留了旧版本 Sqlserver 的注册表项或服务新实例名和旧实例冲突另外一个高频原因是安装介质不完整网上下的精简版缺了sqlenginedll.dll。解决先把旧服务清干净管理员 PowerShell 里执行sc query查找名字里带MSSQL的服务手动删除残留然后用官方完整 ISO 重新安装装 Sqlserver 2022 Developer 版免费或 Standard 版。还有一个小技巧安装时在「功能选择」里不要勾选所有功能只选「数据库引擎服务」和「客户端工具连接」这两个足够开发使用能避开很多依赖组件缺失的坑。5.2 Sqlserver 已经装了服务器面板却识别不到现象Windows 服务器上已经安装完成 Sqlserver任务管理器里sqlservr.exe也在跑但宝塔面板的数据库列表里看不到它以为安装失败。原因宝塔面板的数据库管理默认面向 MySQL / MariaDB它不会主动扫描并管理 Sqlserver 实例这属于能力边界不是故障。解决直接用数据导入导出或写代码连库验证别指望面板给你列出实例。验证连接时在防火墙放行 1433 端口并确认 Sqlserver 的 TCP/IP 协议已启用——这一步很多人会漏掉默认安装时Named Pipes可能开启TCP/IP却是禁用的外部工具连不上配置文件里Server127.0.0.1,1433也连不上。5.3 SELECT TOP 之后又 OFFSET分页结果错乱现象Sqlserver 里执行SELECT TOP 20 * FROM Users ORDER BY Id OFFSET 20 ROWS期望拿第三页的数据结果返回的却是第 20 到 39 行。原因TOP在OFFSET之前被求值也就是先取前 20 行再跳过 20 行逻辑上等价于「取前 20 行留下第 20 到 39 行」。这完全不符合分页预期。解决Sqlserver 分页就老老实实用ORDER BY ... OFFSET x ROWS FETCH NEXT y ROWS ONLY不要再混用TOPSELECT Id, UserName, Role, CreatedAt FROM Users ORDER BY Id DESC OFFSET 20 ROWS FETCH NEXT 20 ROWS ONLY;这里的OFFSET是跳过的行数FETCH NEXT是取多少行两者必须搭配ORDER BY使用。这个坑几乎每个从 MySQL 转 Sqlserver 的人都会踩一次MySQL 里LIMIT 20,20用习惯了到 Sqlserver 就容易随手写错。5.4 Sqlserver 字符串转数字导致的隐式转换坑现象查询WHERE Phone 13800138000时跑得很慢但执行计划里明明有索引却走不了或者查询WHERE Status 1时某天Status值里出现了abc直接抛转换错误。原因T-SQL 的隐式转换规则里把NVARCHAR列和INT常量比较时优先把字符串转换为数字。一旦列里有无法转换的字符串查询直接报错而且这种转换会让列的索引失效。解决显式写转换或者保持两边类型一致SELECT * FROM Users WHERE TRY_CONVERT(INT, Phone) 13800138000;TRY_CONVERT转失败返回NULL不会中断查询。更推荐的做法是电话这类数据本身就应该用字符串存储和查询写参数时直接传字符串不要传数字。5.5 JWT 弱密钥与不校验签名攻击者自己造令牌现象某天收到日志告警有人请求管理员接口返回了 200但这个人从未登录过。查看数据库和登录日志一无所获。原因JWT 的 HS256 是对称签名服务端和客户端用同一把 Key。如果 Key 太短、太常见比如123456或secret攻击者拖到 token 后离线爆破 Key爆破成功就用这把 Key 自己签一个RoleAdmin的 token 打进来。第二个漏洞是配置 JwtBearer 时把ValidateIssuerSigningKey设为 false等于告诉框架「别验证签名」攻击者把 token 的alg改成none就能伪造。解决Key 至少 64 字节放环境变量不要提交 GitTokenValidationParameters里所有Validate*开关保持默认开启定期轮换 Key并在换 Key 时让旧 token 自然过期。6. 上线前值得补的三个小动作事务、软删除与 SQL 性能验证6.1 多表写入时包在事务里增删改查的单表操作没问题但用户和详情表要一起写时必须保证原子性。Dapper 里事务要显式传入transaction参数否则ExecuteAsync会在独立的隐式事务里执行第一条 insert 成功第二条 insert 失败时数据就脏了。public async Taskint CreateUserWithProfileAsync(User user, UserProfile profile) { using var conn _context.CreateConnection(); using var trans conn.BeginTransaction(); try { var userId await conn.ExecuteAsync( INSERT INTO Users (UserName, PasswordHash, Role, CreatedAt) VALUES (UserName, PasswordHash, Role, CreatedAt); SELECT CAST(SCOPE_IDENTITY() AS INT);, user, trans); await conn.ExecuteAsync( INSERT INTO UserProfiles (UserId, FullName, Email) VALUES (UserId, FullName, Email), new { UserId userId, profile.FullName, profile.Email }, trans); trans.Commit(); return userId; } catch { trans.Rollback(); throw; } }事务里有个细节新增后拿自增主键用的是SCOPE_IDENTITY()它在返回当前会话最后插入的标识值不受其他会话干扰。BeginTransaction()之后所有 Dapper 方法都要带上transaction这个参数漏传一个那条 SQL 就不在事务保护范围内出了问题极难排查。6.2 软删除加 IsDeleted 而不是直接 DELETE很多业务场景里用户点删除只是不想看到但数据本身要保留。直接DELETE一时爽审计、恢复、关联查询全都麻烦。常见做法是给表加IsDeleted BIT DEFAULT 0删除时标记为 1所有查询默认过滤UPDATE Users SET IsDeleted 1 WHERE Id id;查询列表时记得加WHERE IsDeleted 0。这一步容易遗忘尤其是通用仓储的GetPageAsync里如果只拼了分页条件漏掉删除标记就会出现「已删除用户仍在列表里」的幽灵数据。我见过线上事故就是软删除表的列表接口没过滤客户看到已注销账号还能登录。所以要么在仓储基类里统一规定所有查询都携带这个条件要么干脆所有查询都走视图视图里先过滤一次。6.3 用 SET STATISTICS IO 验证慢 SQL 到底慢在哪上线前的最后一个动作是把每条核心 SQL 的执行成本量化。Sqlserver 里最直观的工具是SET STATISTICS IO和SET STATISTICS TIME打开后执行 SQL消息窗口里会打印逻辑读次数和 CPU 耗时。逻辑读动辄上千的查询就要考虑加索引了。SET STATISTICS IO ON; SET STATISTICS TIME ON; SELECT UserName, Role, CreatedAt FROM Users WHERE Role Admin ORDER BY CreatedAt DESC;表格里那几个数字比任何 ORM 日志都有说服力逻辑读次数越小越好扫描操作如果走的是表扫描执行计划里会显示Table Scan或Clustered Index Scan加了索引之后变成Index Seek性能往往能提升几个数量级。有些慢 SQL 是环境特有的本地快线上慢先看执行计划里的扫描方向是不是反了再看统计信息有没有更新。这套验证方法做一遍比在网上去搜「Sqlserver 优化秘籍」这种玄学内容靠谱得多。我第一次在这套组合上白班就是分了页、签了 token、表也建完了结果上线第二天被渠道商投诉「上一页和下一页数据重复」。查了一天最后定位到就是那个TOP OFFSET的混用问题。从那以后凡是 Sqlserver 分页我都只写OFFSET ... FETCH NEXT不再动TOP的念头。JWT 的 Key 也是那次差点出事之后才认真换成了环境变量管理顺手加了个轮换脚本。希望这里的五个坑和三个小技巧能帮你把这套 .NET6 WebAPI Sqlserver JWT 的增删改查走得顺一点少交一点学费。希望帮到你。本文还有配套的精品资源点击获取