在对象图上创建校验和

20

这个问题与此问题相关,但我认为应该单独提问。

我有一个复杂的对象实例图。现在我想直接在内存中对该对象图创建一个校验和,以检测自上次保存对象图时是否对其进行了更改。校验和计算应快速且不应消耗太多内存。

据我了解,最好的解决方案可能是在对象图的二进制序列化形式上生成加密密钥(如果我理解错误,请纠正我)。但这引发了一些问题:

  1. 我应该如何序列化这个对象?它必须快速且不会消耗太多内存。而且,如果我使用.NET默认序列化,我真的可以确信如果实际数据相同,创建的二进制流始终相同吗?
  2. 那么还有什么替代方法来序列化,不需要花费太长时间去实现吗?

更新:

你觉得这个方法怎么样:

  1. 遍历整个图并对其中的每个对象使用算法(但排除代表图中节点的引用类型成员)创建一个标准的int哈希码。将每个哈希码添加到整数列表中。
  2. 将整数列表转换为字节数组
  3. 对字节数组创建散列,使用MD5、CRC或类似方法

提到的GetHashCode算法应该能够快速计算出一个相当安全的哈希码,只考虑其基本成员的单个对象。基于此,字节数组也应该是对象图的一个相当安全的表示,并且MD5/CRC哈希值也是如此。


一个不会“消耗太多内存”的校验和并不能保证检测到是否已经进行了更改。如果你可以接受一些非常罕见的假阴性(即相同的校验和但对象图实际上是不同的),那么这可能是可以的。 - Justin
如果您将这些问题分开提问,可能会更容易得到解答。 - Pat
@Justin:是的,校验和不会有影响,但是将大型对象图序列化为二进制流会有影响。 - bitbonk
@Justin,你的RAM和CPU没有随机错误也不能保证。对于任何像sha-1这样的良好校验和,计算机产生随机错误的概率比碰撞的概率更大。 - CodesInChaos
对于哈希本身,CRC32是最快的,但据我所知也是最不安全的。如果性能很重要,我会坚持使用它。否则,哈希本身不需要太多内存。MD5只需要不到1 kB的内存就可以很好地运行--它同样适用于流。在维基百科上阅读MD5设计以了解为什么,从高层次来看它真的很简单。 - Andrei Sosnin
显示剩余4条评论
5个回答

9
与其使用二进制序列化,你可以使用http://code.google.com/p/protobuf-net/,然后计算它的加密哈希值。据说protobuf比二进制序列化更紧凑(例如,参见http://code.google.com/p/protobuf-net/wiki/Performance)。
我想补充说明的是,考虑到你实际上不需要序列化,最好使用反射并“浏览”对象来计算哈希值(就像各种序列化器“遍历”对象一样)。例如,请参见Using reflection in C# to get properties of a nested object 经过深思熟虑,并听取@Jon的意见,我可以告诉你,我的“次要”想法(使用反射)非常非常困难,除非你愿意花一周时间编写一个对象解析器。是的,这是可行的……但在计算哈希之前,你会给数据什么表示?明确一点:
two strings
"A"
"B"

显然,"A","B" != "AB",""。但是MD5("A")与MD5("B")相结合等于MD5("AB")与MD5("")相结合。可能最好的方法是在前面添加长度(因此使用Pascal / BSTR表示法)。
而null值呢?它们有什么“序列化”值?另一个困难问题。显然,如果将字符串序列化为长度+字符串(以解决前一个问题),则可以将null简单地序列化为“null”(没有长度)...那对象呢?您会在前面加上对象类型ID吗?这肯定更好。否则,可变长度对象可能会像字符串一样造成混乱。
使用BinaryFormatter(甚至是protobuf-net)时,您不必真正将序列化的对象保存在某个地方,因为它们都支持流式处理...以下是示例。
public class Hasher : Stream
{
    protected readonly HashAlgorithm HashAlgorithm;

    protected Hasher(HashAlgorithm hash)
    {
        HashAlgorithm = hash;
    }

    public static byte[] GetHash(object obj, HashAlgorithm hash)
    {
        var hasher = new Hasher(hash);

        if (obj != null)
        {
            var bf = new BinaryFormatter();
            bf.Serialize(hasher, obj);
        }
        else
        {
            hasher.Flush();
        }

        return hasher.HashAlgorithm.Hash;
    }

    public override bool CanRead
    {
        get { throw new NotImplementedException(); }
    }

    public override bool CanSeek
    {
        get { throw new NotImplementedException(); }
    }

    public override bool CanWrite
    {
        get { return true; }
    }

    public override void Flush()
    {
        HashAlgorithm.TransformFinalBlock(new byte[0], 0, 0);
    }

