这篇不写"理论上应该怎样",只讲这个项目的配置值、代码路径和排查方法。文中提到的每个数字都能在源码里找到。
一、先理解成本模型
Blazor Server 和传统 MVC 最大的差别:组件状态活在服务端。
每个在线用户 ≈ 一个 Circuit(服务端对象图 + 组件状态 + DI 作用域) 内存占用 ≈ 在线用户数 × 每个 Circuit 的状态大小所以性能优化的第一性问题不是"SQL 快不快",而是:
- 同时在线多少人?
- 每个页面在 Circuit 里保留了多少状态?
- 断线后这些状态保留多久?
框架的 Circuit 配置直接对应第 3 个问题:
// 电路断开后保留时长:后台 Tab 冻结导致连接断开时,保留电路供切回时快速恢复builder.Services.Configure<Microsoft.AspNetCore.Components.Server.CircuitOptions>(options=>{options.DisconnectedCircuitRetentionPeriod=TimeSpan.FromMinutes(10);options.DisconnectedCircuitMaxRetained=200;});| 配置 | 值 | 含义 | 代价 |
|---|---|---|---|
DisconnectedCircuitRetentionPeriod | 10 分钟 | 断线后电路保留多久 | 保留期间内存不释放 |
DisconnectedCircuitMaxRetained | 200 | 最多保留多少个断开的电路 | 超出后最旧的被回收 |
这两个值是"体验换内存"的旋钮。200 个保留电路对普通后台够用;如果单机能承载的在线用户很多(比如 2000+),要把这个数字和服务器内存一起算:
粗算:每个 Circuit 常驻内存(组件 + 状态 + 服务) × 200 再加上:在线电路数 × 每电路内存调小MaxRetained或RetentionPeriod能省内存,代价是用户切回标签页时可能已经重连。
二、SignalR 连接参数
builder.Services.AddSignalR(options=>{options.KeepAliveInterval=TimeSpan.FromSeconds(15);options.ClientTimeoutInterval=TimeSpan.FromMinutes(3);});| 配置 | 默认 | 本项目 | 影响 |
|---|---|---|---|
KeepAliveInterval | 15 秒 | 15 秒 | 服务端 ping 频率,越大越省流量但发现断线越慢 |
ClientTimeoutInterval | 30 秒 | 3 分钟 | 客户端多久无响应判定断线 |
把超时从 30 秒放宽到 3 分钟,是专门为"后台标签页被浏览器冻结"这个场景做的——默认值下切回标签页几乎必然触发重连。
代价:真正掉线的连接会更晚被发现,服务端多保留一段时间的死连接。对后台场景(用户数有限、操作不连续)这个取舍是划算的。
三、页面渲染:避免"查询跑两遍"和"空表格闪现"
1. 回调必须在 await 之前赋值
AdminTable里有一段带警告注释的代码:
// ⚠️ OnQueryAsync/OnSaveAsync/OnDeleteAsync 必须在任何 await 之前先赋值,// 否则 Blazor 在第一个 await 处就会调用 StateHasChanged() 触发首次渲染,// 此时 OnQueryAsync 仍为 null,导致首次数据加载失败。if(OnQueryAsync==null&&Items==null){OnQueryAsync=OnQueryDataAsync;// 首次渲染前即进入查询中状态,避免查询期间空行闪现"无数据"_querying=true;}这不是"代码风格"问题,而是真实的性能与体验问题:
- 赋值晚了 → 首次渲染拿到
null回调 → 页面空一下再查 → 用户看到"无数据"闪烁; - 某些实现会在渲染后再次触发查询 →一次页面打开查两次库。
2. 长时间操作要给可见状态
导出时的"正在导出"是服务端状态渲染的,不依赖 JS:
privateRenderFragmentExportProgressTemplate=>__builder=>{if(_exporting){<spanclass="me-2 text-info align-self-center"><iclass="fa-solid fa-spinner fa-spin me-1"></i>@CommonLocalizer["正在导出"]</span>}};_exporting=true;awaitInvokeAsync(StateHasChanged);try{...}finally{_exporting=false;awaitInvokeAsync(StateHasChanged);}导出是耗时操作,没有进度的长任务在用户看来就是"卡死了"。这段代码的价值在于:状态变化立即渲染,用户知道系统还在干活。
四、数据库层:四个真正影响性能的点
1. 分页与 Count 用同一个查询对象
varquery=select.WhereDynamicFilter(dynamicFilter).ApplyOrder<T,TKey>(options).Count(outvarcount);varitems=options.IsPage?awaitquery.Page(options.PageIndex,options.PageItems).ToListAsync():awaitquery.ToListAsync();好处不只是"条件一致",还避免了手写两遍条件带来的额外维护成本。
成本提示:Count在大表上本身有开销。如果你的表到了千万级,需要评估"是否必须有总数"(BootstrapBlazor 支持不显示总数)。
2. 导出走列投影,而且导出时不 Include
// 只查询导出列(动态投影),避免 SELECT * 把大文本/导航数据全部拉回来;// 查询链路不变(OnBeforeQuery 过滤 + 数据权限 + 动态过滤 + 排序),Include 不会执行rows=awaitLoadExportRowsAsync(select,columns,context.Options,lookupService,exportOptions);LoadExportRowsAsync用Reflection.Emit动态生成 DTO 类型(带缓存),只SELECT需要的列。对照物是:
反例:导出 1 万行 × 每行带一个正文大字段 + Include 3 个导航集合 → 内存、网络、GC 全部放大,通常直接超时3. 列表页谨慎 Include
privatevoidOnBeforeQuery(AdminQueryEventArgs<Article>e){if(!e.IsExport){e.Select.Include(a=>a.Classify);}}框架专门在导出场景传IsExport = true,注释写得很直白:
/// <summary>/// 是否导出场景。导出全量数据时应跳过 Include/IncludeMany 导航集合加载,避免上千行时极慢。/// </summary>4. 批量写而不是逐行写
导入默认走一条批量语句:
affectedRows=await_repo.Orm.InsertOrUpdate<TItem>().SetSource(rows).UpdateColumns(updateColumns).ExecuteAffrowsAsync();批量删除也一样:
vardeleteIds=items.Select(i=>i.Id).ToList();await_repo.Orm.Update<TItem>().SetByPropertyName("IsDeleted",true).Where(x=>deleteIds.Contains(x.Id)).ExecuteAffrowsAsync();五、缓存层:每类数据的 TTL 是设计出来的
项目里的缓存不是"随便加一层",每个 TTL 都对应一类数据的变更频率:
| 数据 | 缓存位置 | 有效期 | 失效方式 |
|---|---|---|---|
系统配置SysConfig | ICacheService | 1 分钟 | 到期自动 |
| 用户角色 | ICacheService | 30 分钟 | 权限版本号 +1 |
| 用户菜单 | ICacheService | 30 分钟 | 权限版本号 +1 |
| 字典项 | ICacheService | 30 秒 | 到期自动 |
| 实体选择器选项 | ICacheService | 30 秒 | 到期自动 |
| 审批流程配置 | ICacheService | 由 Provider 控制 | 改配置后按 key 失效 |
权限缓存用"版本号"而不是"逐个删键":
publicasyncTaskInvalidatePermissionCacheAsync(){varversion=awaitGetPermissionVersionAsync();awaitcache.SetAsync(ScopedPermissionVersionKey,version+1,TimeSpan.FromDays(30));}代价与边界:ICacheService默认是进程内缓存(MemoryCacheService)。多实例部署时"改权限只在当前实例生效"的问题需要靠 Redis / FusionCache 解决(第 19 篇)。
六、并发与资源:几个容易被忽略的点
1. 同一租户的初始化必须串行
// 同一个 tenantCode 的初始化必须串行:防止多个请求同时创建同一个租户的 FreeSql 与表结构。varinitLock=_initLocks.GetOrAdd(tenantCode,_=>newSemaphoreSlim(1,1));initLock.Wait();如果没有这把锁,第一个租户首次访问时,N 个并发请求会同时建库建表。
(如源码注释所说,多实例部署需要分布式锁配合。)
2. 审批用条件更新而不是锁
第 17 篇讲过:并发审批靠WHERE 状态 + 级次的条件更新,affected = 0即冲突。这比"加锁"更轻,也不会因为持锁进程崩溃而卡住流程。
3. 日志落库不阻塞业务
_queue.TryEnqueue(newDatabaseLogEntry(...));TryEnqueue是非阻塞的:写日志的线程不会等数据库(第 27 篇)。这就是"有界队列"带来的性能收益。
4. 连接池
// 多租户库varfsql=newFreeSqlBuilder().UseConnectionString(tenantInfo.DataType,tenantInfo.ConenctionString.Replace("{database}",tenantCode)).UseAdoConnectionPool(true).Build();多租户意味着成倍的连接来源,连接池不是可选项。
5. FreeSql 实例的生命周期
// 注册 IFreeSql 为 Singleton,从 MainOrmHandle 解析。// 注意:IFreeSql 实现了 IDisposable,如果注册为 Scoped 则 DI 会在每次作用域结束时// 对其调用 Dispose(),导致 Singleton 内部的 ObjectPool 被释放,引发// "Cannot access a disposed object" 错误。故改为从 Singleton 的 MainOrmHandle 解析。builder.Services.AddSingleton<IFreeSql>(sp=>sp.GetRequiredService<MainOrmHandle>().Orm);误注册成 Scoped 会导致间歇性的 “Cannot access a disposed object”,而且往往在高并发下才出现——这类问题排查成本极高,源码注释直接把它标出来了。
6. 多实例部署要注意雪花 ID 的 WorkId
publicclassEasyAdminBlazorOptions{publicushortWorkId{get;set;}=1;...}多实例部署时,每个实例必须配置不同的WorkId,否则雪花算法可能生成重复主键(同毫秒 + 同机器号 + 同序列)。
七、怎么排查:现象 → 可能原因
| 现象 | 大概率原因 | 从哪查 |
|---|---|---|
| 页面打开后"无数据"闪一下再出现 | 查询回调赋值时机问题 | OnParametersSetAsync的 await 前赋值 |
| 切回标签页就重连 | Circuit 已释放 / 代理超时 | DisconnectedCircuitRetentionPeriod、反向代理 WebSocket 配置(第 28 篇) |
| 导出超时或内存飙升 | 导出带了Include或未走投影 | OnBeforeQuery里的IsExport判断 |
| 列表页越用越慢 | 大表 + 模糊搜索 + 无索引 | 打开UseMonitorCommand看 SQL 与执行计划 |
| 权限改了不生效 | 权限缓存未失效 / 多实例 | InvalidatePermissionCacheAsync、是否接入 Redis/FusionCache |
| 内存持续上涨 | 断线电路堆积 / 日志队列异常 / 大对象缓存 | DisconnectedCircuitMaxRetained、DroppedCount、缓存键排查 |
高并发下偶发Cannot access a disposed object | IFreeSql生命周期注册错误 | 确认是 Singleton(见上文注释) |
| 多实例下主键冲突 | WorkId未区分 | 每个实例配置不同WorkId |
打开 SQL 日志
FreeSqlBuilder=a=>a.UseConnectionString(DataType.Sqlite,configuration["ConnectionStrings:default"]).UseMonitorCommand(cmd=>System.Console.WriteLine($"[{DateTime.Now.ToString("HH:mm:ss")}]{cmd.CommandText}\r\n"))//监听SQL语句只在诊断时打开。生产环境长期打印全量 SQL 既影响性能,也可能把参数写进日志。
八、小结
| 层 | 关键动作 |
|---|---|
| Circuit | 按内存预算调整DisconnectedCircuitRetentionPeriod/DisconnectedCircuitMaxRetained |
| 连接 | 放宽ClientTimeoutInterval(3 分钟)换取标签页冻结时的稳定性 |
| 渲染 | 回调在 await 前赋值;长任务显示进行中状态 |
| 查询 | 分页与 Count 同条件;列表谨慎 Include;导出走投影且不 Include |
| 写入 | 批量操作;审批用条件更新;租户初始化用信号量 |
| 缓存 | 按数据变更频率设 TTL;权限用版本号失效;多实例上 Redis/FusionCache |
| 基础 | 连接池、IFreeSqlSingleton、日志有界队列、多实例区分WorkId |
| 诊断 | 慢 SQL 监控、DroppedCount、权限缓存与 Circuit 保留数 |
Blazor Server 的性能优化,一半在数据库和缓存,另一半在"电路与长连接"这些服务端状态上。先把状态管好,再谈 SQL 调优。
如果你正在用 .NET 10 + Blazor 做后台,可以直接对照这篇检查自己的 Circuit、缓存和查询配置。EasyAdminBlazor 的相关参数都在源码里,改起来不需要读框架内部实现。
- 文档:https://easyadmin.wang-zhan.com.cn/doc
- 源码:https://gitee.com/gudufy/EasyAdminBlazor