如何在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
相关产品推荐
相关产品推荐