    public override long Length
    {
        get { throw new NotImplementedException(); }
    }

    public override long Position
    {
        get
        {
            throw new NotImplementedException();
        }
        set
        {
            throw new NotImplementedException();
        }
    }

    public override int Read(byte[] buffer, int offset, int count)
    {
        throw new NotImplementedException();
    }

    public override long Seek(long offset, SeekOrigin origin)
    {
        throw new NotImplementedException();
    }

    public override void SetLength(long value)
    {
        throw new NotImplementedException();
    }

    public override void Write(byte[] buffer, int offset, int count)
    {
        HashAlgorithm.TransformBlock(buffer, offset, count, buffer, offset);
    }
}

static void Main(string[] args)
{
    var list = new List<int>(100000000);

    for (int i = 0; i < list.Capacity; i++)
    {
        list.Add(0);
    }

    Stopwatch sw = Stopwatch.StartNew();
    var hash = Hasher.GetHash(list, new MD5CryptoServiceProvider());
    sw.Stop();
    Console.WriteLine(sw.ElapsedMilliseconds);
}

我定义了一个Hasher类,该类接收对象的序列化数据(逐个部分)并以“流模式”计算哈希值。内存使用为O(1)。时间复杂度显然为O(n)(其中n是序列化对象的“大小”)。
如果您想使用protobuf(但请注意,对于复杂对象,它需要使用其属性标记(或WCF属性等)),
public static byte[] GetHash<T>(T obj, HashAlgorithm hash)
{
    var hasher = new Hasher(hash);

    if (obj != null)
    {
        ProtoBuf.Serializer.Serialize(hasher, obj);
        hasher.Flush();
    }
    else
    {
        hasher.Flush();
    }

    return hasher.HashAlgorithm.Hash;
}

唯一的“大”区别在于protobuf不会“Flush”流,所以我们必须这样做,并且它确实希望根对象是有类型的,而不是简单的“对象”。
哦...关于你的问题:
如何序列化对象?它必须快速,不消耗太多内存。同时它必须可靠地总是以相同的方式序列化。如果我使用.NET默认序列化,我真的能确定如果实际数据相同,创建的二进制流总是相同的吗?我怀疑。
List<int> l1 = new List<int>();

byte[] bytes1, bytes2;

using (MemoryStream ms = new MemoryStream())
{
    new BinaryFormatter().Serialize(ms, l1);
    bytes1 = ms.ToArray();
}

l1.Add(0);
l1.RemoveAt(0);

using (MemoryStream ms = new MemoryStream())
{
    new BinaryFormatter().Serialize(ms, l1);
    bytes2 = ms.ToArray();
}

Debug.Assert(bytes1.Length == bytes2.Length);

假设这样说:调试断言会失败。这是因为List“保存”一些内部状态(例如版本),这使得二进制序列化和比较变得非常困难。最好使用“可编程”序列化器(如proto-buf)。您告诉它要序列化哪些属性/字段,它就将它们序列化。
那么有没有一种不需要花费太长时间实现的替代序列化方式呢?
Proto-buf...或DataContractSerializer(但速度相对较慢)。正如您所想象的那样,数据序列化并不存在万能的解决方案。

