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时,可能出现:

  1. 线程A读取_dbStock=100
  2. 线程B读取_dbStock=100
  3. 线程A计算newStock=90,更新数据库和缓存
  4. 线程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时间片
    }
}

这种方法的精妙之处在于:

  1. 使用内存屏障保证可见性
  2. 通过版本号防止ABA问题
  3. 有限重试机制避免死循环

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(); // 返回是否执行成功
}

关键技术点:

  1. Redis事务的原子性保证
  2. WATCH/MULTI/EXEC命令组合
  3. 乐观锁机制

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. 避坑指南:实践中常见陷阱

  1. 缓存雪崩预防
// 错误示例:同时设置相同过期时间
var options = new MemoryCacheEntryOptions()
    .SetAbsoluteExpiration(TimeSpan.FromMinutes(5)); // 所有缓存同时失效

// 正确做法:添加随机偏移
var random = new Random();
var expiration = TimeSpan.FromMinutes(5) + TimeSpan.FromSeconds(random.Next(0, 300));
  1. 缓存穿透防护
public int? GetStockWithProtection(string productId)
{
    if (_cache.TryGetValue(productId, out int cachedStock))
    {
        return cachedStock;
    }
    
    // 使用特殊值标记空结果
    if (_cache.TryGetValue($"{productId}_null", out _))
    {
        return null;
    }
    
    // 数据库查询...
}
  1. 读写分离时的双写问题
// 使用双删策略降低脏数据概率
public void UpdateStockWithDoubleDelete(string productId)
{
    _cache.Remove(productId);
    UpdateDatabase(productId);
    Task.Delay(100).ContinueWith(_ => _cache.Remove(productId));
}

7. 总结:构建稳定缓存系统的四要素

通过多个方案的对比实践,我们可以总结出缓存一致性设计的黄金法则:

  1. 状态可观测:建立完善的监控指标(缓存命中率、更新失败次数等)
  2. 操作可追溯:记录关键操作的日志和版本信息
  3. 失败可恢复:设计自动修复和人工干预的兜底方案
  4. 变更可控制:采用灰度发布和流量控制机制

最后需要强调的是,没有银弹式的解决方案。在实际项目中,往往需要根据业务特点混合使用多种策略。比如在秒杀系统中,可以采用本地缓存+Redis原子操作+异步校对的多层保障机制。记住:好的缓存设计不是消除不一致,而是将不一致控制在可接受范围内。