news 2026/3/14 1:30:35

架构演进:从数据库“裸奔”到多级防护

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
架构演进:从数据库“裸奔”到多级防护

噗,这个标题是不是有点AI味?哈哈,确实有让AI起名,但只是起了个名,我原来的标题是:“给你的数据接口提提速,聊聊二级缓存的架构设计”

前言

前阵子给项目做了点性能优化,最核心的手段就是加上了二级缓存的设计,趁着今天有机会,我想好好聊聊这个话题。

事实上,我们的业务系统一直在采用这一套基于Redis的缓存策略,但最近我们上线的这套系统,是一个可预见的高并发系统,顺利上线的话,可能比以往任何系统的并发量都要高,所以我觉得仅靠Redis就显得有些力不从心了。在大规模流量冲击下,Redis 的网络 I/O、内网带宽以及频繁的反序列化开销,会逐渐成为压垮 Web 服务器CPU的最后一根稻草。

为了提早消除隐患,我稍微改造了一下原有的缓存架构,增加了二级缓存的设计,这其中也踩了点小坑,拿出来一看聊聊。

架构设计

我这里的系统,缓存架构是基于EasyCaching这个第三方库构建的,当然这个不是核心,如果你不喜欢第三方库,完全可以基于原生api自行构造。

一级缓存(L1)

一级缓存的载体就是内存,这是超高并发场景下最有效的防护罩,从计算机硬件上,它离CPU最近,执行速度极快,也没有网络开销,我觉得市面上所有多级缓存的架构设计,第一层基本都得是内存吧。

二级缓存(L2)

二级缓存就是Redis了,它负责数据共享和持久化支撑,是一级缓存穿透后的坚实后盾。因为我们的系统基本都是分布式部署的,所以为了保证架构的简单性,之前的项目里都是直接用Redis做缓存的,引入L1后,它也如释重负了,在高并发的场景下,它的核心作用主要是确保高可用和平衡多个服务节点的数据一致性了。

三级缓存(L3)

其实三级就不叫缓存,就直接落到数据库了,当二级缓存失效,请求最终还是会来到数据库,我们之前写过的检索逻辑还是一切如常,不需要为多级缓存的设计过度修改,但此时它的压力就更小了,在前面两层防护的保护下,即便是高并发的场景,应付起来也能游刃有余。

架构图*

这个架构图如下,需要说明的是,当2级缓存没有命中的话,并不是直接去数据库查询,因为这里还设计了一个信号锁,而关于信号锁的作用主要是预防缓存击穿,当某个热点Key过期后,只有1个线程去查数据库,其他线程会在信号量处等待,然后直接读取第一个线程查出来的缓存。更多的内容大家可以自行GPT一下。实际理解起来,可以跳过这点,认为L2失效就是打到数据库就可以了。

代码

安装依赖

因为我这里依赖了EasyCaching的生态,所以需要先引入EasyCaching.Memory和EasyCaching.CSRedis。

注意EasyCaching对于Redis的封装包有两个,CSRedis和Redis,CSRedis是国人封装的,对中文系统应该来说更接地气一点,配置更简单,而Redis就是基于StackExchange.Redis,底层实现虽有差别,但在EasyCaching里提供的抽象接口都是一致的,所以用谁都可以。

<PackageReferenceInclude="EasyCaching.InMemory"Version="1.9.2"/><PackageReferenceInclude="EasyCaching.CSRedis"Version="1.9.2"/>

注入服务

services.AddEasyCaching(options=>{options.UseCSRedis(configuration,"redis","Easycaching:redisSentinel")options.UseInMemory(conf=>{conf.MaxRdSecond=2;conf.EnableLogging=false;conf.DBConfig=newEasyCaching.InMemory.InMemoryCachingOptions{SizeLimit=1024};},"memory");});

我这里redis是以哨兵集群的方式接入,配置文件如下

