内存镜像
2011年8月31日
当人们开始开发企业应用程序时,最早的问题之一就是“我们如何与数据库交互”。如今,他们可能会问一个稍微不同的问题:“我们应该使用哪种数据库——关系型数据库还是这些 NoSQL 数据库?”但还有另一个问题需要考虑:“我们是否应该使用数据库?”
企业应用程序的一个决定性特征是需要存储长期数据,这自然会导致人们求助于数据库。毕竟,持久化数据是数据库的主要功能之一。使用内存镜像是一种不需要数据库的持久化数据的不同途径。
内存镜像的关键要素是使用事件溯源,这实质上意味着应用程序状态的每次更改都会被捕获到一个事件中,该事件会被记录到持久存储中。此外,这意味着您可以通过重放这些事件来重建完整的应用程序状态。然后,事件就成为主要的持久化机制。
使用事件溯源的一个常见示例是版本控制系统。每次更改都被捕获为一次提交,您可以通过将提交重放到空目录中来重建代码库的当前状态。当然,在实践中,重放所有事件的速度太慢,因此系统会定期持久化应用程序状态的快照。然后,重建过程包括加载最新的快照并重放自该快照以来的任何事件。
事件溯源有很多好处,包括能够重建过去的状态。但对于内存镜像来说,重要的特性是它意味着不再需要担心将应用程序状态保存在最新的持久存储中。相反,您可以将应用程序状态保存在内存中。如果进程崩溃,您可以从事件(和快照)中重建它。
使用内存镜像可以获得高性能,因为所有操作都在内存中完成,无需进行 IO 或对数据库系统的远程调用。也许更重要的是,这意味着您可以摆脱数据库映射代码,或者不必担心内存状态和数据库状态之间的同步。
但您必须确保能够可靠地存储和处理事件。您还需要编写代码来保存和加载快照,并找出如何足够快地恢复系统以保持服务质量。
另一个相当明显的限制是,您必须拥有比需要保存在内存中的数据更多的内存。随着内存大小的稳步增长,这已不再像过去那样成为一个限制。[1]
许多不同类型的系统都可以使用内存镜像,我将提到我遇到过的三个例子。
最近的一个例子是LMAX。LMAX 是一个高性能交易系统,它在单个 JVM 线程上每秒处理 600 万笔交易。在这里,内存镜像的性能优势显然是一个重要因素,但他们发现编程模型的简化同样重要。他们不必担心并发性,因为它只涉及一个线程。为了保持高可用性,他们运行内存镜像的多个副本,因此如果一个副本出现故障,他们可以切换到另一个实例,同时保持非常高的交易速率。
几年前,我写过几篇关于使用事件发布者架构的系统的文章。这种风格为许多用于分析目的的 UI 提供对内存模型的读取访问。多个 UI 意味着多个线程,但只有一个写入器(事件处理器),这极大地简化了并发问题。
最古老的例子也是这个名称的来源——Smalltalk 开发环境。大多数开发工具都依赖于文件系统中的文本文件,这些文件在需要时会被编译或解释。Smalltalk 将其所有源代码和编译后的方法都保存在镜像中[2]。您执行的每个命令都存储在更改日志中。大多数情况下,您会保存镜像(快照),但如果您做了一些愚蠢的操作,则可以在必要时从稳定的基础重放更改日志。
像许多这类想法一样,这种方法已经被使用和改造了很多次[3],但从未成为主流。使用数据库来保存持久性数据仍然是更常见的方法。
我听说过内存镜像的一个问题是迁移。在构建软件系统时,了解它将如何处理更改非常重要。对于内存镜像,基本任务是确保您可以继续从事件日志中重建内存镜像。
这里的一个陷阱是,如果要更改事件的结构,请使用一个不能优雅地处理演变的序列化结构来处理事件日志。如果您创建特定的事件类并对其进行序列化,那么如果您以后更改事件类的结构,这可能会使处理旧事件变得困难。通常最好使用通用数据结构(如映射和列表)进行序列化。
此外,在事件和模型结构本身之间保持良好的解耦也很重要。您可能会想出一个自动映射系统,对事件数据和模型进行回顾,但这会将事件和模型耦合在一起,从而难以迁移模型并仍然处理旧事件。
在某些时候,将事件日志本身从旧格式迁移到新格式可能是值得的。迁移事件日志通常比较麻烦,但如果您已经从原始事件结构演变了很多,那么这可能是一种选择。
长期以来,反对使用内存镜像的一个重要论据是大小,但现在大多数商用服务器的内存都比我们过去在磁盘上使用的内存多。因此,现在大多数工作集都可以安全地保存在内存中。几年前我们就注意到了这一点,但内存镜像仍然相对少见。我认为,既然 NoSQL 运动正在促使人们重新思考持久化的选择,我们可能会看到这种模式的兴起。