一次由组件并发引发的类“缓存击穿”问题排查与修复

背景

近期新开发的一个管理系统,有一个菜单管理的模块,大致的流程是,有高级权限的管理员,可以在系统里增加菜单,然后配置权限,就和大部分类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组件是多个实例,而每个实例的生命周期函数是并发执行的,引发了并发竞争,同时回源查库,击穿缓存层。

或者说,这属于组件生命周期函数利用不当造成的慢性自杀行为,和传统的"缓存击穿"现象还是有点区别,但结果是殊途同归。

具体链路

更细致来说,大概就是这么个链路

  1. NavMenu渲染的时候,为每个根节点生成了一个独立的RenderMenuItem实例
  2. 每个RenderMenuItem在生命周期函数OnAfterRenderAsync中检查权限
  3. 当缓存为空时,多个实例同时判断StateService.AllPermissions为null
  4. 结果,所有实例同时触发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);
    }
}

简单说一下,上面这段代码还没有经过整理,缓存策略部分的比重比业务代码要多得多,其核心逻辑如下

  1. 先检查缓存,能命中,则直接返回
  2. 未命中,就尝试获取分布式锁(利用Redis的SETNX)
  3. 获取锁成功,就再次检查缓存,防止其他请求刚释放完,确认为空后,再去查库更新缓存
  4. 如果获锁失败,就等待其他请求完成后,在读缓存
  5. 最后的lua脚本,是实现原子性释放所,防止误删别人的锁;

这里注意,因为我的项目是分布式部署的,所以使用了Redis集群,如果本身就是单实例部署,那本地锁也完全ok,而且性能更好,上Redis的好处就是后续扩展更加方便。

结语

这次的标题我虽然说的是缓存击穿问题,但其本质还是由于组件并发,以及缓存层保护措施不够引起的和"缓存击穿"很像的一个问题。这也提醒我们,展示层或者说UI层,绝不是简单的完成渲染工作就完事了,还是要精心设计,我这里是Blazor,在其他前端场景也是一样的,组件一多,如果涉及不当,明明一次请求就可以完成的事情,很可能会被放大N倍,造成严重的并发失控问题。

另外,全局数据还是要做好入口统一,像权限这种数据,不应该在子组件里单独加载,而是应该提取到入口,统一处理。而加载权限的后端方法,仅仅用上缓存也是不够的,还应该考虑并发访问,以及失效瞬间的回源保护等。毕竟后端服务是兜底的,高可用设计是首要条件。

至此,这次的缓存击穿案例就基本处理结束了,算是踩了一次Blazor组件生命周期的坑,希望对有同样问题的小伙伴有帮助。

拜拜,下次见。

相关推荐
golang学习记1 小时前
Git 2.54 来了,这个新命令让我终于敢重写历史了
git·后端
绿算技术1 小时前
从 DGX Spark + GP Spark 融合架构说起!!!
架构
二月龙1 小时前
谁说Python不能做高并发?用asyncio+FastAPI吞吐量提高10倍
后端
听风者就是我2 小时前
AI 编程从失控到可控:OpenSpec 实战指南 + 架构深度解析
后端
JAVA学习通2 小时前
AI Agent 工具调用机制与 Spring Boot 工程集成(2026 实战指南)
人工智能·spring boot·后端
用户6757049885022 小时前
AI开发实战6、抄作业吧!我优化了N遍的go-zero项目AI协作规范文件,一字不差全给你
后端·aigc·ai编程
武子康2 小时前
大数据-276 Spark MLib-深入理解Bagging与Boosting:集成学习核心算法对比与GBDT实战
大数据·后端·spark
开心就好20252 小时前
Charles配置HTTP和HTTPS抓包完整指南
后端·ios