ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

ASP.NET Core生产级Redis缓存实践:选型、踩坑与性能优化

ASP.NET Core生产级Redis缓存实践:选型、踩坑与性能优化 1. 为什么ASP.NET Core应用最后都会走到Redis这一步先说一个我自己的经历。早些年做过一个生产管理系统一开始数据量不大接口里直接查数据库完全没压力。后来接了一个大屏看板前端每隔几秒就轮询一次聚合数据数据库连接池直接被拖到报警阈值CPU飙到90%以上页面转圈卡死。当时的救急方案很粗暴——在静态变量里放了一个Dictionary当缓存结果一到多实例部署就露馅了每台机器缓存各自为政刷新一台另一台还拿着旧数据。这就是我后来转向Redis缓存的核心原因当你的应用从单实例走向多实例、从低并发走向高并发本地内存缓存会从优化手段变成一致性灾难源。ASP.NET Core本身自带IMemoryCache适合单机场景但它解决不了分布式环境下所有实例共享同一份缓存数据的问题。Redis作为独立的缓存服务天然就是为这个场景设计的——所有Web实例连同一个Redis读同一个Key数据一致性由服务端保证应用层不用管同步逻辑。很多人把Redis当成快一点的数据库来用这其实低估了它。在ASP.NET Core的高级实践里Redis承担的角色通常是三个缓存层把热点数据、聚合结果、高频读取的配置放在Redis里挡住绝大多数数据库请求。协调层用Redis的分布式锁解决多实例并发写同一个资源的竞态问题。共享存储层存分布式Session、SignalR的背板数据、接口限流计数器等跨实例共享的轻量级状态。这套组合拳打下来应用的扩展性才会真正拉开差距。本文想聊的不是那种装个Redis、写个Helper类的入门用法而是你在生产环境真正会遇到的选型、踩坑、设计和排障经验。如果你是刚接触ASP.NET Core缓存的开发者建议先把官方文档里IMemoryCache和IDistributedCache的部分过一遍再来看这篇如果你已经用了Redis一段时间但总觉得哪里不对劲那这篇文章应该能帮你补上一些关键拼图。2. 集成Redis前的四个关键选型错了后面全返工很多教程直接告诉你安装StackExchange.Redis包写两行代码就完事。但实际上集成Redis之前有几个选型问题没想清楚后面改造的代价会非常大。我把它们按重要程度排序。2.1 客户端库StackExchange.Redis是事实标准别折腾.NET生态里操作Redis的客户端主要有几个选择StackExchange.Redis、ServiceStack.Redis、CSRedisCore、NewLife.Redis。我的建议非常简单粗暴没有特殊理由就用StackExchange.Redis。理由有三点。第一微软官方的Microsoft.Extensions.Caching.StackExchangeRedis底层就是封装它说明它经过了大规模生产验证第二它实现了连接池和多路复用multiplexer机制能在一个TCP连接上并发处理多个请求性能表现稳定第三社区活跃度高遇到问题几乎都能搜到解决方案。ServiceStack.Redis虽然性能也不错但免费版有每小时请求数限制商用要买授权光是这一点就可以直接排除。CSCoreRedis在国产项目里有不少人在用性能也强但如果你只是做缓存不需要追求极限性能StackExchange.Redis完全够用。注意微软的IDistributedCache抽象是基于StackExchange.Redis的封装默认序列化用的是System.Text.Json新版或JSON.NET旧版。如果你只需要简单的Get/Set操作直接用这个抽象就够了但涉及分布式锁、自增、订阅发布等高级功能就必须直接用StackExchange.Redis原生的IDatabase接口。2.2 连接字符串与ConnectionMultiplexer的正确姿势StackExchange.Redis核心对象是ConnectionMultiplexer它负责管理到Redis服务器的所有连接。这里最常见的一个坑是每次请求都New一个ConnectionMultiplexer。如果你查过这个类应该知道它官方文档明确写了一句话ConnectionMultiplexer is designed to be shared and reused between callers也就是说它应该被设计成单例。每次创建新的实例意味着每次都会重新建立TCP连接高并发下连接数会迅速膨胀。而Redis默认最大连接数一般是10000虽然够用但每一条连接都有开销创建销毁频繁会导致TIME_WAIT堆积严重时甚至会让Redis拒绝服务。在ASP.NET Core里正确做法是注册为单例服务// 使用IDistributedCache时微软已经封装好你只需要配置连接字符串 // 注册方式 builder.Services.AddStackExchangeRedisCache(options { options.Configuration localhost:6379,passwordxxx,abortConnectfalse,connectRetry3; options.InstanceName MyApp_; });如果你需要直接用原生的StackExchange.Redis接口正确姿势是手动注册单例builder.Services.AddSingletonIConnectionMultiplexer(sp { var config new ConfigurationOptions { EndPoints { localhost:6379 }, Password xxx, AbortOnConnectFail false, ConnectRetry 3, ConnectTimeout 5000 }; return ConnectionMultiplexer.Connect(config); });这里有两个配置项我需要单独解释一下。abortConnectfalse对应AbortOnConnectFailfalse非常关键。默认情况下如果启动时Redis连不上StackExchange.Redis会直接抛异常导致应用无法启动。但生产环境经常会出现Redis短暂不可用的情况你肯定不希望因为Redis挂了整个应用就挂掉。设置这个参数后连接失败不会立即抛异常而是进入重试逻辑。配合connectTimeout和connectRetry可以保证应用在Redis故障时仍然能启动只是缓存命中率为零。2.3 序列化方案不要碰BinaryFormatter也不要裸存字符串缓存里存什么、怎么序列化直接决定了缓存的可读性、体积和性能。新手最容易犯的错是把对象ToString()之后存成一个字符串然后取出来再手动解析。这种方式短期能跑但字段一变、类型嵌套一深解析代码就会变成灾难。微软IDistributedCache默认的AddStackExchangeRedisCache使用的是JSON序列化基础场景够用。但如果你有性能要求或者缓存的对象结构比较复杂建议换成MessagePack或Protobuf之类的二进制序列化。这里有一个大坑必须说不要用BinaryFormatter。它的安全问题在.NET生态里已经被反复强调过后来直接标记为过时在.NET 8中甚至直接不可用。一旦项目里用了它每次反序列化都是潜在的攻击面有远程代码执行的风险。我自己的实践是对外部接口返回的数据比如API响应缓存用JSON序列化因为方便排查问题查看缓存内容时一眼能看懂对内部服务之间传递的复杂对象用MessagePack性能更好、体积更小。切换序列化器时注意Key的设计要带版本号或前缀不然老数据和新序列化器之间可能出现兼容问题。2.4 IDistributedCache抽象与原生客户端API怎么取舍微软提供IDistributedCache这个抽象接口目的很简单——让你不依赖具体缓存实现今天用Redis明天换内存后天换数据库业务代码不用改。好处很明显坏处也很明显接口能力太弱了。IDistributedCache只有Get、Set、Refresh、Remove等基础方法没有原子自增、没有SetNx、没有TTL批量设置、没有分布式锁、没有Lua脚本扩展。你如果想给某个Key设置10秒过期、并且只有在不存在时才写入对不起抽象接口做不到你得拿到底层的IDatabase。所以我的建议是组合使用而不是二选一。业务代码依赖IDistributedCache做最基本的数据缓存保持可替换性。创建一个额外的IRedisService接口封装需要原子操作的高级功能内部直接用IDatabase实现。如果IDistributedCache不能满足某个功能的性能要求直接注入IConnectionMultiplexer拿IDatabase操作不必拘泥于抽象。这样既有抽象层带来的灵活性又能发挥Redis真正强大的原子能力。3. 缓存读写、过期与一致性高并发下最容易暴露的问题缓存这东西单机、低并发的时候怎么玩都不会出大问题但一旦流量上来各种看起来没什么问题的写法就会变成事故源头。这一节集中聊聊穿透、击穿、雪崩和一致性这四座大山。3.1 穿透、击穿、雪崩三个词对应三种完全不同的解法这三个词经常被放到一起讲但它们的成因和解法其实完全不同面试可以一起背实战必须分开处理。缓存穿透指的是请求的数据在缓存和数据库里都不存在导致每一次请求都直接打到数据库。比如用户查一个不存在的商品IDRedis里没有数据库也没有请求就直接穿透了缓存层。如果攻击者伪造一批不存在的ID循环请求数据库会被打爆。解决方案有三种缓存空值不存在的数据也缓存起来TTL设置短一点比如60秒。这样重复请求会命中缓存不会再打数据库。布隆过滤器把所有合法ID提前放到位图里查询前先判断ID是否可能存在不存在直接返回null根本不查Redis和数据库。参数校验接口层做好合法性校验非法参数直接拒绝不进入查询链路。这里有个小细节缓存空值时TTL不能太长否则数据库补录了数据后用户要等缓存过期才能看到新数据。缓存击穿指的是某一个热点Key在过期的瞬间大量并发请求同时穿透到数据库。比如一个爆款商品的详情缓存设了10分钟结果刚好在抢购高峰期失效了一瞬间几千个请求一起打到数据库。解决方案主要是两种互斥锁Mutex在缓存失效时只让一个线程去数据库加载数据并回填缓存其他线程等锁或直接返回旧值。用Redis实现时就是SETNX命令。逻辑过期缓存里不设物理过期时间而是存一个逻辑过期时间戳。每次读取时判断是否过期过期后先返回旧数据同时异步去数据库拉新数据回填。这种方式胜在响应快但要接受短时间的数据不一致。缓存雪崩指的是大量Key在同一时间段集中过期导致请求全部落到数据库。比如批量缓存数据时设了相同的基础时间或者Redis实例本身宕机了。解决思路三条腿走路过期时间加随机值比如TTL 基础时间 0~300秒的随机数如Random.Shared.Next(300)避免大量Key同时过期。热点数据永不过期为每个热点Key设置逻辑过期后台定时更新。缓存服务做高可用Redis主从哨兵或者集群模式要么别让Redis挂要么挂了能自动切换。3.2 Cache Aside模式的落地细节先更新DB还是先删缓存读多写少的场景最常用的缓存模式是Cache Aside旁路缓存。核心逻辑不复杂读先读缓存命中直接返回未命中则读数据库然后回填缓存。写更新数据库然后删除缓存或更新缓存。但这里有一个被讨论一万遍的问题更新数据库之后到底是删缓存还是更新缓存我的回答是除非你能保证缓存更新一定成功且顺序严格一致否则一律删缓存。原因很简单——更新缓存存在并发时序问题。两个线程同时更新同一个Key先更新数据库的线程后更新缓存就可能把旧值写进缓存导致缓存和数据库不一致。删缓存则不同删掉之后缓存缺失下次读请求会重新从数据库拉数据天然保证了最终一致性。至于先删缓存还是先更新数据库同样有争议。先删缓存、再更新数据库如果更新失败缓存是空的下次请求会读数据库里的旧值然后回填旧值问题不大。先更新数据库、再删缓存如果删缓存失败缓存里还是旧值数据库已经变了不一致时间会持续到缓存过期。所以很多团队会引入延迟双删策略更新数据库后先删一次缓存等几百毫秒再删一次把并发读请求回填的旧值再清掉。不过我要泼一盆冷水延迟双删也不是银弹。它只能降低不一致的概率不能完全根除。如果你的业务对一致性要求极高比如库存扣减正确做法是不要依赖缓存直接把Redis当分布式锁的工具数据库才是唯一数据源。3.3 缓存键设计与命名空间缓存Key的规范我踩过不少坑之后总结了一句话Key要能一眼看出含义也要能批量管理。推荐格式是业务域:实体名:标识符[:子标识]。比如user:profile:1001order:detail:20250101:10086prod:detail:pid:8989在ASP.NET Core的AddStackExchangeRedisCache里如果配置了InstanceName实际存入Redis的Key会自动加上这个前缀。我建议InstanceName按环境来设置比如MyApp_Prod:和MyApp_Test:这样一套Redis可以隔离多套环境不需要单独部署Redis实例。另一个容易忽略的点是Key的过期时间要分级管理。全局配置类的数据可以缓存放一两小时用户维度的数据放十几分钟订单级别的数据可能只放几秒。不要图省事全部统一成10分钟既浪费内存又可能导致数据不及时。4. 进阶玩法分布式锁、Session共享、限流缓存只是Redis在ASP.NET Core中最基础的用途真正让它值回票价的是那几个分布式能力。这一节大部分内容普通入门教程不会讲全但生产环境迟早要用到。4.1 用Redis实现分布式锁的坑与正确姿势先看一个最常见的错误版本// 错误示例加锁和设置过期时间不是原子操作 if (db.StringSet(lock:order:1001, 1, TimeSpan.FromSeconds(10), When.NotExists)) { try { // 处理订单 } finally { db.KeyDelete(lock:order:1001); } }这段代码有两个问题。第一StringSet带了过期时间这没问题但如果你只是用SETNX加锁然后单独再设置过期时间中间挂了会导致锁永远不会释放。实际上SETNX和EXPIRE分开执行本身就是非原子的必须用一条命令原子地完成加锁设过期时间。StackExchange.Redis的StringSet已经支持了原子操作只要把过期时间和When.NotExists同时传进去Redis内部会通过SET key value NX PX expire命令保证原子性这个没问题。第二个更大的坑是锁的Value没有唯一标识释放锁时可能删掉别人的锁。设想一下线程A拿到锁设置10秒过期但是A处理业务超过了10秒锁自动过期线程B拿到锁开始处理这时A终于干完活了直接KeyDelete把B的锁删了。之后线程C又拿到锁……三个线程同时在执行临界区分布式锁形同虚设。正确做法是通过Lua脚本保证先判断Value是否属于自己再删除是原子的// 释放锁必须比较Value是否是自己加的 var script if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end; db.ScriptEvaluate(script, new RedisKey[] { lockKey }, new RedisValue[] { token });再进一步如果你要做严格的分布式锁推荐直接用现成库RedLock.net它实现了Redlock算法在Redis主从环境中更安全。不过Redlock本身也有争议有些专家认为它在极端网络分区下仍有问题。我的判断是如果你的业务场景允许极小的概率失效比如防重复提交、订单状态流转用简单的SETNX方案就够如果是金融级、绝对不允许两个线程同时执行那应该考虑数据库行锁或引入专门的协调服务。4.2 使用Redis存储分布式Session有人会觉得分布式Session不是被JWT替代了吗其实不然。在ASP.NET Core里Session机制传递的是一个SessionId数据默认存在服务器内存中。一旦多实例部署且没有配置Sticky Session用户的请求被负载均衡转到另一台机器Session就丢了。解决方案无非两种迁移到JWT等无状态认证方式或者把Session存储后移到Redis。前者改造成本大后者就一行配置builder.Services.AddStackExchangeRedisCache(options { options.Configuration localhost:6379; options.InstanceName Session_; }); builder.Services.AddSession(options { options.IdleTimeout TimeSpan.FromMinutes(30); options.Cookie.HttpOnly true; options.Cookie.IsEssential true; });这个方案最大的好处是所有实例共享Session不需要负载均衡层做Sticky Session实例宕机也不会丢用户的登录状态。但要注意几点Session数据默认序列化用的是Microsoft.AspNetCore.DataProtection所以你要确保所有实例使用相同的DataProtection密钥否则一台机器写入的Session另一台机器可能解密失败。配置示例builder.Services.AddDataProtection() .PersistKeysToStackExchangeRedis(connectionMultiplexer, DataProtection-Keys);Session里不要塞大对象。Redis是内存存储把用户购物车、大数据量的列表都塞进Session内存会以肉眼可见的速度涨。Session只放必要的信息比如用户ID、角色列表其余数据走数据库或业务缓存。4.3 用Redis做接口限流限流在高并发场景下是保命手段。ASP.NET Core虽然内置了RateLimiter.NET 7但默认实现是基于进程内存的多实例部署时每个实例独立计数达不到全局限流的效果。用Redis做分布式限流最常见的算法是滑动窗口或令牌桶。滑动窗口用Redis实现其实很简单每个时间窗口存一个计数Key自增后判断是否超过阈值。下面这个是固定窗口计数器public async Taskbool IsAllowed(string key, int limit, TimeSpan window) { var count await _db.StringIncrementAsync(key, 1); if (count 1) { await _db.KeyExpireAsync(key, window); } return count limit; }价格便宜量又足适合大多数场景。Redis单线程执行INCR和EXPIRE天然不会有并发竞争问题。但固定窗口有一个缺陷窗口边界流量可能瞬间双倍比如限制每分钟100次在59秒发了100次下一分钟0秒又发了100次。要严格限制用滑动窗口或令牌桶。滑动窗口可以通过有序集合ZSet记录每个请求的时间戳然后删除窗口外的旧记录返回窗口内数量。功能更准但每个Key要存多个时间戳内存开销大性能和复杂度都上来了。我的建议是先想清楚你的限流精度要求。接口限流通通用固定窗口合理阈值就够了除非你有明确的峰值控制要求才上滑动窗口。5. 线上Redis缓存治理与排障经验最后这部分聊的是为什么我的Redis缓存上线后没有想象中好用以及出了问题怎么排查。这些经验大多来自实际运维很碎但每条都能救你一次。5.1 缓存大对象、热点Key、过期集中怎么处理先说大对象。如果一个Key里存了几百KB甚至几MB的数据每次网络传输开销都很大Redis本身也会因为存储大字符串产生内存碎片。这类大Key一旦并发被读极易触达网卡瓶颈。排查方式是redis-cli --bigkeys它会扫描所有Key并列出最大的Key。热点Key则是另一个极端某个Key在瞬间被海量请求命中Redis是单线程模型处理这个Key的耗时虽然短但量大起来会阻塞其他所有Key的请求。解法无非这几种热Key做本地缓存把Redis的热Key再做一层内存缓存、把Key打散成多个副本比如key:1、key:2或者加随机后缀分散到不同实例。过期集中我在前面说过雪崩这里再具体一点。如果业务初始化时用了统一的过期时间比如全部缓存都设10分钟那每10分钟整点会有一次DB压力尖峰。解决方法是过期时间加随机秒数让过期分布均匀。5.2 可视化工具与日常监控我能理解很多人喜欢用命令行敲redis-cli的感觉但实际排查问题时可视化工具效率高得多。Another Redis Desktop Manager目前用的最多的跨平台GUI工具免费开源支持Key搜索、TTL修改、内存分析、慢日志查询。比老牌的Redis Desktop Manager好用后者现在有收费版本。Redis InsightRedis官方出的可视化管理工具功能更现代支持内存分析、命令监控Monitor模式适合做深度排查。如果是云厂商Redis阿里云、腾讯云直接用云控制台自带的监控看板就行重点看命中率、内存使用率、慢查询、连接数这几个指标。日常监控我重点盯几个指标命中率低于80%说明缓存设计可能有问题很多请求都在穿透。内存使用率接近maxmemory时Redis会按淘汰策略清理Key。默认是noeviction不淘汰写不进去需要主动设成allkeys-lru或volatile-lru。慢查询SLOWLOG GET 20可以列出最近的慢命令如果大量慢查询是KEYS命令引起的务必改成SCAN。顺带说一句生产环境禁用KEYS命令它需要遍历所有Key量一大直接卡死Redis。5.3 缓存一致性和运维层面的坑最后再分享几条我亲手踩过的坑。第一个是缓存配置变更后没有清旧前缀的Key。如果某次升级把缓存Key的格式从user:1001改成了user:info:1001老Key会残留在Redis里一直占用内存直到自然过期。这类问题很难被发现最终表现为内存一直涨翻遍代码也找不到谁在写这些Key。日常可以写个定时任务定期用SCAN扫出无业务引用的Key并清理。第二个是Redis服务端持久化策略与缓存的冲突。Redis默认开启RDB快照如果内存很大BGSAVE时fork子进程会占用大量内存极端情况下可能导致主线程短暂阻塞。如果只是当缓存用且数据丢了也不致命可以关掉持久化save 或者只用AOF且设置appendfsync everysec。缓存系统本身就是允许丢数据的没必要付出持久化带来的性能代价。第三个是使用IDistributedCache时大量调用Get刷新过期时间。微软的实现里RefreshAsync会刷新Key的过期时间。如果你的代码在每次读缓存后都调用Refresh那么所有缓存Key都可能变成永不过期——内存会涨到让你怀疑人生。我的经验是除非业务明确需要ActiveWindow式的滑动过期否则读操作不要带Refresh。写在最后的几点体会这篇虽然标题带高级两个字但说白了所有高级的东西本质上都是基本功在极端条件下的组合。Redis缓存的关键不是背下来几个命令和类库API而是想明白每一条数据适合用什么生命周期、什么一致性级别、什么容错策略。我在实际项目里的体感是先把IDistributedCache用熟练再把IDatabase的原子操作吃透然后顺手做一些监控和治理预案这套体系撑住日均千万级请求完全没问题。最后分享一个务实的小技巧任何缓存上线前先写一个缓存血崩演练——把Redis停掉10分钟看应用会不会报错、会不会拖垮数据库、恢复后能否自动回填。这个演练能暴露出大部分连篇累牍的文档里没写的坑。毕竟在生产环境出问题不可怕可怕的是出问题时你一无所知。
返回列表