大多数计算机系统都有一些状态,并且可能依赖于存储系统。我的数据库知识是随着时间的推移积累起来的,但在此过程中,我们的设计失误导致了数据丢失和中断。在数据密集型系统中,数据库是系统设计目标和权衡的核心。尽管不可能忽视数据库的工作方式,但应用程序开发人员预见到和经历的问题往往只是冰山一角。在本系列中,我将分享一些我特别发现对非专业人士有用的见解。
目前的网络可靠性以及系统因网络中断而停机的频率有多高,这是一个公开的争论点。现有的研究是有限的,而且往往由具有专用网络和定制硬件以及专门人员的大型组织主导。
谷歌宣称其服务可用性达到 99.999%,仅将 7.6%的 Spanner(谷歌的全球分布式数据库)问题归咎于网络,尽管它一直将专用网络作为其可用性背后的核心原因之一。Bailis 和 Kingsbury 于 2014 年进行的调查挑战了 Peter Deutsch 于 1994 年提出的分布式计算谬误之一,即网络真的可靠吗?
我们没有针对大型企业或公共互联网的全面调查。主要提供商也没有足够的数据来表明其客户的问题有多少可以追溯到网络问题。我们经常会遇到大型云服务提供商网络堆栈的中断,这些中断可能会导致互联网中断数小时,但这些只是影响大量可见客户的高影响事件。即使不是所有事件都引起很大的噪音,网络中断也可能影响更多的情况。云客户也不一定能了解他们的问题。当出现中断时,无法将其识别为提供商造成的网络错误。对他们来说,第三方服务是黑盒子。如果不是主要提供商,就不可能估计其影响。
与主要参与者在其系统上报告的内容相比,如果网络问题仅占导致停机的潜在问题的一小部分,那么您可以说是幸运的。网络仍然存在传统问题,例如硬件故障、拓扑更改、管理配置更改和电源故障。但我最近了解到,一些新发现的问题,如 SHARK BITES(是的,鲨鱼咬伤)确实存在。
ACID 是原子性、一致性、隔离性和持久性的缩写。这些是数据库事务需要为其用户提供的属性,以确保即使在崩溃、错误、硬件故障等情况下仍然有效。没有 ACID 或类似的合同,应用程序开发人员将不知道他们的责任与数据库提供的内容之间的区别。大多数关系型事务数据库都试图符合 ACID 要求,但像 NoSQL 运动这样的新方法却催生了许多没有 ACID 事务的数据库,因为它们的实现成本很高。
当我刚入行时,我们的技术主管在争论 ACID 是否是一个过时的概念。公平地说,ACID 被认为是一个松散的描述,而不是一个严格的实现标准。今天,我发现它非常有用,因为它提供了一类问题(和一类可能的解决方案)。
并非所有数据库都是符合 ACID 规范的,而在符合 ACID 规范的数据库中,对 ACID 的解释也可能不同。导致 ACID 实现方式不同的原因之一是在实现 ACID 功能时需要进行权衡取舍。有些数据库可能声称自己符合 ACID 规范,但在处理边缘情况或“不太可能”发生的事件时,其解释可能仍然存在差异。开发人员至少可以在高层次上了解数据库的实现方式,以便对故障模式和设计权衡有一个正确的理解。
一个众所周知的争议是 ACID 版的 MongoDB 究竟有多强。即使在 v4 版本中,MongoDB 也没有日志支持,即使它默认情况下不会更频繁地将数据文件提交到磁盘(每 60 秒一次)。考虑以下场景,应用程序进行了两次写入(w1 和 w2)。MongoDB 能够持久化第一个写入的更改,但由于硬件故障,它未能对 w2 进行操作。
将数据写入磁盘是一个代价高昂的过程,而通过避免提交,它们声称在写入时具有高性能,同时牺牲了数据的耐久性。截至今天,MongoDB 具有日志记录功能,但由于默认情况下每 100ms 提交一次日志,因此脏写仍然会影响数据的耐久性。即使风险显著降低,但对于日志中的耐久性和更改仍然存在相同的情况。
在 ACID 属性中,一致性和隔离性具有最广泛的不同实现细节,因为权衡的范围更广。一致性和隔离性是昂贵的功能。为了保持数据的一致性,它们需要协调并增加争用。当必须在数据中心之间(特别是在不同的地理区域之间)进行水平扩展时,问题会变得更加困难。提供高水平的一致性可能非常困难,因为可用性降低,网络分区更加频繁。有关此现象的更一般解释,请参阅 CAP 定理。值得注意的是,应用程序可以处理一些不一致性,或者程序员可能对问题有足够的了解,可以在应用程序中添加额外的逻辑来处理它,而无需严重依赖其数据库。
数据库通常提供各种隔离层,以便应用程序开发人员可以根据自己的权衡取舍选择最具成本效益的隔离层。较弱的隔离层可能更快,但可能会引入数据竞争。更强的隔离层消除了一些潜在的数据竞争,但会更慢,并且可能引入争用,从而导致数据库速度降低到可能导致停机的程度。
SQL 标准仅定义了四个隔离级别,尽管理论上和实际上还有更多的级别。如果您需要进一步阅读,jepson.io 提供了对现有并发模型的引人入胜的概述。例如,Google 的 Spanner 通过时钟同步保证外部可串行化,尽管这是一个更严格的隔离层,但它不在标准隔离层中定义。
The isolation levels mentioned in the SQL standard are:
SQL 标准中提到的隔离级别有:
序列化级别虽然是开销最大且对系统引入最多争用的级别,但为数据竞争的发生提供了最小的机会。其他隔离级别开销较低,但增加了数据竞争的可能性。有些数据库允许您设置隔离级别,有些数据库对此有自己的看法,不一定支持所有隔离级别。
即使数据库宣传它们支持这些隔离级别,仔细检查它们的行为也可能提供更多关于它们实际做什么的见解。
马丁·克莱普曼的隐居处提供了对不同并发性异常的概述,以及数据库是否能够在特定的隔离级别下处理它。克莱普曼的研究表明,数据库设计者可以对隔离级别进行不同的解释。
锁不仅会导致数据库中的争用增加,从而使成本变得非常高,而且可能需要应用程序服务器与数据库之间保持一致的连接。独占锁更容易受到网络分区的影响,并导致难以识别和解决的死锁。在难以持有独占锁的情况下,可以选择乐观锁。
乐观锁是一种在读取一行数据时,记录版本号、最后修改时间戳或其校验和的方法。然后,在修改记录之前,可以原子地检查版本是否没有发生变化。
UPDATE productsSET name = 'Telegraph receiver', version = 2 WHERE id = 1 AND version = 1
如果另一项更新早些时候更改了此行,那么对 products 表的更新将影响 0 行。如果没有更早的更新,则会影响 1 行,并且可以告知更新已成功。
当我们谈论数据一致性时,我们主要关注可能导致脏读和数据丢失的竞争条件。但是,数据异常并不仅限于此。
这种类型的异常的一个例子是写入倾斜。写入倾斜更难识别,因为我们不是主动寻找它们。写入倾斜不是在写入时发生脏读或丢失时发生的,而是在数据的逻辑约束受到侵犯时发生的。
例如,假设一个监控应用程序需要他们的操作员中有一人随时待命。
-- 事务 tx1BEGIN;SELECT COUNT(*)FROM operatorsWHERE oncall = true;-- 返回结果: 0UPDATE operatorsSET oncall = TRUEWHERE userId = 4;COMMIT;-- 事务 tx2BEGIN;SELECT COUNT(*)FROM operatorsWHERE oncall = TRUE;-- 返回结果: 0UPDATE operatorsSET oncall = TRUEWHERE userId = 2;COMMIT;
在上述情况下,如果两个事务成功提交,将会出现写入倾斜。即使没有脏读或数据丢失发生,数据的完整性也会丢失,因为有两个人被分配为值班人员。
可序列化隔离、模式设计或数据库约束有助于消除写入倾斜。开发人员需要在开发过程中能够识别此类异常,以避免生产中出现数据异常。话虽如此,但在代码库中识别写入倾斜可能非常困难。特别是在大型系统中,如果不同的团队负责在不相互交流和检查它们如何访问数据的情况下基于相同的表构建功能。
数据库提供的核心功能之一是排序保证,但对于应用程序开发人员来说,排序可能会令人惊讶。数据库按照它们接收的顺序而不是开发人员看到的编程顺序来查看事务。事务执行的顺序很难预测,尤其是在高并发系统中。
在开发时间中,特别是在使用非阻塞库时,不良的风格和可读性可能会导致用户认为事务是按顺序执行的,即使它们可以以任何顺序到达数据库。下面的程序使得 T1 和 T2 看起来像是按顺序调用的,但如果这些函数是非阻塞的并且立即返回一个承诺,那么调用的顺序将取决于它们在数据库中接收到的时间。
result1 = T1(); // results are actually promisesresult2 = T2();
如果需要原子性(要么完全提交所有操作,要么全部回滚所有操作)并且序列很重要,则 T1 和 T2 中的操作应该在单个数据库事务中运行。
分片是一种水平分割数据库的方法。尽管有些数据库可以自动水平分割数据,但有些数据库可能无法或不擅长水平分割数据。当数据架构师/开发人员可以预测数据将如何被访问时,他们可能会在用户层创建水平分区,而不是将这项工作委托给数据库。这称为应用程序级分片。
这个名称,应用层分片,常常给人一种错误的印象,认为分片应该存在于应用服务中。分片功能可以作为数据库前面的一层来实现。根据数据的增长和模式的迭代,分片的要求可能会变得复杂。能够在不重新部署应用服务器的情况下迭代一些策略可能会很有用。
将分片作为一项单独的服务,可以在不重新部署应用程序的情况下提高对分片策略进行迭代的能力。应用程序级分片系统的一个例子是 Vitess。Vitess 为 MySQL 提供水平分片,并允许客户端通过 MySQL 协议连接到它,它在不知道彼此的各个 MySQL 节点上分片数据。
自增是生成主键的常用方式。在数据库被用作 ID 生成器的情况下,数据库中会有专门用于生成 ID 的表,这种情况并不少见。通过自增生成主键可能不太理想的原因有几点:
在决定哪种方法更适合你之前,请考虑自增 ID 与 UUID 对索引、分区和分片的影响。
多版本并发控制 (MVCC) 使我们上面简要讨论的许多一致性功能成为可能。一些数据库(例如 Postgres、Spanner)使用 MVCC 来允许每个事务看到一个快照,即数据库的旧版本。针对快照的事务仍然可以是可序列化的以保证一致性。从旧快照读取时,您读取的是过时数据。
读取稍微陈旧的数据会很有用,例如,当您从数据生成分析或计算近似聚合值时。
读取陈旧数据的第一个优势是延迟(特别是如果您的数据库分布在不同的地理区域)。MVCC 数据库的第二个优势是,它允许只读事务无锁。如果可以容忍陈旧数据,那么在读取密集型应用程序中,这是一个主要优势。
数据库会自动清除旧版本,在某些情况下,它们还允许您按需执行此操作。例如,Postgres 允许用户按需进行 VACUUM,也可以每隔一段时间自动进行 VACUUM,Spanner 则运行垃圾收集器来清除超过 1 小时的旧版本。
计算中最隐蔽的秘密是所有的时间 API 都在说谎。我们的机器并不确切地知道当前的时间是什么。我们的计算机都包含一个石英晶体,它产生一个信号来计时。但是石英晶体本身并不能准确地计时,它要么走得快,要么走得慢,要么比实际时钟快,要么比实际时钟慢。漂移每天最多可达 20 秒。为了准确性,我们计算机上的时间需要不时地与实际时间同步。
NTP 服务器用于同步,但由于网络原因,同步本身可能会延迟。在同一数据中心与 NTP 服务器同步可能需要时间,而与公共 NTP 服务器同步可能会导致更大的偏差。
原子钟和 GPS 时钟是确定当前时间的更好来源,但它们价格昂贵且需要复杂的设置,因此无法在每台机器上安装。鉴于这些限制,在数据中心中使用了多层方法。虽然原子钟和/或 GPS 时钟提供了准确的计时,但它们的时间通过辅助服务器广播到其他机器。这意味着每台机器都会以一定的幅度偏离实际的当前时间。
还有更多问题……应用程序和数据库通常位于不同的机器上(如果不是在不同的中心)。不仅如此,分布在几台机器上的数据库节点无法就时间达成一致,应用程序服务器的时钟和数据库节点的时钟也无法达成一致。
谷歌的 TrueTime 在这里采取了不同的方法。大多数人认为谷歌在时钟方面的进展可以归因于他们使用原子钟和 GPS 时钟,但这只是故事的一部分。这就是 TrueTime 所做的:
随着当前时间信心的降低,这意味着 Spanner 操作可能需要更多的时间。这就是为什么即使不可能拥有精确的时钟,保持高信心对于性能仍然很重要的原因。
如果你在房间里问十个人“latency”是什么意思,他们可能会给出不同的答案。在数据库中,latency 通常指的是“数据库延迟”,而不是客户端感知到的延迟。客户端将看到数据库延迟和网络延迟的总和。在调试不断升级的问题时,能够识别客户端和数据库延迟是至关重要的。在收集和显示指标时,始终考虑同时包含两者。
有时数据库会根据写入和读取吞吐量以及延迟来宣传其性能特征和限制。虽然这可能会提供主要障碍的高级概述,但在为性能评估新数据库时,更全面的方法是分别评估关键操作(每个查询和/或每个事务)。例如:
评估和实验可能包含此类关键情况,直到您确信数据库能够满足您的性能要求。在收集延迟指标和设置 SLO 时,也会考虑到类似的规则,即考虑这种故障情况。
在收集每个操作的指标时要注意高基数。如果需要高基数调试数据,请使用日志,甚至是收集或分布式跟踪。有关延迟调试方法的概述,请参阅《想要调试延迟吗?》。
并非所有数据库都支持嵌套事务,但当它们支持时,嵌套事务可能会导致令人惊讶的编程错误,这些错误并不总是容易识别,直到出现明显的异常情况。
如果你想避免嵌套事务,客户端库可以检测并避免嵌套事务。如果你无法避免嵌套事务,你必须注意避免出现意外情况,即已提交的事务由于子事务而意外回滚。
将事务封装在不同的层中可能会导致令人惊讶的嵌套事务情况,并且从可读性的角度来看,可能很难理解意图。请看以下程序:
with newTransaction(): Accounts.create("609-543-222")with newTransaction(): Accounts.create("775-988-322") throw Rollback();
上述代码的结果将会是什么?它是回滚两个事务还是只回滚内部事务?如果我们依赖于多个封装事务创建的库层,将会发生什么情况?我们是否能够识别并改进这种情况?
想象一下,数据层已经实现了几个操作(例如 newAccount),并且这些操作在自己的事务中运行。当你在更高层次的业务逻辑中运行它们时,会发生什么情况?隔离和一致性特征是什么?
以下是提取出来的代码:
function newAccount(id string) { with newTransaction(): Accounts.create(id)}
避免嵌套事务,而不是处理这种开放式问题。您的数据层仍然可以实现高级操作,而无需创建自己的事务。然后,业务逻辑可以启动事务,在事务上运行操作,提交或中止。
function newAccount(id string) { Accounts.create(id)}// In main application:with newTransaction(): // Read some data from database for configuration. // Generate an ID from the ID service. Accounts.create(id) Uploads.create(id) // create upload queue for the user.
应用程序开发人员可能希望在事务中使用应用程序状态来更新某些值或调整查询参数。需要考虑的一个关键因素是正确的作用域。当网络问题发生时,客户端通常会重试事务。如果事务依赖于其他地方修改的状态,则根据问题中的数据竞争的可能性,它可能会选择错误的值。事务应该小心应用程序内的数据竞争。
var seq int64with newTransaction(): newSeq := atomic.Increment(&seq) Entries.query(newSeq) // Other operations...
上述事务每次运行时都会增加序列号,无论其最终结果如何。如果由于网络原因导致提交失败,在第二次重试时,它将使用不同的序列号进行查询。
查询规划器决定了您的查询将如何在数据库中执行。它们还会在运行之前分析查询并对其进行优化。规划器只能根据它接收到的信号提供一些可能的估计。如何找到以下查询的结果:
SELECT * FROM articles where author = "rakyll" order by title;
有两种检索结果的方法:
查询规划器的作用是确定哪种策略是最佳选择。查询规划器对其可以预测的内容的信号有限,这可能导致决策不佳。DBA 或开发人员可以使用它们来诊断和微调性能不佳的查询。数据库的新版本可以调整查询规划器,并且在升级数据库时,如果新版本引入了性能问题,那么自我诊断可以帮助您。诸如慢查询日志、延迟问题或执行时间统计信息等报告可能有助于确定要优化的查询。
查询规划器提供的一些指标可能会有噪音,特别是在估计延迟或 CPU 时间时。作为查询规划器的补充,跟踪和执行路径工具对于诊断这些问题可能更有用,尽管并非每个数据库都提供此类工具。
在线实时迁移或实时迁移意味着在不中断服务和不影响数据正确性的情况下从一个数据库迁移到另一个数据库。如果您要迁移到同一数据库/引擎,实时迁移就会更容易,但如果要迁移到具有不同性能特征和架构要求的新数据库,迁移就会变得更加复杂。
在在线迁移方面,有不同的模式,这里有一个:
如果您需要更多关于阶级的研究,请查看 Stripe 遵循此模型的迁移策略的综合文章。
数据库的增长使您体验到不可预测的规模问题。我们对数据库内部结构的了解越多,我们就越难以预测它们的扩展方式,但有些事情是我们无法预测的。
随着增长,以前关于数据大小和网络容量需求的假设或预期可能会变得过时。这时可能会发生大型方案重写、大规模运营改进、容量问题、重新考虑部署或迁移到其他数据库以避免停机。
不要以为了解当前数据库的内部结构是唯一需要的,扩展规模将会带来新的未知问题。不可预测的热点、数据分布不均匀、意外的容量和硬件问题、不断增长的流量以及新的网络分区,这些都会让你重新考虑数据库、数据模型、部署模型以及部署的规模。