2
反射虽然不算“快速”。 - Jon
+1,我本来也想建议类似的东西,但你的解决方案比我想象中的更好...不过,通过只返回false并抛出NotSupportedException而不是NotImplementedException来实现CanRead/CanSeek属性也无妨。 - Thomas Levesque
@xanatos:我可以确保A)和B)得到满足,因为我将亲自实现它(对于每个对象)。我理解@JohnSkeet建议不要使用此算法来计算复杂对象图的哈希码,而我不会这样做:我只会为对象本身(仅包含简单属性而没有引用)使用此算法。这应该是安全的。强大的@JohnSkeet还说:“它很快,并创建了一个相当不错的哈希,不太可能导致冲突”。 - bitbonk
@bitbonk 要计算生日悖论的概率,您可以使用以下代码:double duplicate = 1 - (Math.Exp(-Math.Pow(items, 2.0) / (2.0 * hashSpace))); 它是0-1之间的值(其中1表示100%)。Hashspace是哈希可以拥有的值的数量。Items是您同时计算哈希的项目数(仅考虑相同类型的项目)。 (摘自http://sites.google.com/site/craigandera/craigs-stuff/odds-ends/the-birthday-problem-calculator)您要尝试哈希的图形有多大? - xanatos
@bitbonk,2.0部分相当重要,你知道吗?你应该明确指出它。你应该在你的问题中加上标签.net-2.0。这样可以减少很多事情。 - xanatos
显示剩余16条评论

7
你对这种方法怎么看:
- 遍历整个图,并对图中的每个对象创建一个标准的int哈希码,使用以下算法(但要排除表示图中节点的引用类型成员)。 - 将每个哈希码添加到一个整数列表中。 - 将整数列表转换为字节数组。 - 使用MD5、CRC或类似的算法在字节数组上创建一个哈希。
这种方法的思路是非常接近最佳方法的,但还需要一些优化。
哈希:
考虑到您更关注速度而不是精度,对于每个项使用int大小的哈希码留有足够的空间以避免冲突,因此选择哈希码算法是正确的。排除参与图中的节点的引用类型意味着我们正在扔掉一些信息;请参见下文了解更多信息。
改进节点哈希:
不考虑与我们正在哈希的节点相连的其他节点的想法是正确的,但也许我们可以比简单地扔掉所有这些信息做得更好?我们不希望考虑其他节点的哈希码(它们也将被哈希),但我们正在丢弃由图形边缘提供的信息:具有内部数据X并连接到N个其他节点的节点的哈希码不应该与具有数据X并连接到M个其他节点的节点相同。
如果您有一种便宜的方式可以考虑边缘数据的一部分,请使用它。例如,如果图形是定向的,则可以将从它到其他节点的边数添加到为每个节点计算的哈希码中。
汇总哈希码:
创建哈希码列表将是在将哈希码求和到一个long(非常快速且保留了一些附加信息)与对图形中的项目进行总排序的哈希码列表之间的折衷方法。如果您预计图中有大量项,则求和可能更合适(我会先尝试它,并查看它是否足够无冲突);如果图形没有太多项(如<1000),则我会首先尝试总顺序方法。在创建列表时要记得为列表分配足够的内存(或者直接使用数组);您已经知道其最终长度,因此这是一个免费的速度增加。
生成固定大小的哈希:
如果您将哈希码总和成原始值,则根本不需要此步骤。否则,将列表作为byte[]哈希是我认为最好的选择。由于散列字节所需的时间比创建列表少得多,因此您可能希望使用较大的哈希函数而不会产生实际的性能影响。
提高最终哈希质量:
在获取此“最终”哈希值后,我会将哈希图中的项目数作为固定大小的十六进制编码字符串添加到其前面或后面,因为:
- 它可能有助于减少冲突(具体取决于图形的性质) - 我们已经知道图中的项目数量(我们只是对它们进行了哈希),因此这是一个O(1)操作
定义总排序
如果未严格定义图中项目的处理顺序,则会出现错误否定的情况:两个应该哈希成相同值的图形却没有,因为尽管它们在逻辑上等效,但哈希函数的实现选择以不同的顺序处理每个项目哈希。如果您使用列表,则会出现此问题,因为添加是可传递的,因此“添加到long方法”不受其影响。
为了解决这个问题,您需要按明确定义的顺序处理图中的节点。这可能是易于从节点的数据结构(例如树上的先序遍历)和/或其他信息(例如每个节点的类名或节点类型,如果存在节点ID等)生成的顺序。
由于预处理图以产生总排序需要一些时间,因此您可能需要权衡一下与我上面提到的错误否定结果所产生的成本。此外,如果图形足够大,则由于节点哈希码求和方法更适合您的需求,因此此讨论可能无关紧要。

如果您认为这个答案不够好,我很乐意接受批评。 - Jon

4
以下是我使用的方法:

1. 使用ServiceStack的TypeSerializer进行序列化

这将对象序列化为JSV格式,我会模糊地描述它为“带有更少引号的JSON”,因此它更小,并且据作者称比JSON序列化快约5倍。与BinaryFormatter和Protobuff(本来应该是我的首选)相比的主要优点是,您不必去注释或描述要序列化的所有类型。我就是那么懒,而且这对于任何poco都有效。

2. 计算MD5哈希值

对于我来说,这是一种“足够好”的方法,可以满足性能和冲突特性的要求。如果我想改进它,我可能会选择MurmurHash3,它具有与MD5类似的冲突特性,但速度更快。它不适用于加密目的,但听起来这里并不需要这个功能。我之所以选择MD5,是因为它在BCL中已经预先安装了,并且对于我的目的来说足够快。

以下是整个扩展方法的内容:

using System.Text;
using System.Security.Cryptography;
using ServiceStack.Text;

public static byte[] GenerateHash(this object obj) {
    var s = TypeSerializer.SerializeToString(obj);
    return MD5.Create().ComputeHash(Encoding.UTF8.GetBytes(s));
}

我使用的对象比较小(通常不超过几百个字符序列化),并且从未遇到过冲突问题。结果可能因人而异。


将对象序列化为字符串?这意味着我可以创建一个 JSON 字符串并从中获取 MD5 哈希值。是这样吗? - IAbstract

2
我认为您想要的是为对象生成一个规范顺序,将对象按照该顺序排序,然后计算该排序后的对象的哈希值。
一种方法是定义对象之间的关系,如果对象不包含相同的内容,则该关系始终为“<”或“>”(在这种情况下,根据关系,对象“==”),[注意:这不考虑来自具有相同内容的对象的弧可能允许您将它们区分为“<”或“>”;如果这对您很重要,请在弧上定义一个规范顺序]。现在,枚举图中的所有对象,并按此关系排序。按排序顺序处理对象,并组合它们的哈希值。
我预计这将运行得非常快,肯定比涉及序列化的任何解决方案都要快得多,因为它不会从值生成巨大的文本(甚至二进制)字符串。

对我来说,这似乎非常接近我在更新问题时所做的事情。现在的问题是,我即将使用的单个对象的简单哈希码算法是否足够,并且我如何组合这些哈希值。我只需将所有哈希值写入BinaryWriter,然后在该流上创建MD5(或类似)吗? - bitbonk
几乎无关紧要,如果你试图创建一个校验和。在这种情况下,你所关心的只是大多数时间它可以轻松地检测到变化;在这种情况下,我可能会简单地添加64位校验和并忽略溢出。 - Ira Baxter
请注意,您的更新接近了关键的排序思想,但却没有涉及到。如果您不这样做,另一个相同(同构)的结构在内存中以不同的方式布局(例如,在不同的执行期间),将产生不同的哈希值,我认为这不是您想要的结果。如果您愿意接受“只是”哈希,并使用对顺序不敏感的哈希组合方案(“添加哈希”是其中之一),那么您可以不进行排序,否则就不行。 - Ira Baxter
你如何使用异构对象来实现这个? - xanatos
@xanatos:嗯,苹果是否比梨更好味道上真的是因人而异的 :-} 重要的是你做出决定后要保持一致。 - Ira Baxter
显示剩余3条评论

