美女程序员分享数据库常见17个使用误区 - Jaana


绝大多数计算机系统都具有某种状态,并且可能依赖于存储系统。我对数据库的了解是随着时间的推移而积累的,但是在此过程中,我们的设计错误导致了数据丢失和中断。在数据繁重的系统中,数据库是系统设计目标和折衷方案的核心。
在本系列中,我将分享一些我特别发现的见解,这些见解对那些不擅长该领域的开发人员非常有用。

  1. 如果99.999%的时间网络都不成问题,您将很幸运。
  2. ACID有很多含义。
  3. 每个数据库具有不同的一致性和隔离功能。
  4. 当您无法持有锁时,可以使用乐观锁。
  5. 除了脏读和数据丢失外,还有其他异常。
  6. 我和我的数据库并不总是就顺序处理达成一致。
  7. 应用程序级分片可以存在于应用程序外部。
  8. 主键自动增加AUTOINCREMENT可能有害。
  9. 过时的数据可能有用且无锁。
  10. 任何时钟源之间都会发生时钟偏斜。
  11. 延迟有很多含义。
  12. 评估每笔交易的性能要求。
  13. 嵌套交易可能有害。
  14. 事务不应维持应用程序状态。
  15. 查询计划人员可以提供很多有关数据库的知识。
  16. 在线迁移很复杂,但可能。
  17. 数据库的显着增长带来了不可预测性。

简要翻译如下,详细点击标题见原文:
1. 如果99.999%的时间网络都不成问题,您将很幸运。
如果网络问题仅占导致中断的潜在问题的一小部分,您就很幸运。网络仍然遭受常规问题的困扰,例如硬件故障,拓扑更改,管理配置更改和电源故障。

2. ACID有很多含义
ACID代表原子性,一致性,隔离性,耐用性。这些是数据库事务需要确保其用户有效性的属性,即使发生崩溃,错误,硬件故障或类似情况也是如此。如果没有ACID或类似的合同,应用程序开发人员将无法确定他们的职责以及数据库提供的内容。大多数关系事务数据库都试图符合ACID,但是诸如NoSQL运动之类的新方法催生了许多没有ACID事务的数据库,因为它们实现起来很昂贵。
当我刚接触该行业时,我们的技术主管在争论ACID是否已过时。可以说ACID被认为是宽松的描述,而不是严格的实施标准。今天,我发现它最有用,因为它提供了一类问题(以及一类可能的解决方案)。
并非每个数据库都符合ACID,并且在符合ACID的数据库中,ACID的解释可能有所不同。实施ACID的方式不同的原因之一是实施ACID功能涉及的权衡数量。数据库可能将自己宣传为ACID,但在边缘情况或它们如何处理“不太可能”的事件时可能仍具有不同的解释。开发人员至少可以从高层次上学习数据库如何实现事物,以便对故障模式和设计权衡有一个正确的了解。
MongoDB已经拥有日志功能,但是脏写入仍然会影响数据的持久性,因为默认情况下它们每100毫秒提交一次日记。即使风险显着降低,对于日志的持久性以及这些日志中表示的更改,仍然可能存在相同的情况。

3. 每个数据库具有不同的一致性和隔离功能。
在ACID属性中,一致性和隔离性具有最大的不同实现细节范围,因为折衷范围更广。一致性和隔离性是昂贵的功能。他们需要协调并正在增加争用以保持数据的一致性。当必须在数据中心之间(尤其是在不同地理区域之间)进行水平扩展时,问题将变得更加困难。随着可用性的降低和网络分区的发生越来越频繁,提供高水平的一致性可能会非常困难。
CAP定理对此现象进行更一般的解释。还值得一提的是,应用程序可以处理一些不一致的情况,否则程序员可能对问题有足够的了解,可以在应用程序中添加其他逻辑来处理该问题,而无需过多依赖其数据库。
数据库通常提供各种隔离层,因此应用程序开发人员可以根据自身的权衡取舍最具成本效益的层。较弱的隔离可能会更快,但可能会导致数据争用。更强的隔离消除了一些潜在的数据争用,但是会更慢,并且可能引入争用,从而使数据库减慢到可能导致中断的程度。

