Sergey Ignatchenko 在撰写的一本有关数据库的图书,最近发布了本书的最新章节。本文属于 友情转载 。
本文大量出现了“单一写入连接”这个概念,这个概念的定义出现在这本书的其他章节中,为了让本文更显合理,我专门向Sergey请教了这个概念的定义。
对于单一写入连接(Single-write-connection),我是指只使用一个应用(本文中名为“数据库服务器”),向数据库建立一个用于执行修改语句(UPDATE/INSERT/DELETE)的数据库连接。这种做法可以大幅简化架构的设计:首先,可以从根本上彻底避免所有不可测试的并发问题(如缺少SELECT FOR UPDATE和死锁);其次,整个系统更具确定性(对查找Bug很有帮助,就算简单的纯文本日志也可以让系统更可调试并让事后分析更容易);最后同样重要的是,这种“独断式”的更新可以通过非常创新的方式改善性能(尤其是维持始终井然有序的应用级缓存,可实现比直接访问数据库高100倍-1000倍的效率)。
了解这些情况后,可以开始介绍最有趣的部分了:为事务型数据库和数据库服务器实现这样的设计。本书第7章简要介绍了数据库服务器的实现,本文将对整个重要话题展开更详细的讨论。
首先明确一下我们的目标:处理不同业务(如证交所、银行等)的各种自动化决策任务。
这种数据库中存储了账户等信息,以及这些信息的各类持久属性,此外还存储了与支付处理有关的各类往来通信。“数据库服务器”则是指用于处理DBMS各类访问的应用(正如第7章所述,我坚定地反对通过应用服务器/应用逻辑直接发起SQL语句的做法,因此需要具备数据库服务器这样的“中间人”)。
ACID特性对事务/运营数据库是至关重要的。我们肯定不想自己的钱,或在eBay上能卖出两万美元价格的任何东西不小心丢失或被人复制。出于一些原因的考虑,我们将介绍使用SQL数据库充当的事务/运营数据库(虽然NoSQL也可用于事务/运营数据库,但通常很难实现严格的保障,尤其是目前的大部分NoSQL数据库缺乏多对象ACID事务)。
接下来,正式开始讨论这种有趣的做法吧。
数据库的访问可通过两种截然不同的方法实现:多连接和单一连接。首先谈谈建立多个数据库连接这种更常见(但在我看来存在不足之处)的方法。
多连接方式访问数据库是一种常见做法,以至于很少有人会考虑是否可以通过其他不同方式实现。这样的想法其实很简单:建立到DBMS的多个连接,直接将所有可能需要的请求丢给数据库,由数据库负责处理这些请求。更重要的是,这种情况下不会遇到缩放性方面的问题,想要缩放,只要购买更多硬件即可(但实际情况并没有这么简单,原因见下文)。
虽然多连接数据库访问方式如此普遍,但这种方式依然存在一些不容忽视的局限:
只要涉及两个并发事务(例如写入/修改等事务),此时就需要考虑事务隔离问题。
事务隔离是个很麻烦的事。出于性能方面的考虑,大部分时候我们无法使用序列化(Serialized)隔离,而只要脱离了序列化隔离级别,不同事务就会开始相互交互,糟糕!
就某种意义来说,读取提交(Read Committed)是最常见的事务隔离级别,这种级别类似于编写多线程代码,并且会遇到写此类代码时类似的问题,尤其是:
可能出现本应锁的数据未能锁的隐患,这会导致数据库内出现罕见且无法测试的无效数据修改问题。更重要的是,由于这些问题转瞬即逝/不确定的本质,进而导致:
此外还存在死锁的风险。需要注意,数据库的死锁不像多线程死锁(一笔事务的死锁最终将能回滚)那么让人难以接受,但依然会造成不小的麻烦。为了应对这类问题,项目通常会确定明确的获取锁顺序,只要项目中的所有人能够遵守这样的顺序,大部分情况下都可以高枕无忧[1]。然而如果任何一个查询未能遵守这个顺序,迟早还会遇到死锁。
由于这个问题是转瞬即逝并且不确定的,因此上文提到的不可重现、不可测试、长期潜伏、不可调试等问题依然隐藏在“暗处”,时刻准备着发作并造成最糟糕的影响。
就算实现了序列化隔离级别,依然可能遇到锁(写入/写入锁),或由于某个事务造成写入/写入锁。
这些问题并非无解,其实是可以解决的。然而解决之前需要满足两个非常苛刻的前提条件:
开发团队需要包含具备娴熟经验的数据库专家。更重要的是,此人需要在高负载OLTP数据库方面具备丰富的经验(至少每天数百万事务,并进行事务监视等操作),这样的人其实非常难找。如果不具备这种现实环境中的高负载OLTP经验,可能根本无法处理高负载OLTP数据库可能遇到的某些(或大部分)问题(可能出现的问题远远超出了上文列出的范围),进而为整个项目带来毁灭性后果。
另外还必须要将数据库代码与应用的逻辑代码分开(正如第7章所述,无论使用多连接或事务隔离级别,都需要这样做)。在考虑应用逻辑的同时编写事务隔离感知的SQL语句,这种做法的后果无异于信贷危机级别的灾难。
正如在第7章中提到的,应用逻辑代码和数据库服务器代码之间的接口(也叫做数据库服务器API)必须以应用逻辑的形式(而非SQL的形式)体现。更重要的是,每个请求必须对应至一个事务(不给“已更改但未提交”留下任何可趁之机,因为这会产生大量锁并维持无法确定的时长,会在影响性能的同时大幅提高发生死锁的几率)。
应用逻辑服务器和数据库服务器之间维持清晰的分隔,可以让有经验的数据库开发者专注于数据库工作本身(包括专注于实现令人敬畏的事务隔离级别),同时让其他所有人尽量远离这个“危险的领域”。
更重要的是,无论RDBMS的宣传材料里怎么说,实际情况远非“线性”那么简单,现实世界中,具备多连接数据库访问特性的系统,其缩放能力绝对不是线性的,实际上远比“线性”更复杂。如果你不信,可以问问在每天数百万笔写入事务的数据库方面有经验的人(如果不认识这样的人,那么你还是相信我吧,最好彻底放弃建立多连接数据库服务器这种想法)。
造成这种现象的原因在于,任何多连接DBMS都会因为不同的数据库连接产生大量资源争夺的情况(而数据库日志文件是无法彻底避免争夺,并且最重要的资源之一,数据库日志文件需要连续写入并执行flush()/sync()操作,真是麻烦!),对于资源的任何争夺都会自发地导致非线性的缩放性。
然而我们可能会将OLTP数据库缩放至多台服务器(如果有能够胜任此类任务的OLTP数据库专家的话),但毫无疑问这一点也是很难做到的。
太多的并发请求会导致整个系统速度变慢。为缓解此类问题通常会使用TP Monitor。
虽然几乎每个数据库供应商都会说自己的RDBMS可以实现完美的线性缩放,但实际并非如此(这一点要铭记于心)。从大量过往经验来看,现实世界中的数据库根本无法以线性方式缩放,就算以多连接方式实现可缩放性的产品也是如此,实际上我们迟早要在应用层面上解决缩放问题(从我的经验来说,当你需要考虑使用单一写入数据库连接的时候,就该考虑这个问题了,详见下文讨论)。
平均无故障时间( MTBF )是指可系统内部失败两次之间可预测的间隔时间。— 维基百科
最后同样重要的是,从我的经验来看,相比单一连接数据库,负担繁重的多连接RDBMS通常崩溃的频率也更高。尤其是据我观察(使用装备了ECC、冗余散热、RAID、运行状况监视器等各项措施的企业级服务器,运行某个大型供应商的RDBMS产品),在使用单一连接数据库应用[2]时,该RDBMS本身的MTBF(即RDBMS自身崩溃的情况)约为每执行5e10-1e11次写事务出现一次,使用多连接数据库应用时约为每执行1e10-2e10次写事务出现一次。当然这只是坊间证据(你所使用的RDBMS在这方面可能有所差异),但实际观测证明了在单一连接模式下,RDBMS内部出现“竞争”的可能性低很多,因此如果不是有非常具体的证据能够证明你所用的某个特定DBMS不存在这种情况,我也不会给出这样的观察结果。我们将在第3卷进一步讨论服务器端的MTBF(第31章有关部署架构的第2版内容中进行了简要的介绍)。
如你所见,我本人并不太喜欢为OLTP数据库使用多连接的方法(基本上也是出于类似的原因,我不喜欢大规模多线程,不过对于数据库来说,这些问题的影响并不像对多线程那么大)。但必须承认,你依然可以为OLTP使用多连接。
如果不具备这样的人(当然,也不能想当然地找那些在数TB规模,但主要以读取事务为主的数据库方面有20年从业经验的人来代替),OLTP数据库的多连接数据库访问这种做法会蕴含大量风险。此外情况还可能更糟(原因见上文),数据库服务器也许在测试和“Beta”阶段看起来可以正常运行,但在生产部署并且负载规模达到某一程度后,可能会招致无穷无尽的钱财和数据损失,以及用户的抱怨等。
如果尽可能以并行方式运行所有这些未完成的请求,由于需要进行大量线程上下文切换和资源的争夺,系统性能还将进一步大幅降低。
如果依然希望使用多连接方式访问数据库,有个重要的问题需要注意:有可能迟早你会需要使用“事务处理监视器”,即TP Monitor。TP监视器的想法很简单:如果需要以严格并行的方式运行所有未完成请求,由于需要进行大量线程上下文切换和资源的争夺,系统性能可能大幅降低。换句话(大致来)说,TP监视器可以确保任何特定之间内数据库只需要执行少量请求(实际上可并行运行的请求数量取决于运行数据库的硬件,例如磁盘数量和/或处理器内核的数量)。
目前最著名的两个TP监视器是Microsoft COM+和Oracle Tuxedo(原先的BEA Tuxedo)。
另外作为反应器(Reactor)的“铁粉”,通常我更倾向于自行构建所需的TP监视器。本书第7章将详细介绍相关做法,简单来说实际上就是使用多个数据库服务器工作反应器(与每个数据库建立一个连接),并通过一个数据库服务器代理反应器接受系统其他组件发来的所有请求,随后将请求转发至一个“空闲”(负载最小)的数据库服务器工作反应器。总的来说,TP监视器并不涉及什么高深的技术,大部分时候我觉得自行构建也是一种很简单的方式(当然,始终应该视具体情况决定)。
上文一直在吐槽多数据库连接方法 ;-),该说说单一写入连接了。这种方法最初的形式很简单:有一个数据库服务器应用,维持一个数据库连接,这个数据库服务器应用接收传入的请求,选择预先准备好的相关SQL语句,与配套的参数绑定并发起执行该SQL语句的API调用。收到回复后,该数据库服务器应用会将收到的结果打包成相应格式的回复并发送回请求方,然后等待接收下一个请求。
这里有个很重要的问题需要注意:我们说的是单一写入连接,与这个单一写入连接并发执行的可能有任意数量的只读连接。另外出于性能方面的考虑(并且为了避免使用锁),此时需要使用RDBMS所能提供的最低级别的事务隔离。换句话说,如果你使用的RDBMS支持基于锁的并发,这些并行的只读连接可能需要使用读取未提交(Read Uncommitted)事务隔离级别,对于基于MVCC的RDBMS(但是MySQL+InnoDB是个例外)通常则要使用读取已提交隔离级别。这个特性会造成大量影响,但对于涉及历史数据的请求,以及99%的报表请求都可归于这个类别,这些请求均能在上述隔离级别下正常运行。
如上所述,单一写入数据库连接完全不存在并发的情况,任何特定时刻有且只有一个SQL语句正在执行。这意味着服务器端无须任何调整(且无需考虑所用隔离级别!),上文提到的与隔离级别有关的所有怪异问题都将不存在。
当然这样的简化也需要付出代价:可缩放性的缺乏。实际上缺乏可缩放性正是99%的人认为OLTP数据库使用单一写入连接是一种终极“异端邪说”的最主要原因(例如别人可能告诉你“这样根本不可行”)。我曾经不止一次见过一些高负载游戏(以及证券交易所)就是这样使用的。更重要的是,一些情况下,某一此类数据库甚至被称作“我们所知的Windows平台上运行的最高负载的DB/2实例”,说这话的人可不光来自IBM Toronto Labs(DB/2 UDB最初就是从这里诞生的,据我所知该实验室目前依然承担了DB/2的研发工作)。因此现实世界中,单一写入连接的数据库并非那么不堪,那么理论(“根本不可行”之类的言论)和实践(通过实例证明这种说法是错误的)之间为何会有这么大的差距?
首先要讨论一下单一写入数据库连接架构的性能。严格来说任何程度的性能都不足以取代可缩放性,但性能却可以影响我们开始考虑可缩放性的时机。
只要能严格限定只能通过一个数据库连接修改数据库[3],即可增加应用级缓存,并让这个应用级缓存与数据库实现完美的相干性(Coherent)。这是因为对于单一写入连接数据库,我们可以全面掌握有关数据库的所有改动,因为只能通过一个连接做出这种改动。
实际上我发现很多情况下可以通过这种应用级缓存大幅改善性能。我曾发现通过使用应用级缓存,可让整体性能实现5倍-10倍的提升!如果研究一下具体的处理过程就会发现,这样的结果不足为奇。如果使用应用级缓存(此类缓存中最流行的做法是对PLAYERS表创建缓存),随后我们只需要获取必要的玩家数据(几乎所有操作都需要使用此类数据),例如计算玩家ID的哈希,随后通过哈希值为玩家数据建立内存计算结构。总的来说,这一过程只需要使用大约100-200个CPU时钟(约0.1微秒)即可完成。
然而如果要为DBMS执行类似的操作,我们需要:(a) 绑定一条预先准备好的语句,(b) 发起一个API调用,(c) 由API调用封送(Marshal)所需数据,随后(d) 通过某些IPC(最有可能的做法是进行用户模式-内核模式-用户模式转换,并至少产生一个线程上下文切换)进入另一个进程,随后这个请求将会(e) 取消封送(Unmarshaled),(f) 找到一个与预先准备好的语句对应的执行计划,随后(g) 执行该执行计划,获取并解析(!)多个索引页以及至少一个数据页,随后(h) 解析数据页,(i) 得到有关用户的数据,(j) 封送,(k) 发回(再次产生一次用户模式-内核模式-用户模式转换,以及另一个线程上下文切换),随后还需要(l) 取消封送,并(m) 交付给应用。因此数据库内部的处理比应用中的处理花费更长的时间也不足为奇了,实际上对于数据库的访问,通常需要10微秒-100微秒左右的时间,相比应用级缓存内部的搜索慢了100倍-1000倍左右。为什么无法实现上文提到的5倍-10倍的提速?这是因为有很多其他任务需要通过数据库完成(尤其是为了实现耐久性,数据库事务需要经历完整的数据库同步,为解决这个问题可参阅下文的Kinda-write-back缓存)。
实际应用中,整体性能就算只有5倍-10倍的提升也是个不错的结果。但是需要注意,应用级缓存并不是优化数据库服务器所要采取的第一个措施,此处有关应用级缓存的讨论更多地是为了通过这种概念方便大家理解,并证明单一写入数据库连接这种做法是在进行全面缩放前可以考虑的一种有效的临时措施。数据库的整个优化过程(包括索引、数据库物理布局和RAID级别、规范化(Denormalisation)以及应用级缓存)将在本书第3卷详细介绍。
另外,上文提到的5倍改善是通过直写模式(Write-through)的应用级缓存实现的。此外还可使用Sorta-write-back类型的应用级缓存,该模式也可以实现100%正确的ACID耐久性保障。这样做可以进一步改善性能(然而我自己未曾尝试过,因此无法准确地说到底能实现多大程度的改善)。
本例中的想法主要是:
上述全过程提供了严格的耐久性,因为只有等到相关事务已提交并在数据库层面实现耐久性之后,我们才会发送对应的回复(在某种意义上,延迟的回复实际上只是一种“尝试性的回复”)。另外在上述处理模式中避免了大量提交事务,提交操作在延迟方面的成本非常高(见下文讨论)。上文曾经提到过,我尚未实际尝试过这种Kinda-write-back-cache模式,因此无法估测“能起到多大帮助”。但是粗略估算来说,(a) 有很大可能实现1.5倍-2倍的提速,并且(b) 更可行的结果是,将超过5-10个请求结合在一个Larger Transaction中并没有多大的实际意义。
在谈到单一写入连接数据库(在我看来非常出色的)性能优势时,关于这种配置还有一个不容回避的问题需要注意:单一写入连接数据库配置对延迟极为敏感。
我们即将谈到两方面的延迟:通信延迟,以及“数据库日志flush()/sync()延迟”。前者很简单,是指数据库服务器应用和DBMS之间的通信延迟,解决这种延迟的方法也很简单,将数据库服务器应用和DBMS放在同一台硬件上就行了,我们可以尝试不同的连接选项确定最佳配置,搞定!
后一种延迟(“数据库日志flush()/sync()”延迟)有必要更详细地介绍。为了理解这种延迟,首先需要明白常规的生产用RDBMS是如何处理事务的:
因此在搭建高性能单一写入连接数据库时,我们需要重点考虑服务器内部使用机械硬盘/固态硬盘(或直接附加连接的SCSI/SATA存储)以及BBWC RAID(或NVMe)的配置(软件RAID无须考虑!)。好在这些要求都很好实现(几乎所有主要服务器制造商都提供了BBWC RAID卡,同时虽然并未全面普及,但很多托管ISP也已开始提供此类设备)。BBWC RAID卡的成本约为1千美元(主要服务器制造商的报价),只需要为少数数据库服务器配备,因此预算的压力其实并没有那么大。
理论上这个方法看起来不错,但实际使用情况如何?我有一些经验可以分享 ;-)。
我见过很多现实世界中的系统(同时运行数百个不同的OLTP事务,大部分事务需要修改多行数据,并出于审计需求写入更多行的数据……)采用了单一写入数据库连接这种方法。
其中一个系统的常态为通过一个数据库连接每天处理超过3千万笔写入事务(未使用内存中数据库),同时为大约10万并发玩家提供支持。
(也就是说,在对数据库进行优化并为USERS表增加应用级缓存之后)执行每笔事务的平均时间为800微秒左右(如果负载是均匀的,这就可以实现86400秒/天*1000毫秒/秒 /0.8 毫秒/写入事务,约等于每天1亿笔写入事务,但由于一天内不同时段的负载分布不均匀,并且并非所有时间都是峰值时段,实际环境中该系统每天可以处理3千万-5千万笔数据库写入事务)。SQL语句数量数百个,每个ACID事务的行修改和/或添加操作平均数量(粗略估计)约为10个(其中包含非常复杂的玩家间互动以及各种审计需求)。简而言之,你也可以搭建出这种性能的系统(真正搭建实现实用的系统,而非使用TPC-C等人造的测试环境进行各种无法反应实际情况的测试)。上述系统可以为成千上万的并发游戏玩家提供支持,并提供了所有竞争对手系统中最稳定的表现。
在了解了具体的实现方式以及所涉及的数据后,我觉得对于具体所要处理的数据类型也没什么好说的了。简而言之:
当然具体问题还要具体分析(例如你最终也许只能获得半个数量级的提升[5])。当然,实现这种程度的性能需要付出极大的努力(包括数据库级别的优化和应用级缓存),但我相信市面上大部分OLTP数据库都是可以完美实现的。
上文曾经提到过,性能(哪怕再大的提升)也不值得让我们放弃缩放性。换句话说,某些时候哪怕每天处理3千万数据库事务的性能也是不够的。但我们可以通过另一种方法(现实世界中也得到了广泛应用),以无须共享的方式实现基于多个单一写入数据库连接的可缩放系统。在某种意义上,这种做法可以看作是三种与微服务有关模式的“表亲”:Database-Per-Service模式(参阅 Fowler 和 Richardson.DatabasePerService )、伴生(Accompanied)的Event-Driven Architecture(参阅 Richardson.EventDrivenArchitecture ),以及Application Publishing Events(参阅 Richardson.ApplicationEvents )。更重要的是,还可以通过一些方法(现实中的一系列应用也证明了这种做法的成功之处)从简化的单一写入数据库连接架构逐渐迁移为这种无须共享的多个单一写入数据库连接架构。随后我们会分别进行介绍。
作者: Sergey Ignatchenko , 阅读英文原文 : IT Hare: Ultimate DB Heresy: Single Modifying DB Connection. Part I. Performanc
感谢陈兴璐对本文的审校。
给InfoQ中文站投稿或者参与内容翻译工作,请邮件至editors@cn.infoq.com。也欢迎大家通过新浪微博(@InfoQ,@丁晓昀),微信(微信号: InfoQChina )关注我们。