背景
近期新开发的一个管理系统,有一个菜单管理的模块,大致的流程是,有高级权限的管理员,可以在系统里增加菜单,然后配置权限,就和大部分类ERP或者OA系统的操作一样。
我遇到的问题是,前期赶进度,只是把这个菜单模块草草上线了,为的是快速扩充系统其他模块的管理入口。
随着菜单数量增多,最近明显感觉在切换菜单时系统开始变慢。打开控制台日志一看,发现了一个比较严重的问题,加载菜单时出现了明显的并发回源。
问题分析
虽然有点啰嗦,但我还是想描述下现象和整个问题我的复现链路。
现象
加载菜单时配置了缓存,但实际运行时第一次加载或缓存失效时,每个菜单栏目都会触发一次数据库查询,例如有 10 个菜单栏目,就会执行 10 次数据库查询。
如下图所示(日志有点粗超,多担待),预期的结构,判断出需要回源之后,只回源一次就可以,这里他是有多少菜单,就回源多少次,太吓人了

定位问题
这里我的项目是基于Blazor Server框架的,菜单的加载是组件化的,外层菜单组件核心代码如下
csharp
// 组件部分
@{
if(isLoaded == false)
{
<MudProgressCircular Indeterminate="true" Color="Color.Primary" Size="Size.Large" Class="m-4" />
return;
}
else
{
<MudNavMenu Color="Color.Primary">
@{
foreach (var node in menuTree)
{
node.requirePermissionCount += 1;
<RenderMenuItem Node="node" />
}
}
</MudNavMenu>
}
}
// 逻辑部分
@code{
protected override async Task OnInitializedAsync()
{
ConsoleHelper.WriteLine("加载菜单...", ConsoleColor.DarkCyan);
var result = await menuRepo.GetAvaliableMenus();
if (!result.IsSuccess)
{
Snackbar.Add("获取菜单失败:" + result.ErrorMessage, Severity.Error);
return;
}
menuTree = result.Value;
isLoaded = true;
}
}
递归的RenderMenuItem组件核心代码
csharp
@if (Node.Children == null || !Node.Children.Any())
{
if (Node.hasPermission)
{
<MudNavLink Icon="@(string.IsNullOrWhiteSpace(Node.Data.Icon) ? Icons.Material.Filled.Menu : MudIconMapper.GetIconValue(Node.Data.Icon))" Href="@Node.Data.RoutePath">@Node.Data.Title</MudNavLink>
}
else
{
<MudTooltip Text="当前账号没有该栏目的访问权限" Color="Color.Secondary">
<MudNavLink Disabled="true" Icon="@(string.IsNullOrWhiteSpace(Node.Data.Icon) ? Icons.Material.Filled.Menu : MudIconMapper.GetIconValue(Node.Data.Icon))" Href="javascript:;">
@Node.Data.Title
</MudNavLink>
</MudTooltip>
}
}
else
{
<MudNavGroup Title="@Node.Data.Title" Icon="@(string.IsNullOrWhiteSpace(Node.Data.Icon) ? Icons.Material.Filled.Menu : MudIconMapper.GetIconValue(Node.Data.Icon))" Expanded="false">
@foreach (var child in Node.Children)
{
<RenderMenuItem Node="child" />
}
</MudNavGroup>
}
@code {
protected override async Task OnAfterRenderAsync(bool firstRender)
{
isLoaded = true;
if (!firstRender && Node.requirePermissionCount > 0)
return;
// 出问题的地方👇
if ((StateService.AllPermissions == null || StateService.AllPermissions.Count == 0) && Node.requirePermissionCount==1)
{
ConsoleHelper.WriteLine($"回源加载权限...{Node.requirePermissionCount}", ConsoleColor.DarkMagenta);
var allPermisstionsResult = await permissionProvider.GetAllPermissionsAsync();
if (!allPermisstionsResult.IsSuccess)
{
return;
}
StateService.AllPermissions = allPermisstionsResult.Value;
}
// ...略
}
}
问题的根源就在这两个组件代码里,简单来说,RenderMenuItem组件是多个实例,而每个实例的生命周期函数是并发执行的,引发了并发竞争,同时回源查库,击穿缓存层。
或者说,这属于组件生命周期函数利用不当造成的慢性自杀行为,和传统的"缓存击穿"现象还是有点区别,但结果是殊途同归。
具体链路
更细致来说,大概就是这么个链路
- NavMenu渲染的时候,为每个根节点生成了一个独立的RenderMenuItem实例
- 每个RenderMenuItem在生命周期函数OnAfterRenderAsync中检查权限
- 当缓存为空时,多个实例同时判断StateService.AllPermissions为null
- 结果,所有实例同时触发GetAllPermissionsAsync(),同时去回源查库
这里,同时回源的次数,相当于按菜单菜单组件的数量,等比放大了~~
解决方案
知道了问题的根源,就可以对症下药的解决问题了,这里其实主要涉及到2个地方,一个是优化我们的组件加载逻辑,这也是主要问题。
另一个是优化一下GetAllPermissionsAsync方法的实现,这个主要是用作兜底,我会在后面简单带过一下。
前端统一加载
分析完问题链路,我这边的处理方法是把权限加载的流程从RenderMenuItem提取到NavMenu中统一执行,确保只加载一次,不会随着每个RenderMenuItem的实例化而重新加载。
- 修改NavMenu.razor文件的生命周期函数
csharp
protected override async Task OnInitializedAsync()
{
// 先加载菜单
var result = await menuRepo.GetAvaliableMenus();
if (!result.IsSuccess){
Snackbar.Add("获取菜单失败:" + result.ErrorMessage, Severity.Error);
return;
}
menuTree = result.Value;
// 统一加载权限数据,防止多个RenderMenuItem并发加载导致缓存击穿
if (StateService.AllPermissions == null || StateService.AllPermissions.Count == 0)
{
ConsoleHelper.WriteLine("NavMenu: 统一加载权限数据...", ConsoleColor.DarkMagenta);
var permissionResult = await permissionProvider.GetAllPermissionsAsync();
if (permissionResult.IsSuccess)
{
StateService.AllPermissions = permissionResult.Value;
}
}
isLoaded = true;
}
- 修改RenderMenuItem,
- 移除不必要的依赖注入(代码案例里我省略掉了,这是纯业务层的东西)
- 逻辑变的简单,只负责读取StateService.AllPermissions进行权限检查,不再加载权限数据,
- 使用OnInitializedAsync替代OnAfterRenderAsync,这里我一开始把实现写在OnAfterRenderAsync里,是因为初始版本的组件里,涉及到了js交互,而blazor里,与js的交互逻辑是要在OnAfterRenderAsync里执行的,不能放在预渲染阶段。
csharp
@code {
[Parameter] public MenuTreeNode Node { get; set; }
protected override async Task OnInitializedAsync()
{
// 权限数据已在NavMenu中统一加载到StateService.AllPermissions
// 这里只负责读取并检查当前节点的权限
if (Node.Children == null || Node.Children.Count == 0)
{
var permisstion = StateService.AllPermissions?
.Where(u => u.RoutePath.Equals(Node.Data.RoutePath, StringComparison.InvariantCultureIgnoreCase))
.FirstOrDefault();
if (permisstion != null && await permissionService.HasPermissionAsync(permisstion.PermissionKey))
{
Node.hasPermission = true;
}
else
{
Node.hasPermission = false;
}
}
else
{
Node.hasPermission = true;
}
}
}
这样修改后,在看日志,菜单加载的时候,就只回源一次,后续在检查权限,就不再回库里查询了,从页面执行效果速度也提升了许多

