Redis + .NET 实战:缓存、批量、Pipeline 与性能优化(十二)
上一篇已经把 ConnectionMultiplexer、基础读写和最小 ASP.NET Core 接入搭起来了。真正进入线上之后,新的问题很快会出现:缓存代码怎么封装才不至于每个控制器各写一套?同一个请求里要读十几个 Key 时应该怎么组织?Pipeline 到底是自动发生的,还是要手写?热点 Key 和高吞吐场景下,哪些优化真的有用,哪些只是把复杂度提前引进来?
本文只聚焦这些性能与访问模式问题,不回到分布式锁、限流、幂等和生产排障;这些内容留到下一篇统一收束。
系列导航:
- 上一篇:Redis + .NET 实战:StackExchange.Redis 连接、读写与序列化(十一)
- 当前篇:Redis + .NET 实战:缓存、批量、Pipeline 与性能优化(十二)(本文)
- 下一篇:Redis + .NET 实战:分布式锁、限流、幂等与生产排障(十三)
先把缓存访问收口,而不是把 Redis 调用散落全项目
只要项目进入第二阶段,最先需要优化的通常不是某一条命令,而是代码组织。因为如果控制器、应用服务、后台任务都各自直接拼 key、直接反序列化、直接写 TTL,你很快会遇到三类问题:
- 过期时间写法到处不一致。
- 同一个业务对象被多个地方用不同 key 命名。
- 命中、回源、序列化失败、热点治理都没法统一加进去。
因此更实用的起点通常是先做一个缓存门面,把“取缓存、回源、设置 TTL、加随机抖动”收进一层公共逻辑。例如:
using System.Text.Json;
using StackExchange.Redis;
public sealed class RedisCacheFacade
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private readonly IDatabase _database;
public RedisCacheFacade(IConnectionMultiplexer multiplexer)
{
_database = multiplexer.GetDatabase();
}
public async Task<T?> GetOrSetAsync<T>(
string key,
Func<Task<T?>> factory,
TimeSpan ttl)
{
RedisValue cached = await _database.StringGetAsync(key);
if (!cached.IsNullOrEmpty)
{
return JsonSerializer.Deserialize<T>(cached!, JsonOptions);
}
T? value = await factory();
if (value is null)
{
return default;
}
TimeSpan ttlWithJitter = ttl + TimeSpan.FromSeconds(Random.Shared.Next(5, 30));
RedisValue serialized = JsonSerializer.Serialize(value, JsonOptions);
await _database.StringSetAsync(key, serialized, ttlWithJitter);
return value;
}
}
这个封装的价值不在于“少写几行”,而在于后面想继续加这些能力时有统一入口:
- 统计命中率与回源耗时。
- 对特定异常降级或熔断。
- 热点 Key 做单飞保护。
- 针对不同对象统一 TTL 策略。
换句话说,缓存封装真正解决的是“访问策略一致”,不是“把 Redis 包得看不出来”。
批量读写:同一个请求读多个 Key,不要逐个串行等待
ASP.NET Core 接口里很常见的一种浪费是:一次请求明明要拿多个 Redis Key,却写成严格串行:
RedisValue profile = await db.StringGetAsync(profileKey);
RedisValue permissions = await db.StringGetAsync(permissionKey);
RedisValue cart = await db.StringGetAsync(cartKey);
这种写法逻辑上没有错,但把多个原本可以重叠的网络往返硬生生排成了一条直线。更好的起点通常有两种。
第一种是直接使用多键 API:
RedisKey[] keys =
[
"capsulex:user:1001:profile",
"capsulex:user:1001:permissions",
"capsulex:user:1001:cart"
];
RedisValue[] values = await db.StringGetAsync(keys);
批量写入也有对应方法:
KeyValuePair<RedisKey, RedisValue>[] entries =
[
new("capsulex:user:1001:profile", profileJson),
new("capsulex:user:1001:permissions", permissionsJson),
new("capsulex:user:1001:cart", cartJson)
];
await db.StringSetAsync(entries);
第二种是对不方便走多键 API 的场景,至少把异步请求并发发出去,再统一等待:
Task<RedisValue> profileTask = db.StringGetAsync(profileKey);
Task<RedisValue> permissionsTask = db.StringGetAsync(permissionKey);
Task<RedisValue> cartTask = db.StringGetAsync(cartKey);
await Task.WhenAll(profileTask, permissionsTask, cartTask);
RedisValue profile = await profileTask;
RedisValue permissions = await permissionsTask;
RedisValue cart = await cartTask;
这里的核心不是“代码更花哨”,而是减少每个请求的累计等待时间。只要这些 Key 之间没有严格依赖关系,就没必要人为串行。
Pipeline 的正确理解:很多时候它已经在自动发生
StackExchange.Redis 最容易被误解的一个点,就是大家会把 Pipeline 想成“必须显式打开的模式”。实际上,在这个客户端里,只要你把多个异步命令发出去而不是每发一条就立刻等待,底层多路复用与请求排队就已经在帮助你形成 pipeline 效果。
因此,下面两段代码的性能语义并不一样:
RedisValue a = await db.StringGetAsync("k1");
RedisValue b = await db.StringGetAsync("k2");
RedisValue c = await db.StringGetAsync("k3");
Task<RedisValue> aTask = db.StringGetAsync("k1");
Task<RedisValue> bTask = db.StringGetAsync("k2");
Task<RedisValue> cTask = db.StringGetAsync("k3");
await Task.WhenAll(aTask, bTask, cTask);
第二段更接近 StackExchange.Redis 擅长的使用方式:让客户端一次性把多个待发送命令压进多路复用连接,而不是自己在应用层把每次往返都阻塞住。
如果你已经明确知道要组织一批命令,也可以使用 IBatch:
IBatch batch = db.CreateBatch();
Task<RedisValue> orderTask = batch.StringGetAsync("capsulex:order:1001");
Task<RedisValue> inventoryTask = batch.StringGetAsync("capsulex:inventory:sku-1");
Task<bool> markerTask = batch.StringSetAsync("capsulex:last-access", DateTimeOffset.UtcNow.ToString("O"));
batch.Execute();
await Task.WhenAll(orderTask, inventoryTask, markerTask);
但这里一定要记住两个边界:
IBatch不是事务,不保证原子性。IBatch也不是所有场景都比普通异步并发更高级;它只是帮你更明确地组织一批待发命令。
如果业务真正需要“要么都成功,要么都不成功”的语义,应该看事务、Lua 或业务层补偿,而不是把 Batch 当成一致性工具。
热点 Key 优化:先减回源,再谈更复杂的拆分
高并发下的热点 Key,往往不是 Redis 自己先撑不住,而是缓存失效后突然把数据库、下游 API 和应用线程池一起打爆。处理热点 Key 时,一个更稳妥的顺序通常是:
- 先识别热点是否真是单 Key 问题:确认是某个对象极热,而不是整个请求路径都慢。
- 先减少回源并发:通过本地短缓存、单飞、预热或逻辑过期减少同时穿透。
- 再决定是否需要更重的策略:例如只读副本分流、分片、副本缓存或业务层拆解。
在 ASP.NET Core 里,一个常见而实用的补法是“本地内存缓存 + Redis 二级缓存”组合:
- 极高频、短时间稳定的数据先命中本地
IMemoryCache。 - 本地未命中再走 Redis。
- Redis 未命中才回源数据库或下游服务。
如果热点集中在少数键,还可以在应用层加单飞保护,避免同一时刻成百上千个请求一起回源:
private static readonly ConcurrentDictionary<string, SemaphoreSlim> Locks = new();
public async Task<T?> GetHotValueAsync<T>(string key, Func<Task<T?>> factory, TimeSpan ttl)
{
RedisValue cached = await _database.StringGetAsync(key);
if (!cached.IsNullOrEmpty)
{
return JsonSerializer.Deserialize<T>(cached!);
}
SemaphoreSlim gate = Locks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1));
await gate.WaitAsync();
try
{
cached = await _database.StringGetAsync(key);
if (!cached.IsNullOrEmpty)
{
return JsonSerializer.Deserialize<T>(cached!);
}
T? value = await factory();
if (value is null)
{
return default;
}
await _database.StringSetAsync(key, JsonSerializer.Serialize(value), ttl);
return value;
}
finally
{
gate.Release();
}
}
这类做法的重点不是代码模板本身,而是思路:热点治理的第一目标是避免瞬时放大,而不是立刻上来就把一个 Key 人工拆成十个子 Key。
吞吐优化:优先看序列化、等待方式和负载模型
大多数团队提到“Redis 吞吐优化”,第一反应是加机器、扩连接、或者问要不要建连接池。其实在 StackExchange.Redis 场景里,更常见的瓶颈往往先出现在下面几件事:
- 同步阻塞异步:业务线程被
.Result、.Wait()卡住,线程池先抖。 - 值太大:对象过度序列化,一个 Key 几十 KB 甚至几百 KB,网络和 GC 都会吃不消。
- 访问方式串行:明明可以批量或并发发命令,却被业务代码顺序等待。
- 热点过于集中:单个 Key 在到期瞬间出现回源风暴。
- 超时设置和重试叠加:应用层又重试、网关又重试,Redis 一抖时流量反而被放大。
因此更实用的吞吐优化顺序通常是:
- 先把同步调用改成异步链路。
- 先减少单值体积,避免超大 JSON。
- 先把多键请求改成批量或并发发送。
- 再评估热点保护、本地缓存和读写职责拆分。
- 最后才考虑是否需要更复杂的连接隔离和实例扩容。
这套顺序的重要性在于:很多“Redis 顶不住了”的表象,根因其实在应用层等待方式和数据模型,不在实例本身。
“连接池”与“多路复用”的常见误区
到了性能话题,.NET 团队最常见的误判之一就是:既然数据库常用连接池,那 Redis 是不是也该搞一个“连接池服务”?对 StackExchange.Redis 来说,大多数时候答案是否定的,因为 ConnectionMultiplexer 本身就在做你真正需要的事情:复用少量长连接,在其上多路复用大量命令。
几个常见误区需要明确拆开:
- 误区 1:每个请求拿一个独立连接更快。 实际上这通常只会增加握手和资源竞争。
- 误区 2:连接越多吞吐一定越高。 如果瓶颈在序列化、单值大小、热点 Key 或线程池,盲目加连接并不会解决根因。
- 误区 3:建一个“连接池”是最佳实践。
对
StackExchange.Redis而言,最常见的最佳实践恰恰是稳定持有一个或少量ConnectionMultiplexer。
那什么时候可以考虑多个 ConnectionMultiplexer?通常是以下这些“职责隔离”场景,而不是泛化性能迷信:
- 连接不同 Redis 集群或不同租户实例。
- 把普通缓存读写与特定高风险工作负载隔离。
- 把 Pub/Sub、后台任务与在线请求链路分开。
- 单一连接确实出现了可证实的资源上限,并且已经排除了应用层瓶颈。
也就是说,多连接是架构隔离手段,不是默认优化按钮。
本篇小结
如果说上一篇解决的是“怎样把 Redis 正确接入 ASP.NET Core”,那么这一篇解决的是“接入之后怎样把访问模式写对”。真正值得优先建立的心智有四个:
- 缓存访问先收口成统一封装。
- 多键请求优先批量化或并发发送,避免串行等待。
StackExchange.Redis的 pipeline 很多时候已经通过异步并发自动发生。- 不要把连接池当默认答案,先理解
ConnectionMultiplexer的多路复用语义。
本篇解决了什么:
- 说明了缓存封装、批量读写与
Pipeline应该怎样落到 ASP.NET Core 服务里 - 区分了真正能提升吞吐的优化手段与容易误用的连接池神话
- 把热点 Key、批量访问和访问模式治理放回客户端与业务代码层面