上图是现有并发模型及其之间的关系的概述。
即使在理论上和实践上还有更多的隔离级别,SQL标准也只定义了四个隔离级别。如果需要进一步阅读,jepson.io提供了有关现有并发模型的引人注目的概述。例如,Google的Spanner通过时钟同步来保证外部可串行化,即使这是一个严格的隔离层,也没有在标准隔离层中定义。
SQL标准中提到的隔离级别为:

  • 可序列化(最严格,最昂贵):可序列化的执行与那些事务的某些串行执行产生相同的效果。串行执行是指每个事务在下一个事务开始之前执行完成的过程。关于可序列化级别的一个注意事项是,由于解释上的差异,通常将其实现为“快照隔离”(例如Oracle),并且SQL标准中未表示“快照隔离”。

  • 可重复读取:当前事务中未提交的读取对于当前事务是可见的,但其他事务(例如新插入的行)所做的更改将不可见。

  • 已提交读:未提交的读对事务不可见。只有已提交的写入是可见的,但是幻像读取可能会发生。如果另一个事务插入并提交新行,则当前事务在查询时可以看到它们。

  • 未提交读(最不严格,便宜):允许进行脏读,事务可以看到其他事务尚未提交的更改。实际上,此级别对于返回近似聚合(例如对表的COUNT(*)查询)很有用。


可序列化级别虽然成本最高且引入了最多的系统争用,但却使发生数据争用的机会最少。其他隔离级别更便宜,但增加了数据争用的可能性。有些数据库允许您设置隔离级别,有些数据库则对它们有更多的看法,而不一定支持所有数据库。
即使数据库宣传其对这些隔离级别的支持,但仔细检查它们的行为仍可能提供有关实际操作的更多见解。
Kleppmann的hermitage提供不同的并发异常的概述和数据库是否能够在特定的隔离级别来处理它。他的研究表明,数据库设计师可以如何不同地解释隔离级别。

4. 当您无法持有锁时,可以使用乐观锁。
锁不仅成本高昂,不仅因为它们会在数据库中引入更多争用,而且可能需要从应用程序服务器到数据库的一致连接。排他锁可能会更严重地受到网络分区的影响,并导致难以识别和解决的死锁。如果无法持有排他锁,则可以选择乐观锁。
乐观锁定是一种读取行,记录版本号,最近修改的时间戳或其校验和的一种方法。然后,您可以在更改记录之前检查版本是否没有原子更改。

UPDATE products
SET name = 'Telegraph receiver', version = 2 
WHERE id = 1 AND version = 1

如果另一个更新之前更改了该行,则对产品表的更新将影响0行。如果没有更早的更新,它将影响1行,并且我们可以判断我们的更新已成功。

5.除了脏读和数据丢失外,还有其他异常。
当我们谈论数据一致性时,我们主要关注可能导致竞争性读取和数据丢失的竞争状况。但是数据异常不仅限于此。
这种类型的异常的一个例子是写偏斜。写偏斜很难识别,因为我们没有积极寻找它们。当写操作发生脏读或丢失时,不会造成写偏斜,但会损害数据的逻辑约束。
例如,假设一个监视应用程序要求多个个操作者中的一个始终处于oncall状态为真。

BEGIN tx1;                      BEGIN tx2;
SELECT COUNT(*) 
FROM operators
WHERE oncall = true;
0                          SELECT COUNT(*)
                           FROM operators
                           WHERE oncall = TRUE;
                           0
UPDATE operators                UPDATE operators
SET oncall = TRUE               SET oncall = TRUE
WHERE userId = 4;               WHERE userId = 2;
COMMIT tx1;                     COMMIT tx2;

