Redis + .NET 实战:StackExchange.Redis 连接、读写与序列化(十一)
从这篇开始,redis-from-basics-to-production 系列进入 dotnet-practice 组。目标不再是把 Redis 命令重新讲一遍,而是站在 ASP.NET Core 应用的角度,回答三个真正决定落地质量的问题:客户端该怎么长期持有、业务代码该怎么读写、以及最小可用接入应该长什么样。本文先只处理第一层地基:ConnectionMultiplexer、连接生命周期、基础读写、序列化和最小 ASP.NET Core 接入。
本文刻意不展开批量读写、Pipeline、热点 Key、限流、分布式锁或生产排障;这些内容分别留给后两篇,避免把“先接进来”和“把性能与生产边界讲透”混成同一篇。
系列导航:
- 上一篇:Redis Cluster:哈希槽、重定向与分片扩展(十)
- 当前篇:Redis + .NET 实战:StackExchange.Redis 连接、读写与序列化(十一)(本文)
- 下一篇:Redis + .NET 实战:缓存、批量、Pipeline 与性能优化(十二)
为什么先理解 ConnectionMultiplexer
StackExchange.Redis 最核心的对象不是 IDatabase,而是 ConnectionMultiplexer。如果把 Redis 看成一条远端高速总线,那么 ConnectionMultiplexer 才是那层负责建立 TCP 连接、维护套接字、复用请求、自动重连和路由命令的总入口;IDatabase 只是从这个入口上拿到的逻辑操作句柄。
这层关系决定了一个很关键的使用方式:
ConnectionMultiplexer是昂贵对象,应该长生命周期复用。IDatabase是轻量代理,可以按需获取。- 大多数业务代码不需要自己管理“连接池”;客户端本身已经在做多路复用。
很多第一次接入 Redis 的 ASP.NET Core 项目会写出“每个请求创建一次连接,控制器结束时释放”的代码。这种写法看上去和数据库短连接很像,但放到 StackExchange.Redis 里几乎一定是反模式,因为它会把握手、认证、连接抖动和资源竞争直接放大成吞吐问题。
连接生命周期:单例复用,不按请求创建
在 Web 应用里,最稳妥的起点通常是“进程内一个长寿命 ConnectionMultiplexer 单例”。原因不是教条,而是这几个事实:
- Redis 命令通常非常短,小包、高频、低延迟,请求复用比频繁建连更重要。
ConnectionMultiplexer内部已经维护了连接状态、命令路由与故障恢复逻辑。- ASP.NET Core 的请求并发很多,如果每个请求自己连 Redis,连接数和上下文切换会比业务本身先失控。
一个最小但像样的配置通常会先放进 appsettings.json:
{
"Redis": {
"Configuration": "localhost:6379,abortConnect=false",
"InstanceName": "capsulex:demo:",
"ClientName": "capsulex-orders-api"
}
}
然后在应用启动时只创建一次 ConnectionMultiplexer:
using Microsoft.Extensions.Options;
using StackExchange.Redis;
var builder = WebApplication.CreateBuilder(args);
builder.Services.Configure<RedisOptions>(
builder.Configuration.GetSection("Redis"));
builder.Services.AddSingleton<IConnectionMultiplexer>(sp =>
{
var options = sp.GetRequiredService<IOptions<RedisOptions>>().Value;
var configuration = ConfigurationOptions.Parse(options.Configuration);
configuration.ClientName = options.ClientName;
configuration.AbortOnConnectFail = false;
configuration.ConnectRetry = 3;
configuration.KeepAlive = 30;
return ConnectionMultiplexer.Connect(configuration);
});
builder.Services.AddSingleton<OrderCacheRepository>();
对应的配置对象可以非常简单:
public sealed class RedisOptions
{
public string Configuration { get; set; } = string.Empty;
public string InstanceName { get; set; } = string.Empty;
public string ClientName { get; set; } = string.Empty;
}
这里先建立三个生命周期认知:
ConnectionMultiplexer注册成Singleton。- 业务仓储或缓存服务通常也可以是单例,只要它们不保存请求级状态。
GetDatabase()很轻量,不需要把“拿数据库对象”也设计成连接池。
如果业务确实需要隔离不同 Redis 集群、隔离发布订阅与普通读写,或者需要分别连接主库与只读副本,可以有多个 ConnectionMultiplexer;但那是“按职责拆分连接边界”,不是“为了更快而盲目堆连接”。
IDatabase 是业务读写入口,不是连接本身
一旦拿到 ConnectionMultiplexer,最常用的业务入口就是 IDatabase:
using StackExchange.Redis;
public sealed class OrderCacheRepository
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private readonly IDatabase _database;
private readonly string _instanceName;
public OrderCacheRepository(
IConnectionMultiplexer multiplexer,
IOptions<RedisOptions> options)
{
_database = multiplexer.GetDatabase();
_instanceName = options.Value.InstanceName;
}
private string GetOrderKey(string orderId) => $"{_instanceName}order:{orderId}";
}
要注意两点:
GetDatabase()返回的是逻辑数据库代理,它不等于“新建了一条物理连接”。- 这里把
IDatabase缓存在字段里,是为了让示例更聚焦;按需调用multiplexer.GetDatabase()也完全正常。
换句话说,IDatabase 更像“命令发射器”,而真正承担网络连接、请求复用和命令调度的是 ConnectionMultiplexer。
基础读写:先把最常见的 String 缓存走通
对 ASP.NET Core 项目来说,Redis 最常见的第一步仍然是缓存 JSON 字符串。下面先定义一个业务对象:
public sealed record OrderSnapshot(
string OrderId,
string CustomerId,
decimal Amount,
string Status,
DateTimeOffset UpdatedAt);
然后实现最小的写入与读取:
using System.Text.Json;
using StackExchange.Redis;
public sealed class OrderCacheRepository
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private readonly IDatabase _database;
private readonly string _instanceName;
public OrderCacheRepository(
IConnectionMultiplexer multiplexer,
IOptions<RedisOptions> options)
{
_database = multiplexer.GetDatabase();
_instanceName = options.Value.InstanceName;
}
public async Task SetOrderAsync(OrderSnapshot snapshot, CancellationToken cancellationToken = default)
{
string key = $"{_instanceName}order:{snapshot.OrderId}";
RedisValue value = JsonSerializer.Serialize(snapshot, JsonOptions);
await _database.StringSetAsync(
key,
value,
expiry: TimeSpan.FromMinutes(10));
}
public async Task<OrderSnapshot?> GetOrderAsync(string orderId, CancellationToken cancellationToken = default)
{
string key = $"{_instanceName}order:{orderId}";
RedisValue value = await _database.StringGetAsync(key);
if (value.IsNullOrEmpty)
{
return null;
}
return JsonSerializer.Deserialize<OrderSnapshot>(value!, JsonOptions);
}
}
这个例子只做了最基础的两件事:
StringSetAsync把业务对象作为 JSON 存进 Redis。StringGetAsync取回后再反序列化成业务类型。
在示例代码里,CancellationToken 没有直接传给 StackExchange.Redis API,是因为这里重点是客户端使用方式;如果你所在项目要求全链路取消控制,可以在自己的服务层优先处理中断和超时策略,而不是误以为所有 Redis API 都必须显式携带取消令牌才算“现代化”。
序列化别只看“能不能存”,还要看键和版本
Redis 里最容易被忽略的问题不是“怎么转成 JSON”,而是“以后怎么稳定演进”。只要对象进缓存,序列化就不再只是语法问题,而变成兼容性问题。
落地时建议至少先固定下面四件事:
- 键名前缀:按系统、领域或环境统一前缀,例如
capsulex:demo:order:{id},避免不同服务互相踩键。 - 值格式:同一类缓存统一用一种序列化方式,不要同一个键今天放 JSON、明天放 MessagePack、后天再塞手写字符串。
- TTL 策略:不同数据对象用不同过期时间,不要“一刀切 24 小时”。
- 版本演进:字段可能增删时,尽量保证反序列化向后兼容,或者通过新键前缀切版本。
例如,把“序列化策略”和“键生成策略”收进一个独立服务,比让每个控制器自己拼 key 更稳:
public interface IRedisKeyFactory
{
string Order(string orderId);
}
public sealed class RedisKeyFactory : IRedisKeyFactory
{
private readonly string _instanceName;
public RedisKeyFactory(IOptions<RedisOptions> options)
{
_instanceName = options.Value.InstanceName;
}
public string Order(string orderId) => $"{_instanceName}order:{orderId}";
}
这样后面接入批量读写、热点键治理或多业务前缀时,不会到处散落硬编码字符串。
如果对象字段很多、带宽敏感、吞吐高到 JSON 已经成为瓶颈,可以再评估更紧凑的二进制序列化;但在绝大多数业务的第一阶段,统一性和可维护性通常比极限压缩更重要。
最小 ASP.NET Core 接入:先跑通一个读写接口
把 Redis 真正接进 ASP.NET Core,不需要一上来就抽十层。一个最小可运行例子通常只需要:配置、单例 ConnectionMultiplexer、一个仓储类,以及两个端点。
下面给出一个精简版 Minimal API:
using Microsoft.Extensions.Options;
using StackExchange.Redis;
var builder = WebApplication.CreateBuilder(args);
builder.Services.Configure<RedisOptions>(
builder.Configuration.GetSection("Redis"));
builder.Services.AddSingleton<IConnectionMultiplexer>(sp =>
{
var options = sp.GetRequiredService<IOptions<RedisOptions>>().Value;
var configuration = ConfigurationOptions.Parse(options.Configuration);
configuration.ClientName = options.ClientName;
configuration.AbortOnConnectFail = false;
return ConnectionMultiplexer.Connect(configuration);
});
builder.Services.AddSingleton<OrderCacheRepository>();
var app = builder.Build();
app.MapPost("/orders/{orderId}/cache", async (
string orderId,
OrderCacheRepository repository) =>
{
var snapshot = new OrderSnapshot(
OrderId: orderId,
CustomerId: "C10086",
Amount: 199.00m,
Status: "Paid",
UpdatedAt: DateTimeOffset.UtcNow);
await repository.SetOrderAsync(snapshot);
return Results.Accepted($"/orders/{orderId}/cache");
});
app.MapGet("/orders/{orderId}/cache", async (
string orderId,
OrderCacheRepository repository) =>
{
var snapshot = await repository.GetOrderAsync(orderId);
return snapshot is null ? Results.NotFound() : Results.Ok(snapshot);
});
app.Run();
这段代码体现的不是“已经足够生产”,而是一个正确方向的最小骨架:
- Redis 连接在启动时建立并复用。
- 业务端点不直接依赖连接细节,只依赖仓储接口。
- 缓存读写从第一天开始就带 TTL 和稳定的键命名。
如果你后面要接入健康检查,也建议沿着这个结构继续扩展,例如通过 AddHealthChecks().AddRedis(...) 补可观测性,而不是把探测命令塞进业务控制器里。
刚接入时最容易踩的四个坑
只要项目第一次把 StackExchange.Redis 接进 ASP.NET Core,下面四种误用几乎都会出现至少一种:
- 把
ConnectionMultiplexer注册成Scoped或Transient:这会让连接数量随着请求量放大。 - 在同步接口里调用异步结果:例如
.Result、.Wait(),容易引出线程池拥塞和额外超时。 - 没有键前缀:测试环境、开发环境或多个服务共享实例时,很快就会互相污染。
- 缓存对象没有 TTL:一开始看起来省事,后面常常演变成容量不可控和脏数据难回收。
更稳的原则是:先把生命周期、键规范和序列化策略统一,再去追求更多技巧。因为只要这三个基础做错,后面的 Pipeline、热点键、批量读写和限流封装都会变成在错误地基上叠功能。
本篇小结
在 ASP.NET Core 里接入 Redis,真正的第一步不是命令本身,而是客户端边界:
本篇解决了什么:
- 建立了
StackExchange.Redis在 ASP.NET Core 中的最小接入方式 - 明确了
ConnectionMultiplexer、IDatabase与序列化约定各自的职责 - 给出了一套适合作为业务项目起点的键名、TTL 与读写规范