65.9K
CodeProject 正在变化。 阅读更多。
Home

导致应用程序崩溃的十种缓存错误

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.84/5 (96投票s)

2010年10月3日

CPOL

11分钟阅读

viewsIcon

327963

缓存大型对象、重复对象、缓存集合、实时对象、线程不安全缓存以及其他常见错误会破坏你的应用程序,而不是让它飞速运转。了解开发人员常犯的十个缓存错误。

引言

缓存频繁使用的、从源获取成本高昂的对象,可以在高负载下提高应用程序的性能。它有助于应用程序在并发请求下进行扩展。但是,一些难以察觉的错误会导致应用程序在高负载下受苦,更不用说提高性能了,尤其是在使用分布式缓存时,分布式缓存有独立的缓存服务器或缓存应用程序来存储项。此外,在使用内存缓存时运行正常的代码,在缓存移至进程外时可能会失败。在这里,我将向你展示一些常见的分布式缓存错误,这些错误将帮助你在何时缓存以及何时不缓存方面做出更好的决定。

以下是我见过的十大错误

  1. 依赖 .NET 的默认序列化程序
  2. 将大型对象存储在单个缓存项中
  3. 使用缓存来共享线程之间的对象
  4. 假设项在存储后会立即出现在缓存中
  5. 存储带有嵌套对象的整个集合
  6. 一起存储父子对象,也分开存储
  7. 缓存配置设置
  8. 缓存具有打开的流、文件、注册表或网络句柄的实时对象
  9. 使用多个键存储相同的项
  10. 更新或删除持久存储上的项后,未更新或删除缓存中的项

让我们看看它们是什么以及如何避免它们。

我假设你已经使用 ASP.NET Cache 或 Enterprise Library Cache 一段时间了,并且很满意,现在你需要更高的可扩展性,因此你已经迁移到像 Velocity 或 Memcache 这样的进程外或分布式缓存。之后,事情开始分崩离析,因此下面列出的常见错误适用于你。

依赖 .NET 的默认序列化程序

当你使用像 Velocity 或 memcached 这样的进程外缓存解决方案时,缓存中的项存储在与你的应用程序运行过程不同的独立进程中;每次将项添加到缓存时,它会将该项序列化为字节数组,然后将字节数组发送到缓存服务器进行存储。类似地,当你从缓存中获取项时,缓存服务器会将字节数组发送回你的应用程序,然后客户端库会将字节数组反序列化为目标对象。现在 .NET 的默认序列化程序并非最优,因为它依赖于 CPU 密集型的反射。因此,将项存储到缓存和从缓存中获取项会产生很高的序列化和反序列化开销,从而导致高 CPU 使用率,尤其是在缓存复杂类型时。这种高 CPU 使用率发生在你的应用程序上,而不是在缓存服务器上。因此,你应该始终使用 本文所示的 更好的方法之一,以最大限度地减少序列化和反序列化中的 CPU 消耗。我个人偏好自己序列化和反序列化属性的方法,通过实现 ISerializable 接口,然后实现反序列化构造函数。

[Serializable]
    public class Customer : ISerializable
    {
        public string FirstName;
        public string LastName;
        public int Salary;
        public DateTime DateOfBirth;

        public Customer()
        {
        }

        public Customer(SerializationInfo info, StreamingContext context)
        {
            FirstName = info.GetString("FirstName");
            LastName = info.GetString("LastName");
            Salary = info.GetInt32("Salary");
            DateOfBirth = info.GetDateTime("DateOfBirth");
        }

        #region ISerializable Members

        public void GetObjectData(SerializationInfo info, StreamingContext context)
        {
            info.AddValue("FirstName", FirstName);
            info.AddValue("LastName", LastName);
            info.AddValue("Salary", Salary);
            info.AddValue("DateOfBirth", DateOfBirth);
        }

        #endregion        
    }

这可以防止格式化程序使用反射。当你处理大型对象时,使用这种方法的性能提升有时比默认实现要好 100 倍。因此,我强烈建议,至少对于被缓存的对象,你应该始终实现自己的序列化和反序列化代码,而不是让 .NET 使用反射来弄清楚要序列化什么。

将大型对象存储在单个缓存项中