在上述情况下,如果两个事务成功提交,将存在写偏斜。即使没有发生脏读或数据丢失的情况,数据的完整性也会丢失,因为指定了两个oncall状态设置为True。
可序列化的隔离,模式设计或数据库约束可以帮助消除写偏斜。开发人员必须能够在开发过程中识别此类异常,以避免生产中出现数据异常。话虽如此,要识别代码库中的写偏斜可能非常困难。尤其是在大型系统中,如果不同的团队负责基于相同的表构建要素,而又彼此不沟通并且不检查他们如何访问数据。

6.我和我的数据库并不总是就顺序处理达成一致。
数据库提供的核心功能之一是顺序保证,但是顺序对于应用程序开发人员可能是令人惊讶的。数据库按照事务接收的顺序查看事务,而不是按开发人员看到的编程顺序查看事务。事务执行的顺序很难预测,尤其是在大量并发系统中。
在开发期间,尤其是在使用非阻塞库时,较差的样式和可读性可能会导致以下问题:用户认为事务可以顺序执行,即使它们可以以任何顺序到达数据库。下面的程序使T1和T2看起来将被顺序调用,但是如果这些函数是非阻塞的并且立即以承诺返回,则调用的顺序将取决于它们在数据库中收到的时间。

result1 = T1()//结果实际上是承诺promise
result2 = T2()

如果需要原子性(完全提交或中止所有操作)并且顺序很重要,则T1和T2中的操作应在单个数据库事务中运行。

7.应用程序级分片可以存在于应用程序外部。
片是一种对数据库进行水平分区的方法。即使某些数据库可以自动在水平方向上对数据进行分区,但有些数据库不能或可能不擅长。当数据架构师/开发人员可以预测如何访问数据时,他们可以在用户区域创建水平分区,而不是将这项工作委托给他们的数据库。这称为应用程序级分片。
应用程序级分片的名称经常给人以错误的印象,即分片应存在于应用程序服务中。分片功能可以实现为数据库前面的一层。根据数据增长和架构迭代,分片需求可能会变得复杂。能够在某些策略上进行迭代而不必重新部署应用程序服务器可能会很有用。
将分片作为单独的服务可以提高迭代分片策略的能力,而不必重新部署应用程序。应用程序级别分片系统的此类示例之一是Vitess。Vitess为MySQL提供水平分片,并允许客户端通过MySQL协议连接到它,并且将数据分片到彼此不认识的各种MySQL节点上。

8.自动增加可能有害。
有几个原因导致通过自动增量生成主键可能并不理想:

  • 在分布式数据库系统中,自动递增是一个难题。需要全局锁才能生成ID。如果可以生成UUID,则不需要数据库节点之间的任何协作。带锁的自动增量可能会引起竞争,并且可能会大大降低分布式情况下插入的性能。某些数据库(例如MySQL)可能需要特定的配置,并且需要更多的注意才能使主-主复制正常进行。该配置很容易弄乱,并可能导致写中断。

  • 一些数据库具有基于主键的分区算法。顺序ID可能会导致无法预测的热点,并且可能会使某些分区不堪重负,而另一些分区则保持空闲状态。

  • 访问数据库中行的最快方法是通过其主键。如果您有更好的方法来标识记录,则顺序ID可能会使表中最重要的列成为无意义的值。请尽可能选择一个全球唯一的自然主键(例如,用户名)。


在确定哪种方法更适合您之前,请考虑自动递增的ID与UUID对索引,分区和分片的影响。

