You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何在C#中用IMemoryCache实现多键实体的高效缓存?

解决多唯一键实体的缓存一致性问题

这确实是多维度查询缓存里的典型坑——删了Id对应的缓存,Name的缓存还躺在那里,导致脏数据。咱们不用把同一个实体缓存两次,有几个更优雅的方案,看你场景选:

方案1:单实体缓存+键映射表(内存友好)

核心思路是只缓存实体一次,用Id作为主缓存键,同时维护一个「Name→Id」的映射缓存。这样不管是按Id还是Name查,最终都指向同一个实体缓存,失效时只要清理主缓存和映射就行。

代码示例:

public class EntityRepository
{
    private readonly IMemoryCache _cache;
    private readonly IDbContext _db;
    private readonly TimeSpan _cacheExpiration = TimeSpan.FromHours(1);

    public EntityRepository(IMemoryCache cache, IDbContext db)
    {
        _cache = cache;
        _db = db;
    }

    public Entity GetById(int id)
    {
        // 直接从主缓存取实体
        return _cache.GetOrCreate($"entity:{id}", entry =>
        {
            entry.SetSlidingExpiration(_cacheExpiration);
            return _db.GetById(id);
        });
    }

    public Entity GetByName(string name)
    {
        // 先查Name到Id的映射缓存
        var entityId = _cache.GetOrCreate($"name-to-id:{name}", entry =>
        {
            entry.SetSlidingExpiration(_cacheExpiration);
            var entity = _db.GetByName(name);
            return entity?.Id;
        });

        if (entityId == null) return null;

        // 复用GetById的缓存逻辑,避免重复缓存实体
        return GetById(entityId.Value);
    }

    public void RemoveById(int id)
    {
        var entity = _db.GetById(id);
        if (entity == null) return;

        _db.RemoveById(id);
        
        // 清理主缓存和对应的Name映射
        _cache.Remove($"entity:{id}");
        _cache.Remove($"name-to-id:{entity.Name}");
    }

    public void Update(Entity updatedEntity)
    {
        var oldEntity = _db.GetById(updatedEntity.Id);
        if (oldEntity == null) return;

        _db.Update(updatedEntity);
        
        // 如果Name变了,清理旧的Name映射
        if (oldEntity.Name != updatedEntity.Name)
        {
            _cache.Remove($"name-to-id:{oldEntity.Name}");
        }
        
        // 更新主缓存和新的Name映射
        _cache.Set($"entity:{updatedEntity.Id}", updatedEntity, _cacheExpiration);
        _cache.Set($"name-to-id:{updatedEntity.Name}", updatedEntity.Id, _cacheExpiration);
    }
}

优点:实体只存一份,节省内存;失效逻辑清晰,能保证缓存一致性;
缺点:多了一层映射查询,但开销可以忽略不计。

方案2:缓存标签批量失效(扩展性好)

如果以后可能加更多查询维度(比如按Code查),用缓存标签会更灵活。给每个实体的所有查询缓存条目打同一个标签,删除/更新时通过标签批量清理所有关联的缓存。

首先需要给IMemoryCache加一个标签扩展(因为原生不支持):

public static class MemoryCacheTagExtensions
{
    // 维护标签到缓存键的映射
    private static readonly ConcurrentDictionary<string, HashSet<string>> _tagRegistry = new();

    public static ICacheEntry AddTag(this ICacheEntry entry, string tag)
    {
        var cacheKey = entry.Key.ToString();
        // 把当前缓存键关联到标签
        _tagRegistry.AddOrUpdate(tag, 
            _ => new HashSet<string> { cacheKey },
            (_, keys) => { keys.Add(cacheKey); return keys; });

        // 缓存过期时自动从标签映射中移除
        entry.RegisterPostEvictionCallback((key, _, reason, _) =>
        {
            var keyStr = key.ToString();
            foreach (var tagEntry in _tagRegistry.Where(t => t.Value.Contains(keyStr)))
            {
                tagEntry.Value.Remove(keyStr);
                if (tagEntry.Value.Count == 0)
                {
                    _tagRegistry.TryRemove(tagEntry.Key, out _);
                }
            }
        });

        return entry;
    }

    public static void RemoveByTag(this IMemoryCache cache, string tag)
    {
        if (_tagRegistry.TryRemove(tag, out var cacheKeys))
        {
            foreach (var key in cacheKeys)
            {
                cache.Remove(key);
            }
        }
    }
}

然后改造仓储类:

public class EntityRepository
{
    private readonly IMemoryCache _cache;
    private readonly IDbContext _db;
    private readonly TimeSpan _cacheExpiration = TimeSpan.FromHours(1);

    public EntityRepository(IMemoryCache cache, IDbContext db)
    {
        _cache = cache;
        _db = db;
    }

    public Entity GetById(int id)
    {
        return _cache.GetOrCreate($"entity-by-id:{id}", entry =>
        {
            var entity = _db.GetById(id);
            if (entity != null)
            {
                // 给这个缓存条目打实体唯一标签
                entry.AddTag($"entity:{entity.Id}");
            }
            entry.SetSlidingExpiration(_cacheExpiration);
            return entity;
        });
    }

    public Entity GetByName(string name)
    {
        return _cache.GetOrCreate($"entity-by-name:{name}", entry =>
        {
            var entity = _db.GetByName(name);
            if (entity != null)
            {
                // 同样打同一个实体的标签
                entry.AddTag($"entity:{entity.Id}");
            }
            entry.SetSlidingExpiration(_cacheExpiration);
            return entity;
        });
    }

    public void RemoveById(int id)
    {
        _db.RemoveById(id);
        // 批量移除所有关联该实体的缓存条目(不管是Id还是Name查询的)
        _cache.RemoveByTag($"entity:{id}");
    }

    public void Update(Entity updatedEntity)
    {
        var oldEntity = _db.GetById(updatedEntity.Id);
        if (oldEntity == null) return;

        _db.Update(updatedEntity);
        
        // 先清理旧的所有缓存条目
        _cache.RemoveByTag($"entity:{updatedEntity.Id}");
        
        // 重新缓存新的实体(可选,也可以等下次查询自动缓存)
        _cache.Set($"entity-by-id:{updatedEntity.Id}", updatedEntity, _cacheExpiration);
        _cache.Set($"entity-by-name:{updatedEntity.Name}", updatedEntity, _cacheExpiration);
    }
}

优点:查询逻辑直观,新增查询维度时只要给新的缓存条目加同一个标签就行;
缺点:需要维护标签映射,增加了一点复杂度,但对于多维度查询的场景非常划算。

怎么选?

  • 如果你的系统内存紧张,或者实体体积较大,优先选方案1,能省内存;
  • 如果以后可能扩展更多查询维度(比如按创建时间、状态查),选方案2,扩展性更强;
  • 别选“缓存两次然后手动清理所有键”的方案,太容易漏,维护成本高。

内容的提问来源于stack exchange,提问作者Gur Galler

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.05.14 08:56:02