曾经有一份真挚的数据库知识摆在面前,没有好好珍惜,删数据库的时候后悔了。世界上最悲哀的事情莫过于此。如果再有机会再来,我一定会仔细阅读这篇文章,收藏起来分享给有需要的人。大多数计算机系统都是有状态的,并且可能依赖于存储系统。我对数据库的了解随着时间的推移而增长,但代价是我们的设计错误导致数据丢失和中断。在一个数据量很大的系统中,数据库是系统设计目标的核心。虽然开发人员不可能对数据库一无所知,但他们预见和经历的问题往往只是冰山一角。在这篇文章中,我将分享一些对不擅长数据库领域的开发人员非常有用的见解。如果网络在99.999%的时间内都能正常工作,那你就走运了。今天,虽然网络被认为是可靠的,但由于网络中断导致的系统停机是很常见的。这方面的研究不多,而且通常由使用专用网络、定制硬件和专职人员的大公司主导。Google服务的可用性是99.999%,他们声称Spanner(Google的分布式数据库)只有7.6%的问题是由网络引起的,尽管他们一直认为私有网络是其可用性背后的核心支撑。2014年,Bailis和Kingsbury的一份调查报告挑战了PeterDeutsch在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提供了更多现有并发模型的介绍。Google的Spanner保证了时钟同步的外部序列化,尽管这是一个更严格的隔离级别,但在标准隔离级别中并没有定义。SQL标准中提到的隔离级别是:序列化(最严格,最昂贵):序列化执行与事务的序列化执行具有相同的效果。串行执行意味着每个事务在下一个事务开始之前执行到完成。注意,由于解释上的差异,序列化常常被实现为“快照隔离”(例如Oracle),但SQL标准中并没有“快照隔离”。可重复读:当前事务中未提交的读对当前事务是可见的,但是其他事务所做的改变(比如新插入的行)是不可见的。ReadCommitted:未提交的读取对事务不可见。只有提交的写入是可见的,但可能会出现幻读。如果另一个事务插入并提交新行,则当前事务可以在查询时看到它们。ReadUncommitted(leaststrict,lowestcost):允许脏读,事务可以看到其他事务未提交的变化。事实上,这个级别对于返回近似聚合很有用,例如COUNT(*)查询。序列化级别最大限度地减少了数据竞争的机会,尽管它是最昂贵的并且向系统引入了最多的争用。其他隔离级别的开销较小,但会增加数据竞争的可能性。有些数据库允许设置隔离级别,有些数据库不一定支持所有的隔离级别。各种数据库对隔离级别的支持使用乐观锁使用数据库锁的成本非常高,不仅会引入更多的争用,而且还需要应用服务器和数据库之间有稳定的连接。独占锁更容易受到网络分区的影响,并可能导致难以识别和解决的死锁。这种情况下可以考虑乐观锁。乐观锁是指在读取一行数据时,记下它的版本号、最新修改的时间戳或校验和。然后,您可以在修改记录之前检查版本是否已更改。UPDATEproductsSETname='Telegraphreceiver',version=2WHEREid=1ANDversion=1如果之前的更新操作修改了产品表,当前的更新操作将不会修改任何数据。如果之前没有修改过,当前更新操作将修改一行数据。除了脏读和数据丢失之外,还有其他异常。在讨论数据一致性时,我们关注可能导致脏读和数据丢失的竞争条件。但除了这些,我们还需要关注异常数据。此类异常的一个示例是写入偏斜。写偏斜不会发生在写操作过程中出现脏读或数据丢失时,而是发生在违反数据的逻辑约束时。例如,假设您有一个监控应用程序需要至少一名操作员随时待命。对于上面的情况,如果两个事务提交成功,就会出现写倾斜。即使没有发生脏读或数据丢失,数据的完整性也会丢失,因为分配了两个人值班。序列化隔离级别、模式设计或数据库约束可能有助于消除写入偏差。开发人员需要在开发过程中识别这些异常,以避免在生产中出现这种问题。话虽如此,直接从代码中识别写入偏斜是非常困难的。尤其是在大型系统中,不同的团队使用相同的表而不相互通信并检查数据的访问方式,发现问题要困难得多。顺序问题数据库提供的核心特性之一是顺序保证,但这也是应用程序开发人员感到惊讶的地方。数据库按照事务的接收顺序对事务进行排序,而不是按照它们在代码中写入的顺序进行排序。事务执行的顺序很难预测,尤其是在大规模并发系统中。在开发过程中,尤其是使用非阻塞开发库时,可读性差会导致用户认为事务是顺序执行的问题,但事务可能以任意顺序到达数据库。下面的代码看起来是按顺序调用T1和T2,但如果函数是非阻塞的并立即返回promises,则调用的实际顺序将由它们到达数据库的时间决定。result1=T1()//returnsapromiseresult2=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时钟提供准确的时间,但它们的时间是由辅助服务器广播到其他机器的。这意味着每台机器都将偏离实际时间一些数量级。应用程序和数据库通常位于不同的机器上。不仅分布在多台机器上的数据库节点时间不能一致,应用服务器时钟和数据库节点时钟也不能一致。Google的TrueTime采用不同的方法。大多数人将谷歌在时钟方面的进步归功于他们使用原子钟和GPS时钟,但这只是故事的一部分。TrueTime实际上做了这些事情:TrueTime使用两个不同的来源:GPS和原子钟。这些时钟具有不同的故障模式,因此一起使用它们可以提高可靠性。TrueTime有一个非常规的API,它以间隔的形式返回时间,其中时间可以是下限和上限之间的任意点。Google的分布式数据库Spanner可以等待,直到它确定当前时间超过某个时间。Spanner组件使用TrueTime,TT.now()返回一个时间间隔,Spanner可以休眠以确保当前时间已经过了特定的时间戳。延迟并不像看起来那么简单如果您在一个房间里问10个人“延迟”是什么意思,他们很可能会有不同的答案。在数据库中,延迟通常是指“数据库延迟”,而不是客户端感知的延迟。客户端可以看到数据库延迟和网络延迟。调试问题时,能够识别客户端延迟和数据库延迟非常重要。在收集和显示指标时始终将两者都考虑在内。评估每个事务的性能要求有时,数据库会根据读写吞吐量和延迟来说明其性能特征和限制。但是在评估数据库性能时,评估每个关键操作(查询或事务)会更全面。例如:向表X(已有5000万行)中插入新行并更新相关表。此时写入吞吐量和延迟是多少?当平均好友数为500时,查询用户好友的好友的延迟是多少?当用户订阅了500个帐户(每小时X次更新)时,查询用户时间线的前100条记录的延迟是多少?性能评估可能包括这些情况,直到您确信数据库可以满足您的性能要求。收集指标时,请注意高基数。如果您需要高基数调试数据,请使用日志,甚至分布式跟踪信息。嵌套事务有风险并非每个数据库都支持嵌套事务。嵌套事务可能会导致意外的编程错误,这些错误在抛出异常之前不易识别。可以在客户端检测和避免嵌套事务。如果无法避免,则必须注意避免出现已提交的事务由于子事务而意外中止的意外情况。在不同层封装事务可能会导致意外的嵌套事务,从可读性的角度来看,可能很难理解其意图。看看下面的例子:withnewTransaction():Accounts.create("609-543-222")withnewTransaction():Accounts.create("775-988-322")throwRollback();这段代码的结果是什么?它是回滚两个事务还是仅回滚内部事务?如果我们使用多层库封装交易会怎样?我们能否识别并改善这种情况?假设一个数据层在一个事务中实现了多个操作(比如newAccount),那么当你在业务逻辑中将它们运行在一个事务中时会发生什么?此时的隔离性和一致性特征是什么?functionnewAccount(idstring){withnewTransaction():Accounts.create(id)}与其处理这种问题,还不如避免使用嵌套事务。数据层仍然可以实现自己的操作,但不创建事务。然后业务逻辑可以启动、执行、提交或中止事务。functionnewAccount(idstring){Accounts.create(id)}//在主程序中:withnewTransaction()://从数据库中读取一些配置数据//调用ID服务生成IDAccounts.create(id)Uploads.create(id)//创建用户上传队列事务不应该依赖应用状态应用开发者可能会在事务中使用应用状态来更新一些值或者设置查询参数,此时要注意作用域。当网络出现问题时,客户端通常会重试事务。如果交易依赖的状态在别处被修改,就会使用错误的值。varseqint64withnewTransaction():newSeq:=atomic.Increment(&seq)Entries.query(newSeq)//其他操作无论最终结果如何,上述事务每次运行都会自增序号。如果由于网络原因提交失败,则在第二次重试时将使用不同的序列号查询。查询计划的作用查询计划决定了数据库将如何执行查询。他们还在执行查询之前分析和优化查询。查询计划只能根据某些信号提供一些可能的估计。例如,以下查询:SELECT*FROMarticleswhereauthor="rakyll"orderbytitle;获取结果有两种方式:全表扫描:我们可以遍历表中的每条记录,返回符合作者姓名的文章,然后按标题排序。索引扫描:我们可以使用索引来查找匹配的ID,获取这些行,并对它们进行排序。查询计划的作用是确定最佳执行策略。但是可用于预测的信号是有限的,因此可能导致错误的决策。DBA或开发人员可以使用它们来诊断和调整性能不佳的查询。慢速查询日志、延迟问题或执行时间统计信息可用于识别需要优化的查询。查询计划提供的一些指标可能不是很准确,尤其是在估计延迟或CPU时间方面。补充查询计划、跟踪和执行路径工具在诊断这些问题时更有用,但并非每个数据库都提供这些工具。热迁移复杂,但有迹可循热迁移或实时迁移是在不停机或不影响数据完整性的情况下从一个数据库迁移到另一个数据库。如果您要迁移到相同的数据库或引擎,实时迁移会更容易,但迁移到具有不同性能特征和模式要求的新数据库要复杂得多。实时迁移需要遵循一些模式:在两个数据库上执行双写操作。在这个阶段,新数据库不包含所有数据,而是新数据。确保此步骤安全后,您可以继续进行第二步。针对两个数据库启用查询路径。让新数据库完成大部分读写。对旧数据库的写入已停止,但可以继续从旧数据库读取数据。此时,新数据库还没有包含所有数据,要读取旧数据,仍然需要从旧数据库中获取。此时,旧数据库是只读的。用旧数据库中的数据填充新数据库中缺失的数据。迁移完成后,所有读写路径都可以使用新的数据库,旧的数据库可以从系统中移除。数据库规模增长带来的不可预测性数据库的增长会带来不可预测的可伸缩性问题。随着数据库的增长,之前关于数据大小和网络容量的假设或预期可能会变得过时,例如大型方案重构、大规模运维改进、容量问题、部署计划变更或迁移到其他数据库以避免停机。不要假设了解数据库的内部结构就足够了,因为可伸缩性会引入新的未知数。不可预测的数据热点、不均匀的数据分布、意外的容量和硬件问题、不断增长的流量和新的网络分区都迫使您重新考虑您的数据库、数据模型、部署模型和部署规模。
