一、当数据库与缓存开始"吵架"
在互联网应用的性能优化路上,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 | 必须保证消息队列可靠性 |
| 金融交易系统 | 双写+事务消息 | 需要严格的事务一致性保障 |
六、那些年我们踩过的坑
- 缓存击穿连环案:某次大促时热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");
}
}
- 双写顺序颠倒事件:某次先删缓存后更新数据库,导致读取到旧数据。解决方案:采用延时双删策略。
七、武林秘籍小结
经过多轮实践验证,缓存一致性保障本质上是在做选择题:
- 要实时性还是要性能?
- 要强一致还是最终一致?
- 要开发简单还是要系统可靠?
在SQLServer和Redis这对组合中,推荐采用分级策略:关键业务用Write-Through+事务消息,普通业务用Cache-Aside+延时双删,日志类数据用Write-Behind+队列补偿。记住,没有银弹方案,只有最适合场景的排列组合。
评论