如何通过缓存提高Web 场景中的ASP.NET App性能
Posted dotNET全栈开发
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了如何通过缓存提高Web 场景中的ASP.NET App性能相关的知识,希望对你有一定的参考价值。
原文来自互联网,由长沙DotNET技术社区编译。如译文侵犯您的署名权或版权,请联系小编,小编将在24小时内删除。限于译者的能力有限,个别语句翻译略显生硬,还请见谅。
原文来自:https://www.toptal.com/dot-net/caching-in-a-distributed-web-farm-using-asp-net
作者菲尔·卡尔顿说:在计算机科学中只有两件难事:缓存无效和命名。
缓存简介
缓存是通过一种简单的技巧来提高性能的强大技术:通过运用缓存,开发者无需在每次需要结果时都进行繁琐的工作(例如复杂的计算或复杂的数据库查询),系统可以存储或缓存该工作的结果,以非常简单的操作在下次请求时使用之前工作的见过,而无需重新执行该工作(因此可以大大加快响应速度)。
当然,只有在我们缓存的结果仍然有效的情况下,缓存背后的链条才起作用。在这里,我们进入了问题的实际困难部分:我们如何确定何时缓存的条目变得无效并需要重新创建?
ASP.NET内存中的缓存非常快,非常适合解决分布式Web场景缓存问题。
通常,典型的Web应用程序必须处理比写请求更多的读请求。这就是为什么将旨在处理高负载的典型Web应用程序设计为可扩展和分布式的架构,并将其部署为一组Web层节点(通常称为服务器场)。所有这些事实都会影响缓存的适用性。
在本文中,我们着重讨论缓存在确保高吞吐量和旨在处理高负载的Web应用程序的性能中所扮演的角色,我将利用我的一个项目中的经验并提供基于ASP.NET的解决方案作为说明。
处理高负荷的问题
我必须解决的实际问题不是一个原始的问题。我的任务是使ASP.NET MVC[2]整体Web应用程序原型能够处理高负载。
改善整体Web应用程序吞吐能力的必要步骤是:
•使它能够在负载均衡器后面并行运行Web应用程序的多个副本,并有效地处理所有并发请求(即使其具有可伸缩性)。•分析应用程序,以揭示当前的性能瓶颈并对其进行优化。•使用缓存来提高读取请求的吞吐量,因为这通常构成整个应用程序负载的重要部分。
缓存策略通常涉及使用某些中间件缓存服务器(例如Memcached或Redis)来存储缓存的值。尽管它们被广泛采用和公认的适用性,但这些方法仍存在一些缺点,包括:
•通过访问单独的缓存服务器而引入的网络延迟可能与到达数据库本身的延迟相差不大。•Web层的数据结构可能不适合直接进行序列化和反序列化。要使用缓存服务器,这些数据结构应支持序列化和反序列化,这需要不断进行额外的开发工作。•序列化和反序列化会增加运行时开销,从而对性能产生不利影响。
所有这些问题都与我的情况相关,因此我不得不探索其他选择。
内置的ASP.NET内存缓存(System.Web.Caching.Cache)非常快,在开发过程中和运行时都可以使用而无需序列化和反序列化开销。但是,ASP.NET内存缓存也有其自身的缺点:
•每个Web层节点都需要其自己的缓存副本。在节点冷启动或回收时,这可能导致更高的数据库层消耗。•当另一个节点通过写入更新的值使缓存的任何部分无效时,应通知每个Web层节点。由于缓存是分布式的,并且没有适当的同步,因此大多数节点将返回通常不可接受的旧值。
如果额外的数据库层负载本身不会导致瓶颈,那么实现适当分布的缓存似乎很容易处理,对吧?好吧,但这不是一件容易的事。就我而言,基准测试表明数据库层应该不是问题,因为大多数工作都发生在网络层。因此,我决定使用ASP.NET内存缓存,并专注于实现适当的同步。
介绍基于ASP.NET的解决方案
如前所述,我的解决方案是使用ASP.NET内存中的缓存而不是专用的缓存服务器。这需要Web场景的每个节点都有自己的缓存,直接查询数据库,执行任何必要的计算,然后将结果存储在缓存中。这样,由于高速缓存的内存性质,所有高速缓存操作将迅速发展。通常,缓存的项目有明确的生存期,并且在进行某些更改或写入新数据时会变得过时。因此,从Web应用程序逻辑来看,通常很清楚何时应该使缓存项无效。
剩下的唯一问题是,当一个节点使自己的缓存中的缓存项无效时,其他节点将不知道此更新。因此,其他节点服务的后续请求将传递过时的结果。为了解决这个问题,每个节点应与其他节点共享其缓存无效。收到这种无效信息后,其他节点可以简单地删除其缓存的值,并在下一个请求时获得一个新值。
Redis在这里可以发挥作用。与其他解决方案相比,Redis的功能[3]来自其发布/订阅功能[4]。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[5]进行依赖项注入,这是依赖项管理的控制反转(IoC)[6]设计模式的实现。此功能使开发人员无需担心依赖关系即可创建其类,因为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;
}
...
}
Pu blishRedisMessageSafe这里的方法负责将消息(第二个参数)发送到特定的通道(第一个参数)。 实际上,有单独的放置和触摸通道,因此每个消息处理程序都确切知道该怎么做-放置/触摸等于所接收消息有效负载的键。棘手的部分之一是正确管理与Redis服务器的连接。 如果服务器由于任何原因关闭,应用程序应继续正常运行。 当Redis再次重新联机时,应用程序应无缝开始再次使用它,并再次与其他节点交换消息。 为实现此目的,我使用了 StackExchange.Redis[7] 库,并按如下方式实现了所产生的连接管理逻辑:
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()
);
);
}
rvice.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之[8]类的工具用于两件事:
•为了通过HTTP请求对这些关键用例进行基准测试。•模拟被测Web节点的高负载。
为了获得性能概要文件,可以使用能够附加到IIS工作进程的任何概要文件。就我而言,我使用了JetBrains dotTrace Performance[9]。经过一段时间的实验来确定正确的jMeter参数(例如并发和请求计数)之后,就有可能开始收集性能快照,这对于识别热点和瓶颈非常有帮助。
在我的案例中,一些用例表明,在数据库读取中花了大约15%-45%的总代码执行时间,而瓶颈明显。应用缓存后,大多数缓存的性能几乎提高了一倍(即快一倍)。
结论
如您所见,我的案例似乎像是通常所说的“重塑车轮”的一个例子:当已经有广泛应用的最佳实践时,为什么还要尝试创建新的东西?只需设置一个Memcached或Redis,然后放手即可。
我绝对同意使用最佳做法通常是最好的选择。但是在盲目应用任何最佳实践之前,应该问自己:“最佳实践”的适用性如何?它适合我的情况吗?
在做出任何重大决定时,我的看法,正确的选择和权衡分析是必不可少的,这就是我选择的方法,因为问题并不那么容易。就我而言,有许多因素需要考虑,当可能不是解决当前问题的正确方法时,我不想采取一种一刀切的解决方案。
最后,有了适当的缓存,与原始解决方案相比,我确实获得了近50%的性能提升。
References
[1]
丹尼尔·伊凡诺夫(DANIEL IVANOV): https://www.toptal.com/resume/daniel-ivanov[2]
ASP.NET MVC: https://www.toptal.com/asp-dot-net-mvc[3]
功能: http://redis.io/topics/pubsub[4]
/订阅功能: http://redis.io/topics/pubsub[5]
Autofac: https://autofac.org/[6]
的控制反转(IoC): https://en.wikipedia.org/wiki/Inversion_of_control[7]
StackExchange.Redis: https://github.com/StackExchange/StackExchange.Redis[8]
Apache jMeter之: http://jmeter.apache.org/[9]
JetBrains dotTrace Performance: https://www.jetbrains.com/profiler/
推荐阅读
微信
转载
转载
转载
微信
坐牢
面试
微信后台回复“core”,获取全网最强.NET Core学习资料精选
回复“实战”,获取20套实战成品项目源码回复“进群”,可加入dotnet core开发者交流群
.NET Core已经崛起
长按关注,刷新认知
dotNet全栈开发
以上是关于如何通过缓存提高Web 场景中的ASP.NET App性能的主要内容,如果未能解决你的问题,请参考以下文章