.NET Redis 数据结构、分布式锁、缓存问题

一、Redis 常用 5 个数据结构

  StackExchange.Redis 是 .NET 最主流客户端,所有结构都有对应 API。  

1. String(字符串)

  用途:缓存对象、计数器、分布式锁、简单配置  

// 存对象(序列化)
await db.StringSetAsync("user:1", JsonSerializer.Serialize(user));
// 取对象
var user = JsonSerializer.Deserialize<User>(await db.StringGetAsync("user:1"));
// 计数器
await db.StringIncrementAsync("view:count");

 

2. Hash(哈希)

  用途:存储对象、用户信息、商品详情  

await db.HashSetAsync("product:100", new HashEntry[] {
    new HashEntry("name", "iPhone"),
    new HashEntry("price", "5999")
});
var name = await db.HashGetAsync("product:100", "name");

 

3. List(列表)

  用途:消息队列、最新动态、排行榜列表   命令:LPush、RPop、LRange  

4. Set(集合)

  用途:去重、共同关注、抽奖、标签   命令:SAdd、SRem、SInter(交集)  

5. SortedSet(有序集合)

  用途:排行榜、延时任务、权重排序   命令:ZAdd、ZRangeWithScores  


 

二、.NET Redis 分布式锁

  分布式锁 = 解决多服务 / 多实例同时操作同一资源的问题。  

核心原则

 

  1. 互斥:同一时间只有一个线程拿到锁
  2. 防死锁:必须设置过期时间
  3. 防误删:只能删自己加的锁
  4. 原子性:加锁 / 解锁必须原子操作

 


 

.NET 标准分布式锁代码

 

/// <summary>
/// 获取分布式锁
/// </summary>
/// <param name="lockKey">锁key</param>
/// <param name="requestId">唯一请求ID(防止误删)</param>
/// <param name="expireSeconds">过期时间(防死锁)</param>
public async Task<bool> LockAsync(string lockKey, string requestId, int expireSeconds = 10)
{
    // NX = 不存在才设置 | EX = 秒级过期
    return await _db.StringSetAsync(lockKey, requestId, TimeSpan.FromSeconds(expireSeconds), When.NotExists);
}

/// <summary>
/// 释放分布式锁(Lua 保证原子性)
/// </summary>
public async Task UnlockAsync(string lockKey, string requestId)
{
    string luaScript = @"
        if redis.call('get', KEYS[1]) == ARGV[1] then
            return redis.call('del', KEYS[1])
        else
            return 0
        end";

    await _db.ScriptEvaluateAsync(luaScript, new RedisKey[] { lockKey }, new RedisValue[] { requestId });
}

 

为什么必须用 Lua?

  因为判断锁 + 删除锁必须是原子操作,否则会出现:   线程 A 判断锁是自己 → 锁过期 → 线程 B 加锁 → 线程 A 删除 B 的锁。  


 

三、缓存穿透 / 缓存击穿 / 缓存雪崩(.NET 解决方案)

  这三个是高频面试题 + 高并发必踩坑。  


 

1. 缓存穿透(查不存在的数据)

  现象:查询一条数据库根本没有的数据 → 每次都查库 → 压垮数据库。   解决方案:  

  1. 缓存空值

 

// 查不到数据时缓存空对象,过期时间短一点
await db.StringSetAsync(key, "", TimeSpan.FromMinutes(1));

   

  1. 布隆过滤器(Bloom Filter)   提前把存在的 key 放入布隆过滤器,不存在直接返回。

 


 

2. 缓存击穿(热点 Key 失效)

  现象:一个极高并发的 Key 过期 → 所有请求瞬间打数据库。   解决方案:  

  1. 热点 Key 永不过期
  2. 互斥锁(分布式锁)   只有一个线程去查库,其他线程等待。

  .NET 简单击穿防护逻辑:  

var value = await db.StringGetAsync(key);
if (!value.IsNull) return value;

// 加锁
if (await LockAsync(lockKey, requestId))
{
    try
    {
        // 再次检查缓存(双重检查)
        value = await db.StringGetAsync(key);
        if (!value.IsNull) return value;

        // 查询数据库
        var data = await _repo.GetData(id);
        // 写缓存
        await db.StringSetAsync(key, JsonSerializer.Serialize(data), TimeSpan.FromHours(1));
        return data;
    }
    finally
    {
        await UnlockAsync(lockKey, requestId);
    }
}
else
{
    // 等待重试
    await Task.Delay(100);
    return await GetCacheAsync(id);
}

   


 

3. 缓存雪崩(大量 Key 同时过期)

  现象:大量缓存同一时间集体失效 → 数据库压力暴增。   解决方案:  

  1. 给过期时间加随机值

 

var expire = TimeSpan.FromHours(2) + TimeSpan.FromSeconds(new Random().Next(0, 600));

   

  1. 多级缓存(本地缓存 + Redis)
  2. 服务熔断 / 降级
  3. Redis 集群高可用(主从 + 哨兵)

 


 

四、三者快速区分

 

  • 穿透:查不存在的数据 → 缓存永远不命中
  • 击穿:一个热点 Key 过期 → 并发打库
  • 雪崩:大量 Key 同时过期 → 数据库崩溃

 


 

总结

 

  1. 5 种数据结构:String/Hash/List/Set/SortedSet,.NET 用 StackExchange.Redis 操作。
  2. 分布式锁:必须满足 互斥 + 过期 + 唯一ID + Lua解锁
  3. 缓存三大问题:
    • 穿透 → 缓存空值 / 布隆过滤器
    • 击穿 → 永不过期 / 分布式锁
    • 雪崩 → 随机过期 / 集群 / 多级缓存

文章摘自:https://www.cnblogs.com/chuansheng/p/19916001