0

正如Ira Baxter所指出的那样,您想要以特定的规范顺序重新排列(排序)图形中的对象。然后,您可以逐步计算哈希值并将它们缩减(如“map-reduce”)为单个哈希值。

作为一种性能技巧,有时候尝试一直保持图形的这种方式也是不错的--有时候比在更新事务之后再对其进行排序更容易保持集合排序。

这就是您可以使用的一种技巧,以最小化内存和CPU使用率。您需要分析对象和图形变化的频率以及您想知道对象图是否发生了更改的频率。

正如我在您的问题评论中提到的那样,MD5和类似的哈希算法不会占用太多内存--每个实例少于1千字节。您只需要保留512字节的数据块进行哈希处理。

如果你很幸运,你的对象和图形将会经常改变(即许多对象连续更改状态),但你只想偶尔知道这一点(即在整个图形更新事务结束后才知道)。在这种情况下,你只想在事务结束后计算哈希值。或者仅在需要时计算哈希值(即当你推送更新事件或从单独的线程中轮询它以进行更改时)。在这种情况下,为了节省内存,你需要向MD5/SHAxxx哈希计算对象提供数据块流,保持尽可能少的中间值。这样,你的内存使用量将是恒定的,并且与图形大小无关(即O(1))。

现在,如果你更幸运,你的对象几乎不会改变,但你想立即知道它们是否发生了变化,例如通过引发每次更改的事件。在这种情况下,你需要修改对象来计算哈希值或仅仅检查它们是否实际发生了变化。在对象的每个属性设置器中推送“已更改”事件。对于图形的更改也是如此。这将使你完全避免计算哈希值(在某些情况下可以获得巨大的性能提升)。

如果您的对象很少更改,并且您也很少需要检查它们是否更改(包括在过程中某处使用了序列化/反序列化的情况),那么第一种方法仍然是最好的。

通常,尝试为经常更改的图形中的复杂对象计算哈希值以立即了解每个更改发生的情况(以对每个更改采取行动)是不可取的。在这种情况下,您需要使用事件(.NET最佳)或回调来进行某种变更信号方法。


按需排序3000个对象并不昂贵,特别是考虑到只有在需要校验和时才会执行此操作,而我希望这种情况并不常见。 - Ira Baxter
@Ira Baxter在那张图中没有提到大约3000个项目,所以我不会那么确定。你怎么知道Bitbonk指的是什么样的图表?例如,它可能是一个大洲详细道路地图或一个非常大的社交网络连接图。或者我错过了评论中的这个细节? - Andrei Sosnin
有的。请参考xanatos答案下的bitbonk评论。 - Ira Baxter

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接