作者 | Jaana Dogan,谷歌软件工程师
译者 | 弯月,责编 | 夕颜
出品 | CSDN(ID:CSDNnews)
大多数计算机系统都有某种需要保存到存储系统的状态。多年来我积累了许多数据库知识,大多都是从导致数据丢失和网站下线的设计错误中吸取的教训。在以数据为主的系统中,数据库是整个系统设计的核心,也是需要权衡的重点。尽管数据库的工作原理不可忽视,但许多应用程序开发者看到的、经历过的问题依然仅仅是冰山一角。在这篇文章中,我想分享一些我认为特别有用的知识。
人们经常说如今的网络很稳定,也有人争论许多系统宕机都是因为网络故障。现在这方面的研究很有限,而且多数研究都集中在拥有独立网络、专用硬件和配备了专人负责的大型组织中。
google的Spanner(Google的分布式数据库)实现了99.999%的服务在线,他们的研究表明,仅有7.6%的问题是由网络导致的,尽管他们将高可用性归功于专用的网络。2014年Bailis和Kingsbury的调查(https://cacm.acm.org/magazines/2014/9/177925-the-network-is-reliable/fulltext)挑战了1994年Peter Deutsh发表的“分布式计算的陷阱”(https://en.wikipedia.org/wiki/Fallacies_of_distributed_computing)。网络真的可靠吗?
除了这些巨头之外,互联网上并没有太完整的调查。大型供应商也没有提供足够的有关客户网络故障的数据。云提供商的网络导致的故障同样会导致部分互联网宕机数个小时,但这也仅仅是一小部分许多人能注意到的故障而已。有时候,许多我们不得而知的网络宕机也会造成巨大影响。云服务的客户也不一定能够注意到自己遇到的问题。当网络宕机时,客户并不能确定故障是由供应商的网络故障导致的。对于他们而言,第三方服务就是黑盒子。只有那些供应商自己才能估计出影响的范围。
与那些大企业的系统相比,如果你的系统中网络问题导致的宕机只占很小比例,那么只能说你的运气好。网络依然会受到各种传统问题的影响,如硬件故障、拓扑变化、管理配置改变,以及断电等。但我最近听说,鲨鱼也会导致网络故障(https://twitter.com/rakyll/status/1249891472993693696)。
ACID指原子性、一致性、孤立性和持久性。这些是数据库事务需要保证的几项特性,只有保障这几项特性才能在崩溃、错误、硬件故障等极端情况下保证数据的有效性。没有ACID或类似的保障,应用程序开发者就不能判断哪些应该由数据库负责,哪些应该由开发者自己负责。绝大多数关系型事务数据库都试图遵循ACID,但新的标准(如NoSQL)催生了一大批不保证ACID的数据库,因为ACID的实现代价相当大。
我刚刚入行时,曾经和技术领导争论过ACID是否已经过时。可以说,ACID只是一个松散的描述,而不是严格的实现标准。如今,我认为ACID非常有用,因为它提供了一大类的问题(以及一大类可能的解决方案)。
并非每个数据库都支持ACID,即使是实现了ACID的数据库,对于ACID的解读也不尽相同。其原因之一就是实现ACID需要做出的妥协非常多。数据库通常声称自己符合ACID,但在某些极端情况下依然会有不同的解释,或者它们对于那些“不太可能发生”的情况的处理方法也不一样。开发者至少可以在较高层次上理解数据库的实现方式,理解它们何时会出现问题,以及设计上的取舍。
最有争议的莫过于MongoDB的ACID实现(即使在第四版发行后该争论依然存在。)MongoDB很长时间都不支持日志,尽管它每隔60秒(甚至更长)才会向磁盘保存一次数据。考虑如下情况:应用程序发出了两个写请求(w1和w2)。Mongo能将第一个写请求持久化,但w2的持久化会因为硬件故障而失败。
该图演示了MongoDB在写入磁盘之前失败时的数据情况。
将数据提交到磁盘是一项昂贵的操作,避免提交能够以牺牲持久性的代价换来性能的提升。今天,MongoDB已经支持日志,但脏写操作依然可能会影响数据的持久性,因为默认情况下MongoDB每隔100毫秒提交一次数据。因此,即使有日志支持,类似的情况依然可能发生,尽管发生故障时导致的变化丢失会少很多。
在ACID属性中,一致性和隔离性在不同实现之间最大的区别,因为实现上的妥协更多。一致性和隔离性都是非常昂贵的功能。两者都要求协调,为了保证数据一致性,这两者都会导致竞争的发生。当需要在多个数据中心水平扩展时(特别是跨越不同地理区域的多个数据中心时),该问题就愈发严重。关于这个现象更一般的解释参见CAP理论。值得指出的是,应用程序能够处理一部分不一致性的问题,有经验的程序员也可以添加额外的逻辑,因此不必完全依赖数据库。
数据库通常提供不同的隔离层,这样应用程序开发者可以选取性价比最佳的隔离层使用。较弱的隔离速度较快,但可能会引发数据竞争。强隔离会消除可能的数据竞争,但速度比较慢,而且可能会引入数据冲突,从而拖慢数据库速度,甚至会导致宕机。
现有并发模型及其关系概览
SQL标准只定义了四层隔离,尽管无论从理论还是从实践来看,更多的隔离层也是可能的。jepson.io(https://jepsen.io/consistency)提供了对于以后并发模型的另一种观点,你可以阅读一下。例如,Google的Spanner能够保证带有时钟同步的外部序列化,而且这是一个标准中没有定义的、更强的隔离层。
SQL标准中提到的隔离级别如下:
可序列化(最严格,最昂贵):在同一个事务中,按顺序执行与并行执行能够产生相同的效果。串行执行是指每个事务在下一个事务开始之前执行完成。需要指出,序列化通常实现为“快照隔离”(如Oracle),而快照隔离并非SQL标准的内容。
可重复的读操作:当前事务中未提交的读操作仅对当前事务可见,而其他事务造成的读操作是不可见的。
读提交:未提交的读操作对于其他事务不可见。只有提交的写操作才可见,但可能会出现影子读取的问题。如果另一个事务插入并提交了新行,那么当前事务可能会看到新的数据。
未提交的读操作(不太严格,但比较廉价):允许脏读取,事务可以看到其他事务尚未提交的改变。在实践中,该级别可以用来返回大致的聚合结果,如在表上执行COUNT(*)操作。
可序列化的级别允许发生的数据竞争最少,但实现起来最昂贵,会给系统引入最多的数据竞争。其他隔离级别比较廉价,但会增加数据竞争的可能性。一些数据库允许自己设置隔离级别,另一些数据库则不支持某些隔离级别。
即使自称支持某些隔离级别的数据库,也需要仔细检查其行为,理解其实际的操作:
不同数据库的不同隔离级别的实现。
锁的实现可能非常昂贵,不仅因为它会引入数据竞争,还要求应用程序与数据库服务器之间存在稳定的连接。排他锁可能会更严重地受到网络分区的影响,并导致难以识别和解决的死锁。如果无法持有排他锁,则可以选择乐观锁。
乐观锁指的是,在读取行时,记录版本号以及最近修改的时间戳或其校验和。然后,您可以在更改记录之前检查版本是否没有原子更改。
UPDATE products
SET name = 'Telegraph receiver', version = 2
WHERE id = 1 AND version = 1
如果另一个更新之前更改了该行,则对产品表的更新将影响0行。如果没有更早的更新,它将影响1行,并且我们可以判断我们的更新已成功。
在讨论数据一致性时,通常我们会将注意力放在可能会导致脏读取和数据丢失的数据竞争上。然而数据的异常情况并不止这两种。
这类异常的一个例子是写偏斜。写偏斜很难识别,因为我们没有特意寻找它们。写偏斜的原因不是发生脏读取或数据丢失,而是数据的逻辑约束被破坏。
例如,假设某个监控应用程序要求随时必须有一个操作员在值守。
在上述情况中,如果两个事务都成功提交,则会发生写偏斜。尽管没有发生任何脏读取,也没有发生数据丢失,数据的一致性也会被破坏,因为两个操作员都被分配了值守任务。
可序列化的隔离、结构设计或数据库约束可以帮助消灭写偏斜问题。开发者应该能够在开发过程中识别出这种异常,并在生产环境中避免这种数据异常。话虽如此,识别代码中可能出现的写偏斜非常困难。即使在大型系统中,如果不同的团队负责同一个表上的不同功能,而缺乏互相交流,也会出现这种问题。
数据库的核心功能之一就是保证顺序,但数据库理解的顺序可能与应用程序开发者看到的顺序不一致。数据库看到的事务顺序是按照接收的时间排序的,而不是开发者认为的顺序。在高并发系统中,事务的执行顺序很难预测。
在开发期间,尤其是在使用非阻塞库时,较差的代码风格和可读性可能会导致以下问题:用户认为事务可以顺序执行,即使它们可以以任何顺序到达数据库。下面的程序使T1和T2看起来将被顺序调用,但是如果这些函数是非阻塞的并且立即以承诺返回,则调用的顺序将取决于它们在数据库中收到的时间。
如果要求原子性(完整提交或放弃所有操作),而且顺序很重要,那么操作T1和T2应该在同一个数据库事务中运行。
分片是数据库水平扩展的一种方式。尽管一些数据库能够自动实现水平扩展,但也有一些数据库不能,或者不擅长该功能。如果数据架构师和开发者能够预测数据点访问方式,那么可以在用户的层次实现水平分片,而不是将该任务交给数据库。这称为应用程序层的分片。
“应用程序层分片”这个名字通常会带来错误的印象——分片存在于应用程序服务之间。然而,分片功能可以实现为数据库之上的一层。根据数据增长和数据结构的迭代方式,分片需求可能会变得非常复杂。如果能迭代实现某些策略,而不需要重新部署应用程序服务器,是最理想的。
应用程序服务器与分片服务解耦合的例子
将分片作为单独的服务可以提高迭代分片策略的能力,而不必重新部署应用程序。应用程序级分片系统的此类示例之一是Vitess。Vitess为MySQL提供了水平分片,并允许客户端通过MySQL协议连接到它,并且它在彼此不认识的各个MySQL节点上分片了数据。
AUTOINCREMENT是生成主键的常用方式。利用它生成ID的情况数不胜数,但有些数据库也会使用专门的ID生成方式。下面是使用自增和使用专用主键生成方式的一些优缺点:
在分布式数据库系统中,自增可能会很困难。需要全局锁才能生成ID。如果生成UUID,则数据库的各个节点之间不需要任何合作。带有锁的自增可能会导致数据冲突,可能会导致分布式下的插入操作产生严重的性能降级。一些数据库如MySQL可能会要求特殊的配置,才能正确地在双主架构中实现自增。这种配置很容易搞错,从而导致写操作失败。
一些数据库具有基于主键的分区算法。顺序ID可能会导致无法预测的热点,并且可能会使某些分区不堪重负,而另一些分区则保持空闲状态。
访问数据库中行的最快方法是通过其主键。如果有更好的方法来标识记录,则顺序ID可能会使表中最重要的列成为无意义的值。请尽可能选择一个全局唯一的自然主键(例如用户名)。
在采用自增ID或UUID作为索引、分区或分片时需要认真考虑哪种方式最合适。
多版本并发控制(MVCC)引入了许多我们上面谈过的一致性功能。一些数据库(如Postgres,Spanner)使用了MVCC来保证每个事务都看到一个快照(数据库过去的某个版本)。事务和快照依然可以序列化,以保证一致性。在从旧的快照中读取时,你读到的是过时的数据。
读取稍稍过时的数据可能很有用,例如从数据中生成分析报告,或者计算大致的聚合值。
读取过时数据的第一个好处就是延迟(特别是当数据库分布在不同物理地区时)。MVCC数据库的第二个好处就是,它允许只读事务无锁执行。如果能够接受过时数据, 那么在存在大量读取的应用程序中,无锁是最大的优势。
即使太平洋另一侧的数据库中存在最新版本,应用程序服务器依然从本地副本中读取5秒之前的旧数据。
数据库会自动清理旧版本,在某些情况下,你可以按需进行清理。例如,Postgres允许用户按需使用VACUUM命令,同时也会每隔一段时间自动执行清理。Spanner会定时执行垃圾回收来清理超过一个小时的旧版本。
计算中最隐秘的秘密就是所有时间的API都是谎言。我们的机器无法准确知道当前时间。我们的计算机都包含一个石英晶体,该石英晶体会产生一个计时信号。但是石英晶体无法准确地计算时间,总会比实际的时钟快或慢。每天的漂移可能长达20秒。为了准确起见,我们的计算机上的时间需要不时地与实际时间同步。
我们使用NTP服务器进行同步,但是同步本身可能出现网络延迟。与同一数据中心中的NTP服务器同步可能需要一些时间,与公用NTP服务器同步可能会导致更多偏差。
原子钟和GPS时钟是确定当前时间的更好来源,但是它们太昂贵,并且需要复杂的设置,因此无法安装在每台机器上。考虑到这些限制,数据中心通常会使用多层方法。原子钟或GPS时钟能够提供准确的计时,它们的时间会通过另一台服务器广播到其他机器。这意味着每台机器都会在一定程度上偏离实际当前时间。
还有复杂的情况。应用程序和数据库通常位于不同的计算机中(甚至可能位于不同的数据中心)。不仅分布在几台计算机上的数据库节点无法在时间上达成共识,应用服务器时钟和数据库节点时钟也无法达成共识。
Google的TrueTime在这里采用了不同的方法。大多数人认为Google在时钟方面的进步可以归因于对原子钟和GPS时钟的使用,但这只是一部分原因。TrueTime的实际工作原理如下:
TrueTime使用两个不同的源:GPS和原子钟。这些钟表的失败模式不同,因此同时使用两种可以提高可靠性。
TrueTime使用了非传统的API。它返回一个时间范围,实际的时间位于下界和上界之间的任意地方。Google的分布式数据库Spanner会一直等待,直到它能确信当前的时间超过了某个特定时间。该方法给系统引入了一些延迟,特别是当主服务器广播的不确定较高时,但即使在全球分布式的情况下依然能够提供正确性。
Spanner的组件使用TrueTime,其中TT.now返回一个时间范围,这样Spanner可以插入sleep来确保当前时间超过某个特定时间。
对当前时间的信心下降,意味着Spanner操作可能需要更多时间。因此,尽管不可能拥有精确的时钟,但高可信度对于性能依然很重要。
对于“延迟”一词每个人的理解都不同。在数据库中,延迟通常称为“数据库延迟”,而不是客户端感知的延迟。客户端看到的延迟是数据库延迟和网络延迟之和。在调试不断升级的问题时,能够识别客户端和数据库延迟至关重要。收集和显示指标时,请始终考虑同时使用两者。
有时,数据库在宣传其性能和限制时会使用读写吞吐量和延迟作为指标。而实际上,在评估新数据库的性能时,更全面的方法是分别评估关键操作(每个查询和/或每个事务)。例如:
在表X(包含五千万行数据,并带有约束)中插入一行并填充关联数据的写吞吐量和延迟。
当每个用户的平均朋友数为500时,查询给定用户的朋友的朋友的延迟。
当每个用户订阅了500个账号,每个账号每小时有X条消息时,获取时间线的最新100条记录的延迟。
评测和实验应当包含这种极端用例,才能确信数据库是否能够满足你的性能要求。类似的经验法则是,在收集延迟数据和设定服务水平目标时也要用这种细致的用例。
并非每个数据库都支持嵌套事务,但是当嵌套数据库支持嵌套事务时,嵌套事务可能会导致出人意料的编程错误,这些错误通常很难被发现,直到你发现数据异常。
如果想避免嵌套事务,客户端库通常可以帮你检测并避免嵌套事务。如果无法避免,则必须注意避免出现子事务会导致已提交的事务意外中止的意外情况。
将事务封装在不同的层中可能会导致令人惊讶的嵌套事务案例,并且从可读性的角度来看,其意图会很难理解。看一看以下程序:
上述代码的结果是什么?它会回滚两个事务,还是只会回滚内层事务?如果这段代码依赖于库中的多个层,每个层都封装了事务处理?我们怎样才能识别并改善这种情况?
想像一个包含多个操作(如newAccount)的数据层,该数据层已经实现了自己的事务处理。数据层无需自己创建事务就能实现高层操作。于是,业务逻辑可以启动事务、在事务中执行操作,然后提交或放弃。
应用程序开发者也许想在事务中使用应用程序状态,以更新特定的值,或修改查询参数。需要考虑的一个关键因素就是要使用正确的作用域。客户端通常会在发生网络问题时重试整个事务。如果事务依赖于某个状态,而该状态可能在其他地方被修改,那么事务就可能会在数据竞争发生时读取错误的值。事务应当注意应用程序内的数据竞争。
上述事务每次运行时会增加序列数字,而不管实际执行结果如何。如果网络故障导致提交失败,那么第二次尝试时就会使用不同的序列数字进行查询。
查询计划器可以给出查询在数据库中的执行方式。它还会分析查询并在执行之前进行优化。计划期只能提供一些大致的估计。怎样才能得到下述查询的结果呢?
SELECT * FROM articles where author = "rakyll" order by title;
有两种方式可以获取结果:
全表扫描:扫描表中的每一条记录,返回作者名字匹配的所有文章,然后排序
索引扫描:使用索引找到匹配的ID,获取这些行,然后排序
查询计划器的职责就是判断哪种方式最合适。查询计划器得到的信号很有限,因此可能会导致不理想的决定。DBA或开发者应该使用这些信息来诊断并对效率不高的查询进行调优。新版数据库可以调整查询计划器并进行自我诊断。慢查询报告、延迟问题报告、执行时间报告等也有助于优化查询。
查询计划器提供的某些指标可能很混乱,特别是有关延迟或CPU时间的指标。作为查询计划器的辅助,跟踪和执行路径工具也有助于诊断这些问题。不过并不是每个数据库都提供这些工具。
实时在线迁移意味着从一个数据库迁移到另一个数据库,中途不停止服务,还要保证数据的正确性。如果在同种数据库之间迁移,那么在线迁移就能容易些,但如果使用新的数据库,再加上不同的性能要求和表结构,迁移就会变得更加复杂。
在线迁移有不同的模型,下面是其中一种:
开始对两个数据库进行双重写入。在此阶段,新数据库不会拥有所有数据,但新数据会出现在新数据库中。一旦对这一步充满信心,就可以继续进行第二步。
在两个数据库上同时启用读取路径。
主要使用新的数据库进行读写操作。
停止在旧的数据库上进行写入操作,但读取操作依然在旧的数据库上进行。此时,新的数据库并不包含所有新数据,在读取旧数据时,可能依然需要使用旧的数据库。
此时,旧的数据库是只读的。将新数据库中缺少的数据填入。迁移完成后,所有的读写路径都可以使用新的数据库,旧的数据库就可以移除了。
如果需要更多案例研究,可以阅读Stripe的这篇详尽的文章(https://stripe.com/blog/online-migrations)。
数据库增长会带来不可预料的规模问题。对数据库内部原理的理解越多,我们对于规模的预见就越少,但有些东西是无法预料的。
数据库增长会导致以前对于数据规模和网络容量需求的假设变得无效。这时就需要进行大规模结构修改、大规模操作改善、容量问题改善、重新考虑部署、迁移到其他数据库等方式来避免服务中断。
永远不要假设你只需要理解当前数据库的内部原理,因为规模会引发新的问题。无法预料的热点、不均衡的数据分布、无法预料的容量和硬件问题,日益增长的流量和新的网络分区,都会让你不得不重新考虑数据库、数据模型、部署模型和部署规模等问题。
原文链接:
https://medium.com/@rakyll/things-i-wished-more-developers-knew-about-databases-2d0178464f78
本文为CSDN翻译文章,转载请注明出处