作者都是各自领域经过审查的专家,并撰写他们有经验的主题. 我们所有的内容都经过同行评审,并由同一领域的Toptal专家验证.
Daniel Ivanov's profile image

Daniel Ivanov

Daniel已经帮助创业公司将产品推向市场十多年了,他使用了最好的HTML/CSS方法, JS, Python, and C#.

Expertise

Previously At

Trilogy
Share

在计算机科学中只有两件困难的事情:缓存失效和命名.

A Brief Introduction to Caching

缓存是一种强大的技术,可以通过一个简单的技巧来提高性能:每次我们需要结果时,都不用做昂贵的工作(比如复杂的计算或复杂的数据库查询), 系统可以存储或缓存该工作的结果,并在下次请求时提供它,而无需重新执行该工作(并且可以), therefore, respond tremendously faster).

当然,缓存背后的整个思想只有在我们缓存的结果仍然有效的情况下才有效. 在这里,我们进入了问题的真正困难部分:我们如何确定缓存项何时失效并需要重新创建?

缓存是提高性能的一种强大技术

The ASP.. NET内存缓存非常快
完美解决分布式web农场缓存问题.

Usually, 一个典型的web应用程序必须处理比写请求多得多的读请求. 这就是为什么设计用于处理高负载的典型web应用程序的架构是可扩展和分布式的, deployed as a set of web tier nodes, usually called a farm. 所有这些事实都对缓存的适用性有影响.

In this article, 我们关注缓存在确保高吞吐量和性能的web应用程序处理高负载方面所扮演的角色, 我将利用我的一个项目的经验,提供一个ASP.基于。net的解决方案作为示例.

The Problem of Handling a High Load

我要解决的实际问题并不是最初的问题. My task was to make an ASP.NET MVC 单片web应用原型能够处理高负载.

提高单片web应用程序吞吐量能力的必要步骤如下:

  • 使其能够并行运行web应用程序的多个副本, behind a load balancer, 并有效地处理所有并发请求(i.e., make it scalable).
  • 概要分析应用程序以揭示当前的性能瓶颈并对其进行优化.
  • 使用缓存来提高读请求吞吐量, 因为这通常构成了整个应用程序负载的重要部分.

缓存策略通常涉及使用一些中间件缓存服务器, like Memcached or Redis, to store the cached values. 尽管它们的高采用率和经过验证的适用性, 这些方法也有一些缺点, including:

  • 通过访问单独的缓存服务器引入的网络延迟可以与访问数据库本身的延迟相媲美.
  • web层的数据结构可能不适合开箱即用的序列化和反序列化. To use cache servers, 这些数据结构应该支持序列化和反序列化, 哪一个需要持续的额外开发工作.
  • 序列化和反序列化会增加运行时开销,并对性能产生不利影响.

所有这些问题都与我的情况有关,所以我必须探索其他选择.

How caching works

The built-in ASP.NET in-memory cache (System.Web.Caching.Cache)非常快,并且可以在没有序列化和反序列化开销的情况下使用, 无论是在开发期间还是在运行时. However, ASP.. NET内存缓存也有它自己的缺点:

  • 每个web层节点都需要自己的缓存值副本. 这可能导致节点冷启动或回收时更高的数据库层消耗.
  • 当另一个节点通过写入更新的值使缓存的任何部分无效时,应该通知每个web层节点. 因为缓存是分布式的,没有适当的同步, 大多数节点将返回旧值,这通常是不可接受的.

如果额外的数据库层负载本身不会导致瓶颈, 那么实现一个适当的分布式缓存似乎是一项容易处理的任务, right? Well, it’s not an easy task, but it is possible. In my case, 基准测试表明,数据库层应该不是问题, 因为大部分工作都发生在web层. So, I decided to go with the ASP.. NET内存缓存,并专注于实现适当的同步.

Introducing an ASP.NET-based Solution

如前所述,我的解决方案是使用ASP.. NET内存缓存,而不是专用缓存服务器. 这就需要网络农场的每个节点都有自己的缓存, querying the database directly, 执行任何必要的计算, and storing results in a cache. 这样,由于缓存的内存特性,所有缓存操作都将非常快. Typically, 缓存项具有明确的生命周期,并且在更改或写入新数据时变得陈旧. 因此,从web应用程序逻辑来看,通常很清楚缓存项何时应该无效.

这里剩下的唯一问题是,当其中一个节点在其自己的缓存中使缓存项无效时, 其他节点不会知道此更新. 因此,由其他节点服务的后续请求将交付过时的结果. 为了解决这个问题,每个节点应该与其他节点共享其缓存失效. Upon receiving such invalidation, 其他节点可以简单地删除它们缓存的值,并在下一次请求时获取一个新值.