9.过时的数据可能有用且无锁。
多版本并发控制(MVCC)支持我们上面简要讨论的许多一致性功能。一些数据库(例如Postgres,Spanner)使用MVCC来允许每个事务查看快照,这是数据库的旧版本。快照的事务仍然可以序列化以保持一致性。从旧快照读取时,您将读取过时的数据。
读取稍微陈旧的数据将很有用,例如,当您根据数据生成分析数据或计算近似的合计值时。
读取陈旧数据的第一个优点是延迟(特别是如果您的数据库分布在不同的地理区域中)。MVCC数据库的第二个优点是它将允许只读事务成为无锁的。如果可以忍受过时的数据,则是读取大量应用程序中的主要优势。
数据库会自动清除旧版本,在某些情况下,它们允许您按需执行。例如,Postgres允许用户VACUUM根据需要以及偶尔自动吸尘一次,而Spanner运行垃圾收集器以摆脱早于一个小时的版本。

10.任何时钟源之间都会发生时钟偏斜
计算中最隐秘的秘密是所有时间的API都是谎言。我们的机器无法准确知道当前时间。我们的计算机都包含一个石英晶体,该石英晶体会产生一个计时信号。但是石英晶体无法准确地滴答和漂移时间,而不是比实际时钟快或慢。每天漂移可能长达20秒。为了准确起见,我们的计算机上的时间需要不时地与实际时间同步。
NTP服务器用于同步,但是同步本身可能由于网络而延迟。与同一数据中心中的NTP服务器同步可能需要一些时间,与公用NTP服务器同步可能会导致更多偏差。
原子钟和GPS时钟是确定当前时间的更好来源,但是它们昂贵,并且需要复杂的设置,因此无法将其安装在每台机器上。考虑到这些限制,在数据中心中,使用了多层方法。当原子和/或GPS时钟提供准确的计时时,它们的时间会通过辅助服务器广播到其他机器。这意味着每台机器都会在一定程度上偏离实际当前时间。

Google的TrueTime在这里采用了不同的方法。大多数人认为Google在时钟方面的进步可以归功于他们对原子钟和GPS时钟的使用,但这只是故事的一部分。这是TrueTime的作用:

  • TrueTime使用两种不同的来源:GPS和原子钟。这些时钟具有不同的故障模式,因此同时使用它们可以提高可靠性。

  • TrueTime具有非常规的API。它以时间间隔返回时间。时间实际上可以在下限和上限之间的任何位置。然后,Google的分布式数据库Spanner可以等到确定当前时间已超出特定时间。这种方法给系统增加了一些等待时间,特别是当主机通告的不确定性很高时,即使在全局分布的情况下也可以提供正确性。

随着对当前时间的信心下降,这意味着Spanner操作可能需要更多时间。这就是为什么即使拥有精确的时钟都是不可能的,但保持对性能的高信心仍然很重要。

11. 延迟有很多含义
如果您问一个房间中的十个人“延迟”是什么意思,他们可能都有不同的答案。在数据库中,延迟通常称为“数据库延迟”,而不是客户端感知的延迟。客户端将看到数据库延迟和网络延迟的延迟。在调试不断升级的问题时,能够识别客户端和数据库延迟至关重要。收集和显示指标时,请始终考虑同时使用两者。

12.评估每笔事务交易的性能要求
有时,数据库会在写入和读取吞吐量以及延迟方面公布其性能特征和限制。尽管这可以从总体上概述主要的阻止因素,但是在评估新数据库的性能时,更全面的方法是分别评估关键操作(每个查询和/或每个事务)。例子:

  • 在具有给定约束的表X中插入新行(具有5000万行)并在相关表中填充行时,写入吞吐量和延迟。

  • 平均好友数为500时,查询用户好友的时延。

  • 当用户订阅了每小时X个条目的500个帐户时,检索用户时间轴的前100条记录的延迟。


在您确信数据库将能够满足您的性能要求之前,评估和试验可能包含此类关键情况。在收集延迟指标和设置SLO时,类似的经验法则也在考虑这种故障。
在收集每个操作的指标时,请注意高基数。如果需要高基数调试数据,请使用日志,甚至收集或分布式跟踪。请参阅要调试延迟吗?有关延迟调试方法的概述。

