绝大多数计算机系统都具有某种状态,而且很可能还依赖于一个存储系统。我对数据库的知识也是逐渐累积起来的,但在累积的过程中,我们的设计错误曾导致过数据丢失和中断问题。在严重依赖数据的系统中,数据库是系统设计的目标和权衡的核心。尽管我们不可能忽略数据库的工作方式,但应用开发者可以预见或实际经历的问题往往都只是冰山一角。在本系列文章中,我将分享一些我专门找到的对不擅长数据库领域的开发者很有用的见解:
如果 99.999% 的时间里网络没有问题,那你确实很幸运。
ACID 有很多含义。
每个数据库具有不同的一致性和隔离性。
当你无法搞定锁时,就使用乐观锁。
除了脏读和数据丢失,还存在其它异常。
我的数据库和我在排序方面并不总是一致的。
应用层面的分片可以存在于该应用之外。
AUTOINCREMENT 可能有害。
过时的数据可能有用而且是无锁的。
任何时钟源之间都会发生时钟偏移。
延迟(latency)有很多含义。
评估每个事务的性能需求。
嵌套事务可能有害。
事务不应维持应用状态。
查询计划器能提供有关数据库的一切信息。
在线迁移可能很复杂,但却可以实现。
数据库显著增长时会引入不可预测性。
如果 99.999% 的时间里网络没有问题,那你确实很幸运。
人们至今仍在论辩如今的网络连接技术有多可靠以及由于网络中断而导致系统停机的情况有多频繁。可行的研究很有限,而且这些研究往往由拥有使用定制硬件的专用网络的大型组织以及特定人员所主导。
凭借 99.999% 的服务可用性,谷歌仅把 Spanner(谷歌散布在全球的数据库)出现的问题中的 7.6% 归因于网络连接,尽管该公司称其专用网络是这种可用性背后的核心原因。Bailis 和 Kingsbury 2014 年的调查向 Peter Deutsch 于 1994 年提出的分布式计算的谬误(Fallacies of Distributed Computing)之一发起了挑战。网络真的可靠吗?
我们并没有来自巨头企业之外的调查结果或在公共互联网上的调查结果。主要电信提供商也没有足够的数据,让人无法了解他们的客户端遇到的问题有多少可追溯到网络问题。我们常会遇到大型云提供商的网络堆栈中断的情况,这可能导致部分互联网下线几个小时,但只有影响力很高的事件才会影响到大量可见客户端。网络中断可能影响范围很大,但不是每个案例都会产生严重影响。云客户端也不一定需要详细了解他们遇到的问题。当出现中断时,不可能识别出这是否是由提供商导致的网络错误。对他们而言,第三方服务都是黑箱。如果不是主要提供商,是不可能估计出影响有多大的。
对比一下主要玩家公布的系统报告,如果可能导致中断的潜在问题中仅有一小部分是网络问题,那么可以说你是相当幸运的。网络连接仍面临着许多常规问题,比如硬件故障、拓扑变化、管理配置更改和电源故障。但我最近看到一个新闻,发现鲨鱼撕咬也是一个现实存在的问题——已经出现过鲨鱼撕咬海底光缆的案例。
ACID 有很多含义
ACID 表示原子性(atomicity)、一致性(consistency)、隔离性(isolation)、持久性(durability)。ACID 是数据库事务(database transaction)需要向用户确保有效的属性——即使在出现崩溃、错误、硬件故障等情况时也需要保证这些属性。如果没有 ACID 或类似的保证,应用开发者将难以区分他们自己的职责与数据库能够提供的保证。大多数关系事务数据库都会尽力符合 ACID 指标,但 NoSQL 运动等新方法催生了许多没有 ACID 事务的数据库,这些这些事务的实现成本比较高。
在我刚进入这一行业时,我们的技术主管当时讨论过 ACID 是否已是一个过时的概念。可以合理地说,ACID 可视为一种定义宽松的描述,而不是严格的实现标准。现如今,我发现 ACID 最有用的地方是它提供了问题的类别(以及可能的解决方案的类别)。
并非每个数据库都符合 ACID,而在符合 ACID 的数据库中,ACID 的解读方式也可能不同。为什么 ACID 会有不同的实现方式?一个原因是在实现 ACID 时,需要权衡的东西太多了。数据库在做广告宣传时可能会说自己符合 ACID,但在许多边缘案例上仍可能有不同的解释或在处理不太可能发生的事件时的方法不同。为了适当地理解故障模式和设计权衡,开发者至少可以在高层面上了解数据库实现各项功能的方式。
一个众所周知的争议问题是 MongoDB 在第 4 版后有多符合 ACID。MongoDB 很长时间都不支持日志功能,尽管默认情况下其也不会更频繁地(每 60 秒)将数据文件提交到磁盘。考虑以下情况,一个应用执行两次写入(w1 和 w2)。MongoDB 能够在第一次写入时保留更改,但无法在写入 w2 时保留这项更改,因为这会出现由硬件故障所致的崩溃。
MongoDB 在写入物理磁盘前崩溃而导致数据丢失的示意图
将数据提交到磁盘的过程具有较高的成本,而通过避免提交,它们可以宣称在写入方面表现出色,但这样就牺牲了持久性。如今,MongoDB 已经有了日志功能,但脏写(dirty writes)仍然可能影响数据的持久性,因为它们默认是每 100 ms 提交一次。对于日志及这些日志所表示的更改的持久性,也可能会出现同样的情况,不过这种风险要小得多。
每个数据库具有不同的一致性和隔离性
在 ACID 属性中,一致性和隔离性的不同实现细节的范围是最广的,因为其涉及的权衡因素更多。一致性和隔离性都是实现成本较高的属性。为了保持数据一致,它们需要协调而且正得到越来越多的讨论。当必须以水平方式扩展数据中心时(尤其是对于不同的地区),这些问题会变得更加困难。因为此时可用性会下降且网络分区会越来越普遍,这会导致很难实现高层面的一致性。CAP 定理为这一现象给出了更普适的解释。需要指出的是,即使有一些不一致性,一般应用也能处理,或者程序开发者对这一问题有足够的认知,让他们能为该应用添加用于处理这一情况的逻辑,从而无需过于依赖他们的数据库。
数据库往往会提供多种不同的隔离层,这样应用开发者就可以基于自己的权衡策略来选择最具成本效益的。当隔离更弱时,速度可能更快,但也可能导致数据竞争(data race)。当隔离更强时,不会出现某些潜在的数据竞争,但速度会更慢,而且还可能出现争用(contention)情况,这甚至可能将数据库的速度拖慢到中断的程度。
现有并发模型及它们之间的关系概况
SQL 标准仅定义了 4 种隔离层级,但理论上和实践中的层级都更多。jepson.io 很好地总结了现有并发模型的情况:https://jepsen.io/consistency。举个例子,谷歌的 Spanner 使用了时钟同步来保证外部可串行化,即使这是一种更严格的隔离层,但标准隔离层中却并没有这样的定义。
SQL 标准中提及的隔离层级包括:
可串行化(最严格,成本最高):可串行化执行(serializable execution)得到的效果与这些事务的某些序列执行的效果一样。序列执行(serial execution)是指在每个事务执行完成之后再执行下一个事务。关于可串行化执行,需要注意的一点是:由于解释的差异性,它往往被实现为快照隔离(snapshot isolation),比如 Oracle,而快照隔离并不在 SQL 标准中。
可重复的读取:当前事务中未提交的读取对当前事务来说是可见的,但其它事务做出的改变(比如新插入的行)不是可见的。
已提交的读取:未提交的读取对事务来说不可见。只有已提交的写入是可见的,但可能出现幻象读取(phantom read)。如果另一个事务插入和提交了新的行,则当前事务在查询时可以看到它们。
未提交的读取(最不严格,成本最低):允许脏读(dirty read),事务可以看到其它事务做出的尚未提交的更改。在实践中,这个层级可用于返回近似聚合结果,比如对一个表格的 COUNT(*) 查询。
可串行化层级出现数据竞争的情况最少,但成本也最高,而且会让系统出现最多争用。其它隔离层级的成本更低一些,但也更可能出现数据竞争问题。某些数据库允许自行设置隔离层级,某些数据库则在这方面更为固执一点,并不一定支持所有这些层级。
而就算数据库宣称自己支持这些隔离层级,但只要仔细检查一下它们的行为,就可以了解这些数据库实际究竟是怎么做的。
每个数据库在不同隔离层级上的并发异常概况
Martin Kleppmann 的 hermitage 项目总结了不同的并发异常,并说明了一个数据库在不同的隔离层级上能否处理这样的异常:https://github.com/ept/hermitage 。Kleppmann 的研究表明数据库设计者会以不同的方式解释隔离层级。
当你无法搞定锁时,就使用乐观锁
锁的成本非常高,不仅是因为它们会为数据库引入更多争用,而且还需要你的应用服务器与数据库之间存在一致的连接。网络分区可能会更显著地影响排它锁(exclusive lock),这会导致难以识别和解决的死锁(deadlock)。如果有些案例无法很好地使用排它锁,可以选择乐观锁(optimistic locking)。
乐观锁这种方法是指当读取某行时会记录版本号、上次修改的时间戳或其校验和(checksum)。然后你可以在更改记录之前检查原子方面并无修改的版本。
UPDATE products
SET name = 'Telegraph receiver', version = 2
WHERE id = 1 AND version = 1
如果另一项更新之前已经修改了这一行,那么对 products 表的更新将影响 0 行。如果没有更早的更新,则它会影响 1 行,则我们可以说更新成功了。
除了脏读和数据丢失,还存在其它异常
当我们在探讨数据一致性时,我们主要关注的是可能导致脏读和数据丢失的竞争问题。但数据方面的异常并不止这两种。
举个例子,还有一种异常是写偏序(write skew)。写偏序更难以识别认定,因为我们不会主动地去查找这个问题。导致写偏序的原因不是发生在写入上的脏读或数据丢失,而是因为数据上的逻辑约束损坏。
比如,假设一个监控应用需要一个人类操作员始终处于待命状态。
BEGIN tx1; BEGIN tx2;SELECT COUNT(*)
FROM operators
WHERE oncall = true;
0 SELECT COUNT(*)
FROM operators
WHERE oncall = TRUE;
0UPDATE operators UPDATE operators
SET oncall = TRUE SET oncall = TRUE
WHERE userId = 4; WHERE userId = 2;COMMIT tx1; COMMIT tx2;
在上面的情况中,如果这些事务中有两个成功提交,就会出现写偏序。即使此时没有出现脏读或数据丢失,数据也失去了完整性,因为其指定了两个待命的人。
可串行化隔离、模式设计或数据库约束有助于消除写偏序。开发者需要在开发过程中识别这样的异常,以避免生产过程中出现数据异常。话虽如此,识别代码库中的写偏序却非常之难。尤其是在大型系统中,如果负责基于同一表格构建功能的不同团队之间没有沟通且没有互相检查他们存取数据的方式,那么就会出现这种问题。
我的数据库和我在排序方面并不总是一致的
数据库提供的一大核心能力是排序保证,但排序结果可能会出乎应用开发者的预料。数据库查阅事务的顺序就是它们接收这些事务的顺序,而不是开发者查看它们时的程序设计顺序。事务执行的顺序难以预测,尤其是在高容量的并发系统中。
在开发时,尤其是在使用非阻塞软件库进行开发时,较差的样式和可读性可能会导致用户认为事务是按顺序执行的,即使它们可能以任何顺序抵达数据库。下面的程序看起来像是 T1 和 T2 将按顺序调用,但如果这些函数是非阻塞的,则它们将立即带着 promise 返回,调用的顺序将取决于它们在数据库中接收到的时间。
result1 = T1() // results are actually promises
result2 = T2()
如果需要原子性(以便完全提交或放弃所有操作)且序列很重要,则 T1 和 T2 中的操作应该运行在单个数据库事务中。
应用层面的分片可以存在于该应用之外
分片(Sharding)是一种水平划分数据库的方法。有的数据库可以自动地对数据进行水平分区,有的数据库则不支持这种功能或做得不好。当数据架构师 / 开发者可以预测访问数据的方式时,他们可能会在用户区域创建水平分区,而不是将这项工作委托给他们的数据库。这种方式称为应用级分片(application-level sharding)。
应用级分片这个名称往往会给人带来一种错误印象,让人以为这种分片应该存在于应用服务之中。分片功能可以实现为数据库的前面一层。取决于数据增长和架构迭代情况,分片的要求可能会变得非常复杂。如果能在无需重新部署应用服务器的前提下对某些策略进行迭代,则会大有裨益。
应用服务器与分片服务分离的架构示例
如果将分片作为一个单独的服务,你就能更好地在不重新部署应用服务器的前提下迭代分片策略。Vitess 就是应用级分片系统的一个例子。Vitess 为 MySQL 提供了水平分片,并允许客户端通过 MySQL 协议连接它;Vitess 会将数据分片到多个互相之间无联系的 MySQL 节点上。
AUTOINCREMENT 可能有害
AUTOINCREMENT(自动递增)是生成主键(primary key)的一种常用方法。数据库被用作 ID 生成器以及数据库中有 ID 生成指定表格的情况其实并不少见。但使用自动递增生成主键的方式其实并不理想,原因有几点:
在分布式数据库系统中,自动递增很困难。为了生成 ID,需要使用全局锁才行。而如果你可以生成 UUID,那么就不需要数据库节点之间有任何合作。使用锁的自动递增可能导致争用,并可能导致分布式情况中插入性能显著下降。MySQL 等一些数据库可能需要特定的配置和更多的注意才能正确地完成 master-master 复制。这样的配置容易混乱而且可能导致写入中断。
某些数据库有基于主键的分区算法。按顺序排布的 ID 可能导致无法预测的热点,从而使得某些分区过于繁忙,另一些则一直空闲。
访问数据库中某行的最快方式是通过主键。如果你有更好的标识记录的方式,那么顺序 ID 可能会让表中最显著的列成为无意义的值。请尽可能地选择全局独一的自然主键(比如用户名)。
请考虑自动递增 ID 与 UUID 对索引、分区和分片的影响,然后再决定哪种方式对你而言最好。
过时的数据可能有用而且是无锁的
多版本并发控制(MVCC)能实现我们上面简要讨论过的很多一致性。Postgres 和 Spanner 等一些数据库使用 MVCC 以让每个事务都能看到一个快照,即该数据库的一个更旧版本。参照快照的事务仍然可以串行化以实现一致性。当读取一个旧快照时,实际读取的是过时的数据。
但即使读取的是稍微过时的数据,也会很有用处,比如当在生成数据分析结果或计算近似聚合值时。
读取过时数据的第一大优势是延迟(尤其是当你的数据库分布在不同的地区时)。MVCC 数据库的第二大优势是其允许只读事务是无锁的。在需要大量读取的应用中,一个优势是用过时的数据也是可行的。
即便太平洋另一端有某个数据的最新版本,但也可以从本地读取 5 秒前的过时副本。
数据库会自动清除旧版本,而在某些情况下,数据库也支持按需清理。举个例子,Postgres 允许用户按需执行 VACUUM 操作或每隔一段时间自动执行 VACUUM,而 Spanner 则是通过运行一个垃圾收集器来丢弃时间超过 1 小时的版本。
任何时钟源之间都会发生时钟偏移
在计算领域,隐藏得最好的秘密是所有时间 API 都在说谎。我们的机器并不能准确地知道当前的时间是多少。我们的计算机全都包含一个用以产生计时信号的石英晶体。但石英晶体并不能准确计时和计算时间偏移量,要么比实际时钟快,要么就更慢。一天的偏移量甚至可达 20 秒。为了准确,我们的计算机时间必须不时地与实际时间保持同步。
NTP 服务器可用于同步,但同步本身却可能由于网络的原因而出现延迟。与同一数据中心的 NTP 服务器同步况且需要时间,与公共 NTP 服务器同步更是可能产生更大的偏移。
原子钟和 GPS 时钟是更好的确定当前时间的信息源,但它们的部署成本更高,而且需要复杂的设置,不可能在每台机器上都安装。由于存在这些限制条件,数据中心通常使用的是多层方法。即在使用原子钟和 / 或 GPS 时钟提供准确计时的同时,再通过辅助服务器将时间信息广播给其它机器。这意味着所有机器都与实际的当前时间存在一定程度的偏移。
不仅如此,应用和数据库往往搭建在不同的机器中,甚至还可能位于不同的数据中心。因此,不仅分散在不同机器上的不同数据库节点之间无法统一时间,应用服务器时钟和数据库节点时钟也无法统一。
谷歌的 TrueTime 为此采用了一种不同的方法。大多数人认为谷歌在时钟上的成果可以归功于他们使用了原子钟和 GPS 时钟,但那其实仅仅是部分原因。TrueTime 实际上是这样工作的:
TrueTime 使用了两个不同的时间信号源:GPS 时钟和原子钟。这些时钟存在不同的故障模式,因此同时使用两者可以提升可靠性。
TrueTime 的 API 并不是常规型的。它会以区间的形式返回时间。因此实际时间事实上处于这个时间区间的上界和下界之间。因此,谷歌的分布式数据库 Spanner 就可以等到它确定了当前时间超过了特定时间之后才执行事务。这种方法会给系统带来一些延迟,尤其是当主机通告的不确定性很高时;但这种方法能保证正确性,即使数据库分布在全球也是如此。
使用 TrueTime 的 Spanner 组件,其中 TT.now() 会返回一个时间区间,这样 Spanner 就可以插入睡眠时间以确保当前时间已超过特定时间戳。
当当前时间的置信度下降时,Spanner 执行操作可能会耗费更多时间。因此,即使不可能获得精准的时钟,保证时钟的置信度对性能而言也是非常重要的。
延迟有很多含义
如果房间里有 10 个人,你问他们「延迟(latency)」是什么意思,你可能会得到 10 个不同的答案。在数据库中,延迟通常是指数据库延迟,而非客户端所感知到的延迟。客户端感知到的延迟包含数据库延迟和网络延迟。在调试不断恶化的问题时,分辨客户端延迟和数据库延迟是非常重要的。在收集和展示指标时,往往需要同时包含这两种延迟。
评估每个事务的性能需求
有时候,数据库会将它们的读写吞吐量和延迟作为性能优势的卖点来进行宣传。尽管这能在评估数据库的性能时从较高层面上展现主要的限制因素,但为了更全面地进行评估,需要单独分开评估各个关键操作的性能,比如每次查询或每个事务的执行性能。示例:
为具有给定约束条件的包含 5000 万行的表格 X 插入新的一行并填充相关表格时的吞吐量和延迟。
当平均好友数为 500 时,查询一个用户的好友的好友时的延迟。
当用户订阅了 500 个账号且每个小时有 X 项新输入时,检索用户时间线前 100 条记录时的延迟。
评估和实验可能包含这样的关键性案例,直到你有信心你的数据库能够满足你的性能需求。另一个类似的经验法则是在收集延迟指标和设置 SLO 时考虑这种故障情况。
在收集每个操作的指标时要注意高基数。如果你需要高基数的调试数据,请使用日志或分布式的跟踪方法。如果你想了解延迟调试方法,请参阅《Want to Debug Latency?》(https://medium.com/observability/want-to-debug-latency-7aa48ecbe8f7)。
嵌套事务可能有害
并非每个数据库都支持嵌套事务(nested transactions),但如果支持,那么嵌套事务可能导致出人意料的程序设计错误,而且这种错误往往不易识别,直到出现了明显异常才能看清。
如果你想要避免嵌套事务,则可以使用客户端软件库来检测和避免嵌套事务。如果你不能避免嵌套事务,则必须注意不要出现意料之外的情况,即当提交的事务因为子事务而被意外抛弃时。
如果将事务封装在不同的层中,可能会出现出人意料的嵌套事务案例,而从可读性角度来看,其意图可能将变得难以理解。看看下面的程序:
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...
上面的事务不管最终结果究竟如何,在每次运行时都会增加序列号。如果因为网络问题而导致提交失败,则在第二次重试时会使用不同的序列号进行查询。
查询计划器能提供有关数据库的一切信息
查询计划器(query planner)决定了查询在数据库中的执行方式。它们还会在运行之前分析和优化这些查询。计划器仅能基于其拥有的信号提供某些可能的估计。如何确定找到以下查询的结果的方法:
SELECT * FROM articles where author = "rakyll" order by title;
检索结果的方法有两种:
全表扫描:我们可以遍历表中的每一项,然后返回作者名匹配的文章,然后再执行排序。
索引扫描:我们可以使用索引来查找匹配的 ID,检索这些行,再执行排序。
查询计划器的作用是确定哪种策略是最佳选择。不过对于哪些可以预测,哪些可能导致糟糕的决策,查询计划器仅有有限的信号。数据库管理员(DBA)或开发者可使用它们来诊断和优化表现较差的查询。当数据库升级时,如果新版本的数据库出现了性能问题,那么这个数据库可以调节查询计划器并进行自我诊断。慢查询日志、延迟问题或关于执行时间的统计信息等报告可用于确定需要优化的查询。
查询计划器提供的某些指标可能具有较多噪声,尤其是当估计延迟或 CPU 时间时。作为对查询计划器的补充,跟踪和执行路径工具对诊断这些问题而言可能会更加有用,不过并非每个数据库都会提供这样的工具。
在线迁移可能很复杂,但却可以实现
在线或实时迁移的意思是在不停机且不损害数据正确性的同时从一个数据库迁移到另一个数据库。如果是迁移到同样的数据库 / 引擎,在线迁移会更为简单;但如果是迁移到性能特性和组织结构要求不同的新数据库,那情况会复杂得多。
在线迁移有多种模式,下面介绍其中一种:
开始向两个数据库执行双写入(dual writes)。在这一阶段,新数据库还不包含所有数据,但将开始看到新数据。一旦这一步得到了保证,你就可以进入下一步了。
让读取路径可同时使用这两个数据库。
主要使用新数据库来进行读取和写入。
停止向旧数据库写入,但继续保持从旧数据库读取。此时,新数据库仍未包含所有新数据,而在获取旧记录时,可能还需要回退至旧数据库。
这时候,旧数据库处于只读状态。从旧数据库取出新数据库缺失的值对新数据库进行回填。迁移完成后,所有的读取和写入路径都将使用新数据库,旧数据库则从系统中移除。
如果你需要更具体的案例,可以看看 Stripe 的遵循这一模式的迁移策略:https://stripe.com/blog/online-migrations
数据库显著增长时会引入不可预测性
数据库增长会让你遭遇不可预测的扩展问题。我们对自己数据库的内部情况越了解,可能就越难预测它们的扩展情况,还有些事情是我们无法预测的。
在数据库增大时,之前关于数据规模和网络容量需求的假设和预期都将变得过时。这时候,为了避免中断,需要大规模地重写组织结构、大规模地改进运营、解决容量问题、重新考虑部署方案或迁移到其它数据库。
不要以为了解你当前数据库的内部情况就万无一失了,规模扩大还会带来新的未知。无法预测的热点、数据不平衡的分布、意料之外的容量和硬件问题、不断增长的流量和新的网络分区都会让你重新考虑你的数据库、数据模型、部署模型和部署规模。
原文链接:https://medium.com/@rakyll/things-i-wished-more-developers-knew-about-databases-2d0178464f78