Here, Redis can come into play. 与其他解决方案相比,Redis的强大之处在于它的 Pub/Sub capabilities. Redis服务器的每个客户端都可以创建一个通道,并在上面发布一些数据. 任何其他客户机都能够侦听该通道并接收相关数据, 与任何事件驱动系统非常相似. 此功能可用于在节点之间交换缓存无效消息, 因此,当需要时,所有节点都可以使其缓存失效.

A group of ASP.. NET web层节点使用Redis背板

ASP.. NET的内存缓存在某些方面是直接的,在另一些方面是复杂的. In particular, 它是直接的,因为它是键/值对的映射, 然而,它的失效策略和依赖关系有很多复杂性.

Fortunately, typical use cases are simple enough, 并且可以对所有项使用默认的失效策略, 使每个缓存项最多只有一个依赖项. 在我的例子中,我以下面的ASP结尾.缓存服务接口的。NET代码. (注意,这不是实际的代码, 因为为了简单和专有许可,我省略了一些细节.)

public interface ICacheKey
{
	string Value { get; }
}

IDataCacheKey: ICacheKey {}

ITouchableCacheKey: ICacheKey {}

public interface ICacheService
{
	int ItemsCount { get; }

	T Get(IDataCacheKey key, Func valueGetter);
	T Get(IDataCacheKey key, Func valueGetter, ICacheKey dependencyKey);
}

这里,缓存服务基本上允许两件事. 首先,它支持以线程安全的方式存储某个值getter函数的结果. 其次,它确保在请求时总是返回当时的值. 一旦缓存项过时或显式从缓存中移除, 再次调用值getter来检索当前值. 缓存键被抽象掉 ICacheKey 接口,主要是为了避免在整个应用程序中硬编码缓存关键字串.

为了使缓存项失效,我引入了一个单独的服务,它看起来像这样:

public interface ICacheInvalidator
{
	bool IsSessionOpen { get; }

	void OpenSession();
	void CloseSession();

	void Drop(IDataCacheKey key);
	void Touch(ITouchableCacheKey key);
	void Purge();
}

除了用数据和触摸按键来掉落物品的基本方法, 哪个只有依赖的数据项, 有一些与某种“会话”相关的方法.

Our web application used Autofac 的实现 inversion of control (IoC) 依赖关系管理的设计模式. 该特性允许开发人员创建自己的类,而无需担心依赖关系, 因为IoC容器为它们管理这个负担.

缓存服务和缓存无效器在IoC方面具有截然不同的生命周期. 缓存服务被注册为单例(一个实例), shared between all clients), 当缓存无效器被注册为每个请求的实例时(为每个传入请求创建一个单独的实例). Why?

答案与我们需要处理的另一个微妙之处有关. web应用程序使用模型-视图-控制器(MVC)架构, 哪些主要有助于分离UI和逻辑关注点. 一个典型的控制器动作被包装到一个子类中 ActionFilterAttribute. In the ASP.. NET MVC框架中,这样的c#属性被用来以某种方式修饰控制器的动作逻辑. 该特定属性负责在操作开始时打开新的数据库连接并启动事务. Also, at the end of the action, 过滤器属性子类负责在成功的情况下提交事务,并在失败的情况下回滚事务.

如果缓存失效发生在事务的中间, 可能存在竞争条件,因此对该节点的下一个请求将成功地将旧值(对其他事务仍然可见)放回缓存中. 为了避免这种情况,所有的失效都被推迟到事务提交之后. After that, cache items are safe to evict and, 在事务失败的情况下, 根本不需要修改缓存.

这就是缓存无效器中与“会话”相关部分的确切目的. 此外,这也是将其生命周期绑定到请求的目的. The ASP.NET code looked like this:

类HybridCacheInvalidator: ICacheInvalidator
{
	...
	public void Drop(IDataCacheKey key)
	{
		if (key == null)
			抛出新的ArgumentNullException("key");
		if (!IsSessionOpen)
			抛出新的InvalidOperationException("必须先打开会话").");

		_postponedRedisMessages.Add(new Tuple("drop", key.Value));
	}
	...
	public void CloseSession()
	{
		if (!IsSessionOpen)
			return;

		_postponedRedisMessages.ForEach(m => PublishRedisMessageSafe(m.Item1, m.Item2));
		_postponedRedisMessages = null;
	}	
	...
}

The PublishRedisMessageSafe 方法负责将消息(第二个参数)发送到特定通道(第一个参数)。. In fact, 有单独的通道用于掉落和触摸, 因此,它们每个的消息处理程序都确切地知道该做什么——放下/触摸等于接收到的消息有效负载的键.