*后端兜底策略
后端的问题,就是请求权限的方法GetAllPermissionsAsync,先看下代码
csharp
public async Task<Result<List<ManagePermissionResponseDto>>> GetAllPermissionsAsync()
{
try
{
string cacheKey = $"{CacheKeys.DecPermissionPrefix}_all";
var cacheValues = await _easyCachingProvider.SMembersAsync<ManagePermissionResponseDto>(cacheKey);
if(cacheValues!=null && cacheValues.Count > 0)
{
ConsoleHelper.WriteLine("走缓存", ConsoleColor.DarkBlue);
return Result<List<ManagePermissionResponseDto>>.Success(cacheValues);
}
// 缓存未命中,使用分布式锁防止缓存击穿
string lockKey = $"{cacheKey}_lock";
string lockValue = Guid.NewGuid().ToString();
int lockExpireSeconds = 10; // 锁过期时间,防止死锁
int maxWaitSeconds = 30; // 最大等待时间
int checkIntervalMs = 50; // 检查间隔
// 尝试获取锁(使用SETNX语义)
bool lockAcquired = await _easyCachingProvider.StringSetAsync(
lockKey,
lockValue,
TimeSpan.FromSeconds(lockExpireSeconds),
"nx");
if (!lockAcquired)
{
// 未获取到锁,等待其他请求完成加载后读缓存
ConsoleHelper.WriteLine("等待其他请求加载权限数据...", ConsoleColor.DarkYellow);
int waitedMs = 0;
while (waitedMs < maxWaitSeconds * 1000)
{
await Task.Delay(checkIntervalMs);
waitedMs += checkIntervalMs;
// 再次检查缓存
cacheValues = await _easyCachingProvider.SMembersAsync<ManagePermissionResponseDto>(cacheKey);
if (cacheValues != null && cacheValues.Count > 0)
{
ConsoleHelper.WriteLine("等待后走缓存", ConsoleColor.DarkGreen);
return Result<List<ManagePermissionResponseDto>>.Success(cacheValues);
}
}
// 等待超时,返回失败
ConsoleHelper.WriteLine("等待超时,权限加载失败", ConsoleColor.DarkRed);
return Result<List<ManagePermissionResponseDto>>.Fail("权限加载超时");
}
try
{
// 获取到锁,再次检查缓存(防止其他请求刚放完缓存)
cacheValues = await _easyCachingProvider.SMembersAsync<ManagePermissionResponseDto>(cacheKey);
if (cacheValues != null && cacheValues.Count > 0)
{
ConsoleHelper.WriteLine("获取锁后走缓存", ConsoleColor.DarkGreen);
return Result<List<ManagePermissionResponseDto>>.Success(cacheValues);
}
// 缓存确实为空,执行数据库查询
ConsoleHelper.WriteLine("回源了。。。",ConsoleColor.DarkRed);
var result = await SearchPermissions();
await _easyCachingProvider.SAddAsync(cacheKey, result, TimeSpan.FromSeconds(CacheKeys.DefaultExpireSeconds));
return Result<List<ManagePermissionResponseDto>>.Success(result);
}
finally
{
// 释放锁
string luaScript = @"if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
await _easyCachingProvider.EvaAsync(luaScript, new[] { lockKey }, new[] { lockValue });
}
}
catch (Exception ex)
{
return Result<List<ManagePermissionResponseDto>>.Fail(ex.Message);
}
}
简单说一下,上面这段代码还没有经过整理,缓存策略部分的比重比业务代码要多得多,其核心逻辑如下
- 先检查缓存,能命中,则直接返回
- 未命中,就尝试获取分布式锁(利用Redis的SETNX)
- 获取锁成功,就再次检查缓存,防止其他请求刚释放完,确认为空后,再去查库更新缓存
- 如果获锁失败,就等待其他请求完成后,在读缓存
- 最后的lua脚本,是实现原子性释放所,防止误删别人的锁;
这里注意,因为我的项目是分布式部署的,所以使用了Redis集群,如果本身就是单实例部署,那本地锁也完全ok,而且性能更好,上Redis的好处就是后续扩展更加方便。
结语
这次的标题我虽然说的是缓存击穿问题,但其本质还是由于组件并发,以及缓存层保护措施不够引起的和"缓存击穿"很像的一个问题。这也提醒我们,展示层或者说UI层,绝不是简单的完成渲染工作就完事了,还是要精心设计,我这里是Blazor,在其他前端场景也是一样的,组件一多,如果涉及不当,明明一次请求就可以完成的事情,很可能会被放大N倍,造成严重的并发失控问题。
另外,全局数据还是要做好入口统一,像权限这种数据,不应该在子组件里单独加载,而是应该提取到入口,统一处理。而加载权限的后端方法,仅仅用上缓存也是不够的,还应该考虑并发访问,以及失效瞬间的回源保护等。毕竟后端服务是兜底的,高可用设计是首要条件。
至此,这次的缓存击穿案例就基本处理结束了,算是踩了一次Blazor组件生命周期的坑,希望对有同样问题的小伙伴有帮助。
拜拜,下次见。