使用缓存对象和集合以获得更高性能和可伸缩性的简单方法






4.86/5 (26投票s)
使用 AspectF 库在您的项目中实现缓存,
引言
缓存频繁使用的数据可以大大提高应用程序的可扩展性,因为您可以避免在数据库、文件系统或 Web 服务上进行重复查询。当对象被缓存时,它们可以从缓存中检索,这比从数据库、文件或 Web 服务加载要快得多,也更具可扩展性。然而,当您需要为许多类实现缓存时,实现缓存会很棘手且单调。您的数据访问层会获得大量代码,用于处理缓存对象和集合、在对象更改或删除时更新缓存、在包含的对象更改或删除时使集合失效等。您编写的代码越多,添加的维护开销就越大。在这里,我将向您展示如何使用 LINQ to SQL 和我的库 AspectF 来使缓存变得容易得多。这是一个库,可以帮助您从一个中等规模的项目中摆脱成千上万行重复的代码,并完全消除基础结构(日志记录、错误处理、重试等)类型的代码。
这里有一个关于缓存如何显著提高应用程序性能和可扩展性的示例。Dropthings – 我的开源 Web 2.0 AJAX 门户,在没有缓存的情况下,在双核 64 位 PC 上,面对 10 个并发用户,每秒只能处理大约 11 个请求。这里的数据是从数据库以及外部来源加载的。平均页面响应时间为 1.44 秒。
实现缓存后,速度显著提高,约为 **32 个请求/秒**。页面加载时间也显着减少至 **0.41 秒**。在负载测试期间,CPU 利用率约为 60%。
这清楚地表明它对您的应用程序可以产生的显著差异。如果您正遭受页面加载性能不佳以及数据库和应用程序服务器的高 CPU 或磁盘活动,那么缓存应用程序中最常使用的 Top 5 个对象将立即解决该问题。这是使您的应用程序运行速度大大提高的“快速胜利”,而无需对应用程序进行复杂的重新设计。
缓存对象和集合的常用方法
有时缓存可以很简单,例如缓存一个不属于集合且没有单独缓存的子集合的单个对象。在这种情况下,您会编写简单的代码,如下所示:
- 正在请求的对象是否已在缓存中?
- 是,则从缓存提供。
- 否,则从数据库加载,然后缓存。
另一方面,当您处理缓存的集合时,集合中的每个项也都单独缓存,那么缓存逻辑就没那么简单了。例如,假设您缓存了一个 User
集合。但每个 User
对象也都单独缓存,因为您需要频繁加载单个 User
对象。那么缓存逻辑会更复杂:
- 正在请求的集合是否已在缓存中?
- 是。获取集合。对于集合中的每个对象
- 该对象是否单独在缓存中可用?
- 是,从缓存获取单个对象。在集合中更新它。
- 否,从缓存中丢弃整个集合。转到下一步
- 该对象是否单独在缓存中可用?
- 否。从源(例如数据库)加载集合,然后单独缓存集合中的每个项。然后缓存集合。
- 是。获取集合。对于集合中的每个对象
您可能在想,为什么我们需要从缓存中读取每个单独的项,以及为什么在整个集合已缓存的情况下,我们还需要单独缓存集合中的每个项?当您缓存一个集合并且该集合中的单独项也单独缓存时,您需要处理两种情况:
- 单个项已被更新,并且更新后的项在缓存中。但是,包含所有这些单个项的集合尚未刷新。因此,如果您从缓存中获取集合并按原样返回,您将获得集合中过时的单个项。这就是为什么每个项都需要单独从缓存中检索的原因。
- 集合中的某个项可能已被强制从缓存中过期。例如,对象已更改或对象已被删除。因此,您将其从缓存中过期,以便下次检索时从数据库获取。如果您仅从缓存加载集合,那么集合将包含过时的对象。
如果您以传统方式进行,您将在数据访问层中编写大量重复代码。例如,假设您正在加载属于用户的 Page
集合。如果您想缓存用户的 Page
集合,以及单独缓存 Page
对象,以便每个 Page
可以直接从缓存中检索,那么您需要编写如下代码:
public List<Page> GetPagesOfUserOldSchool(Guid userGuid)
{
ICache cache = Services.Get<ICache>();
bool isCacheStale = false;
string cacheKey = CacheSetup.CacheKeys.PagesOfUser(userGuid);
var cachedPages = cache.Get(cacheKey) as List<Page>;
if (cachedPages != null)
{
var resultantPages = new List<Page>();
// If each item in the collection is no longer in cache, invalidate the collection
// and load again.
foreach (Page cachedPage in cachedPages)
{
var individualPageInCache = cache.Get
(CacheSetup.CacheKeys.PageId(cachedPage.ID)) as Page;
if (null == individualPageInCache)
{
// Some item is missing in cache. So, the collection is stale.
isCacheStale = true;
}
else
{
resultantPages.Add(individualPageInCache);
}
}
cachedPages = resultantPages;
}
if (isCacheStale)
{
// Collection not cached. Need to load collection from database and then cache it.
var pagesOfUser = _database.GetList<Page, Guid>(...);
pagesOfUser.Each(page =>
{
page.Detach();
cache.Add(CacheSetup.CacheKeys.PageId(page.ID), page);
});
cache.Add(cacheKey, pagesOfUser);
return pagesOfUser;
}
else
{
return cachedPages;
}
}
想象一下为每个要缓存的实体一遍又一遍地编写这样的代码。随着项目的增长,这会成为一个维护噩梦。
这是您可以使用 AspectF 完成的方法:
public List<Page> GetPagesOfUser(Guid userGuid)
{
return AspectF.Define
.CacheList<Page, List<Page>>(Services.Get<ICache>(),
CacheSetup.CacheKeys.PagesOfUser(userGuid),
page => CacheSetup.CacheKeys.PageId(page.ID))
.Return<List<Page>>(() =>
_database.GetList<Page, Guid>(...).Select(p => p.Detach()).ToList());
}
而不是 42 行代码,您只需 5 行代码即可完成!
工作原理如下:CacheList
函数接受一个 ICache
接口实现,该接口处理缓存的底层实现,接受一个用于在缓存中存储项目的集合的键,以及一个 delegate
,您可以通过该 delegate
为集合中的每个项返回缓存键。它的作用是:
- 检查集合是否存在于缓存中。
- 如果存在,则
- 对于集合中的每个项,通过调用
delegate
获取该项的键。 - 检查该项是否存在于缓存中。
- 如果存在,则保留它。
- 如果不存在,则集合已过时。需要刷新。
- 对于集合中的每个项,通过调用
- 如果不存在,则
- 执行
Return
函数参数中的代码。该代码将从源(数据库、文件、Web 服务等)返回集合。 - 将集合中的每个项单独存储在缓存中。
- 缓存集合。
- 执行
与 CacheList
类似,还有 Cache
,它使用前面解释的简单逻辑将单个项缓存到缓存中。例如,如果要缓存单个 Page
,则执行此操作:
public Page GetPageById(int pageId)
{
return AspectF.Define.Cache<Page>(Services.Get<ICache>(),
CacheSetup.CacheKeys.PageId(pageId))
.Return<Page>(() =>
_database.GetSingle<Page, int>(...);
}
如果此处指定的键在缓存中不存在,它将调用 Return
函数参数中指定的代码。然后它将缓存您的代码返回的任何内容。此方法后续的任何调用都将从缓存(如果存在)中返回项,而不会执行 Return
参数中的代码。
ICache
接口定义如下:
public interface ICache
{
void Add(string key, object value);
void Add(string key, object value, TimeSpan timeout);
bool Contains(string key);
void Flush();
object Get(string key);
void Remove(string key);
void Set(string key, object value);
void Set(string key, object value, TimeSpan timeout);
}
您需要根据您想要使用的任何缓存机制来实现此接口。例如,我通过这种方式将其用于 Enterprise Library Caching Application block:
class EntlibCacheResolver : ICache
{
private readonly static ICacheManager _CacheManager =
CacheFactory.GetCacheManager("DropthingsCache");
#region ICache Members
public object Get(string key)
{
return _CacheManager.GetData(key);
}
public void Put(string key, object item)
{
_CacheManager.Add(key, item);
}
public void Add(string key, object value, TimeSpan timeout)
{
_CacheManager.Add(key, value, CacheItemPriority.Normal, null,
new AbsoluteTime(DateTime.Now.Add(timeout)));
}
public void Add(string key, object value)
{
_CacheManager.Add(key, value);
}
public bool Contains(string key)
{
return _CacheManager.Contains(key);
}
public void Flush()
{
_CacheManager.Flush();
}
public void Remove(string key)
{
_CacheManager.Remove(key);
}
public void Set(string key, object value, TimeSpan timeout)
{
if (_CacheManager.Contains(key))
_CacheManager.Remove(key);
this.Add(key, value, timeout);
}
public void Set(string key, object value)
{
if (_CacheManager.Contains(key))
_CacheManager.Remove(key);
this.Add(key, value);
}
#endregion
}
此类使用 Enterprise Library Caching Application block。
缓存 LINQ to SQL 对象
缓存 LINQ to SQL 需要一些基础结构。首先,您必须使 DataContext
支持 SerializationMode
= Unidirectional
,以便 LINQ to SQL 生成的 Entity
类具有 [Serializable]
属性。您可以通过在 Visual Studio 设计器中打开数据上下文,然后转到数据上下文的属性(而不是任何实体)并更改 SerializationMode
来实现此目的。
其次,您必须实现一个 Detach
方法。您需要为同一个 LINQ to SQL 实体声明一个部分类,并创建一个 Detach
方法,该方法初始化所有引用的对象和集合,并清除事件侦听器,以便关闭更改跟踪。例如,我有一个 Data Context,其中 aspnet_User
引用了几个其他类。因此,我必须全部清除它们。
public partial class aspnet_User
{
#region Methods
public void Detach()
{
this.PropertyChanged = null;
this.PropertyChanging = null;
this._Pages = new EntitySet<Page>(new Action<Page>(this.attach_Pages),
new Action<Page>(this.detach_Pages));
this._UserSetting = default(EntityRef<UserSetting>);
this._Tokens = new EntitySet<Token>(new Action<Token>(this.attach_Tokens),
new Action<Token>(this.detach_Tokens));
this._aspnet_UsersInRoles = new EntitySet<aspnet_UsersInRole>(
new Action<aspnet_UsersInRole>(this.attach_aspnet_UsersInRoles),
new Action<aspnet_UsersInRole>(this.detach_aspnet_UsersInRoles));
this._RoleTemplates = new EntitySet<RoleTemplate>(
new Action<RoleTemplate>(this.attach_RoleTemplates),
new Action<RoleTemplate>(this.detach_RoleTemplates));
}
#endregion Methods
}
在这里,您清除事件侦听器,因为它们被 Object Change Tracker 使用。当 aspnet_User
对象被序列化以进行缓存时,它将不引用 Object change tracker。接下来,您必须清除对其他实体的所有引用,因为这些引用是不可序列化的。
最后,您需要关闭 DataContext
的延迟加载功能,该功能允许您在访问它们时惰性加载引用的对象和集合。当对象从缓存中加载并且与任何 DataContext
都没有关联时,这将不起作用。为了禁用延迟加载,您需要创建一个禁用此选项的 DataContext
。
var context = new DropthingsDataContext(GetConnectionString());
context.DeferredLoadingEnabled = false;
现在您已经清除了所有引用的实体,当您将对象存储在缓存中时,引用的实体不会被存储在缓存中。因此,当您从缓存中检索时,您将不得不单独加载这些引用的实体。结果是,当您从缓存中获取 aspnet_User
时,不要期望 aspnet_UsersInRoles
集合已经填充或使用 LINQ to SQL 延迟加载功能按需加载。
处理更新和删除场景
缓存对象和集合需要巧妙的设计规划。当您缓存一个对象时,您需要确保在对象更改时更新或过期它,以便对同一对象的未来请求能够获得最新对象。因此,每当您对某个实体调用更新或删除时,您都需要处理缓存。
public void Update(UserSetting userSetting)
{
_database.UpdateObject<UserSetting>(...);
RemoveUserSettingCacheForUser(userSetting);
}
private void RemoveUserSettingCacheForUser(UserSetting userSetting)
{
_cacheResolver.Remove(CacheSetup.CacheKeys.UserSettingByUserGuid(userSetting.UserId));
}
在这里您可以看到,当您在数据库中更新对象时,您会从缓存中删除该项,以便下一个 Get 调用从数据库加载新对象。
使缓存中的依赖对象和集合失效
如果您有也属于缓存集合的对象,例如,一个 Page
集合,其中集合被缓存,单个 Page
对象也单独缓存,您需要处理从包含 Page
对象的缓存中过期/删除集合。例如,假设您缓存了一个用户的 Page
集合。现在用户添加了一个新的 Page
对象。缓存的集合尚不包含它。在这种情况下,如果您获取用户的缓存 Page
集合,您将无法获得用户刚刚添加的新 Page
。同样,如果用户删除一个页面,缓存的集合将包含不再存在的 Page
。因此,您需要使缓存中的集合失效。
public void Delete(Page page)
{
RemovePageIdDependentItems(page.ID);
_database.Delete<Page>(DropthingsDataContext.SubsystemEnum.Page, page);
}
public Page Insert(Action<Page> populate)
{
var newPage = _database.Insert<Page>
(DropthingsDataContext.SubsystemEnum.Page, populate);
RemoveUserPagesCollection(newPage.ID);
return newPage.Detach();
}
在这里您可以看到,当插入新页面或删除页面时,任何引用该页面的缓存条目都会从缓存中删除。
处理具有多个键缓存的对象
频繁使用的对象有时会使用不同的键进行多次缓存。例如,一个 User
对象使用 UserGuid
和 UserName
进行缓存,因为您需要频繁地通过 UserName
和 UserGuid
获取用户。那么挑战就是在 User
发生变化时使所有此类缓存的对象失效。
这是我的做法:
public void RemoveUserFromCache(aspnet_User user)
{
CacheSetup.CacheKeys.UserCacheKeys(user).Each(cacheKey =>
Services.Get<ICache>().Remove(cacheKey));
}
在这里,UserCacheKeys
方法返回用户的所有可能缓存键。每个键都会从缓存中删除。因此,一个对象的所有实例都会从缓存中删除。
public static string UserFromUserName(string userName)
{
return "UserFromUserName." + userName;
}
public static string UserFromUserGuid(Guid userGuid)
{
return "UserFromUserGuid." + userGuid.ToString();
}
public static string[] UserCacheKeys(aspnet_User user)
{
return new string[] {
UserFromUserName(user.UserName),
UserFromUserGuid(user.UserId)
};
}
我总是使用函数来生成缓存键,并将这些函数声明在处理所有缓存对象键的类中。
缓存数据集时避免数据库查询优化
一般来说,您总是会尝试优化数据库查询,以便只加载绝对必要的内容。例如,有时您会加载用户的页面。有时您需要加载带有特定过滤条件的用户的页面。因此,您创建两个不同的查询——一个加载用户的所有页面,另一个仅加载符合用户某些附加条件的页面。这是最佳实践,因为您不希望加载用户的所有页面,然后根据业务层中的条件进行过滤。那将是数据库资源的浪费,也是网络上传输数据的浪费。
当您实现缓存时,您就是在缓存用户页面集合。所以,页面的超集已经被缓存了。您可以轻松地检索该超集并进行一些过滤,然后返回您需要的子集。没有必要在缓存中分别缓存用户的“所有”页面和“过滤”后的页面。那将是宝贵的缓存空间的浪费。由于从缓存中检索用户的全部页面比从数据库中检索要快得多,因此您可以避免单独缓存它们。例如:
public List<Page> GetPagesOfUser(Guid userGuid)
{
return AspectF.Define
.CacheList<Page, List<Page>>(_cacheResolver,
CacheSetup.CacheKeys.PagesOfUser(userGuid),
page => CacheSetup.CacheKeys.PageId(page.ID))
.Return<List<Page>>(() =>
_database.GetList<Page, Guid>(...)
.Select(p => p.Detach()).ToList());
}
public List<Page> GetLockedPagesOfUser(Guid userGuid)
{
return this.GetPagesOfUser(userGuid)
.Where(page =>
page.IsLocked && page.IsDownForMaintenance == isDownForMaintenenceMode)
.ToList()
}
在这里您可以看到,GetLockedPagesOfUser
重用了 GetPagesOfUser
函数。因此,用户的页面被缓存,并且所需的进一步过滤是通过检索缓存的集合来完成的。如果我没有缓存,我会编写一个单独的查询来仅加载用户的锁定页面,而不会重用 GetPagesOfUser
,因为获取所有页面然后在客户端进行过滤会是浪费。但是当有缓存时,我可以避免单独实现所有这些过滤查询,从而减少数据访问层的代码。
结论
缓存极大地提高了应用程序的性能和可扩展性,但代价是可维护性和设计开发中的复杂性增加。您必须在考虑所有过期场景的情况下缓存对象。否则,您的应用程序将显示过时数据,产生错误的结果。此外,如果您不使用 AspectF 这样的库(它极大地简化了缓存),您就会重复代码,这很快就会成为维护的噩梦。使用 AspectF,您可以极大地简化缓存实现,使您的应用程序更快、更具可扩展性,而没有维护方面的挑战。
历史
- 2009 年 11 月 1 日:初始发布