有时我们认为大型对象应该被缓存,因为它们从源获取成本太高。例如,你可能认为缓存 1 MB 的对象图可以比从文件或数据库加载该对象图获得更好的性能。你会惊讶于它的不可扩展性。当一次只有一个请求时,它肯定比从数据库加载相同的东西要快得多。但在并发负载下,频繁访问该大型对象图会耗尽服务器的 CPU。这是因为缓存具有很高的序列化和反序列化开销。每次尝试从进程外缓存中获取 1 MB 的对象图时,它都会消耗大量 CPU 来在内存中构建该对象图。

var largeObjectGraph = myCache.Get("LargeObjectGraph");
var anItem = 
    largeObjectGraph.FirstLevel.SecondLevel.ThirdLevel.FourthLevel.TheItemWeNeed;

解决方案不是使用单个键将大型对象图作为单个项存储在缓存中。相反,你应该将该大型对象图分解成更小的项,然后单独缓存这些更小的项。你应该只从缓存中检索你需要的最小项。

// store smaller parts in cache as individual item
var largeObjectGraph = new VeryLargeObjectGraph();
myCache.Add("LargeObjectGraph.FirstLevel.SecondLevel.ThirdLevel", 
  largeObjectGraph.FirstLevel.SecondLevel.ThirdLevel);
...
...
// get the smaller parts from cache
var thirdLevel = myCache.Get("LargeObjectGraph.FirstLevel.SecondLevel.ThirdLevel");
var anItem = thirdLevel.FourthLevel.TheItemWeNeed;

这个想法是查看你最常需要的大对象中的项(例如配置对象图中的连接字符串),并将这些项单独存储在缓存中。始终牢记,你从缓存中检索的项总是很小的,比如说最多 8 KB。

使用缓存来共享线程之间的对象

由于你可以从多个线程访问缓存,有时你会使用它来方便地在多个线程之间传递数据。但是缓存,就像 static 变量一样,可能会出现竞态条件。当缓存是分布式的时,这种情况更加普遍,因为存储和读取项需要进程外通信,你的线程比内存缓存有更多的机会重叠。下面的示例显示了内存缓存如何很少演示竞态条件,而进程外缓存几乎总是显示它。

myCache["SomeItem"] = 0;