13.嵌套事务可能有害
并非每个数据库都支持嵌套事务,但是当嵌套数据库支持嵌套事务时,嵌套事务可能会导致令人惊讶的编程错误,这些错误通常很难被发现,除非您清楚看到异常。
如果您想避免嵌套事务,则客户端库可以进行工作来检测和避免嵌套事务。如果无法避免,则必须注意避免出现意外情况,在这种情况下,已提交的事务由于子事务而意外中止。
将事务封装在不同的层中可能会导致令人惊讶的嵌套事务案例,并且从可读性的角度来看,可能很难理解其意图。看一下以下程序:

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.

14.事务不应维持应用程序状态
应用程序开发人员可能希望在事务中使用应用程序状态来更新某些值或调整查询参数。要考虑的一件关键事情是考虑作用域/范围界限(scope)。
发生网络问题时,客户通常会重试交易。如果事务依赖于在其他地方发生了变异的状态,则它可能会选择错误的值,具体取决于问题中数据争用的可能性。事务处理应谨慎对待应用程序内数据竞争。

var seq int64
with newTransaction():
     newSeq := atomic.Increment(&seq)
     Entries.query(newSeq)
     // Other operations...

上面的事务每次运行时都会增加序列号,无论最终结果如何。如果提交由于网络而失败,则在第二次重试时,它将使用不同的序列号进行查询。

15. Query planner可以告诉你数据库情况
查询计划者确定如何在数据库中执行查询。他们还分析查询并在运行之前对其进行优化。计划者只能根据其拥有的信号提供一些可能的估计。那么如何找到以下查询的结果:

SELECT * FROM articles where author = "rakyll" order by title;

有两种方法来检索结果:

  • 全表扫描:我们可以浏览表上的每个条目,并返回与作者姓名匹配的文章,然后进行排序。

  • 索引扫描:我们可以使用索引来查找匹配的ID,检索那些行然后进行排序。


查询计划者的作用是确定哪种策略是最佳选择。
查询计划人员只能预测有限的信号,并可能导致错误的决策。DBA或开发人员可以使用它们来诊断和调整效果不佳的查询。新版本的数据库可以调整查询计划程序,如果新版本引入性能问题,则在数据库升级时进行自我诊断可以为您提供帮助。诸如慢查询日志,延迟问题或执行时间统计之类的报告对于确定要优化的查询很有用。
查询计划程序提供的某些指标可能很嘈杂,尤其是在估算延迟或CPU时间时。作为查询计划者的补充,即使不是每个数据库都提供这样的工具,跟踪和执行路径工具对于诊断这些问题可能更有用。

16. 在线迁移很复杂,但可能。
在线,实时或实时迁移意味着从一个数据库迁移到另一个数据库而不会造成停机,并且不会影响数据的正确性。如果要迁移到相同的数据库/引擎,则实时迁移会更容易,但是当迁移到具有不同性能特征和架构要求的新数据库时,实时迁移会变得更加复杂。
在线迁移有多种模式,以下是其中一种

  • 开始对两个数据库进行双重写入。在此阶段,新数据库将不会拥有所有数据,但会开始查看新数据。一旦对这一步充满信心,就可以继续进行第二步。

  • 开始启用读取路径以同时使用两个数据库。

  • 新数据库主要用于读取和写入。

  • 尽管继续从旧数据库读取数据,但请不要继续写入旧数据库。此时,新数据库仍不具有所有新数据,您可能需要回退到旧数据库以获取旧记录。

  • 此时,旧数据库是只读的。用旧数据库中缺少的数据回填新数据库。迁移完成后,所有读写路径都可以使用新数据库,并且旧数据库可以从系统中删除。


如果需要更多种姓研究,请参阅Stripe 关于该模型遵循的迁移策略的综合文章

17.数据库的显着增长带来了不可预测性
随着数据增长,以前对数据大小和网络容量要求的假设或期望可能会过时。这是大型方案重写,大规模操作改进,容量问题,部署重新考虑或迁移到其他数据库以避免中断的时候。