news 2026/10/7 23:51:09

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Blazor Server 性能优化实战:Circuit、数据库、Redis 与并发

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


一、先理解成本模型

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

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

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

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

框架的 Circuit 配置直接对应第 3 个问题:

// 电路断开后保留时长:后台 Tab 冻结导致连接断开时,保留电路供切回时快速恢复builder.Services.Configure<Microsoft.AspNetCore.Components.Server.CircuitOptions>(options=>{options.DisconnectedCircuitRetentionPeriod=TimeSpan.FromMinutes(10);options.DisconnectedCircuitMaxRetained=200;});
配置值含义代价
DisconnectedCircuitRetentionPeriod10 分钟断线后电路保留多久保留期间内存不释放
DisconnectedCircuitMaxRetained200最多保留多少个断开的电路超出后最旧的被回收

这两个值是"体验换内存"的旋钮。200 个保留电路对普通后台够用;如果单机能承载的在线用户很多(比如 2000+),要把这个数字和服务器内存一起算:

粗算:每个 Circuit 常驻内存(组件 + 状态 + 服务) × 200 再加上:在线电路数 × 每电路内存

调小MaxRetained或RetentionPeriod能省内存,代价是用户切回标签页时可能已经重连。


二、SignalR 连接参数

builder.Services.AddSignalR(options=>{options.KeepAliveInterval=TimeSpan.FromSeconds(15);options.ClientTimeoutInterval=TimeSpan.FromMinutes(3);});
配置默认本项目影响
KeepAliveInterval15 秒15 秒服务端 ping 频率,越大越省流量但发现断线越慢
ClientTimeoutInterval30 秒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 都对应一类数据的变更频率:

数据缓存位置有效期失效方式
系统配置SysConfigICacheService1 分钟到期自动
用户角色ICacheService30 分钟权限版本号 +1
用户菜单ICacheService30 分钟权限版本号 +1
字典项ICacheService30 秒到期自动
实体选择器选项ICacheService30 秒到期自动
审批流程配置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 objectIFreeSql生命周期注册错误确认是 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
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/7 23:50:57

飞鲲GEO实操拆解:企业内容建设如何适配AI采信规则

一、从技术角度理解GEO的四个常见问题生成式引擎优化正在改变企业获取线上可见度的方式&#xff0c;但多数企业对其技术原理仍存在认知偏差。第一&#xff0c;GEO与传统搜索引擎优化的本质区别在哪里&#xff1f;第二&#xff0c;AI模型筛选信源时究竟看重什么&#xff1f;第三…

作者头像 李华
网站建设 2026/10/7 23:47:54

AI编程上下文模式(context-mode)实操指南

最近一段时间&#xff0c;我几乎每天都要跟"context-mode"这个词打交道。不是在翻某个AI编程工具的配置文档&#xff0c;就是在跟同事争论某个上下文模式到底该不该开。作为一名从命令行走过来的老开发者&#xff0c;我最早接触"上下文"这个概念还是在Vim里…

作者头像 李华
网站建设 2026/10/7 23:44:01

AI Agent工程实现七要素与七个关键决策点

1. 这不是概念科普&#xff0c;是工程师手里的Agent拆解图谱你刷到过太多“AI Agent是什么”的文章——讲定义、画架构图、列几个开源框架名字&#xff0c;最后告诉你“它能自主规划、调用工具、记忆上下文”。听起来很酷&#xff0c;但回到工位上&#xff0c;你依然不知道&…

作者头像 李华
网站建设 2026/10/7 23:43:24

企业级AI应用底座架构实战:基于Spring Cloud与JDK 21的微服务化AI工程化落地

1. 从一个真实困境说起&#xff1a;为什么“能跑起来的AI Demo”和“能上线的AI应用”之间隔了一整个太平洋过去一年多&#xff0c;我参与过不下十个企业级AI项目的评审和落地咨询。一个反复出现的场景是&#xff1a;业务部门用两周时间基于某个开源框架搭出了一个效果惊艳的De…

作者头像 李华
网站建设 2026/10/7 23:39:50

PMOS高边开关在低功耗设计中的应用:从原理到实测

1. 从两个真实痛点说起&#xff1a;为什么我要用PMOS做电源开关墨水屏设备最让人头疼的地方&#xff0c;不是刷新慢&#xff0c;而是待机功耗压不下去。我手上有个基于STM32L4的温湿度记录仪项目&#xff0c;带一块2.13寸墨水屏&#xff0c;最初方案是用一颗LDO常供电给墨水屏驱…

作者头像 李华