CMU-数据库导论-3数据库日志ACID的最后一块拼图是持久性。持久性代表回复外界时，数据就要保证不能丢失。恢复算法分为俩个部分，为了应对可能发生的故障，在事务运行期间，我们需要采取额外的措施。准备失败。另一个是当我们从故障恢复后，要弄清楚崩溃发生时的系统状态。处理失败。为了实现这一目标，我们需要俩个关键操作，undo和redo。undo是一出未完成的事务的更改或影响。因为系统自身终止系统崩溃，或者由于俩阶段锁定或者OCC机制限制无法提交。该事务被认为未提交，redo是对于已提交的事务确保在系统恢复后，保留其所有更改。我们需要确保每个已提交的事务的更改都已写入。我们如何进行undo和redo操作，将取决于缓冲池管理器能为我们提供什么支持。我们实际上需要对它进行一些更改，使其能够更智能地处理系统中的各个情况。我们可以进行俩种策略不窃取（No-Steal）：只有事务提交后，其脏页才能落盘。这要求缓冲区为每个活跃事务保留足够的空间，如果一个长事务修改了大量页面，可能会撑爆缓冲区。窃取（Steal）：即使事务未提交，其脏页也可以被刷写回磁盘。这解决了缓冲区空间被未提交事务占满的问题，允许缓冲池更灵活地换入换出页面。预写日志read-ahread logging将使用窃取策略。因为允许从尚未提交的事务中驱逐页面，缓冲池 管理器的替换策略（LRU-K）就能有更多可释放帧的选择。如果不适用窃取策略，那么一个事务修改的所有内容都需要保留在内存中。这意味着如果事务修改的页面超过了你可用的内存，那么就糟了，因为你无法逐出任何内容。另外一种策略：force（强制）：当一个事务已经提交时，在告知外界事务已经提交前，需要将所有的脏写更改写回并刷新到磁盘。no-force（非强制）：事务提交不需要将脏数据刷新到磁盘。即：Steal / No-Steal 解决的是：事务未提交时，它的脏数据能否被挤出内存写到磁盘？Force / No-Force 解决的是：事务提交时，它的脏数据是否必须强制写到磁盘？force会让提交速度便慢。影子分页假设 这里采用no-steal、force策略。如果我们有ABC三个数据在同一个页面上。比如我们T1读取A页面被加载到了缓存中，T2读取并修改了B。然后提交了事务。当事务提交，根据force策略，就必须将脏数据刷回磁盘。这里就产生了冲突问题，因为根据no-steal策略，我们不允许写出来自尚未提交的任何脏页或任何未提交的修改。因此解决这个问题的方法是，在缓冲区中复制这个页面，但是只包含自己已提交事务的更改。然后就可以把数据回写。这样一来当回到T1时，T1的回滚操作就会变得非常简单。因为实际上没有修改任何东西，任何修改的东西都没有被写入磁盘。很明显上述方案非常糟糕。如果更新10亿个页面呢？那么需要复制10亿个页面才能删除不需要的内容。高并发系统中有成千上万个事务同时运行，我需要跟踪所有事务修改过的页面。还需要制作这些页面的副本。因此没有任何系统用这种做法，这种做法实际上也会加速硬件老化，SSD的擦写次数是有限的。这就是shadow paging 影子分页。是一种no-steal，force策略 。它避免了每次都复制整个数据库。这是IBM在System R项目初期发明的。在影子分页中，不允许多个事务同时更新一个页面。因此，将数据刷新到磁盘的时候，就不需要剔除未提交事务的部分了。其中主版本用于存放已经提交的事务更改，影子副本用于存放未提交事务的更改。当事务提交时，我们会将所有脏页从影子表刷新到磁盘。然后用一个指针来指向当前的主页表。这基本上就是写时复制的工作原理。它在处理并发时表现不佳。影子分页还会导致数据不再连续存储。undo：移除影子页。保留主页面和数据库根指针不动。redo：不需要任何操作Sqlite（2010之后）sqlite有一种叫做回滚的机制。sqlite第一个版本的实现类似影子分页。（2010年后处于性能考虑，添加了WAL）sqlite最开始的做法是，页面在内存中，数据库在磁盘上，此外还有一个额外的文件叫做日志文件（journal file）。当事务想要修改一个页面时，你会从磁盘读取它，加载到缓存中，然后在你做任何更改之前，将更改复制到日志文件中。现在就可以在内存中进行修改了。此时更改page2，然后是page3。但是更改page2的时候崩溃了，page的一部分写入了磁盘，在写入page3之前崩溃了，此时你就会知道这个事务没有提交。在这里内存中的缓存区会被清除。在恢复时，首先查找日志文件。然后将这些页面复制回内存，因为你知道这些页面时事务开始之前的原始版本。然后直接将他们复制回磁盘，因为你知道任何事务运行并更改之前，这些页面是数据库的正确版本。sqlite使用但写线程，因此无需担心多个事务并发修改页面的问题。当事务提交时，会删除该文件将日志文件标记为不需要，因此恢复后，你不需要撤销已经提交的更改。写数据文件是顺序IO，但是更新的页面是分散在文件中的，需要通过随机IO来访问数据库。因此我们需要找到一个方法来实现顺序写入数据库。我们通过预写日志来实现这一点。WALwrite-ahead-log：Steal+no-force，数据未提交时可以被写入磁盘，提交时不强制将数据写入磁盘。这是当今几乎每个数据库都在用的方式。预写日志的具体存储内容，刷新时机，以及提交方式都可能存在差异。我们将维护一个日志文件。它会独立于数据库的堆文件，也就是存放数据的文件。日志文件中会包含最少的信息。你需要知道事务对数据库对象做了什么更改。以及更改发生前，对象是什么状态。你可以存储一些额外的东西，比如时间戳，校验和等。最基本信息包括，哪个事务进行了更改，更改了什么，更改前的值（用于撤销），以及更改后的值（用于重做）。我们要保证一旦告诉外部系统已经提交，就必须保证事务对数据库的所有更改都已经记录到磁盘上的日志中。并且，在缓冲池中的任何脏页写入磁盘之前，必须将使该页变脏的日志记录刷新到磁盘。Redo Log：记录“数据被改成了什么”，崩溃后用来前滚，保障事务提交后不丢失。Undo Log：记录“数据之前是什么”，出问题时用来回滚，同时实现多版本并发控制。我们的目标仍然是达到图的最佳象限，也就是右上象限。它的工作方式是存在日志缓冲区空间，它是在内存中分配的专用空间，用来暂存事务的变更以及这些变更对应修改的日志记录。我们知道这些日志记录的应用顺序。然后我们就能知道页面变更相对应的日志记录编号。当一个事务开始时，我们将在内存中的日志缓冲区中追加一个开始记录。大多数系统中，直到看到事务的第一个查询，才会存储开始记录。然后我们将对每个对象做的更改，都会有一个对应的日志条目或日志记录。为此我们需要存储以下四个信息进行更改的事务id事务修改的对象的ID用于撤销操作的before值（undo）用于重做操作的after值（redo）Postgres的早期并没有采用预写日志WAL，因为它们使用的是仅追加的多版本并发控制。他们的假设是，因为总是重建新的元组，如果有一种方法可以记录事务是否已提交，并将其写入日志，那么就不需要重新应用任何修改，因为始终在执行写实复制。因此，如果采用的是append-only的mvcc，可以省略undo操作，但是大部分系统并非如此。因为你始终永远元组的原始版本。当一个事务完成时，我们会将提交记录追加到日志中。然后在通知外部系统事务已经提交之前，将日志刷新到提交点。一旦数据持久化之后，为我们可以向外部应用程序确认事务已经提交。T1开始，调用begin，我们将begin写入WAL buffer，然后调用写入A，在修改页面之前，我们首先要做的就是添加一个日志条目。其中包含事务ID，被修改的对象，以及修改前后的值。然后我们继续修改页面。我们实际上会记录日志的编号。写B也是同样的，将缓冲区中附加日志条目，然后应用到页面。调用commit，附加到磁盘上的日志文件。然后告知外部事务已经提交。现在在这一刻，缓冲区中的脏页是否真正写入磁盘已经不重要了。如果我们现在崩溃，所有内存中的内容都会消失，但重放该事物的一切信息都存在于预写日志中。我们可以通过预写日志，重建被事务修改过的页面。这套机制的好处是，这些都是连续的IO。但是现在问题在与如果每次事务调用都刷新所有的内容，容易形成瓶颈。所以我们可以借鉴之前影子分页提到的批量处理方法，将一批已经提交的事务放在一起处理。它们都允许附加到日志，我们不必担心只有一个写入器线程，可以按照任何想要的形式追加。因为在这之上，还有传统的协议来决定谁可以在什么时间修改哪些数据。我们可以对WAL日志进行批处理，以便大约每5毫秒设置一个超时，如果你的事务是5毫秒窗口内的第一个，那么就必须等待5毫秒。所以现在需要维护俩个预写日志缓冲区，下图中在执行完写入C时，缓冲区已经满了。因此我们可以在写入D之前，将满的缓冲区写入磁盘，这个过程可以在后台进行异步写入。然后我们现在只需要告诉任何想要向另一个日志缓冲区添加新条目的事务，在另一个缓冲区写入磁盘的同时，填充这个缓冲区。我们需要返回之前的象限。采用steal和np-froce的WAL策略在运行时最快，但是在恢复时最慢。上述只是理论模型。实际上应该往日志里面放什么呢？物理日志记录：基本上是进行字节级别的差异比较，记录页面修改前后的内容。在指定文件内，页面内的确切物理位置的情况下，这是所做的更改。逻辑日志记录：不记录我对数据进行的底层字节级的修改，而是记录执行的语句，比如update，delete，insert。在崩溃恢复时，只需要重放查询，重新执行。生理（physiological）日志：介于前俩者之间，记录类似物理日志的记录，它会追踪事务对哪些页面进行了修改。但不会告诉你在页面上进行修改的具体偏移量。比如一个简单的事务。如果采用物理日志，对于更新的每个条目，需要记录页码以及在该页面上修改的偏移量。还需要记录对索引页面的修改。我们要确保索引也能保持同步。逻辑日志只是记录执行的确切的查询语句。假设所有信息都按顺序排列，实际上就是在重放事务在运行期间执行的查询轨迹。这通常只出现在内存数据库中，因为它们没有需要维护的表堆。更复杂的情况是查询中存在非确定性因素比如ramdom函数。生理日志记录是一种混合方法，它仍然在页面中记录所做的更改，但是只告诉你修改的页码或者索引页面。大部分系统会采用物理日志记录的变体，因为它在崩溃恢复时提供极大的灵活性，并且系统重启后，能更自由的选择如何重新应用这些变更。CDC我们可以将OLTP数据库以流的形式传输到数据仓库，并且在仓库中进行分析，这种过程被称为抽取转换和加载。关于预写日志，我们可以不仅用于恢复，还能用于复制。和变更传播从一个数据库到另一个数据库。这被称为change data capture，你可以在ETL的流程中使用它，很多不同的方法可以实现这一点。预写日志是最简单的方法，因为你已经有了生成预写日志进行恢复的机制。所以现在只需要将预写日志发送到另一个系统，该系统会应用这些变更，并将其数据库与原始数据库同步。有很多工具可以实现这一点，比如oracle goldengate，debezium。检查点但是缺点也很明显，预写日志将会永远增长。如果挂了，重启，恢复的时间可能会非常长。运行了十年挂掉之类的。为了避免这种情况，系统会进行定期的检查点机制，我们要把磁盘上所有的脏页都刷掉。在预写日志里价格提示，说明现在做了检查点，系统就不必从头扫描预写日志数据库了，因为我们已经把内容都刷新了。可以阶段日志，在某个时间点之后的内容就可以丢弃了，没必要保留全部历史记录。原始版本一种简单的检查点方案是阻塞式，或者持久化检查点。隔一段时间，就做一个检查点，暂停所有事务和查询，开始运行的查询就让他们跑完。然后把内存的日志记录都刷到磁盘上，把缓存池里所有脏页数据也刷到磁盘上，将检查点信息写入WAL文件，刷盘然后解除暂停。我们必须暂停事务的原因是为了避免开始刷新脏页，比如一个事务更新了俩个页面，我们不希望只完成了一个页面一半的更新，并将这个不完整的页写入磁盘，而另一个页面根本没写入。之后我们可以使用模糊检查点解决这个问题。它允许事务在你仍然运行 检查点操作时继续修改数据。假设下面崩溃了，上面做了一个检查点，我必须查看在检查点记录前后的时间，确定此时正在运行什么，做了什么更改，以及需要确保哪些事务的数据已安全写入磁盘。以及需要撤销哪些事务。在这里我们看到T1已经提交，进行检查点时，我们将所有脏页刷新到磁盘，因此不需要对T1做任何事情。但是T2和T3只有begin，T2的commit在检查点之后，我们知道外部事务系统已经提交了，因此我们需要保证T2的所有更改写入磁盘。而T3没有提交，因此所有关于T3的更改都需要被撤销。实际系统中，要考虑应该以多高的频率执行检查点操作？这会对性能产生影响。但是太久不进行检查点，那么崩溃的时候就要发生大量的日志重放。这实际上取决于你愿意为应用程序牺牲多少性能，以及你需要在崩溃后多块的速度进行恢复。通常五分钟一次是默认值。ARIES我们将如何使用预写日志，将数据库恢复到本来应该所处的状态。要使用的核心算法叫做aries，他在1990年被IBM为了DB2而开发出来。aries基本上定义了如何安全地实现数据库系统的可恢复性。是这方便的经典文献。文章有70页，很长。pgsql没有完全按照这种方法来实现，但从高层来看，实现十分接近。ARIES有三个关键点。ARIES依赖于WAL。当事务对页面进行提交时，我们创建日志条目，说明更改内容。然后当事务提交时，或者在允许数据系统刷新未提交事务脏页之前，我们必须刷新对应的日志条目。另一个关键想法是，在ARIES中，redo阶段总是会重复历史操作，意味着当你崩溃是必须非常小心的确定我要从预写日志的哪个位置开始回溯。然后要重放病重做所有日志条目，直到达到日志末尾。我们需要非常谨慎，不会去猜测某个更改是否已经刷新到磁盘。而且没有预写日志条目刷页面已经刷新到磁盘。所以预期猜测崩溃前页面是否刷新到磁盘，不如直接应用这些更改。在撤销已终止的事务时，我们会记录相关更改。我们将引入新的日志记录，就像正常执行时一样，当进行更新时，我们在其中放入一个日志条目。现在我们终止事务病回滚更改时，也会为这些回滚操作添加日志条目。这是为了确保万无一失，我们在WAL中尽可能记录详细的信息。日志序列号我们现在要做的一件事是，找到一种方法来记录日志条目中在日志中出现的顺序。我们需要一种单独识别每个日志记录的方法，而且还要跟踪给定事务的日志记录顺序。因此我们要添加这些日志的序列号，log sequence number（LSN），只是一个单调递增的计数器。之后我们会在数据库系统中使用这些日志序列号。来跟踪关心哪个序列号，从而确定哪些内容需要写入磁盘，以及接下来要在系统中修改的位置。现在每个数据库系统中的每个页面都需要扩展格式来包含LSN，pageLSN。pageLSN是指该页面最后一次更新或修改所对应的日志条目的最后一个日志序列号。这样就能知道这个日志条目在哪里了。是在内存中，还是在磁盘中。如果在产品中，那么我知道可以刷新该脏页，因为日志条目已写入磁盘。FlushedLSN 表示：数据库的日志已经可靠地刷到持久化存储的哪个位置。经过 fsync、fdatasync 等操作，数据库认为断电后仍能恢复所以说现在，当我们要决定，是否能把缓冲区池里的某个页面刷新到磁盘上时，可能是淘汰策略说要腾出空间，这个页面必须被移走，也可能是后台写入器说，要写入这个页面。现在你可以查看这个已刷新的LSN，然后判断，对于给定的页面，页面LSN是否小于等于已刷新的LSN。如果是，那就说明，日志已经刷新到了一个时间点，这个时间点晚于该页面条目，也就是该页面的修改。进行回复时，我们会用到recLSN：某个数据页自从变成脏页以来，最早可能尚未写入磁盘的那条日志的 LSN。比如第一次修改这个页面是什么时候lastLSN跟踪给定事务的最后一个记录，这样我就知道如何跳转到该事物在日志中的起始位置。MasterRecord：全局LSN，用于跟踪上一次做检查点的时间。每次进行检查点时，都是日志中的一个条目。它至少会成为一个起点，让我们跳转到日志中的相应位置。然后确定在做检查点时系统发现了什么。从而决定我们需要回溯多远。这是一个快捷方法，避免我们扫描大量的日志。在这里FlushLSN表示，从内存写入产品的日志中，最后一个条目的LSN是什么。主记录（MatserRecord）用来指示检查点的位置。也就是checkpoint的位置。在这个例子中，页面LSN指向磁盘上的记录12，也就是LSN12。那么我们可以刷新12吗？可以，因为日志条目12已经在磁盘上了（FlushedLSN>=pageLSN）。同样在缓冲吃管理器中，当它执行页面置换策略时，如果发现某个页面的LSN小于FlushedLSN它就知道不能刷新这个页面。在基础的WAL实现中不需要考虑这些，因为那时还没有日志序列号的概念。但是你需要进行额外的检查，一确保某个页面是否可以被置换出去。正常执行和终止正常执行当事务正常执行时，会对数据进行大量的写入和读取操作。我们不会将读取操作放到预写日志中，因为它不会进行更改。现在进行提交或回滚操作，我们必须决定，用什么协议，将什么放入预写日志中，来说明此事务已提交。后端系统需要做什么才能为给定的事务写出它需要的所有脏页。我们先简单假设所有的日志记录都能放进一个页面里。如果日志太大，需要跨越多个页面，那么通常会使用分段编号，这样子恢复时，就知道对于一个完整的日志记录，一个看到多少个分段的日志记录。如果你没有看到全部分段，就说明这个日志记录并没有完全写入磁盘。我们假设使用单版本事务，并且满足强严格2PL。这样我们就不用担心那些读取了其他数据的事务，以及如何回滚它们。使用steal+no-force的WAL。当事务提交，数据库写入commit记录到日志并且保证所有日志记录，在高速外部系统之前，这些事务的所有日志记录都已经被刷新到磁盘。现在我们要添加一种新的日志记录叫做（TXN-END）事务结束。这只是内部记账用的。我们可以用它来标记，当我们看到事务结束的日志记录时，就知道这个事务的所有记录已经处理完了。这样我们就可以把它从内部数据结构里删掉了。所以如果从旧到新扫描日志，一旦看到事务结束，就知道以后不会再看到这个事务的任何记录了。现在大部分行为依旧和之前一样，但是我们会维护内部元数据，比如该事务已经提交，并且修改了某些对象。类似在OCC中看到的，需要将rewrite set保留更久。但是在稍后的某个时候，一旦确认该事物彻底完成，我们就会添加事务结束记录。同样这只是内部记账，表明我们不需要关注该事务。commit表示事务的结果已经持久化，数据库即使崩溃，恢复后也必须保留它的更改。END表示数据库内部已经把这个事务清理完了。事务终止终止（abort）本质上是事务的一个特殊情况，相当于事务本身调用更新操作来撤销之前的更改。因此我们将为这些更改的回滚添加日志记录，并对其进行跟踪。这些日志对我们来说比较特殊，因为它们用于撤销过程，而不是最终要提交事务的正常执行过程。现在我们要添加一个字段叫做preventLSN，这主要是为了提高协议和算法的效率。这样子在查看日志时，前一个LLM会指向下一个日志序列号。即需要撤销的终止事务的下一个日志记录。所以可以把它想象成一个链表，当我们扫描并试图找出为该终止事务撤销的所有内容时，前一个LSN正在回溯时间。如果你按照某种顺序执行查询，那当你进行undo（撤销）时，你必须按相反的顺序重放它们。前一个LSN会告诉你需要跳转日志中的哪个位置，以确定下一个需要撤销的内容。你不需要这个来保证正确性，但是需要其来保证效率。现在可以看到我们有连续的日志序列号，对于第一个记录，前一个LSN为空，因为不存在前一个LSN。但之后每个记录都会跟踪前一个记录的LSN。例如，对于A的更新，前一个LSN是12，因为12是紧邻其前的LSN。运行多个事务时它们会相互交错，这样子需要撤销这些事务时，可以很容易跳转到对应的位置。我们必须确保撤销终止事务的所有更改，以避免系统崩溃并恢复后看到任何不完整的写入。为此我们将添加一种新的日志记录类型，称为补偿日志（Compensation log recordCLR）。这仅仅是一个普通更新操作的逆向操作，目的是恢复到之前的版本。这就是为什么在我们的日志记录中，同时记录了修改前和修改后的值。以便我们生成CLR来撤销已终止的事务时，我们知道先前的是什么。这就是为什么在日志记录中存储绝对值的原因。我们存储的是要恢复到先前版本的确切数值，而不是类似value=value+1反转是value=value-1这样的逻辑操作。与常规事务不同，常规事务中应用更新、提交完成后必须刷新这些日志记录。对于已经终止的事务，我们可以知道事务已终止的那一刻，直接通知它们终止即可。他不需要等待任何事情。当把CLR添加到WAL日志中时，数据库系统不需要立即刷新这些CLR来写入磁盘。但它们最终会被写入，就和其他内容一样。我们也将维护下一个LSN，同样是出于效率考虑。它指向我们需要反转的下一个LSN以便撤销操作。这里我们还有这个UndoNextLSN记录了我们需要撤销的下一个条目。它与之前的LSN不同，因为在本例中，之前的LSN指向11，那是终止条目。实际上我们需要知道的是，接下来需要撤销的具体条目是什么。接下来撤销条目会带我们回到事务起始的位置。preventLSN会带你到日志序列中的上一个条目，事务进行的更改。UndoNextLSN作用类似于指针，指向你需要反转的更改。现在我们一旦到达UndoNextLSN链的末尾，回到事务的起始位置，我们就知道已经撤销了该事物在正常运行时所做的更改。然后我们可以继续添加事务结束记录TXN-END。这意味着我们不会在日志中看到该事务的任何CLR或者其他日志记录。CLR永远不需要撤销。检查点优化先来查看俩种不友好的检查点。之前提到检查点的时候就stop the world。停止所有事务。稍微更好一点的办法是，暂停系统中正在运行的事务，创建一个脏快照，然后允许它们继续运行。因此即使它们有活跃的查询，我们也会停止查询，而不是等待它们完成。无论它们处于哪个阶段，我们都会执行检查点操作。完成后，它们就可以恢复运行了。但是问题随之而来，当一个事务执行一半被冻结，如果丢弃了所有undo日志，那么当一个事务要进行回滚的时候，就不知道应该如何进行操作了。因此我们需要维护额外的元数据，来跟踪哪些事务正在允许，以及我们开始以来哪些页面被修改过。现在我们会在日志中设置一个分界线，表明检查点何时开始，何时结束，然后我们就能知道在这期间可能发生了修改。这些额外的元数据会帮我们跟踪这些信息。Active Transaction table（ATT）Dirty Page Table（DPT）实际的事务表只是一个内部哈希表，我们会在系统中维护它。也就是说，对于每个当前正在进行的事务，它的事务ID是什么，当前状态，代码或系统模式是什么？ATT：正常执行情况下，除非我们看到终止提交，否则每个事务都处于运行状态。但是在恢复时，我们会假设每个事务都需要被终止。然后根据日志来判断是否应该将其状态设置为已提交或正在提交。因此我们将维护这个表，维护其中的条目和事务，直到收到该事务结束的消息，此时才可以删除。因为我们不知道不会再看到任何来自它的更改。因此如果需要回滚一个事务，我们可能会进行检查点时生成大量的CLR或者反向页面。我想知道该检查点期间系统发生了什么。DPT：脏页表记录了在检查点开始时，缓冲池中所有尚未刷新到磁盘的脏页。如何对于页表中的每一个脏页，我们都会记录一个日志记录的LSN，这个LSN对应于首次导致该页面变为脏页的日志记录。因此事务可能会读取记录，从而将一个页面引入缓冲池，但是一旦事务修改了该页面，我们就会开始跟踪LSN，以便我们知道在该检查点开始后，页面被修改的时间。我们在这里进行第一个检查点，假设页面11研究在之前被刷新因为它不在我们的脏页表中，并且我们已经知晓事务T2的存在，因此在档期啊能维护的检查记录中，我们假设这是检查点的发生时间，并再次记录这些元数据。因此我们知道事务T2，并且脏页表存在一个脏页22。然后在第二个检查点我们知道存在俩个事务，T2和T3。我T3在第一个检查点之后开始，而T2在第一个检查点之前就开始，然后我们有俩个脏页11和33。这为我们提供了额外的元数据，以便能解决之前的问题。但是在这个例子中，我们仍然暂停了事务的执行。这将查询正在运行并更新第一个页面的情况，然后我们进行检查点，脏页表记录了在执行检查点时所能看到的内容，因此我们就会知道这些日志记录可能修改了我们在检查点过程中遗漏的内容。但是我们仍然在进行此操作时暂停事务的执行。模糊检查点因此，模糊检查点通过在日志中添加一个条目来解决这个问题，这个条目记录了检查点开始的时间，记录脏页表的内容以及实际事务表的状态。然后让检查点运行，在检查点完成后，可以在日志中为检查点添加另一个条目。这是实际事务和脏页表塞进去的地方。CHECKPOINT-BEGIN:：代表了检查点的开始。CHECKPOINT-END: 包括了ATT和DPT可以根据检查点开始和结束之间的所有日志记录，来判断哪些内容可能被修改过了。因为日志里有一条记录说某个页面被修改并标记为脏，并不能保证它已经被写入磁盘。不过脏页表至少会告诉你之前哪些页面被修改过。这些修改是否已经被写入磁盘。现在你看到我们有LSN7和LSN10，这里有个事务T2，位于活动事务表中，然后在检查点开始前，T2修改了22页，因此我们在脏页表中有该条目。在其实是在提醒恢复协议，在检查点启动之前，有一个T2一直在做一些事情，把T2做的事情弄清楚，确保你应用了这些所有更改，如果它终止了，就撤销这些更改。脏页表显示，在启动检查点之前，有个脏页22，它本应该在检查点开始和检查点结束之间被写入磁盘。检查点将页面刷新到磁盘之后，它也可能在该检查点期间被另一个事务修改过。但是没有日志记录说这是页面被刷新的时间。总而言之这些元数据能够帮助我们追踪崩溃时系统的状态，从而重建系统，并了解当时发生了什么。成功写入检查点结束记录后，就可以更新主记录，表明如果在崩溃后需要恢复，应该从WAL中该检查点的位置开始。以此为起点，确定崩溃时或崩溃前系统发生了什么。ARIES在ARIES下，是一个三阶段协议。分析：根据主记录，回溯到预写日志的某个时间点，确定当时正在运行的事务，以及页面表中哪些是脏页，这些都依赖于检查点信息。redo（重做）：跳转到日志记录中的某个位置，然后重放所有后续的更改，即使是哪些最终会被终止的事务。undo（撤销）：将没有提交的事务，按照相反的顺序回溯，并撤销所有未完成事务的更改。过程：通过masterRecord，从最后的CHECKPOINT-BEGIN开始，向前扫描所有信息。接下来在重做阶段查看脏页表中最小的RECLSN值，那么在检查点开始时，首次修改脏页表中的某个页面的最早的日志记录是什么。跳到哪个时间点，然后扫描日志并应用所有变更。因此如果有事务被abort，可以使用CLR来补偿被abort的事务。然后遇到事务结束的标记，就表示该事务已经完成。在下图中，当开启CHECKPOINT-BEGIN后，没有begin，所以知道在日志中的某个地方，这个begin在checkpoint之上，并且可能正在做一些事情。所以这里要往ATT里添加一个条目，并将其状态设置为带撤销，然后T96修改了P33，所以要往DPT里添加一个条目，说明被修改的第33也，以及修改它的事务的最早LSN，也就是RECLSN。然后检查点结束，但是都没有看到任何关于T97的日志消息，这里还有脏页20，它在检查点开始之前被LSN8修改过。接着我们看到了96的提交消息，将其状态设置为已提交。然后看到了T96结束。我们可以把它从ATT中移除，因为之后就不需要考虑它们了。只有Checkpoint才会把ATT和DPT写入日志，而不是个条目都需要进行维护。现在我们要弄清楚哪些页被修改过了，在重做阶段，我们要跳回检查点之前的历史记录的地方，通常是在上一次检查点开始的地方之前。然后我们将扫描时间线并重放所有更改。即使我们知道这个事务会终止（我们可以在ATT中看到最终终止的候选事务），我们仍然要重放所有内容。因为我们想要把数据恢复到那一刻完全系统的状态，包括所有直到最后的过渡状态。当然，这里可以进行一系列优化，比如我们在同一个页面上看到一系列更改，我们能否将它们合并成一个更新（通常不这样）。这听起来非常浪费，但是为了不丢失任何数据。所以我们将在日志中前向扫描，查找脏页表中最早的RECLSN，在那个时间点，我们就知道存在一条最早的记录，修改了我们在分析阶段看到的页面。因此要重做一项操作，只需要重放相应的条目即可，无论来自常规事务还是CLR，它的工作方式始终相同。我们不需要维护额外的日志记录，因为我们已经有了WAL，它告诉我们应该做什么。在这个重做阶段，当我们扫描时，不需要再看到事务提交的消息时刷新页面，因为我们处于恢复阶段。因此当我们看到结束消息或提交消息时，不必确保正在刷新内容。现在当重做阶段结束时，对于任何已经成功提交，并且仍在ATT中的事务（因为我们没看到end）我们继续为它添加事务结束消息，并从ATT中删除它。因为在这里，当我们完成重做时，实际事务表中唯一剩下的条目是正常操作期间未提交的事务，这些事务都会被撤销，我们根据LSN反向执行这些操作。在这个例子中，我们在预写日志中记录了begin，检查点begin和检查点结束。在T1终止时，我们希望撤销T1所做的更改。所以我们要做的第一件事情是添加它的CLR记录。然后标记T1事务结束。然后T3开始运行，T2开始运行，都进行了一次更改。然后宕机了。现在我们至少知道有T3和T2这俩个事务，可能还有我们不知道的其他事务，但它们会存在于ATT中，或者在检查点结束时被记录。然后我们需要恢复数据库。在完成分析阶段后，我们需要填充ATT，我们有元数据来跟踪每个事务的信息，分析完成后，没有看到它们的提交信息。所以知道要撤销它们，DPT告诉我们P1、P3和P5有一些可能不在磁盘上面的修改。我们需要继续确保重新应用这些修改。我们需要撤销的第一件事是T2在这里所做的更改，所以我们继续为它创建一个CLR记录，其中包含我们正在成效的更改信息。然后我们现在也有一个指针，指示我们需要为这个事务进行的下一个反转是什么。然后同样，撤销LSN 008中发生的事情，反转该更改，然后apply。在这一点上，对于事务3，我们知道它已经没有更多条目了，因为撤销下一个指针基本是空的。所以我们可以直接在此处添加事务结束的消息。按照最初的ARIES协议，在撤销阶段看到事务结束时，你会实际刷新WAL并刷新所有脏页。所以在这一点上你会知道，如果崩溃并返回，至少在这个日志位置上，我已经应用了所有这些更改。大多数系统不会这样做，这是因为在这一点上，你并没有真正印象实际的查询和事务。然后更新flushedLSN，表明如果执行刷新，这是最新刷新记录的日志条目。但是如果我们紧接着有崩溃了，我们需要从这次恢复中再次恢复。这是ARIES的优势所在。在这里ATT基本不变。现在我会确保撤销T2的更改，因为没有发现T2的提交记录。所以继续执行之前的操作，在重新应用更改后，继续创建一个CLR，指向尚未撤销的上方记录。完成后我就知道在回溯时，我已经应用了这里的更改。现在数据库恢复到了崩溃前的状态，所有被终止的事务部分都已被撤销。现在外部系统就可以看到数据了。系统成功恢复。如果在分析阶段发生崩溃只需要重新开始恢复流程，如果在重做阶段发生崩溃不需要做任何操作，再redo一次就可以了。在重做阶段，只是盲目地覆盖页面上的内容。只需要确保以正确的方式执行重做。如果在undo期间发生崩溃，如果某些CLR已经从预写日志中出来，并被刷新，那么它们将在redo中被纳入考虑。这意味着在undo阶段，我可能需要做更少的工作，因为我可能已经提前完成了一部分撤销工作。因此无论崩溃一次又重新启动多少次，只要撤销工作持续推进，最终所有需要回滚的事务都能完成。那么就不需要单独的撤销阶段了，因为现在我的重做阶段将包含所有需要撤销的更改。乍一看WAL日志可以记录为UPDATE T id = 1 before = 10 after = 20这样可以恢复也可以回滚，为什么mysql要拆分为redo log和undo log。实际上最早的WAL就是这种形式，但实际上数据库的问题远远比这个复杂。如果数据库崩溃，需要重做已经commit更改。但是rallback只需要处理没有commit的事务。redo要扫描的是所有commit的事务，undo要扫描的是active事务。此外redo和undo要保存的时间完全不同。事务commit之后，redo必须保留，因为之后可能crash（此时数据还没写入B+树中）。对于undo，commit之后也不能立即删除，因为mvcc（undo log版本链）。undo生命周期为rallback+mvcc。redo要极致顺序写，因为几乎没有随机IO。Undo每个事务都有自己的undo chain，所以undo更像一个数据结构，不是简单日志。分布式架构数据库如果要做一个分布式数据库，关键的主题是围绕协调。数据库系统可能要向顶层的查询规划组件，底层或磁盘缓冲器，甚至中间件发送消息。但关键在于之前在单节点下的问题依旧存在。对于并行数据库，通常假设节点在物理位置上非常接近，在同一个机架上，它们通过告诉局域网连接。通信成本被认为非常小。但是分手环境中，节点可能彼此相距甚远。这些节点可能通过公网连接。通信成本大大增加。核心就是回答问题。内存和硬盘在哪？这会允许到你需要在不同节点之间发送什么样的信息，以及应该由谁来发送。到目前位置，之前学过的数据库的内容，在数据库系统文献里，都被称为共享一切架构。但是提到分布式数据库，人们首先会想到的是shared noting架构。这是20世纪80年代提出的一个概念。你有多个节点，每个节点都有自己独立的CPU，内存和硬盘。但是任何节点之间的通信都要通过网络。但是在10年作用shared disk架构实际上变得越来越流行。在某种程度上已经占据了主导地位。过去五年里，大多数新兴的数据库创业公司都采用了共享磁盘架构。所有节点仍然有CPU和内存，但它们共享对一个大型逻辑磁盘的访问。为了完整性可以通过在途中移动网络所代表的红色方框来理解共享内存架构。共享内存架构的特点是，每个节点都共享CPU，它们共享磁盘和内存。可以通过网络请求获取内存地址中某处的值。常见的优异成绩是高性能计算，比如科学模拟，有RDMA或者infiniBand等技术。但是在分布式数据库的场景下，共享内存架构没有实际的应用价值。Shared nothing抛开学术原型不谈，第一个商业化的shared nothing 系统大概是1982年的teradata。意味着每个节点都有自己的CPU，内存和磁盘。只能通过网络通信。如果每一个节点都拥有回答查询的所有数据，那么这对于性能和效率来说十分友好。但是扩容变得困难，这意味着如果想启动一个新节点来处理，就得把数据复制过去，扩展容量涉及到大量的数据迁移。每个节点都很难知道其他节点在干什么所以很难保证一致性。有很多shared nothing数据库，这只是一部分。此外，之前提到过的单节点算法经过少量修改，就能应用到这种共享网络架构中。Shared disk节点通过某种互联访问单个逻辑磁盘。每个节点还是有自己的CPU，内存。想搞大点的缓存，可以加个本地磁盘。这种架构是20世纪80年代，但是当时并没有成功。但是近5-10年的数据库创业公司，都在使用shared disk架构。真正的原因是公有云火了。Amazon S3随便用了。有了这些现成的基础设施，数据库开发者就不用费劲去实现共享磁盘那部分了。好处在于，基本上可以无限使用云存储，只要信用卡额度足够。云服务会自动处理多地备份。它可以让你独立地扩展计算资源和存储资源。这种情况下，如果需要CPU只需要按需付费即可。服务器架构通常采无状态计算节点来表示。计算的最终结果仍然存储在公共云上。因此在共享磁盘架构中，如果无状态计算节点处于空闲状态，可以随时关闭，不会有任何影响。shared memory拥有公共内存地址空间，可以通过RDMA，infiniband等技术实现。在分布式数据库很少用这种架构所以跳过。设计问题我们希望做到执行对用户透明。我们希望数据分区来充分利用计算资源。分区在nosql中也会把分区叫做分片。它合并多个查询结果获取答案。一个方法是表分区，如果查询从来不需要join表，且访问模式统一，这会是一个不错的决定。另一个常见的方式是垂直分区。向列存一样，需要在拆分后维护一些元数据，还给你元祖重建回其原形式。假设一张表中某些数据不常被访问，就可以采用这种方式。水平分区指的是根据某个分区键和方案，将表中的元组拆分成互不相交的子集。我们会选择一些列根据某种指标，尽可能均匀分配数据库。federated database理论上可以存在一个中间件，重写查询语句，将sql方言转换成mysql或pgsql。让mysql，pgsql协同为一个集群工作。但在实践中这非常困难。此外作为数据库开发人员，为单点数据库优化查询已经十分困难。如果要跨分布式进行优化，非常麻烦。在分布式事务中如果如果事务之间出现了死锁将会非常麻烦，之前我们可以通过等待图来分析是否出现死锁。但是如果是分布式情况下就会有很多问题。分布式事务数据库我们假设数据库系统中所有节点都是友好可控的。除非你在做比特币，不然不需要关心拜占庭问题。我们有俩俩种方案，一个是单主，一个是多主。我们如何将更改从主服务器传播到从服务器？如果采用连续传播，意味着当服务器获得事务的所有更改时，会立即预写WAL消息。发送给副本。这种方法的缺点是还必须发送一条提交消息。如果在提交时进行传播，那么会在主节点上暂存所有已经生成的日志，只有当事务成功提交时，才将其发送到副本。这保证了它只看到已提交的事务，无需浪费时间进行回滚操作。但是在大多数系统中，回滚非常少见。因此第一种方式通常更好，因为你不需要等待。active-active：同一个事务会在每个副本上独立的运行active-passive：事务只执行一次。传播的是修改后的数据但是在raft中，不能直接使用active-passive。以tidb为例。sql在tidb层时解析完成。到了tikv的时候，已经变成了kv操作。leader会把这些操作封装为raft entry，因此follower执行entry即可。第二种方案还很好消除了随机数等问题带来的不确定性。原子提交协议关键在于我们要能决定事务中不同节点上的提交顺序。二阶段提交在没有明确指定哪个节点负责提交的情况下，我们会首先和节点1通信。这里决定是否允许提交事务的节点1称之为协调器。其他的节点被称为participant（参与者）。俩个阶段的第一阶段是想所有节点发送准备消息，如果可以就告诉俩个节点进行提交。发送的所有消息都会作为预写日志记录下来。为了确保崩溃恢复，需要将俩阶段提交的所有网络消息刷新到磁盘。这样恢复后才能知道事务允许到俩阶段协议的哪个阶段了。你可以把它放在你为常规执行的同一个预写日志中。一旦协调器收到了所有参与节点的确认。然后就进入第二阶段，向所有节点发出提交此事务的指令。然后每个节点都应用更改。等待刷新完成后返回确认。此时协调器可以告诉应用程序，现在事务已经成功提交。当有一个节点出现故障的情况将会丢失提交。因此如果当前节点是参与者，发送确定后崩溃，当重新上线时，会查看日志记录，发现为这个准备消息发送了一个同意信号。然后重新回到协调器那边询问该事务的执行情况。根据协调器返回的内容，可以撤销这个事务，也可以提交这个事务。如果协调节点崩溃了，上线的时候可以向所有节点发送一条消息，指示所有节点继续提交。即使你可能没有告诉外界你的事务已经提交，但根据事务协议或数据系统的事务保证：如果成功将所有更改保存到产品，在崩溃之前没有将提交或确认发送回客户端，则该事务被视为已提交。所以应用程序服务器需要返回并行查找，确认刚刚发送的内容是否已经提交。如果协调器崩溃了怎么办？参与者可以在超时一段时间后自行决定怎么做，在不同节点之间发送心跳询问是否存活。如果心跳超时，那么就要决定是否告诉协调器要提交。在弄清楚事务是否应该被提交之前，不能进行提交。如果某个节点崩溃，协调者会认为该节点已发送终止消息，如果没有收到参与者的准备就绪恢复，则会假定该操作无论如何都会失败。因此可以对俩阶段提交进行优化。一个优化叫做提前准备投票。如果你确定向某个节点发送的查询请求是你将要在此节点上为此事务执行的最后一个查询，并且你不会通过其他方式访问该节点，你可以将该查询捎带在消息中，并告知节点允许此查询，并返回结果。并且一并将准备阶段的投票结果返回。因此在允许俩阶段提交时，不必再次等待协调者和参与者再次往返通信。因为你已经从最后一个查询中得知了事务是否能提交。但是大多数系统不采用这种做法，因为在sql中，你无法得知查询实际在哪里运行。early acknowledge更常见的方法是在准备阶段后进行早期确认，在准备阶段并从所有参与者哪里获得同意后，不必等待他们回复，而是立即将确认信息发送给客户端。这里减少了等待消息提交的往返。Paxospaxos made simple其实写的也不怎么样，最好的论文是google的paxos made live。lamport还有另一篇论文，consensus on transaction commit。分布式分析数据库ETL 是 Extract（抽取）→ Transform（转换）→ Load（加载） 的缩写，是数据仓库、数据湖、BI 分析中最经典的数据集成流程。它的作用是把业务数据中的数据经过清洗加工后，导入分析数据库。有talend，qlik，fivetran之类的工具。OTLP中数据的组织方式实际上不利于数据分析，因为如果我们想处理数据了解人们在历史趋势中购买了什么，我们就必须对这种树状结构进行大量的连接操作。因此20实际90年代末和21世纪初出现了一种趋势。数据仓组织数据结构的方式会不同于事务系统组织数据的方式。主要方法是star模型和snowflake模型。在星型模式中，核心思想是存在一个中心点，也就是事实表。围绕着事实表，还有一些其他的表，这些表通过外键与事实表关联，我们称之为维度表。维度表存储的数据库用来丰富事实表中的数据。核心思想是，当我相对数据进行分析时，我主要都是在和事实表打交道。现在只需和这些外部的维度表进行一层连接即可。这样就简化了数据结构。但是这里会有许多重复的数据。另一种方式被称为雪花模式，可以将星型模式视为雪花模式的受限形式。思想是仍然会有一个中心化的表，但是维度表可以深入到多个级别。可以想象为雪花的各个分支中心向外延伸。在雪花模式下，存储出重复数据更少，因此占用我们更少的存储空间。一些类名发生修改时（这在OLAP数据库中并不常见），如果在星模型下，我们就必须找到所有重复的数据进行修改。现在市面上主流的分析系统都支持这俩种模式。不需要刻意声明，只需根据不同的方式自己进行设置即可。Snowflake数据库就是因为支持雪花模式。分布式查询的执行流程这类似mapreduce，但是这套方案其实在90年代并行数据库中就被大量使用了。甚至mapreduce做的还不好。在这里甚至有论文证明mapreduce的速度很慢，远不如vertica这样的分布式OLAP系统。但是后期人们意识到编写java函数来处理数据简直是灾难。还是需要sql，于是就有了类似Hive这样的工具，把sql查询转换成mapreduce任务来执行。但即使这样，性能还是不如并行分布式数据库。Snowflake的聪明之处在于，没有跟风mapreduce，坚持sql关系模型，所以他们构建了一个可扩展的跑在云上的共享磁盘系统。数据在这里分为了持久数据和临时数据。持久数据是存储在数据库中永久保存到数据。而通过连接、聚合等从持久数据生成的所有结果，都叫做中间数据。因此分布式系统中必须做出的重大决定是如何处理这些数据。总的来说，只有俩种方法，那就是将查询推送到数据，或者拉取数据来响应查询。将查询推送到数据意味着无论在一个节点还是分布式系统中，与其提取所有数据，将其带到向执行查询的地方，不如将查询发送到数据所在的位置，让它处理并发送返回结果。push query to data：比如你有1PB的数据，要对它进行扫描，并且有一个选择性很强的过滤器。比如过滤掉百分之99%的数据，那么当然不想将这1PB的数据通过网络发送到我的节点。这就是shared nothing架构可以做的。缺点就是如果要添加节点，就必须要复制数据，成本会很高。pull data to query：通常是在shared disk系统中做到的，尤其是在最初读取持久化数据时。例如，虽然无法直接在S3中进行计算，因此你必须在S3中读取所有数据，带到你的节点，然后在上面进行任何你需要的计算。一旦进一步处理中间结果时，就可以将查询推送到数据方法。SQL本身可能只有几十KB，数据本身可能达到PB级别。但是这俩者界限模糊，因为Amazon S3实际上允许直接对S3上的数据执行SQL查询。S3会处理这些数据，返回你真正需要的部分。Azure也有类似的东西。容错：我们不想运行了几天的事务爆炸。因此我们要为中间结果提供故障容忍机制。这样发生故障，我们就不需要从头再来了。这是在mapreduce，hadoop的人做的事情，但是早期的分布式数据库没有这样做。查询计划：之前讨论过的所有优化方法，比如谓词下推，投影下推，最佳连接顺序仍然适用。不同的是除了像连接算法那样考虑磁盘IO，我们还需要考虑网络成本。为了解决这个问题，有些系统会进行单节点的规划，接着再进行第二步，将节点查询调整为分布式的，至少根据微软所说，sqlserver是这样做的。有些系统不对网络传输等成本进行硬编码，而是在系统启动是快速运行一些精准测试，以确定集群中不同的节点都传输速度。将其用于成本计算。那么节点之间到底传输什么呢？大家最后都得发物理操作符，有些系统页能发送sql。大概思路是，先拿一个sql过来，生成一个AST，在生成一个逻辑计划，然后从这个逻辑计划生成物理计划，这个计划会说明要按说明顺序，在哪些数据上执行哪些操作。这样查询以来就会有个中心协调器，或者系统里的某个模块帮你把查询计划都搞定。尝试构建一个最优的计划然后分发到各个节点上。但发出去的还是物理计划。所以现在，计算节点拿到物理计划之后，就知道它该干啥了，直接执行，然后处结果就行。但也确实有少见的系统不直接发物理计划，而是先生成物理计划，把sql查询拆成小块，也就是子任务，然后把这些小块转换回sql，发给不同的节点，让它们自己去做解析，计划和优化。这里的核心思想是，集中式系统中，你所认为的全局视图永远不会是完全最新的。因此各个本地节点能更好地判断数据的实际情况，并进行相应的本地优化。singlestore和Vitesse（mysql分片中间件）是这样做的。 shuffle连接本质上是一种分布式join算法，核心思想是把参与 Join 的两张表按照 Join Key 重新分区（Shuffle），让相同 Key 的数据最终落到同一个节点，然后各节点独立完成 Join。Broadcast Join是将小表广播到所有节点。gather join是将数据汇聚到一个节点。semi join是先过滤在join。shuffle操作可以通过交换算子等方式来进行优化。云数据库：最早的云数据库，云厂商基本上就是拿一个现有的开源或者单节点系统，直接部署在EC2实例上。但是核心架构和你本地跑的是，或云服务上跑的是一样的。亚马逊把postgres部署在EC2的事例里，然后收你双倍的钱，就像你自己部署的一样。snowflake带来的是新一代云原生架构，一开始设计就为这云环境下运行，采用存算分离的架构。他们具备所允许的云服务商通信的所有必要机制，并将这些细节对用户隐藏。最近5年出现了新概念，无服务数据库，核心思想是，不再使用允许数据库系统的核心硬件，在数据库实例空闲时，将其从硬件资源中释放。不同的系统采用不同的阈值来判断空闲状态，具体是销毁虚拟起还是关闭一部分VM取决于具体实现。但是当应用程序恢复并执行新的查询时，会有一个启动延迟，从共享磁盘中将缓冲池的内容重新加载到内存中。尝试将数据库恢复到之前的状态，就像虚拟机冻结一样，但是粒度更细。终止下不要长期允许服务器。类似的系统有NEON，cockroachDB，fauna。对于某个查询来说，这种方式会更贵，但是如果每周就发一个查询，这种方式就非常合适。数据湖：在之前的模型中想放入数据库的数据都必须经过数据库本身，这些处理过后的数据不能被其他数据库进行读取。而数据湖中，并非所有应用都希望数据经过数据库系统。可能包括json，csv文件。它们直接插入S3中，现在当查询到来的时候，仍然需要某种元数据目录来说明这里有哪些数据文件。databricks将其命名为湖仓一体架构。核心思想就是数据存储本身的内容，不一定总是又数据库来完成。假设我要存储一些数据，但是一年俩年之后，应用程序的存储需求完全不同了，那么之前所有关于如何进行转换的假设都可能失效。所以说这种存储方式相对便宜。OLAP数据库是数据仓的一种实现技术。数据仓还包括了数据清洗，转换，BI等功能。数据湖相比OLAP数据库直接存储原始数据。
CMU-数据库导论-2Query Execution处理模型当关系型数据库接收到SQL查询时，会将其转化为执行计划。理想情况下，你希望查询计划是一个有向无环图，directed acyclic graph，也就是DAG。大多数系统是将其实现为树。理论上DAG是更优的结构，因为树不能重用一些输出，除非使用特殊的技巧。管道是操作符的合计，数据库在这些操作符之间连续传递，无需阻塞或等待更多数据。管道中断器（pipeline breaker）本质上是管道的边界，除非在查询中执行另一个操作（另一个管道），否则无法继续处理当前管道中的任何元组。管道的核心思想是，定义了一个边界，在这个边界内，我们可以执行一系列操作符，而无需将结果物化到中间存储。处理模型定义了数据库将如何执行查询计划，以及如何将数据从一个操作符移动到另一个操作符。这里有些操作方式适合OLAP有些适合OLTP。控制流（control flow）是只是数据库系统用来下一步要运行哪个操作符的机制。它可以是一个管道或者一系列运算符。数据流则决定了操作符发送的位置和方式。在迭代器模型中控制流和数据流的界限实际上会变的模糊。在迭代器模型中，表示数据已足够，停止运行的方式，和指示其完全停止的方式是相同的。一个操作符的输出会取决于我们想要使用的处理模型。常见的三种方法是iterator model：最常见，pg，mysql，oracle，db2都是用这个。materialization model：少见vectorized/batch model：常见，OLAP系统中用的多。迭代模型这是最常见的实现方式，尤其在行存储系统中。查询计划中的每个算子都要实现系统提供的API，这个API包含三个基本的函数。其中next函数会发出指令，算子会生成下一个元组，这个元组成为正在处理数据的输出结果。父算子会不断在其子算子上重复调用next函数，直到子算子没有更多元组能够返回。它会返回空指针和一个文件结束标记。因此，如果一个算子有一个子算子，其并不关心子算子实际上是如何生成数据的。只需要调用子算子的next函数，不需要关心是来自网络，还是来自索引。我们可以维护一些关于算子状态的元数据。所以我们必须要open和close函数。当调用索引扫描算子的open函数式，就像为B+树创建迭代器一样，实例化迭代器，移动它生成一个元组。先open，调用next直到eof，最后调用close清理迭代器。当顶层的算子调用child.next时，会发生阻塞，然后执行流程会跳转到下层算子。（不设置的情况下，数据库默认使用的是inner join）管道之间存在依赖关系，R没有执行完毕之前，S是不能执行的。父级不在乎子级真正调用next时干了什么，可以是实际去查询一条数据，也可以时将查询好的数据直接返回。这种方式几乎被用了所有的DBMS中。这种方法的另一个优点是容易控制输出，如果有一个limit语句只要10条数据，那么只需要在获取10个元组后停止调用getNext即可。这就是之前说的，控制流和数据流在某种程度上是混合在一起的。物化模型物化模型中仍然有一种next函数，它实际上就像，给我一切。因此不再是每次调用操作符时生成一个元组，而是直接获取该操作符产生的所有元组。这项技术，20世纪90年代名为monadb的项目，Duckdb实际上是mongnadb的嵌入式版本。获取S中的所有元组，添加到输出缓冲区，然后现在传递给这个运算符，它会扫描数据并应用谓词。但是如果有一百万条记录，谓词只会匹配其中俩条，显然是不合理的。因此这个领域里一个非常常见的优化方式是算子融合。基本思想是，将同意流水线的不同算子内联，或者组合起来，从而避免不必要的复杂操作。在上述例子中，扫描R表，应用谓词，如果满足条件就将其放入输出缓冲区。物化模型更适合OLTP，因为查询只访问小部分的元组。更少的执行和协调开销，更少的函数掉用。对于OLAP，调用next会很昂贵，因为必须要扫描10亿个元组的场景存在。而且我们不想一次性物化所有的数据，因为我们希望分解处理流程并将其并行化，而不必担心耗尽所有内存，只需要生成运算符的之间结果即可。一个显而易见的方式是在俩者之间做一些事情向量化模型向量化模型类似迭代器模型，当调用next时，得到的不是一个元组，而是一批元组。现在获得该批元组的运算符将处理这批元组，在整个批上面处理。操作符的一次内部循环处理多一个元组批的大小可以基于精简和查询资源如果知道某个列中的属性和元组返回相同的值，可以使用类似游程编码的方式来进行。Duckdb底层就会进行类似的操作。和之前的例子类似，但是这次不再是传递单个元组，而是传递一个向量。该模型在Clickhouse，oracle，sql server，duckdb中均有使用。这是2006,2007年才提出的概念，最初的论文是基于monadb的项目。该项目发现，迭代模型在现代CPU上非常慢。很适合OLAP数据库系统，近15年来出现的大多数，OLAP系统，即使是较早的系统，也经过改造使用这种方法。CPU喜欢进行的操作方式是，一段连续的内存数据，连续执行1000次操作。基于推送的迭代模型这种方式相对少见。但是可以更精确的控制数据在缓存和寄存器中的位置。上述的都是让父节点往子节点执行，另一种方式是将叶子节点的数据推到父节点。如果我们现在不再使用getNext方法来触发操作符，通知他们开始运行并生成输出。需要一个位于这些之上的协调器或者调度器。它会显示的调用这些操作符，告诉它们该开始运行了。duckdb和snowflake用了这种方式。访问方式最暴力的搜索方式是读取每个页面，直到找到目标，或扫描完整的表。有三种不同的方式顺序扫描索引扫描 有不同变体多索引扫描，选择多个然后合并查询结果顺苏扫描根据数据是聚集型还是非聚集型可以做不同的优化。顺序扫描如果没有可用的索引，顺序扫描是最基本的方法。在构建新的数据系统时，通常先实现顺序扫描，已验证数据存储的正确性。可以使用页面目录或其他机制来获取数据。虽然很慢，但是之前已经讨论过一堆方法进行优化。比如数据压缩，预取。数据跳过（oracl术语）*对于要扫描的表，其中一部分数据可能是查询不需要的。它分为有损压缩和无损approximate queries（有损）如果查询不需要精确的结果，就不需要扫描每个页面了。这样就可以用统计置信度衡量请求的准确性。比如提供最小值最大值平均值的近似函数。比如统计9.99亿和9.998之间的差距可能是不重要的。但是数据库默认不进行该操作。必须明确告诉系统需要近似计算。zone map（无损）：区域图是预先计算好的元数据，描述了特定页面或者一组页面中数据的概要信息。根据这些信息决定是否需要读取这些页面。区域图的粒度可以很细，针对单个页面，也可以很粗，针对多个页面。显然，范围越大，选择性就越差。OLAP领域的几乎所有数据库系统都支持某种区域图。有区域图的情况下我们还可以跳过一些查询。parquet和ORC都原生支持这种特性。在最初的论文中，它被称为小型物化聚合信息。物化这里指的是预计算。index扫描另一种选择是使用索引扫描。应用程序在需要扫描的表上创建索引，然后收到一个查询请求，运行时数据库必须确定，对于特定的查询使用的最佳索引是什么。当执行查询select * from students where age<30 and dept='CS' and contry='US'我们必须决定要使用哪个索引。因为我有俩个索引。有可能在这里使用部门更好，有可能使用年龄更好。实际上我们想把它们都用在我的查询中。这被叫做多索引扫描（multi-index scan 这是oracle的叫法）。pgsql中称为位图扫描。然后将他们进行并集操作，得到交集。mysql中称之为索引合并。它们将对索引进行多次扫描，取回匹配的记录ID。根据是and还是or确定哪些是匹配的记录。在这里我们可以先试用索引获取年龄小于30岁记录的ID。然后对第二个索引进行查找，部门为CS。取这俩个结果的交集去获取那些元素。然后检查这些元素是否满足最后一个谓词，即国家为美国。pgsql就是通过bitmap来进行实现的。oracle中实际上使用的是集合，计算集合的交集。修改操作UPDATE / DELETE子算子（Child Operator）传递目标记录的 Record ID（RID），父算子根据 RID 直接定位要修改或删除的记录。需要记录已经处理过的元组（tuple），避免同一条记录因为查询计划（如 Join）产生多个结果而被重复更新或删除。INSERT有两种实现方式：物化（Materialize）后再插入 先把所有待插入的元组缓存（Materialize）到算子内部。等输入结束后，再统一执行插入。流式（Pipeline）插入子算子每产生一个元组，INSERT 算子就立即插入。 不需要额外缓存，能够保持流水线执行（Pipeline）。假设我们要对每个少于1100薪资的人涨薪100。这里存在一个万圣节问题。在update或者delete语句中，由于更新后数据再次满足扫描条件，导致同一行数据被重复更新。这个这个问题最早于 1976 年在 IBM 的 System R 项目中发现，因为是在万圣节（Halloween）那天发现的，所以得名 Halloween Problem。如果扫描索引，找到记录，立即修改之后，这条数据可能到了原本索引的后面。这是因为索引需要保持有序。解决方案是，跟踪之前处理过的逻辑记录，这样就不会尝试重复更新同一条记录了。因为当你更新查询是，从集合语义角度或者回滚语义角度来看，应该只更新一次操作。执行Expression Tree（表达式树）是现代数据库执行器（Execution Engine）中最核心的数据结构之一。几乎所有关系型数据库（MySQL、PostgreSQL、SQL Server、DuckDB、Snowflake 等）都会在执行 SQL 时构建各种形式的 Expression Tree。他负责需要计算的所有表达式。Sql parser在解析sql时就会把表达式解析为一棵树。salary * 1.1 + bonus会变成 + / \ * bonus / \ salary 1.1每一个节点都是一种 Expression。最终形成查询计划。但是CPU更倾向于顺序执行。沿着树中的指针向下查找并不是一个好的选择。当然这里需要先表示为树，才能进行额外的优化。更好的方式是直接评估表达式。我们希望一个比较运算不进行树节点的遍历，因为对cpu不友好。希望只是通过一个函数来比较。有些系统甚至会生成整个查询计划，包括所有这些谓词。但实际上这非常少见。pgsql具有一种叫做即时编译（JIT just in time）的技术专门优化这些where子句。这样就不必为查看的每个元组都遍历那颗树了，我只是编译了一个完全按照我想要方式执行的函数。这里需要做一些成本效益分析，判断是否值得。PGsql会尝试评估需要扫描多少元素。编译谓词（where子句）预计花费多少时间？这么做是否划算。在数据量大的情况下，先编译再执行，比直接通过树要好。但是不是每个数据库都会有类似的方案。大部分系统都没有采用pgsql的做法，因为这在实现上非常复杂。另一种替代方案是snowflake采用的，先预编译函数，而不是jit，然后根据谓词内容，将这些函数拼接起来。sql中有的数据类型只有那么多。所以可以预先准备一系列宏。预先生成这些函数。在运行时，调用函数指针来执行相同的操作。它们运行的是向量模型，而不是迭代器模型，因此可以分摊函数调用的开销。此外，还可以进行常量折叠。另一种常见的字表达消除方法时，如果我看到子表达式。被重复调用，我们可以只计算一次病重复使用它。同一个逻辑查询，或者同一个查询可以用很多不同的方式来进行。并行查询执行我们想要利用cpu的多核能力。如果它能在单节点上运行，理论上应该能在多节点和多个woker上运行。但是并行查询和分布式数据库是不应用的。大家都在用更多的资源来执行查询，但是并行查询无论是存储资源还是计算资源，在物理位置上都会比较临近。我们可以假设通信是可靠的，不需要付出很高的成本。我们使用woker的组件来调度和执行任务。woker是系统中负责执行任务的计算单元，可以处理客户度请求，以及内部后台或维护任务。当讨论垃圾回收和MVCC时，可能会看到一个后台进程需要定期这些垃圾回收。当讨论大型结构化合并树，需要在后台进行压缩，这时候可以给该任务分配一个worker。一个woker可以对应一个线程，也可以对应一个进程，还有一种是嵌入式数据库的woker。最常见的是每个worker对应一个线程。这是因为现在编写多线程程序不像80,90年代那么困难了。进程worker如果要支持并行，最古老的进程模型就是每个工作进程使用一个线程，这意味着每一个woker都有其自身完整的操作系统进程。就像调用fork来创建一个独立的进程，这个进程有自己的地址空间并且与其他进程隔离。pgsql有一个pgmatser负责调度，这个调度进程实际上是数据库系统的一部分。调度线程会选择一个工作进程来处理这个链接，如果当前没有工作进程，就fork一个进程。在这里可以把调度器看作是一个协调器，查询发送到调度器程序，然后调度器程序将它们分发给其他工作进程。这种情况下如果进程之间要进行通信，就要使用共享内存之类的技术。因此需要做更多的工作，依赖操作系统来提供很多功能。这些老系统采用单进程woroker的模式的原因是，早期unix系统，多线程实现方式各不相同。db2，oracle，pgsql用的都是多进程实现。开发者需要针对每个平台重新实现基础设施。但是如果使用posix标准，fork和join操作是许多功能的基础。而所有操作系统都原生支持这些操作。线程worker现代的实现和方向法则更倾向于单线程worker的方式，开发者可以使用posix thread，或其他操作系统提供的线程库系统方面可以更方便协调和调度这些线程。mysql，sqlserver都用的是这种方式。这样系统就能更号管理线程调度，因为它能控制每个线程的运行时间和方式。所有线程都在同一地址空间内，因此worker之间的通信成本会更低。与单进程worker模式不同，在单进程worker模式下，如果一个进程崩溃，只会影响该进程本身。但是一个线程在单线程worker模式下崩溃，则会导致整个系统崩溃。近20年来开发的数据库都支持这种模式，即便是DB2和Oracle这样的老牌系统也增加了对单线程模式的支持，尽管它们最早出采用的是单进程worker模式。只有那些从pgsql派生或基于pgsql开发的系统才会基于沿用pgsql的单进程worker模式嵌入式数据库系统嵌入式数据库通常没有自己的线程，也不负责线程的管理，因为它们被嵌入在应用程序的内部。比如sqlite，rocksdb，duckdb，leveldb。应用程序会对这个数据库发起调用，无论哪个线程进行调用，系统库都会用该线程来调用他们所需执行的任何查询。对于duckdb，你可以告诉它启动额外的线程，以便它可以用来并行执行查询。在经典的系统模型中，sqlite和rocksdb，都由调用线程来执行。第一个支持这种模式的嵌入式数据库是berkeleydb。调度当一个查询出现时，数据集必须决定如何执行，在哪执行，以及何时执行。大多数数据库执行一个简单的先进先出优先级队列。无论出现什么查询，都会首先查询。虽然操作系统有自己的调度器，但它也不知道实际查询想要做什么。因为它只看到一些盲目的进程或者线程。数据库系统更能比操作系统更知道该做什么。更高端的系统，比如Oracle，DB2，sql server会基本上重写所有事情，不依赖操作系统做任何事情。但是，出现一个查询，并不意味着可以将该查询分配到多个worker上。mysql就是最好的例子，出现一个查询时，只有一个线程可以完整的执行这个查询，没有类似pgsql那样的并行查询。它们无法跨多个核心进行拆分。那么如何构建系统支持多个运算符的并行。有俩个主流的方式，查询间并行和查询内并行。内部查询并行可以让不同连接或不同应用程序发起多个查询。在系统中同时运行。大多数数据库都是采用先到先得的方式。在一些系统中，比如db2，可以根据用户名来设置事务或者排序优先级。比如CEO的查询速度可以比普通销售人员更快。如果所有查询都是只读的，也就是说不修改数据库，那就好办了，不需要协调数据，反正不会存在数据更新。内部查询并行的基本思路是把一个查询分解成多个任务，然后分配给多个worker同时运行。这样就有可能缩短查询的执行时间。实现内查询并行主要有俩种方式。一种是水平平行（intra operator），也就是横向扩展同一操作符的多个实例，同时运行。（用的最多）另一种是垂直并行（inter operator），也就是同时运行不同的pipeline操作符。它们直接存在明确的依赖关系。（很少用）这些技术不是互斥的，可以结合使用，之前讨论的各种算法，在具体实现的时候做一些小改动就可以了。而且我们之前讨论过的所有算法几乎都有并行版本。比如hash join，grace hash join。此外，还有第三种类型，即树形并行本质上是前俩种方式的结合。（高端系统能实现的功能）intra operator在intra operator中，我们的想法是采用查询计划，不拧将操作符调用复制到工作线程中，以便它们可以同时并行运行。关键在我们将为每个操作符实例分配不同的任务，以便处理输入数据不同的子集。具体来说，假设有1000个元组的表，并且有四个工作线程，可以将前25%的数据分配给第一个工作线程，接下来25%分配给下一个工作线程以此类推。现在它们可以并行进行扫描，以及过滤等任何其他需要执行的操作。而且它们直接不需要相互通信，其实我们只是在对数据进行分区，然后分发给不同的worker。但现在，某些情况下还需要吧数据整合起来。为了查询计划中执行的后续操作，我们需要引入一种新机制，叫做交换算子（exchange operator）。交换算子在关系代数中没有对应的概念，但我们需要它来进行数据合并。我们可以把它看做是管道的中断器，它的作用是，必须等到所有子运算符都产生了所需的结果，才能继续执行后续的查询计划。无论使用推送模型，还是拉取模型，这一点都一样。在pgsql中，这个操作叫做gather，类似Mapreduce的原理，是开始下一阶段之前的一个屏障。这是最常见的收集方法，将多个输入流合并成一个输出流。inter operator现在讨论操作符间的并行，即不同的操作符可以同时运行，即使它们之间存在依赖关系。有的时候这被称为pipelined parallelism。还是以这个sql举例子，假设hash计算非常昂贵，我们想让这些操作并行执行，那么可以一个线程负责扫描，一个线程负责进行哈希投影。这在流逝系统中更为常见，比如spark slq，kafka等。在这些系统中查询操作是连续的输入数据流没有终点。bushy parallelism这是水平并行和垂直并行的混合组合，将所有这些并行方式结合起来使用。比如对于笛卡尔集操作。同时对A,B，C，D进行连接，将所有这些元组混合生成所有可能的组合，使用缓冲区来跟踪之前处理过的数据，当新的元组进来时可以匹配它们。但是很多情况下，磁盘和锁是性能瓶颈，因此即使有一堆worker，能够并行查询实际意义也不大。所有的worker都必须访问磁盘，并且因此阻塞，那么并行化就起不了什么作用。IO并行思路是，如果我们可以将数据库的数据分散到多个存储设备上，那么就能升级磁盘带宽。有很多方式可以做到这一点。最简单的方式是给单个数据库防一个单独的磁盘。也可以将单个表或者单个关系放到一个磁盘上。你可以跨越多个磁盘拆分关系表。pgsql和mysql的innodb都支持表跨磁盘。在一些系统中可以将WAL放到一个磁盘上，然后将常规表的数据放到另一个磁盘上（Oracle）。这样高速写入WAL，读取数据时，就不会受到写入操作干扰。高端的系统可以对IO并行机制进行显式控制。对于mysql等着用的系统，在实际数据目录本身中，你只需要将符号链接放到不同的驱动器，数据系统不知道你做了这些，但是你获得了并行性，因为多个磁盘可以同时运行并且互不干扰。在pgsql中有tablespace，你可以指定这些表属于此表空间。如果让数据库之间控制磁盘可以实现更细粒度的控制。下面的方式思路类似raid0理论上可以让数据库来管理这些在不同磁盘上的页面，也可以让操作系统来管理，表现为一个透明的逻辑磁盘。如果数据库系统拥有控制权，它就可以决定如何进行拆分。条带化的另一个方式是raid1，也就是说对于数据库系统的每个页面，我们都会将其写入，并把多副本写入到每个不同的磁盘驱动器。因此，第一页会被写入磁盘1,2,3，这样做的好处是，如果要进行大量的读取操作，速度会更快，因为可以访问任何一个驱动器。但这样也增加了写入成本，因为对一个页面的任何操作，都必须在三个驱动器上写入三份。实际上有一些硬件设备可以自动完成这些操作。raid0，raid1，raid有不同的级别，可以组合使用。软件方案是让数据系统自行管理这些操作，那么就可以使用更多的技巧，无需条带化或者镜像，可以使用纠删码来结合俩者的优点。这通常比硬件支持更快和更灵活。理解IO并行的一种方式是，在性能耐用性和容量之间进行权衡。更快的速度，更大的容量，可靠性之间做权衡。我们更希望数据库系统内部实现显示分区，由数据库负责数据划分方式，存储位置，以及如何将数据加载到内存中并行处理。这是sql的优势，无论数据库是否经过分区或者分片，相同的sql理论上都应该能运行，因为它只是在处理逻辑表。不关心数据的物理布局。查询计划问题我们会基于元数据的信息，使用近似估计，来判断一个计划是否优于另一个计划。比如在join操作的时候有个where子句，那么我们就可以先过滤然后再进行join。一个笑话是，如果你尝试在优化查询方面有所成就，但最终失败了，那么plan B应该是当一个火箭科学家。每个数据库都会面临这个问题，并且没有AI和LLM能神奇的解决这个问题。整体的执行姐狗味，解析器获取sql转化为抽象语法树，比如rust的sqlparser，c++中的libpgquery。现代数据库系统都倾向于兼容pgsql的语法。因此你可以获得pgsql解析器模块的优化版本。然后将抽象语法书作为绑定器传递到数据库中，绑定器的作用是将数据库对象的字符串标识，比如表名，映射到目录的对象ID或者内部ID。在pg中有一个pg类表，其中存储了所有这些信息。绑定器用来检查表是否存在，解析器主要负责检查sql查询的语法是否正确。接下来绑定器会生成逻辑计划，逻辑计划会说明需要执行哪些连接操作，需要引用哪些表，进行哪些投影和筛选操作。但是逻辑计划只描述了怎么做，没描述做什么。然后绑定器会将操作传递给优化器，并且取决于是否采用基于成本的优化方法，目录中会包含一个成本模型组件，通常目录里本来就会有一些统计信息。然后最后输出一个可以最终执行的物理计划。所以在pgsql，duckdb，sqlite中调用explain时，看到的输出结果，那个树状结构就是物理计划。逻辑计划会从高层次告诉我们要做什么，物理计划会说明，我希望在查询计划中执行的逻辑步骤，实际上应该以何种物理的方式执行。在某些情况下，也可以将一个逻辑计划，分解为多个物理计划。逻辑计划转化成物理计划这个过程是不可逆的。查询优化已经被证明是np-hard问题，连接操作可能出现各种排列组合。现在的思路是我们需要找到一批不同的逻辑计划备选项。然后对于每一个方案，尝试评估其成本，我们正在基于成本的优化，最终要么时间耗尽要么搜索完毕，要么确定这是目前能找到的最佳物理计划。之所以困难是因为可以把所有可能的查询计划看做是一个巨大的搜索空间，一个包含各种复杂查询解决方案的空间，然后我们无法探索一切。因此我们需要更聪明的做出决策，如何在这个有限的时间内，快速找到这个庞大空间中的某个区域，进行探索，找到一个相对好的方法。因为实际上不可能进行详尽的搜索。对于简单查询来说很简单，比如ID=1的表中选择所有字段，并且ID列上存在索引，那么就能快速找到最优计划。但当进行连接是，上千张表的连接非常复杂。我们将要做的俩种通用方法是应用规则和启发法，，我想在执行连接之前进行筛选过滤。在进行查询优化的时候，启发式/规则我们可以重写查询，以删除我们认为可能是坏主意的操作。谓词下推就是最好的例子，或者让投影提前进行，我们通常只查看元数据而不是实际数据，因为查询甚至还没有开始运行，但是mysql在查询计划时会尝试通过采样收集数据，但是大部分系统不会这样做。pgsql会执行基于规则的方法几次，然后执行基于成本的方法，然后再返回到基于规则的方法。基于成本的查询使用模型估计执行计划的成本。为查询枚举多个等效计划，并选择成本最低的计划优化方法基于规则的方法基于规则的方法实际上是关于逻辑计划优化，这里思路是我们会有一些规则，基本上是我们想要在查询计划中匹配的模式，如果某个模式匹配成功，我们就知道它存在某种低效之处，需要我们去消除。然后我们应用第一个转换规则，通过重写规则来进行改变。核心思路是去除那些已知是无用的操作。将其查询计划引导至一个方向，即基于成本的搜索。这是一种更为详细的搜索，确保我们只少从一个好的起点开始。在这种情况下，我们无法进行任何比较来决定，未执行这种转换是否更优。比如作为数据库开发人员，我们知道谓词下推通常是一个好的选择，所以通常会采用这种做法。在这种情况下我们无法保证找到最优执行方案，但是能帮我们朝向正确的方向前进。比如进行投影的时候丢弃掉了大部分数据，我们可能希望复制投影操作符，放到连接操作符的下方。成本模型在这里可以起作用，如果表只有三列，那么不需要花费时间进行投影操作，将数据从扫描操作复制到连接操作中可能是一种浪费。通常来说在这些规则中，会看到一些硬编码的数值。比如设置阈值10，如果列的数量超过10就执行投影下推。查询优化器的目标就是在合理的成本计算下，尽可能丢掉更多没用的东西。几乎所有数据库都会采用某种形式的这些规则。因为它们速度快，实现起来也相对容易。基于成本的搜索（CBO）如果有很多物理计划备选的情况下，选择哪个计划呢？比如排序归并连接，循环嵌套连接，索引嵌套循环连接。我需要一种方法来说明，在所有这些连接算子的选择中，哪一个是最好的？这种估算是数据库系统内部的成本，基于特定系统实现，与其他系统不具备可比性。如果数据发生变化，硬件，系统参数调优发生变换，这些成本在系统内部也可能发生变化。IBM在20世纪70年代构建了第一个基于成本的优化器。基本上要做的是列举出一些列不同的查询计划估计它们的成本，他们会使用不同的方法来处理单表查询的情况，以及多表连接查询的情况。最棘手的情况可能是子查询或者嵌套查询了。优化器可能会运行枚举所有这些不同的计划，逐一尝试，直到发现已经找到了所有可能的方案，因此能够确定哪个方案是最优的。但是对于三个以上表的查询，这样做就变得非常困难了，情况会变的非常复杂。就像在pgsql和mysql以及所有其他系统中一样，可以设置查询优化器运行时间上限。因为优化器运行时间可能超过sql运行时间。如果是单表查询的化，优化更为简单，关键在于找到最佳访问方式。是否需要进行全表扫描，数据是否按照索引排列。如果命中了主键索引，那么就很容易选择。但是如果where同时用了A和B字段进行查找，A和B拥有各自的独立索引，该如何处理？我们可以将俩个结果合并在一起求交集等。查询算法根据这些成本来估算重新排序这些谓词。多表查询计划bottom-Up代表：System R / Volcano / PostgreSQL自下而上，动态编程风格，我们从一个没有任何内容的查询计划开始。起点是列出所有需要连接的表，通过迭代的方式逐步构建，并尝试找出最佳的连接顺序和算法。然后选择哪个具有最佳下降路径回到起点的方案。IBM构建的第一个查询优化器使用这种方法，首先应用我们在开始看到的一系列静态规则，目的是丢弃那些明显不合理的方案。然后他们会使用动态规划来确定表的最佳连接顺序，本质上是分而治之的策略。大多数开源数据库都会使用这种变体。umbra系统采用了一种更加激进的方式，结合超图来实现。System R是1974年启动的项目，是第一个实现sql的数据库。是一个研究原型，后来IBM将其发展为DB2数据库System R的工作方式是，首先将查询分解为块，可以简单理解为俩个表之间进行连接操作，计算出这个块的逻辑运算符，实际上更复杂的树形计划可能才是最优解。不考虑矮胖树（平衡）是因为构建过于困难。放弃构建矮胖树，可以大大减少搜索空间。并且可以做流水线化。top-Down代表：Cascades / SQL Server / Oracle自顶向下的方法，类似与分支定界搜索。从最顶层开始，目标是，这就是我希望查询产生的结果。然后向下逐步分解开始添加操作符，最终达到想要的效果。但是他们在构建查询的时候只会考虑left deep join tree。也就是说A和B连接的结果和C连接的结果和D连接。这里的想法是如果我们认识到在某个分支的某个点上，我目前为止在这个分支上看到的最佳成本，或者说分支下降到当前为止的成本，已经比我搜索中见过的最佳成本还要糟糕，我们就知道不必追踪分支的其他部分，因为成本空间不可能降低。这就是sqlserver做的。微软在20世纪90年代基于cascades中的方法，构建了sql server中的新查询优化器。sql server现在有可能夺取最佳优化器的宝座（之前是德国的umbra）。应用一系列规则，将逻辑计划转化成其他逻辑计划，AB连接可以转换为BA，也有一些规则将逻辑计划转化为物理运算符。AB连接可以转化为A和B的哈希连接。用了这些规则，就不能从物理运算符回到逻辑运算符了。enforcer rules是专门用来处理物理属性的规则。在top-down优化过程中，优化器从根节点向下探索，父节点会对子节点提出物理属性要求，最常见的偶俩种。排序顺序（Ordering）：例如 ORDER BY 要求，或者 Merge Join 要求两侧数据按连接键有序。数据分布（Distribution）：例如并行执行时，数据需要按某个Key进行Hash重分布（Repartition），才能做并行聚合。问题来了：子节点原本生成的计划可能不满足这个属性要求（例如子节点扫描的是无序的堆表）。此时，优化器不能直接丢弃这个子计划，而是需要给它“加装一个功能”来满足父节点的要求。这个“加装功能”的动作，就是由 Enforcer Rules 触发的。它会在现有的逻辑/物理算子之上，强制插入一个能改变物理属性的物理算子（Physical Operator）。维度Bottom-UpTop-Down思考起点从怎么读磁盘开始（叶子）从最终输出结果开始（根）优化方向自下而上组合自上而下拆解需求搜索空间穷举所有子集（空间大）按需探索（空间小，靠剪枝）典型算法动态规划（DP）分支定界 + 记忆化搜索物理属性处理较难（需额外推导）非常自然（需求下推）代表数据库PostgreSQL, MySQL (8.0之前)SQL Server, Oracle, CockroachDB, TiDB嵌套子查询嵌查询实际上比较麻烦，因为必须考虑子查询，所以可以重写它们消除相关性，并将它们展平尾join查询。也可以提取出嵌套查询运行一次，就像对该查询使用CTE一样，将结果注入到外部查询中。我们可以把嵌套查询重写为join查询。如果不是这样，需要对sailors表中的每个元组运行内部查询，现在可以直接进行连接操作，速度会快很多。数据库的CTE是什么CTE 的全称是 Common Table Expression（公用表表达式），可以简单理解为一个临时的、只存在于当前查询执行期间的“虚拟结果集”。它的核心作用就是让复杂的 SQL 查询变得更易读、更易写，通常用来替代子查询或递归查询。也可以直接分解执行。处理表达式与查询计划类似，我们也可以建立一个基于规则的系统，对表达式进行模式匹配和重写。这里的关键在于对于where或on子句中的一些列谓词，我们要尽可能减少计算量，同时保证查询结果的准确性。与查询计划类似，我们也可以建立一个基于规则的系统，对表达式进行模式匹配和重写。对于表达式树的优化，除非需要重新排序，否则通常不需要成本模型。有些优化是显而易见的，比如处理不可能成立的谓词比如select * from A where 1=0;可以对这种情况进行模式匹配，判断是否存在一个包含等值比较的俩个常量子句。检查结果是否为真，如果结果为假，则将它们重写为真或假。在某些系统中，如果计算结果为假，pgsql和洽系统可以识别出这是一个不可能的谓词，甚至都不会去扫描表。此外select * from A where val between 1 and 100 or val between 50 and 150;可以直接重写为select * from A where val between 1 and 150;成本估算如何确定一个查询比另一个查询更好？比如对于连接查询，不仅需要知道俩个表的数据量，还需要知道这些谓词的选择率，才能确定要过滤掉多少元组。对于连接操作，有多少数据会被过滤掉，通过连接运算符之后，又被传递到投影运算符。计算预估这些非常困难，因为我们实际上无法运行真实的查询，那样做会太慢，这是一个np-hard问题。我们仍然需要用到成本模型，帮助我们预测在特定数据库状态下的行为。成本模型将由俩部分组成，物理成本和逻辑成本物理成本指的是，cpu时间消耗，IO使用量。包括不同运算符的缓存命中次数。逻辑成本指的是运算符输出的元组数量。以及这些元组的大小，列入包含的列数。这些信息可以帮助我们判断是否应该提前进行投影操作。然后将这些信息传递给下一个运算符，用于预测该运算符的预期输出。大多数系统会结合俩者。高端的企业系统更侧重使用第一种方式。pgsql主要使用IO成本。DB2会在启动时运行一些列基准测试，测量CPI，磁盘和网络的速度，并将这些结果用作物理成本计算中的常量。以pgsql为例，他们只是有这样的概念，有CPU，IO成本，它们的成本是彼此相关的。顺序IO和随机IO也是如此，它们的成本是相对的。因此可以设置一些参数来调整这些成本，例如顺序页面读取的默认成本为1。随机页面读取的成本是该值的若干倍。在pgsql的文档中有一段这样的警告：这些值是no well-defined，如果想让系统正常工作，必须正确设置这些参数。固态，机械可能会让这些值不准确。比如SSD的性能会随着存储容量增加而降低。比如磁盘空间在100时这些参数可能是最佳的，但是磁盘空间到90%时，最佳参数可能就需要调整了。我们如何估算这些不同操作符的输入和输出，以便我们能以物理成本估算等方法老评估工作量呢？每个数据系统都会维护一个目录，查询优化器会利用这个目录来维护关于数据特征的统计信息。系统使用统计信息来评估不同操作符中各种谓词的选择性。从而预估需要完成的工作量。大多数系统会在后台自动维护这些统计信息，你也可以通过sql命令手动触发更新。这实际上是在后台调用一个查询。对标进行顺序扫描，并计算出数据信息的内部表示，如直方图或者草图等。比如oracle数据库会在每天晚上9-11点运行。重新计算所有数据。在pgsql中，如果一个表的数据更新超过20%，就会触发统计信息更新。简单来说，对于给定的表，选择性是满足谓词条件的元组所占的比例，这些元组将会作为操作符的输出。假设查询年龄为9的数据，在45条中查询出来4条，那么该谓词的选择性就是4/45。现在我可以用这个信息来确定连接的先后顺序，我们可以用它来判断哪个表在谓词下推之后的数据量更大。在很多情况下通常假设数据是均匀分布的，我们不可能为每个值维护直方图，假设有10亿条记录，10亿个唯一的值，我们不想重复存储只出现一次的值。因此需要对数据进行合并或聚合，以简化处理。系统还需要假设各个谓词之间是相互独立的，但是情况不是总是如此。此外在进行连接查询的时候，我们通常假设被连接的表中存在一个匹配的key。但是情况总是并非如此。对于直方图，我们可以用等宽直方图或者等频直方图来进行数据压缩优化。还有一个场景的方式是采样。并不是所有系统都这样做，方法的核心思想是从原始表中抽样一部分数据，组成一个样本集，它会被存储在目录里，或者想其他表一样存储在数据库里。如果现在要对谓词成本进行评估，不运行完整的查询，只是在样本的数据上进行一个线性扫描，这样就能更好的估计世纪的选择性了。比如采样中有三分之一的数据符合谓词查询的结果，就可以作为评估的依据。并发控制有些数据库比如sqlite，允许有很多读取者，但是只允许有一个写入者。但是mysql和pgsql中，它们都允许多个事务同时运行，因为这样性能更好。我们需要解决操作的任意交错执行。意味着并行的结果需要和串行执行是一样的。数据库的事务只能控制内部发生的事情，如果发消息队列和数据库一起，我们则并不能保证俩者的原子性。因此我们需要定义正确的含义，并判断事务在交错执行中，这些操作是否有效。ACID中的C其实有点勉强，因为这个C是要开发者编写sql的正确性才能保证的。原子性有俩个方式保证原子性，最常见的日志记录。这里的日志记录是跟踪数据库在运行时所做的所有操作的机制，将这些操作写入log，就像一个分类账。实际上日志可以独立于数据库本身进行维护。今天几乎每个数据库都在使用某种形式记录日志。在某些情况下可以获得更好的性能，对于可审计和可恢复性也很有帮助。另一种方法叫做影子分页，每当更新页面时，都会在影子空间创建该页面的副本，然后应用更新。在提交时，只需确保以原子性的方式将所有这些页面安装到数据库系统当中。这是IBM在1970年代首次实施的。但是出于性能考虑，这不是一个好主意。需要在页面上进行碎片整理，因为现在所有的更新最终会在空间中产生碎片，必须重新使用这些空间。这会严重影响顺序扫描性能。实际上很少有数据库使用这种方法（couchdb和lmdb）。不过相比于日志记录，影子分页的优点是，崩溃后恢复速度非常快，几乎是瞬间完成的。因为要做的就是回到之前数据库的这种一致性或者正确状态。系统崩溃时，未完成事务残生的修改都保存在一些脏页中，重启后这些脏页会被直接忽略。WAL恢复需要一定时间一致性其实如果事务本身没有问题，数据库的状态也是正确和一直的，那么事务运行后，数据库的状态应该也是正确的。应用程序必须告诉数据库怎么样才算正确，你可以通过完整性约束来定义正确性。总的来说数据库系统会保证，在事务运行前后，所有完整性约束都会成立。一般来说这是在每个查询基础上完成的，但也可以先让事务做任何操作，只有在最后提交的时候做检查，因为有些操作可以批量处理。但是大部分系统在执行更新操作的查询之后会立即检查完整性约束。在分布式数据库，和nosql中存在最终一致性的概念。一致性在分布式上下文中更有意义。隔离性我们希望数据库提供的这种抽象，事务可以运行，并且应用程序不必担心它是否会看到来自同事运行的其他事务或者其他不符合预期的数据。我们希望提供一种假象，我在独占数据库的运行。为了实现这种并发性，我们可以并行对不同对象的读写。数据库中实现了并发控制协议。比如二阶段锁定（2PL），乐观并发控制（OCC）。它将成为我们系统中的一种算法或实现。通常分为悲观锁定和乐观锁定。大多数系统通常只实现一种协议。可序列化调度将是一种任意交错操作的事务调度，它使数据库达到的状态和按串行执行时顺序一样。如果调度的事务在同一对象上执行操作，我们将次定义为事务间的冲突。因此根据调度中可能发生的冲突类型，会出现不同的异常。可以有读写冲突写入冲突写写冲突，丢失更新脏读：事务允许读取尚未提交事务写入的数据。不可重复读（unrepeatable read）：一个事务在多次读取同一对象时获取不同的值。当对oracle设置为SERIALIZABLE的时候底层其实也不是串行执行。冲突可串行化俩个调度表当且仅当它们对相同对象进行多个操作时，才被认为是冲突等效的。冲突操作的排序，比如在同一对象上的读写，写写，他们的顺序必须一致。我们可以通过构建依赖图。可串行化是在问：一个并发执行的结果，能不能找到一种串行执行顺序，使两者的最终效果完全一致。一个并发执行，如果最终效果等价于某一种串行执行顺序，那么它就是可串行化的。一个节点等于一个事务，一条边表示一个事务必须先于另一个事务。只要图中没有环，就说明不会发生任何冲突。因此这个调度不是串行化的，它不等同于串行排序。直观上看t1的输出取决于t2做某事。t2的输出取决于t1做某事。上图中的依赖没有成环，因此，这里是可串行化的。即使这些事务可能在不同的时间启动。我也会保证数据可串行化的顺序提交。视图可串行化即便t3的begin命令在t2的begin命令之前启动，t3也会在t2之后执行。如果事务执行后的最终结果等同于某种串行排序执行的结果，即使由于冲突实际上不应该发生这些操作，那也没关系（结果可串行化）这里写入了不应该允许写入的数据但是这里唯一关心的是A的最终值，这里最终重要的是t3的写入值。没有人关心T1读了什么，t2又读了什么呢？数据库的最终状态就是最后写入者的内容。以上执行顺序，相当于t1，t2，t3一次执行调度。视图等价性不仅允许所有冲突可串行化的调度，还允许其他视图可串行化调度，尤其是那些执行忙写的速度。支持视图可串行化，意味着能更好理解应用程序的需求和事务本身的含义，从而获得更好的并行性，但这通常需要静态分析，甚至人工判断是否可行，这就是为什么没有什么实际系统能够支持。我们想象调度的所有可能是一个空间，最里面是序列化调度，外面是冲突可序列化调度，它是串行的超集。再往外是视图可序列化调度。Serial：事务一个一个执行，没有任何交错。Conflict Serializable（CSR）：虽然交错执行，但可以通过交换不冲突操作变成某个串行顺序。View Serializable（VSR）：不能通过交换操作变成串行，但最终每个事务看到的数据以及最终数据库状态，与某个串行执行一致。由于判断 View Serializable 是 NP-Complete 问题，因此几乎没有数据库直接去判断它。大部分实际上使用2pl等实现。持久性如果一个事务提交了，并告知了外部系统，那么无论发生什么，恢复后，它们仍然应该能够看到他们的更改。基本思路就是日志记录和影子分页（shadow paging）。这些都是静态调度的内容，意味着数据库提前知道事务的所有操作。也就是说，所有的渡河写操作都会提前声明，因此我们可以决定如何交错它们，已确定是否可以产生冲突串行化。但是真实的系统中，大部分系统都不是这样工作的。除非使用存储过程。大概nosql运动盛行时，许多人认为事务是个坏主意。但是后期大部分nosql都增加了事务。从程序员的角度来看，假定事务以串行顺序运行，处理起来就容易多了。spanner论文：We believe it is better to have application programmers deal with performance problems due to overuse of transactions as bottlenecks arise, rather than always coding around the lack of transactions.实际上，大部分 OLAP 数据库都支持一定程度的事务，只是它们更关注批量数据导入的一致性，而不是高并发事务处理。Lock我们可以通过锁来解决不同事物的隔离性。latch保护数据结构的物理完整性（内存）lock保护数据内容的逻辑一致性（事务）维度Latch（闩锁）Lock（锁）保护对象内存数据结构（物理）用户数据内容（逻辑）持有周期极短（指令/操作级）长（事务级）实现方式硬件原语（CAS）+ 自旋锁管理器 + 等待队列死锁处理靠约定顺序，不检测死锁检测或超时回滚行为模式互斥、读写锁（共享/排他）更复杂（行、表、间隙、意向锁等）用户可见性完全透明可通过SQL和隔离级别影响对于latch来说只有读写锁。对于lock来说我们有共享和独占俩种模式。共享锁意味着读取，独占锁意味着写入。在使用latch时，我们必须以正确的编码方式编写代码以避免死锁。对于lock，我们使用额外协议的处理系统内部的死锁。因为查询是程序员发送的，可能会导致死锁。因此数据库系统必须保护开发者，防止它们因自身的原因导致系统故障。我们将采用流量协调器（traffic coordinator）或者事务协调器（transaction coordinator）它可以检测到死锁。或者在某些情况下，可以通过对锁请求排序，避免死锁的发生。lock不再像b+树中的latch那样保存在数据结构内部，而是位于节点本身中。我们现在在数据库系统中设置一个集中管理器，称为事务管理器或锁管理器。它本质上是一个哈希表，跟踪现有锁的信息。谁以何种模式持有锁，以及使用优先级队列活队列来跟踪哪些事务正在等待获取该锁。与latch相比，维护lock信息的机制更加昂贵复杂，latch则更为轻量。我们之所以要这种重量级的管理器，是因为考虑到事务持有锁的时间，我们认为值得付出这样的代价，在一个中心化的位置跟踪所有锁。此外，事务通常不会显示地请求锁定和解锁。这种事情通常是自动发生的。比如运行一个select查询，系统会自动获取共享锁。当进行提交操作的时候，系统会自动解锁。Two phase locking我们有共享锁和排他锁。这是俩种最基本的类型，实际上可以参考数据库手册，会发现它们的兼容性矩阵要复杂许多。锁的的类型有很多种，对于不同类型的对象，用于更新表的目录，用于目录更新与索引更新。事务在对任何对象进行读写操作之前，都必须像锁管理器申请该对象的锁。可以进行锁升级操作，页技术说，如果持有一个对象的共享锁，下一步要进行写操作，我们可以将已经持有的共享锁升级为排他锁。锁管理器的职责是记录哪些事务持有哪些锁。以何种模式持有，以何种模式等待。事务完成操作后会释放对应的锁。锁管理器通常以哈希表的形式出现。在某些系统比如pgsql中，可以像查询普通数据库一样查询锁管理器。在记录日志信息时，我们不需要将锁表的内容保存到磁盘，因为系统崩溃重启之后，这些信息已经没有意义。俩阶段锁是一种协议，它规定了如何释放获取锁。这是20世纪70年代首个被证明正确的并发协议，无需预先知晓所有查询，即可实现事务的可序列化。IBM在开发System R系统时发明了该方法，并在20世纪90年代荣获图灵奖。增长（growing）阶段：事务可以持续请求锁，锁管理器照常授予或者拒绝，一旦事务释放锁，就会进入自动收缩阶段。进入收缩（shrinking）阶段后，事务便无法再获取新的锁，只能释放锁。这就避免了之前的问题，即解锁A之后又尝试获取另一个锁（当释放锁，导致被其他事务修改时，自己看到的数据就不一致了）。俩阶段锁可以保证事务或调度冲突是可序列化的，因为任何先例图，或者事务序列总是无环的，也就是说不会导致它们之间产生循环的边。但是会产生性能问题，被叫做cascading aborts 级联终止。在这里如果t1发生回滚，但是t2正常执行，就会产生问题。这样的调度安排在俩阶段锁定下是允许的，但当t2尝试提交，或任何事务读取了来自一个未提交事务的数据时，就必须等待，确认其他事务是否成功提交，之后才能提交。其实你不希望任何人读取到你已经写入，但尚未提交的内容。我们将对俩阶段锁进行微调以规避这一特定的问题。这被称为string strict 2PL。其中不存在任何收缩阶段的切换，实际上在收缩阶段不会释放任何锁。只有当事务提交时，才会释放你拥有的锁。有一个略微宽松的版本，称为strict 2pl，允许在收缩阶段释放共享锁。但是会将独占锁保留到最后，但是强严格模式下，共享锁和独占锁会被一只保持到事务结束。因此如果有一个方案不允许其他任何事务读取或写入来自另一个事务的数据，直到该事务完成提交，那么该调度方案就被称为严格调度，即操作的严格顺序。确保我们不会修改或读取正在进行中的数据。这样的好处是可以避免上述的级联回滚问题。而且这实际上简化了实现过程，因为现在当一个事务回滚时，由于我们知道没有其他事务可能读取过我们写入的数据，因此回滚操作会变得非常简单。当然，实际上没有显示的解锁命令，没有一个sql命令是用来执行解锁操作的。大多数系统会之间提供，序列号隔离级别，默认情况下，你会得到严格的俩阶段锁定。处理死锁有俩种方式处理死锁死锁检测是用一个后台线程，来检测死锁何时可能发生，并尝试解除它。预防指的是我们用某种方式对事务获取锁的顺序进行排序，确保不可能发生死锁。死锁检测这本质上是一个事务循环，这些事务都在等待另一个线程释放锁。除非我们采取措施，否则这些锁永远无法释放。对于oracle这些数据库，会同时使用俩种方式，并且根据工作负载的不同，采用不同的方式来切换使用这俩种机制。它还会维护许多统计数据。如果要进行死锁检测，应该终止哪个事务，并获取其锁，以便将其提供给其他事务。从而最大限度减少资源浪费并提高性能。这里需做权衡，我们需要考虑死锁检测工作线程的积极程度。如果每60秒运行一次，那么检查循环的开销会很低，最坏的情况下可能需要60秒才能解决死锁。但是如果让死锁检测器每微妙运行一次，那么基本上就是在浪费cpu的资源，一遍又一遍的检查死锁。当一个事务正在等待另一个事务的缩时，该事务等待图就引入一条边。通常找到这些循环是np-hard问题，因此数据系统将运行一个简单的形式，有限寻找设计俩个事务的循环。但是如果没有通过简单的检测找到换，那么就要运行更耗时，更复杂的检测。但是如何选择受害者是一个复杂的问题。这体现了企业系统和开源系统的区别。因为我们在决策中会考虑很多不同的因素，这些因素可能会因应用程序的需求变化。在企业系统中，你可以调整参数来制定如何选择死锁受害者。最可能的情况是，我想运行这个事务，然后杀死最老的事务，或者选择最年轻的事务。也可以基于已经执行的查询数量，优先保留已经进行了更多查询的事务。也可以根据锁定的项目数量，杀死锁较少的事务，因为获取锁是昂贵的。也可以是回滚事务的次数，如果这个事务被反复一直回滚，就可以让这个事务优先执行。另一个问题是，想回滚多远。最简单的做法是完全回滚该事务的所有操作，然后将错误告诉应用程序，让应用程序决定是否重新启动。某些情况下可以使用savepoint进行部分回滚。可以将其理解为事务生命周期中的一个书签。如果发生死锁，将事务回滚到这个保存点，然后重新执行后续操作，通常只在存储过程的情况下才这样做。因为存储过程可能包含应用程序逻辑，根据查询结果不同，会执行不同分支和查询。在某些情况下，即使部分回滚，最终仍能实现可序列化的排序。死锁预防当一个事务请求锁时，系统会立即判断是否允许该事务获取此锁，如果该锁已经被其他事务持有，系统会判断当前事务是否可以等待，还是直接强行终止持有锁的事务，夺取其资源，还是选择放弃自身，因为无法等待。这种方法不需要额外的后台工作线程。我们不需要图，无需寻找循环依赖，因为这种请求排序和事务处理方式能够确保不会发生死锁。因此我们假设时间戳较旧的事务具有更高的优先级，这种死锁预防算法的俩个变种是wait-die和wound-wait关键在于，如果存在俩个事务，它们时间戳永远不会相同，因为否则它们就是同一个事务。等待-死亡算法的核心思想是，年老的事务可以等待年轻的事务释放锁，那么请求锁的事务可以等待持有锁的事务释放锁，然后获取该锁并运行。如果请求锁的事务比持有锁的事务年轻，则必须终止自身，因为不允许等待。创伤-等待核心思想是，年轻事务可以等待老年的事务。因此请求事务的优先级高于持有事务，那么持有事务必须终止。你可以射杀另一个事务，然后获取它们的锁并且释放。一个是射杀，一个是自杀。锁粒度到目前为止讨论的内容都是一个锁对应一个对象，我们只是大致假设它们是元组，实际上不一定是。但是问题是，如果我们必须获取大量的锁，那将非常昂贵。访问锁管理器不像latch那么简单，因为需要先获取锁管理表，如果每次更新十亿个元组会非常昂贵。这就是为什么我们现在要拖过不同的锁粒度引入分层锁定的原因。当一个事务想要获取锁时，我们可以决定它想要获取的该锁的范围，即粒度。我们加更粗粒度的锁，这样只需要少量访问锁管理器。mongodb刚推出时，整个数据库只有一个全局锁，这意味着，即使只更新一个元组，也会导致其他人无法读取和写入数据库。理解这一点的关键在于，将数据库视为一种层级结构，在顶部有一个数据库，数据库由数据表组成。现在有一个T1，如果T1获取了表锁，那么该表在层级结构中的下级节点，即该树节点的所有后代，都会被隐式锁定。因此获取表锁会隐式锁定其下所有内容。在锁的层级结构中，最常见的是表锁和元组锁。页面上的锁可能是另一个最常见的内容，但是并非所有系统都实现它们。某些系统在进行DML和DDL时会锁定数据库，但不表锁和元组锁常见。非常罕见的是单个属性或者单个列上具有非常细粒度的锁（yougabyte）。这就是意向（intention）锁的用武之地，这个想法是在此层级结构中的较高级别节点上的提示，用于告知事务，你在树的较低层级，以共享或独占模式获取对象。意向锁会告诉你，底层持有一个显式的锁模式，在上层，持有模糊的意向锁。意向锁主要分为三种意向共享锁（IS）：提示其他事务在层级结构中的某个较低层级，正在使用显式共享锁获取某些对象。意向独占锁（IX）：提示其他事务在底层有事务正在使用显式锁独占获取对象。共享意向独占锁（SIX）：实际上是俩中种锁的结合，是意向共享锁和意向独占锁的结合，以共享模式读取下方一部分数据，也会以独占模式更新下方的一些对象。俩个事务可以在同一对象上持有不同的锁，只要这些锁是兼容的。因此现在我们需要跟踪给定对象上持有锁的模式，可以是一个或多个。在进行six锁的时候数据库并不知道你需要改哪些行，所以SIX锁和IX锁是不兼容的。我们可以支持锁升级，如果系统意识到底层正在获取大量的锁，那么最好返回上层结构，升级已持有的锁，将其至于显式模式。这样就能获取所有需要的锁，无需在锁表中为每个所都添加条目。因此可以减少所管理器的次数。实践中不需要显式制定获取或释放锁，在某些系统中可以获取对特定资源的独占锁，但大多数应用程序并非这样设计。for update当执行select 查询是，数据存储会自动将读取的对象锁定为共享模式。一个常见的模式是读取，修改写入。我们可以在执行查询的时候，直接使用独占模式，因为稍后将会更新这些数据。这会强制系统在尝试获取锁并读取数据时，立即进入独占模式获取锁。也可以设置共享锁，比如select for share，如果采用较低隔离级别，会强制事务持有锁的时间比更低隔离级别下更长。select for share能防止其他事务更新该记录。对比维度普通的 SELECTSELECT ... FOR SHARE锁的类型表级共享锁 (ACCESS SHARE)表级行共享锁 (ROW SHARE) + 行级共享锁 (FOR SHARE)锁定对象整个表查询返回的特定数据行目的保证在查询表时，表不会被删除或修改表结构的操作（如 DROP TABLE, ALTER TABLE）破坏。告诉数据库：“我可能要修改这些行，请在我检查（读取）期间阻止其他事务修改它们。”对其他写操作的影响不阻止其他事务修改数据行。主要保护表结构不被删除或大改。会阻止其他事务对这些行执行 UPDATE、DELETE 或 SELECT FOR UPDATE/FOR NO KEY UPDATE 等操作。它会等待直到锁释放。对并发读取的影响通常不阻塞其他普通的 SELECT 查询。不阻塞其他 SELECT FOR SHARE 或 SELECT FOR KEY SHARE，即多个事务可以同时持有共享行锁。skip locked另一个功能叫做select skip locked。这里可以执行select查询，但是不必等待获取sleect语句中任何数据的锁，我可以指示数据库系统，忽略已经被锁定且无法获取的数据，直接跳过。这会将数据库的抽象概念或者事务处理机制暴露给应用，有缺点和优点。OCC时间俩阶段锁定可以理解为一种悲观的协议，它要求你必须获得想要操作的锁才能进行并发。然而在某些工作负载环境和数据库中冲突可能很少。大部分事务周期都很少。大多数事务都是，开始事务，读取和写入少量数据，然后提交，一气呵成。假设冲突少周期短，那么总是要在事务开始前获取锁不是什么好办法。更号的协议可能针对无冲突场景的优化系统即，事务不会尝试访问或修改相同数据。并发协议分为俩类，一类是悲观，一类是乐观，乐观依赖于时间戳。数据库会为每个事务分配一个时间戳，系统根据时间戳来决定事务提交的熟悉。所以接下来时间戳会渗透到整个系统，也就是说，我们会为每个事务分配时间戳。我们也会为数据库中的每个对象分配时间戳。这些时间戳不一定是实际的物理时间，但它会是一个整数，代表我们希望发生事务的某种顺序。通常来说，最好的情况是使用64位时间戳。一些如pgsql之类的系统使用32位的时间戳。对于给定的事务Ti我们会分配一个唯一固定的时间戳，这个时间戳的值是单调递增的，时间戳只能随着时间推移而增加，不能跳回到过去。因此系统中需要某个模块来负责给事务分配时间戳，先假设一个事务只能分配到一个时间戳。我们可以用系统的时钟来实现时间戳分配。可以从本地CPU获取当前时间，用本地时钟可能出现时钟漂移。而且如果时钟精度不够，只能到毫秒级，就可能出现俩个事务在同一毫秒发生的情况。我就必须等到下一个毫秒才能分配新的时间戳。另一个方法是使用逻辑计数器，用atomicinteger，使用CAS保证线程安全。但是这在分布式数据库情况下会存在问题。任何获取这个时间不重要，重要的是它必须随着时间单调递增。存在一类乐观并发控制协议，其中optimistic concurrency control是最主要的协议。OCC如果现在没有冲突，我们就可以把事务在私有工作区里的更改，应用到全局数据库中。二阶段锁定是1976年发明的，OCC是1981年发明的。OCC被分为三个阶段读取：事务可以读取和写入数据库，无论何时从数据库读取和写入内容，都是从全局数据库复制到你的私有区，任何读取都是那份副本。这里复制的仅仅是需要更新的数据，表里可能有上千个字段，但是只想更新其中一条。验证：一旦事务想要提交，自动进入到验证阶段，数据库系统会为你分配事务时间戳。系统会检查它与其他事务是否冲突，这些事务可能是过去已经完成的，也可能是当前正在运行的。写入：当通过了验证，就可以进行写入阶段，在这个阶段你可以应用对于全局数据库所做的所有更改。然后我们将为每个元组设置一个写入时间戳，以跟踪上次写入元组的该事物。我们将更新全局数据库中的写入时间戳，在write操作时，我们来看T2的写集，它是空的，因为我们只读了一个数据，并没有进行写入。因此在该阶段什么也不做。然后T1对A进行写入操作。此时T1还没有时间戳，因为它还没有开始提交，我们直接把T1的时间戳设置为无穷大，这样就足以进行跟踪了。当读取A是不会访问全局数据库，而是直接读取之前修改过的同一个条目。然后进入验证阶段，数据系统会分配一个时间戳，为2，我们进行验证检查是否与其他事务存在冲突。这里发现没有冲突，则允许进行写入，我们将用分配的时间戳覆盖原有的无穷大时间戳。然后我们继续在上面安装更改。这个例子在二阶段锁定中也是行得通的。我们要做的是跟踪每个事务的读写集，并将更改存储在私有工作区中。如果要保证可重复读，必须复制读取的所有内容将其放入私有工作区中。简单期间，我们采用串行验证，这意味着只有单个事务可以检查是否被允许通过验证阶段提交。如果允许并行验证则需要更多的工作。前向（forward）验证指的是检查正在提交的事务，其读写集是否与仍在运行但是尚未运行的任何活动事务的读写集相交。这意味着这些活动事务尚未进入验证阶段。后向验证（backward）则是回顾过去，查看是否有事务进了我本来应该看到但没有看到的更改。前向验证 (Forward Validation)：检查的是当前要提交的事务T，它的写集合（Writeset）与所有还在运行中的事务的读集合（Readset）是否有冲突。它的逻辑是：“我要写的数据，有没有别人正在读？”如果发现冲突，为了维护一致性，通常会终止还在运行的那个事务，保证当前事务能提交。反向验证 (Backward Validation)：检查的是当前要提交的事务T，它的读集合（Readset）与所有已经提交的事务的写集合（Writeset）是否有冲突。它的逻辑是：“我读的数据，有没有在我执行期间被别人改过并提交了？”如果发现冲突，就必须终止当前正要提交的这个事务，因为它读到了过时的数据。前向验证对于前向验证，我们将在每个事务进入验证阶段时分配一个时间戳。然后如果我们的事务要提交，则必须满足以下三个条件之一。当你的事务正在提交时，系统知道哪些事务处于活动状态。并且你会查看它们的写集合，私有空间和所做的修改。满足三种情况就可以进行提交。如果事务T2想要提交，T1已经提交，T1在T2启动前就完整了写入阶段，那么相当于串行执行。T1在T2开始写入阶段之前就完成了写入阶段，即使T2仍在运行，T2仍然可以对数据库进行读取和写入操作。如果T1没有修改T2读取的任何对象，那么就不存在冲突。T1可以安全提交。简单来说T1是第一个事务，拥有写入集，如果我的写入集与另一个事务的读取集的交集为空集，那么说明男娘没有读取我写入的任何数据。上图中T1不允许提交。数据库状态在t1提交时为1，但在T2尝试提交时，它会被分配时间戳2，这意味着它本应看到时间戳2时数据库状态。但他实际看到的是时间戳0时的状态。因此它看到了不应该看到的数据。正向验证是为了保证其他人不会错过你的更改。在上图的情况中，T1和T2都是允许提交的。其实还需要用锁来保证验证线程的读写安全。第三种允许的情况是需要确保当前事务的写集合补语其他事务的读集合相交。因此上图的情况，俩个事务都可以正确提交。假设当前要提交的是中间这个事务，正向验证的思路是，在事务提交的实际节点，我们要检查的范围和内容包括从当前事务开始，所有其他事务的读写集合，这些事务在我们提交时，或者说在处于验证阶段时仍然在运行。因此你不需要关心过去运行的事务。如果它们已经提交，就无需考虑了。（其他事务能提交，说明其他事务已经通过了检查）反向验证反向验证表示，假设T1在此处需要提交，不需要考虑任何活动的事务，T2只需要回顾最近提交的事务，检查它们是否写入了我本应该看到但实际上没有看到的内容。因为这些更改最初是在私有工作空间进行的。如果T1修改了对象A，然后T2读取了对象A，T1提交时这些更改才会被应用到全局数据库，因此T2就丢失了这次写入。总结在实践中，冲突较少时，OCC效果很好。比如数据库规模庞大，事务访问模式也很均匀，那么OCC的效果会非常好。如果所有事务是只读事务，因为不需要获取共享锁。俩阶段锁定中获取锁和维护锁表有巨大开销，将数据复制到私有工作区也会产生很大开销。虽然可以通过增量复制解决这个问题，但是复制内存始终是非常耗费资源的。验证和写入阶段会成为瓶颈，因为在应用更改时，需要获取latch来保护数据结构。此外终止事务的成本在OCC中也比2PL昂贵的多。因为在一个2PL锁定中，如果要访问十亿个对象，但无法获取第一个对象的锁，事务就会终止，这样实际上没有造成任何的资源浪费。但在OCC中会先更新所有10亿个对象，然后尝试提交，最终发现第一个对象仍然无法获取锁，因此不得不回滚所有操作（必须完整执行整个事务，才能确定它是否成功）。幻读：插入和删除上面2PL和OCC都只讨论了更新操作，事实上数据库还存在插入和删除操作。第一个事务想要统计人员表状态为lit的记录数量。这就像发生了不可重复读。执行了相同的查询返回了不同的结果。这在串行排序或可串行化执行下是不应该发生的。2PL无法防止这种情况。因为导致第二次查询不正确的记录，在第一次查询时并不存在。因此我们无法锁定不存在的数据，因为我们不知道该锁什么。因此基础版本的2PL和OCC只有当对象是固定不变的情况下才能正常工作。这个问题被称为幻读（phantom read）。幻读的定义是：两次执行同一个谓词（predicate）查询，返回的结果集发生了变化，因为其他事务插入、删除或更新了满足该谓词的记录。但是数据库底层锁实现是按索引区间来做的。因此在事务中多次扫描一个表的某个范围，并且在俩次扫描之间有另一个事务插入或删除对象，那么就会产生不一致的结果。有几种方法来解决这个问题锁定索引资源，最简单的方法，但是对性能不利。（不常见）提交时重新执行每次扫描已确认结果是否一致。（少见）谓词锁：检查不同操作或查询之间的逻辑冲突那，以确定是否存在可能导致幻读的冲突。（非常少）索引锁：一来索引本身保护数据的范围（最常用）重新执行扫描：这在内存数据库中最为常见，追踪每次要查询时用到的where子句，当事务需要提交时，除了执行验证阶段（无论采用的是2PL还是occ）都需要重新执行查询中的扫描部分，检查是否有新的结果单身。如果有一个带有where子句的更新查询，希望确保更新所有符合条件的记录。有些数据库比如dynamodb和faunadb会采用类似的做法。它们称之为reconnaissance transactions（侦查事务）。简单来说，限制性所有的查询，但仅仅是为了观察它们的读写行为，并不实际修改任何数据，提交阶段再执行这些查询，检查结果是否一致，一致就apply不然就回滚。谓词锁：（predicate lock）是一种逻辑锁定方案，用于检查事务尝试持有的锁在多维空间中是否冲突。hyper，duckdb，cedardb，umbra使用了这种方案。实际上这是SystemR在20世纪70年代首次推出的。但是实现过于困难。除了20年代名为精确锁定的近似计数外，几乎没有真正实现它的只有上面四个。假设对于事务T1的扫描查询，在多维空间中村在一个由where子句指定的有界区域，该区域对应于可能存在的元组。因此该区域拥有一个作用于多维空间的逻辑锁。现在我们要检查的是任何其他查询的多维空空间是否与该区域存在交集。这是只是一个二维投影，如果它们重叠或者相交，则表明它们在逻辑上访问的是相同元组。但是考虑到有成千上万的事务和各种复杂的查询这会变得非常昂贵。要完全实现这点，复杂读是np-complete的索引锁人们实际上做的是，利用索引来管理特定取值范围的锁，从而检查这些取值范围是否存在并发冲突。这些锁本质上和元组锁类似，实际上它们可以是B+树中的节点。最简单的锁类型是键值锁。在某个属性取值范围内我们对特定的键值进行加锁。锁管理器会记录，对于B+树特定范围内的键值存在一个锁，例如可以包含键值14的锁。你还可以使用gap lock（间隙锁）用于跟踪索引中相邻键值之间的间隙。然后就可以对这些间隙进行加锁。现在可以将这些锁组合成键范围锁，对于给定的键，键范围锁既包含该键上的锁，也包含紧邻其后的间隙锁。比如查询了一条不存在的记录的时候，我们仍然可以进行锁定。当然也可以锁定(12,14]。但通常只沿一个方向加锁，以避免不必要的锁重叠。现在可以将上次讨论的分层锁机制应用。在更大范围采用更粗粒度的锁。比如我可以获取[10,16)之间的间隙锁，并设置为意向排它模式。因此依赖数据结构本身可以帮助我们识别或划分希望维护锁定的对象范围。然而实际上大部分数据库不会以可串行化的方式运行。关闭其中的一些特性，可以获得更好的性能。串行读：强严格的2PL和幻读保护（index lock）重复读：和串行读一样，但是没有幻读保护提交读：和重复读一样，但是S锁被立即释放未提交读：类似重复读，但允许脏读（没有S锁）理论上可以指定每个事务的隔离级别。每个事务以不同的隔离级别运行。oracle最高隔离级别是快照隔离。如果你要使用serializable隔离级别，它会说没问题，但实际上提供的是更弱的隔离级别，叫做快照隔离。google的spanner可以保证事务到达系统的顺序就是它们提交的顺序。没有一个系统同时使用OCC和2PL协议，因为实现其中一个就非常的困难。MVCC快照隔离从概念上来说，在OCC里面发生的事情，就会发现元组存在多个版本，也就是说在数据库中，一个逻辑元组对应多个物理版本。一个逻辑元组可以理解为由主键标识的对象。在使用OCC时，如果我们要修改某个元组，我会先把它复制到私有工作区。这样就有了另一个物理版本，也就是那个逻辑元组的另一个物理副本。MVCC也是同样类似的思路，只不过这次更加彻底，数据库系统拍给你可以为每个逻辑元组货逻辑对象维护多个物理版本。因此任何时候想更新数据库，都会创建一个新的逻辑版本。在OCC中这个新版本只是把整个元组复制到了私有工作区，但其实还有更好的做法，比如只复制修改的部分。然后现在任何事务要读取这个对象时，都会看到一个视图，我们称之为快照隔离，也就是事务启动时数据库的状态快照。这意味着，如果另一个事务与你同时执行，但它在你之后启动，而且没有任何更改，那么你是看不到它的。因为你看到的是数据库在启动的逻辑时间点的一致性快照。MVCC可以和OCC与2PL以及其他协议结合使用。我们任然要应用之前讨论过的所有并发控制方法，来确定谁在什么时候可以更新什么内容。这种架构和系统工作方式基于一个理念，即创建事务的多个版本。MVCC的首次描可以追溯到1978年的一篇MIT博士论文。但这篇论文属于系统领域，直到几年后人们才意识到这实际上可以应用于数据库中我们关注的并发控制问题。MVCC的第一次实现是在一个RDB-VMS的系统中。然后另一个是名为InterBase的系统。现代几乎所有数据库系统都会使用mvcc。MVCC有俩个核心思想，写入者不会阻塞读取，读取也不会阻塞写入。当一个事务出现的时候，它们将被赋予一个时间戳，该时间戳会说，这是允许你查看该时间戳的数据库的一致性快照的时间戳。因此你不会看到任何未提交的更改。现在因为读取者可以在某个时间戳读取内容，当事务想要写入对象或元组时，它们会创建一个新的版本。这不会干扰其他人读取旧版本。因此我们将允许事务获得一致的快照，而无需获取显式的锁。因此，自然的我们会得到一种隐式的隔离级别，叫做快照隔离。这意味着我看到的数据库，就像它在某个时间点的样子。因此当20世纪80年代，人们大力宣扬MVCC的一大优点就是可以支持所谓的时间旅行查询。理论上，你可以说，帮我运行这个查询，但是要基于三个星期前的数据库来运行。如果你有所有的数据版本，而且你也没有做任何的垃圾回收清理，那么这件事情就变得简单了。因为你只需要搞清楚，三个星期之前的时间戳是多少。然后就去查看那个时间点的数据。pgsql最初就带有这个功能，这是pgsql在1984年推出的时候，经常被拿来说的功能。但是后期这个功能被砍掉了，因为如果不进行垃圾收集，存储空间很快就会耗尽。在事务开始时，我们必须分配它的开始时间戳（begin time），因为它需要有一个一致性快照。用哪个时间来查看版本。（OCC只有在准备提交的时候才会给时间戳）因为得直到你能看到啥。现在要读取A最终会定位到元组的A0版本。然后就能读取它。T2把开始时间戳设置成T2的启动时间戳。然后回到之前的版本，把结束时间戳，设置为T2的时间戳。为了让其他事务知道哪些版本已经提交，因此维护一个Txn事务状态表。在这个表里跟踪所有在系统中活跃或正在运行的事务ID。这里记录者它们的市场价，也就是事务启动时，被分配的时间戳。然后提交状态，或者说这些事务的当前状态。中止，提交还是已清理。在下图中T2为停滞，直到T1完成提交。每次执行写入时，创建一个新版本，结束时间是无穷大，索引，我像链表一样跟踪版本链，我会看到A0，其实时间0，结束时间是无穷大。很明显这种方式不同于串行执行。这是因为快照隔离容易受到我们尚未讨论的成为write skew anomaly的影响。快照隔离的基本思想是，你可以获得事务开始时存在哎的数据库的一致性视图。因此你不会看到来自事务的任何脏写。如果它们更新了五个数据线，你将不会看到三个错过其他俩个。但是当应用更改时，这并不等同以串行执行的结果。假设我们有俩个事务，一个想把白球涂成黑色的，一个想把黑球涂成白色的。在快照隔离的情况下。就会变成这个样子。这是允许发生的。俩个事务都更新了自己需要更新的部分。但如果真正的串行执行，结果应该是这样的。要么全白，要么全黑。在pgsql中我们还有一些额外的东西要做。在其他系统中，会对快照隔离进行改造，来支持可序列化。1992年事务隔离级别的原始返回没有体积这个定义。它根据俩阶段锁定义了隔离级别。它不仅仅是像我们之前看到的2PL或OCC这样的并发协议，版本控制的概念渗透到了整个数据库中。目前来说，mvcc最不理想的实现是pgsql。mysql和oracle做的更好。如今大部分系统采用mysql和oracle的实现方案。这是最常见的做法，因为SQL标准没有定义快照隔离，这些数据库就将自己高效的快照实现，安上了标准里“可重复读”这个名字。PostgreSQL：它的可重复读级别就是标准的快照隔离。需要真正的串行化，要用它的“可串行化”级别。MySQL (InnoDB)：它的可重复读读数据时就是基于快照的，但它额外用间隙锁防止了部分幻读，所以不完全等同于纯快照隔离。想关闭间隙锁用纯快照，可以用 READ COMMITTED + binlog_format=ROW。Oracle：它的可串行化就是快照隔离。数据存储可以将逻辑元组维护的不同版本，想象成一个链表。版本链中会包含指针，指向元祖的下一个版本。因此会存在一个包含记录ID的字段，大致沿着版本链遍历下一个版本的位置。访问的入口通常是版本链的头部。有三种方式可以存储版本。仅追加：每次创建新版本时，复制旧版本，将其作为新的元组插入到表中，同时在头部信息中维护版本链。（最差但是pgsql用了）时间履行存储：类似仅追加存储，但是新版本不会存储在同一个表中，而是存储在指定的时间旅行表中。可以把所有的版本都放在那里。因此会有一张主表包含最新或者最旧的版本，在主表中会有指针指向这些辅助表，所有版本数据都会存储在这些辅助表中。delta存储：这种方法其实是最好的，不用存储完整元祖，而只是存储增量信息。如果表里有上千列，前俩种方法要保存全部的数据。增量存储只需要保存被修改过的列。然后把这些数据放在独立的增量存储区域。append only所有数据都放在一个表中。数据库里的没标都会分配一个表空间来存储数据。每次进行更新操作，都会把整个记录复制到表里的一个新位置，写入新的值。然后更新版本链，指向新创建的版本。在这里A0是最旧的，如果要找到最新的，需要沿着版本链直到找到想要的那个。版本链排序实际上也对性能有很大的影响。如果从旧到新排序，意味着每次进行查找时，比如跟随一个索引，查找索引，得到记录ID，这将到版本链的链头，然后沿着版本链进行扫描，直到找到我需要的版本。同样需要查看开始时间和结束时间，来判断此版本是否符合你的需求。从新到最旧的版本链头将是最新的版本，因此当查找索引，并且只需要最新版本时，查找操作首先定位到为止，就是我需要的版本。但是每次更新元组的时候，都需要维护大量的索引。现在必须更新所有条目确保它们都指向链头（如果采用原地更新的存储方式这实际上不是问题，因为如果只是覆盖链表头，那么总是会有新的版本）。time-travel Storage和追加存储基本相同。区别在物理版本不是存储在主表中。而是存储在一个完全独立的表中，这个表本质上是另一个数据表。这个表用于存储所有历史版本。每次更新A2，会将A2复制到时间旅行表中，然后用最新版本更新主表中的主版本记录。最后如图中一样更新指针，让它指向这里的下一个版本。在最初设计为不是多版本的数据库中，会使用这个方法。在这里会创建大量重复的元组副本。比如有1000列，但它只更新其中一列，你仍然需要付出一样的代价。sql server就是这样工作的。delta storage基本思想是每次更新，值复制已经修改的列以及其修改之前的值，把它存储到增量存储段中。mysql将其称之为回滚段（rollback segment）。这就像是一个undo lock。那么我将存储之前的值以及在主表中的版本信息，现在再设置一个指针指向它，并在主表中覆盖，就是该元组最新的版本了。这有点类似于LSMtree。在LSM树种，我们是将新的条目，也就是新的更改附加到树上，而在这里，新的更改是直接应用到主表上的，然后把之前的值放到版本存储里。垃圾收集现在我们必须用垃圾收集来清理无效的数据，否则存储空间迟早会被耗尽。回收哪些不再被任何事务需要的旧版本所占用的空间。意味着没有任何活跃的事务需要访问这些元组的版本。除非我们需要时间旅行功能，否则我们就没有理由保留这些旧版本。那么如何查找过期的版本，如何确定合适可以安全回收这些记录所占用的空间存储？backroom vacuuming：查找过期版本的方法是执行元组级别的垃圾回收。我们查看版本本身，然后说当前正在运行的事务是否还能看到这个版本。我们可以使用专用的工作线程来执行，这被称为backroom vacuuming，或者在是扫描数据的同时进行清理。为了防止每次查找都遍历整个表，可以维护一个bitmap作为一个脏图。用于跟踪哪些页面被修改过。每次页面被修改时，就将对应位置设置为1。所以当清理程序启动时，会查看脏位图，值获取那些被标记为脏页的数据。检查是否可以删除任何条目。清理程序不知道页面是被如何修改的，因为只记录了一个位。这基本上就是pgsql执行的方式。也可以在终端命令行调用vacuum命令，它同样会启动清理。cooperative cleaning协同清理的核心思想是，工作线程会知道哪些内容对它来说是可见的。现在当他们沿着版本链扫描是，如果识别出任何对其他事务不可见的版本。就可以对其进行修剪。即使扫描速度便慢，但是不需要单独的后台线程。这种方法有效，但是仍然需要定期启动清理过程。因为如果更新了一条记录，创建了新版本，使之前版本失效，并且之后再也没有人读取，那么就永远无法回收该空间。sql server会因此产生一些脏数据，需要定期启动后台线程进行清理。transaction-level GC：同样需要跟踪事务，以及它们修改过的内容。例如更新A1、A2，创建A3，然后保留这些旧版本。可以是记录ID，或者指针，用于指示哪些内容已失效。事务提交时，获得一个提交时间戳，将次信息传递给vacuum进程，让其进行清理。这个方法很罕见，大部分系统都采用后台清理机制。不过如果你正在进行差量存储就容易了，因为不用扫描整张表了。只需要扫描差量记录即可。索引管理逐渐容易管理，无论使用什么方案，主键总是指向版本头，如果更新了主键本身，就把这个操作当做删除后插入来处理。因为从理论上，它就是一个新的元组。但是二级索引比较麻烦，因为二级索引的指向会影响处理方式。还需要考虑版本排序，比如从新到旧，或者从旧到新。这样一来更新记录，创建新版本是，可能需要更新所有的索引，让它们指向新版本。那么如果有二级索引，二级索引指向什么呢？一种方法是逻辑指针，它本质上是一个逻辑标识符，帮助你找到版本链头部的实际物理地址。最简单的事情就是存储主键，mysql就是这样干的，通过二级索引会先找到主键，然后通过主键查询位置。另一种方法是使用元组标识符，可以把它看做是一个合成ID。Pgsql存储的是物理版本，也就是指向版本链同步的物理指针。当查找主键索引，拿到对象A的时候，会得到一个记录ID。这是页码和槽号，就能准确找到想要的地方，也就是版本链的头部。如果进行二级查找，会得到这个版本链的头部，或者类似这样的记录ID。而且在OLTP数据库中，通常会有大量索引，尤其是二级索引。这些所有索引都会指向版本链的头部。因此如果我现在更新元组，即使没有更新二级索引依赖的属性，由于版本链的头部会苏子和新的物理地址二改变，我们仍然需要更新所有这些二级索引。pgsql的做法是，实际上在每个版本中都会在索引中拥有另一个条目。如果新版本和旧版本位于一个页面，由于是仅追加的，创建一个新版本，那么就不需要更新索引。因此如果新版本在同一个页面中，pgsql实际上不会更新二级索引。这是最常见的例外情况，能显著减少索引维护开销。如果同时满足这两个条件，就会触发 HOT（堆内元组）更新：更新的列不是任何索引的组成部分。索引所在页上有足够空间存放新版本的行。此时，PostgreSQL 会完全跳过对索引的维护。它只在数据页内操作，并依赖一种指针链，让查询能通过旧索引条目找到新行。索引中，多版本信息总是保存在元数据本身中。大多数系统不会讲这些信息作为索引键的一部分存储。PGsql是例外，会存储一些版本信息，这样就不需要实际遍历版本链。如果版本链分布在多个页面上，原本只需要单页查找就能找到一个元组，现在却可能变成一个全表扫描。这将会影响性能。
CMU数据库导论-1关系模型关系型数据库（Relational Database，RDB）是一种基于关系模型（Relational Model）来组织和管理数据的数据库。它由Edgar F. Codd于 1970 年提出，其核心思想是将数据组织成二维表（Table），并利用数学中的关系代数来操作数据。但是论文中的表述是完全数学化的。IBM发明了第一个查询语言叫做SQUARE，但是这种语言写起来很怪，没有人真正实现它。然后SEQUEL被发明了，由于版权原因被改名为了SQL。SQL在86年就被标准化了，但是规范仍然会不停的更新。这些变化是为了适应人们编写应用的方式，以及对查询语言需求的变化。XML在90年代末20年代初很火，所以增加了XML的支持，Json在2010年火，所以增加了JSON。2023年增加了对图查询的支持，因此neo4j，可以通过SQL，在表的基础上进行图的遍历。如今要构建一个新系统，只少需要支持SQL92，它支持了常见的功能，比如select，from，group by，聚合函数等。平均每10年都有人说SQL要死了，但是它们从未发生。SQL分为DML，DDL，DCL。数据存储文件数据库可以分为多层查询计划-操作执行-访问方法-缓存管理-磁盘管理这一章讨论磁盘管理我们数据库会假设，在数据库管理系统中，数据主要存储在非易失性存储上。系统本质上是在协调数据在非易失性存储和内存上移动。磁盘数据需要加载到内存中才能提取答案。中间这条线以上的任何存储都是按照字节寻址的。按字节寻址意味着，每一个内存地址对应1Byte的数据。地址 数据0x1000 0x110x1001 0x220x1002 0x330x1003 0x44地址0x1000保存1个字节，地址0x1001保存1个字节，地址0x1002保存1个字节。。。。这里和操作系统的位数不同32位CPUCPU寄存器宽度、指针宽度、地址宽度主要为32位64位CPUCPU寄存器宽度、指针宽度、地址宽度主要为64位32位操作系统运行在32位架构上，使用32位指针和地址空间64位操作系统运行在64位架构上，使用64位指针和地址空间在non-volatile上存储的数据都是按块存储的。我们必须以块为单位访问整个页面，通常4KB。现代的存储设备，普遍采用4KB作为物理扇区，以前的硬盘很多是512byte现在通常是4096bytes。如果物理社区大小为4KB每个数据块需要刚好都落在4KB扇区内。如果一条数据跨越了俩个物理扇区，则需要进行多一次的内存读写。这就是为什么需要4k对齐。这将决定我们使用什么数据结构和算法。但是现代计算机中这些界限正在变模糊。现在SSD和HDD中出现了fast network storage，不存储在本地，但可以进行快速的访问，而且它是持久的。此外傲腾其实介于内存和磁盘之间。然而2022年傲腾被砍掉了。傲腾是一种特殊的硬件，向dram一样插在DIMM插槽中，但是如果拔掉电源可以保存存储的内容。如果intel没有砍掉它，很多课程中的内容将会消失，因为我们不再需要DRAM和磁盘之间来回移动数据了。现在替代傲腾的CXL3，是一种字节寻址的内存，它可能并不在你本地的物理机上。三星会售卖这种插在PCIE插槽上的设备，它就像是扩展了DRAM插槽的数量。它就像扩展了DRAM插槽的数量。CXL本身也不能解决断点数据保存。NVDIMM，运行时和普通内存一样，断电时电池继续供电，然后将数据写入磁盘，但是其也没有得到广泛的使用，在部分细分市场。随机访问通常比顺序访问来的慢。即便是SSD，真正读写的是page，顺序访问可以充分利用并行性，现代SSD内部其实有很多Flash芯片，多个channel同时工作，顺序读取几乎能跑满PCIE贷款。随机访问很难形成连续流水，假设读取4KB，可能在chip0,7,3。控制器需要查FTL，找到物理地址，发送命令，等待Flash响应，每一次都有固定的延迟。这意味着我们在设计数据库系统时，劲量选择顺序算法，因为顺序写入比随机写入块。我们假设系统运行在现成的文件系统上，并且我们只是在读取和操作这些文件。页在数据库系统和计算机中，实际上有三种页面概念在最低级别，有一个硬件页面，这是硬件公开的，实际可以执行的原子写入操作的最大大小，通常是4KB原子写入：要么写入完成，要么写入全部失败，不会出现中间状态。OS层级页会有page的概念，以为这是x86在80年代的做法。默认是4kb，在更新的版本中可以使用巨页（huge page）是2mb甚至1GB。在数据库中，基于操作系统页面，我们可以使用不同大小的页面。页面大小因数据库而异。sqlite、oracle、IMBDB2、rocksdb、wiredtiger（mongodb的默认存储引擎）都是4kb。sqlserver，pgsql是8kb，mysql是16kb。这些只是默认配置，是可以修改的。在DB2中，甚至可以给每一个表设置不同的page大小。mysql和pgsql则不能这样做。如果数据库页面很大，比如64kb但是只更新了1kb，我们必须将64kb全部写入数据库。因为我们不能只写入小部分的更新。如果读取操作很多，需要对数据进行大量顺序的扫描，那么我非常需要大的页，因为我要读取页面内的所有数据。我希望以此顺序读取，将一个大页面带入缓冲池，然后快速处理。我们还需要额外元数据来跟踪在文件系统上的物理位置以及其他可用的空间。如果我们的数据是单个文件，那么我们需要维护的元数据对于页面查找来说就非常简单，比如我们想要访问第二页，我们就可以通过简单的算数得出，如果想要第二页，需要的偏移量是什么。sqlite就是这样做的，所有的表都存储在一个文件中。如果有多个文件，我们想获取23页，我们不知道哪个文件可能包含该页面，假设它们属于同一个表，我们要先通过页目录来访问多一次的跳转。页面目录类似一个hash表，不同数据系统有不同的实现方式。每个数据对象都有一个页面目录，或者说，页面目录中会有一个条目。这个条路会记录对于索引y，应该去哪里查找文件，跳转到哪个偏移量。这个页面目录就像是数据库的索引，因为记录有哪些文件，以及它们的位置。我们需要确保这个目录本身也是磁盘上的页面，并且在写入数据页面的同时以安全的方式同步磁盘。我们需要跟踪每个页面的可用空间，这样有新数据插入时，我们不需要顺序扫描所有页面来查找空闲槽或者闲置空间位置。SELECT * FROM pg_freespace('student');我们可以通过这个语句来查询pgsql维护的可用空间映射。我们可以使用vacuum命令来整理空间，本质上是锁定原本的数据表，然后将所有数据页复制到新的数据页中。pgsql使用单独的文件来存储空闲页。每一个页面，都有一个页面头告诉我们关于该页面的信息。假设我们现在将所有数据连续存储为一行，我们先假设单个元组的所有属性都能放入单个页面。一个不太好的初步方案是当元组插入数据库时，我们计算一下，然后跳转到页面中空闲的位置。然后开始写入，直到空间用完。但如果我们删除一条数据呢？如果是固定长度的数据还好，直接插入下一个空闲插槽就可以了。但如果数据长度是可变的，每个元组大小都不同，这很常见。在页里如何处理呢？更好的方法是使用lotted pages，槽页。这是几乎所有面向行的数据库都会采用的。从高层来啊看，他们都有一个槽数组，会移动数据，工作方式基本相同。页面的起始处有个槽数组，固定长度全部指向页面内的位置。页面底部，是所有固定长度或可变长度的元组数据（行记录），当我们新添加元组时，会将它们追加到页面末尾，所以它会从末尾向头部扩展。当空间耗尽时，它们会在中间相遇。所以这个槽数组存储这页面内的偏移量，指示位置1的元组在此偏移处。如果扇出了3，我们只需要在槽数组中标记为元组3已删除，并在header中说明此位置已经清空即可。我们实际可以不将数据进行删除。一些系统会决定，如果这里有空闲的空间，就进行压缩。我们对页的所有操作其实是在内存中进行的，是快速的。在关系模型下，表内的数据是没有顺序的。在元组中存储页码和槽号是没有意义的，因为它已经在那个槽的页面里了。PGsql中存在ctid，是pgsql特有的一个系统列，ctid表示一行数据在表中的物理位置。ctid由俩部分组成，分别是（block_id，tuple_index），第一个为数据页的编号，第二代表改页中的第几行。sqlite中存在rowid。为每个行自动生成唯一标识（不过不是物理地址）。fn_PhysLocFormatter(%%physloc%%) 是 SQL Server 中一个未公开（undocumented）的内部函数，用于把隐藏列 %%physloc%% 转换成人类可读的格式。它的作用和 PostgreSQL 的 ctid 非常相似：显示一行记录当前的物理存储位置。它会显示三元组FileID：数据文件编号PageID：数据页编号SlotID：该页中的槽号（Slot）这个记录id或者叫行id，就是元组的物理地址。数据库知道如何把表示记录的id字节数组转换为文件号，页号和偏移量。oracle的槽号是从0开始，pgsql的槽号是从1 开始的。如果需要更新现有元组，我们需要获取它的记录ID。可能存在更新一条记录时，当前页剩下的空间不足以放下这条记录，那么会先从当前页删除，在其他页插入。有些系统可能会通过整理空闲位置腾出空间，来尝试能否放入修改后的数据。LSM上面的访问方式存在一些缺陷问题：槽数据和元组数据向俩端增长，总会出现一些没有被使用的数据。存在无意义的磁盘I/O操作：如果我需要更新一个元组，即使一个页面可以存储1000个元组，我也必须获取整个页面。如果更新多个元组，比如10个，而它们又不在同一个页面上，可能产生大量不需要的io这引出了LSM数据结构。其核心思想是我们假设只能向块和页面追加数据。浙会加快写入速度，读取速度会因此便慢。日志存储结构的核心思想是，不再使用原地更新的槽页，而是数据库系统维护一个日志，类似于一个账本，记录对元组的所有修改。每个日志条目表示一个put操作，例如插入一条记录，或者一个删除操作。这是最低层级，可以在日志结构存储上执行的唯一基本操作。LSM tree中会有一个叫mem表的内存数据结构，我们用它来对数据表进行原地更新。但这个数据结构不会包含或总结所有可能的键。它只包含工作集，也就是最新的更改。当mem表在某个时候满了时，会转换为一个叫做SSTable的数据结构。这几本上是将内存中的数据结构转换成日志记录。这些SSTable存在荣誉信息，所以会定期对他们进行压缩。Rocksdb中使用跳表作为memtable的实现。如果程序要写入数据，我们只能执行put和delete操作。我们需要设置一个key作为唯一id根据键对这些条目进行排序，这个排序本身可以从叶子节点获得，它会记录下这是日志条目。由于SkipList是有序的，可以直接顺序扫描，然后构造SSTable。如果有太多的SStable，那么我们需要把他们进行压缩，这本质上就是在进行归并排序。我们将Level0的多个SStable合并为一个更大的Level1的SSTable。这里需要找到SSTable中不同key的最新条目，因为我们知道它们发生的时间顺序。就这样逐层往下。现在写入可以很快，插入一条记录不需要查找页面来放置。此外我们为内存表维护者一个单独的预写日志，一旦崩溃，就可以从预写日志中恢复。但是读取会很慢，如果在内存中没有找到，需要从0级开始，在每个ss表中进行二分查找。SSTable中真正可以二分的是index，实际数据长这样Data BlocksIndex BlockFilter BlockFooterindex本身就是(key, offset)(key, offset)(key, offset)在最坏的情况下要扫描全部层级。因此它通过摘要表（summary table）来解决这个问题。摘要表会告诉你Primary index的 哪一部分可能包含目标的key。此外我们还可以通过布隆过滤器来判断某个key不会在，每个SStable都会有一个布隆过滤器。Rocksdb，leveldb，hbase，tidb，yugabytedb，clickhouse，cockroachdb，cassandra，neon，wiredtiger都是基于LSM树。其实部分原因是不同的数据库系统都广泛使用了rocksdb。rocksdb是leveldb的fork，leveldb来自google。Facebook在forkleveldb创建rocksdb时第一件事情就是去掉了nmap，因为谷歌让操作系统管理页面。rocksdb对其进行了正确的重写，并拥有了自己的buffable manager。LSM tree的缺点是写放大，如果对一条记录进行一次写入，它会被多次重写到存储中。比如从level0层到最后的level8，可能要将其写入数十次甚至数百次。插入操作非常快，因为不需要任何数据充足，但是后台必须要多次压缩归并这些数据。索引索引分为很多种，最常见的B+树索引，一个page仍然按上文方式组织，但是页面中的数据不一定有序，但是槽是有序的，因此我们可以进行二分查找。现在当我想基于该键查找记录时，我会遍历数据结构，达到叶节点，获取页面，然后在页头第一个数组中的键上进行二分查找。元组数据库是如何组织元组的呢？如果我们知道行记录的具体数据结构，和他的起始位置，就可以进行访问。然后执行类似reinterpret cast的操作，将任意字节数组转换为我需要的类型。现代cpu希望数据按字大小对齐。因此现代处理器通常用64位，有时还需要考虑将数据保存在64字节的缓存行内。对于现代64位的cpu来说，一次最自然的处理数据宽度通常是64bit=8byte因此创建的表包含无法与这些边界对其的混合类型，就会出现问题，我们需要采取措施，否则访问速度就会很慢。假设需要访问跨缓存行的数据。cpu会帮你隐藏问题，并返回你想要打包对其的数据但本质上需要进行俩次内存或缓存行访问来获取数据。在旧的cpu中，你实际上会收到一个中断，告诉你不能这样做，那是错误的，但现在的x86或者arm都会隐藏这一点。现在在CDATE中的成本会是对其的俩倍，意味着数据库会运行的更慢。最简单的解决方式是增加一些空的空间。当想访问这些不同的属性时，知道如何跳转到字节数组中正确的偏移地址，来获取所需数据。这是最常用的方法。另一个方法是我们可以重新排列字节顺序中这些属性的布局，以便更好的打包到字节数组中，我们可能不能总是能节省空间，但是我们避免了跨字边界的访问数据对于varchar可变长度数据，或者text，blobs这样的大数据。通常会在元组头部存储一个与其数据内联的头部信息老记录数据的大小。时间戳可以用不同的方式排序最常见的方法是存储unix 纪元以来的毫秒数或者微秒数，还需要考虑时区信息。空值有三种主流的方式表示null值，在每一行数据的头部维护一个bitmap，只需要1bit表示一列是否为空，空间录用率高，所有null信息集中存储容易管理，pgsql和sqlserver用的是这种方式。特殊值：使用某个数据类型指定一个特殊值表示为null，不需要额外存储null来进行标记，查询速度快，但是会损失一个合法取值每个字段旁边都放一个flag，实现简单，但是每个字段都要增加一个标志。但是完全不推荐这种做法，因为浪费空间，影响对齐。逻辑上null不等于null。如果写select null =null;没有返回，但是如果写select null is null返回为true如何处理大数值呢？比如一个列的大小可能超过了一个page能存放的空间。比如我们要存储一个文本字段，这个文本字段超过了一个page的大小。我们要做的是有一个溢出页，其与其余数据一起存储内联属性。现在将包含大小和位置信息，这个位置信息是指向溢出页的指针。必须通过该指针重新定向一次，来找的哦啊实际的值，如果新的页面也不够存储该数据，那么就需要添加另一个指向移出区域的指针，并标明剩余部分的位置。当然对于这么大的数据，你可以在磁盘上对其进行压缩，读取时进行解压缩，因为文本字段，压缩效果通常会非常好。数据库不管理溢出页面，而是操作系统文件来管理。列存与行存概要有一种负载是只读查询，比如分析操作视图从拥有的现有数据中得出新的信息。在这些查询中，我们不关心如何让删除插入尽可能的快。只关心查询。数据库的主要负载分为，OLTP：ON-Line Transaction processingOLAP：On-Line analytical ProcessingHTAP：Hybrid Transaction+Analytical Processing相比于其他的负载，OTLP通常更偏重于写入操作。查询不会一次性处理大量的数据。但是如果我们需要提取一些数据模式，或者信息，进行更加复杂的分析查询，早期大部分数据库都是OLTP的。因为只有数据达到一定规模时，OLAP才有意义。关系模型并未规定数据在表或元组中的存储方式。当然也不会规定单个元组的数据必须在同一个页面。比如我们要找一个以特定标志结尾的字符串，我需要扫描所有的记录。存储模型被分为N-ary Storage Model（NSM）通常被称为行存Decomposition Storage Model（DSM） 分解存储模型通常被称为列存Hybrid Storage Model（PAX）混合模型，间距NSM和DSM的特点按行存储对于分析查询是灾难，因为它可能会造成全表扫描。DSM的基本思想是不将一个元组的所有属性连续存储在一个列中，而是将数学的所有元组的值连续存储在同一页中。比如，所有主机名存储在一起，所有的登录时间存储在一起。其他所有列都用相似的方式进行处理。列存在1984年就被提出，直到21世纪人们才意识到这是一个好主意。列存中如何知道A列第二个值，和B列的第二个值属于同一条记录？第一个选择是固定长度偏移。意思是每一列的数据长度固定。如果每一个age都占用4bytes，因为每个元素长度一样，第i个元素就是滴i个tuple，所以不需要存元组的id。因此不需要额外的空间，查询非常快，但是只能适合固定长度数据结构。另一种选择是嵌入元组的id，每个值都带一个元组的id。对于可变长度的数据来说，不能直接用偏移量，因此最简单的方式就是额外增加一个元组id。（最好不要用这种方式）现代数据库几乎都采用value buffer+offset arrayAlice Bob Christopher Tom会保存为Characters AliceBobChristopherTom旁边保存Offsets 0 5 8 19 22最后一个offset表示字符串结束。这个唯一的row id插入数据的时候自然产生的。DSM模型缓解了IO操作，因为我们只需要读取实际上需要的数据。CPU希望你一条接着一条执行指令，避免分支和跳转。但是如果表有1000个属性，插入一个元组就需要写1000个页面。PAX将在一定程度上解决这个问题。在某些系统中会使用一个类似日志结构的系统作为写入的缓冲区，所有的插入更新删除操作都在缓冲区中进行。只有达到一定阈值才会刷新到磁盘上，这些变动会通过日志进行记录以便宕机时进行恢复。因此可以在内存中行存，在存储时列存。有很多不同的方法可以将列存储的概念引入数据库中。pgsql允许每个表最多有16000列。oracle中允许1000列，其实曾经是100（这是一个硬编码的值）我们想要一种方法能获得列存的优势，又能保证同一列的属性值在多个元组中是连续存储的。这就是PAX存储模型的作用。PAXPartition Attributes Access（PAX）是一个物理存储模型。基本思想是，我们不是在表内存储，而是讲数据分成迷你块或者更大的块。他结余行存和列存之间。它被称为rogues，指的是更大数据区域，我们在其中以列的形式存储数据。但单个元组的数据仍然会相对接近。大多生成是列存的系统世纪上都会采用这种方式。PAX解决的问题是，有没有一种方法也之间还是Row Store，但是页内部像Column Store在PAX中，我们首先将元组水平划分为行组。然后在每个行组内，我们将元组垂直划分为块。它的工作方式是元数据不存储在文件头中，二色存储在文件的页脚footer里。因为创建行组填充文件时，会记录放入的内容，然后当文件完成时，把收集到的信息都放入页脚。所以要打开文件先要看页脚，它会告诉你上面有什么内容。pgsql等系统中，它总是在header中，因为那些是动态更新。虽然扫描文件时，还是需要将一整页写入到内存中，然后才能读取，但是只需要读取内存中一部分数据，这对CPU缓存非常友好。在现代数据库中，大量热点数据都已经驻留在 Buffer Pool 中。真正耗时间的不一定是磁盘，而可能是：CPU Cache MissPAX 并不是为了减少 Page 的读取，而是为了让已经读进内存的 Page 更符合 CPU 的访问模式。现代主流列存数据库很少直接采用 PAX（Partition Attributes Across）作为核心存储架构，大多数采用的是 DSM（Decomposition Storage Model，纯列存）或者其变种。数据压缩数据压缩有三个目标必须产出固定长度的值在查询执行期间尽可能长时间地推迟解压缩必须是无损的模式压缩可以有不同的粒度块级元组（记录）级（不常用）属性级（不常用）列级块级压缩在OTLP和行存中会更常见。元组级别的压缩可以在sqlserver中完成。有一个terarch的系统，是rocksdb的衍生版本。mysql和mysql默认不启用块压缩。但是mysql可以通过创建表是来设置块压缩算法来启动。几乎每一个列式存储系统，每个采用PAX架构的系统都会进行某种类型的列式压缩。块级压缩对于块级压缩，可以使用任何压缩算法。比如LZ4，LZO，Snappy，Zstd。数据库天然以page为单位读写，既然Page本身就是I/O单位，那么压缩整个Page最合理。比如16KB的数据，被压缩到6KB读取的时候只需要读取8KB（文件有4K对齐）块级压缩减少的是磁盘上传输的数据量，而不是改变数据库逻辑页的大小。这是能提升I/O的主要原因。我们还需要考虑实际解压时的具体情况，以及如何与缓存池配合。但是如果频繁进行读后写，写后读操作，导致反复压缩和解压缩，性能上会很糟糕。但总的来说，大多数即便是行存负载中也偏向于读取。header不会被压缩。此外header中有一些元数据，比如只想执行select count*，那么只需要在header中读取数量即可。无需解压缩。但是行存算法的压缩率通常不会很高。对于压缩算法来说，最佳的情况是一连串的0 。如果尝试用zip压缩一个mp4电影，效果往往会很差。我们希望直接在压缩的数据上进行操作，而无需进行解压缩。我们可以直接在压缩的数据上进行查询。列级压缩相比行存数据库来说，列存数据库更容易压缩。它们数据类型一致，相邻值类似。游程编码 RLE是一种最简单的无损数据压缩方式，核心思想是如果数据中有很多相同的元素，RLE可以显著减少存储空间；如果数据变化频繁，反而可能使得数据变得更长。例如11111112222333333344444编码为(1,7)(2,4)(3,7)(4,5)如果我们根据进行又成编码的列排序，可以获得巨大的优势。这样我们就可以让所有相同的值出现在一起。排序有些复杂，因为我们需要确保偏移量都匹配。如果对表进行排序，假设表有俩列。需要先对第一列进行排序，然后计算出新的偏移量，然后确保第二列那些偏移量也要相应的移动。为了获取最佳的压缩效果，一个好的方法可能是首先对这一列进行排序，但这实际上可能不是对另一列进行排序的最佳方法。因此数据库会尝试找出最佳的排序方式。上图情况下我们没有获得显著的压缩效果，甚至最后的数据比原始数据还要大，因为他们是混合排列的。但是对于查询 select isDead，count(*) from user group by isDead来说，我们可以快速遍历压缩数据，只需要计算重复的次数，就能快速得到答案。但是如果排序后，压缩的数据就只有俩个三元组另外一个方案叫做bit packing有的时候也被称为bit shaving。基本思想是如果列中的所有制都远小于其最大可能值，那么就不需要存储那些未使用的位。bit packing的一个问题是，可能会出现异常值，因此需要用到一个叫做patching的技术。此外还有bitmap encoding。为每个不同的值建立bitmap。然后位每一个row id编号，如果为当前值就设置为1，不为当前值就 设置为0。但是这种编码对于可能出现多个结果的列并不友好。实际上有一些压缩的位图对稀疏数据集非常有用，比如roaring bitmaps。大多数列存数据库，默认都会启用某种压缩，但不是必须依赖某种压缩。比如parquest，clickhouse。Delta encoding（差分编码）是一种数据压缩与表示方法，其核心思想是：不存储原始值，而是存储相邻数据之间的“变化量（delta）”。比如温度通常变化不会特别多。我们还可以在差分编码的基础上用RLE进行进一步的压缩。还有类似的一类计数被称为参考帧编码，在列中存储在一个全局值，列中的其他值都是对该值的增量。字典压缩：用一个“字典（dictionary）”来存储重复出现的字符串/片段，然后用更短的“引用（reference）”替代原始数据。内存与磁盘的移动在冯诺依曼架构的计算机下，我们不能直接操作磁盘中的数据，必须每次都转移到内存中（除了一些特殊硬件）。我们尝试优化俩件事情，我们希望将一起使用的数据存储在磁盘上，也许不一定在同一页上，而是连续的页面。这样我们可以尝试进行顺序访问通过一次I/O获取操作，读取更多的数据。我们希望优化时间局部性，尽可能长的将数据保留，以供尽可能多的查询或者操作使用缓冲池在buffer poll中，内存数组中用于重复利用的固定大小站位区域通常称之为帧，大小和数据库页相同。当数据库需要读取一个磁盘page，查看page手放在buffer pool中。如果不在找一个空闲的frame，根据替换算法淘汰一个frame，把磁盘page加载进去。内存中数据的排列顺序不必与磁盘上的顺序相匹配。除了跟踪这些帧外，我们的持久管理器重还会有一个额外的数据结构，叫做页表，本质上是一个从页面ID到你可以在缓冲管理器内存中找到该页面的位置映射。有一个页面ID到帧号的映射。我们需要跟踪元数据的脏位，来确定该页面是否被系统的某个部分写入。会有一个引用计数器指示，有多少的其他部分现在持有指向这些页面的指针。latch是一种用于保护内存中的共享数据机构的轻量级同步机制，和锁很像但是用途不同。Lock 用于保护数据库逻辑数据（事务并发控制），Latch 用于保护数据库内部内存结构（线程并发访问）。线程 A 正在修改 Buffer Pool 中的一个 page线程 B 正在读取同一个 page线程 C 正在修改 B+Tree 索引节点没有同步可能导致线程A:读取page修改page中的slot数组线程B:同时读取slot数组latch也可以是通过mutex机制或者futex机制实现的。页目录（page directory）：将页面ID映射到数据库文件中的页面位置。每当我们进行更改，比如像表添加更多页面时，我们都必须更新页目录。如果重新启动，我们会重新加载页目录，并且需要确保它与实际文件同步。header中不存放每条记录的起始位置，只存放管理槽的数量。页目录位于页的尾部，是一个槽（Slot）数组。每个槽占 2 字节，存放的是页内某条记录的页内偏移量。页表（page table）：是我们在缓冲区管理器内存中维护的一种临时数据结构。MMAP操作系统会有MMAP的功能，将文件内容直接映射到应用程序的内存空间。而且它会采用懒加载的方式，不会将文件的所有内容都加载到内存。它会根据文件大小提供一个起始点和结束点。如果访问到不在内存中的内容，就会发生页错误，然后操作系统会将其读回。操作系统试图对用户保持透明，让你感觉不到，你想要的数据其实不在内存里。它会直接阻止你，停止运行，取消的调度，然后获取你需要的数据，载入内存。一旦数据进入内存，你就能获得访问它的指针。操作系统隐藏了如何在磁盘和内存之间移动数据的机制，这样做的目的是给你一种拥有的内存比实际多的错觉。操作系统可以随时刷新脏页。如果我现在崩溃并返回，我们必须弄清操作系统在实际崩溃前写入了什么东西。但我实际上不知道它写了什么，它在底层做了这一切。用户不知道内存中有哪些脏页，因为操作系统为我们管理所有这些。无法在操作系统的页表中查看并说出实际上在内存或不在内存中的内容。操作系统为了维护这些映射会有自己的页表。问题：事务安全IO阻塞错误处理性能问题可以使用madvise给哦操作系统一些提示，说明你将如何读取某些页面。比如顺序读取，随机读取。调用mlock，告诉操作系统某些区域不能被换出。然后调用msync，等同于fsync，指示将内存区域刷新到磁盘。视图使用这些所有技巧，让MMAP达到我们在缓冲区管理中想要的效果。但实际上效果会更差，程序也会变得很复杂。数据库总是比系统更了解如何使用数据。mongodb一开始使用mmap（MMAPv1），但是现在已经放弃（WiredTiger）。singlestore，weaviate也是一开始使用mmap，后来删除了。一开始用mmap是因为快速且容易，但是性能受限。Elasticsearch，sqlite，他们用MMAP主要是服务于只读的工作负载。LMDB喜欢使用MMAP，甚至给其他数据系统发邮件，告诉它们为什么做错了，应该使用mmap（suck）。论文：Are you sure you want to use mmap in your database Management system置换算法我们需要有一个有限的缓冲置换算法。这将是区分开源系统和高端昂贵的企业系统的关键因素之一。它们投入了数百万美元和数百小时的工程时间来优化这些东西。企业系统会比开源系统有更复杂的功能，但是他们是闭源的。最常用的缓冲池替换策略最常见缓存置换算法是LRU，简单性能也好。对于每个页面，都记录最后一次被访问的时间，我们可以用不同数据结构维护页面访问的顺序，比如链表，B+树，trie树。CLock算法：它被认为是LRU算法的近似实现。把所有的页面组成一个环形，看起来就像钟表。每一个页面都有一个reference bit。只有俩个值。0代表最近没有被访问，1代表最近北方问过。如果buffer pool已满需要淘汰一个页面，时钟指针不断前走，如果最近被访问过，就不进行淘汰，如何变为0 。如果位0代表很久没人访问，就进行淘汰。但是如果查询需要对整个表进行顺序扫描，比如在进行join操作。那么这会严重影响缓冲池效率，并干扰我们用于跟踪页面使用情况的元数据，因为所有页面看起来都像最近被访问过的。它会丢弃那些非常热门我们应该保留的页面。因此有时最近使用的页面反而是你想要最先滔天的。LRU和CLock算法只存储少量信息，即上次访问的时间。无法告诉你访问的次数，因此无法做出是否将该页面保存在内存中的有效决策。一个简单的方法是使用LRUK技术（作者是发明了LSM树的人）。这里的写法是保留一些额外的元数据，来跟踪页面之前被访问的最后k个时间戳。当决定驱逐哪个时，会查看历史记录，并选择间隔最长的那个。此外pgsql还有技术追踪访问是否在同一个事务中，比如一个事务中读取了俩次相同的数据，可以在逻辑上只被视为一次。sqlserver可以区分页面访问的上下文，并将其添加到额外的元数据中，以追踪之前的访问记录。另一个问题是冷启动，如果系统重启，我将丢失所有页面访问时间的元数据。mysql使用一种称为approximate LRU-K的技术。这里的想法是，它仍然是个单链表，用来白送页面被访问的顺序。在LRU中位列表设置了俩个部分。Young list，old list。我们不会直接将其添加到LRU的开始位置，二十将首次访问时添加到旧链的head中。淘汰从后开始，也就是page8的位置。如果再次访问这个页面6，页面将会被放到young list头部。它类似于LRU2或者K=2的LRUK。mysql这样做是因为它比pgsql和sql server的实现更简单。需要的元数据更少，是LRU的一个简单扩展。脏页如果要驱逐的页面是脏页，我们必须保证脏页写入磁盘，并且在磁盘上安全后才能重用该帧。快路径：页不是脏的，直接删除慢路径：页是藏的，数据库必须协会磁盘确保修改持久化。为了优化慢路径，我们可以在后台增加写入器，会定期让工作现场查看缓冲池，找到脏页，如何刷新并写入磁盘。该页仍然在内存中，因为我们没有进行置换操作。一旦写入完成，我们清楚脏位。这类似操作系统底层为我们做的事情，但是操作系统不了解事务，也不了解我们系统中其他部分正在进行的操作。如果没有这个后台程序，那么任何需要驱逐内容时，查询都会被阻塞。因为必须先刷新脏页。在刷脏页时，可以优先刷新即将被驱逐的页，而不是随机刷新页面。在写入磁盘时，操作系统中的硬件会使用一些技巧，通过创新排序和批量处理I/O请求来最大化硬件带宽。现代m2驱动器和MVME驱动器快的原因是，你可以在它们上面执行大量并行操作，并从中获取非常好的吞吐量。但是操作系统并不知道这些I/O请求之间的关系。它不知道哪个请求更重要，因为它知道看需要读取的文件或数据块。大多数据库有自己的调度器，因此会建议你关闭Linux提供的其他调度，简单的使用戒指时间或者FIFO调度器。Linux有一个叫做IOuring的机制，提供缓冲区，允许提交请求并接受通知（首次出现在2019年）。system call如果在操作系统上执行fread，它会命中内核允许的文件系统，内核说好的，你想从磁盘读取整个数据块或数据页，它实际上会先检查操作系统的页缓存。所以如果使用操作系统页缓存，每次fread都会产生页面的俩个副本，一份在用户空间给数据库使用，一份在内核中。因此，大多数数据库系统会使用direct I/O，在调用FOpen的时候传递O_direct标志。这会告知操作系统绕过其缓存页。任何Fread操作会直接通过文件系统到达硬件。现在只有一份从内核到用户态的副本，这样可以避免荣誉副本。操作系统缓存页有自己的驱逐策略，我们不想受其影响。一个著名的数据库系统以依赖操作系统页缓存而闻名，pgsql会告诉你，当你为pgsql分配内存时，他们会建议你使用系统中可用内存的25%。其他数据库系统会建议，为缓冲池使用内存的80%。pg只建议用25%-30%，剩余的内存用于操作系统的页面缓存。这意味着pg可能会从缓冲池逐出某些内容，但它仍然会存在于操作系统的页缓存中。在以为微软负责pgsql的开发人员尝试关闭操作系统的页面缓存，让pg使用driectIO。如果一个数据库调用fwrite系统会先写入缓冲，然后缓冲机制在底层运行刷入磁盘。但是如果崩溃了，fwrite可能会导致数据丢失。所以必须调用fsync，让操作系统向硬件发出指令，确保数据被刷新。当调用fsync得到一个错误信号，你就不知道哪些写入成功了，哪些没有成功。在linux中第二次调用fsync肯定会成功，但这可能是操作系统在欺骗你。（linux担心有人插入了usb存储后拔出，这样fsync可能永远不会成功，但是这不是数据库期望的行为）。2018年爆出了丑闻，很多数据库存在了20年的bug，不只是pgsql，mysql，mongodb和其他数据库，就是fsync导致的问题，你可能丢失数据但是没有发现。优化手段Multi Buffer Pool（多缓冲池） 是现代数据库中一种常见的 Buffer Pool 管理技术。它的核心思想是不要吧所有数据也都放到一个统一的buffer pool中管理，二十分成多个buffer pool。这是为了解决高并发下buffer pool的锁竞争问题。在企业级系统中可以在每个buffer pool指定淘汰算法。那么如何知道数据页放到哪个buffer pool中呢？可以使用hash或者轮询，保证无论哪个现场访问相同的id都会访问同一页面，因为一个页面不能同时存在于多个缓冲池。另一个优化手段是预取（prefetching），我们可以尝试基于查询计划提前识别出未来需要访问的页面，并进行预取。这对于顺序扫描来说很简单，在扫描page1和page2的时候可以预取page3。但是我们不能进行索引预取，操作系统不知道这些页面代表什么，不知道他们是如何连接的，另一个技巧是扫描共享，有时候也被称为同步扫描。如果有俩个或多个查询同时运行，并访问相同的页面，没必要让他们呢从头都扫一遍，二十让他们一起共享这次扫描。我们可以重用查询访问的页面，比如一个查询统计平均值，一个查询统计最大值，就可以让他们共享页面。这是pgsql相较于其他查询的优势，oracle也支持，这这种机制，但是只有查询完全一致的情况下才能重用结果（hash字符串）。缓冲池旁路（buffer pool bypass），有的时候被称为轻量扫描（light scans）。某些数据访问不经过数据库的buffer pool，二十直接在磁盘和用户空间之间进行IO。它目的是为了避免缓冲池污染，提高整体性能。当一个查询读取多个页面，把页面放在只有查询能看到的小缓冲当中，避免污染全局的buffer pool。Hashtable有俩中哈希表，静态哈希，方案，这种方案假设数据结构里有固定数量的元素，也就是键值对。还有一种是动态哈希方案允许哈希表可以增长。在数据库中给定一个key，无论是主键，还是辅助键，我们都希望通过这个键来找到对应的元组。在静态hash中由于长度是固定的带来俩个严重的问题预估困难：预分配太大浪费内存，太小则冲突剧烈，性能从O(1)退化到O(n)。无法动态增长：数据量超过预期，表就满了（尤其是开放寻址法）。静态哈希完美哈希：第一层用常规hash将n个键映射到m个桶里，这层允许冲突。第二级，定制完美哈希，为每个桶里的k个键，专门构造一个无冲突的二级哈希函数，所有键值都存入这个二级哈希表。查询经过俩步，是严格的O（1）时间复杂度。在计算哈希函数我们要权衡速度和冲突率。在hash表中大部分情况下，返回一个32位或者64位的整数。pgsql实现了自己的哈希函数。最常见的hash函数是face book的xxxhash（对于恶意攻击不抗碰撞）。rocksdb使用了xxhash。为了解决hahs冲突诞生了很多算法线性探测哈希是开放寻址法的一种实现：当插入冲突时，向后查找空暇的桶，直到找到为止。但是线性探测需要遇到空位才能停止，删除操作还需要打上额外的墓碑标记。这让删除操作变得很麻烦。但是在数据库中，有时候不需要进行删除操作，比如进行hash join，只需要插入和读取。如果它的大小和负载因子都 很合理，是不太需要进行多次扫描的。我们还可以做一些额外的优化，比如存储哈希值，这样比较相等之前可以先比较哈希值来判断是否相同。大部分情况下hash值会不同。一个不好的方法是在删除时将下方的数据重新哈希，并向上移动。这样做的化必须重新hash很多数据，因此大部分情况使用删除标记。墓碑需要作为负载因子的一部分。当一个key对应多个value时，我们可以给每个value存一个链表，也可以重复存储多次。布谷鸟哈希是准备多个哈希函数，占用不同的哈希槽。那么如果多次哈希后，如果所有的槽都被占用，那么就需要从其他位置偷一个槽（把原有的值给踢掉）。如果踢掉成环了，那么就要进行扩容操作。IBM的DB2加速器是使用了该算法。布谷鸟哈希牺牲了插入速度换取读取速度。Clickhouse中甚至有20种哈希表。动态哈希动态哈希的理念是，逐步调整自身大小，无需重新哈希所有内容加载到第二个哈希表。最常见的是链式哈希（chained hash），可扩展哈希和线性哈希是更高级的替代方案。动态哈希的思想是只复制指针，不复制数据。在桶头中，我们可能仍然想维护一个指向哈希表开头的指针，在其前防止一个过滤的数据结构，用于判断该键是否存在于桶链当中，用于判断这个key是否真的存在（比如布隆过滤器）。一般链式哈希的负载因子是按照所有元素算的，而不是按照被占用的数组算。可扩展哈希是一种动态哈希技术，能随着数据量增长平滑地扩展空间。它由俩部分组成，本质是一个大小为2^d的数组，存放指向桶的指针，d称为全局变量，哈希值的前d为决定了使用桶中的哪个项。桶是真正存储数据的地方，有固定的容量，通常是2-4个记录，每个桶都有一个局部深度d'，表示这个同理记录哈希值的前d'位都相同。显然d'<=d可以这样理解，目录是一张索引表，通过哈希值的前几位来索引到对应的桶。假设桶容量为2，全局深度d=1，目录有2^d=2个项。所有数据存入俩个桶，它们的局部深度也是1。插入两条哈希为 00... 和 01... 的记录到桶A，没问题。插入第三条 0... 记录时，桶A满了。目录 桶 [0] -----> [桶 A (d'=1)] 存放哈希值0开头的记录 [1] -----> [桶 B (d'=1)] 存放哈希值1开头的记录次数需要分裂桶A：桶A的局部深度d'增加：从1变为2,。意味着区分更细了，现在要用钱俩位来区分桶A里的记录。创建新桶C：局部深度也为2，存放前俩位01的记录。重分配数据：把原桶A的记录，按其哈希值的前两位重新分配到桶A（前两位00）和桶C（前两位01）。更新目录：因为全局深度d=1小于桶的新局部深度，目录需要扩大一倍。目录从2变成4，全局深度d变为2,。新目录的指针根据前缀指向正确的桶。目录 (d=2) 桶 [00] -----> [桶 A (d'=2)] 存放00开头的记录 [01] -----> [桶 C (d'=2)] 存放01开头的记录 [10] -----> [桶 B (d'=1)] 存放1开头的记录（10和11都指向它） [11] -----> [桶 B (d'=1)] 可以看到，目录的扩展是成倍增长的，但数据只在新分裂的桶之间重新匹配，不影响其他的桶。为什么（10和11都指向它）呢？因为这里b桶还没有满，不需要进行分裂。查询：计算哈希值，取钱d位作为索引到目录中该索引位置，拿到指向桶的指针。在桶内顺序搜索。查询最多只需要俩次磁盘I/O一次读取目录，一次读取桶。效率很高优点：性能稳定：查询最多2次磁盘访问，不会因数据量增大而明显变差。扩容代价小：只影响单个溢出桶，是平滑的、局部的调整。实现相对简单：思想非常清晰。缺点：目录空间可能很大：目录大小按 2^d 指数增长，当数据分布极不均匀或数据量巨大时，目录本身会占用大量连续内存。桶容量固定：如果选择的桶容量太小，分裂会很频繁；太大则退化为顺序搜索。pgsql用的是线性哈希，源码里称之为dynahash使用一组哈希函数：它不依赖单个哈希函数，而是使用一个函数族 h0, h1, h2, ...。这些函数的计算范围是逐步翻倍的。例如，h0 把键映射到 N 个桶，h1 则映射到 2N 个桶，以此类推。这样，系统就可以在多个“版本”的映射规则下工作。维护一个“分裂指针”：这是最关键的设计。系统维护一个指针，指向下一个要被“分裂”（即扩容）的桶。何时分裂：当某个桶的负载（比如存储的记录数）超过预设阈值时，就会触发一次分裂操作。分裂哪个桶：注意，被分裂的桶并非刚刚变满的那个，而是当前“分裂指针”所指向的那个桶。这种“轮转”式的处理保证了扩容是线性、均匀地遍历所有桶。如何分裂：系统会申请一个新桶，然后将“分裂指针”指向的旧桶中的一部分记录（通过新的哈希函数 h1 计算）移动到新桶中。之后，指针就移到下一个桶。这听起来很奇怪，因为在拆分的不是溢出的桶，而是在拆分当前指向的任何存储桶，但是我们最终会处理到那个桶。删除操作理论上可以重新让拆分指针会到之前的位置。在我们创建索引的时候可以指定索引的类型，比如hash索引。B+ tree大部分情况下，创建索引是指b+树索引。有些系统完全基于哈希表构建本质上得到的就是哈希表（Redis ）。B+树属于一个名为B树的数据结构族，有个特定的数据结构叫做b tree。这俩者之间是有区别的。大部分数据库系统实现的都是b+ tree。但是大多数人没按照70年代的定义来实现B+ tree，而是使用了各种优化技术。b+树是平衡的，每个叶子节点到根节点的距离都相同。意味着向下遍历的时间复杂度为log n。此外除了根节点之外，每个节点都是半满的。B+树存在兄弟指针，最初的B+树定义是在底部有兄弟指针（叶子节点之间）。但是b-link树的论文也添加了内部节点的兄弟指针。在进行数据扫描的时候不需要总是回到根节点。对于空值可以放在开头或者结尾。因为空值之间无法比较。B+树中没有子节点指向父节点的指针，我们只能在数据结构中单项遍历树，也就是从上到下。这样就不会出现死锁了，不会有从底部向上访问的情况。兄弟指针也会带来一些问题，有些系统之在单方向上使用兄弟指针链表，因此不会出现死锁。另一种方法实际上是更好的方法，将键和连续地存储，然后让值数组也连续地单独存储。这样类似之前谈到的链式存储。这样存储可以快速获取需要的key对应的数据。对于主键索引，叶节点直接存储数据元组本身，对于二级索引，叶子接地啊你通常会包含指向元组的主键。在B+树中真正关心的数据只会在叶子节点中找到，内部节点只是我们达到底部的路标而已。这意味着我们可能会删除一个键，它会从叶子节点移出，但可能仍然存在于内部节点中。内部节点不清理也没有问题，因为它只是用来帮助我们到达底部。如果我在B+树中进行遍历，我会跳过所有内部节点，除非是扫描，否则永远不需要往上走了。这意味着，只需要验证叶子节点按顺序遍历，就可以找到我正在查找的所有数据，这会最大程度提高顺序访问效率。我们可以通过一些方式，比如预分配一些叶子节点，确保它们在物理上也是连续的。对于插入操作，我们向查找一样访问B+树，如果空间足够，直接存放，但是如果空间不足，我们就要进行页分裂。创建一个新节点，把要拆分的节点一半的key放进去，也就是新的节点，另一半留在原处。然后需要更新父节点，这可能会递归更新树的其余部分，根据布局来重新分配。在删除的情况下B+树要从其他兄弟节点借走元素，确保自己是半满的，这是为了保证树的平衡。这可能导致会需要再次更新父节点。在借不到兄弟节点的元素的情况下，可能需要将父节点和当前节点进行合并，来维持半满。所以在进行删除操作时，可能导致树结构坍塌，并且最多让树的高度减少1。通常只会问相邻的兄弟节点借元素。b+树删除之后可以不清理内部节点的元素，但是b树不允许这样。向下遍历的时候可以维护指针的堆栈，这样就可以直接访问父节点。为了处理重复的key，最常见的一种是实际上隐式的将记录ID作为索引键的一部分进行存储。pgsql是这样做的。mysql直接将主键追加到二级索引的后面。来保证索引的唯一。在搜索时这种搜索方式本质上也是前缀查询。对于变长结构我们可以通过使用slot进行二分。前缀查询也是可以进行二分的。另一种方式是移除叶节点，识别出有重复的地方，像类似链式哈希那样，不断在末尾附加更多的页，但这很糟糕，如果分布不均匀，最终可能会得到一个很长的链。默认情况下，PGsql中的主键索引存储的是TID，TID = (BlockNumber, OffsetNumber)。因此PostgreSQL 的主键索引叶子节点存储的是 TID，而不是整行数据。但是PGsql提供了CLUSTER，CLUSTER user USING user_pkey;按它会：按 user_pkey 的顺序重写整个 Heap Table如果数据是无需的，索引会按照某种顺序加载。然后堆文件或者其他任何页面，它们都是完全无序的。浙江导致扫描索引然后抓取页面将会变成随机访问。一个非常简单的优化是扫描索引时，实际上不获取任何元组，而是对我们想要的索引在的物理位置进行排序。然后扫描并获取元组时，每个页面只检索一次。PGsql称之为bitmap heap scan，在mysql中类似MRR（但mrr是根据主键进行排序）bitmap heap scan是默认开启的，但是mysql是不开启的。优化手段B+树甚至有一本专门的书来讲这个数据结构（红黑树可没有）B+树的很多参数取决于硬件设备，比如设备速度越慢，页面越大越好，因为当需要读取时，希望最大化顺序访问。（如果需要修改页面大小，需要重新编译PG数据库，但是mysql不需要）。如果速度很慢使用1mb甚至更大的页面，如果速度很快，512字节的页面大小就比较合适。事实上，如果B+树正在经历反复的删除和插入，有可能导致反复进行拆分和合并。为了避免这种情况，理论上可以放宽这个约束，允许节点小于半满，以便操作不会反复的折返。但是会导致B+树是非平衡的。另一个技巧是叶子节点的大小可以和内部节点不同。比如内部节点比叶子节点稍大一些。（但是mysql和pg中都是固定大小）在页内查询时，我们可以用SIMD优化，单指令多数据。在SIMD中可以依赖硬件来允许处理一个数值向量，如何使用专门的向量化质量对向量内所有数值执行相同操作。我们可以在一条指令内，将包含四个数值向量进行比较，用0和1表示是否匹配。使用AVX512等指令集可以处理更多的数值如果没有匹配就继续查找，直到找到目标。另一个方法是进行二分查找。最快的方法是插值搜索，单它仅存在于学术文献中，实际应用很少。可以看作是二分查找的改进版。二分查找不在意数据的分布。（但是由于数据库中剪枝分布通常不均匀，很难满足于近似均匀分布，所以没有被使用。）压缩：按照字典序排序的数据，相邻的数据前缀很可能非常隐近似的，因此我们可以采用前缀压缩技术。这样表中不同属性的分散值，就变成了单个属性的连续值。我们可以存储相同的前缀一份，和不同的后缀多份。在进行查找时按需查看前缀，如果没有匹配性则仅查看剩余后缀。我们还可以对重复的数据进行删除（deduplication），只保留该键一次，然后跟上一个可能的较小的数字，来表示该键会出现多少次。Pgsql在三年前的B+树上增加了此功能，显著减少了空间占用。当然实际上也可以对值做该操作，而不仅仅是key。比如邮政编码就有大量的重复。suffix truncation：我们可以对内部节点进行后缀截断。假设有俩个很长的字符串，但是我们只需要第一个字母来判断是否需要向左或者向右，那么我们就可以只存放第一个字母。只存储那个，而不是全部内容。只能在B+树中进行该操作，因为b+树的内部节点不存储实际的值。理论上可以在内部节点存储任何东西，只要能正确的向左或向右移动即可。随着时间的更新前缀可能会产生改变，到时候进行更新即可。pointer swizzling：指针交换，这实际上会打破数据库系统中关于页面ID和记录ID的良好抽象层，并实际存储原始内存指针。每次访问的时候，原本我们需要根据页面在磁盘上的位置来获取在内存中的位置，但是这种技术会将原始内存指针存储在页面中，这样遍历时就无需访问缓存池管理器了。比如我们可以对根节点进行这个操作，这样访问根节点不需要访问buffer pool了。但是我们要小心这一点，因为我们将内存地址直接存储在页面本身中，页面是在内存里的。我们需要保持它被锁定，在刷新磁盘时我们需要跟踪这个操作。我们不能直接写入内存地址，因为当再次读取时这个内存地址可能就无效了。虽然这里需要更多的工作，但是我们避免了额外的查找工作，不需要每次都访问buffer pool了。批量插入：在某些情况下，你可能已经预先知道了所有的key，因此在加载表的时候，通常建议先删除全部索引（除非有唯一性约束要确保数据完整性）。然后批量加载数据，最后创建索引。增量更新索引的总时间比创建索引慢很多，而且创建索引可以并行处理。如果预先知道所有的key，最好先排序，然后用兄弟指针填充叶节点，然后侯建索引的方式就不是自顶向下，而是自底向上。这样数据结构就能达到最紧凑的状态。增量维护B+树很昂贵，因为需要进行拆分合并。最坏的开启是一个插入操作要重新平衡整棵树。有一种方式可以吸收对b+树的更新，然后在必要时批量更新。这被称作写优化B+树，有的时候被称作分形树。分形树树种每个节点都有一个mod日志，插入时，如果空间足够就写入mod日志，无需完全遍历到叶节点。删除操作和插入都写在这个节点旁边的mod中，进行查询时会先从mod中招，如果没找到，就按B+树的正常流程做左右遍历，当modlog的空间不足时，我们会响应的应用或向下推送内容。目前SplinterDB正在使用类似的数据结构。对于写密集的工作负载，这种结构会比b+树快很多。其他常见结构其他典型的过滤器counting bloom filtercuckoo filter（Redis明确提供Cuckoo过滤器）succinct range filter（SuRF）跳表是另一个数据结构，在链表中每次插入节点，通过抛硬币来决定它能出现在哪几层索引中，具体规则是：每个节点必定出现在最底层（第0层）。从第0层开始，每个节点独立地以 1/2 的概率（抛硬币正面）决定是否也出现在上一层。如果出现在上一层，就继续抛硬币，决定是否再往上出现一层……以此类推，直到抛到反面为止。优点：相对于b+树用更少的内存，插入和扇出不需要重平衡缺点：对于磁盘和缓存不友好，因为它们不优化局部性。对于反向查找不友好。另一种结构比b+树法更加早叫做trie（1970）。通过前缀匹配查找字符串。时间复杂度进位O(L)，L为字符串长度。它由极值的前缀匹配效率。但是内存占用较大， 构建成本较高。Radix tree是tire树的压缩版本。如果存储在经典 Trie 里，如果存储 abc、abd、abcd，前三个字符 a->b->c 都是单支路径，每个字符都要单独创建一个节点，每个节点又都带着 26 个空指针，极其浪费。Radix Tree 的做法是：“单行道”全部合并。它会把那些“只有一个子节点”的连续路径，直接压缩成一个字符串片段（Edge label）。经典 Trie：a -> b -> c -> d （4个节点，每个节点带一堆空指针）Radix Tree：abc -> d （只有2个节点，把共享的前缀 abc 直接挂在一条边上）插入过程的核心规则：当新单词和旧单词共享部分前缀时，Radix Tree 会把原来合并的字符串“劈开（Split）”。比如先插入 abcd（树里压缩成 abcd 一个节点），再插入 abce。发现两者共享 abc，但到 d 和 e 处分叉了。此时树会分裂成：abc (公共父节点) -> d (左子) 和 e (右子)。优点：内存站原地，查询依然搞笑，插入删除更稳定缺点：实现较为复杂，更新开销更大，插入一个新词导致节点分裂，需要重新分割父节点上的字符串重新分配内存。不适合极短的字符串。Linux内核用 Radix Tree（具体是基数树变种）管理文件系统的页缓存，用来快速通过文件偏移量找到对应的物理内存页。IP路由表的前缀匹配也是使用Radix Tree来查询的。此外，duckdb，hyper，umbra，solidDB内部也使用了该结构。倒排索引倒排索引一般包含俩个部分dictionary和posting lists这里的字典就是所有被提取的术语词汇。字典会维护所有术语，已经这些术语的出现概率。每个术语都有一个指向发布列表的指针。发布列表中是包含了该术语的id。倒排索引很多情况下会存储词频，类似：“苹果” → [ (文档1, 词频=3, 位置=[5,12,20]), (文档3, 词频=1, 位置=[8]) ]rust写的tanivi，java写的lucene，c++写的zapien。有许多系统使用这些倒排索引库，围绕它们构建更强大的数据库系统。比如ES，Solr，Splunk，Vespa。OpenSearch是亚马逊的ES分支。FST（finite state transducer）有限状态转换器是一种核心的数据结构。用来构建倒排索引的词向索引。它可以看做前缀树（tire）的升级版本。不仅共享词项的共同前缀，还能共享共同后缀。极大压缩了存储空间。目标是，让你能用极快的速度，查到一个词是否存在以及它对应中的值。FST会将keu拆分成单个数字。但数据结构的叶节点不是指向目标位置的指针，而是边上的权重。它会告诉我们如何计算到达目标位置的偏移量。它们会为插入数据库的每条记录创建增量字典。这样每个字典树都只针对一部分文档。这样每个字典都只针对一部分文档。然后在后台，它们会进行类似日志结构合并树的压缩操作，将段合并成更大的字典，减少冗余和重复值。Pgsql有一个叫做gin的东西。也就是通过倒排索引。本质上他们使用的数据结构是一个树的森林。也就是说使用B+树来存储字典。其中的值，或者说术语（term）会是整个键。初始B+树的值会根据posting list中对应的记录数量而变化。如果一条记录出现了过多次，在叶子节点还会组成一个b+树。比如一个词在100w个文章中出现了。显然这些数据需要额外的b+树存储。向量索引向量索引的查找是基于嵌入向量的。但是通常，我希望在where子句中有额外的谓词，这些谓词无法被嵌入向量捕获。最常见的方式是inverted file。获取到要的向量。利用k均值算法之类的聚类算法，将它们分层更小的组。当我想查找时，为我的查找向量或者embedding找到最近邻。然后在周围查找，找到靠近的条目。这是一个简单的分治算法另一种方法是使用navigable small worleds，可导航小世界。其核心思想是我们构建一个图结构，其中每个节点会指定发出多少条边，这些边将指向其余最近的邻居。数据结构中会有一个入口点，告诉我们搜索从哪里开始。然后我们只需要沿着图中的路径前进，选择一条更接近目标向量的路径。假设我们查询包含一个嵌入向量，它将我们定位到这个空间的某个位置。为了找到最临近，我们从入口点开始。然后沿着路径前进。找到所有更接近的节点。当我们到达一个无法更靠近的节点时，搜索就会停止。如果需要，我们可以在此结构下添加更多的层级（HNSW）。优化算法排序问题是，如何在主内存无法容纳全部数据的情况下，仍然获得全局排序数据？如果所有数据能放入内存，大多数据库系统会使用快速排序或它的变体，有时野会使用插入排序（pgsql使用的是快速排序）。那么如何判断使用快速排序，归并排序还是其他基于磁盘的排序呢？这一半是通过估算查询中不同操作符的数量。Top-N heap sort：适用于你明确知道查询需求的时候，不需要对所有数据排序。外部归并排序则是我们用于磁盘排序的核心算法。顶端堆排序是一种特殊情况，适用于带有limit的order by子句的查询，数据集就知道它不需要全局排序所有数据。它只需要根据order by子句获取最大或者最小的子集。select * from enrolled order by sid asc fetch first 4 row with ties;这条 SQL 语句的含义是：从 enrolled 表中查询所有列，按 sid 升序排列，并返回排名前 4 的所有记录（包含并列情况）。大部分数据库都是这样做的，比如pgsql。使用快速排序这类操作，在磁盘上非常慢，归并排序对于磁盘更加友好。外部归并排序是一种分治的方法，我们将把需要排序的数据分割成为运行的较小单元。核心思想 是现在内存中把这些数据排好序。然后把它们写到磁盘上，为接下来要排序的数据腾出空间。然后通过多轮迭代来完成这个锅，直到得到一个全局排序的完整数据集。物化：将某个中介结果或者逻辑表达式，从定义的状态，实实在在地计算出来并保存在内存里。未物化：不持有具体数据，只持有位置引用，比如第三行第五航数据。物化状态：把实际数据构造出来。拿着那几行，去order_id之类的列把对应的数据提取出来提前物化的好处是，早早组好整行数据，用大量空间床底宽行，换的随用随去的遍历。延迟物化：劲量值传递小体积的行号，节省空间和带宽，把物化的计算开销推迟到最后。假设有10亿个元组，使用过滤器删除99%的数据之后再进行排序，之后我需要返回并获取剩余的数据。在这种情况下，延迟物化会带来优势。一般来说行存数据库采用提前物化，列存数据库采用延迟物化。外部归并排序是可以多路的。三路归并排序是带入三个有序页面并将它们合并。最简单的就是双路的。理论上使用排序算法，内存越大运行越快，但较大的内存也可能导致其他操作的执行变慢。因此通常对于排序可用内存有一个可调节的参数。如果我们尝试对很大的表进行排序，通常需要双倍的磁盘空间。但磁盘一般是便宜的，只是运行速度回非常慢。下图中，1个page有2条数据。这样每次都只需要加载俩个page到内存中。在归并排序的语境下，run是指待排序序列中一段已经有序的连续子序列。排序的轮数为1+[log2N]，但是当以10亿为单位的数据大小时，以2为底就不是那么好了。为了让它更快，我们需要吧这个2改成更大的数值。这样我们就可以一次将更多的页面加载到内存中，从而拥有更大的排序运行空间。但是如果和之前展示的一样只用单个线程按顺序处理每次运行，速度就不会那么快。因此有一些技巧可以优化它。对于通用m路的排序算法，会有B个缓存池。n/b向上取整，这是因为末尾可能会有无法完全填满最后一次运行的页面。在后续的迭代中，我们只需要加载B-1个页面到内存中，因为始终需要一个页面作为输出。我们可以把流程从 获取数据->计算->写入数据变成在后台获取数据，计算完毕之后，执行写入磁盘的同时，先获取下一轮需要计算的数据。当然磁盘速度很快的情况下，这种优化作用有限。比较键值可能比较耗时。比较2个32长度的数据是快速的，因为这只是cpu中的一条指令。但数据并不总是固定长度值。因此需要优化比较操作，在大数据中这会相当频繁。排序优化核心思想是，与其使用通用的排序算法，传递函数指针，并调用函数进行比较。不如根据数据类型内联或者硬编码比较运算符。这样就不需要进行跳转调用。如果我知道将要比较俩个浮点数，我们可以直接在排序算法里加入浮点指令。在PG中它们使用一个perl脚本，获取排序算法的实现，然后复制它，生成多个版本，分别用于证书，浮点以及其他类型。因此编译到系统后就有了比较函数的不同版本或者变体。这类似c++的模板，但是pgsql是用c语言写的，所以他们才需要复制这些代码。对于非常长的varchars或者字符串，可以适用于类似B+树的技巧。不必比较整个字符串来查找匹配项，而是可以传递数据。去掉键的前缀，基于此前缀进行排序，可以使内联操作非常快。然后如果前缀相同，可以回退并调用完整的比较运算符或者比较函数。当然，如果你有保存索引，比如b+树，它已经存在于order by子句中需要排序的键上，那么再次排序就没有意义，因为它已经排序好了。如pgsql，如果order by子句使用了A列和B列，我必须在A和B列上都建立索引才能使用。哈希当我们不需要进行排序，那么在进行分组时，使用哈希可能更有利，因为我们可以快速识别数据的位置。比如去重和聚合。哈希能够避免查找目标和顺序扫描所有数据的麻烦。如果我们通过哈希进行聚合，方法是纤维查询创建一个临时的哈希表，然后将key放入其中。根据计算的内容这个值会是一个运行总计，或者用于计算总和，或者计算最小最大值等等。对于如下查询select distinct cid from enrolled where grade in('B','C');从课表中找出，获得过B或者C成绩的学生，并去重。我们先进行过滤得到去重的结果。然后现在在外部哈希聚合第一遍中，扫描这里拥有的任何数据。如果数据存不下就溢出到磁盘中。在第一轮中我们可以先将数据分区。这样相同的数据就不能跨分区存在。然后现在在第二阶段，我们将引入这些分区，我将再次哈希它们，并将其写入内存中的哈希函数或者哈希表。这里的主要想法是，对于我正在查看的缓冲区集，哈希表足够小可以放入内存。一旦完成了对这组缓冲区所需的任何计算，我们就可以通过第一个哈希表写出结果后，将其放入一个新的哈希表中。简单来说就是第一遍筛选，第二遍去重。在第二次去重的时候，我们可以分区进行，完成后，可以从哈希表中获取数据，生成发送给客户端最终结果的一部分。这样不必进行顺序IO来遍历所有数据，维护一个可能很大的哈希表来查找匹配。这里将问题分解成了更小的子问题和数据块。对于聚合，键不仅存在于哈希表中，还指向我们维护的总计，以便计算我们需要的聚合，因此聚合函数要求，探测哈希表找到key时，需要更新正在维护的运行值。如果key不存在，就放入到哈希表中。比如运行avg的group by时，我们只需将行数和记录数记录，然后相除，就可以得到平均值。在需要写入磁盘时，哈希处理的比排序更好，我们可以进行分治，即使它访问通常是随机io。连接查询连接算法解决查询的常见方案是分治，具体来说就是把不适合放入内存的数据分解成适合放入内存的小块。当讨论哈希join时，也会使用同样的策略。总的来说结论是，哈希join总是更好，更快的选择。我们重点关注的算法是inner echo join。也就是内部等值连接。inner join和left join是最受欢迎的。反连接和半连接是特殊情况。在下文暂时只讨论二元（binary）连接，也就是只有俩张表进行连接。如果需要连接更多的表，可以将前一次的连接结果作为输入，再运行二元连接算法与其他的表进行连接。多路连接在文献中早已出现，因为其思路非常直观，就是同时连接多个表。但现实中，很少有人实现这些算法，因为它们的性能不够稳定。sql server在1998年添加了这个算法，但是在2001年移出了这个算法，因为这个算法的性能不太稳定。因此，你所知道的大部分系统都要执行二元连接。还有一类更先进的算法。这些算法用于执行图遍历，例如社交网络表示图结构的网络。这些情况很少见，只有少数系统使用这类算法，比如德国的umbra和cedarDB。具体的连接算法都由成本模型和优化器来决定。总的来说，我们可以总结出一个经验法则，我们希望把左边的表，也就是外表，设置为较小的表。查询计划通常是一个有向无环图，图的顶端是返回的输出结果。如果要将小表作为外部表，如果要连接三个表，我们怎么知道应该先连接哪俩个表，最后再连接第三个表呢？我们使用late materialization（延迟物化）与early materialization（早期物化），结果是一样的，方式可能不一样。比如哈希join相比于排序来说，速度更快，但是输出us随机的。计算结果最终会被传递到最上面的投影操作符（projection）。这个操作符会丢弃不需要的数据。连接操作就像把俩个东西混在一起，然后沿着查询计划向上传递，查询计划中更上层的运算符，就不需要回去获取更多数据了。数据从一个运算符传递到另一个运算符。在延迟物化中，核心思想是，在从底层数据源检索数据的那一刻，你只需要获取当前运算符所需要的最少量的数据。比如对于select R.id, S.cdate from R join S in R.id=S.id where S.value > 100那么我们就不需要引入其他属性，比如名称，值，日期等等。只需要引入ID。然后我再存储一个记录ID，这个ID对应着这个元组在数据源中的位置。然后计算结果会被传递到它上面的运算符，在投影运算符丢弃所有不需要的数据。在这个例子中，在底层就获得了元组的所有内容，然后进行连接操作。在连接时，我们可以值传递id，而不传递全部数据。判断连接好坏的标准就是计算连接所需的IO数量。给定一个将表 R 与表 S 进行连接（JOIN）的查询，假设数据库管理系统（DBMS）已知这两个表具有以下信息：表 R：共有 M 个数据页（pages），包含 m 条记录（tuples）。表 S：共有 N 个数据页（pages），包含 n 条记录（tuples）。实际上我们很难确定哪个是大表小表。因为可能在where过滤之后1亿条记录的该表只剩下1条记录。不管用哪个算法，元组太多内存不够，都得溢出到磁盘。嵌套循环连接是最容易实现的。因为它的cpu开销很小，本质上就是一堆for循环而已。对于哈希连接，需要对数据进行哈希运算，然后找到对应的存储位置。根据使用的算法，看能需要扫描哈希表。这在计算上可能会更加昂贵。但我们这里假设，磁盘I/O开销远大于任何计算操作。特别是我们的哈希表将会进行高度的优化。暂时不考虑网络开销，所有数据库都在单机环境中（分布式查询可以在多台机器上进行连接操作）。nested loop joinsort-merge joinhash join如果你的系统想要支持事务，或OLAP负载，通常会实现索引嵌套循环连接。如果是OLAP工作负载，那么这些连接方式都会用到。高端系统（比如pgsql）会支持所有这些连接方式。mysql在2019年的8.0.18版本中首次引入哈希连接。循环连接查询（NLJ）这是最简单朴素的连接方式。还是对于查询语句select R.id, S.cdate from R join S in R.id=S.id where S.value > 100朴素嵌套循环连接，就是俩个for循环。真正做的就是遍历表R中的所有元组，然后对于表R中的每个元素，遍历表S中的所有元素。大多数系统中，查询计划中的表示为，join运算符左侧的表，被认为是外部的表。右侧被认为是内部表。有些系统比如snowflake会反转这个。但是千万不要这样做。对于R表中的每个元组，我们需要扫描一次S表，很显然是低效的。这个操作成本是M+N*N。其中+M是因为我们需要扫描外部表的所有页面一次。下图展示的是该操作需要的时间如果让小表驱动大表，执行性能会得到一定的改善。但是仍然很长（在不考虑系统磁盘缓存的情况下）。但是这个表其实非常小，假设每个页面大小为4KB，那么S表只有6MB，这么小的数据量完全可以放在CPU的L3缓存中。但是数据库会以块或者也的形式来存储页面，所以我们可以利用这一点，采用所谓的嵌套循环连接反复噶。Block Nested Loop（BNL）算法这里的想法是现在我们有更多的for循环包括外部循环。外层循环获取R中的每个块，内层循环获取S中的一个块。然后在内存中对这俩个块进行for循环，检查它们俩是否匹配。在数据库中，块和页面通常是一回事。硬件层面称之为块。cost：M+(M*N)现在算法有所改进，因为开始时仍然有M（外部表的扫描成本），但是扫描内部表的成本是M次。因为对于外部表中的每个页面，都要扫描内部表的所有页面。这里我们假设了给外部表一个frame，内部表一个frame，显然我们还要非常多的内存可以使用。因此对于所有这些算法，我们都希望较小的表作为外部表，这样可以尽可能多的保留在内存中。计算方式是基于实际拥有的表页数，而不是期望拥有的元组数。数据集会在磁盘管理器或者页目录中，维护大量关于给定表的元数据，记录着我们拥有的所有页面。当然其中一些页面可能是空的，但这没有影响。如果我们有B个可用Buffer，使用B-2个buffer给外部表的每一个块，使用一个buffer给内部表，一个buffer作为输出，因为我想尽可能多的将外部表存放到内存中。现在的模型会变成Cost：M+([M/(B-2)]*N)的向上取整。因此对于外部表的每一批页面，都需要读取内部表的所有页面。因此连接可以在150毫秒内完成如果内存足够，比如mysql会把整个S表放入到内存中。Index nested loop join但是如果我们在表上已经存在索引。那么我们可以将其用于内部表。只在索引上查找匹配项。sql Server会动态构建索引，即便这个索引不存在。比如对于内部表进行一次顺序扫描，构建索引，探测它来连接，然后在查询结束后将其丢弃。如果有索引成本将会变为Cost：M+(m*C)这里C是一个常数，用来代表探查索引的成本。这个索引如果是B+树， 那么成本是log n，如果是哈希表，成本是O（1）在查询的数据有索引过滤式，就会使用NLJ算法。没有索引就会采用BNL算法。Sort-Merge join排序合并连接的核心思想是，如果我先按照连接key对俩个表的数据进行排序。那么我可以遍历并扫描排序后的结果。而且我知道在某些情况下，永远不必回溯。因为在向下扫描的过程中，如果内层表下方不存在于外层表当前匹配的key，我就知道不会错过任何匹配项。这样就可以简化扫描过程，避免盲目搜索连接所需的匹配项。基本思想是，首先对俩个表进行排序，然后使用游标以同步的方式遍历外层和内层表。当游标确认不会再有匹配时，就会向下移动，因为键值是按顺序递增或递减的。对于内层表，如果外层表向下移动或者出现重复值，我们可能需要回溯，以防止错过匹配项。比如对于排序好的数据，我们首先看俩边光标指向的数据是否相同。如果相同就知道有一个匹配上了，如果不系统，我们就根据实际的值向下移动。我们会在内部表中维护一些关于这个最后值的元数据。以说明我查看的最近一批数据或某个值范围内那个值是什么。万一外表再次出现这个值，就需要进行回溯。还有一些额外的元数据需要说明跳回的偏移量是多少。last value是用来遇到现在不匹配，之前匹配的值时，用来向上回溯的。合并算法的复杂度不是乘算的关系成本但是排序合并连接算法最坏的情况是，每一列都只有一个值，比如ID都是1。但是在时间中，这种算法也有时非常有用，特别是当查询有一个group by子句，而这个子句正好和join子句相同的时候。这样的情况下，对齐进行排序时也可以进行连接。然后连接的输出也被排序了（不必附加order by子句）。这里的大部分成本都在排序中，，如果可以跳过排序，直接进行后面的步骤，那就相当于在使用索引嵌套循环连接了。hash join如果我基于一个表构建哈希表，然后在连接子句中查找另一个表里对应的key。如果值相同，就能得到匹配数据。我们会在外部表上构建哈希表。然后用内部表来探测它（在索引嵌套连接中，探测的是内部表的索引）。一个简单的哈希连接算法包含俩个阶段。在构建阶段，扫描外部表，构建哈希表（通常是线性探测哈希，因为最快）。第二阶段，扫描哈希表来寻找匹配项。此外，比如在构建链式哈希的时候，我们可以在每个链的前面放一个布隆过滤器。来加块查询速度。当key非常长时，我们就不需要逐key进行比较了。但是上面的假设是哈希表能够被完全放到内存中。在内存中无法存放的情况下，我们将采用之前提到的类似技术，在进行聚合时，将表分解为多个桶，并将根据需要将桶溢出到磁盘。然后在第二阶段，我们每次将一个桶加载到内存中，确保其大小适用于内存。在第一阶段中已经完成了哈希和分区操作，因此需要连接的数据必然位于同一级别的存储桶中。这是一种经典金丝狐，称之为partitioned hash join（分区哈希连接），嘤的时候也成为grace hash join。在80年代内存不足的时候，几乎所有系统都实现了这种在内存不足时进行哈希连接的方法。如果考虑到数据库系统需要支持的不同操作和功能，你会发现不需要通用CPU，它只需要一套规模适中的关系代数运算符。可以针对那些最耗费资源的部分进行专门的优化，从而获得显著的提升。现在也有许多数据库厂商是这样做的。但是这类硬件没成功的原因是，当你完成硬件设计，制造，交付给客户的时候，intel已经退出了最新款的CPU，很具摩尔定律，之前所有的优势都荡然无存。因此专用硬件加速数据库成为了数据库领域的圣杯。人们今天仍然大量使用FPGA。当进行哈希梳理的时候，大量的key集中在一个桶中，以至于该桶无法容纳与内存，即便是重新分区之后，该桶仍然无法放入内存。我们只能再次执行相同的操作，在进行一次划分，将无法放入内存的桶取出，使用不同的种子再次进行哈希处理。创建更多更小的桶，并且在所有数据再次溢出到磁盘后进行连接操作。但是当某个key存在大量的数值，无论是否再次对其进行哈希处理，这些数值，仍然会集中在一个桶级别中。如果意识到这种情况发生，可以退回搭配嵌套循环连接，仅针对小部分键执行连接操作。这是哈希连接的成本。在分区阶段，必须读取外表和内表各一次，总共是2M+N，第二阶段可以将页面都读取回来。这是hash join的成本。一个优化方式叫做hybrid hash join这个想法已经出现很久了，但是还没有人实际实现它，因为它很难被正确实现。因为必须先知道一些关于数据的信息，但你可能无法提前得知这些信息。如果你能识别出某个key非常热门，那么与其在第一阶段将所有数据桶溢出到磁盘，不如保留一个小部分我知道是热点的数据，将其保存在内存中。让其他内容溢出到磁盘。如果我能提前知道外部表的精确大小，我们就可以使用静态大小的哈希表，这样就能达到几乎完美的性能，但通常做不到。因此在大多数数据系统中，你会指定，当运行查询是，允许查询为哈希表分配多少内存，估计错了就得锁定整个结构，将其大小翻倍，再重新加载。总的来说对于大数据集，首选的永远是哈希。这里INLJ的C是可变的，可以是B+树，也可以是tire树，hash表等。
那些底层使用了Rocksdb的项目元数据使用了RocksDB的框架kafka：的streams默认将持久化状态的中间计算结果Pulsar的底层存储，也就是Bookkeeper：的默认存储实现中，Rocksdb用来维护Ledger元数据索引rocketmq：RocketMQ 在 broker 端通过 RocksDB 来保存 消费偏移量、订阅组管理/配置、认证授权数据等元数据Ceph：使用Rocksdb来管理对象映射和磁盘分配记录。Doris在后端BE存储中使用了rocksdb保存元数据的键值对。主要状态数据使用RocksDBMySQL/MariaDB中存在innodb的另一个存储引擎选择，MyRocks，底层使用了rocksdb作为存储引擎。顺序批量写入性能更高，但是查询延迟稍高。Kvrocks，pikiwidb，Tendis他们都兼容Redis协议，并且底层使用rocksdb作为主要的持久化协议存储。tidb：底层使用的tikv的内部使用了rocksdb作为存储实现。Flink：Checkpoint可以选择，通过使用rocksdb存储到本机。ClickHouse 提供了一个叫 EmbeddedRocksDB 的表引擎，可以让用户把一个表的数据存放在一个 RocksDB 实例中，并通过 ClickHouse SQL 访问它。Nacos等这些底层依赖sofa-jraft的框架底层都采用rocksdb作为存储，不过也允许切换为其他存储实现。MongoDB提供了rocksdb存储引擎插件，优化高写入场景
LSM‍‌‌‍⁠‌‬⁠⁠‍‍‬‌﻿‌⁠⁠‬‌‌‍‬‬‍‌‬‍⁠wiscKey论文精读 - 飞书云文档经典LSM实现-LevelDB适合磁盘索引的结构通常有俩中，一种是就地更新B+，一种是就地更新LSM。B+树读取性能更好，B+树随机写入很差，LSM写入性能更好。接下来内容都讨论LSM Tree。最直观的想法是将随机写入改成顺序写入，最简答的方法就是假设一个无限大小的文件，每次kv为一组，一直向后追加。这样会引出几个问题。同一个key可能被写入多次，更早的key就无效了，会浪费磁盘空间。读取的性能是不可接受的，读取一个数据可能要扫描全部文件。一个简单的优化想法就是合并这个无限大的文件，将被覆盖的key清理掉，只保留最新的key，但这样就不能在一个文件中操作（不能将压缩过程变成更新，类似GC），因此需要将文件分段。每次合并本质上就是一个归并排序的过程（为了支持范围查询，以及空间压缩），那么就需要找到所有分段文件中有重合范围的文件，进行合并。而且不能合并一个正在写入的文件。为了降低读取的复杂度，第一个想法就是使用内存，将最近新写入的kv存储在内存数据结构中，红黑树，跳表等。那么问题是合适将数据结构dump到磁盘？最简单的是根据其大小的区别，然而在dump之前我们不能继续向其中写数据。为了更好的确定内存中的数据结构什么时候应该被写入，我们应该在内存中维护一个不变内存表和活跃内存表，周期性的将不变内存表dump到内存中形成一个文件。这些都没有解决最先写入且从未更新的key尧都区全量数据的问题，为解决这个问题LSM结构引入了分层的思想。由于我们区分了不变内存，因此从内存dump到磁盘的文件一定是有序的。L0中每个文件内是不会出现重复的现象，但是文件之间可能是乱序的。从L1开始，每个层级内的SSTable文件必须保证Key范围有序且不重叠。这是通过compaction实现的。我们假设直接从内存写入磁盘的文件为第0层，从第0层合并数据到第二层。那么从c1-ck层，他们的数据只会出现一次。而c1-ck是发生过合并的文件。由于ci+1 是ci中具有重叠部分的文件合并的产物，因此可以说在同一层内是不存在重叠key的，因为重叠key已经在其上一层被合并了。那么只有c0层是可能存在重叠的文件的。所以当要读取磁盘上的数据时，最坏情况下只需要读取c0的所有文件以及c1-ck每一层中的一个文件即c0+k个文件即可找到key的位置，分层合并思想使得非就地更新索引在常数次的IO中读取数据。L1 的每个文件负责的 Key 范围是 由 Compaction 动态划分的，而非预先固定。例如：如果 L0 和 L1 的合并数据覆盖 [a, z]，Compaction 可能将其拆分为多个文件（如 [a, g]、[h, m]、[n, z]），每个文件大小接近 target_file_size（默认 2MB）。LSM tree中，文件更新完全通过原子替换实现。L1和L2的归并步骤选择 L1 层的所有 SSTable 文件选择 L2 层中与 L1 层有键范围重叠的 SSTable 文件对这些文件进行多路归并排序生成新的 L2 层 SSTable 文件删除旧的 L1 和 L2 层参与合并的文件leveldb最多有8层，假设第0层最多6个文件，我们只需要读取14个文件就能找到一个key。用户在读取同一个key的时候，只要读取最上面的最新版本即可。简单来说，同一层只有一个相同的key，但是不同层可能有多个相同的key的（不同版本）。在一个巨大的日志文件中找一个key也是很难的，leveldb中有三个模块，元数据区，索引区，数据表。元数据区存在布隆过滤器，可以直接判断key是不是在当前文件。布隆过滤器存在假阳，当假阳发生时，需要读取索引块。这个索引存储了key和对应value的偏移量，可以直接在内存中进行二分查找。实际上存储数据的结构叫做sstable。为了标记哪些SStable属于哪一层，因此要存在一个sstable的元数据管理文件，在levelDB中叫做MANIFEST文件。其中存储每一个sstable的文件名，所属的级别，最大与最小的key的前缀。LSM树中，一层有多个文件，这允许多个文件进行并行写入操作，小文件可以独立合并，避免每次合并都要重写整个层的数据。小规模更新对系统影响小，且每个层都可以维护自己的布隆过滤器。LSM tree优化方向LSM 是牺牲读取性能和空间利用率为代价，换取写入性能的。因此，对LSM的结构优化目标就是想办法提高读取性能和空间录用率。KV分离对SSD的优化SSD上引用LSM技术带来俩个问题，第一写放大缩短了SSD寿命，第二传统LSM无法充分发挥SSD的并行写入特性。相较于机械硬盘，随机读取与顺序读取的性能在固态上没有那么显著。意味着过分追求顺序读取而进行的激进优化是没有必要的。其中最明显的就是将key和value放在一个文件内（sstable），在机械硬盘上这种结构能保证一次读取就获得数据，减少了磁头的寻道次数，而对于固态哟安这样会导致合并压缩过程中，对同意kv进行多次写入与移动，从而降低了SSD的寿命。虽然也是读取一次但对于固态硬盘来说两次读取也要快于机械硬盘一次读取，而为了有效的减少LSM造成的读写放大，wisckey中将key与value分离存储，value仅存储在vlog中以仅追加的方式，而key存储在之前的lsm tree结构中。专业带来了俩个好处。不需要频繁的移动value的值，所以写放大减少，LSM存储固定大小的key使得其存储占用变小，在内存中可以同时存储更多的key进而提高了key的数据量，间接降低了读放大问题。对于范围查询，由于真正的value都存储在vLog中因此是无序的，所以进行范围查询就是需要进行随机读取（先从LSM顺序读key再随机读value），这必然会造成性能的下降，传统的随机读取是串行的难以发生固态并行随机读取的特性，因此在wisckey中根据迭代器方法调用prev，next来排序号lsm中读取一定的key到内存中，加速 随机的范围查询性能，这种读取是异步进行的，充分发挥固态硬盘的并行读取性能。实现细节工程优化kv分离带来的挑战kv分离会导致vLog文件不断增大，那么就需要合并，如何合并才能保证对性能的影响最小化？同时由于移动了value的位置，LSM结构中维护的value的位置信息也需要更新。数据具体如何布局？VLog日志如何拆分？Wisckey崩溃后如何才能保证数据一致性在sstable文件中数据布局: <key, addr(vlogName,offset,size)>在vlog文件中的数据布局: <keySize,valueSize,key,Value>这里的key是一个固定大小的字符串，addr是一个地址指针应该是一个64或者128的int值。value log中存储了 key大小（确定读取程度）值大小，key和value的具体值。这里再将key存放一遍是为了故障恢复需要使用。随机点查：先访问内存表是否命中key，如果找到地址信息判断是在内存中还是磁盘中(LRU缓存)在内存中被缓存了value则直接返回，否则去磁盘中查找，根据vlog的名字找到具体的vlog文件然后根据offset定位字节的首地址，根据size读取内容并返回基于一定策略将整个vlog涉及的bolck缓存下来范围查询，会返回一个迭代器。根据迭代器 next 还是 prev判断 是在游标之前读还是之后读，预先读取一定的key，交由底层的线程池异步的取ssd中获取数据将异步结果缓存在内存中 等到迭代器的调用随机、顺序写入将set写操作写入vlog日志中（存储key的作用之一就是即作为预写日志，又作为值日志）将返回的地址信息写入LSM中返回写入返回成功合并压缩head是最后写的，tail是最早写的其中合并后得到的新value log文件，可能在之后仍需要被再次合并Vlog分为多个文件，其中存在一个活跃vlog文件，用于写入数据最为head地址。（有些类似kafka，会又活跃的vlog）最先写入的日志在最后（早）的vlog中，存在tail地址。多个写入线程在head地址出追加日志而只有一个后台执行收集运行在尾部每次选择一个vlog文件的内容去lsm结构中随机查询（可并行化）将没有失效的key重新写回到head地址出重新追加到vlog中。然后更新lsm中这些key的新地址并释放老的vlog文件这一过程中需要先将数据写入新的vlog文件后，刷新到固态硬盘后一步更新索引。最后扇出老文件，保证次过程中进程崩溃不会造成数据丢失。故障恢复wisckey的设计中预写日志就是值日志因此引擎进程只需要定时保存head和tail的地址即可数据库恢复时需要获取崩溃前最新的地址然后从tail到head将日志进行redo即可（会有检查点的操作）同时为了保证一致性查询，在查询key时要做一些必要的一致性就爱你差当前key如果在tail预head索引范围外则忽略当前位置上的值具有key预查询的key不匹配则忽略当发生上述情况时，引擎直接返回错误信息。改进和优化写缓冲区，为减少写入的系统调用次数，多个小key的写入会被缓存在内存中回去在一起同意的写入lsm的sstable中，但会优先写入vlog中当做预写日志处理。空间放大率：数据的实际大小预逻辑上的大小比值用来衡量空间放大情况，对于固态硬盘来说，价格昂贵，降低空间放大率会节省成本在线垃圾收集：GC过程会造成引擎的性能尖刺，通过并发的在线分批次进行的GC操作来对前台读写行的影响。小value的存储：经过论文测试value大于4kb时读取性能才会有极大的提升，incid可以设置一个阈值，当value大于次阈值时才进行kb分离，而再次之前用传统lsm tree的格式。
Faster笔记Epochepoch框架Faster是基于一个关键的设计原则，旨在实现可伸缩性：避免在线程和公共的访问路径之间的昂贵协调开销。更快的线程在大多数情况下独立执行操作，不需要同步。与此同时，他们需要就共享系统整天同步的通用机制达成一致。为了实现这个目标我们扩展了多线程访问 epoch安全到一个框架中在任意全局操作上启用异步延迟。Epoch basics是Faster用于协调多线程并发访问的轻量级同步机制。以下是核心组件线程记作$T$全局当前epoch（E）一个原子计数器，由系统维护，记为当前的epoch。线程本地epoch$E_T$：每个线程维护自己本地版本的E，线程会定期刷新本地的E。安全epoch$E_s$：全局变量，表示所有线程以观察到的最小epoch值。即本地所有线程的epoch大于$E_s$时，$E_s$才会更新为那个新值。所有线程本地的$E_T$被存储在epoch table中每个线程占用一个缓存行（应该是为了防止缓存行伪共享）。对于任意的epoch $c$如果所有的线程本地的epoch都大于$c$，那么这个c就是安全的。系统保持对于任意的$T$, $E_s<E_T \leq E$Trigger action当把当前的线程从$c$增加到$c+1$,线程可以额外关联一个操作这个操作在将来系统epoch$c$为安全时进行触发。这是通过drain-list实现的，一个<epoch,action>pairs的列表，其中的action是回调代码。这通过使用小的数组来实现的，每当$E_s$更新时，该数组会被扫描以寻找触发的动作。我们在数组上使用原子的比较并交换来确保每个操作只被执行一次。使用epoch框架我们使用四个操作来暴露epoch保护框架，它可以被任意线程调用。Acquire：为$T$保留一个条目并且设置$E_T$为$E$。Refresh：更新$E_T$为$E$, $E_s$更新为当前最大的安全epoch并且触发在drain-list中就绪的操作。BumpEpoch(Action)：将计数器E从当前值c增加到c+1，并且添加到drain-list当中。Release：从epoch table中删除T。具有延迟触发动作的epoch可用于简化并行系统中的延迟同步。考虑一个典型案例，当共享变量状态更新为active时，必须调用active-now函数。线程将自动的将状态更新为active，并以active-now作为触发动作来触发epoch。并不是所有线程都会立即观察到状态的变化。然而他们都保证在刷新它们的epoch的时候观察到它，（由于使用内存屏障的顺序内存一致性）因此active-now只有在所有线程看到状态为活动状态后才会被调用，因此是安全的。我们使用epoch框架在faster中协调系统操作，比如内存安全的垃圾收集，索引改变，循环缓冲维护和页刷新，共享日志页边界维护和检查点。同时在短时间内为用户操作（如读取和更新提供更快的线程不收锁限制地访问共享内存位置）。Faster线程的生命周期我们使用faster实现了一个计数器存储（counter store），其中一组faster用户线程会传递与传入请求关联的计数器。线程首先调用Acquire向epoch机制注册自身，随后发起一系列的用户操作，并定期调用Refresh（例如没256次操作调用一次）以将线程移至当前epoch，同时调用completePending（例如没64K操作调用一次）来处理所有先前待处理操作。最后线程调用release来注销Faster的调用。Faster的哈希索引Faster的关键构建块是哈希索引，一个并发的无延迟可扩展以及可变的基于哈希的索引。为了便于说明，我们假设一个64位的机器，有64个字节的缓存行。在大的架构中我们期望有更大的原子操作，从而允许我们的设计可扩展。在4,5,6章中，我们把这个索引与不同的分配器配对，以创建功能不断增强的键值对存储。3.1 索引组织faster索引是一个缓存对其的$2^k$大小的哈希桶，其中每个桶的大小和缓存行对其（图2）。因此，一个64字节的同由7个8字节的哈希桶项和一个8字节的条目。每个溢出的桶（每个桶包含7个条目+1个溢出指针）也具有缓存行的大小和对其方式，并使用内存分配器按需分配。选择8字节的条目是关键的，因为它允许我们使用64位原子比较并交换操作对条目进行无锁操作。在64位机器上，物理地址通常占用不到64位，例如因特尔机器通常使用48位指针。因此我们可以偷窃这些额外的比特来进行索引操作（Faster至少需要一个bit）。在本文的其余部分中，我们使用48位指针，但请注意，我们可以支持63位长度的指针。每个哈希桶（图2）包含了三个部分：tag（15bit），tentative bit和地址。0值的条目代表着空槽位。在一个有$2^k$个哈希桶的索引中，tag用来将索引的有效散列分辨率从k位增加到k+15位。这通过减少哈希碰撞来提升性能。对于哈希值位h的键，其对应的哈希桶先通过h的前k位（成为h的偏移量，offset）来定位。h后面的15位被称为h的标签（tag）。标签只用于增加哈希分辨率，根据地址的大小，标签可能更小，或者完全被删除。试探性钻头是插入所必需的，稍后将介绍3.2索引操作Faster的索引是基于不变的每个(offset,tag) 有唯一的索引项，该索引项指向一组记录，这些记录的散列指向相同的偏移量和标签。在支持并发无锁读取、插入和删除索引条目的同时，确保这种不变性是一种挑战。这里的k取决于哈希表的大小。文中都是在说如何解决一个哈希桶中7个条目遇到冲突的情况。可以简单的理解为，为了缓存行对其，一个链表中存放了7个条目，图中的next Bucket address就是指向下一个的指针。其中每个条目都是一个链表。这里的tag可以将哈希分辨率提升到k+15位，15就是tag的固定大小。比如在查找时如果一个桶中有许多元素，可以先比较tag，来排除一部分，而不需要直接比较俩个key本身（如string的比较代价是高昂的）。查找和删除一个条目。定位一个条目对应的key是直接的：我们使用k哈希bit，扫描存储桶以找到标记的条目。从索引条目中删除条目也很容易：我们使用比较交换将匹配项（如果有的话）替换为0。插入一个条目：考虑一个例子，当一个tag没有存在于bucket中，并且必须插入一个新条目。一个简单的方法是查找空条目并使用比较并交换插入标记。但是俩个线程可以并发地在bucket中的俩个不同的空槽中插入相同的标记，从而破坏我们的不变量。作为一种变通，考虑一个解决方案，每个线程从左到右扫描bucket，并确定地选择一个空条目作为目标。他们将使用比较并交换来竞争插入，并且只有一个会成功。这里给的例子意思是一个哈希桶中出现了一模一样的重复的key即使是这种方法在存在删除操作时也会违反不变性条件，如图3a所示。一个线程$T_1$从左到右扫描桶，并且选择槽5来插入tag$g_5$。另一个线程$T_2$从相同桶中的slot3中删除$g_3$，然后尝试插入具有相同tag的$g_5$.从左到右扫描将会导致线程$T_2$为这个tag选择第一个空的条目3。可以证明，对于任何独立选择槽并直接插入的算法都存在这个问题：要了解原因，请注意，就在线程$T_1$执行比较并交换之前，它可能会被交换出去，数据库状态可能会任意更改，包括具有相同tag的另一个槽。简单来说就是插入之后重新跑一遍检查看看有没有值被修改过，如果被修改过了。说明发生竞争了，需要重新执行。但是一个桶内发生竞争的概率很小。因此，锁定桶是一个必要（但是沉重）的解决方案。Faster使用latch-free俩阶段插入算法这使用了tentative bit条目。一个线程找到一个空槽并插入了含有tentative bit 记录。然后我们重新扫描桶（注意，它已经存在于我们的缓存中）来检查是否另一个tentative条目在同一个tag中。如果是回退进行重试。否则我们重置暂定位以完成插入。因为每个线程都遵循这个俩阶段提交方法，因此每个线程都保证稳定的不变性。图3b展示了俩个线程的操作顺序：不存在任何并发交错（interleaving）会导致重复的非暂定（non-tentative）tag。3.3 扩容和索引检查点对于键数可能随时变化很大的应用程序，我们支持动态调整索引大小。我们利用epoch保护盒阶段状态机降低开销执行大小。有趣的是，即使存在并发操作，使用无锁存操作也总是将索引保持在一致状态。这允许我们在不获得读锁的情况下执行索引的异步模糊检查点，从而大大简化了恢复。内存键值对存储我们现在构建了完整的内存键值对存储使用上述的hash index，搭配在内存中的入jemalloc分配器。具有相同(offset,tag)的记录被组织为反向单链表。哈希桶入口指向链表尾部（最近的一条记录）尾部指向前一条记录，以此类推。每个记录可以是固定大小或可变大小，由64位记录头、键和值组成。记录头如图2所示。除了前面的指针之外，我们还使用一些位（invalid和tombstone）来跟踪日志结构分配器所需的信息（参见第5节和第6节）。这些位作为地址字的一部分存储，但如果需要，也可以单独存储。内存分配中的操作fetch-and-add是什么？fetch-and-add 是一种原子操作（atomic operation），用于在多线程环境下安全地修改共享变量的值。它的核心特点是原子性（不可分割），即在执行过程中不会被其他线程中断，从而避免竞态条件（race condition）。在faster中，用户在epoch保护的安全下线程读取和修改记录值，使用用户的读取或更新逻辑处理记录级并发。例如可以对计数器级别使用fetch-and-add，采用记录级别的锁，或者利用用户级别的分区只是来进行无锁更新。下面描述对于存储的操作。读取，我们从3.2节中描述的索引中找到匹配的标记条目，然后遍历该条目的链表以找到具有匹配键的记录。更新和插入。盲更新（upsert）和rmw更新都是从查找键的哈希桶条目开始的。如果条目不存在，我们使用3.2中描述的俩阶段算法来插入标记和新纪录的地址到散列桶的空槽之中。如果条目已存在，我们扫描链表来找到一个匹配键的记录。如果存在这样的记录，我们就地执行操作。线程保证访问记录的内存位置，只要它不刷新它的epoch。此属性允许线程在不担心线程安全的情况下就地更新值。如果这样的记录不存在，我们使用比较-交换将新记录拼接到列表的尾部。在我们的计数存储示例中，我们增加现有键的计数器值，使用fetch-and-increment或普通的in-place increment（如果key是分割的）。插入新键的初始值设置为0删除操作。我们通过原子性地将记录从链表中移除来实现删除，具体方式是对记录头或哈希桶条目（针对首条记录）执行比较并交换操作。当从单例链表中删除记录时，该条目会被置为0，从而允许后续插入操作重新使用该位置。由于同一内存地址可能存在并发更新，被删除的记录无法立即归还给内存分配器。为此我们采用基于epoch的保护机制：每个线程维护一个线程本地（避免并发瓶颈）的(epoch, 地址)配对空闲列表。当相关epoch进入安全状态后，即可将这些内存安全地归还给分配器。
Megastore: Providing Scalable, Highly Available Storage for Interactive Services（1）
Megastore: Providing Scalable, Highly Available Storage for Interactive ServicesMegastore.pdfINTRODUCTIONMegastore Blends the scalability of a NOSQL data storage with the convenience of a traditional RDBMS. It uses synchronous replication to achieve high availability and a consistent view of data. It provide fully serializable ACID semantics over distant replicas with low enough latencies to support iterative application.We accomplish this by taking a middle ground in the RDBMS vs. NoSQL design space: we partition the data-store and replicate each partition separately, providing full ACID semantics within partitions, but only limited consistency guarantees across them. We believe that megastore is the largest system deployed that uses Paxos to replicate primary user data across datacenters on every write. Megastore has been widely deployed within Google for several years. It handles more than three billion write and 20 billion read transactions daily and stores nearly a petabyte of primary data across many global datacenters.The key contribution of this paper are:the design of a data model and storage system that allows rapid development of interactive applications where high availability and scalability are built-in from the start;an implementation of the Paxos replication and consensus algorithm optimized for low-latency operation across geographically distributed datacenters to provide high availability for the system;a report on our experience with a large-scale deployment of Megastore at Google.2 Toward Availability and scalefor availability, we implemented a synchronous ,fault-tolerant log replicator optimized for long distance-links.for scale, we partitioned data into a vast space of small databases, each with its own replicated log stored in a per-replica NoSQL datastore.2.1common strategies for with-area replication:Asynchronous Master/slave: a master node replicates write-ahead log entries to at least one slave. Log appends are acknowledged at the master in parallel with transmission to slaves. The master can support fast ACID transactions but risks downtime or data loss during failover to a slave. a consensus protocol is required to mediate mastership.Synchronous master/slave a master waits for changes to be mirrored to slaves before acknowledging them allowing failover without datal loss. Master and slave failures need timely detection by an external systemOptimistic replication any member of a homogeneous replica group can accept mutations, which are asynchronously propagated through the group. availability and latency are excellent. However, the global mutation ordering is not known at commit time, so transactions are impossible.2.2 Partitioning and locality2.2.1 Entity groupsTo scale throughput and localize outages, we partition our data into a collection of entity groups, eah independently and synchronously replicated over a wide area. The underlying data is stored in scalable NoSQL datastore in each datacenter(see Figure 1).Entities within an entity group are mutated with single phase ACID transactions(for which the commit record is replicated via Paxos). Operations across entity group could rely on expensive two-phase commits, but typically leverage Megastore's asynchronous messaging. A transaction in a sending entity group places one or more messages in a queue; transactions in receiving entity groups atomically consume those message and apply ensuring mutations.Note that we use asynchronous message between logically distant entity groups, not physically distant replicas. All network traffic between datacenters is from replicated operations, which are synchronous and consistent.Index local to an entity group obey ACID semantics; those across entity group have looser consistency. See Figure 2 for the vraious operation on and between entity groups.2.2.2 Selecting Entity Group boundariesThe entity group defines the a priori grouping of data for fast operations. Boundaries that are too fine-grained force excessive cross-group operations, but placing to much unrelated data in a single group serializes unrelated writes, which degrades throughput, but placing too much unrelated data in a single group serializes unrelated writes, which degrades throughput.The following examples show ways applications can work within these constraintsEmailBlogsMapsphysical layoutWe use Google's Bigtable for scalable fault-tolerant storage within a single datacenter, allowing us to support arbitrary read and write throughput by spreading operations across multiple rows.We minimize latency and maximize throughput by letting applications control the placement of data: through the selection of Bigtable instances and specification of locality within an instance.To minimize latency, application try to keep data near users and replicas near each other. They assign each entity group to the region or continent from which it is accessed most. Within that region they assign a triplet or quintuplet of replicas to datacenters with isolated failure domains.A tour of megastoreMegastore map this architecture onto a feature set carefully chosen to encourage rapid development of scalable applications. This section motivates the tradeoffs and describes the developer-facing features that result.3.1 Api design philosophyACID transactions simplify reasoning about correctness, but it is equally important to be able to reason about performance. Megastore emphasizes cost-transparent API with runtime costs that match application developers' intuitions.Normalized relational schemas rely on join at query time to service user operations. This is not the right model for Megastore applications for several resons:High-volume interactive workload benefit more from predictable performance than from an expressive query language.read dominate writes in our target applications, so it pays to move work from read time to write time.Storing and querying hierarchical data is straightforward in key-value stores like Bigtable.With this in mind, we designed a data model and schema language to offer fine-grained control over physical locality. Hierarchical layouts and declarative denormalization help eliminate the need for most joins. Queries specify scans or lookups against particular table and indexes.Join, when required, are implemented in application code. We provide an implementation of merge phase of the merge join algorithm, in which the user provides multiple queries that return primary key for the same table in the same order; we then return the intersection of keys for all the provided queries.We also have applications that implement outer joins with parallel queries. This typically involves an index lookup followed by parallel index lookups using the results of the initial lookup. We have found that when the secondary index lookups are done in parallel and the number of results from the first lookup is reasonably small, this provides an effective stand-in for SQL-style join.3.2 data modelMegastore defines a data model that lies between the abstract tuples of an RDBMS and the concrete row-column storage of NoSQL. ASs in an RDBMS, the data model is declared in schema and is strongly typed. Each schema has a set of tables, each containing a set of entities, which in turn contain a set of properties a set of entities. properties are named and typed values. The types can be strings, various flavors of numbers, or Google's protocol Buffers. They can be required, optional, or repeated(allowing a list of values in a single property). All entities in a table have the same set of allowable properties. All entities in a table have the same set of allowable properties. A sequence of properties is used to form the primary key of the entity, and the primary keys must be unique with in the table. Figure 3 shows an example schema for a simple photo storage application.Megastore tables are either entity group root tables or child tables. Each child table must declare a single distinguished foreign key referencing a root table, illustrated by the ENTITY group KEY annotation in Figure3. Thus each child entity references a particular entity in its root table(called the root entity). An entity group consists of a root entity along with all entities in child tables that reference it. A megastore instance can have several root tables, resulting in different classes of entity groups.In the example schema of Figure 3, each user's photo collection is a separate entity group. The root entity is the User, and the photos are child entities. Note the photo. tag field is repeated, allowing multiple tags per photo without the need for a sub-table.3.2.1 pre-joining with KeysWhile traditional relational modeling recommend that all primary keys take surrogate value, Megastore keys are chosen to cluster entities that will be read together. Each entity is mapped into a single Bigtable row; the primary key values are concatenated to form the Bigtable row key, and each remaining property occupies its own Bigtable column.Note how the Photo and User tables in figure 3 share a common user_id key prefix. The IN TABLE USER directive instructs Megastore to colocate these two tables into the same Bigtable, and the key ordering ensures that Photo entities are stored adjacent to the corresponding User. This mechanism can be applied recursively to speed queries along arbitrary join depths. Thus, users can force hierarchical layout by manipulating the key order.Schemas declare keys to be sorted ascending or descending, or avert sorting altogether: the scatter attribute instructs Megastore to prepend a two-byte hash to each key. Encoding monotonically increasing keys this way prevents hotsots in large data set that span Bigtable servers.3.2.2 IndexesSecondary indexes can be declared on any list of entity properties, as well as fields with in protocol buffers. We distinguish between two high-level classes of indexes: local and global (see Figure2). A local index is treated as separete indexes for each entity group. It is used to find data within an entity group. In Figure3, PhitiByTime is an example of a local index. The index entries are stored in the entity group and are updated atomically and consistently with the primary entity data.a global index spans entity groups. It is used to find entities without knowing in advance the entity groups that contain them. The phitisByTag index in Figure 3 is global and enables discovery of photos marked with a given tag, regardless of owner. Global index scans can read data owned by many entity groups but are not guaranteed to reflect all recent updates.Megastore offer additional indexing features:storing clause(物化视图)repeated indexesInline Indexes.3.2.3 mapping to BigtableThe Bigtable column name is a concatenation of the megastore table name and property name, allowing entities from different Megastore to be mapped in to the same Bigtable row without collision. Figure 4 show how data from the example photo application might look in Bigtable.Within the Bigtable row for a root entity, we store the transaction and replication metadata for the entity group, including the transaction log. Storing all metadata in a single Bigtable row allows us to update it atomically through a single Bigtable transaction.Each index entry is represented as a single Bigtable row; the row key of the cell is constructed using the indexed property values concatenated with the primary key of the indexed entity. For example, the photosByTime index row keys would be the tuple(user_id,time,primary key) for each photo. Indexing repeated fields produces one index entry per repeated element. For example, the primary key for a photo with three tags would appear in the photosByTag index thrice.3.3 Transactions and concurrency contorlEach Megastore entity group functions as a mini-database that provides serializable ACID semantics. A transaction writes its mutations into entity group's write-ahead log, then the mutations are applied to the data.Bigtable provides the ability to store multiple values in the same row/column pair with different timestamps. We use this feature to implement multiversion concurrency control(MVCC): when mutations within a transaction are applied, the values are written at the timestamp of their transaction. Readers use the timestamp of the last fully applied transaction to avoid seeing partial updates. Readers and writers don't block each other, and reads are isolated from writes for the duration of a transaction.3.1.1 queuesQueues provides transactional messaging between entity groups. They can be used for cross-group operations, to batch multiple update into a single transaction, or to defer work. A transaction on an entity group can atomically send or receive multiple message in addition to updating its entities. Each message has a single sending and receiving entity group; if they differ ,delivery is asynchronous.(See figure 2)Queues offer a way to perform operation that affect many entity groups. For example, consider a calendar application in which each calendar has a distinct entity group, and we want to send an invitation to a group of calendars. A single transaction can atomically send invitation queue messages to many distinct calendars. Each calendar receiving the messages will process the invitation in its own transaction which updates the invitee's state and deletes the message.There is a long history of message queues in full-featured RDBMSs. Our support is notable for its scale: declaring a queue automatically create an inbox on each entity group, giving us millions of endpoints.3.3.2 two-phase commitMegastore supports two-phase commit for atomic updates across entity groups. Since these transactions have much higher latency and increase the risk of contention, we generally discourage application from using the feature in favor of queues. Nevertheless, they can be useful in simplifying application code for unique secondary key enforcement.
Dynamo：亚马逊的高可用键值对存储概要在大规模下的可靠性是我们在亚马逊一个大的挑战，一个世界上最大的电商系统。即便是轻微的中断也会明显的财务影响并且影响了客户的信任。亚马逊平台提供了给世界尺度的很多网站提供服务，在成千上万台基础设施上实现的，并且网络组件位于许多遍布世界的数据中心。在这个规模下，小或者大的组件连续不断的故障，面对这些故障时管理持久化状态的方式驱动了可靠的可扩展的软件系统。这个paper提出了设计和Dynamo的实现，一个高可用的键值对存储系统，亚马逊的一些核心服务就是用它来提供永远的在线体验的。为了达到这种级别的可用性，Dynamo牺牲了在某些故障场景下的一致性。它以一种为开发人员提供新颖接口的方式，广泛使用了对象版本控制应用辅助的冲突解决，1 介绍亚马逊运行着世界范围的电商平台，在高峰时段为数以千万的客户提供服务，使用上万台服务器位于世界的许多数据中心。在亚马逊平台上的操作对于性能，可靠性，有效性方面有严格的要求，为了支持持续增长，平台需要具有高度可扩展性。可靠性是一个最重要的需求，因为即便是小的中断也会导致明显的经济后果和影响客户的信任。此外，为了支持持续的增长平台徐亚高扩展性。我们团队组织从运营亚马逊平台中学到的经验之一，系统的可靠性可扩展性依赖于如何管理应用程序状态。亚马逊使用了高度去中心化的，松耦合的，面向服务体系结构，由数百个服务组成。在这个环境特别需要强大的永远可用的存储技术。例如客户有能查看和添加项目到购物车即便磁盘故障。网络线路不稳定，数据中心被龙卷风摧毁。因此，服务负责管理购物车他可以永远被读取和写入在数据存储，数据需要跨多个数据中心高可用。处理由百万个组件组成的基础设施中的故障是我们标注的操作模式；总会有几个小的但是显著数量的服务和网络组件在一些给定时间内故障。因此亚马逊软件系统需要构建一种方式，他将异常视为正常情况而不影响可用性和性能。为了应对可靠性和扩展性需求，亚马逊开发了几个存储技术，其中Amazon Simple storage Service (也可以在亚马逊之外访问到，被称为Amazon S3)，可能是最有名的。这篇论文提出了Dynamo的实现和设计。在亚马逊平台上构建的另一种高可用的可靠的可扩展的分布式数据存储。Dynamo被用来管理服务状态，它们对可靠性的需求非常高并且需要严格的控制，可用性，一致性成本效益和性能。amazon平台有非常多样的应用在不同的存储需求。选择一组应用需要一种存储技术，他足够弹性，以让应用设计人员根据这些权衡适当的配置数据存储以实现高可用，使用最划算的方式保证性能 。许多在亚马逊平台上的服务他们只需要键值对访问。对于许多服务，比如提供畅销排行榜的，购物车，客户偏好，会话管理，销售排名和产品目录，使用关系型数据库的常见模式会导致低效率以及限制了规模和可用性。Dynamo提供了简单的键值对接口，以应对这些应用的需要。Dynamo使用了一些工作的良好的技术综合以达到可扩展性和可靠性：数据被通过一致性哈希进行分区和复制。通过在更新期间保持副本的一致性通过法定人数技术和去中心化的复制同步协议。Dynamo使用了基于gossip的故障探测和成员协议。Dynamo是一个完全去中心化的系统并且最小化手动管理的需求。存储节点可以在Dynamo添加或者删除而不需要任何手动分区或者重分布。在过去的一年中，Dynamo已经成为了亚马逊贸易平台中的几个核心服务的底层存储技术。它能够在节假日扩展到极端的峰值负载而无需任何停机时间。例如，维护购物车的服务服务于数千万的请求在同一天就有超过300万的结账，管理会话状态的服务处理数十万的活动会话。这项工作对于学术届的主要贡献是评估了如何结合不同的技术来提供单一的高可用性系统。他展示了最终一致性的存储系统可以被用在对于有着高要求的应用。他也提供了对这些技术调优的深入理解以应对生产系统非常严苛的性能需求。背景亚马逊的电商平台由几百项服务组成，这些协同功能从推荐到订单履行到欺诈检测的各种功能。每个服务都通过良好定义的接口进行暴露可通过网络访问。这些服务被托管于由上万个服务谓语世界范围内的数据中心。其中的一些服务是无状态的（服务聚合从其他服务的影响）一些是有状态的（即服务在他存储在持久化系统的状态上执行业务逻辑，生成它的相应）。传统的生成系统存储他们的状态在关系型数据库中。然而， 对于许多常见的状态持久化的使用模式，关系系数据库是非理想的解决方案。他么中的大部分服务只存储并检索数据，不需要关系型数据库管理系统提供的功能复杂的查询和管理。这种多余的功能需要昂贵的硬件和高技能的操作人员，这是一个非常低效的方案。此外，可用的复制技术是有限的，通常选择一致性而不是可用性。虽然尽管近年来取得了许多进展，横向扩展数据库依然不简单，或者使用智能的分区模式以负载均衡。这篇论文描述了Dynamo，一个高可用的数据存储技术解决了这些重要类型的服务的需求。Dynamo有简单的键值对接口，他具有明确定义的一致性窗口的高可用，在资源存储中是有效的，并且有一个简单的扩展方案来解决数据集大小或请求率的增长。每个使用Dynamo运行自己的Dynamo实例。2.1 系统假设和需求此类业务对存储系统的要求如下：查询模型：简单的读取和写入操作到到数据条目，她被通过key唯一进行标识。状态就像二进制对象一样被存储（blobs）被唯一的key定义。没有跨多个数据项的操作，也不需要关系模式。这个需求基于一个显著的观察，亚马逊的服务可以在简单的查询模型下工作，并且不需要任何关联模式。Dynamo目标一样需要存储的对象通常小于1MB。ACID属性 ：ACID（atomicity，consistency，isolation，durability）是一组保证数据库事务处理可靠性的资源。在数据库文本中，单一逻辑的操作被叫做事务。在亚马逊用的经验已经展示了提供事务的数据存储提供了差的可用性。这已经被机构和学界所认可。Dynamo 目标一样他操作是弱一致性的（ACID中的C）如果这导致高可用性。DynamoDynamo不提供任何的隔离性保证，许可只有单个键更新的时候。有效性：系统需要功能谓语通用的硬件基建之上。在亚马逊平台，服务有很明显的延迟要求，通常在分部99.9%处测量。鉴于状态访问在服务中扮演了重要的决策，存储系统必须能够面对这样严苛的SLA。服务必须能够配置Dynamo，这样他就能始终如一的达到延迟和吞吐量。成本效益，可用性和持久性保证之间做出权衡。其他假设：Dynamo被用来亚马逊内部的服务。他的操作环境被假设为非敌对的，不存在认证授权等安全相关需求，此外，因为每个服务使用了Dynamo的不同实例，它最初的设计模板是多大数百台存储主机的规模。我们将会讨论Dynamo可靠性限制并且可扩展性和相关的扩展在之后讨论。2.2 服务水平的协议（SLA）为了保证应用可以在有限的时间内交付它的功能。平台中的每个依赖项都需要在更严格的范围内交付其他功能。客户和服务签订服务水平协议（SLA），正式协商的合同，其中客户端和服务就系统的几个特征达成一致，其中最突出的是包括客户端的预期的特定API的请求率分布以及这些条件下预期的服务延迟。一个简单的SLA的例子是服务保证了他在300ms内提供响应百分之99.99%的请求在峰值时客户端每秒负载500个请求。在亚马逊去中心化导向的基建，SLA扮演了重要的角色。例如，一页的请求达到一个电商网站通常需要呈现引擎构造其相应，通过调用超过150个服务。这些服务通常由多个依赖，这些经常是其他的服务，因此程序调用图有多个级别并不罕见。为了确保页面呈现擎可以维护明确的界限，调用链中的每个服务都必须遵守其性能契约。图1：亚马逊平台上面向服务的架构图1展示了亚马逊平台的抽象架构图，其中动态的web内容被页面呈现组件，这些组件反过来查询许多其他的服务。一个服务可以使用不同的数据存储来管理他的状态，这些数据存储是只能在他的服务边界内访问。一些服务的行为像聚合器，通过使用几个其他的服务生成复合响应。通常聚合器服务是无状态的，虽然他们使用了庞大的缓存。业界形成面向性能的SLA的常用方法是使用均值，中值和期望变量来描述它。在亚马逊我们发现了这些指标不足够好，如果想要构建一个系统让所有的用户有良好的体验，而不只是大多数。例如，如果使用广泛的个性化技术，那么具有较长历史的用户需要更多的处理，这会影响分布上端的分布性能。用中指或者均值响应时间表示SLA不能解决这一部分客户群体的表现。为了解决这个问题，在亚马逊中，SLA表达和衡量的是第99.9百分位的分布。这个选择对于99.9%或者更高的百分位已经基于成本效益分析得出，展示了提高性能成本的显著增加。亚马逊的生产环境系统已经表明，与那些满足基于平均值或中位数定义的SLA的系统相比，这种方法提供了更好的整体体验。在这个paper中很多次提到了分布中的99.9百分位，这反应了亚马逊工程师持续的站在客户经验的角度专注于性能。许多论文都上报了平均值，所以当在有意义的情况下这些会被包含进来用于比较。然而亚马逊工程师和优化工作并不聚焦于平均值。几个技术，比如负载均衡选择了协调者，是完全具有针对性的在控制性能在99.9的百分位上存储系统通常在建立一个服务的SLA中扮演了重要的角色，特别是业务逻辑相对轻量级的时候，亚马逊的许多其他服务器也是如此。一个主要设计的考虑是因为Dynamo是让服务控制它们的属性，比如持久性和一致性，并且让服务在功能性和性能和成本效应之间做权衡。2.3 设计考虑商业系统中的数据复制算法传统上执行同步副本协调以提供强大的一致性数据访问接口。为了达到这个级别的一致性，这些算法聚焦于在特定的故障场景做权衡。例如，与其处理答案是否正确的不确定性，不如让些数据是绝对正确的否则不能使用这些数据。在一个非常早起的复制数据库工作，当处理可能的网络故障时，强一致性和高可用性不可能被同时达到，这一点广为人知。这些系统和应用需要知道哪个属性可以在哪个条件下被达到。对于容易出现网络故障的系统，通过优化复制技术可以增加可用性，允许将改变传播到后台的副本，并且允许并发的，断开连接的工作。这些方法带来的挑战可能必须检测和解决相互冲突的更改。这个冲突解决的过程导致了俩个问题：什么时候解决他们以及谁来解决它们。Dynamo被设计为最终一致性的数据存储；也就是说所有的更新最终会达到副本。一个重要的设计考量是决定什么时候执行解决更新冲突的处理，即是否应该在读写期间解决冲突。一些传统的数据存储执行冲突解决在写入的时候并且保持读取的复杂性较低。在这样的系统中，如果数据存储不能达到所有的（或者大多数）节点在给定时间中，写入可能会被拒绝。另一方面，Dynamo的目标是“始终可写”数据存储的设计空间（即：数据存储对于写入高可用）。对于一些亚马逊的服务，拒绝客户的更新会导致糟糕的客户体验。举个例子，购物车服务必须允许用户添加或者删除购物车的项目。即便网络出现故障的情况下也是如此。这一需求强迫我们推动冲突解决的复杂性到读取去却把写入永远不会被拒绝。下一个设计选择了谁来执行这个冲突决议。这个可以通过数据存储或者应用来完成。如果冲突决议被数据存储完成，他的选择非常有限。在这个例子中数据存储只能使用简单的策略，比如最后写入的得胜以解决冲突更新。另一方面因为应用知道数据冲突的模式他可以决策冲突决议方法，这对于客户端的体验来说是非常合适的。例如，那些管理客户购物车冲突的应用可以选择合并冲突的版本并且返回一个一致的购物车。即便有这种特性，一些应用开发者可能不想编写自己的冲突解决机制，并选择将其下推到数据存储中，这样反过来又选择了一个简单的策略，比如因为最后一次写为获胜。设计中包含的其他关键原则是：增量可扩展性：Dynamo应该能过横向扩展（之后称作节点）在一个时间，并且最小化对于系统操作和系统本身的影响。对称性：每一个节点在Dynamo中应该和对等节点有相同的指责；没有特殊角色的节点或者有额外特殊职责的节点。在我们的经验中，对称性简化了系统的配置和维护过程。去中心化：一个对称性的延伸，设计应该偏好于去中心化的p2p技术不受中心化的控制。在过去，中心化的结果导致了停机，我们的目的是尽量避免它们。这导致了简单的，可扩展的和更可靠的系统。异质性：系统需要能够利用跑在基础设施上的异质。即工作分布必须与单个服务器的能力成比例。这是非常重要的对于添加新的高容量的节点，而无需对所有主机位进行升级。3 相关工作4 系统架构存储系统需要的操作在生产环境是复杂的。除了真正的数据持久性组件，系统需要扩展和健壮解决负载均衡，成员和故障探测，复制同步，过载处理，状态转换，一致性和工作调度，请求整理，请求路由，系统监控和报警以及配置管理。描述每个解决方案是不可能的，这篇论文聚焦于核心的分布式系统技术在Dynamo中的使用：分区，复制，版本管理，成员，失败处理和扩展。4.1 系统接口Dynamo通过相关联的键使用简单的接口；他暴露出来了俩个操作，get()和put()。get(key)操作定位对象副本在存储系统中和key的关联，并返回冲突版本的单个对象或者对象列表以及上下文。put(key,context,object)根据关联键放置对象副本的位置，并且写入副本到磁盘。context编码了系统关于对象的元数据，他们对于调用者来说是不透明的比如对象版本等信息。上下文信息与对象一起存储，以便系统可以验证放置请求中提供的上下文对象的有效性。Dynamo同时处理调用者提供的键并且都将对象视为不透明的数组。他使用MD5哈希来生成128位的标识符，这杯用来确定服务这个key的存储节点。4.2 分区算法一个对于Dynamo关键的设计是它必须逐渐的扩展。这需要机制来动态的分区数据在整个节点的集合上在系统中。Dynamo的分区模式依赖于一致性哈希以分布负载穿过多个存储主机。在一致性哈希中，哈希的函数的输出范围如同一个固定的圆空间或者环（即从最大的环绕到最小的哈希值）。系统中的每个节点指派一个在空间中的随机值他表示环上的位置。每个由键标识的数据项通过散列数据项的键来获得其在环上的位置，从而分配给节点，然后顺时针遍历环，找到一个位置大于项目位置的节点。因此每个节点都负责他在环上和前一个节点之间的区域。一致性哈希的主要优点是一个节点离开或者只到达影响它的邻近，其他节点维持不受影响。基础的一致性哈希算法提出了一些挑战。首先环上各个节点随机位置分配导致了负载分布的不均衡。其次基础算法忽略了节点性能之间的异质性。为了解决这个问题，Dynamo使用了一致性哈希的变体：不是将节点映射到环中的单个点，每个节点分配到环中的多个点。为了这个目的，Dynamo使用虚拟节点。一个虚拟节点就像在系统中的单个节点，但是节点可以负责超过一个的虚拟节点。实际上，当新的节点添加到系统中，它指派多个位置（之后叫做token）在环中。Dynamo分区方案的微调过程将在第6节中讨论。使用虚拟节点有如下的优势：如果一个节点不可用（由于失败或者日常维修），这个节点上处理的负载均匀的分布在剩下的可用节点上当一个节点再次可用，或者新的节点被添加到系统中，新的可用节点接受和其他可用节点大致相等的负载。节点负责的虚拟节点的数量可以根据节点的容量来进行确定，考虑到物理基础设施的异质性。4.3 复制为了实现高可用性和耐用性，Dynamo复制它的数据到多个主机上。每个数据项被复制到n个主机上，其中n是每个实例可以配置的参数。每个键k分配一个给协调器节点（在前一张描述）。协调者负责复制其范围内的数据项。除了本地存储范围内的每个键之外，协调器复制这些键在环中n-1个顺时针的后继者节点。这导致了在系统中每个节点负则它和在它之前的第n个环区域。在图2中，节点B复制在c和d上的的键key，并将其存储在本地。节点D存储落在范围(A, B], (B, C], and (C, D].中的数据。负责存储特定键的节点列表被叫做preference偏好列表。系统的设计将会在4.8章进行解释，所以系统中的每个节点可以决定对于特定的键，哪个节点应该在列表中。为了考虑节点故障，偏好列表维护了n个节点。注意通过使用虚拟节点，对于特定键的前n个节点可能被小于n个物理节点持有（即节点可能保存前N个位置中的多个位置）。为了解决这个问题，键的偏好列表通过跳过环中的位置来进行构建以确保列表只包含不同的物理节点。4.4 数据版本Dynamo提供了最终一致性，这允许更新异步的传播到所有的副本。put() 调用可能在更新尚未应用到所有副本之前就返回给调用者，这可能导致后续的 get() 操作返回一个没有最新更新的对象。如果没有失败，更新的传播时间会有边界。然而，在不确定失败的场景下（即，服务退出或者网络分区），更新可能在很长的时间内不能到达所有副本。亚马逊平台的一类应用类型可以忍受这种不一致，并且可以在这个条件下构建操作。例如，购物车应用程序要求永远不能忘记或拒绝添加购物车操作。如果最新状态不可用，并且用户对购物车的旧版本进行了更改，这种变化是有意义的，应该被保留。但是与此同时，它不应该取代购物车不可用的状态，它本身可能包含应该被保留的变化。记住无论是添加购物车还是删除购物车操作都会被翻译为put请求到Dynamo。当用户想要添加一个项目（或者删除）到购物车最后的版本是不可用的，该项目天极爱（或者从中删除）就的版本不同的版本应该在之后被调和。为了提供这种保证，Dynamo处理每次修改的结果作为一个新的不可变的数据版本。它允许对象的多个版本在系统的不同时间被提出。大部分时候，新的版本包含了老的版本并且系统本身可以授权版本（语义调和）。然而版本可能会出现分歧，在存在故障和伴随着并发冲突更新的情况下，导致对象版本的冲突。在这个例子中，系统无法协调同一个对象的多个版本，客户端必须进行调和将数据演化的多个分支合并为一个（语义复制）。一个典型的合并操作的例子是merge客户购物车的不同版本。使用调和机制，添加到购物车操作不会丢失，然而，删除项目可能重新出现。这对于理解特定的故障模型可能导致系统有俩个但是不同版本的相同数据很关键。在网络存在分区时和节点故障时的更新可能潜在的导致了具有不同版本子历史对象，系统将会在未来对这些进行调和。这就要求我们在设计应用程序时明确承认同一数据存在多个版本的可能性（以不丢失任何的更新）。Dynamo使用了向量时钟以补货不同版本对于相同对象的因果关系。向量时钟是一个有效的(node，counter)对列表。一个向量时钟与每个对象的版本相关联。通过检查向量向量时钟，可以确定一个对象的俩个版本是在平行分支上还是有因果顺序，如果第一个对象的计数器是小于或者等于第二个时钟的所有节点，那么前者是后者的祖先可以被遗忘。否则，俩个变化被认为是冲突的需要调和。在Dynamo中，当一个节点希望更新一个对象，他必须指名要更新的版本。这是通过 传递从先前读取的上下文来实现的，它结合了向量时钟的信息。在处理读请求时，如果Dynamo访问了多个分支，这在语义上是无法调和的，它会返回所有叶子上的对象，以及在上下文中对应的版本信息。使用此上下文的更新被认为已经调和了不同的版本，分支被折叠成单个新版本。为了演示向量时钟的使用，让我们来考虑一个例子如图3所示。一个客户端写入新的对象。节点（Sx）处理这个键的写操作以增加它的序列号，并且创建了数据向量时钟。系统现在有对象D1和相关的时钟[(Sx,1)]。客户端更新对象。假设相同的节点处理这个请求。系统现在有对象D2和它相关联的时钟[(Sx, 2)]。D2从D1位置下降因此替代D1，然而可能在尚未看到D2的节点上存在D1的副本。让我们假设相同的客户端再一次更新对象并且不同的服务（Sy）处理请求。系统现在有数据D3和它相关联的时钟[(Sx, 2), (Sy, 1)]。下面假设不同的客户端读取D2，并且尝试更新它，另一个节点（Sz）进行写入。系统现在有D4（D2的后代）它的版本时钟是[(Sx, 2), (Sz, 1)]。D1或D2的节点可以在接受时确定D4和它的时钟，D1和D2被新的数据覆盖，可以被垃圾收集。一个节点知道D3并且接收D4将会发现它们之间没有因果关系。换句话说，D3和D4的变化没有相互反应。必须保持俩个版本数据并呈现给客户（在读取时）以进行语义协调。现在假设一些客户机读取了D3和D4（上下文将反应俩个值都是由read找到的）。读取的上下文是D3和D4的总结，即 [(Sx, 2), (Sy, 1), (Sz, 1)]。如果客户端执行调和，并且节点Sx协调了些操作，Sx将会更新它在时钟中的序号[(Sx, 3), (Sy, 1), (Sz, 1)]。向量时钟的一个问题是向量时钟可能会增加许多服务协调写入到对象的大小。在现实中这是不可能的，因为些通常在头n个偏好列表的节点中的一个进行写入。在网络分区的例子或者多个节点失败的例子中，写入请求可能被不在topn个偏好列表中的节点进行处理导致向量时钟的增长。在这个情况下，最好限制向量时钟的大小。为此，Dynamo使用了以下的时钟截断方案：为每个（节点，计数器）对，Dynamo存储一个时间戳，它代表了节点更新数据项最后的时间。当（节点，计数器）对的数量在向量时钟达到了预制（比如10），最后的对见会被从时钟中移除。这种截断模式可能导致调和的低效后台的关系不能被准确交付。然而这个问题还没在生产环境中出现，因此还没有得到彻底的调查。4.5 执行get()和put()操作Dynamo中存储任何节点都有资格接受针对客户端任何秘钥的get和put操作。在这一章节中为了简单我们描述了这些操作如何在无故障的环境下进行执行，在之后的章节我们讨论操作在有故障的时候进行执行。get和put操作执行都是通过HTTTP在亚马逊的特定基础设施处理框架调用的。客户端可以使用俩中策略来查找节点。通过一般的负载均衡器路由请求它们会给予负载信息来选择节点。使用了解分区的客户端库它直接路由请求到合适的协调者节点。第一种方法的优点是，客户端不必在其应用程序中链接任何特定于Dynamo的代码，第二个策略可以达到低延迟因为它跳过了潜在的转发步骤。一个节点处理读取和写入操作的节点称之为协调器，一般这是选项列表前N个节点的第一个。如果请求是通过负载均衡器接受的，请求节点可以路由到环中的任意一个节点。如果节点不在请求键首选列表的前N位，在这个情况下，接收请求的节点将不会协调。相反，该节点吧请求转发给首选项列表中的第一个。在偏好列表中，读取和写入操作相关的前N个健康的节点，跳过那些已经下线的或者无法访问的。当所有节点都运行正常时，将访问键首选项列表中的前N个节点。当节点故障或者网络分区，访问优先级列表中排名较低的节点。为了保持副本之间的一致性，Dynamo使用了一致性协议类似于法定人数系统所使用的。该协议有俩个可配置的键值：R和W。R是必须参加成功读取操作的节点最小数量。W是必须成功写入操作的节点数量。设置R和W 使得R+W>N产生一个类法定人数的系统。在这个模型中，get(或者put)操作的延迟依赖于最慢的R和W副本。处于这个原因R和W通常被配置为小于N已提供更好的延迟。在接收到对key的put请求时，协调者生成一个新版本号的向量时钟，并且本地写入新版本。协调者之后发送新的版本（以及新的向量时钟）到N个最高的排行的可达节点。如果只少有w-1个节点响应了，那么写入被视为成功。类似的，对于get()请求，协调器从首选列表中排名最高N个可达节点中请求该键所有现有版本的数据，然后等待R个响应，然后将结果数据返回给客户端。如果协调器收集了多个版本的数据，它返回所有它认为没有因果关系的版本。然后对不同的版本进行协调，并将取代当前版本的协调版本写回来。4.6 处理故障：提示转交在Dynamo中传统的法定人数方法在服务故障和分区期间，即便在最简单的失效条件下也会降低耐久性。为了解决这个问题，它不强制的执行法定人数成员作为替代使用sloppy quorum（草率法定人数）；所有的读取和写入操作在偏好列表中前N个健康的节点上执行；这可能不总是在遍历一致性哈希环遇到的前N个节点。考虑一个Dynamo配置在图2中让N为3的例子。在这个例子中，如果节点A临时下线下线或者在写入操作中不可达，然后通常存在于节点A上的副本被发送到了节点B。这样做是为了维护所需的可用性和持久性保证。发送给D的副本将会在元数据中有提示，这表明了那个节点是预期的接受者（这个例子中的A）。接受到提示副本的节点将会吧他们保持在独立的本地数据库，并定期扫描，一旦A已经回复，D会尝试将副本交付给A。一旦转换成功，D可以删除来自本地存储的对象，而不需要减少系统中副本的总数。使用提示转交（hinted handoff），Dynamo确保了读取和写入操作不会因为节点的临时故障而写入失败。需要最高级可用性的应用可以将w设置为1，这确保了只要一个节点在系统中持久的写入键到它的本地存储，系统就可以接受写入。因此只有系统中的所有节点都不可用时写入才会被拒绝。然而，现实中亚马逊服务大部分都在生产环境中设置更高的w以面对所需的持久性水平。我们将在第6章详细的讨论配置N、R和W。高可用的存储有能力去处理数据中心级别的故障是非常重要的。数据中心故障可能会由于店里供应散热失效，网络故障和自然灾害。Dynamo是可以配置为每个对象都被付出在多个数据中心。本质上键的首选项列表的构造使得存储节点分布在多个数据中心。这些数据中心通过告诉的网络连接起来。这种跨多个数据中心的复制方案允许我们处理整个数据中心的故障，而不会造成数据中断。4.7 处理永久性故障 ：副本同步如果系统成员流失率很低并且节点故障很短暂，暗示着切换状态最好。在某些情况下，副本返回到原始副本节点之前变得不可用。为了处理这个和其他耐久性的威胁，Dynamo实现了反熵（副本同步）协议已保持副本同步。为了快速和最小传输数据的代价，探测副本之间的不一致。Dynamo使用了merkle tree。Merkle tree是一个哈希树，其叶子是单独key的哈希值。树中较高的父节点的其他它子节点的哈希值。Merkle tree的主要优点是，每个树的树干可以单独的被检查，而不需要节点下载整个树或者数据集。此外，Merkle tree帮助减少了需要传输的数据量，同时检查不同数据副本的不一致。例如，如果俩个根节点的哈希值相同，叶子节点在树中是相同的，此时节点不需要同步。如果不是，这意味着一些副本的值是不同的。在这种情况下，节点可能会交换子节点的哈希值，这个过程一直持续到到达树的叶子，此时主机可以识别出“不同步”的键。Merkle树最小化了需要为同步传输的数据量，并且减少了磁盘读取执行在反熵过程中的次数。Dynamo使用merkle树进行反熵如下：每个节点维护他托管key范围的merkle树（虚拟节点覆盖的键集合）。这允许节点比较，范围内的键是否是最新的。在这个模式下，俩个节点交换他们共同托管的对应键范围内的merkle树的根。随后使用树遍历之前描述的确定节点，如果有任何不同就执行同步操作。这个模式的缺点是，当节点加入或者离开系统许多键的范围会改变，因此需要重新计算树。这个问题已经通过精致的分区被解决，在6.2章中进行描述。4.8 成员和失败探测4.8.1 环成员在亚马逊中节点听起（由于失败和维护任务）通常是转瞬即逝的，但可能会持续很长的间隔。节点中断很少意味着永久的离开，因此不会导致分区作业的不平衡或者副本不可达。类似的手动的错误可能导致无意识的启动新的Dynamo节点。因为这个原因，使用显示的机制来初始化添加或者删除Dynamo中的节点是合适的。管理员使用命令行工具或者浏览器以连接到Dynamo节点并且发出成员改变加入节点到环中或者删除环中的节点。节点服务于请求写入成员改变，并且他持久化存储发送时间。历史的成员改变来自历史，因为节点可以删除或者添加可以来回多次。一个基于gossip协议传播成员的改变并且维护最终一致性的成员视图。每个节点的联系人是在随机时刻被对等的选择，并且节点有效的调和成员在历史中的冲突。当节点在第一时间启动， 他选择它的token集合（一致性哈希空间的虚拟节点）并且映射节点到他代表的token集上。映射在磁盘上持久化，初始化只包含本地节点和token集合。存储在不同节点上的映射在同一通信之间被协调，这个协调了成员之间的修改历史。因此，分区和防止信息也通过gossip协议传播，并且每个存储节点知道他对等节点处理的token的范围。这允许每个节点转发键的读取写入操作到直接到正确的节点。4.8.2 外部发现上面的机制可能暂时导致Dynamo环中的分区。例如，管理员可能联系节点A将A加入到环中，联系节点B将B加入到环中。在这个场景下，节点A和B每个都认为自己是环中的一员，然而双方都不会立刻意识到对方。为了维护逻辑分区，一些Dynamo节点扮演者种子的角色。种子是通过外部机制发现，并且被所有节点知道的节点。因为所有节点最终都用一个种子来调和他们成员的关系，因此逻辑分区是非常不可能的。种子可以在静态配置文件或者配置服务中得到。通常，种子在Dynamo中是彻底的功能性节点。4.8.3 故障探测故障探测在Dynamo中被用来避免，当传输分区时暗示副本，在不平衡的节点中的在get和put操作时的尝试通信，故障检测纯粹的局部性概念是完全足够的：如果节点B不响应节点A的信息，节点B考虑出现故障（即便节点B响应节点C的信息）。在Dynamo环中客户端请求在节点通信之间产生的稳定速率存在时，当节点B失败的回复信息，一个节点快速的发现节点B是没有反应的。之后节点A使用备份节点来处理服务请求映射到B的分区；A定期重试B以检查后者的恢复情况。在缺乏客户端节点请求驱动俩个节点之间的流量时，俩个节点都不需要知道另一个节点是可达还是不可达的。去中心化的故障探测使用了简单的gossip风格的协议，他允许系统中的每个节点学习如何到达（或者离开）另一个节点。有关分散故障检测器和影响其精度的参数的详细信息，请参阅有兴趣的读者。更早的Dynamo的设计使用了去中心化的故障探测已维护故障视图的全局一致性。后来确定，显式节点加入或者离开方法消除了故障状态全局视图的需要。这是因为节点会收到永久性节点添加的通知，通过显式的加入节点加入和离开进行删除，当与其他节点沟通失败，通过独立的节点进行探测（同事转发请求）。4.9 添加和删除存储节点当一个新的节点（如X）加入了系统，他得到指派的token编号 ，这个编号在环上是随机分散的。对于每个指派给x的键的范围，可能存在多个节点（小于或者等于N），它们负责处理范围内的秘钥。由于将秘钥的范围分配给X，一些已经存在的节点不长期持有键并且这些节点传输他们的键到X。让我们来考虑简单的启动场景，其中接地那X被添加如图2所示的环中，在A和B之间。当X被添加到系统中，他负责存储范围(F, G], (G, A] 和(A, X]的秘钥。作为结果，节点B，C和D补偿器存储这个表示的范围。因此，节点B，C和D将会提供并且在确认后转移适当的秘钥。当节点在系统中删除，秘钥的再分配发生在相反的过程中。秘钥的重新分配发生在相反的过程中。操作经历已经展示了这个方法分发键的负载一致的穿过存储节点，他对于应对延迟的徐亚和确保快速启动非常重要。最后通过在资源和目标之间添加配置圆，他确保了目标节点对一给定的key范围不会接受任何重复读传输。5 实现在Dynamo中每个存储节点有是哪个主要的软件组件：请求协调，成员与故障探测，以及本地持久化引擎。 所有的这些组件都是用java实现的。Dynamo的本地持久化组件允许不同的存储引擎插入。Berkeley 数据库（BDB），传统数据存储，BDBJAVA版本，MYSQL。设计一个可插拔的持久化组件的主要原因是为了选择最适合应用访问模式的存储引擎。例如，BDB可以处理典型的十几KB大小的对象，而MySql可以处理更大的对象。应用基于他的对象大小的分布，选择Dynamo的本地持久化引擎。Dynamo大部分的生产实力使用了BDB事务数据存储。SEDA（Staged Event-Driven Architecture，分阶段事件驱动架构）是一种高性能、可伸缩的系统架构设计模型，旨在更好地管理复杂和高并发的应用程序。SEDA 最初由 Matt Welsh 在其博士论文中提出，用于解决传统线程池模型和事件驱动模型在高负载条件下的不足。请求协调者组件是在事件驱动的消息传递底层之上构建的。其中消息处理流水线被切分到多个状态类似于SEDA架构。所有的沟通使用Java的NIO管道进行实现。协调者通过收集一个或多个的执行读取和写入请求在客户代理上（在读取的例子中），或者存储数据在一个或者多个节点上（为写入）。在客户端请求的节点上，每一个客户端请求都会创建一个状态机。状态机包含了所有的定义节点对哪些key负责的逻辑，发送请求，等待响应，可能的进行重试，处理回复并且打包响应到客户端。每个状态机实例只处理一个客户端请求。例如，一个读取操作实现了以下的状态机发送读取请求到节点等待需要响应的最小数量如果在给定时间范围内回复的太少，让请求失败否则手收集所有的数据版本并且确定要返回的版本如果版本可用，执行语法的调和并且生成不透明的写入上下文，他包含了持有剩余版本的向量时钟。为了简洁，故障处理和重试状态在读取响应给调用者后，状态机等待一小段时间以接受任何未完成的响应。如果过期的版本已经在任何的响应中返回了，协调者更新这些节点最后的版本。这个操作被叫做read repair（读取修理），因为它在一个合适的时机修复哪些错过更新的副本，从而减少了反熵协议的负担，不必由它来完成修复工作。如前所述，写请求通过偏好列表中的头N个节点进行协调。尽管他总是希望前N个节点中的第一个节点来协调写操作，从而在单个位置序列化所有写操作，这个方法会导致负载不均，从而违反SLA。这是因为请求负载在是不一致的分布在不同的对象之间。为了解决这个问题，允许偏好列表的前N个节点中的任何一个进行这个操作。特别地，由于每次写入通常是在一次读取操作之后进行的，因此写入的协调节点被选择为对先前读取操作响应最快的节点，该信息存储在请求的上下文信息中。这个优化允许我们选择在之前的读取操作已经写入数据的节点，从而增加“读你写的”一致性。他也减少了请求处理性能的变化，提高了99.9百分位的性能。6 经验教训7 结论在这篇paper中描述了Dynamo，一个高可用的可扩展的数据存储，用来存储Amazon.com电商平台的核心服务的持久化状态。Dynamo已经提出了可用性和性能的渴望即便，并且成功的处理服务失败，数据故障和网络分区。Dynamo是增量可扩展的并且允许服务拥有者基于当前的负载扩展和下调。Dynamo允许服务拥有者，通过允许它们去调整参数N,R和W去定制存储系统以应对他需要的性能，持久性和一致性SLA。最近几年Dynamo的成功已经展示了，去中心化的技术可以联合以提供一个高可用的系统。在最具挑战性的应用环境他成功的展示了，最终一致性存储系统可以发布高可用的应用块。
Cassandra 一个去中心化的结构化存储概要cassandra是一个分布式存储系统已管理大量的结构化数据分布在许多商用服务器上，同时允许在单点故障的情况下提供高可用。Cassandra的目标是运行在上百台节点的基础设施之上（可能分割到不同的数据中心）。在这个尺度下，大的和小的组件故障连续不断。Cassandra在面对这些故障管理持久状态的方式驱动了一来该软件服务的软件系统的可靠性和伸缩性。在很多封面Cassandra类似一个数据库，并与之贡献很多实现策略Cassandra不知此完全的关系模型；作为替代他提供给客户端一个简单的数据模型以支持对数据布局和格式的动态控制。Cassandra的系统被设计为运行在廉价的通用硬件。在处理高吞吐量的同时不影响读取的效率。1 介绍Facebook运行最大的社交网络平台，服务于上亿的用户在峰值时段使用上万台服务器其遍布整个世界。这是一个严格的操作要求facebook的平台的性能，可靠性，有效性有严格的要求，并且支持持续的增长，平台需要高可缩放性。处理包括上千个组件的故障是我们标准运作模式。总会有小的但是明显的服务和网络组件在给定的事件故障。因此，软件系统需要结构化的管理者将失败视为日常而不是意外。为了应对上述的可用性和可缩放需求facebook开发了Cassandra。Cassandra总和使用了知名的技术一达到可缩放和可用性。Cassandra被设计为实现Inbox serch 问题。Inbox是一项功能允许用户搜索他们的facebook收件箱。在facebook中这意味着系统需要处理非常高的写入量，每天上百万的写入，并且随着用户数量的扩大。由于用户从地理分布的数据中心获得服务，允许副本数据穿过数据中心是保证低延迟的关键。Inbox 查询在2008年6月启动服务于大约1亿用户如今我们有超过2.5亿的用户，并且Cassandra还在保持增长。Cassandra现在被部署在后端存储系统为facebook的多个服务服务。2 相关工作分布式数据的可靠性可用性和持久性在文件系统和数据库已经有了广泛的研究。对于只支持平面空间的p2p存储，分布式文件系统特别支持了分级的命名空间。文件系统类似Ficus和Coda 以牺牲一致性为代价来获得高可用性。更新冲突通常使用专门 打冲突解决程序进行管理。Farsite是一个分布式文件系统他不使用任何中心化服务。Farsite使用复制达到了高可用和可缩放。GFS是另一个文件系统以托管谷歌内部的应用状态。GFS使用了简单的单个主设计以托管整个元数据，其数据被切分为chunk并且存储在chunk服务上。然而，GFSmaster使用Chubby的抽象处理容错。Bayou是一个分布式关系型数据库允许不连贯的操作并且提供数据的最终一致性。在这些系统中，Coda和Ficus 允许断开操作并对网络分区和中断问题具有弹性。这些系统在解决冲突上的过程有所不同。例如coda和Ficus执行文件级别的冲突决议，Bayou允许应用级别的解决。然而，他们都保证最终一致性，Dynamo允许读取和写入操作连续，即便是在网络分区的情况下别去使用不同的由客户端驱动的冲突解决机制来解决冲突。传统的复制的关系数据聚焦于保证副本数据的强一致性。虽然强一致性提供了应用写入方便的模型，这写系统限制了可缩放性和可用性。这些系统没有处理网络分区的能力，因为他通常提供强一致性保证。向量时钟（Vector Clock）是一种用于解决分布式系统中并发更新冲突的问题的机制。它通过记录每个节点（或副本）在某个数据项上的修改历史，来追踪这些数据项的状态变化，以便在多个副本并行修改时能够检测到哪些修改是冲突的。在分布式系统中，通常会有多个节点（或副本）处理同一数据项的不同副本。为了确保数据一致性，需要一种机制来判断这些副本是否有冲突，尤其是在它们的更新是并行进行的情况下。向量时钟就提供了一种解决方法。每个节点有一个时钟：系统中的每个节点维护一个向量，记录该节点对数据项的修改历史。向量中的每个元素对应一个节点的时钟，通常是一个整数，表示该节点的修改次数。例如，系统中有三个节点 A、B 和 C，那么每个节点会维护一个三维向量 [A, B, C]，每个维度的值代表该节点对数据项的修改次数。每次修改都会更新时钟：当某个节点修改数据时，它会增加自己的时钟值，同时将这个更新传播到其他节点。例如，假设节点 A 修改了数据，它的向量时钟会变成 [1, 0, 0]（假设初始时钟为 [0, 0, 0]），表示节点 A 对数据进行了第一次修改。向量时钟的传播：当节点 A 将数据更新发送给节点 B 和节点 C 时，它会把自己的向量时钟一并发送过去。接收到更新的节点会更新自己的时钟，确保自己的向量时钟包含其他节点的历史修改。检测冲突：向量时钟的核心在于它能帮助系统识别冲突。例如：如果两个节点同时修改了同一数据项，但修改的顺序无法确定，或者修改的内容互相冲突，则这两个版本会被标记为冲突。如果两个节点的向量时钟不相等，并且没有完全包含对方的历史时钟，那么这两个修改就是并行的，意味着可能会发生冲突，需要进一步处理。向量时钟的核心方案是通过维护每个节点的历史修改来检测分布式系统中是否存在更新或者冲突。Dynamo是一个存储系统他被亚马逊用来存储和检索用户购物车。Dynamo的基于成员的算法帮助每个节点维护其他节点的信息。Dynamo可以定义为一个结构化的覆盖网络，最多有一跳路由请求。Dynamo使用矢量时钟的方案检测冲突，但是更偏好于客户端测解决冲突。Dynamo的写操作也需要执行读取操作来管理矢量时间戳。在系统处理非常高的写吞吐量的环境中，这可能是非常有用的。Bigtable提供了结构和数据分布，但是他的持久性依赖于分布式文件系统。3 数据模式Cassandra中的表是由键缩阴丹分布式多维映射。值是一个高度结构化的对象。行键（row key）在表中是一个字符串但是允许16-36的字节长度。每个操作对每个副本都是原子的，无论读取或写入多少列。列被分组到一起加入一个叫做列族的集合，这与bigtable非常类似。Cassandra暴露了俩种列组，简单和超级列族。超级列族可以被抽象为列族的列族。此外，应用可以指定列的顺序使用超级列族或者是普通列族。系统 允许列变得有序通过时间或者名字。类似iInbox 查询这样的应用利用了列的时间排序，结果总是按顺时间排序的顺序显示。在一个列族（column family）内的任何列都可以通过以下方式访问：column_family : column，任何类型为超级的列都可以通过column_family:super_column:column进行访问。一个关于超级列抽象能力的非常航的例子在6.1。应用程序通常使用专用的Cassandra集群，并将其作为服务的一部分进行管理。虽然系统提供了多个表的概念，但是所有的部署模式只有一个表。4 APICassandra API包含了如下的三个简单方法。insert(table, key, rowMutation)get(table, key, columnName)delete(table, key, columnName)columnName用来指定的列，列族， 超级列族或者超级列中的列。5 系统架构需要在生产环境中运行的系统架构是很复杂的。除了实际的数据存储组件之外，系统须有如下的特性；可缩放和健壮的环境一应对负载均衡成员和故障检，失败回复，复制同步，过载处理，状态转换，实时工作调度，请求编组，请求路由，系统监控和警报与配置管理。描述解决方案的每个细节超出了这个论文的范围，所以我们关注于cassandra使用的核心系统分局技术，分区，复制，成员，故障和缩放。所有的这些模块同步处理负载请求。通常，对于key的读写请求被路由到cassandra的任何节点。节点之后确定特定key的复制。对于写入，系统路由请求到副本并且等待副本的法定数量来确认写操作的完成。对于读取，基于通过客户端对一致性保证的需要，系统要么路由到最近的副本要么路由请求到所有副本并且等待法定人数的响应。5.1 分区cassandra的一个设计的关键特性是增量扩展的能力。这需要在集群中的节点集（即存储主机）上动态划分数据。cassandra使用一致性哈希分区数据跨越集群，但是使用了一个保持顺序的哈希函数。在一致性哈希中，哈希函数的数范围被视为一个固定圆形空间形成的环（即最大的哈希值到最小的哈希值）。每个节点在信息系统中被分配一个随机值，代表它在环上位置。由key标识的每个数据项通过哈希分给每一个节点，数据项的键以获得其在环上的位置，然后顺时钟遍历找到位置大于该数据项的第一个节点。这个节点被视为此键的协调器。应用程序指定此键，并且cassandra使用它路由请求。因此，每个节点负责环中该区域，并且处理环上的节点。一致性哈希的一个主要优点是，一个节点的离开或者到达只影响它的近邻，其他节点仍然不受影响。首先随机的位置指派到圆上的每个节点导致数据和载荷分布不均。通常解决这个方法由俩个问题：一个方法是将节点分配到圆中的多个位置（类似Dynamo），第二是分析圆上的负载信息并且稍微调整环上节点的移动减轻过载节点。cassandra选择了后者因为他的视线非常容易处理并且有助于做出关于负载均衡非常确定的选择。5.2 复制不同的策略就是为了就近复制。在复制速度和可靠性做取舍。复制的越远，越不容易一起崩溃。cassandra使用复制以保证高可用性和持久性。每个数据项被复制到n个节点其中n是复制因子被每个实例配置。每个键k被分配到对应节点（在之前的章节描述的）。协调器负责其数据范围内的数据项。除了本地存储范围内的每个键之外，协调器在环中的N-1个节点复制这些键。Cassandra为客户端提供了各种复制数据的选项。Cassandra提供了多种复制策略比如Rack Unaware和Rack Aware（在数据中心中）以及Datacenter Aware（在数据中心）。副本的选择基于应用的复制策略。如果中心应用选择了Rack Unaware复制策略，那么非协调器的副本将通过选择协调器后续的N-1个节点来确定。Cassandra系统选举leader使用了一个叫做Zookeeper的系统。所有加入集群的节点与leader联系，告诉它们复制的范围是什么，并且leader会协调维护不变性，没有节点负责超过n-1个范围。关于节点范围的元数据在本地缓存在每个节点上并且在Zookeeper中保证容错性。通过这种方式崩溃和恢复的节点知道它的范围。我们借用了Dynamo的说法，将负责给定范围的节点称为该范围的偏好列表（preference list）。如5.1章的例子一样，每个节点都知道系统中的其他节点和他们负责 的范围。Cassandra通过5.2章中描述的仲裁要求，在节点故障和网络分区存在的情况下保证持久性。数据中心的故障 是由于断点，散热失效，网络故障和自然灾害。Cassandra被配置为在数据中心复制每个行。本质上，构造了键的首选项列表，因此存储节点扩展跨越多个数据中心。这些数据中心使用告诉网络进行连接。跨越多个数据中心的复制方案允许我们处理整个数据中心的故障无论是否停电。5.3 成员Cassandra的客户端成员是基于Scuttlebutt的，一个非常有效的基于Gossip的逆熵机制。Scuttlebutt的一个突出的特性是非常有效的CPU利用率以及非常有效的利用了gossip通道。在Cassandra系统内部，gossip不仅用于成员，还用于传播系统的相关控制状态。5.3.1 失败探测Accrual Failure Detector 是一种用于分布式系统中的故障检测机制，旨在通过累积的信息来逐渐确定某个节点是否发生故障。与传统的故障检测机制（如心跳或超时检测）不同，Accrual Failure Detector 提供了一个更为渐进的失败判定过程，使得系统能够在不同情况下适应不同的故障检测需求。每个节点都会定期发送心跳消息，以便其他节点检测它的存活状态。接收节点根据这些消息来计算发送节点失效的概率。通过累积收到的心跳信息，节点估算出发送节点失败的可能性，这个概率随着时间的推移逐渐增加，直到某个阈值被触发，从而确定节点已经失败。系统会设定一个阈值（通常是一个概率值），当某个节点的失败概率超过这个阈值时，系统就会认为该节点发生了故障。这个阈值通常是动态调整的，根据实际的网络状况和节点的健康状态来修改。失败探测是一个机制，如果有任何系统中的节点上线或者下线，节点可以本地进行确定。在Cassandra中失败探测也被用来避免在各种操作中与不可达的节点通信。Cassandra使用修改版本的$\Phi$累计故障检测器。累计故障检测器的思想是故障探测模块，不会我发出一个布尔值来表示节点是up还是down。相反。故障检测模块发出一个值，表示每个监视节点怀疑的级别。这个值的定义如同$\Phi$。其基本的思想是动态调整的尺度上表示$\Phi$的值，以反映被监视节点的网络负载和条件。$\Phi$遵循以下机制，给定一些阈值$\Phi$，并且假设我们在$\Phi$的时候怀疑节点，然后是我们犯错的可能性（即，这个决定会在未来被延迟的心跳推翻）约为10%。当$\Phi$=2概率是1%,$\Phi$=3概率是0.1.以此类推。系统中的每个节点都维护一个滑动窗口，显示来自集群中其他节点的gossip信息的间隔到达时间。这些到达间隔的时间是确定的，并且由$\Phi$计算出来。虽然原论文认为分布式近似高斯的，我们发现指数分布是一个更好的近似。因为gossip通道的性质和对延迟的影响。据我们所知，我们的是使用gossip实现累计故障检测同类中的第一个。累计故障检测对于准确性和速度都非常好，他能很好的适应网络环境和服务负载条件。5.4 启动在一个节点启动的第一时间，他选择一个随机token已定位环上的位置，为了容错，映射被持久化到本地磁盘和Zookeeper中。token信息之后会被gossip环绕到集群。这就是我们如何知道所有节点在圆上的位置。这允许所有节点为key进行请求路由到合适的集群中的节中。在启动案例中，当一个节点需要加入集群他读取配置文件，其中包含了了几个接触点的列表。我们叫这个初始化点为集群的种子。种子也来自配置服务比如Zookeeper。在facebook环境节点停机时（由于异常和维修任务）通常是短暂的，但是也可能遗留较长时间。故障可以是多种形式，比如磁盘故障，CPU损坏等我。节点停机很少意味着永久离开，因此不会导致分区作业重平衡或者修复无法访问的副本。类似的手动的错误可能会导致无意中启动新的Cassandra节点。每条信息都是这样包括每个Cassandra实例的节点名。因为这个原因，它被认为是合适的：使用显示的机制初始化Cassandra实例中的节点添加和删除。管理者使用命令行工具或者浏览器连接到Cassandra节点并且发送成员修改已加入或者离开集群。5.5 缩放集群当新的节点添加到系统中，他被分配一个token，这样就可以减轻负载过重的节点。这导致了新节点拆分其他节点之前负责的范围。操作员使用命令行或者Cassandra web仪表盘，Cassandra启动算法从系统中的任何其他的节点上初始化。放弃数据的节点使用核-核复制技术将数据传输到新节点。运行经验表明，数据可以在40MB/s的速度在单个节点传输。我们正在努力改进这一点，通过多个副本参与启动传输，从而并行化工作，类似于Bittorrent。5.6 本地持久化Cassandra系统信任本地文件系统以进行持久化。数据在磁盘上的表示使用使用有效的数据检索方式。写操作包括写入提交日志用于持久性和可恢复性并且更新内存的数据结构。写入到内存的数据结构只有在成功的写入提交日志之后才会执行。我们在每个机器上有专用的磁盘用于提交日志因此所有写入提交日志是连续的，因此我们最大化磁盘的吞吐量。当内存中的数据结构超过特定的阈值（基于数据大小和对象数量计算的），它将自己存储到磁盘。这个写操作是在机器所配备的许多商品磁盘上上的一个进行的。所有写操作都是顺序写入磁盘的，并且还生成一个索引，一遍根据row key高效的进行查找。这些索引页随着文件数据一起被持久化。随着时间推移，许多这样的文件可能存在于磁盘上，并且归并程序在背景中运行以核对文件的不同合并到一个树中 。这个过程非常类似bigtable系统中的压缩过程。一个典型的读取操作在查看磁盘文件之前，首先查询内存中的数据结构。这些文件是按照从新到旧的顺序来进行查看的。当进行磁盘查找时，我们可以查找多个磁盘上文件的一个key。为了防止查找到不包含键的文件，一个bloom过滤器，总结文件中的键，他存储在每个数据文件中也存储在内存中。首先要查看bloom过滤器，以检查正在查找的键是否存在于给定的文件中。列族中的键可能有多列。需要一些特殊的索引来检查离键较远的列。为了防止扫描磁盘上的每一列，我们维护索引，使我们能能跳转到磁盘上正确的快进行检索。当给定键被序列化并且写到磁盘时，我们在256K区块的边界生成索引。这个参数是可配置的，但是256K对于我们生产环境工作的很好。5.7 实现细节SEDA（Staged event-driven architecture）事件驱动架构是一种为高并发和高可扩展性系统设计的架构模型阶段（Stage）： 每个阶段是一个独立的处理单元，通常有自己的线程池、任务队列和资源管理机制。一个阶段只负责执行特定的功能，比如接受请求、解析请求、处理数据、返回响应等。这样可以避免系统出现“瓶颈”或者某个阶段过载的问题。事件驱动： 各个阶段通过事件传递任务，即每个阶段完成任务后，会生成一个事件，并将其放入下一个阶段的队列中，从而实现任务的分布式处理。通过事件驱动机制，SEDA能够在负载较大时有效缓解系统压力，并通过异步非阻塞方式提升性能。资源管理： 每个阶段的线程池和任务队列可以动态调整，系统可以根据负载自动调节阶段资源，避免因单个阶段过载影响整体系统性能。这样，SEDA可以灵活地应对负载的变化。流控： 通过对每个阶段的任务队列进行监控，SEDA可以动态调整资源分配和处理速度。当一个阶段的负载过高时，可以通过减少进入该阶段的任务数量来缓解压力，从而保证系统的平稳运行。Cassandra的进程在单个机器上主要由以下的抽象构成：分区模块，集群成员和失败探测模块以及存储引擎模块。每个模块都依赖于事件驱动的基层，其中消息处理管道和任务管道沿着SEDA架构的路线分成多个阶段。这些模块都是使用java从头开始实现的。集群成员和故障检测模块，构建在网络层的上方，它使用nio。系统控制消息基于UDP的消息传递，而用于复制和请求路由相关的应用信息依赖于TCP。请求和路由模块使用特定的状态机进行实现。当读写请求到到达集群中的任何节点，集群中的状态机通过以下几种变化 1 定义拥有key数据的节点 2 路由请求到节点并且等待响应等待的到来 3 如果响应没有在配置的超时内到达，请求返回失败并返回到客户端 4 根据时间戳找出最近的响应 5 如果没有找到最新的数据片，则安排对数据的修复。为了说明我们不讨论失败的场景。系统可以配置执行同步或者异步操作。对于确定需要高吞吐量的系统，我们依赖于异步的复制。在这里进入系统的写操作远远大于进入系统的读操作。在同步案例中，在返回给客户端之前，我们等待响应的法定人数 。在任何日志系统中，需要存在一种清楚日志条目的机制。在Cassandra中我们使用滚动提交日志其中当旧的日志提交超过特定的可配置大小时，就会生成一个新的提交日志。我们发现在生产环境中滚动提交日志大小设置在128MB能工作的非常好。每个提交日志都有一个header，他基本上是一个位向量，通常比系统处理的列组数量要多。在我们的实现中，我们有一个内存中的数据结构和一个按列族生成的数据文件。每次将特定内存中数据结构存储到磁盘时，我们都会提交日志中设置它的位，表示该列组已经成功持久化到磁盘。这表示信息片已经提交了。这些位向量是每个提交日志，也保存在内存中，每次滚动提交日志的所有向量。如果认为所有数据都成功持久化到磁盘啧删除这些提交日志。写入提交日志的操作可以是正常模式也可以是快速同步模式。在快速同步模式中对提交日志写操作将被缓冲。在这种模式下，我们还以缓冲的方式将内存中的数据结构转储到磁盘。传统的数据库不是为处理特别高的写入吞吐而设计的。传统的数据库不是为了高吞吐量而设计的。由于转储到磁盘的文件永远不会发生变化，因此在读取它们时不需要加锁。因此，我们不需要处理或处理存在于基于B-Tree的数据库实现中的并发问题。Cassandra系统基于主键索引索引所有的数据。磁盘上的数据文件被分解成一系列的块。每个块包含了最多128个key，通过块索引进行切分。块索引捕获到块中的相关key的偏移和数据的大小。当将内存中的数据结构转储到磁盘时，将生成一个块索引，并将其偏移量作为索引写入磁盘。这个索引维在内存中以进行快速访问。一个典型的读取操作总是第一步查阅在内存中的数据。如果发现数据就返回给应用，因为内存中包含任何键的新数据。如果没有找到，那么我们执行相反的事件顺序对磁盘上的所有数据进行io。因为我们总是找寻最新的数据，如果我们找到了 数据就返回。数据文件的数量将会随着时间增长。我们执行压缩过程，非常类似Bigtable系统，他合并多个文件为一个；本质是对已经排序的文件进行归并排序。系统总是对相似的大小的文件进行压缩。也就是说永远不会出现一个100GB的文件被压缩为小于50gb的文件。定期的运行一个主要的亚索过程我，将所有的相关的数据文件压缩成一个大文件。这个压缩过程是一个磁盘密集型操作。可以进行很多优化不影响即将到来的读请求。6 实践7 结论我们已经构建和运行了一个强大的系统，他提供可缩放，高性能，广泛的应用能力。我们可以通过经验证明可以提供低延迟的同时支持非常高的更新吞吐量。未来的工作包括增加压缩， 支持跨键原子性的支持和二级索引。
Percolator思考Google的三驾马车提供了Google的搜索引擎功能。建立网页我过程如下爬取互联网的内容进行解析提取出关键字确认反向索引对反向索引的URL进行整理形成了搜索引擎所需的网页引擎在构建网页索引的过程中，我们初始状态是由大量URL到页面内容这个键值对。我们把它处理成从关键字到URL这样一个反向的键值对（倒排索引）。reduce函数合并共同的反向索引。如果多个URL的内容一样，那么只有pageRank高的网页才会进行展示。但是一旦有网页更新，新的网页产生。我们需要更新到索引库中。网页之间的关系非常复杂。加入在新URL产生后需要内容去重然后重新pagerank。但是MapReduce这套生成网页索引的机制是全量的。没办法单独为新增的网页跑一套这个流程。MapReduce只能权利生成不能增量更新。Google长期采用的方法是通过MapReduce批处理过程，定期重新生成一个新的索引库。假设原来索引库索引了1000个页面，这时候五个页面新加入。MapReduce需要重新处理10005个页面才能得到新索引库。那么能不能只处理5个新增页面通过一个增量的更新呢？其实增量这个操作就是select+insert/update的过程。还需要保证整个过程的一致性和隔离性。如果这个索引库支持事务就能解决我们的问题了。那么如何让Bigtable支持事务呢？我们需要一个支持事务的DBMS来索引存储库。因此出现了percolator，在Bigtable上面做了一层封装。支持事务解决了问题并成为了分布式数据库。也就是newSQL的雏形。原论文中说：percolator是专门为了增量处理而构建的，并不是为了取代大多数数据任务处理的解决方案，MapReduce可以更好地处理无法将结果分解为小更新。此外计算应该有强烈的一致性要求不然Bigtable就足够了。计算在某些维度上应该非常大。传统的DBMS可以处理不适合Mapreduce或Bigtable的较小计算。但后续的Spanner到现在的各种newsql数据库已经在现有的解决方案商越走越远了。在性能差异不大的情况下，NewSQL能支持近乎无限容量的数据。提供强大的扩展性，又能弥补原有分布式的种种限制。（变得all in one）基本原理percolator是基于俩阶段提交的改进版本。prepare阶段：协调器向各个节点发送paepare消息，各节点决定该节点事务部分是提交还是中止。commit阶段：协调者收到各节点的ready或不能提交响应。所有节点都ready则提交成功，任一阶段无法提交就回滚。俩阶段提交的特点是强一致性失去了并发能力：不能使用MVCC需要一个特殊实现的协调者，这个协调者需要单独实现并存储一些日志和锁内容。（这个协调者是脱离bigtable的，所以更难控制）需要付出更大的性能。percolator基于此做了最核心的俩个改造提交过程中的第一个参与者设计为一个类似俩阶段提交协调者的角色，整个事务的原子性状态也以它为准。由此，借用Bigtable的高可用存储能力，免去了特殊设计协调者的麻烦和风险。percolator采用全局授时机制，通过全局时间戳实现多版本的管理，从而实现了snapshot isolation（快照隔离），具有一定的并发能力和first-commiter-win的特性。percolator存储了四个列：key，data，lock（锁状态），write（提交状态）字段前5或6是时间戳（我们用一个简单的自增整数来代替，时间戳由Bigtable负责存储）write下时间戳为data@5表示时间戳为5已经提交data字段的写入不表示这个事务已经提交了，没有提交的数据是不可以读的。读的流程为先查询write字段，找到最近的一个提交记录。再按照这个时间戳找到已提交的数据进行读取。接下里Bob给Joe转账7元。我们首先扣除Bob账户的7元。并在Bob账户上加锁。因为这是分布式事务的第一把锁，所以我们设定它为primary锁。这里最新的write字段还是时间戳6，只能查询到时间戳5的数据。所以现在Bob的余额还是10（MVCC）然后给Joe账户增加7元，余额变为9，并在Joe的账户上加锁。因为这是分布式事务的第二把锁，它指向primary锁的位置。这是为了让分布式事务的提交与成功有一个原子性的标志。一个事务中除了第一个锁是Primary锁其他的锁都是secondary锁。这样我们的prepare阶段就完成。事务进入commit阶段，事务释放掉primary锁，并申请时间戳8，在时间戳8写入write列。时间戳8就是这个事务的提交时间戳。底层实现我们可以简单把Bigtable当做一个整体来看待，Bigtable上面就是运行percolator程序的服务器了。percolator其实是作为Bigtable的client来调用Bigtable的接口的。在这里percolator在系统中的角色非常像一个中间件。可以看做是一个proxy代理。percolator需要在保证持久化的部分都借用Bigtable高可用的能力。因此他们都是无状态的，因此在考虑后续流程的情况下我们需要考虑proxy节点宕机的情况。先是事务的执行阶段写入根据start_ts前最后一个快照进行修改，并且只写入proxy本地缓存，无任何持久化和上锁操作（proxy挂掉了就丢掉）（强一致性读取）先要校验读取在start_ts之前是否有锁，如果有所要尝试清理掉这个锁。（因为proxy可能在俩阶段提交的过程中崩溃，这里是lazy清理。）如果是primary锁直接清理掉如果是secondary锁，那么找的哦啊primary锁的位置。根据primary锁的状态判断释放与否；如果token的时间戳很旧，大概率是之前的事务出问题了。那么可以清理锁如果没锁，就去找到start_ts前最后write的一个字段，再根据这个write字段指向的时间戳去读取数据。来看prepare阶段在自己修改的快照之后又新的事务提交，也就是write列写了信值在自己修改的行上有锁，也就是lock列写了新制没发生1,2的情况就成功上锁写入data和lock。其中1和2俩中情况都意味着在当前事务读取和prepare之间进行了prepare和commit。因此根据first-commoter-win这个特性，当前事务就必然要回滚了。最后来看commit阶段依次校验锁是否都加上了再获取提交时间戳commit再一次提交各行数据，提交过程就是清理锁，并写入write字段的过程。percolator使用全局时间戳实现快照隔离级别。percolator通过一台机器的时间接口统一给出全局递增的时间戳。percolator采用的是乐观事务模型