本文概述
在计算机科学中只有两件难事:缓存无效和命名。
- 作者:菲尔·卡尔顿
缓存简介
缓存是通过一种简单的技巧来提高性能的强大技术:无需在每次需要结果时都进行昂贵的工作(例如复杂的计算或复杂的数据库查询), 系统可以存储或缓存该工作的结果, 并且非常简单在下次请求时提供它, 而无需重新执行该工作(因此可以大大加快响应速度)。
当然, 只有在我们缓存的结果仍然有效的情况下, 缓存背后的整个想法才起作用。在这里, 我们进入了问题的实际困难部分:我们如何确定缓存的项目何时变为无效并需要重新创建?
ASP.NET内存缓存非常快
完美解决分布式Web场缓存问题。
鸣叫
通常, 典型的Web应用程序必须处理比写请求更多的读请求。这就是为什么将旨在处理高负载的典型Web应用程序设计为可扩展和分布式的架构, 并将其部署为一组Web层节点(通常称为服务器场)。所有这些事实都会影响缓存的适用性。
在本文中, 我们重点介绍缓存在确保高吞吐量和旨在处理高负载的Web应用程序的性能中所扮演的角色, 我将利用我的一个项目中的经验, 并提供基于ASP.NET的解决方案作为说明。
处理高负荷的问题
我必须解决的实际问题不是原始问题。我的任务是使ASP.NET MVC整体Web应用程序原型能够处理高负载。
改善整体Web应用程序吞吐能力的必要步骤是:
- 使它能够在负载均衡器后面并行运行Web应用程序的多个副本, 并有效地处理所有并发请求(即使其具有可伸缩性)。
- 分析应用程序, 以揭示当前的性能瓶颈并对其进行优化。
- 使用缓存来提高读取请求的吞吐量, 因为这通常构成整个应用程序负载的重要部分。
缓存策略通常涉及使用某些中间件缓存服务器(例如Memcached或Redis)来存储缓存的值。尽管它们被广泛采用和公认的适用性, 但这些方法仍存在一些缺点, 包括:
- 通过访问单独的缓存服务器而引入的网络延迟可以与到达数据库本身的延迟相媲美。
- 网络层的数据结构可能不适合直接进行序列化和反序列化。要使用缓存服务器, 这些数据结构应支持序列化和反序列化, 这需要不断进行额外的开发工作。
- 序列化和反序列化会增加运行时开销, 从而对性能产生不利影响。
所有这些问题都与我的情况相关, 因此我不得不探索其他选择。
内置的ASP.NET内存缓存(System.Web.Caching.Cache)非常快, 在开发过程中和运行时都可以使用, 而无需序列化和反序列化开销。但是, ASP.NET内存缓存也有其自身的缺点:
- 每个Web层节点都需要其自己的缓存值副本。在节点冷启动或回收时, 这可能导致更高的数据库层消耗。
- 当另一个节点通过写入更新的值使缓存的任何部分无效时, 应通知每个Web层节点。由于缓存是分布式的, 并且没有适当的同步, 因此大多数节点将返回通常不可接受的旧值。
如果额外的数据库层负载本身不会导致瓶颈, 那么实现适当分布的缓存似乎很容易处理, 对吧?嗯, 这不是一件容易的事, 但有可能。就我而言, 基准测试表明数据库层应该不是问题, 因为大部分工作都发生在网络层。因此, 我决定使用ASP.NET内存缓存, 并专注于实现适当的同步。
介绍基于ASP.NET的解决方案
如前所述, 我的解决方案是使用ASP.NET内存中的缓存而不是专用的缓存服务器。这需要Web场的每个节点都有自己的缓存, 直接查询数据库, 执行任何必要的计算, 然后将结果存储在缓存中。这样, 由于高速缓存的内存性质, 所有高速缓存操作将迅速发展。通常, 缓存的项目有明确的生存期, 并且在进行某些更改或写入新数据时会变得过时。因此, 从Web应用程序逻辑来看, 通常很清楚何时应该使缓存项无效。
剩下的唯一问题是, 当一个节点使自己的缓存中的缓存项无效时, 其他节点将不知道此更新。因此, 其他节点服务的后续请求将传递过时的结果。为了解决这个问题, 每个节点应与其他节点共享其缓存无效。收到这种无效信息后, 其他节点可以简单地删除其缓存的值, 并在下一个请求时获得一个新值。
Redis在这里可以发挥作用。与其他解决方案相比, Redis的功能来自其发布/订阅功能。 Redis服务器的每个客户端都可以创建一个通道并在该通道上发布一些数据。任何其他客户端都能够收听该频道并接收相关数据, 这与任何事件驱动系统非常相似。此功能可用于在节点之间交换高速缓存无效消息, 因此所有节点将能够在需要时使其高速缓存无效。
ASP.NET的内存缓存在某些方面很简单, 而在另一些方面则很复杂。特别是, 它很直接, 因为它可以作为键/值对的映射, 但是与它的失效策略和依赖关系相关的复杂性很多。
幸运的是, 典型的用例非常简单, 并且可以对所有项目使用默认的无效策略, 从而使每个缓存项目最多仅具有单个依赖性。就我而言, 我以下面的ASP.NET代码结束了缓存服务的接口。 (请注意, 这不是实际的代码, 因为为简单起见和专有许可证, 我省略了一些细节。)
public interface ICacheKey
{
string Value { get; }
}
public interface IDataCacheKey : ICacheKey { }
public interface ITouchableCacheKey : ICacheKey { }
public interface ICacheService
{
int ItemsCount { get; }
T Get<T>(IDataCacheKey key, Func<T> valueGetter);
T Get<T>(IDataCacheKey key, Func<T> valueGetter, ICacheKey dependencyKey);
}
在这里, 缓存服务基本上允许两件事。首先, 它允许以线程安全的方式存储某些值获取器功能的结果。其次, 它确保在请求时始终返回当时的值。一旦高速缓存项变得陈旧或从高速缓存中显式退出后, 将再次调用值getter来检索当前值。 ICacheKey接口将缓存键抽象化, 主要是为了避免对整个应用程序中的缓存键字符串进行硬编码。
为了使缓存项无效, 我引入了一个单独的服务, 如下所示:
public interface ICacheInvalidator
{
bool IsSessionOpen { get; }
void OpenSession();
void CloseSession();
void Drop(IDataCacheKey key);
void Touch(ITouchableCacheKey key);
void Purge();
}
除了使用数据和触摸键删除项目的基本方法(这些方法仅具有相关的数据项目)外, 还有一些与某种”会话”相关的方法。
我们的Web应用程序使用Autofac进行依赖项注入, 这是依赖项管理的控制反转(IoC)设计模式的实现。此功能使开发人员无需担心依赖关系即可创建其类, 因为IoC容器为他们减轻了负担。
关于IoC, 缓存服务和缓存无效器的生命周期完全不同。缓存服务注册为单例(一个实例, 在所有客户端之间共享), 而缓存无效器注册为每个请求的实例(为每个传入请求创建一个单独的实例)。为什么?
答案与我们需要处理的其他细微之处有关。该Web应用程序使用的是Model-View-Controller(MVC)体系结构, 该体系结构主要有助于将UI和逻辑问题分离。因此, 典型的控制器动作被包装到ActionFilterAttribute的子类中。在ASP.NET MVC框架中, 此类C#属性用于以某种方式装饰控制器的动作逻辑。该特定属性负责打开新的数据库连接并在操作开始时启动事务。同样, 在操作结束时, filter属性子类负责在成功的情况下提交事务, 并在失败的情况下回滚事务。
如果缓存无效发生在事务的中间, 则可能存在竞争条件, 据此, 对该节点的下一个请求会将旧值(对于其他事务仍然可见)成功地放回缓存中。为了避免这种情况, 所有无效都将推迟到提交事务之前。之后, 可以安全地撤消缓存项, 并且在事务失败的情况下, 根本不需要修改缓存。
这就是缓存无效器中与”会话”相关的部分的确切目的。同样, 这是将其生存期绑定到请求的目的。 ASP.NET代码如下所示:
class HybridCacheInvalidator : ICacheInvalidator
{
...
public void Drop(IDataCacheKey key)
{
if (key == null)
throw new ArgumentNullException("key");
if (!IsSessionOpen)
throw new InvalidOperationException("Session must be opened first.");
_postponedRedisMessages.Add(new Tuple<string, string>("drop", key.Value));
}
...
public void CloseSession()
{
if (!IsSessionOpen)
return;
_postponedRedisMessages.ForEach(m => PublishRedisMessageSafe(m.Item1, m.Item2));
_postponedRedisMessages = null;
}
...
}
此处的PublishRedisMessageSafe方法负责将消息(第二个参数)发送到特定通道(第一个参数)。实际上, 有单独的放置和触摸通道, 因此每个消息处理程序都确切知道该怎么做-放置/触摸等于接收到的消息有效负载的键。
棘手的部分之一是正确管理与Redis服务器的连接。如果服务器由于任何原因关闭, 应用程序应继续正常运行。当Redis再次重新联机时, 应用程序应无缝开始再次使用它, 并再次与其他节点交换消息。为实现此目的, 我使用了StackExchange.Redis库, 并按如下方式实现了所产生的连接管理逻辑:
class HybridCacheService : ...
{
...
public void Initialize()
{
try
{
Multiplexer = ConnectionMultiplexer.Connect(_configService.Caching.BackendServerAddress);
...
Multiplexer.ConnectionFailed += (sender, args) => UpdateConnectedState();
Multiplexer.ConnectionRestored += (sender, args) => UpdateConnectedState();
...
}
catch (Exception ex)
{
...
}
}
private void UpdateConnectedState()
{
if (Multiplexer.IsConnected && _currentCacheService is NoCacheServiceStub) {
_inProcCacheInvalidator.Purge();
_currentCacheService = _inProcCacheService;
_logger.Debug("Connection to remote Redis server restored, switched to in-proc mode.");
} else if (!Multiplexer.IsConnected && _currentCacheService is InProcCacheService) {
_currentCacheService = _noCacheStub;
_logger.Debug("Connection to remote Redis server lost, switched to no-cache mode.");
}
}
}
在这里, ConnectionMultiplexer是StackExchange.Redis库中的一种类型, 它负责与底层Redis进行透明工作。此处的重要部分是, 当特定节点失去与Redis的连接时, 它会退回到无缓存模式, 以确保没有请求将接收过时的数据。恢复连接后, 该节点将再次开始使用内存缓存。
以下是不使用缓存服务的操作示例(SomeActionWithoutCaching)和使用该服务的相同操作(SomeActionUsingCache):
class SomeController : Controller
{
public ISomeService SomeService { get; set; }
public ICacheService CacheService { get; set; }
...
public ActionResult SomeActionWithoutCaching()
{
return View(
SomeService.GetModelData()
);
}
...
public ActionResult SomeActionUsingCache()
{
return View(
CacheService.Get(
/* Cache key creation omitted */, () => SomeService.GetModelData()
);
);
}
}
ISomeService实现中的代码片段可能如下所示:
class DefaultSomeService : ISomeService
{
public ICacheInvalidator _cacheInvalidator;
...
public SomeModel GetModelData()
{
return /* Do something to get model data. */;
}
...
public void SetModelData(SomeModel model)
{
/* Do something to set model data. */
_cacheInvalidator.Drop(/* Cache key creation omitted */);
}
}
基准和结果
设置好缓存的ASP.NET代码之后, 就该在现有的Web应用程序逻辑中使用它了, 基准测试可以很方便地决定将大部分精力花在重写代码上以使用缓存的地方。挑选出一些最常见的操作或关键用例进行基准测试至关重要。之后, 可以将诸如Apache jMeter之类的工具用于两件事:
- 为了通过HTTP请求对这些关键用例进行基准测试。
- 模拟被测Web节点的高负载。
为了获得性能概要文件, 可以使用能够附加到IIS工作进程的任何概要文件。就我而言, 我使用了JetBrains dotTrace Performance。经过一段时间的实验来确定正确的jMeter参数(例如并发和请求计数)之后, 就有可能开始收集性能快照, 这对于识别热点和瓶颈非常有帮助。
在我的案例中, 一些用例表明, 在数据库读取中花了大约15%-45%的总代码执行时间, 而瓶颈明显。在我应用了缓存后, 大多数应用程序的性能几乎翻了一番(即快一倍)。
相关:Microsoft Stack仍然是可行选择的八个原因
总结
如你所见, 我的案例似乎像是通常所说的”重塑车轮”的一个例子:当已经有广泛应用的最佳实践时, 为什么还要尝试创建新的东西?只需设置一个Memcached或Redis, 然后放手即可。
我绝对同意使用最佳做法通常是最好的选择。但是在盲目应用任何最佳实践之前, 应该问自己:”最佳实践”的适用性如何?它适合我的情况吗?
在做出任何重大决定时, 我的看法, 正确的选择和权衡分析是必不可少的, 这就是我选择的方法, 因为问题并不那么容易。就我而言, 有许多因素需要考虑, 当可能不是解决当前问题的正确方法时, 我不想采取一种一刀切的解决方案。
最后, 有了适当的缓存, 与原始解决方案相比, 我确实获得了近50%的性能提升。
评论前必须登录!
注册