曾经有一份真挚的数据库知识摆在我的面前,我没有珍惜,等到删库时才后悔莫及。人世间最悲痛的事莫过于此。如果再给我一次重来的机会,我一定会好好读这篇文章,并把它收藏、分享给有需要的人。
大多数计算机系统都是有状态的,并且可能会依赖存储系统。随着时间的推移,我对数据库的了解程度不断加深,这是以我们的设计错误导致数据丢失和中断为代价。在数据量很大的系统中,数据库是系统设计目标的核心。尽管开发人员不可能对数据库一无所知,但他们所预见和所经历的问题往往只是冰山一角。在本文中,我将分享一些见解,这些见解对于不擅长数据库领域的开发人员来说非常有用。
如果在 99.999% 的时间里网络不出问题,那你很幸运
现如今,一方面人们认为网络很可靠,一方面由于网络中断而导致系统宕机的情况却又很普遍。这方面的研究工作并不多,而且通常由大公司主导,而这些公司使用了配备定制硬件的专用网络和专门的工作人员。
谷歌服务的可用性为 99.999%,他们声称只有 7.6% 的 Spanner(谷歌的分布式数据库) 问题是因为网络导致的,尽管他们一直认为专用网络是其可用性背后的核心支撑。2014 年,Bailis 和 Kingsbury 的一份调查报告对 Peter Deutsch 在 1994 年提出的分布式计算谬论之一提出了挑战——网络真的可靠吗?
我们无法进行全面的调查,供应商们也不会提供足够的数据来说明有多少客户的问题是因为网络导致的。我们经常会遭遇大型云供应商网络发生宕机,导致部分网络瘫痪数小时,这些事件有大量可见的受影响客户,还有很多是我们看不到的。网络中断可能会影响到更多方面,尽管并非所有事件都产生了很大的影响。云计算客户也不一定能看到这些问题所在。当问题出现时,他们不太可能认为与供应商的网络错误有关。对他们来说,第三方服务就是黑盒。如果你不是供应商,要估计出真实的影响程度是不太可能的。
与供应商的报告相比,如果你的系统只有一小部分宕机与网络问题有关,那你是幸运的。网络仍然受传统问题的影响,比如硬件故障、拓扑变更、管理配置变更和电源故障。但最近我才知道,一些新发现的问题(比如鲨鱼咬断海底光缆)也成了主要影响因素。
ACID 没有表面看上去的那么简单
ACID 代表原子性、一致性、隔离性和持久性。即使在发生崩溃、错误、硬件故障等类似事件时,数据库也需要保证这些属性是有效的。大多数关系型事务数据库都尽量提供 ACID 保证,但很多 NoSQL 数据库是没有 ACID 事务保证的,因为实现成本很高。
在我刚进入这个行业时,我们的技术主管怀疑 ACID 是不是一个过时的概念。可以说,ACID 被认为是一个种泛泛而谈的概念,而不是一个严格的执行标准。现在,我发现它非常有用,因为它提供了一类问题和一类潜在的解决方案。
并不是每个数据库都兼容 ACID,而且在兼容 ACID 的数据库当中,对 ACID 的解释也可能存在差异。之所以存在差异,其中一个原因是在实现 ACID 时涉及的权衡程度的不同。数据库可能宣称自己兼容 ACID,但对于一些边缘情况,或者在面对“不太可能”出现的问题时,处理方式有所不同。
MongoDB 的 ACID 表现一直饱受争议,即使是在发布了 v4 版本之后。MongoDB 在很长一段时间内都不支持日志记录。对于下面这种情况,应用程序进行了两次写操作 (w1 和 w2),MongoDB 能够持久化 w1,但因为发生硬件故障,导致无法持久化 w2。
MongoDB 在将数据写入物理磁盘之前发生崩溃,造成数据丢失
将数据提交到磁盘是一个开销很大的过程,它们声称写入性能良好,却是以避免频繁提交数据为代价,从而牺牲了持久性。现在,MongoDB 有了日志记录,但脏写仍然会影响数据的持久性,因为默认情况下每 100 毫秒才提交一次日志。即使风险大大降低,日记记录的持久性和变更仍然有可能出现同样的问题。
不同的数据库有不同的一致性和隔离能力
在 ACID 这几个属性中,一致性和隔离级别的实现方式是最多的,因为权衡范围最大。为了保持数据一致性,数据库需要进行协调,争用资源的情况会增加。当需要在多个数据中心之间进行水平伸缩时 (特别是在不同的地理区域之间),就变得非常困难。随着可用性的降低和网络分区的频繁出现,提供高水平的一致性是非常困难的。关于这一问题的深入解释,请参见 CAP 定理。不过需要注意的是,应用程序可以在数据一致性方面做一些处理,或者程序员可能对这个问题有足够的了解,可以在应用程序中添加额外的逻辑来处理,而不是严重依赖数据库。
数据库通常会提供各种隔离级别,应用程序开发人员可以根据权衡选择最经济有效的隔离级别。较弱的隔离级别可能速度更快,但可能会引入数据竞态问题。更强的隔离级别消除了一些潜在的数据竞态问题,但速度较慢,并且可能会引入资源争用,使数据库慢到宕机。
现有并发模型及其之间关系的概览
SQL 标准只定义了 4 个隔离级别,尽管在理论方面和实际当中都还有更多可用的级别。如果你想进一步了解,jepson.io 提供了更多对现有并发模型的介绍。谷歌的 Spanner 保证了时钟同步的外部串行性,即使这是一个更严格的隔离级别,但它在标准隔离级别中并没有定义。
SQL 标准中提到的隔离级别是:
- 串行化 (最严格、成本最高):串行化执行的效果与事务的串行执行是一样的。串行执行是指每个事务在下一个事务开始之前执行完成。需要注意的是,由于在解释上的差异,串行化通常被实现成“快照隔离”(例如 Oracle),但在 SQL 标准中并没有“快照隔离”。
- 可重复读:当前事务中未提交的读取对当前事务可见,但其他事务所做的更改 (如新插入的行) 不可见。
- 读已提交:未提交的读取对事务不可见。只有提交的写是可见的,但可能会发生幻读取。如果另一个事务插入和提交新行,当前事务在查询时可以看到它们。
- 读未提交 (最不严格、成本最低):允许脏读,事务可以看到其他事务未提交的更改。实际上,这个级别对于返回近似聚合很有用,比如 COUNT(*) 查询。
串行化级别将发生数据竞争的机会降到最低,尽管它的开销最大,并给系统带来了最多的争用。其他隔离级别开销较小,但增加了数据竞争的可能性。有些数据库允许设置隔离级别,有些数据库不一定支持所有的隔离级别。
各种数据库对隔离级别的支持情况
使用乐观锁
使用数据库锁的成本是非常高的,它们不仅引入了更多的争用,而且要求应用程序服务器和数据库之间保持稳定的连接。排它锁受网络分区的影响更大,并会导致难以识别和解决的死锁。在这种情况下,可以考虑使用乐观锁。
乐观锁是指在读取一行数据时,记下它的版本号、最近修改的时间戳或校验和。然后,你可以在修改记录之前检查版本有没有发生变化。
- UPDATE products
- SET name = 'Telegraph receiver', version = 2
- WHERE id = 1 AND version = 1
如果之前有一个更新操作修改了 products 表,那么当前的更新操作将不修改任何数据。如果之前没有被修改,当前的更新操作将修改一行数据。
除了脏读和数据丢失之外,还有其他异常
在讨论数据一致性时,我们主要关注可能会导致脏读和数据丢失的竞态条件。但除了这些,我们还要注意异常数据。
这类异常的一个例子是写倾斜(write skew)。写倾斜并不是在进行写操作时发生脏读或数据丢失时出现的,而是在数据的逻辑约束被破坏时出现的。
例如,假设有一个监控应用程序,要求至少有一个运维人员可以随叫随到。
对于上述情况,如果两个事务成功提交,就会出现写倾斜。即使没有发生脏读或数据丢失,数据的完整性也会丢失,因为有两个人被指派随叫随到。
串行化化隔离级别、模式设计或数据库约束可能有助于消除写倾斜。开发人员需要在开发期间识别出这些异常,避免在生产环境中出现这个问题。话虽如此,直接从代码中识别出写倾斜是非常困难的。特别是在大型的系统中,如果不同的团队使用相同的表,但没有相互沟通,也没有检查如何访问数据,就更难发现问题了。
顺序问题
数据库提供的核心功能之一是顺序保证,但这也是让应用程序开发人员感到惊讶的一个地方。数据库按照接收事务的顺序来安排顺序,而不是按照代码中所写的事务顺序来安排顺序。事务执行的顺序很难预测,特别是在大规模并发系统中。
在开发过程中,特别是在使用非阻塞开发库时,糟糕的可读性可能会导致出现这样的问题:用户认为事务是按顺序执行的,但事务可能以任意顺序到达数据库。下面的代码看起来像是要顺序地调用 T1 和 T2,但如果这些函数是非阻塞的,并且会立即返回 promise,那么实际的调用顺序将由它们到达数据库的时间决定。
- result1 = T1() // 返回的是promise
- result2 = T2()
如果原子性是必需的 (完全提交或中止所有操作),而且顺序很重要,那么 T1 和 T2 应该包含在单个数据库事务中。
应用程序级别的分片可在应用程序之外进行
分片是对数据库进行水平分区的一种方法。尽管有些数据库可以自动对数据进行水平分区,但有些数据库不会这么做,或者可能不擅长这么做。当数据架构师或开发人员能够预测数据的访问模式时,他们可能会在用户端进行水平分区,而不是在数据库端,这叫作应用程序级别的分片。
“应用程序级别的分片”通常给人一种错误的印象,即认为分片应该存在于应用程序中。实际上,分片功能可以作为数据库前面的一个层。随着数据增长和模式的迭代,分片需求可能会变得越来越复杂。
一个应用服务器与分片服务分离的示例架构
将分片作为单独的服务,可以在不重新部署应用程序的前提下提升分片策略的迭代能力。Vitess 是这方面的一个很好的例子。Vitess 为 MySQL 提供了水平分片能力,客户端可以通过 MySQL 协议连接到 Vitess,Vitess 会在各个 MySQL 节点上对数据进行分片。
https://youtu.be/OCS45iy5v1M?t=204
自动递增 ID 有“毒”
自动递增是生成主键的常用方法。使用数据库作为 ID 生成器,并在数据库中创建带有 ID 生成的表,这种情况并不少见。但是,通过自动递增生成主键可能不是理想的方法,原因如下:
- 在分布式数据库系统中,自动递增是一个难题。你需要一个全局锁来生成 ID,但如果可以生成 UUID,就不需要协调数据库节点。使用带锁的自动递增可能会引入争用,并且可能会显著降低分布式写入性能。像 MySQL 这样的数据库可能需要特定的配置,并且要保证主主复制的正确性。但是,配置很容易出错,并可能导致写入中断。
- 一些数据库有基于主键的分区算法。顺序 ID 可能会导致不可预测的热点,导致某些分区数据量过大,而其他分区处于空闲状态。
- 访问数据库最快方法是使用主键。如果你使用了其他列来标识记录,那么顺序 ID 可能会变得毫无意义。所以,请尽可能选择一个全局唯一的自然主键 (例如用户名)。
在决定哪种方法更适合自己之前,请考虑一下自动递增 ID 与 UUID 对索引、分区和分片的影响。
无锁的陈旧数据很有用
多版本并发控制 (MVCC) 可以支持上述的很多一致性方面的能力。一些数据库 (如 Postgres、Spanner) 借助 MVCC 让每个事务可以查看快照,即数据库的旧版本。这些事务可以串行化,以此来保持一致性。从旧快照读取数据时,读取的是陈旧的数据。
读取稍微陈旧一点的数据也是很有用的,例如,基于数据生成分析报告或计算近似聚合值。
读取陈旧数据的第一个好处是延时 (特别是当数据库分布在不同的地理区域时)。MVCC 数据库的第二个优点是它允许只读事务是无锁的。如果读取陈旧数据是可接受的,那么对于偏重读取很大的应用程序来说,这就是一个主要的优点。
应用服务器从本地副本读取 5 秒前的陈旧数据,即使在太平洋的另一端有可用的最新版本
数据库会自动清除旧版本,在某些情况下,它们允许按需进行清理。例如,Postgres 允许用户按需清理,或者每隔一段时间自动清理一次,而 Spanner 则使用垃圾回收器来清除超过一小时的陈旧数据。
任何与时钟有关的资源之间都会发生时钟倾斜
计算系统最隐秘的秘密是所有的时间 API 都会“撒谎”。计算机无法准确地知道当前时间,它们都有一个石英晶体,会产生计时信号,但石英晶体无法准确地计时,不是比实际时钟快就是比实际时钟慢。每天出现的时间漂移最多可长达 20 秒。为了准确起见,计算机上的时间需要时不时地与实际时间同步。
NTP 服务器用于同步时间,但同步本身可能会因为网络而出现延迟。在同一个数据中心中进行 NTP 服务器同步需要花费一点时间,而与公共 NTP 服务器同步有可能出现更大的倾斜。
原子时钟和 GPS 时钟是用来确定当前时间更好的一种来源,但它们昂贵,而且需要复杂的设置,无法在每台机器上安装。考虑到这些限制,数据中心使用了多层方法。虽然原子时钟和 GPS 时钟提供了准确的时间,但它们的时间是通过辅助服务器广播到其他的机器上的。这意味着每台机器都会与实际的时间发生某种量级的倾斜。
应用程序和数据库通常位于不同的机器上,不仅分布在多台机器上的数据库节点无法就时间达成一致,应用服务器时钟和数据库节点时钟也无法达成一致。
谷歌的 TrueTime 采用了不同的方法。大多数人认为谷歌在时钟方面的进步要归功于他们使用了原子时钟和 GPS 时钟,但这只是其中的部分原因。TrueTime 实际上做了这些事情:
- TrueTime 使用两种不同的来源:GPS 和原子时钟。这些时钟有不同的故障模式,因此同时使用它们提高了可靠性。
- TrueTime 有一个非常规的 API,它以间隔的形式返回时间,时间可以是下限和上限之间的任意点。谷歌的分布式数据库 Spanner 可以等待,直到确定当前时间超过了特定时间。
Spanner 组件使用了 TrueTime,TT.now() 返回一个时间间隔,Spanner 可以进行 sleep,以确保当前时间已经通过了一个特定的时间戳。
延迟没有看上去的那么简单
如果你在一个房间里问 10 个人“延迟”是什么意思,他们可能会有不同的答案。在数据库中,延迟通常是指“数据库延迟”,而不是客户端所感知到的延迟。客户端可以看到数据库的延迟和网络延迟。在调试问题时,能够识别客户端延迟和数据库延迟是非常重要的。在收集和显示指标时,始终都要考虑到两者。
评估每个事务的性能需求
有时候,数据库会说明它们在读写吞吐量和延迟方面的性能特征和限制。但在评估数据库性能时,更全面的做法是对每一个关键操作 (查询或事务) 进行评估。例如:
- 往一张表 X(已经有 5 千万行记录)插入新行,并更新相关表,此时的写入吞吐量和延迟是怎么样的?
- 当平均朋友数量为 500 人时,查询某个用户的朋友的朋友,此时的延迟是怎样的?
- 当用户订阅了 500 个帐号 (每小时有 X 项更新) 时,查询用户时间轴的前 100 条记录,此时的延迟是怎样的?
性能评估可能包含了这些情况,直到你确信数据库能够满足你的性能需求为止。
在收集指标时,要小心高基数。如果你需要高基数调试数据,请使用日志,甚至是分布式跟踪信息。
嵌套事务有风险
并不是每一种数据库都支持嵌套事务。嵌套事务可能会导致意外的编程错误,这些错误不容易识别,直到抛出异常。
嵌套事务可以在客户端检测和避免。如果无法避免,就要注意避免出现意外情况,即已提交的事务由于子事务而意外中止。
在不同的层封装事务可能会出现意外的嵌套事务,而从可读性角度来看,可能很难理解其意图。看看下面这个例子:
- 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)
- }
- // 在主程序中:
- with newTransaction():
- // 从数据库读取一些配置数据
- // 调用ID服务生成ID
- Accounts.create(id)
- Uploads.create(id) // 创建用户上传队列
事务不应该依赖应用程序状态
应用程序开发人员可能会在事务中使用应用程序状态来更新某些值或设置查询参数,这个时候要注意作用域。当发生网络问题时,客户端经常会重试事务。如果事务依赖的状态在其他地方被修改,就使用了错误的值。
- var seq int64
- with newTransaction():
- newSeq := atomic.Increment(&seq)
- Entries.query(newSeq)
- // 其他操作
无论最终结果如何,上面的事务每次运行时都会增加序列号。如果由于网络原因提交失败,在第二次重试时,它将使用不同的序列号进行查询。
查询计划的作用
查询计划决定了数据库将会如何执行查询。它们还会在执行查询之前对其进行分析和优化。查询计划只能根据某些信号提供一些可能的估计。例如下面这个查询:
- SELECT * FROM articles where author = "rakyll" order by title;
获取结果有两种方法:
- 全表扫描:我们可以遍历表中的每条记录,并返回与作者姓名匹配的文章,然后根据标题排序。
- 索引扫描:我们可以使用一个索引来查找匹配的 ID,获取这些行,然后排序。
查询计划的作用是确定最佳执行策略。但可用于预测的信号是有限的,因此可能会导致做出错误的决策。DBA 或开发人员可以用它们来诊断和调优性能较差的查询。慢查询日志、延迟问题或执行时间统计信息可用于识别需要优化的查询。
查询计划提供的一些度量可能不会很准确,特别是在估计延迟或 CPU 时间方面。作为查询计划的补充,跟踪和执行路径工具在诊断这些问题方面更有用,但并不是每种数据库都会提供这些工具。
在线迁移虽复杂,但还是有迹可循
在线或实时迁移就是在不停机、不影响数据正确性的情况下从一个数据库迁移到另一个数据库。如果要迁移到同一个数据库或引擎,实时迁移会容易一些,但要迁移到具有不同性能特征和模式需求的新数据库,就要复杂得多。
在线迁移有一些可遵循的模式:
- 在两个数据库上执行双重写操作。在这个阶段,新的数据库不包含所有数据,但会包含新数据。在这一步稳妥之后,就可以进入第二步。启用针对两个数据库的查询路径。
- 让新数据库承担主要的读写任务。
- 停止对旧数据库的写入,但可以继续从旧数据库读取数据。此时,新数据库仍然不包含所有数据,要读取旧数据,仍然需要从旧数据库获得。
- 此时,旧数据库是只读的。用旧数据库中的数据填充新数据库缺失的数据。迁移完成后,所有读写路径都可以使用新数据库,旧数据库可以从系统中移除。
数据库规模增长带来的不可预测性
数据库的增长会带来不可预测的伸缩性问题。
随着数据库的增长,之前对数据大小和网络容量的假设或预期可能会过时,比如大型 scheme 重构、大规模的运维改进、容量问题、部署计划改变或迁移到其他数据库以避免宕机。
不要以为了解数据库的内部结构就足够了,因为伸缩性会带来新的未知问题。不可预测的数据热点、不均匀的数据分布、意外的容量和硬件问题、不断增长的流量和新的网络分区,这些都会迫使你重新考虑数据库、数据模型、部署模型和部署规模。