其中一个棘手的部分是正确管理与Redis服务器的连接. 在服务器因任何原因宕机的情况下, 应用程序应该可以继续正常运行. When Redis is back online again, 应用程序应该再次无缝地开始使用它,并再次与其他节点交换消息. To achieve this, I used the 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是noacheservicestub) {
			_inProcCacheInvalidator.Purge();
			_currentCacheService = _inProcCacheService;
			_logger.调试(“连接到远程Redis服务器恢复,切换到进程内模式.");
		} else if (!Multiplexer.IsConnected && _currentCacheService是InProcCacheService) {
			_currentCacheService = _noCacheStub;
			_logger.调试(“连接到远程Redis服务器丢失,切换到无缓存模式.");
		}
	}
}

Here, ConnectionMultiplexer is a type from the StackExchange.Redis库,负责底层Redis的透明工作. The important part here is that, 当一个特定节点失去与Redis的连接时, 它退回到无缓存模式,以确保没有请求将接收过时的数据. 连接恢复后,节点重新使用内存缓存.

以下是不使用缓存服务的操作示例(SomeActionWithoutCaching)和使用它的相同操作(SomeActionUsingCache):

class SomeController : Controller
{
	public ISomeService SomeService { get; set; }
	public ICacheService CacheService { get; set; }
	...
	SomeActionWithoutCaching()
	{
		return View(
			SomeService.GetModelData()
		);
	}
	...
	SomeActionUsingCache()
	{
		return View(
			CacheService.Get(
                	/* Cache key creation omitted */,
                	() => SomeService.GetModelData()
            	);
		);
	}
}

A code snippet from an ISomeService 实现可能是这样的:

类DefaultSomeService: ISomeService
{
	ICacheInvalidator _cacheInvalidator;
	...
	public SomeModel GetModelData()
	{
		返回/*做一些事情来获取模型数据. */;
	}
	...
	SetModelData(somemodelmodel)
	{
		/* Do something to set model data. */
		_cacheInvalidator.删除(/*缓存键创建省略*/);
	}
}

Benchmarking and Results

After the caching ASP.NET code was all set, 是时候在现有的web应用程序逻辑中使用它了, 基准测试可以方便地决定在何处重写代码以使用缓存. 挑选出几个操作上最常见或最关键的用例进行基准测试是至关重要的. After that, a tool like Apache jMeter could be used for two things:

  • 通过HTTP请求对这些关键用例进行基准测试.
  • 模拟被测web节点的高负载.

要获得性能概要文件,任何能够附加到的概要文件 IIS worker process could be used. In my case, I used JetBrains dotTrace Performance. 在花了一些时间进行实验以确定正确的jMeter参数(例如并发数和请求数)之后, 可以开始收集性能快照, 哪些对识别热点和瓶颈非常有帮助.

In my case, 一些用例表明,大约15%-45%的总代码执行时间花在了具有明显瓶颈的数据库读取上. 在应用缓存之后,性能几乎翻了一番.e.(速度是它的两倍).

Conclusion

As you may see, 我的案例似乎是所谓“重新发明轮子”的一个例子:为什么要费心去创造新的东西呢, 当已经有了广泛应用的最佳实践时? 只要建立一个Memcached或Redis,然后放手.

我绝对同意使用最佳实践通常是最好的选择. 但在盲目地应用任何最佳实践之前, 人们应该问自己:这个“最佳实践”有多适用?? Does it fit my case well?

The way I see it, 在做出任何重大决策时,必须进行适当的选择和权衡分析, 这就是我选择的方法,因为这个问题不是那么简单. In my case, there were many factors to consider, 我不想采取一刀切的解决方案,因为它可能不是解决手头问题的正确方法.

In the end, with the proper caching in place, 与最初的解决方案相比,我确实获得了近50%的性能提升.

就这一主题咨询作者或专家.
Schedule a call
Daniel Ivanov's profile image
Daniel Ivanov

Located in Moscow, Russia

Member since July 6, 2016

About the author

Daniel已经帮助创业公司将产品推向市场十多年了,他使用了最好的HTML/CSS方法, JS, Python, and C#.

Toptal作者都是各自领域经过审查的专家,并撰写他们有经验的主题. 我们所有的内容都经过同行评审,并由同一领域的Toptal专家验证.

Expertise

Previously At

Trilogy

世界级的文章,每周发一次.

订阅意味着同意我们的 privacy policy

世界级的文章,每周发一次.

订阅意味着同意我们的 privacy policy

Toptal Developers

Join the Toptal® community.