Azure反模式——无缓存
在处理大并发的云应用程序中,反复提取相同数据可能会降低性能和可伸缩性。
问题描述
在没有缓存的情况下,可能会出现一些异常行为,包括:
反复从访问开销较高(I/O开销或网络延迟)的资源提取相同信息。
为多个请求反复构造相同的对象或数据结构。
向具有服务配额的远程服务发出过多的调用请求。
这些问题可能进一步导致系统响应时间不佳、数据存储中资源争用加剧,以及可伸缩性下降。
以下示例使用EF连接到数据库。即使多个请求提取完全相同的数据,每个客户端请求也会访问数据库。重复请求的成本(体现在 I/O开销和数据访问方面)可能会迅速累积。
可在此处找到完整示例。(https://github.com/mspnp/performance-optimization/tree/master/NoCaching)
出现此反模式的原因通常是:
没有使用缓存。因为这种解决方案更容易实施,在低负载下可正常运转。缓存使代码变得更复杂。
不能清楚地了解使用缓存的优缺点。
注重缓存的准确性和刷新缓存所带来的开销。
应用程序是从本地系统迁移过来的,网络延迟不是问题,并且系统在昂贵的高性能硬件上运行,因此,原设计中未考虑缓存。
开发人员未意识到缓存在指定方案中是可行的。例如,开发人员在实现Web API时可能不会考虑使用Etag。
解决方案
最流行的缓存策略是按需策略或缓存预留策略。
读取时,应用程序先从缓存中读取数据。如果数据不在缓存中,应用程序会从数据源中检索数据,并将其添加到缓存。
写入时,应用程序将更改直接写入数据源,并从缓存中删除旧值。下一次有需要时,会在缓存中检索,如果不存在则将新数据添加到缓存。
该方法适用于经常更改的数据。下面将前一示例更新为使用缓存预留模式的实现。
请注意,GetAsync方法现在调用了CacheService类,而不是直接访问数据库。CacheService类首先尝试从AzureRedis缓存中获取。如果在Redis缓存中未找到,CacheService会回调传递给它的lambda。该lambda函数负责从数据库提取数据。该实现将数据存储与缓存的解决方案分离,并将CacheService与数据库分离。
注意事项
如果缓存不可用(可能是暂时性故障造成的),不要向客户端返回错误。而应该从原始数据源提取数据。但是在恢复缓存时,原始数据存储可能忙于处理请求,导致超时和连接失败。(毕竟这是首先使用缓存的动机之一。)可使用断路器(https://docs.microsoft.com/en-us/azure/architecture/patterns/circuit-breaker)等技术来避免数据源瘫痪。
缓存非静态数据的应用程序应支持最终一致性。
对于WebAPI,可通过在请求和响应消息中包含Cache-Control标头并使用Etag标识对象版本来支持客户端缓存。有关详细信息,请参阅API实现。(https://docs.microsoft.com/en-us/azure/architecture/best-practices/api-implementation#optimizing-client-side-data-access)
不需要缓存整个实体。如果某实体的大部分内容是静态的,但只有一小部分经常更改,对静态元素做缓存,并直接从数据源检索动态元素。这样有助于减少对数据源执行的I/O数量。
在某些情况下,如果可变数据的生存期较短,将它缓存可能是一个选择。例如,假设某设备持续发送状态更新。一种做法可能是在对其做缓存,而完全不用存数据库。
为了防止数据过时,许多缓存解决方案支持可配置的失效期,以便在达到指定的间隔后,从缓存中自动删除数据。可根据具体场景对过期时间做优化。静态数据在缓存中的保留时间可以长于会很快过时的可变性数据。
如果缓存解决方案未内置过期策略,可能需要实现一个后台进程,定时扫描缓存,防止它不受限制地扩大。
缓存数据除了来自外部数据源以外,还可以使用缓存来保存复杂计算的结果。但是,在执行此操作之前,需要确定应用程序是否真正有CPU的使用约束。
在应用程序启动时准备好缓存可能很有帮助。在缓存中初始化最有可能会用到的数据。
始终提供检测机制来检测缓存命中数和缓存未命中数。使用此信息来优化缓存策略,例如,要缓存哪些数据,以及在数据过期之前要在缓存中保存数据多长时间。
如果缺少缓存造成了瓶颈,则添加缓存可能会大幅提高并发请求,导致Web前端过载。客户端可能会收到HTTP-503(服务不可用)错误。这些问题表明需要对前端进行横向扩展。
如何检测问题
可以执行以下步骤来鉴别是否是缺少缓存导致了性能问题:
检查应用程序设计。盘点应用程序使用的数据存储机制。对于每个数据存储,确定应用程序是否使用了缓存。在可能的情况下,确定数据的更改频率。适合缓存的初始候选项包括不经常更改的数据,以及频繁读取的静态数据。
检测应用程序并实时监视系统,找出应用程序检索数据或计算信息的频率。
在测试环境中分析应用程序,捕获有关数据访问或其他经常执行的与计算关联的开销度量指标。
在测试环境中执行负载测试,识别系统在承受正常工作负荷和重度负载的情况下如何响应。 负载测试应使用真实工作负荷来模拟在生产环境中观察到的数据访问情形。
检查底层数据存储的数据访问统计信息,并检查相同数据请求的重复频率。
诊断示例
以下部分将这些步骤应用到前面所述的示例应用程序。
检测应用程序并实时监控系统
应用程序已部署到生产环境,检测并监视应用程序,以获取有关指定用户请求的信息。
下图显示了在负载测试期间NewRelic(http://newrelic.com/azure)捕获的监视数据。在本例中,执行唯一的GET操作是Person/GetAsync。但在生产环境中,了解每个请求的执行频率可以用于深入分析应该缓存哪些资源。
如果需要更深入的分析,可以在测试环境(而不是生产环境)中使用探查器捕获底层性能数据。 查看I/O请求速率、内存使用率和 CPU利用率等指标。这些指标可以反应出向数据存储或服务发出的大量请求,或者相同计算的重复执行。
对应用程序进行负载测试
下图显示了对示例应用程序执行负载测试的结果。该负载测试模拟一个包含800个用户的阶跃负载,这些用户执行一系列操作。
每秒成功执行的测试数达到平稳状态,因此,接下来的请求速度会减慢。平均测试时间随着工作负荷的增大而平稳增加。达到用户负载峰值后,响应时间趋向平稳。
检查数据访问统计信息
数据存储能够提供有用的数据访问统计信息和其他信息,例如,哪些查询最频繁。在微软SQLServer中,sys.dm_exec_query_stats管理视图就提供了最近执行的查询统计信息。sys.dm_exec-query_plan视图中能提供每个查询的文本。可使用SQL Server Management Studio等工具来运行以下SQL查询,以确定查询的执行频率。
结果中的UseCount列指示每个查询的运行频率。下图显示第三个查询已运行250,000次以上,远远超过其他任何查询。
下面是导致发送过多数据库请求的查询:
这是EF在前面所示的GetByIdAsync方法中生成的查询。
解决方案
合并缓存后,重复负载测试,并将结果与前面未使用缓存执行的负载测试进行比较。下图是将缓存添加到示例应用程序后的负载测试结果。
成功测试数量仍保持平稳状态,但用户负载更高。在承受负载时,请求速率明显高于前面的测试结果。平均测试时间仍然随着负载的增大而增加,但最大响应时间为0.05毫秒,而前面的测试中为1毫秒—有了20倍左右的改善。
相关资源
缓存的最佳实践(https://docs.microsoft.com/en-us/azure/architecture/best-practices/caching)
断路器模式(https://docs.microsoft.com/en-us/azure/architecture/patterns/circuit-breaker)
问题描述
在没有缓存的情况下,可能会出现一些异常行为,包括:
反复从访问开销较高(I/O开销或网络延迟)的资源提取相同信息。
为多个请求反复构造相同的对象或数据结构。
向具有服务配额的远程服务发出过多的调用请求。
这些问题可能进一步导致系统响应时间不佳、数据存储中资源争用加剧,以及可伸缩性下降。
以下示例使用EF连接到数据库。即使多个请求提取完全相同的数据,每个客户端请求也会访问数据库。重复请求的成本(体现在 I/O开销和数据访问方面)可能会迅速累积。
public class PersonRepository : IPersonRepository
{
public async Task<Person> GetAsync(int id)
{
using (var context = new AdventureWorksContext())
{
return await context.People
.Where(p => p.Id == id)
.FirstOrDefaultAsync()
.ConfigureAwait(false);
}
}
}vvv
可在此处找到完整示例。(https://github.com/mspnp/performance-optimization/tree/master/NoCaching)
出现此反模式的原因通常是:
没有使用缓存。因为这种解决方案更容易实施,在低负载下可正常运转。缓存使代码变得更复杂。
不能清楚地了解使用缓存的优缺点。
注重缓存的准确性和刷新缓存所带来的开销。
应用程序是从本地系统迁移过来的,网络延迟不是问题,并且系统在昂贵的高性能硬件上运行,因此,原设计中未考虑缓存。
开发人员未意识到缓存在指定方案中是可行的。例如,开发人员在实现Web API时可能不会考虑使用Etag。
解决方案
最流行的缓存策略是按需策略或缓存预留策略。
读取时,应用程序先从缓存中读取数据。如果数据不在缓存中,应用程序会从数据源中检索数据,并将其添加到缓存。
写入时,应用程序将更改直接写入数据源,并从缓存中删除旧值。下一次有需要时,会在缓存中检索,如果不存在则将新数据添加到缓存。
该方法适用于经常更改的数据。下面将前一示例更新为使用缓存预留模式的实现。
public class CachedPersonRepository : IPersonRepository
{
private readonly PersonRepository _innerRepository;
public CachedPersonRepository(PersonRepository innerRepository)
{
_innerRepository = innerRepository;
}
public async Task<Person> GetAsync(int id)
{
return await CacheService.GetAsync<Person>("p:" + id, () => _innerRepository.GetAsync(id)).ConfigureAwait(false);
}
}
public class CacheService
{
private static ConnectionMultiplexer _connection;
public static async Task<T> GetAsync<T>(string key, Func<Task<T>> loadCache, double expirationTimeInMinutes)
{
IDatabase cache = Connection.GetDatabase();
T value = await GetAsync<T>(cache, key).ConfigureAwait(false);
if (value == null)
{
// Value was not found in the cache. Call the lambda to get the value from the database.
value = await loadCache().ConfigureAwait(false);
if (value != null)
{
// Add the value to the cache.
await SetAsync(cache, key, value, expirationTimeInMinutes).ConfigureAwait(false);
}
}
return value;
}
}
请注意,GetAsync方法现在调用了CacheService类,而不是直接访问数据库。CacheService类首先尝试从AzureRedis缓存中获取。如果在Redis缓存中未找到,CacheService会回调传递给它的lambda。该lambda函数负责从数据库提取数据。该实现将数据存储与缓存的解决方案分离,并将CacheService与数据库分离。
注意事项
如果缓存不可用(可能是暂时性故障造成的),不要向客户端返回错误。而应该从原始数据源提取数据。但是在恢复缓存时,原始数据存储可能忙于处理请求,导致超时和连接失败。(毕竟这是首先使用缓存的动机之一。)可使用断路器(https://docs.microsoft.com/en-us/azure/architecture/patterns/circuit-breaker)等技术来避免数据源瘫痪。
缓存非静态数据的应用程序应支持最终一致性。
对于WebAPI,可通过在请求和响应消息中包含Cache-Control标头并使用Etag标识对象版本来支持客户端缓存。有关详细信息,请参阅API实现。(https://docs.microsoft.com/en-us/azure/architecture/best-practices/api-implementation#optimizing-client-side-data-access)
不需要缓存整个实体。如果某实体的大部分内容是静态的,但只有一小部分经常更改,对静态元素做缓存,并直接从数据源检索动态元素。这样有助于减少对数据源执行的I/O数量。
在某些情况下,如果可变数据的生存期较短,将它缓存可能是一个选择。例如,假设某设备持续发送状态更新。一种做法可能是在对其做缓存,而完全不用存数据库。
为了防止数据过时,许多缓存解决方案支持可配置的失效期,以便在达到指定的间隔后,从缓存中自动删除数据。可根据具体场景对过期时间做优化。静态数据在缓存中的保留时间可以长于会很快过时的可变性数据。
如果缓存解决方案未内置过期策略,可能需要实现一个后台进程,定时扫描缓存,防止它不受限制地扩大。
缓存数据除了来自外部数据源以外,还可以使用缓存来保存复杂计算的结果。但是,在执行此操作之前,需要确定应用程序是否真正有CPU的使用约束。
在应用程序启动时准备好缓存可能很有帮助。在缓存中初始化最有可能会用到的数据。
始终提供检测机制来检测缓存命中数和缓存未命中数。使用此信息来优化缓存策略,例如,要缓存哪些数据,以及在数据过期之前要在缓存中保存数据多长时间。
如果缺少缓存造成了瓶颈,则添加缓存可能会大幅提高并发请求,导致Web前端过载。客户端可能会收到HTTP-503(服务不可用)错误。这些问题表明需要对前端进行横向扩展。
如何检测问题
可以执行以下步骤来鉴别是否是缺少缓存导致了性能问题:
检查应用程序设计。盘点应用程序使用的数据存储机制。对于每个数据存储,确定应用程序是否使用了缓存。在可能的情况下,确定数据的更改频率。适合缓存的初始候选项包括不经常更改的数据,以及频繁读取的静态数据。
检测应用程序并实时监视系统,找出应用程序检索数据或计算信息的频率。
在测试环境中分析应用程序,捕获有关数据访问或其他经常执行的与计算关联的开销度量指标。
在测试环境中执行负载测试,识别系统在承受正常工作负荷和重度负载的情况下如何响应。 负载测试应使用真实工作负荷来模拟在生产环境中观察到的数据访问情形。
检查底层数据存储的数据访问统计信息,并检查相同数据请求的重复频率。
诊断示例
以下部分将这些步骤应用到前面所述的示例应用程序。
检测应用程序并实时监控系统
应用程序已部署到生产环境,检测并监视应用程序,以获取有关指定用户请求的信息。
下图显示了在负载测试期间NewRelic(http://newrelic.com/azure)捕获的监视数据。在本例中,执行唯一的GET操作是Person/GetAsync。但在生产环境中,了解每个请求的执行频率可以用于深入分析应该缓存哪些资源。
如果需要更深入的分析,可以在测试环境(而不是生产环境)中使用探查器捕获底层性能数据。 查看I/O请求速率、内存使用率和 CPU利用率等指标。这些指标可以反应出向数据存储或服务发出的大量请求,或者相同计算的重复执行。
对应用程序进行负载测试
下图显示了对示例应用程序执行负载测试的结果。该负载测试模拟一个包含800个用户的阶跃负载,这些用户执行一系列操作。
每秒成功执行的测试数达到平稳状态,因此,接下来的请求速度会减慢。平均测试时间随着工作负荷的增大而平稳增加。达到用户负载峰值后,响应时间趋向平稳。
检查数据访问统计信息
数据存储能够提供有用的数据访问统计信息和其他信息,例如,哪些查询最频繁。在微软SQLServer中,sys.dm_exec_query_stats管理视图就提供了最近执行的查询统计信息。sys.dm_exec-query_plan视图中能提供每个查询的文本。可使用SQL Server Management Studio等工具来运行以下SQL查询,以确定查询的执行频率。
SELECT UseCounts, Text, Query_Plan
FROM sys.dm_exec_cached_plans
CROSS APPLY sys.dm_exec_sql_text(plan_handle)
CROSS APPLY sys.dm_exec_query_plan(plan_handle)
结果中的UseCount列指示每个查询的运行频率。下图显示第三个查询已运行250,000次以上,远远超过其他任何查询。
下面是导致发送过多数据库请求的查询:
(@p__linq__0 int)SELECT TOP (2)
[Extent1].[BusinessEntityId] AS [BusinessEntityId],
[Extent1].[FirstName] AS [FirstName],
[Extent1].[LastName] AS [LastName]
FROM [Person].[Person] AS [Extent1]
WHERE [Extent1].[BusinessEntityId] = @p__linq__0
这是EF在前面所示的GetByIdAsync方法中生成的查询。
解决方案
合并缓存后,重复负载测试,并将结果与前面未使用缓存执行的负载测试进行比较。下图是将缓存添加到示例应用程序后的负载测试结果。
成功测试数量仍保持平稳状态,但用户负载更高。在承受负载时,请求速率明显高于前面的测试结果。平均测试时间仍然随着负载的增大而增加,但最大响应时间为0.05毫秒,而前面的测试中为1毫秒—有了20倍左右的改善。
相关资源
API实现的最佳实践
https://docs.microsoft.com/en-us/azure/architecture/best-practices/api-implementation#optimizing-client-side-data-access
缓存预留模式(https://docs.microsoft.com/en-us/azure/architecture/patterns/cache-aside)缓存的最佳实践(https://docs.microsoft.com/en-us/azure/architecture/best-practices/caching)
断路器模式(https://docs.microsoft.com/en-us/azure/architecture/patterns/circuit-breaker)