1. 当缓存遇上并发:一场数据同步的战争
想象这样一个场景:你的电商平台正在举行秒杀活动,突然发现部分用户看到的库存数量不一致。有的用户下单时显示还剩100件,实际扣减后却出现超卖。这背后往往是由于高并发请求导致缓存更新不同步造成的。在C#开发中,这种"缓存漂移"现象尤为常见。
让我们看个典型错误示例(使用.NET Core MemoryCache):
// 危险!并发不安全的缓存更新示例
public class ProductService
{
private static MemoryCache _cache = new MemoryCache(new MemoryCacheOptions());
private static int _dbStock = 100; // 模拟数据库库存
public int GetStock(string productId)
{
if (!_cache.TryGetValue(productId, out int cachedStock))
{
// 模拟数据库查询耗时
Thread.Sleep(100);
_cache.Set(productId, _dbStock, TimeSpan.FromMinutes(5));
return _dbStock;
}
return cachedStock;
}
public void UpdateStock(string productId, int quantity)
{
// 两个并发的写操作可能互相覆盖
var newStock = _dbStock - quantity;
_dbStock = newStock; // 更新数据库
_cache.Set(productId, newStock, TimeSpan.FromMinutes(5)); // 更新缓存
}
}
当10个并发请求同时调用UpdateStock时,可能出现:
- 线程A读取_dbStock=100
- 线程B读取_dbStock=100
- 线程A计算newStock=90,更新数据库和缓存
- 线程B计算newStock=90,覆盖数据库和缓存 实际应该扣减20,结果只扣减了10,造成数据不一致。
2. 锁的妙用:给缓存操作上把安全锁
2.1 基础版:lock语句实现同步
private static readonly object _lockObj = new object();
public int GetStockWithLock(string productId)
{
lock (_lockObj)
{
if (!_cache.TryGetValue(productId, out int cachedStock))
{
Thread.Sleep(100);
_cache.Set(productId, _dbStock, TimeSpan.FromMinutes(5));
return _dbStock;
}
return cachedStock;
}
}
public void UpdateStockWithLock(string productId, int quantity)
{
lock (_lockObj)
{
var newStock = _dbStock - quantity;
_dbStock = newStock;
_cache.Set(productId, newStock, TimeSpan.FromMinutes(5));
}
}
这种方法虽然解决了并发问题,但存在明显缺陷:
- 锁粒度太粗,影响系统吞吐量
- 无法应对分布式场景
- 容易导致线程饥饿
2.2 进阶版:SemaphoreSlim优化锁粒度
private static readonly SemaphoreSlim _semaphore = new SemaphoreSlim(1, 1);
public async Task<int> GetStockWithSemaphore(string productId)
{
await _semaphore.WaitAsync();
try
{
if (!_cache.TryGetValue(productId, out int cachedStock))
{
await Task.Delay(100); // 模拟异步数据库操作
_cache.Set(productId, _dbStock, TimeSpan.FromMinutes(5));
return _dbStock;
}
return cachedStock;
}
finally
{
_semaphore.Release();
}
}
优点:
- 支持异步操作
- 可设置超时时间
- 更细粒度的控制
3. 无锁编程:CAS模式的应用
Compare-And-Swap模式可以避免显式锁带来的性能损耗:
private static int _stockVersion = 0;
public void UpdateStockWithCAS(string productId, int quantity)
{
int retries = 3;
while (retries-- > 0)
{
var originalStock = Volatile.Read(ref _dbStock);
var originalVersion = Volatile.Read(ref _stockVersion);
var newStock = originalStock - quantity;
var newVersion = originalVersion + 1;
// 原子比较交换
if (Interlocked.CompareExchange(ref _dbStock, newStock, originalStock) == originalStock)
{
Interlocked.Increment(ref _stockVersion);
_cache.Set(productId, newStock, TimeSpan.FromMinutes(5));
break;
}
Thread.Yield(); // 让出CPU时间片
}
}
这种方法的精妙之处在于:
- 使用内存屏障保证可见性
- 通过版本号防止ABA问题
- 有限重试机制避免死循环
4. 分布式环境下的挑战:Redis事务方案
当系统扩展到多节点时,本地锁将失效。这时需要引入分布式锁和原子操作:
// 使用StackExchange.Redis实现
public async Task<bool> UpdateStockWithRedis(string productId, int quantity)
{
var redis = ConnectionMultiplexer.Connect("localhost");
var db = redis.GetDatabase();
var transaction = db.CreateTransaction();
// 开启事务
transaction.AddCondition(Condition.HashEqual(productId, "version", GetCurrentVersion()));
var newStock = (int)await db.HashGetAsync(productId, "stock") - quantity;
transaction.HashSetAsync(productId, "stock", newStock);
transaction.HashIncrementAsync(productId, "version");
return await transaction.ExecuteAsync(); // 返回是否执行成功
}
关键技术点:
- Redis事务的原子性保证
- WATCH/MULTI/EXEC命令组合
- 乐观锁机制
5. 方案选型:不同场景的最佳实践
5.1 单机应用场景
方案 | 适用场景 | QPS上限 | 实现复杂度 |
---|---|---|---|
Lock语句 | 简单业务,并发量低 | 5000 | ★☆☆☆☆ |
SemaphoreSlim | 需要异步支持的中等并发 | 20000 | ★★☆☆☆ |
CAS模式 | 高性能要求的原子操作 | 50000+ | ★★★★☆ |
5.2 分布式场景
flowchart TD
A[开始] --> B{数据一致性要求}
B -->|强一致| C[Redis事务+Redlock]
B -->|最终一致| D[版本号+消息队列]
C --> E[数据库事务]
D --> F[定时校对]
6. 避坑指南:实践中常见陷阱
- 缓存雪崩预防
// 错误示例:同时设置相同过期时间
var options = new MemoryCacheEntryOptions()
.SetAbsoluteExpiration(TimeSpan.FromMinutes(5)); // 所有缓存同时失效
// 正确做法:添加随机偏移
var random = new Random();
var expiration = TimeSpan.FromMinutes(5) + TimeSpan.FromSeconds(random.Next(0, 300));
- 缓存穿透防护
public int? GetStockWithProtection(string productId)
{
if (_cache.TryGetValue(productId, out int cachedStock))
{
return cachedStock;
}
// 使用特殊值标记空结果
if (_cache.TryGetValue($"{productId}_null", out _))
{
return null;
}
// 数据库查询...
}
- 读写分离时的双写问题
// 使用双删策略降低脏数据概率
public void UpdateStockWithDoubleDelete(string productId)
{
_cache.Remove(productId);
UpdateDatabase(productId);
Task.Delay(100).ContinueWith(_ => _cache.Remove(productId));
}
7. 总结:构建稳定缓存系统的四要素
通过多个方案的对比实践,我们可以总结出缓存一致性设计的黄金法则:
- 状态可观测:建立完善的监控指标(缓存命中率、更新失败次数等)
- 操作可追溯:记录关键操作的日志和版本信息
- 失败可恢复:设计自动修复和人工干预的兜底方案
- 变更可控制:采用灰度发布和流量控制机制
最后需要强调的是,没有银弹式的解决方案。在实际项目中,往往需要根据业务特点混合使用多种策略。比如在秒杀系统中,可以采用本地缓存+Redis原子操作+异步校对的多层保障机制。记住:好的缓存设计不是消除不一致,而是将不一致控制在可接受范围内。