如何使用缓存提高 Web Farm 中的 ASP.NET 应用程序性能
已发表: 2022-03-11计算机科学中只有两个难点:缓存失效和命名。
- 作者:菲尔·卡尔顿
缓存简介
缓存是一种通过简单技巧提高性能的强大技术:每次我们需要结果时,系统都可以存储或缓存该工作的结果,而不是做昂贵的工作(如复杂的计算或复杂的数据库查询),并且只需下次请求时提供它,而无需重新执行该工作(因此可以更快地响应)。
当然,缓存背后的整个想法只有在我们缓存的结果仍然有效时才有效。 在这里,我们遇到了问题的实际困难部分:我们如何确定缓存项何时变得无效并需要重新创建?
完美解决分布式网络场缓存问题。
通常,典型的 Web 应用程序必须处理比写入请求更多的读取请求。 这就是为什么设计用于处理高负载的典型 Web 应用程序被设计为可扩展和分布式的,部署为一组 Web 层节点,通常称为农场。 所有这些事实都会影响缓存的适用性。
在本文中,我们关注缓存在确保设计用于处理高负载的 Web 应用程序的高吞吐量和性能方面可以发挥的作用,我将利用我的一个项目的经验并提供基于 ASP.NET 的解决方案作为插图。
处理高负载的问题
我必须解决的实际问题不是原始问题。 我的任务是让 ASP.NET MVC 整体式 Web 应用程序原型能够处理高负载。
提高单体 Web 应用程序的吞吐量能力的必要步骤是:
- 使其能够在负载均衡器后面并行运行 Web 应用程序的多个副本,并有效地服务所有并发请求(即,使其具有可扩展性)。
- 分析应用程序以揭示当前的性能瓶颈并对其进行优化。
- 使用缓存来增加读取请求吞吐量,因为这通常构成整个应用程序负载的重要部分。
缓存策略通常涉及使用一些中间件缓存服务器,例如 Memcached 或 Redis,来存储缓存的值。 尽管它们的采用率很高并且经过验证的适用性,但这些方法也有一些缺点,包括:
- 通过访问单独的缓存服务器引入的网络延迟可以与到达数据库本身的延迟相媲美。
- Web 层的数据结构可能不适合开箱即用的序列化和反序列化。 要使用缓存服务器,这些数据结构应该支持序列化和反序列化,这需要持续的额外开发工作。
- 序列化和反序列化会增加运行时开销,并对性能产生不利影响。
所有这些问题都与我的情况相关,因此我不得不探索替代方案。
内置的 ASP.NET 内存缓存 ( System.Web.Caching.Cache
) 速度非常快,并且可以在开发期间和运行时使用而无需序列化和反序列化开销。 但是,ASP.NET 内存缓存也有其自身的缺点:
- 每个 Web 层节点都需要自己的缓存值副本。 这可能会导致节点冷启动或回收时更高的数据库层消耗。
- 当另一个节点通过写入更新值使缓存的任何部分无效时,应通知每个 Web 层节点。 由于缓存是分布式的并且没有适当的同步,大多数节点将返回旧值,这通常是不可接受的。
如果额外的数据库层负载本身不会导致瓶颈,那么实现正确分布的缓存似乎是一件容易处理的任务,对吧? 嗯,这不是一件容易的事,但它是可能的。 就我而言,基准测试表明数据库层不应该成为问题,因为大部分工作都发生在 Web 层。 因此,我决定使用 ASP.NET 内存缓存并专注于实现正确的同步。
介绍基于 ASP.NET 的解决方案
如前所述,我的解决方案是使用 ASP.NET 内存中缓存而不是专用缓存服务器。 这需要网络场的每个节点都有自己的缓存,直接查询数据库,执行任何必要的计算,并将结果存储在缓存中。 这样,由于缓存的内存特性,所有缓存操作都将非常快。 通常,缓存项具有明确的生命周期,并在某些更改或写入新数据时变得陈旧。 因此,从 Web 应用程序逻辑来看,缓存项何时应该失效通常是很清楚的。
这里剩下的唯一问题是,当其中一个节点使自己缓存中的缓存项无效时,其他节点将不会知道此更新。 因此,其他节点服务的后续请求将提供陈旧的结果。 为了解决这个问题,每个节点都应该与其他节点共享其缓存失效。 收到此类无效后,其他节点可以简单地删除其缓存值并在下一次请求时获取新值。
在这里,Redis 可以发挥作用。 与其他解决方案相比,Redis 的强大之处在于其 Pub/Sub 功能。 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 函数的结果。 其次,它确保在请求时始终返回当时的值。 一旦缓存项变得陈旧或被明确地从缓存中逐出,则再次调用 value 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 应用程序使用模型-视图-控制器 (MVC) 架构,主要有助于分离 UI 和逻辑关注点。 因此,一个典型的控制器动作被包装到ActionFilterAttribute
的一个子类中。 在 ASP.NET MVC 框架中,此类 C# 属性用于以某种方式装饰控制器的操作逻辑。 该特定属性负责在操作开始时打开新的数据库连接并启动事务。 此外,在操作结束时,过滤器属性子类负责在成功的情况下提交事务并在失败的情况下将其回滚。

如果缓存失效发生在事务的中间,则可能存在竞争条件,即对该节点的下一个请求将成功地将旧的(其他事务仍然可见)值放回缓存中。 为了避免这种情况,所有的失效都被推迟到事务提交之前。 之后,缓存项可以安全地驱逐,并且在事务失败的情况下,根本不需要修改缓存。
这就是缓存失效器中与“会话”相关的部分的确切目的。 此外,这也是其生命周期与请求绑定的目的。 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
方法负责将消息(第二个参数)发送到特定的通道(第一个参数)。 实际上,drop 和 touch 有单独的通道,因此每个通道的消息处理程序都知道要做什么——drop/touch 等于接收到的消息有效负载的键。
棘手的部分之一是正确管理与 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% 的总体代码执行时间花在了数据库读取上,并且存在明显的瓶颈。 在我应用缓存之后,它们中的大多数的性能几乎翻了一番(即快两倍)。
结论
如您所见,我的案例可能看起来像是通常所说的“重新发明轮子”的一个例子:既然已经有广泛应用的最佳实践,为什么还要费心去尝试创造新事物呢? 只需设置一个 Memcached 或 Redis,然后放手。
我绝对同意使用最佳实践通常是最佳选择。 但在盲目应用任何最佳实践之前,应该问自己:这种“最佳实践”的适用性如何? 它适合我的情况吗?
在我看来,正确的选择和权衡分析是做出任何重大决定的必要条件,这就是我选择的方法,因为问题并不那么容易。 就我而言,有很多因素需要考虑,我不想采取一种万能的解决方案,因为它可能不是解决手头问题的正确方法。
最后,通过适当的缓存,我的性能确实比最初的解决方案提高了近 50%。