"redisSentinel":{"MaxRdSecond":5,"EnableLogging":false,"LockMs":5000,"SleepMs":300,"SerializerName":"redis","dbconfig":{"ConnectionStrings":["略"],"Sentinels":["节点1","节点2","节点3"],"ReadOnly":false}},

需要注意MaxRdSecond这个参数,我这里设置的默认值在redis里是5,内存里是2,这个参数的意义是EasyCaching为了预防出现缓存雪崩的一个小设计,在写入缓存的时候随机加入一个不大于这个MaxRdSecond的时长,所以这个值是多少,或者需不需要用,还是要看你的项目场景。

接口和实现

publicinterfaceIMultiLevelCacheService{Task<T>GetOrCreateAsync<T>(stringkey,Func<Task<T>>factory,int?l2Seconds=null,CancellationTokenct=default);Task<Result<T>>GetOrCreateForResultAsync<T>(stringkey,Func<Task<Result<T>>>factory,int?l2Seconds=null,CancellationTokenct=default);TaskRemoveAsync(stringkey,CancellationTokenct=default);}publicclassMultiLevelCacheService:IMultiLevelCacheService{privatereadonlyIEasyCachingProvider_l2Provider;// RedisprivatereadonlyIEasyCachingProvider_l1Provider;// MemoryprivatereadonlyILogger<MultiLevelCacheService>_logger;privatereadonlyMultiLevelCacheOptions_options;//本地锁,防止同一个 Key 的缓存失效时,大量请求同时冲向数据库(防击穿)privatestaticreadonlyConcurrentDictionary<string,SemaphoreSlim>_locks=new();publicMultiLevelCacheService(IEasyCachingProviderFactoryfactory,IOptions<MultiLevelCacheOptions>options,ILogger<MultiLevelCacheService>logger){_l2Provider=factory.GetCachingProvider("redis");_l1Provider=factory.GetCachingProvider("memory");_logger=logger;_options=options.Value;}publicasyncTask<Result<T>>GetOrCreateForResultAsync<T>(stringkey,Func<Task<Result<T>>>factory,int?l2Seconds=null,CancellationTokenct=default){//预处理过期时间NormalizeL2Seconds(refl2Seconds);varl1Seconds=CalculateL1Seconds(l2Seconds!.Value);//尝试从一级缓存读取 (最快)varl1Result=await_l1Provider.GetAsync<T>(key,ct);if(l1Result.HasValue){ConsoleHelper.WriteLine("1级缓存命中:"+key,ConsoleColor.DarkGreen);returnResult<T>.Success(l1Result.Value);}//尝试从二级缓存读取try{varl2Result=await_l2Provider.GetAsync<T>(key,ct);if(l2Result.HasValue){ConsoleHelper.WriteLine("2级缓存命中:"+key,ConsoleColor.DarkBlue);//取 Redis 剩余 TTL 和配置上限的最小值,塞回L1varttl=await_l2Provider.GetExpirationAsync(key,ct);varremainingL1=NormalizeRemainingSeconds(ttl,l1Seconds);await_l1Provider.SetAsync(key,l2Result.Value,TimeSpan.FromSeconds(remainingL1),ct);returnResult<T>.Success(l2Result.Value);}}catch(Exceptionex){_logger.LogWarning(ex,"L2 缓存读取异常,Key: {Key}",key);}//防击穿加锁回源获取或创建针对该 Key 的信号量varsemaphore=_locks.GetOrAdd(key,_=>newSemaphoreSlim(1,1));awaitsemaphore.WaitAsync(ct);try{//在获取锁的期间,可能上一个线程已经把缓存写好了vardoubleCheck=await_l1Provider.GetAsync<T>(key,ct);if(doubleCheck.HasValue)returnResult<T>.Success(doubleCheck.Value);ConsoleHelper.WriteLine("缓存未命中,回源加载数据:"+key,ConsoleColor.DarkYellow);//执行回源业务逻辑varfreshResult=awaitfactory();//成功则写入双级缓存if(freshResult.IsSuccess){awaitWriteBothAsync(key,freshResult.Value,l2Seconds.Value,l1Seconds,ct);}returnfreshResult;}finally{semaphore.Release();//如果没有人在等待这个锁了,可以从字典中移除(节省内存)if(semaphore.CurrentCount>0)_locks.TryRemove(key,out_);}}publicasyncTask<T>GetOrCreateAsync<T>(stringkey,Func<Task<T>>factory,int?l2Seconds=null,CancellationTokenct=default){//逻辑与上面类似,仅返回值处理不同,此处略varresult=awaitGetOrCreateForResultAsync(key,async()=>{varval=awaitfactory();returnResult<T>.Success(val);},l2Seconds,ct);returnresult.Value;}publicasyncTaskRemoveAsync(stringkey,CancellationTokenct=default){awaitTask.WhenAll(_l1Provider.RemoveAsync(key,ct),_l2Provider.RemoveAsync(key,ct));}}

大概解释下,我这里主要用到的方法实现是GetOrCreateForResultAsync,因为我的数据接口场景里,数据回传到接口层时,外层包了一个统一的Result,接口案例如下

[HttpGet("GetArticleDetail/{id}")]publicasyncTask<IActionResult>GetArticleDetail(longid){stringcacheKey=ApiCachePrefixKeys.BuildKey(ApiCachePrefixKeys.DecArticles,id);;varresult=await_multiLevelCacheService.GetOrCreateForResultAsync(cacheKey,()=>_decArticleRepo.GetArticleDetail(id));//不用缓存时//var result = await _decArticleRepo.GetArticleDetail(id);if(result.IsSuccess){returnOk(ApiResult.Success(result));}returnAccepted(ResultExtensions.ToApiResult(result));}

这是一个读取文章的案例,增加缓存机制后,需要将编译后的委托缓存起来,所以写法看起来是现在这个样子。

如果不需要外层包Result,直接拿到数据对象,那就可以直接使用GetOrCreateAsync就好。

执行效果

最后,看一下执行的效果,第一次请求数据,未命中缓存

此时Redis里可以看到我们刚刚缓存的数据

接下来马上进行第二次请求,如期命中一级缓存L1

再过一小会儿,在请求,按预期命中二级缓存L2

至此,我们的缓存架构就基本完成了,而删除缓存的案例就不演示了,

一个小坑*

如同我前面说到我的场景里特殊的返回值类型,目前是缓存的编译后的委托,而在这之前,为了追求方便我使用的表达式函数,像下面这样

varfreshResult=awaitmethodCall.Compile()();

看起来也不错,而且注意这是2个括号,第一个括号是把表达式函数编译成委托,返回一个Func<Task>,第二个括号才是的到这个函数之后的执行。而问题也就出在这两个“括号”上,这样看起来优雅,实际上隐藏着很大的性能隐患,就在第一个括号执行的时候,会调用CPU执行IL编译,尽管有前面2层缓存做防护,真到了高并发的场景,一下子多次执行编译工作,CPU也得冒烟,这就得不偿失了,最后改成了现在的样子。当然肯定不是所有小伙伴都能遇到这个问题,但如果恰好遇到,又恰好也看到这,只能说咱们太有缘了,点个关注吧哈哈。

结语

最后,我经常听到一些阴阳怪气的声音,什么“就你这业务量,还搞多级缓存?””不想着早点交差,一天在这些地方浪费时间有什么用“…巴拉巴拉。
我说想,有这种声音的人,如果你真的理解技术,理解业务,也亲身验证过这个技术不适合你的业务,那你说什么都OK。
而现实情况是,大部分所谓的专家,基本都不做验证性工作,他们只想着交差,恨不得代码写完再也不改,连自己写的代码都不想多看一眼。。。这种人发出的这种声音,我的态度是,当放屁就好,千万别让他们阻挡了我们的好奇心,对自己产生怀疑,他们的话不值一提。
再聊回多级缓存,这从来都不是高深技术,哪怕是一个日活几百的小系统,只要存在重复读、热点数据或对响应速度有要求,或者作为开发者的你不甘于只做简单架构,那从设计一个多级缓存模块开始吧,它一定可以给你和你的系统带来立竿见影的体验提升。
还有,即便是小项目,你能保证它一辈子当个“小项目”吗?架构设计不是一锤子买卖,而是在演进中预留弹性,提前埋下合理的扩展点,这远比在流量突增时连滚带爬的救火要从容得多。有偏见的开发者,永远写不出好用的系统。
好了,至此,这个多级缓存的话题差不多就聊完了,下次再见。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/3/13 4:53:44

Cursor 最新发现:超大型项目 AI 也能做了,上百个 Agent 一起上

大家好&#xff0c;我是拭心。 2008 年 9 月 2 日&#xff0c;Google Chrome 浏览器正式发布。这个项目从 2005 年立项到发布&#xff0c;「历时 3 年&#xff0c;投入了数千名工程师」。如今&#xff0c;Chromium 代码规模已超过 3600 万行&#xff0c;被称为“人类史上最复杂…

作者头像 李华
网站建设 2026/3/12 12:59:11

实测Qwen1.5-0.5B-Chat:轻量级AI对话效果超预期

实测Qwen1.5-0.5B-Chat&#xff1a;轻量级AI对话效果超预期 1. 引言&#xff1a;为何需要更小的对话模型&#xff1f; 随着大模型技术的快速演进&#xff0c;行业正从“参数规模至上”转向“效率与实用性并重”。尽管千亿级模型在复杂任务上表现出色&#xff0c;但其高昂的部…

作者头像 李华
网站建设 2026/3/12 20:52:02

YOLO11+DeepSORT多目标追踪:云端3分钟部署完整方案

YOLO11DeepSORT多目标追踪&#xff1a;云端3分钟部署完整方案 你是不是也遇到过这样的情况&#xff1f;公司要做一个智能交通系统的Demo&#xff0c;老板说“两天内必须出效果”&#xff0c;而你自己从零开始搭环境、装依赖、调模型&#xff0c;光配置就得折腾一周。时间紧任务…

作者头像 李华
网站建设 2026/3/12 16:26:20

企业级应用落地实践:AI手势识别生产环境部署案例

企业级应用落地实践&#xff1a;AI手势识别生产环境部署案例 1. 引言 1.1 业务场景描述 在智能交互系统、远程控制设备、虚拟现实&#xff08;VR&#xff09;和增强现实&#xff08;AR&#xff09;等前沿技术领域&#xff0c;非接触式人机交互正逐渐成为用户体验升级的核心方…

作者头像 李华
网站建设 2026/3/12 20:58:26

BGE-M3实战:构建智能电商搜索系统

BGE-M3实战&#xff1a;构建智能电商搜索系统 1. 引言 在现代电商平台中&#xff0c;用户对搜索体验的要求日益提升。传统的关键词匹配方式已难以满足复杂语义场景下的精准召回需求&#xff0c;例如用户输入“轻薄长续航笔记本”时&#xff0c;系统应能理解其与“超极本 电池…

作者头像 李华
网站建设 2026/3/13 1:53:23

混元翻译模型再升级|HY-MT1.5-7B本地化部署全攻略

混元翻译模型再升级&#xff5c;HY-MT1.5-7B本地化部署全攻略 1. 引言&#xff1a;为何选择HY-MT1.5-7B进行本地化部署&#xff1f; 随着全球化交流的不断深入&#xff0c;高质量、低延迟的翻译服务需求日益增长。传统的云端翻译API虽然便捷&#xff0c;但在隐私保护、网络依…

作者头像 李华