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,password=xxx,abortConnect=false,connectRetry=3"; options.InstanceName = "MyApp_"; });如果你需要直接用原生的StackExchange.Redis接口,正确姿势是手动注册单例:
builder.Services.AddSingleton<IConnectionMultiplexer>(sp => { var config = new ConfigurationOptions { EndPoints = { "localhost:6379" }, Password = "xxx", AbortOnConnectFail = false, ConnectRetry = 3, ConnectTimeout = 5000 }; return ConnectionMultiplexer.Connect(config); });这里有两个配置项我需要单独解释一下。
abortConnect=false(对应AbortOnConnectFail=false)非常关键。默认情况下,如果启动时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 穿透、击穿、雪崩:三个词对应三种完全不同的解法
这三个词经常被放到一起讲,但它们的成因和解法其实完全不同,面试可以一起背,实战必须分开处理。
缓存穿透指的是请求的数据在缓存和数据库里都不存在,导致每一次请求都直接打到数据库。比如用户查一个不存在的商品ID,Redis里没有,数据库也没有,请求就直接穿透了缓存层。如果攻击者伪造一批不存在的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 Task<bool> 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 Insight:Redis官方出的可视化管理工具,功能更现代,支持内存分析、命令监控(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分钟,看应用会不会报错、会不会拖垮数据库、恢复后能否自动回填。这个演练能暴露出大部分连篇累牍的文档里没写的坑。毕竟在生产环境,出问题不可怕,可怕的是出问题时你一无所知。