在C#后端开发中,Redis是最常用的分布式缓存组件,而StackExchange.Redis凭借其异步友好、连接复用和高性能特性,成为.NET生态里的主流客户端。理解它的核心对象模型与常见陷阱,是写出稳定缓存代码的前提。

一、ConnectionMultiplexer的正确用法
StackExchange.Redis最核心的对象是ConnectionMultiplexer,它代表一组到Redis服务器的多路复用连接。很多初学者会在每次请求时new一个,这会带来巨大的连接建立开销并可能耗尽端口。正确做法是进程内单例复用。
以下代码展示如何在ASP.NET Core中通过依赖注入持有单例:
using StackExchange.Redis;
using Microsoft.Extensions.DependencyInjection;
public class RedisConnectionProvider
{
private static readonly Lazy<ConnectionMultiplexer> LazyConnection = new Lazy<ConnectionMultiplexer>(() =>
{
// 使用逗号分隔可配置多个节点实现哨兵或集群
return ConnectionMultiplexer.Connect("127.0.0.1:6379,abortConnect=false");
});
public static ConnectionMultiplexer Instance => LazyConnection.Value;
public static IDatabase GetDatabase()
{
return Instance.GetDatabase();
}
}
通过Lazy延迟初始化,我们在第一次访问时建立连接。abortConnect=false参数保证Redis暂不可用时应用不会崩溃,而是等待重连。这种单例模式在压测中可将每秒请求处理量从单连接模式的约3k提升到20k以上。
需要注意的是,ConnectionMultiplexer本身是线程安全的,所有数据库操作都通过它创建的IDatabase对象进行,不需要为每个线程单独开连接。
二、基础缓存读写与过期策略
最简单的缓存场景是把序列化后的字符串存入Redis,并设置绝对过期时间。下面的例子演示订单对象的缓存写入与读取,并采用随机TTL来避免缓存雪崩。
using StackExchange.Redis;
using System.Text.Json;
public class OrderCacheService
{
private readonly IDatabase _db;
public OrderCacheService()
{
_db = RedisConnectionProvider.GetDatabase();
}
public async Task SetOrderAsync(Order order)
{
string key = $"order:{order.Id}";
string json = JsonSerializer.Serialize(order);
// 基础过期时间30分钟,加随机0-300秒防止集体失效
var expire = TimeSpan.FromSeconds(1800 + new Random().Next(0, 300));
await _db.StringSetAsync(key, json, expire);
}
public async Task<Order> GetOrderAsync(string orderId)
{
string key = $"order:{orderId}";
var json = await _db.StringGetAsync(key);
if (json.IsNullOrEmpty)
{
return null;
}
return JsonSerializer.Deserialize<Order>(json);
}
}
public class Order
{
public string Id { get; set; }
public decimal Amount { get; set; }
}
上述代码使用StringSetAsync写字符串,TimeSpan控制过期。随机TTL是关键防护手段:若所有缓存都在整点过期,瞬间请求会全部穿透到数据库。随机偏移把失效压力摊平到一段时间内。
读取时先查Redis,未命中再查库并回写,这是标准Cache-Aside模式。在真实业务中,回写动作应放在数据库查询成功之后,且要考虑并发回写可用分布式锁控制。
三、用哈希结构存储对象字段
当对象字段需要单独更新时,用整个字符串序列化就不太合适。Redis的Hash结构允许按字段读写。下面示例展示用HashEntry存储订单的部分字段。
public async Task UpdateOrderAmountAsync(string orderId, decimal newAmount)
{
string key = $"order:hash:{orderId}";
// 只更新金额字段,不需整体序列化
await _db.HashSetAsync(key, new HashEntry[]
{
new HashEntry("amount", newAmount.ToString())
});
// 延长哈希过期
await _db.KeyExpireAsync(key, TimeSpan.FromMinutes(30));
}
public async Task<string> GetOrderAmountAsync(string orderId)
{
string key = $"order:hash:{orderId}";
var value = await _db.HashGetAsync(key, "amount");
return value.IsNullOrEmpty ? null : value.ToString();
}
哈希方式在网络传输上更省带宽,尤其大对象局部更新时优势明显。但要注意Redis的Hash在编码上为节省内存会使用ziplist,当字段过多或值过大时会转成hashtable,此时内存占用会上升,需要评估单key字段数。
此外,哈希里的所有值都是RedisValue类型,写入前需转成字符串或数字,读取后再解析,这比JSON反序列化轻量,却也失去了强类型便捷性,应按业务权衡。
四、批量操作与Pipeline优化
在高并发读取多个key时,逐条异步调用会产生多次网络往返。StackExchange.Redis通过Batch或Lua脚本合并命令。以下代码用CreateBatch批量获取多个订单。
public async Task<List<string>> BatchGetOrdersAsync(List<string> orderIds)
{
var db = RedisConnectionProvider.GetDatabase();
var batch = db.CreateBatch();
var tasks = orderIds.Select(id => batch.StringGetAsync($"order:{id}")).ToList();
batch.Execute();
await Task.WhenAll(tasks);
return tasks.Select(t => t.Result.ToString()).ToList();
}
CreateBatch会把多条命令打包发往服务端,减少RTT。在内部实现上,StackExchange.Redis使用了多路复用,所以即便不用batch,单连接也能并发处理,但batch在命令数极多时更省开销。
如果操作需要原子性,比如先读后写,应改用Lua脚本。Lua在Redis服务端原子执行,避免中间状态被其他连接篡改,是分布式锁和限流的实现基础。
五、缓存击穿与分布式锁实战
缓存击穿指某个热点key过期瞬间,大量请求同时穿透到数据库。解决思路是加分布式锁,只放一个请求回源。下面用StringSet的NX参数实现简易锁。
public async Task<Order> GetOrderWithLockAsync(string orderId)
{
var db = RedisConnectionProvider.GetDatabase();
string key = $"order:{orderId}";
var cache = await db.StringGetAsync(key);
if (!cache.IsNullOrEmpty)
{
return JsonSerializer.Deserialize<Order>(cache);
}
string lockKey = $"lock:{orderId}";
// 获取锁,过期10秒防止死锁
bool locked = await db.StringSetAsync(lockKey, "1", TimeSpan.FromSeconds(10), When.NotExists);
if (locked)
{
try
{
// 查数据库逻辑省略,这里模拟
var order = new Order { Id = orderId, Amount = 100 };
await SetOrderAsync(order);
return order;
}
finally
{
await db.KeyDeleteAsync(lockKey);
}
}
else
{
// 未获取锁,短暂等待后重试
await Task.Delay(50);
return await GetOrderWithLockAsync(orderId);
}
}
上述代码用When.NotExists模拟SET NX,只有第一个线程能写入锁。锁带超时是为了避免持有锁的进程崩溃后永久死锁。等待重试机制保证最终能拿到数据。
在集群环境下,简单的单节点锁可能不够安全,可引入RedLock算法或多级缓存。但大多数业务用带超时的单实例锁已能挡住绝大部分击穿流量。
六、总结与最佳实践
StackExchange.Redis的实战要点可归纳为:单例复用ConnectionMultiplexer、合理设置随机TTL防雪崩、用Hash优化局部更新、用Batch或Lua降低RTT、用带超时锁防击穿。把这些模式组合进项目基础库,可让C#服务在Redis加持下轻松应对高并发。
最后提醒,缓存和数据库的一致性永远只能是最终一致。写操作时建议先更新数据库再删缓存,并配合重试补偿,才能把脏数据窗口压到最低。
C#StackExchange_RedisRedis缓存修改时间:2026-08-07 17:18:26