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来访问数据库。因此我们需要找到一个方法来实现顺序写入数据库。我们通过预写日志来实现这一点。
WAL
write-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更常见的方法是在准备阶段后进行早期确认,在准备阶段并从所有参与者哪里获得同意后,不必等待他们回复,而是立即将确认信息发送给客户端。这里减少了等待消息提交的往返。
Paxos
paxos 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数据库直接存储原始数据。