最近接到个任务,一直在思考最有效率的方法是怎么样的。
问题描述:
首先从App.Config或者Web.Config(第一个xml文件)中取得一个key-value对,value表明了另一个xml文件的路径,然后根据value把该xml(第二个xml文件)加载进来做缓存。在实际应用中,第二个xml文件大多也都是一组key-value对,暂且不需要考虑更复杂的情形(深度更深的树形结构或者某种自定义结构)。然后这个缓存可能只存在60s(60s刷新一次)。而且xml文件里的内容可能会非常频繁的被使用。
最后这个缓存的客户端目前需要的只是XElement类型。
问题规模的假设(高峰时段):
假设有50个用户同时使用这个产品,对应一个应用程序,每个用户每分钟需要读取缓存内容21次。同时显然这种频度的访问下,缓存每分钟都需要及时刷新。
也就是每分钟需要读取该缓存1050次,构造1次。
key-value对中key的长度假设为10个UTF-8字符的字符串,也就是占用10byte。
然后假设第一个xml文件共有3个key-value对,对应的每个第二个xml也有3个key-value对。每个key-value被访问的概率都相同。为了能被9整除把读取操作设为1080次。
解题思路:
这里考察2个思路,
第一个思路是比较直接的思路,每个文件对应存储为一个XElement,然后用第一个xml(config)中的key作为键,存入一个Dictionary<string, XElement>类型的缓存中去。
优点:容易理解,容易应对变化,构造速度快
缺点:整个儿缓存的结构成为一个深度为2的森林,对于许多频繁使用的值都需要一个深度为2的查找过程。第一个查找过程为hash,第二个则为顺序查找,因此当第二个xml文件的规模扩大的时候,消耗会大量增加。(尤其是如果第二个xml文件也用<add key="" value="">这种形式的话,单从ElementName无法判断,需要取Attribute的值,用linq,消耗更加大,但是本实验不采用这种形式),
然后第二个思路,是为了减少深度的,由于大部分查找都是深度为2的查找,因此可以考虑将深度为2的查找简化为1,具体做法是用第一个xml文件的key和第二个xml文件的key的两个hash值拼凑为一个大hash值,然后缓存从而减少访问深度。
最开始考虑把这两个key连接为一个字符串作为键,但是这样的话就要设定一个分割字符(串),会比较丑陋。最后决定用hash值的方法,由于hash值是一个UINT32,所以可以用一个UINT64结构完美的装下2个Hash值。而一个UINT64作为键,在运算上空间和时间复杂度都好于两个长度为10的字串的连接。
优点:减少了查找深度,读取速度上应该好于方法1。
缺点:两个不同字符串的hash值仍然有冲突的可能,构造速度差于方法1。
分析:
从略,这么简单的小问题,就没必要计算了。
代码:
方法1的构造操作
foreach (string key in keys)
{
string filePath = System.Configuration.ConfigurationManager.AppSettings[key];
cache.Add(key, XDocument.Load(filePath).Element("root"));
}
方法1的读取操作
for (int i = 0; i < 120; i++)
foreach (string key in keys)
{
string str1 = cache[key].Element("testtest01").Attribute("value1").Value;
string str2 = cache[key].Element("testtest02").Attribute("value1").Value;
string str3 = cache[key].Element("testtest03").Attribute("value1").Value;
}
方法2的构造操作
foreach (string key in keys)
{
string filePath = System.Configuration.ConfigurationManager.AppSettings[key];
cache.Add((UInt64)key.GetHashCode() << 32 | (UInt32)"testtest01".GetHashCode(),
XDocument.Load(filePath).Element("root").Element("testtest01"));
cache.Add((UInt64)key.GetHashCode() << 32 | (UInt32)"testtest02".GetHashCode(),
XDocument.Load(filePath).Element("root").Element("testtest02"));
cache.Add((UInt64)key.GetHashCode() << 32 | (UInt32)"testtest03".GetHashCode(),
XDocument.Load(filePath).Element("root").Element("testtest03"));
}
方法2的读取操作:
for (int i = 0; i < 120; i++)
foreach (string key in keys)
{
string str1 =
cache[(UInt64)key.GetHashCode() << 32 | (UInt32)"testtest01".GetHashCode()].Attribute("value1").Value;
string str2 =
cache[(UInt64)key.GetHashCode() << 32 | (UInt32)"testtest02".GetHashCode()].Attribute("value1").Value;
string str3 =
cache[(UInt64)key.GetHashCode() << 32 | (UInt32)"testtest03".GetHashCode()].Attribute("value1").Value;
}
实验结果:
(从上到下分别是方法1执行2回(每回10000次)所花时间,与方法2执行2回(每回10000次)所花时间)

