一、引子
最近一两年里,每次做分布式数据库的内容分享活动时,总是会提及现在数据库的两个重要的存储结构,B-TREE和LSM-TREE。因为,我觉得作为数据库的存储根基,无论是要选型,或者是用好一个数据库,清楚这两的差别和各自特点,都特别重要。但是几乎每一次都只能提一下,哪种数据库用了哪个存储。再多就是稍微介绍LSM-TREE的写入友好。对于有些朋友,正面临二选一项目抉择时,这点信息显然是不够的。于是他们会想要知道,更多二者差异细节,以及到底哪一种适合自己的系统。可惜我总是只能遗憾讲个大概,并解释要彻底讲清楚的话,整个分享就只能光讲这个可能时间还不太够。然而,我还是觉得每次都含含糊糊的过去,未免也有点耍流氓的感觉。总得找个机会,把这里面一些有意思的内容拿出来罗列一下。适逢国庆宅家,想想也是时候把这个坑给填一填了。
当然,本文并不打算从0开始介绍LSM-TREE,那样篇幅也太冗长了。本文默认各位读者具有一点B-TREE和LSM-TREE的基础背景知识。
二、背景板-分布式关系型数据库
其实,抛开分布式数据库来纯写这两个存储引擎,似乎要更加简明一点。但是,这样的话实用性不太行。单机版LSM-TREE数据库有排名在100名左右的ROCKSDB,LEVELDB。它们被划分为KEY-VALUE类型,而且它们通常不会直接出现在我们的视野,也极少直接被使用在项目中。然而,基于LSM-TREE的分布式数据库则是非常常见的,比如这些:
甚至可能有些人,是有用分布式数据库的需求,才去学习和理解LSM-TREE的(比如说我就是先要使用CASSANDRA)。不过带上分布式的这个背景,未免多少会使得存储结构差异比对,变得不那么纯粹。因为分布式数据库嘛,总是带来了一些额外的开销。比如说数据库层面的SQL解析到分布式执行计划产生。再比如说分布式的场景下,分布式事务、全局时间戳获取等等。不过这个还是可以大致对比一下基于B树的分布式数据库情况来说明一下。但就具体到每一个数据库的话,因为各个数据库的具体实现方式各有不同,实际情况就得看某一个具体的数据库还做了些什么额外的动作,再把它附加上去。
还有关系型也算是一个重要背景。因为有些特殊项目场景、特殊的数据格式,使用某种数据库或者存储结构时快得飞起,然而却缺乏了一般通用性。其实,像REDIS和CASSANDRA这样的数据库已经非常的热门,使用范围也非常的广。但是我们在项目中使用这些数据库,也还是充当某种功能性的角色比较多,我们很难把它做成系统的主数据库。尤其是那些有历史包袱迁移而来的系统。无论如何,关系型模型,基本上还是我们见到的主要情况。
所以我准备在分布式和关系型的数据库大背景下,以最为常见的几种数据访问形态来描述一下,这样就更加具有真实的参考性价值。先摊开来数据库的主要操作都做了些什么。然后再套几个经典的案例场景进去看看。这样子的话,围绕着分布式关系型数据库来展开这两颗树的内容,也就更贴近大家现实使用这两存储结构的实际需求,起码我感觉上是如此。
在开始眼花缭乱的各种操作叙述之前,我们可以先草率的做一点假定。比如说,我们SQL的响应时长,认为是最关键的性能指标。比如说,我们认为内存中的操作比较快,对性能的影响稍微小一些。而磁盘操作比较慢,比较可能影响交易时长。再比如,异步动作,基本不怎么影响交易性能。这些假定虽然草率,但是我认为这可以使我们对一些数据库操作的性能开销,有一些更粗暴而直观的感性认知。
三、LSM-TREE之优势的写
写友好,几乎是LSM-TREE的标志性特点,那么我们就从写流程开始。先来个经典的LSM-TREE的图。
STEP 1 WAL日志写入(Write Ahead Log) 磁盘操作
这个步骤基本上各个数据库都有,有各种各样的叫法。比如说Innodb的Redo log,Cassandra 的Commit logs。但是,作用都是一样的,在数据库宕机之后,这份日志可以保证数据的恢复。而这个日志,都是以顺序写入的方式不断追加。所以,感觉上,这部分开销应该是非常的接近的。
我觉得MYSQL的BINLOG也可以放在这里提一下,从严格意义上来说,BINLOG不属于存储引擎而是属于MYSQL,它与B-TREE这个存储结构没有必然关系。功能上来看,我觉得它有点接近某些数据库的归档日志。它开销是显然有的,但是我觉得应该把这笔账算在高可用的头上去。
STEP 2 树数据结构维护
有观点说B-TREE至少要写两次,一次是写WAL日志,一次是写B-TREE本身,以此推出B-TREE写入比LSM-TREE更加慢。这个说法我觉得有些歧义的。因为LSM-TREE其实也是写两次,也是一次写WAL,一次写树。如果非要说,LSM-TREE能少一次,除非是某种LSM-TREE数据库在WAL写完即认为写入成功返回,不需要等MemTable维护好,而这就意味着这种数据库存在以提交的数据读不到的情况。
我个人觉得比较精准的说法应该是,LSM-TREE中MemTable的追加写入速度,要比B树的维护快得多。首先无论是哪种树,写树本身是个内存操作,两种结构都不需要等树结构落盘数据库才算Commit成功。数据库脏页通常都是异步进程慢慢刷出的。所以单纯的写树动作并不是关键。但是,B-TREE的写树动作,并非一个纯粹内存操作。因为只要从根节点开始,一直到数据页。B-TREE这一条路上,无论是索引页还是数据页,有任何页不在缓存里,数据库都会触发磁盘IO来读取。所以在一般的写入场景下,B树的维护就慢了,它是一个先读后写的过程。
我们可以比对一下,在Cassandra的写入步骤里面,资料上的描述,仅有ADD TO MEMTABLE这么简单来描述。首先,它体现了这条数据是顺序的追加上去的简单性。如果说还要干点啥,就只剩下一句,如果在ROW CACHES里存在,废弃存在的数据。对于LSM-TREE而言,MemTable写完,交易就应该是成功返回了。并且这些全都是内存操作。
然后我们随便来看看B树在这个阶段的维护都做了什么:
1)写Undo日志,内存操作,一般只有Undo空间不够才写盘。
2)从索引根节点开始,找到记录行应该存储的数据页,内存加磁盘操作。若为命中缓存,则可能促发多次磁盘IO。
3)可能B-TREE索引分裂,一大波内存加磁盘操作。
突然感觉有点什么不对劲对不对,首先追加写入MemTable显然是做不了约束性检查的,如果你的应用想写入重KEY回滚,那么在LSM-TREE的写入这里挡不住。那么你需要,那么它需要以另一种方式实现,比如说:读后写。那这时候的时间,其实有点近似与UPDATE的操作。
这个地方显然需要两分的来看待LSM-TREE的优势。不需要约束性检查的超大规模写入场景有没有?当然有,而且还很多。比如一个专门写入流水的数据表。在互联网特别常见的记录PV数据的表,只要有人点了某个页面,就等于一条记录。不需要约束,不需要事务。这样的场景,这样的系统,用LSM-TREE自然快得飞起。
然而,如果你的系统,几乎每个写入都要判定是不是重主键、重唯一索引的话。那么这个写入优势显然是有限的。
STEP 3 脏数据落盘 磁盘操作
由于这部分操作都是异步执行,只要机器资源没有问题的话,通常已经不太影响交易响应时间了。所以,这里的性能差别不是我想说的重点。我想说一些虚无缥缈的,想法。刚才说的这个脏页落盘异步,其实是很多数据库性能优化的一个空间。很多数据库的优化思路都在基于这一点去展开。比如说INNODB的DOUBLE-WRITE,Merge Insert Buffer等等。这些优化的核心思想都围绕着落盘可以异步,如何减少磁盘交互而展开。在看到这个地方的时候,不知道有没有隐隐约约的感受到,数据库的千差万别之中,总是存在着某些相似的地方。所以,接下来,我准备一边聊数据库的更新(UPDATE)操作,一边说一说这一条隐约的线索。
四、关于存储结构的思考
在其他枯燥的知识点对比介绍之间呢,让我们先乱入一波。
在我没有学过LSM-TREE之前,在B树还是我脑子里,唯一占统治地位的存储结构的少年时代。我一直有一个潜在的观念,即“数据库的存储结构是为数据查询服务的”。如果非要说的不那么武断,也至少是主要为查询服务的。比如说,最常见的索引吧。我们建立一个索引,损耗插入时索引维护的成本,使用额外磁盘空间。为啥?显然是为了加快查询。再比如说“聚簇”。维持数据库中数据某种维度的顺序,为啥?显然还是为了某种查询。再往比如说列存储,为了分析型SQL在计算过程中,减少无关列的读取,还是为了查询吧。这些概念没有一个是为了数据写入服务的吧。
正如上面所描述的,当我接触了LSM-TREE的存储结构之后,我有一个特别深刻而直观的印象,这个“数据库的存储结构是为数据写入服务的”。它和B树有根源性的不同,B树的存储结构,处处损耗写入的性能来提高查询性能。而LSM-TREE在提高写入性能,并且可能在某些时候损耗了读取的性能。
容我大胆的在这里丢一个问题和一个猜想。
问题:为什么数据库的发展历史中,是先有B树而后有LSM树?只是偶然的巧合吗?
猜想:在古老的数据库使用场景时,绝大部分数据产生的是比较慢的,这些数据变化也比较慢,但是他们反反复复的被使用和读取。于是,数据库使用了B树的存储结构。后来,随着时间的变迁。有些新兴的应用场景数据产生的速度,大幅度的加快了,数据的更新速度也加快了。甚至于出现了超大量的数据产生,但是这些数据快速的产生出来,但是被反复读取的使用的次数大幅度减少了,于是LSM树出现了。
为什么要写这个猜测?因为,如果我的猜想是对的,那么就是时候反问一句,你的数据库系统哪一种?你系统的数据是相对稳定的还是快速膨胀的?你系统的数据是反复读取吗?那么你感觉它更适合哪种存储引擎呢?
先别感觉豁然开朗,故事当然没有这么简单,选择也当然没有这么容易。注意,我描述LSM-TREE用的词汇是“可能”在某些时候损耗了读取性能。这个地方有破绽的点起码有两条。第一,既然是可能,那有没有什么情况没有损耗。那就变成,用LSM-TREE我的系统可能写入变快了,读取没变慢。第二个大破绽是,这个损耗没有量化。不同的系统对于损耗的容忍程度天差地别。有的项目一个交易慢几毫秒都会被人盯着追杀,而有的场景SQL语句跑个几秒几十秒都不叫事。
所以,要做出更准确的选择,我们还需要把自己系统的实际情况往里带入并量化差异。最佳的选择当然是实测。因为它优势的地方你的系统不一定优势,它劣势的地方你的系统也不一定就劣势,就是这么神奇。
五、写流程的衍生-更新动作
回到前面介绍写流程的主线上来。数据库的增删改查,并称数据库的四大操作。但是我觉得把UPDATE当作是写流程的某种衍生是合适的。我们前面讲了,在B树数据写入维护B树的过程,其实是一个先读后写的过程。如果我们把INSERT一条记录,看成是要更新这条记录所在的数据页的内容。假设空间够用,也没有分裂等等,那INSERT和UPDATE动作可不就是一个流程吗?
另一方面,你会发现我在描述LSM-TREE写优的反例竟然用的是约束性检查,而并没有用UPDATE操作来反例读后写。因为纯粹的LSM-TREE的UPDATE更加是一个纯纯的INSERT动作,不存在半点读后写。来看一下引用于2020年VLDB论文《LSM-based Storage Techniques:A survey》中的描述和图。
“通常,索引结构可以选择两种策略之一种来处理更新,即就地(in-place)更新和非就地(即异位,out-of-place)更新。就地更新结构,如B+树,直接覆盖旧记录来存储新更新。例如,在图1a中,为了将key k1的值从v1更新到v4,索引条目(k1, v1)被直接修改以应用该更新。这些结构通常是读优化的,因为只存储每个记录的最新版本。然而,这种设计牺牲了写性能,因为更新会导致随机I/O。此外,索引页可以被更新和删除所分割,从而减少空间利用率。
相反,异位(out-of-place)的更新结构,例如LSM-tree总是将更新存储到新的位置,而不是覆盖旧的条目。例如在图1b中,更新(k1, v4)被存储到一个新的位置,而不是直接更新旧的条目(k1, v1)。这种设计提高了写性能,因为它可以利用顺序I/O来处理写。它还可以通过不覆盖旧数据来简化恢复过程。然而,这种设计的主要问题是牺牲了读取性能,因为记录可能存储在多个位置中的任何一个。此外,这些结构通常需要一个独立的数据重组过程,以不断提高存储和查询效率。顺序的、异位的更新并不是新的想法;自20世纪70年代以来,它已成功地应用于数据库系统。”
可以看出LSM-TREE的异位(OUT-OF-PLACE)更新结构,压根就不是读后写,它就是一个INSERT动作。那这样子来看,是不是感觉把更新动作当作是一个写流程的衍生物,无论是对于B-TREE而言,还是LSM-TREE而言,基本上是没有什么违和感的。
然而,在这一堆反反复复的文字中,你可能已经建立起一个LSM-TREE读取性能被牺牲的概念,并且可能认为读取性能不佳,可能会是阻碍你的系统选取LSM-TREE的重要障碍。因为,哪怕你就是简简单单的看最前面那个彩图,也能知道,你的一条记录可能要读好几个文件才能得到,从而质疑它的读取性能。
如果你内心敏感,你也许能从我举得例子中察觉到另一朵重要的乌云。这个乌云是资源的冲突。LSM-TREE的写优势根源是,用追加写取代读后写。如果,你的系统有任何主要的场景,避免不掉读后写。那这个优势的根基便被动摇了。约束性检查,是最容易想到的场景之一,因为不读,就不能确定能不能写。继而,我们很容易的想到另一个重要更新场景,悲观锁和事务。为啥?长期的B-TREE经验告诉我们,SELECT FOR UPDATE,得先SELECT才能上锁FOR UPDATE。那不然我异位INSERT的时候,我去哪检查这条记录有没有锁呢?我怎么确定我INSERT的时候,别人不能INSERT呢?那这一部分内容,我们放在后面的章节里再去展开。先把基本的动作来看完。
六、B-TREE之优势的读
再看一下这个图:
数据读取,数据库通常视为最最主要的能力。就像我前面说的,有一段时间我都一直觉得,数据如何写,如何摆放最终都是为了读起来方便。从图上看的感觉总是直观的,前面提到了,很多人眼看着LSM-TREE那张图读取动作出现了5条读取线,得出了LSM-TREE读取性能不行的结论(很显然,我初看这张图,也是这么认为的)。当然,读多次,性能是不是受损,那肯定是受损的。损得大不大?那就不好说了。
在来来回回看这张图之后,我不再紧盯那五根线,而是开始细细的观察Level 1开始往下的这些SSTABLE文件。这些文件由异步的Compaction操作产生。这个动作有很多文档都把它翻译成数据压缩,我觉得这很容易和数据库的DATA COMPRESS概念混淆。我更喜欢把它译为数据整理。我们看看它做了什么,去重多版本数据、删除多余的老数据、数据按KEY排序。没错,它竟然是排序的。如果说SSTABLE的文件头里面,标上了起止的KEY,它像不像一个小小的B树呢?还记得我在前面说那个隐隐约约的线索吗?数据库总是把一些异步操作,作为一些优化的空间。LSM-TREE的Compaction就绝不是仅仅为了防止数据量不断增长而设计的清理机制。它的存在还有更重要的意义。LSM-TREE其实并没有自暴自弃的在优化写入的时候就放弃查询。其实它遵循了我们之前那个现索,数据库在异步的流程中,暗暗的优化数据查询时的速度。
我们来接着填一下前面的坑。如果说,相比B树而言,LSM-TREE的读取总是很慢的。是不是数据相对稳定的系统就不能选LSM-TREE吗?如果,我们的数据一次INSERT之后,就没不动了,那会是什么情况?我们来细细的推敲一下,首先必然的,LSM-TREE写优势用不上不对,因为你就不怎么写嘛?但是读的真的就慢吗?我们来看看,首先,我们在内存里找到了这条记录,CACHE命中了,或者MEMTABLE里有最新的数据,都没有磁盘IO,那妥妥的快。假设没命中,那么只做个一次INSERT的记录,可能出现几层的几个SSTABLE里面?好像只有一个吧。当然,这里会有一个疑惑,就是我没读这个SSTABLE,我怎么知道里面有没有这条记录?在我印象中,通常SSTABLE的数据文件,通常都要配一个BLOOM过滤器,来告诉你这条KEY它有没有来解决这件事情。诶,这时候,再来整理整理感觉,是不是上面那两根内存里的线,还挺快的。下面指到磁盘上的三根线,好像也没有三根线也就只有一根,剩下这一根,感觉还和B-TREE有那么一点点的像,搞不好在这一个SSTABLE里面,拿得还比B-TREE快。
好啦,这个例子其实有点过,我并不是要证明LSM-TREE的读取性能要比B-TREE好,这是不可能的。我只是想再提醒前面那个观点,它优势的地方你的系统不一定优势,它劣势的地方你的系统也不一定就劣势,就是这么神奇。比如我还常常被问到类似的问题,我的系统以更新为主,SSTABLE的线特别多是不是不能选LSM-TREE?那也不一定啊,如果你平时查都不查,那读取有一百根线对你的系统性能又有什么影响呢?那我的系统又改得多又查得多,又不是以写入为主呢?对的,它不适合,因为SSTABLE的读取线很多,而且它的写优势又发挥不出来。所以无论如何,你还是得把自己系统的场景往里面带一带,不要凭看图的直觉。
LSM-TREE的读取性能或许确实的受损的。但是显而易见的是,对于很多时候,这种受损是可以通过各种各样的手段优化、缓解。使得这种受损处于可接受的范围,不然LSM-TREE是怎么越来越火的呢?对吧。
而B树的读取方面,我觉得这里就不太需要再展开来讨论了,因为前面在说明它写的时候,其实也是要先完成读取流程的,再者B树大家也相对而言比较熟悉。
七、资源的冲突-数据锁
接下来,是时候来讲一讲这一朵乌云了。资源冲突问题,一直以来都在数据库的重要困难点附近出没。而且,冲突问题又和分布式的问题互相之间有一些纠缠。举个例子,在数据库单机架构往SHARE DISK的架构演变过程中,它就一度成为了很多数据库厂商搞不定的难点。比如,我在集群中的某一个数据库实例上,上锁并修改了数据。这个锁和修改信息,就需要立刻在内存中,直接通讯给其他的数据库实例。最后比较成熟的方案只有几个少数像ORACLE 的RAC,DB2的DATA SHARING这样的能够解决。这个例子特别清楚的能体现,资源冲突处理,要么要集中处理,要么需要特别靠谱的互相通信,这个通讯在分布式水平扩展的情况下会有所放大。所以,有一些SHARE NOTHING的数据库产品,会选择在数据存储节点,来集中处理数据的上锁问题。
对于基于LSM-TREE的数据库产品而言,有的产品选择不支持锁和事务,有的选择通过其它巧妙的手段来解决。而我们还是可以显著的看到,B-TREE结构中,上锁与不上锁,也许只是内存里面一个记录标志位的修改差别,上锁与不上锁的性能差别似乎并不是很大。但是LSM-TREE结构的数据库里,似乎很有必要把上锁流程的开销拿出来额外关注一下。因为如果数据库做的是一般性先读后写,那么写优势没了。如果是别的冲突处理机制,那这部分显然属于额外开销。
为什么不支持,竟然也可以算一个出路,我们可以往回退一步。资源冲突问题的源头是并行的处理。而在冲突的资源点上,我们需要转并行为串行,其实大致上我觉得可以分开成两个问题来看,第一个解决时序问题,就是我们认为后面的更新才是对的,第二个是解决并发过程中更新丢失和脏读问题。像CASSANDRA这样的数据,用时间戳来解决时序问题,即改更新为追加,并在数据读取合并时,以最新时间戳的数据为准。再利用时间戳来实现多版本的读取。算是解决半个资源冲突的本源问题。所以对没有第二部分问题的系统,这的确也是一个解决方案。
另外半个问题,就比较棘手,比如说钱的转入转出模型,有的转入有的转出,若钱不够就不能够转出。
我们来看一个我觉得比较优秀的基于LSM-TREE存储结构的 Percolator模型的实现方案。比A有10块钱,B有2块钱,A向B转7块钱。
首先,使用三个列簇(COLUMN FAILY)来存三样东西,一个是数据本身,一个是锁信息,一个是写入的版本。要有一个时间戳和一个版本号来解决时序问题,那么在PREWRITE阶段,数据库除了写入数据之外,还要写入LOCK信息。最后再提交阶段,把LOCK信息干掉,再写一个版本信息。
作为读后写替代,那么这种方案下的冲突解决能不能比B树的维护代价更小一点。我们先看看,在理想LSM-TREE的UPDATE只有一个追加写 MEMTABLE的操作上,又附加了什么。首先,我们看A记录,一共写了 三次,最后COMMIT结束之后,还需要把锁信息干掉。草算一算,好像时间翻了四倍。再细细的看一下,写数据和写版本看起来应该是可以追加写的。但是,这个锁信息可不就是一个妥妥的读后写吗?因为对一条记录上锁之前,起码得看看前面有没有锁吧。但是感觉上,这个锁的CF应该不大,应该基本上都是内存操作。
我们来细细推敲一下。普通更新的话,把A从版本5的10改为版本7的3。追加写数据,追加写版本,两次内存操作。检查读一次锁,如果没锁,写一次锁,先算他是内存操作。如果在B树上,一般随机转账命中概率不大,把A读起来,那么有一波磁盘IO,再加同样的内存上锁放锁,感觉还是B树不一定快。为啥,因为同样是读后写,这个锁CF信息的读后写很可能是内存里没有磁盘操作的。而B树的读后写,那基本上磁盘IO跑不了的。在这样的情况下,其实LSM基本上不落下风的。
但是,程序可不一定是这么写的。比如说,悲观锁。我相信很多时候,你很难把上面这个场景的SQL写成,UPDATE TABLE SET YUAN=YUAN-7 WHERE KEY=A吧。比较常见的写法不应该是,SELECT YUAN FROM TABLE WHERE KEY=A FOR UPDATE。然后IF YUAN>=7 YUAN_NEW=YUAN-7。再然后UPDATE TABLE SET YUAN=YUAN_NEW WHERE KEY=A。
这中间出现了什么变化,响应时间对比其实从单一的UPDATE对比变成了两个SQL符合在一起的形态对比。首先,由于SELECT FOR UPDATE这个动作出现,磁盘读变成了一个跑不掉的步骤。B树上,记录会在SELECT FOR UPDATE的时候就被IO到内存里面来。这时候,你再看B树的UPDATE,那就纯粹是个内存操作,那应该不会比写三个CF慢的。上锁放锁的内存动作,我们姑且认为二者差不多,那这个场景就变成纯粹的比读取。而且,考虑的有冲突和并发,表示这是一个频繁变更数据的情况,那么我会猜测LSM-TREE读取的SSTABLE的线比较多,最终直接成为影响系统性能的重要因素。
再稍微说一下分布式的叠加,如果A和B存在不同的节点里。B中的副锁还要再跨网络去访问A节点的主锁有没有释放。感觉也是有一些跟硬件相关的开销在里面。但应该不是重点,因为类比我们前面说的,RAC各实例间也有基于网络的锁信息交互开销。
八、高可用附加
其实这个内容,跟存储引擎的联系有限,但是前面写入的时候提到了BINLOG。MYSQL的BINLOG承担了主备机同步的桥梁作用,但它对于很多重要系统来说都是必开的。最关键的是,再RPO=0的前提下,BINLOG的远程落盘,是COMMIT成功的必要条件。它的开销对性能影响是必然的,写BINLOG也几乎写流程中的一部分。不过我觉得它还是可以剥离出来看,因为它本质并不是WAL日志。如果一定要对标的话,我觉得它类似于RAFT LOG的写入,这两个东西都是围绕着高可用和多节点数据复制展开的。对于写入的性能开销增加,似乎也是差不多的。
九、结尾
终于把这个坑稍微填了一填,感觉说了好多,又感觉好多内容没有说。想必以我的认知力,也并没有能力把所有的内容说的很全。基本上把实践中,遇到过、顾虑过的一些我觉得的关键点整理了一下吧,也算可以给需要的朋友提供个参考。另外,从文中想必也能看得出来,有些内容基本我全凭猜测,未必准确,也期待有大佬看到并帮我指出其中的错漏。
作者介绍
宇文湛泉,现任金融行业核心业务系统DBA,主要涉及Oracle、DB2、Cassandra、MySQL、GoldenDB、TiDB等数据库开发工作。