一、当数据库与缓存开始"吵架"

在互联网应用的性能优化路上,SQLServer和Redis这对组合就像老搭档:一个负责持久存储,一个专注高速响应。但在实际工作中,我们总能看到这样的场景:

某电商平台促销期间,运营人员修改了商品库存,结果用户看到的缓存库存半小时没更新。当程序员小王用咖啡灌肠般地调试时,突然发现Redis里的数据还停留在修改前的状态——这种数据库与缓存的"数据打架"现象,就是我们今天要解决的缓存一致性问题。

二、三位缓存更新策略选手入场

2.1 保守派代表:Cache-Aside Pattern

这位选手的原则是"能不动缓存就不动缓存",工作流程如下:

// 使用.NET 6 + Dapper + StackExchange.Redis技术栈示例
public Product GetProduct(int productId)
{
    // 先查缓存
    var cacheKey = $"product:{productId}";
    var product = _redis.StringGet(cacheKey);
    
    if (product.HasValue) 
    {
        return JsonConvert.DeserializeObject<Product>(product);
    }
    
    // 缓存未命中时查数据库
    using var conn = new SqlConnection(_connectionString);
    var dbProduct = conn.QueryFirstOrDefault<Product>(
        "SELECT * FROM Products WHERE Id = @Id", new { Id = productId });
    
    if (dbProduct != null)
    {
        // 异步更新缓存,设置5分钟过期
        _ = _redis.StringSetAsync(cacheKey, 
            JsonConvert.SerializeObject(dbProduct),
            TimeSpan.FromMinutes(5));
    }
    
    return dbProduct;
}

优点:

  • 实现简单,适合大多数读多写少场景
  • 天然防缓存穿透(查不到就不缓存)

缺点:

  • 更新数据库后需要额外操作处理缓存
  • 存在短暂的数据不一致窗口期

2.2 激进派选手:Write-Through

这位实行动不动就刷存在感的策略,全程严格保持同步:

public void UpdateProduct(Product product)
{
    using var transaction = new TransactionScope(
        TransactionScopeAsyncFlowOption.Enabled);
    
    try
    {
        // 先更新数据库
        using var conn = new SqlConnection(_connectionString);
        conn.Execute(
            "UPDATE Products SET Name=@Name, Price=@Price WHERE Id=@Id",
            product);
        
        // 同步更新缓存
        _redis.StringSet($"product:{product.Id}", 
            JsonConvert.SerializeObject(product),
            TimeSpan.FromMinutes(5));
        
        transaction.Complete();
    }
    catch
    {
        // 记录错误日志
        _logger.LogError("Update failed");
        throw;
    }
}

优点:

  • 强一致性保障
  • 天然防止并发更新问题

缺点:

  • 每次写操作都会触发缓存更新
  • 同步操作影响系统吞吐量

2.3 拖延症晚期:Write-Behind

这位的策略是"能拖就拖",但拖得有技巧:

// 使用后台任务处理缓存更新
public async Task UpdateProductAsync(Product product)
{
    // 先更新数据库
    using var conn = new SqlConnection(_connectionString);
    await conn.ExecuteAsync(
        "UPDATE Products SET Name=@Name, Price=@Price WHERE Id=@Id",
        product);
    
    // 将缓存更新任务放入队列
    _backgroundQueue.QueueBackgroundWorkItem(async token =>
    {
        await _redis.StringSetAsync($"product:{product.Id}", 
            JsonConvert.SerializeObject(product),
            TimeSpan.FromMinutes(5));
    });
}

优点:

  • 写操作性能极致优化
  • 适合高并发写入场景

缺点:

  • 需要额外的消息队列组件
  • 数据不一致窗口期可能较长

三、缓存失效的策略

3.1 时间驱逐策略

// 设置固定过期时间
_redis.KeyExpire("product:123", TimeSpan.FromMinutes(30));

// 滑动过期时间(每次访问续期)
public Product GetProductWithSliding(int productId)
{
    var product = GetProduct(productId);
    if (product != null)
    {
        _redis.KeyExpire($"product:{productId}", 
            TimeSpan.FromMinutes(30));
    }
    return product;
}

3.2 内存驱逐策略

在Redis配置文件中设置:

maxmemory 2gb
maxmemory-policy allkeys-lru

这种组合拳能确保内存不足时自动淘汰最近最少使用的键。

四、混合战术:消息队列补刀术

当遇到突发的缓存雪崩时,可以采用组合策略:

// 使用RabbitMQ处理缓存更新
public void SubscribeDatabaseChanges()
{
    using var channel = _rabbitMq.GetChannel();
    var consumer = new EventingBasicConsumer(channel);
    
    consumer.Received += (model, ea) =>
    {
        var message = Encoding.UTF8.GetString(ea.Body.ToArray());
        var product = JsonConvert.DeserializeObject<Product>(message);
        
        // 延时双删策略
        _redis.KeyDelete($"product:{product.Id}");
        Thread.Sleep(100); // 等待主从同步
        _redis.KeyDelete($"product:{product.Id}");
    };
    
    channel.BasicConsume(queue: "product_updates",
                         autoAck: true,
                         consumer: consumer);
}

五、选择恐惧症的用药指南

场景 推荐策略 注意事项
商品详情页 Cache-Aside 配合布隆过滤器防穿透
库存秒杀系统 Write-Through 需要熔断机制保护数据库
用户行为日志 Write-Behind 必须保证消息队列可靠性
金融交易系统 双写+事务消息 需要严格的事务一致性保障

六、那些年我们踩过的坑

  1. 缓存击穿连环案:某次大促时热key突然失效,导致数据库被打垮。解决方案:互斥锁重建缓存。
public Product GetProductWithLock(int productId)
{
    var lockKey = $"lock:{productId}";
    if (!_redis.LockTake(lockKey, "token", TimeSpan.FromSeconds(10)))
    {
        Thread.Sleep(50);
        return GetProductWithLock(productId);
    }
    
    try
    {
        // 临界区代码
        return GetProduct(productId);
    }
    finally
    {
        _redis.LockRelease(lockKey, "token");
    }
}
  1. 双写顺序颠倒事件:某次先删缓存后更新数据库,导致读取到旧数据。解决方案:采用延时双删策略。

七、武林秘籍小结

经过多轮实践验证,缓存一致性保障本质上是在做选择题:

  • 要实时性还是要性能?
  • 要强一致还是最终一致?
  • 要开发简单还是要系统可靠?

在SQLServer和Redis这对组合中,推荐采用分级策略:关键业务用Write-Through+事务消息,普通业务用Cache-Aside+延时双删,日志类数据用Write-Behind+队列补偿。记住,没有银弹方案,只有最适合场景的排列组合。