异步/等待和缓存

19

我的服务层将许多数据库请求缓存到memcached中,这会使使用Async/Await变得不可能吗?例如,我该如何等待这个请求?

public virtual Store GetStoreByUsername(string username)
{
        return _cacheManager.Get(string.Format("Cache_Key_{0}", username), () =>
        {
                return _storeRepository.GetSingle(x => x.UserName == username);
        });
}
注意:如果缓存中存在该键,则返回一个“Store”(而不是Task<Store>),如果缓存中不存在该键,则执行lambda表达式。如果我将Func更改为
return await _storeRepository.GetSingleAsync(x => x.UserName == username);

方法签名为:

public virtual async Task<Store> GetStoreByUsername(string username)

显然,这无法工作是因为缓存返回类型不允许。

2个回答

23
这是一种缓存异步操作结果的方法,可以保证没有缓存未命中,且是线程安全的。
在被接受的答案中,如果在循环中或从多个线程请求相同的用户名,则DB请求将不断发送,直到获取到被缓存的响应为止,此时缓存将开始被使用。
下面的方法为每个唯一的键创建一个SemaphoreSlim对象。这将防止长时间运行的async操作针对同一键运行多次,同时允许它在不同的键上同时运行。显然,保留SemaphoreSlim对象以防止缓存未命中会有额外开销,因此它可能根据用例不值得这么做。但如果保证没有缓存未命中很重要,那么这个方法可以实现。
private readonly ConcurrentDictionary<string, SemaphoreSlim> _keyLocks = new ConcurrentDictionary<string, SemaphoreSlim>();
private readonly ConcurrentDictionary<string, Store> _cache = new ConcurrentDictionary<string, Store>();

public async Task<Store> GetStoreByUsernameAsync(string username)
{   
    Store value;
    // get the semaphore specific to this username
    var keyLock = _keyLocks.GetOrAdd(username, x => new SemaphoreSlim(1));
    await keyLock.WaitAsync().ConfigureAwait(false);
    try
    {
        // try to get Store from cache
        if (!_cache.TryGetValue(username, out value))
        {
            // if value isn't cached, get it from the DB asynchronously
            value = await _storeRepository.GetSingleAsync(x => x.UserName == username).ConfigureAwait(false);

            // cache value
            _cache.TryAdd(username, value);
        }
    }
    finally
    {
        keyLock.Release();
    }
    return value;
}

注意:为了进一步优化此方法,在获取锁之前可以进行额外的缓存检查。

有没有特别的原因,你为什么不使用ConcurrentDictionary<string,object>lock(obj){...}代替SemaphoreSlim呢?我进行了一些基准测试,似乎在我的情况下locks更快。 - eddyP23
1
我使用了 SemaphoreSlim,因为它提供了异步的等待方法。而在锁语句的主体中是无法使用 await 的。 - Brandon
但请注意,它会占用内存,因为键和信号量都不会被垃圾回收,_keyLocks将保留引用。 - zihotki
是的,要清除缓存,您需要同时清除“_keyLocks”和“_cache”。 - Brandon

11

看起来缓存管理器(cache-manager)负责执行整个"检查是否存在, 如果不存在则运行lambda函数并存储"的过程。如果是这样,那么使其异步的唯一方法是创建一个GetAsync方法,该方法返回一个Task<Store>而不是一个Store

public virtual Task<Store> GetStoreByUsernameAsync(string username)
{
    return _cacheManager.GetAsync(string.Format("Cache_Key_{0}", username), () =>
    {
        return _storeRepository.GetSingleAsync(x => x.UserName == username);
    });
}
请注意,这不需要标记为async,因为我们没有使用await。缓存管理器将执行以下操作:
public async Task<Store> GetAsync(string key, Func<Task<Store>> func)
{
    var val = cache.Get(key);
    if(val == null)
    {
        val = await func().ConfigureAwait(false);
        cache.Set(key, val);
    }
    return val;
}

吹毛求疵:return val.Result。另外,也许Task.FromResult是多余的:var val = func(); await val.ConfigureAwait(false); cache.Set(key, val); - noseratio - open to work
1
@Noseratio 是的,这是有意为之的修改,并且它可以成功编译。 - Marc Gravell
1
@Ryan 确实你不能;你有一个想要的例子吗? - Marc Gravell
1
值得注意的是,GetAsync中的缓存对象应该是线程安全的。例如,如果在循环中使用相同的用户名调用GetStoreByUsernameAsync,则将值添加到缓存中的继续和调用cache.get()的原始线程可能都在不同的线程上运行。 - Brandon
2
此外,这种方法不能保证对于相同的用户名不会出现缓存未命中。与上面相同的示例-循环使用相同的用户名。在第一个响应被返回并缓存之前,可能会进行多次DB请求,此时缓存将开始被使用。请参阅我的答案,了解可能的解决方案。 - Brandon
显示剩余3条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接