这篇不写"理论上应该怎样",只讲这个项目的配置值、代码路径和排查方法。文中提到的每个数字都能在源码里找到。
一、先理解成本模型
Blazor Server 和传统 MVC 最大的差别:组件状态活在服务端。
text
每个在线用户 ≈ 一个 Circuit(服务端对象图 + 组件状态 + DI 作用域)
内存占用 ≈ 在线用户数 × 每个 Circuit 的状态大小
所以性能优化的第一性问题不是"SQL 快不快",而是:
- 同时在线多少人?
- 每个页面在 Circuit 里保留了多少状态?
- 断线后这些状态保留多久?
框架的 Circuit 配置直接对应第 3 个问题:
csharp
// 电路断开后保留时长:后台 Tab 冻结导致连接断开时,保留电路供切回时快速恢复
builder.Services.Configure<Microsoft.AspNetCore.Components.Server.CircuitOptions>(options =>
{
options.DisconnectedCircuitRetentionPeriod = TimeSpan.FromMinutes(10);
options.DisconnectedCircuitMaxRetained = 200;
});
| 配置 | 值 | 含义 | 代价 |
|---|---|---|---|
DisconnectedCircuitRetentionPeriod |
10 分钟 | 断线后电路保留多久 | 保留期间内存不释放 |
DisconnectedCircuitMaxRetained |
200 | 最多保留多少个断开的电路 | 超出后最旧的被回收 |
这两个值是"体验换内存"的旋钮。 200 个保留电路对普通后台够用;如果单机能承载的在线用户很多(比如 2000+),要把这个数字和服务器内存一起算:
text
粗算:每个 Circuit 常驻内存(组件 + 状态 + 服务) × 200
再加上:在线电路数 × 每电路内存
调小 MaxRetained 或 RetentionPeriod 能省内存,代价是用户切回标签页时可能已经重连。
二、SignalR 连接参数
csharp
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 里有一段带警告注释的代码:
csharp
// ⚠️ OnQueryAsync/OnSaveAsync/OnDeleteAsync 必须在任何 await 之前先赋值,
// 否则 Blazor 在第一个 await 处就会调用 StateHasChanged() 触发首次渲染,
// 此时 OnQueryAsync 仍为 null,导致首次数据加载失败。
if (OnQueryAsync == null && Items == null)
{
OnQueryAsync = OnQueryDataAsync;
// 首次渲染前即进入查询中状态,避免查询期间空行闪现"无数据"
_querying = true;
}
这不是"代码风格"问题,而是真实的性能与体验问题:
- 赋值晚了 → 首次渲染拿到
null回调 → 页面空一下再查 → 用户看到"无数据"闪烁; - 某些实现会在渲染后再次触发查询 → 一次页面打开查两次库。
2. 长时间操作要给可见状态
导出时的"正在导出"是服务端状态渲染的,不依赖 JS:
csharp
private RenderFragment ExportProgressTemplate => __builder =>
{
if (_exporting)
{
<span class="me-2 text-info align-self-center">
<i class="fa-solid fa-spinner fa-spin me-1"></i>@CommonLocalizer["正在导出"]
</span>
}
};
csharp
_exporting = true;
await InvokeAsync(StateHasChanged);
try { ... }
finally
{
_exporting = false;
await InvokeAsync(StateHasChanged);
}
导出是耗时操作,没有进度的长任务在用户看来就是"卡死了"。这段代码的价值在于:状态变化立即渲染,用户知道系统还在干活。
四、数据库层:四个真正影响性能的点
1. 分页与 Count 用同一个查询对象
csharp
var query = select
.WhereDynamicFilter(dynamicFilter)
.ApplyOrder<T, TKey>(options)
.Count(out var count);
var items = options.IsPage ? await query.Page(options.PageIndex, options.PageItems).ToListAsync() : await query.ToListAsync();
好处不只是"条件一致",还避免了手写两遍条件带来的额外维护成本。
成本提示 :Count 在大表上本身有开销。如果你的表到了千万级,需要评估"是否必须有总数"(BootstrapBlazor 支持不显示总数)。
2. 导出走列投影,而且导出时不 Include
csharp
// 只查询导出列(动态投影),避免 SELECT * 把大文本/导航数据全部拉回来;
// 查询链路不变(OnBeforeQuery 过滤 + 数据权限 + 动态过滤 + 排序),Include 不会执行
rows = await LoadExportRowsAsync(select, columns, context.Options, lookupService, exportOptions);
LoadExportRowsAsync 用 Reflection.Emit 动态生成 DTO 类型(带缓存),只 SELECT 需要的列。对照物是:
text
反例:导出 1 万行 × 每行带一个正文大字段 + Include 3 个导航集合
→ 内存、网络、GC 全部放大,通常直接超时
3. 列表页谨慎 Include
csharp
private void OnBeforeQuery(AdminQueryEventArgs<Article> e)
{
if (!e.IsExport)
{
e.Select.Include(a => a.Classify);
}
}
框架专门在导出场景传 IsExport = true,注释写得很直白:
csharp
/// <summary>
/// 是否导出场景。导出全量数据时应跳过 Include/IncludeMany 导航集合加载,避免上千行时极慢。
/// </summary>
4. 批量写而不是逐行写
导入默认走一条批量语句:
csharp
affectedRows = await _repo.Orm.InsertOrUpdate<TItem>()
.SetSource(rows)
.UpdateColumns(updateColumns)
.ExecuteAffrowsAsync();
批量删除也一样:
csharp
var deleteIds = 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 失效 |
权限缓存用"版本号"而不是"逐个删键":
csharp
public async Task InvalidatePermissionCacheAsync()
{
var version = await GetPermissionVersionAsync();
await cache.SetAsync(ScopedPermissionVersionKey, version + 1, TimeSpan.FromDays(30));
}
代价与边界 :ICacheService 默认是进程内缓存(MemoryCacheService)。多实例部署时"改权限只在当前实例生效"的问题需要靠 Redis / FusionCache 解决(第 19 篇)。
六、并发与资源:几个容易被忽略的点
1. 同一租户的初始化必须串行
csharp
// 同一个 tenantCode 的初始化必须串行:防止多个请求同时创建同一个租户的 FreeSql 与表结构。
var initLock = _initLocks.GetOrAdd(tenantCode, _ => new SemaphoreSlim(1, 1));
initLock.Wait();
如果没有这把锁,第一个租户首次访问时,N 个并发请求会同时建库建表。
(如源码注释所说,多实例部署需要分布式锁配合。)
2. 审批用条件更新而不是锁
第 17 篇讲过:并发审批靠 WHERE 状态 + 级次 的条件更新,affected = 0 即冲突。这比"加锁"更轻,也不会因为持锁进程崩溃而卡住流程。
3. 日志落库不阻塞业务
csharp
_queue.TryEnqueue(new DatabaseLogEntry(...));
TryEnqueue 是非阻塞的:写日志的线程不会等数据库(第 27 篇)。这就是"有界队列"带来的性能收益。
4. 连接池
csharp
// 多租户库
var fsql = new FreeSqlBuilder()
.UseConnectionString(tenantInfo.DataType, tenantInfo.ConenctionString.Replace("{database}", tenantCode))
.UseAdoConnectionPool(true)
.Build();
多租户意味着成倍的连接来源,连接池不是可选项。
5. FreeSql 实例的生命周期
csharp
// 注册 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
csharp
public class EasyAdminBlazorOptions
{
public ushort WorkId { 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 日志
csharp
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 |
| 基础 | 连接池、IFreeSql Singleton、日志有界队列、多实例区分 WorkId |
| 诊断 | 慢 SQL 监控、DroppedCount、权限缓存与 Circuit 保留数 |
Blazor Server 的性能优化,一半在数据库和缓存,另一半在"电路与长连接"这些服务端状态上。先把状态管好,再谈 SQL 调优。
如果你正在用 .NET 10 + Blazor 做后台,可以直接对照这篇检查自己的 Circuit、缓存和查询配置。EasyAdminBlazor 的相关参数都在源码里,改起来不需要读框架内部实现。