Blazor Server 性能优化实战:Circuit、数据库、Redis 与并发

这篇不写"理论上应该怎样",只讲这个项目的配置值、代码路径和排查方法。文中提到的每个数字都能在源码里找到。


一、先理解成本模型

Blazor Server 和传统 MVC 最大的差别:组件状态活在服务端。

text 复制代码
每个在线用户 ≈ 一个 Circuit(服务端对象图 + 组件状态 + DI 作用域)
内存占用 ≈ 在线用户数 × 每个 Circuit 的状态大小

所以性能优化的第一性问题不是"SQL 快不快",而是:

  1. 同时在线多少人?
  2. 每个页面在 Circuit 里保留了多少状态?
  3. 断线后这些状态保留多久?

框架的 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 的相关参数都在源码里,改起来不需要读框架内部实现。

相关推荐
asong1 小时前
从写代码到部署上线,Cloudflare 给 AI 配了一把新钥匙
前端·javascript·后端
卷无止境1 小时前
当AI写代码遇见Rust为什么会卡壳
后端·python
Percy_kk1 小时前
Django的CRUD映射
后端·django
王中阳Go1 小时前
Agent 第一句就 500,我们查了三轮:入口日志少打了一个参数
后端·agent·ai编程
羑悻1 小时前
穿越Docker内核迷雾:揭秘镜像分层存储的叠加态与卷挂载的多维空间穿梭技术
后端·docker·容器
励志不掉头发的内向程序员1 小时前
从鼠标点击到画出一条线:CAD 交互层的状态机设计
后端·架构
用户EasyAdminBlazor1 小时前
EasyAdminBlazor 日志系统源码解析:为什么数据库日志需要有界队列?
后端
Sylven1 小时前
【DevOps 开发流程】问题+标签驱动开发,可视化你和AI的开发进度
后端
松就是我902981 小时前
Agent系统设计七条通用原则-CSDN
后端