var thread1 = new Thread(new ThreadStart(() =>
{
    var item = myCache["SomeItem"]; // Most likely 0
    item ++;
    myCache["SomeItem"] = item;
});
var thread2 = new Thread(new ThreadStart(() =>
{
    var item = myCache["SomeItem"]; // Most likely 1
    item ++;
    myCache["SomeItem"] = item;
});
var thread3 = new Thread(new ThreadStart(() =>
{
    var item = myCache["SomeItem"];  // Most likely 2
    item ++;
    myCache["SomeItem"] = item;
});

thread1.Start();
thread2.Start();
thread3.Start();
.
.
.

上面的代码大多数时候演示了使用内存缓存时最可能发生的行为。但是当你转到进程外或分布式缓存时,它总是无法演示最可能发生的行为。你需要在这里实现某种锁定。一些缓存提供商允许你锁定项。例如,Velocity 具有锁定功能,但 memcache 没有。在 Velocity 中,你可以锁定一个项。

// get an item and lock it
DataCacheLockHandle handle;
SomeClass someItem = _defaultCache.GetAndLock("SomeItem", 
   TimeSpan.FromSeconds(1), out handle, true) as SomeClass;
// update an item
someItem.FirstName = "Version2";
// put it back and get the new version
DataCacheItemVersion version2 = _defaultCache.PutAndUnlock("SomeItem", 
    someItem, handle);

你可以使用锁定来可靠地读取和写入被多个线程更改的缓存项。

假设项在存储后会立即出现在缓存中

有时你在提交按钮单击时将一个项存储在缓存中,并假设在页面回发时,可以从缓存中读取该项,因为它刚刚被存储在缓存中。你错了。

private void SomeButton_Clicked(object sender, EventArgs e)
{
  myCache["SomeItem"] = someItem;
}

private void OnPreRender()
{
  var someItem = myCache["SomeItem"]; // It's gone dude!
  Render(someItem);
}

你永远不能确定项肯定会在缓存中。即使你在第 1 行存储项,并在第 3 行读取它。当你的应用程序处于压力之下且物理内存稀缺时,缓存将冲掉不常用的项。因此,当代码到达第 3 行时,缓存可能已被冲掉。永远不要假设你总是可以从缓存中取回一个项。始终进行 null 检查并从持久存储中检索。

var someItem = myCache["SomeItem"] as SomeClass ?? GetFromSource();

读取缓存项时,你应该始终使用此格式。

存储带有嵌套对象的整个集合

有时你会将整个集合存储在单个缓存项中,因为你需要频繁访问集合中的项。因此,每次尝试从集合中读取一个项时,你都必须先加载集合,然后读取那个特定的项。类似这样。

var products = myCache.Get("Products");
var product = products[1];

这是低效的。你只是为了读取某个特定项而不必要地加载整个集合。当缓存是内存缓存时,你不会有问题,因为缓存只会存储对集合的引用。但在分布式缓存中,每次访问整个集合时都需要反序列化整个集合,这会导致性能下降。与其缓存整个集合,不如单独缓存各个项。

// store individual items in cache
foreach (Product product in products)
  myCache.Add("Product." + product.Index, product);
...
...
// read the individual item from cache
var product = myCache.Get("Product.0");

这个想法很简单,你可以使用一个容易猜到的键来单独存储集合中的每个项,例如使用索引作为填充。

一起存储父子对象,也分开存储

有时你会将一个具有子对象的对象存储在缓存中,而你也单独将该子对象存储在另一个缓存项中。例如,假设你有一个 customer 对象,其中包含一个订单集合。因此,当你缓存 customer 时,订单集合也会被缓存。但是然后你单独缓存各个订单。因此,当缓存中的单个订单更新时,包含相同订单的 customer 对象中的订单集合不会更新,从而导致不一致的结果。这在内存缓存中可以正常工作,但在缓存移至进程外或分布式缓存时会失败。

var customer = SomeCustomer();
var recentOrders = SomeOrders();
customer.Orders = GetCustomerOrders();
myCache.Add("RecentOrders", recentOrders);
myCache.Add("Customer", customer);
...
...
var recentOrders = myCahce.Get("RecentOrders");
var order = recentOrders["ORDER10001"];
order.Status = CANCELLED; 
...
...
...
var customer = myCache.Get("Customer");
var order = customer.Orders["ORDER10001"];
order.Status = PROCESSING; // Inconsistent. The order has already been cancelled

这是一个棘手的问题。它需要巧妙的设计,以免最终在缓存中存储同一个对象两次。一种常见的方法是不在缓存中存储子对象,而是存储子对象的键,以便可以单独从缓存中检索它们。因此,在上述场景中,你不会将 customer 的订单集合存储在缓存中。相反,你会将 OrderID 集合与 Customer 一起存储,然后当你需要查看 customer 的订单时,你会尝试使用 OrderID 加载单独的订单对象。

var recentOrders = SomeOrders();
foreach (Order order in recentOrders)
   myCache.Add("Order." + order.ID, order);
...
var customer = SomeCustomer();
customer.OrderKeys = GetCustomerOrders(); // Store keys only
myCache.Add("Customer", customer);
...
...
var order = myCache.Get["Order.10001"];
order.Status = CANCELLED; 
...
...
...
var customer = myCache.Get("Customer");
var customerOrders = customer.OrderKeys.ConvertAll<string, Order>
   (key => myCache.Get("Order." + key));
var order = customerOrders["10001"]; // Correct object from cache

这种方法确保了实体的一个特定实例在缓存中只存储一次,无论它在集合或父对象中出现多少次。

缓存配置设置

有时你会缓存配置设置。你使用一些缓存过期逻辑来确保配置定期刷新,或在配置文件或数据库表更改时刷新。由于配置设置被访问得非常频繁,从缓存中读取它们会增加显著的 CPU 开销。相反,你应该只使用 static 变量来存储配置。

var connectionString = myCache.Get("Configuration.ConnectionString");

你不应该遵循这种方法。从缓存中获取项并不便宜。它可能不像从文件或注册表中读取那么昂贵。但它也不是非常便宜,特别是当该项是一个自定义类,增加了序列化开销时。因此,你应该将配置设置存储在 static 变量中。但是你可能会问,在配置存储在 static 变量中时,如何刷新配置而不重启应用程序域?你可以使用一些过期逻辑,如文件监听器,在配置文件更改时重新加载配置,或者使用一些数据库轮询来检查数据库更新。

缓存具有打开的流、文件、注册表或网络句柄的实时对象

我见过开发人员缓存持有打开的文件、注册表或外部网络连接的类实例。这是危险的。当项从缓存中移除时,它们不会自动处置。除非你处置了这样的类,否则你会泄漏系统资源。每次这样的类实例因过期或其他原因从缓存中移除而未被处置时,它都会泄漏其持有的资源。

你不应该缓存持有打开的流、文件句柄、注册表句柄或网络连接的对象,仅仅因为你想节省每次需要它们时打开资源。相反,你应该使用一些 static 变量或使用一些保证给你过期回调的内存缓存,以便你可以正确地处置它们。进程外缓存或会话存储不能一致地提供过期回调。所以,永远不要在那里存储实时对象。

使用多个键存储相同的项

有时你会使用键和索引来存储对象在缓存中,因为你不仅需要通过键检索项,还需要通过索引迭代项。例如。

var someItem = new SomeClass();
myCache["SomeKey"] = someItem;
.
.
myCache["SomeItem." + index] = someItem;
.
.

如果你使用的是内存缓存,下面的代码会正常工作。

var someItem = myCache["SomeKey"];
someItem.SomeProperty = "Hello";
.
.
.
var someItem = myCache["SomeItem." + index];
var hello = someItem.SomeProperty; // Returns Hello, fine, when In-memory cache
/* But fails when out of process cache */

上面的代码在使用内存缓存时有效。缓存中的两个项都指向同一个对象实例。因此,无论你如何从缓存中获取项,它总是返回同一个对象实例。但在进程外缓存中,尤其是在分布式缓存中,项在序列化后存储。项不是通过引用存储的。因此,你存储的是项的副本,你永远不会存储项本身。所以,如果你通过键检索一个项,你得到的是该项的全新副本,因为每次从缓存中获取它时,该项都会被反序列化并重新创建。结果是,对对象的更改永远不会反映回缓存,除非你在进行更改后覆盖缓存中的项。因此,在分布式缓存中,你必须这样做。

var someItem = myCache["SomeKey"];
someItem.SomeProperty = "Hello";
myCache["SomeKey"] = someItem; // Update cache
myCache["SomeItem." + index] = someItem; // Update all other entries
.
.
.
var someItem = myCache["SomeItem." + index];
var hello = someItem.SomeProperty; // Now it works in out-of-process cache

一旦你使用修改后的项更新了缓存条目,它就会起作用,因为缓存中的项接收了该项的新副本。

当项在数据源中更新或删除时,未更新或删除缓存中的对象

这在内存缓存中有效,但在进程外/分布式缓存中会失败。这是一个例子。

var someItem = myCache["SomeItem"];
someItem.SomeProperty = "Hello Changed";
database.Update(someItem);
.
.
.
var someItem = myCache["SomeItem"];
Console.WriteLine(someItem.SomeProperty); // "Hello Changed"? Nope.

这在内存缓存中可以正常工作,但在进程外或分布式缓存中会失败。原因是你更改了对象,但从未用最新对象更新缓存。缓存中的项存储为副本,而不是原始实例。

另一个错误是,当项从数据库中删除时,没有从缓存中删除项。

var someItem = myCache["SomeItem"];
database.Delete(someItem);
.
.
.
var someItem = myCache["SomeItem"];
Console.WriteLine(someItem.SomeProperty); // Works fine. Oops!

当你从数据库、文件或某个持久存储中删除一个项时,不要忘记从缓存中删除该项,所有可能存储在缓存中的方式。

结论

缓存需要仔细规划和对要缓存的数据有清晰的理解。否则,当缓存被做成分布式时,它不仅性能下降,而且还会导致代码失败。在缓存时牢记这些常见错误将有助于你从代码中获益。

© . All rights reserved.