BizTalk 中缓存的考虑因素






4.60/5 (4投票s)
涵盖 BizTalk 缓存中的常见挑战和陷阱。
引言
本文探讨了 BizTalk Server 编排中缓存的优缺点、影响和常见陷阱。这个主题在其他一些有趣的文manbetx 登录 章和博客中已经讨论过(Richard Seroter 和 Saravana Kumar),但其中存在一些尚未探讨且需要解决的线程安全问题。如果您之前在 BizTalk 中实现过缓存,只想阅读要点,可以直接跳转到“线程安全”部分。
缓存
缓存是一种有用的编程模式,原则上,它不过是将静态或大部分静态数据存储在内存中。我们或多或少都熟悉 .NET 应用程序中的缓存概念,尤其是 ASP.NET Web 应用程序。缓存是一种常见的方法,其中
- 缓存的数据很少更改
- 从数据存储检索数据非常耗时,因为
- 数据存储上的负载增加会引起担忧
- 往返数据存储会限制网络带宽并增加延迟
- 检索数据非常耗时
- 需要非常高的性能和低延迟
通过消除应用程序每次需要静态数据时都访问数据存储的需要,可以大大提高性能。如果数据位于需要调用外部服务(数据库、Web 服务等)的存储中,还可以节省往返消息框的开销,这意味着性能会更好。通过不创建新对象,性能也会略有提升,因此垃圾收集器触发的次数会减少。通过减少 BizTalk 运行编排所花费的时间,吞吐量也将得到提高。
不用说,缓存虽然在某种程度上与存储上下文/会话数据相似,但它的不同之处在于,与上下文/会话数据存储不同,它处理的是应用程序范围的静态数据。然而,方法非常相似,并且存储上下文的唯一问题是实现缓存大小的限制,以便缓存只存储最近使用的上下文数据。
通常使用单例(优于普通静态)对象来包含缓存数据。通常采用延迟加载模式来加载数据,其中数据在首次请求时被缓存,而不是在应用程序启动的第一个时刻。如果数据是静态的,则数据会一直加载直到应用程序关闭。如果数据可能更改,则需要考虑缓存刷新策略,但这超出了本文的范围。这有时可能会为逻辑注入相当大的复杂性,并可能抵消缓存的好处。
在这里,我们只考虑纯静态数据的缓存。
BizTalk 和内存
BizTalk 在设计时考虑了大量细节和小心,使其内存效率高,甚至可以说内存非常精简。内存不足,这是早期 BizTalk 版本的一个常见“特性”,在最近的 BizTalk 版本(2006 和 R2)中几乎不会发生。“平面内存占用”意味着当消息在 BizTalk 服务器上传输时,它在内存消耗方面几乎是透明的。
那些曾尝试创建自定义管道组件的人可能已经注意到 Microsoft SDK 示例中在处理内存和流方面极为小心。FixMsg 示例处理需要向消息添加前缀和后缀的场景。典型的实现可能是将整个消息数据读入新流,同时分别添加前缀和后缀。然而,Microsoft 示例的实现极度小心地处理底层流,并实现了一个新的流,该流仅在请求时读取底层流。话虽如此,我见过的大多数自定义管道都没有像上面描述的那样经济地处理内存;非常普遍的情况是,所有数据都被读入内存,然后进行操作;诚然,这有时是不可避免的。
现在,考虑到 BizTalk 对内存如此严格,难道不应该通过不实现缓存而每次都访问数据存储来节省内存吗?毕竟,正是内存我们需要如此小心(Yossi Dahan 在这里有类似的担忧)?
我的回答是我们打算使用多少内存?如果只有几百兆,可能不是个好主意。几兆字节?肯定可以缓存!归根结底,如果您的服务器上有大量未使用的内存,您可以将其一小部分用于提高应用程序的吞吐量。
现在,我们如何计算缓存对象的内存需求?嗯,正如你们大多数人所知,Int32
和 float
是 4 字节,double
和 DateTime
是 8 字节,char
实际上是 2 字节(而不是 1 字节),等等。字符串有点复杂,2*(n+1),其中 n 是字符串的长度。对于对象,它将取决于其成员:只需将所有成员的内存需求相加,记住所有对象引用只是 32 位盒上的 4 字节指针。现在,这实际上并非完全准确,我们还没有考虑到堆上每个对象的开销。我不确定您是否需要担心这一点,但我认为,如果您使用大量小对象,您将不得不考虑开销。每个堆对象的价格与其原始类型相同,再加上 4 字节的对象引用(在 32 位机器上,尽管 BizTalk 在 64 位机器上运行时也是 32 位),再加上 4 字节的类型对象指针,我认为还有 4 字节的同步块索引。为什么这个额外的开销很重要?嗯,假设我们有一个类,它有两个 Int32
成员;在这种情况下,内存需求是 16 字节而不是 8 字节。
如果您缓存原始类型(如 Int32
、DateTime
(继承自 ValueType)等),请始终使用泛型 List 和 Dictionary,而不是 Hashtable
或 ArrayList
。原因是后者是为了处理堆对象而设计的,在这种情况下,每次访问都会产生装箱和拆箱的开销,以及最终的垃圾回收开销。泛型列表和字典针对原始类型进行了优化,并且必须尽可能使用。
BizTalk 和 AppDomain
在缓存中,我们使用静态对象以便共享数据。重要的是要记住,静态对象仅在同一 AppDomain 中加载的应用程序之间共享。有多少个 AppDomain 加载在 BizTalk 主机中?Richard 的精彩文章涵盖了这个问题,我不会在这里重复所有内容,但我将描述一些确认这一点的测试。
- 一旦 BizTalk 主机启动,就会创建一个默认的 AppDomain。
- 所有用于处理发送和接收端口的代码都将在该 AppDomain 中运行。这包括任何自定义管道组件和映射——您可以在其中实现和使用任何缓存。
- 编排在专用的 XLANG/s AppDomain 中运行,该 AppDomain 在第一个编排运行后立即创建。这包括从您的编排调用的任何映射。 此 AppDomain 将由该主机中运行的所有编排和应用程序共享。
我创建了一个小型 BizTalk 应用程序,它
- 使用文件接收端口/位置接收消息以触发编排。
- 在表达式形状中递增静态计数器并输出到 Trace。
- 在转换形状中使用 Map1,该形状在映射中递增相同的静态计数器。
- 最后,使用发送端口发送消息。此端口使用 Map2 转换输出并调用我们的静态计数器。
- 我们可以在两种不同的场景中尝试此测试:一种场景是发送端口和编排运行在同一个主机实例中,另一种场景是它们运行在不同的主机实例中。
编译、部署和设置测试 BizTalk 项目后,关闭除运行测试编排的主机实例之外的所有主机实例。打开 *perfmon* 并从 .NET CLR Loading 添加 Total AppDomains。
选择您拥有的所有 *BTSNTSvc.exe* 进程并添加计数器。在这里,我们将设置系统为发送和编排使用相同的主机实例。这是您将在 *perfmon* 中看到的结果(仅刚刚启动主机实例,刻度为 10)。
因此,一个 AppDomain 由端点管理器服务默认加载和使用。第二个 AppDomain 是为运行编排(XLANG/s 服务)创建的,这是上面提到的文章中解释的同一个 AppDomain。这是 DebugView 的输出。
请注意,所有跟踪输出的 procId 都是相同的,但由发送端口内的映射触发的代码在默认 AppDomain 中运行,而所有其他代码在 XLANG/s AppDomain 中运行。
现在,让我们分离编排和发送主机。然后,以同样的方式,我们打开 *perfmon* 并选择相同的计数器,但这次,我们需要选择两个主机。
这是我们 *perfmon* 的输出(刻度为 10)。
如果您继续加载更多编排,即使是不同应用程序的编排,这些数字也不会改变。因此,基本上,这表明我们可以使用静态对象在不同 BizTalk 应用程序的对象之间共享数据。发送或接收主机不会为执行发送或接收端口创建单独的专用 AppDomain。DebugView 的输出将如下所示(请注意 procId 的差异)。
现在,一个问题是如何多次将缓存加载到内存中?答案取决于应用程序的逻辑和物理设计。如果缓存用于在编排对象之间共享数据,则对于运行编排的任何主机实例,它都将加载一次。这发生在我们可以拥有每个主机一个以上实例的多服务器场景中。
BizTalk 和线程安全
这里涵盖的主题并非 BizTalk 特有,任何 C# 开发人员都可以从中受益。但是,如果您是 BizTalk 开发人员并且在编排中使用静态对象,您更有可能看到奇怪的线程错误弹出。多线程错误往往非常棘手,难以重现,并且非常常见,仅发生在生产环境中。重现线程问题的一个问题是它们发生在极端负载下以及大量消息同时到达时。这在开发环境中使用 BizTalk 编排实际上非常难以重现,因此我将描述一种方法来重现——相当容易——您在生产环境中遇到的相同线程问题,您也可以将其用于非 BizTalk 项目,并且测试本身不涉及任何 BizTalk 代码。
我还记得,几年前我在学习多线程时,我在想为什么每个人都认为多线程如此困难且容易出错。经过几年的 C# 多线程应用程序开发,我想我现在明白为什么会这样认为了。您几乎永远不会停止遇到新的惊喜。
回到我们的缓存,一个理想的缓存必须
- 实现延迟加载
- 使用锁定进行同步
- 不要过度使用锁定,即仅在加载缓存时使用锁定
我们将看到实现以上所有三项比乍一看要困难得多。现在,我将从 Saravana 的缓存类开始。此类是线程感知(我们也将看到它不是线程安全的),并在此处使用 lock
语句。
if (_authorIds == null)
{
_authorIds = new Dictionary<string,string>();
lock (_authorIds)
{
// load the cache
…
}
}
在上面的代码中,我们有四个主要步骤,我们将看到这四个步骤始终存在于任何缓存中:检查加载缓存的条件,创建缓存,锁定某个对象,最后加载缓存。上面的代码没有实现单例,但我认为这不是一个大问题。这里可能导致问题的是同步。
让我们想象缓存是空的,并且同时到达了三条消息。因此,主编排的三个实例被加载,每个实例都会尝试加载缓存,所有这些都在同一个 AppDomain 中,但每个都有自己的线程。所有这些线程同时到达条件检查,由于缓存是空的,条件为真,因此每个线程都会创建一个新的缓存实例。重要的是要注意,每个实例都会覆盖另一个实例的缓存,这是问题之一。在创建了三次缓存后,其中一个将能够锁定缓存,而另外两个将等待锁。在第一个缓存加载完成之后,锁被释放,然后第二个线程将锁定它并再次尝试加载缓存!猜猜怎么着?我们将收到一个异常,因为第二个线程将尝试加载相同的项目!
现在,我们如何创建这种理论上的场景?嗯,这并不难,我已经将其实现为一个单元测试。您需要的就是 NUnit。
[Test()]
public void SaravanaCacheTest()
{
AutoResetEvent[] resets = new AutoResetEvent[NUMBER_OF_THREADS];
for (int i = 0; i < NUMBER_OF_THREADS; i++)
{
Thread th = new Thread(new ParameterizedThreadStart(AccessSaravanaCache));
resets[i] = new AutoResetEvent(false);
th.Name = "Thread " + (i + 1).ToString();
th.Start(resets[i]);
// to create some delay similar to data arriving at different times
Thread.Sleep(_random.Next(0, 1));
}
WaitHandle.WaitAll(resets);
Assert.AreEqual(NUMBER_OF_THREADS, _successCount,
"There were some failures");
}
如您所见,我们创建了多个线程,然后使用 `WaitHandle` 等待所有线程完成。我们使用 `Thread.Sleep()` 创建偶尔的延迟,模拟生产环境。`AutoResetEvent` 的实例被传递给线程运行的方法,以便在线程工作完成后发出信号。最后,我们检查成功了多少次,如果失败了,测试就会失败。请记住,这不是典型的单元测试,因为典型的单元测试会产生一致的结果,但这个测试每次运行的结果都会不同。您可能需要运行几次才能看到测试失败。
在控制台窗口中,我们看到了我们所期望的确切错误。
好的,那么解决方案是什么?嗯,我们可以通过几种不同的方式解决这个问题。首先,我们可以在向缓存添加条目之前简单地添加一个额外的条件来检查它是否存在。更好的解决方案是添加一个条件来检查缓存中的项目数量,并且仅当缓存计数为 0 时才填充它。
但这并不能解决我们所有的问题。我实际上创建了一些其他的缓存类:`Cache1`、`Cache2`、`Cache25` 和 `Cache3`,以演示其他方法及其问题。这些缓存中的代码非常相似,结果却出奇地多样。每个都是一个简单的缓存类,它创建一个包含 3000 个数字的字典。我使用了一个专用的 Object
类型对象进行锁定,尽管我也可以使用字典来实现相同目的。有一些内置的延迟或控制台输出可以在 NUnit 的控制台窗口中看到。
using System;
using System.Collections.Generic;
using System.Text;
namespace BizTalkCaching
{
public class Cache1
{
private static Cache1 _instance = null;
private Dictionary<int,int> _numbers = new Dictionary<int,int>();
public Dictionary<int,int> Numbers
{
get { return _numbers; }
}
private Cache1()
{
}
public static Cache1 Instance
{
get
{
if (_instance == null)
{
_instance = new Cache1();
DataStoreSimulator store = new DataStoreSimulator();
store.LoadCache(_instance._numbers);
}
return Cache1._instance;
}
}
}
}
`Cache1` 完全没有实现任何锁定或同步。不用说,它在我们的单元测试中惨败。
using System;
using System.Collections.Generic;
using System.Text;
namespace BizTalkCaching
{
public class Cache2
{
private static Cache2 _instance = null;
private Dictionary<int,int> _numbers = new Dictionary<int,int>();
private static object _padlock = new object();
public Dictionary<int,int> Numbers
{
get { return _numbers; }
}
private Cache2()
{
}
public static Cache2 Instance
{
get
{
if (_instance == null)
{
_instance = new Cache2();
lock (_padlock)
{
DataStoreSimulator store = new DataStoreSimulator();
store.LoadCache(_instance._numbers);
}
}
return Cache2._instance;
}
}
}
}
`Cache2` 确实实现了锁定,但显示了与之前相同的问题。此外,有趣的是,每个线程实际上都能够调用单例并获取计数,而计数实际上不是我们期望的 3000,而是远小于这个数字。这可能是一个更严重的问题,因为它只是一个逻辑错误,不会导致应用程序崩溃。
using System;
using System.Collections.Generic;
using System.Text;
namespace BizTalkCaching
{
public class Cache25
{
private static Cache25 _instance = null;
private Dictionary<int,int> _numbers = new Dictionary<int,int>();
private static object _padlock = new object();
public Dictionary<int,int> Numbers
{
get { return _numbers; }
}
private Cache25()
{
DataStoreSimulator store = new DataStoreSimulator();
store.LoadCache(_numbers);
}
public static Cache25 Instance
{
get
{
if (_instance == null)
{
lock (_padlock)
{
_instance = new Cache25();
}
}
return Cache25._instance;
}
}
}
}
`Cache25` 是 `Cache2` 的改进,它在私有构造函数(真正的 Singleton 设计模式)中初始化缓存,但这效率低下,因为缓存由每个线程加载。
using System;
using System.Collections.Generic;
using System.Text;
namespace BizTalkCaching
{
public class Cache3
{
private static Cache3 _instance = null;
private Dictionary<int,int> _numbers =
new Dictionary<int,int>();
private static object _padlock = new object();
public Dictionary<int,int> Numbers
{
get { return _numbers; }
}
private Cache3()
{
DataStoreSimulator store = new DataStoreSimulator();
store.LoadCache(_numbers);
}
public static Cache3 Instance
{
get
{
lock (_padlock)
{
if (_instance == null)
{
_instance = new Cache3();
}
}
return _instance;
}
}
}
}
`Cache3` 是健壮的,不会导致任何逻辑错误或异常。但它效率低下,因为它每次访问缓存时都会实现锁定。
using System;
using System.Collections.Generic;
using System.Text;
namespace BizTalkCaching
{
public class TopCache
{
private static TopCache _instance = null;
private Dictionary<int,int> _numbers = new Dictionary<int,int>();
private static object _padlock = new object();
public Dictionary<int,int> Numbers
{
get { return _numbers; }
}
private TopCache()
{
DataStoreSimulator store = new DataStoreSimulator();
store.LoadCache(_numbers);
}
public static TopCache Instance
{
get
{
if (_instance == null)
{
lock (_padlock)
{
if (_instance == null)
_instance = new TopCache();
}
}
return _instance;
}
}
}
}
最后,我们得到了我称之为 `TopCache` 的理想缓存。它是真正的单例,实现了延迟加载,不获取不必要的锁定,并且健壮高效。诀窍是实现双重条件,一个在锁外部,一个在锁内部。记住,实例化缓存必须始终在 lock
语句内。
所以魔术子弹是双重条件!
结论
在本文中,我们涵盖了 BizTalk 缓存的各个方面。我们还回顾了缓存(包括 BizTalk 编排)的线程安全问题。