然后把读取的循环改为1200次(10倍),再次测量需要的时间

结论:的确方法2可以简化读取时间,不过在这个规模下其构造过程反而成为瓶颈。
唉,C#中你可控的优化真是很少。
在看公司代码的时候发现这么一个东西,就是遍历表达式树,然后把调用Where方法的(MethodCallExpression)表达式下面的条件记录在一个Collection里面。具体细节还没有看的太仔细,但是它大体上是这样干的:
用一个Stack<bool>,记录当前状态,在遍历开始Push(false),然后遍历表达式树的时候,如果该节点是调用Where方法的表达式,则Push(true),其他则Push(false),访问完毕一个表达式以后Pop()。在需要判断的时候用Peek()来判断。
我写了一个大概的模拟代码如下:(如果是直接在Where方法下面的表达式,则放入一个collection)
首先模拟的表达式是一个string数组:
string[] expressionTree = new string[] { "A", "B", "C", "D", "WHERE", "Condition1", "Condition1A", "Condition1B", "Condition1C" };
然后是模拟的遍历部分:(index为模拟表达式树的数组的当前索引)
static List<string> collection; static Stack<bool> stack; static int index; static string[] expressionTree; static void test1Main(string[] iexpressionTree) { index = 0; collection = new List<string>(); stack = new Stack<bool>(); stack.Push(false); expressionTree = iexpressionTree; test1(expressionTree[index]); } static void test1(string expression) { if (stack.Peek()) collection.Add(expression); switch (expression) { case "WHERE": stack.Push(true); //DOSomeThing if (++index == expressionTree.Length) break; else test1(expressionTree[++index]); stack.Pop(); break; default: stack.Push(false); if (++index == expressionTree.Length) break; else test1(expressionTree[++index]); stack.Pop(); break; } }
程序最后记录的条件应该是“Condition1”。
那么有没有比这个更加有效率的解决方案呢,暂时想到了2个。
第一个由于这个要处理的表达式中至多有一次Where方法的调用,我可以用一个int来做这个事情,最开始将int设置为1,如果当前遍历的是Where方法的调用节点,则将int设置为0,否则在对应Push的部分把这个int加1,再对应Pop的部分把这个int-1。如果当前该int的值为0则认为当前是在Where方法下面的条件。在只有一个Where的时候,这个方法可以正常运转。
但是这样做显然与原来堆栈的做法分歧较大,可能产生扩展上的不方便。于是又想到了另外一个模拟堆栈的方法,就是用一个uint(姑且称为stackuint),初始设置为0,然后设置一个uint的flag为0x00000001 ,如果当前是Where,则通过stackuint与flag取或把最低位设置为1
stackbyte |= flag
然后对应以前Push(false)的部分把该uint左移一位,对应Pop的部分把该uint右移1位。
在判断的时候用
(stackuint&flag) != 0
如果不为0,则认为当前语境是在Where条件下。这个方法与Stack的方法基本上是等效的,只是要求遍历
表达式的深度不能超过UInt的位数。
而这个条件应该是可以满足的(刨除某些变态的情况)。
最后附上三种方法执行1000万次所花费时间的测试结果:
