更多开发人员应该了解数据库这一点

笔记。 翻译。:Jaana Dogan 是 Google 的一位经验丰富的工程师,目前致力于该公司用 Go 编写的生产服务的可观察性。 在这篇深受英语读者欢迎的文章中,她收集了 17 个有关 DBMS(有时还包括一般分布式系统)的重要技术细节,这些细节对于大型/要求较高的应用程序的开发人员来说非常有用。

更多开发人员应该了解数据库这一点

绝大多数计算机系统都会跟踪其状态,因此需要某种数据存储系统。 我在很长一段时间内积累了有关数据库的知识,在此过程中犯了一些设计错误,导致了数据丢失和中断。 在处理大量信息的系统中,数据库位于系统架构的核心,并且是选择最佳解决方案的关键要素。 尽管人们对数据库的工作给予了密切关注,但应用程序开发人员试图预见的问题往往只是冰山一角。 在本系列文章中,我分享了一些对于不专门从事该领域的开发人员有用的想法。

  1. 如果 99,999% 的时间网络不会造成问题,那么您很幸运。
  2. ACID 意味着很多不同的事情。
  3. 每个数据库都有自己的机制来确保一致性和隔离性。
  4. 当难以维持正常状态时,乐观阻塞就会派上用场。
  5. 除了脏读和数据丢失之外,还有其他异常情况。
  6. 数据库和用户并不总是就行动方案达成一致。
  7. 应用程序级分片可以移至应用程序之外。
  8. 自动增量可能很危险。
  9. 过时的数据可能有用且不需要锁定。
  10. 对于任何时间源来说,失真都是典型的。
  11. 延迟有多种含义。
  12. 应针对特定交易评估性能要求。
  13. 嵌套事务可能很危险。
  14. 事务不应与应用程序状态绑定。
  15. 查询规划器可以告诉您很多有关数据库的信息。
  16. 在线迁移很困难,但也是可能的。
  17. 数据库的显着增加意味着不可预测性的增加。

我要感谢 Emmanuel Odeke、Rein Henrichs 和其他人对本文早期版本的反馈。

如果 99,999% 的时间网络不会造成问题,那么您很幸运。

问题仍然是现代网络技术的可靠性如何以及系统因网络故障而停机的频率如何。 关于这个问题的信息很少,研究往往由拥有专门网络、设备和人员的大型组织主导。

Google 声称 Spanner(Google 的全球分布式数据库)的可用性高达 99,999% 7,6% 问题与网络有关。 同时,该公司将其专业网络称为高可用性的“主要支柱”。 学习 巴利斯和金斯伯里,于 2014 年进行,挑战了“对分布式计算的误解”,Peter Deutsch 于 1994 年提出。 网络真的可靠吗?

在大公司之外,针对更广泛的互联网进行的综合研究根本不存在。 主要参与者也没有提供足够的数据来说明其客户问题中与网络相关的百分比。 我们很清楚大型云提供商的网络堆栈出现中断,这些中断可能会导致整个互联网块瘫痪几个小时,仅仅是因为它们是影响大量人和公司的引人注目的事件。 在更多情况下,网络中断可能会导致问题,即使并非所有这些情况都受到关注。 云服务的客户也不知道问题的原因。 如果出现故障,几乎不可能将其归因于服务提供商方面的网络错误。 对于他们来说,第三方服务是黑匣子。 如果不是大型服务提供商,就不可能评估其影响。

鉴于大型企业对其系统的报告,如果网络问题仅占潜在停机问题的一小部分,那么可以肯定地说您很幸运。 网络通信仍然受到硬件故障、拓扑变化、管理配置变化和断电等常见问题的影响。 最近,我惊讶地发现可能出现的问题列表被添加了 鲨鱼咬伤 (是的,你没听错)。

ACID 意味着很多不同的事情

首字母缩略词 ACID 代表原子性、一致性、隔离性、可靠性。 事务的这些属性旨在确保在发生故障、错误、硬件故障等时其有效性。 如果没有 ACID 或类似的方案,应用程序开发人员将很难区分他们负责的内容和数据库负责的内容。 大多数关系事务数据库都试图兼容 ACID,但是像 NoSQL 这样的新方法已经催生了许多没有 ACID 事务的数据库,因为它们的实施成本很高。

当我第一次进入这个行业时,我们的技术主管谈到了 ACID 概念的相关性。 公平地说,ACID被认为是一个粗略的描述而不是一个严格的实现标准。 今天,我发现它非常有用,因为它提出了特定类别的问题(并提出了一系列可能的解决方案)。

并非每个 DBMS 都符合 ACID; 同时,支持 ACID 的数据库实现对这组需求的理解也不同。 ACID 实现不完整的原因之一是为了实现 ACID 要求必须进行许多权衡。 创建者可能会将他们的数据库呈现为符合 ACID 标准,但对边缘情况的解释可能会有很大差异,处理“不太可能”事件的机制也是如此。 至少,开发人员可以对基本实现的复杂性有一个高层次的了解,从而正确理解其特殊行为和设计权衡。

即使在版本 4 发布之后,有关 MongoDB 是否符合 ACID 要求的争论仍在继续。 MongoDB 很长时间没有得到支持 记录,尽管默认情况下数据提交到磁盘的次数不超过每 60 秒一次。 想象一下以下场景:应用程序发布两个写入(w1 和 w2)。 MongoDB成功存储了w1,但是w2由于硬件故障丢失了。

更多开发人员应该了解数据库这一点
说明该场景的图表。 MongoDB 在将数据写入磁盘之前崩溃

提交到磁盘是一个昂贵的过程。 通过避免频繁提交,开发人员提高了记录性能,但牺牲了可靠性。 MongoDB 目前支持日志记录,但脏写仍然会影响数据完整性,因为默认情况下每 100 毫秒捕获一次日志。 也就是说,对于日志及其中呈现的更改,仍然可能出现类似的情况,尽管风险要低得多。

每个数据库都有自己的一致性和隔离机制

在 ACID 要求中,一致性和隔离性拥有最多的不同实现,因为权衡的范围更广。 必须要说的是,一致性和隔离性是相当昂贵的功能。 它们需要协调并增加数据一致性的竞争。 当需要跨多个数据中心水平扩展数据库时(特别是如果它们位于不同的地理区域),问题的复杂性会显着增加。 实现高水平的一致性非常困难,因为它还会降低可用性并增加网络分段。 对于这种现象的更一般的解释,我建议您参考 CAP定理。 还值得注意的是,应用程序可以处理少量的不一致,并且程序员可以很好地理解问题的细微差别,以便在应用程序中实现额外的逻辑来处理不一致,而无需严重依赖数据库来处理它。

DBMS 通常提供不同级别的隔离。 应用程序开发人员可以根据自己的喜好选择最有效的一种。 低隔离可以提高速度,但也会增加数据争用的风险。 高绝缘性降低了这种可能性,但会减慢工作速度并可能导致竞争,从而导致底座出现制动,从而开始出现故障。

更多开发人员应该了解数据库这一点
回顾现有的并发模型以及它们之间的关系

SQL 标准仅定义了四种隔离级别,尽管理论上和实践中还有更多隔离级别。 杰普森io 提供了对现有并发模型的精彩概述。 例如,Google Spanner 通过时钟同步保证外部可串行性,尽管这是一个更严格的隔离层,但标准隔离层中并未定义它。

SQL 标准提到了以下隔离级别:

  • 序列化 (最严格和昂贵):可串行化执行与某些顺序事务执行具有相同的效果。 顺序执行意味着每个后续事务仅在前一个事务完成后才开始。 需要注意的是,水平 序列化 由于解释上的差异,通常实现为所谓的快照隔离(例如,在 Oracle 中),尽管快照隔离本身并未在 SQL 标准中表示。
  • 可重复读取:当前事务中未提交的记录可供当前事务使用,但其他事务所做的更改(例如新行) 不可见.
  • 读已提交:未提交的数据不可用于事务。 在这种情况下,事务只能看到已提交的数据,可能会出现幻读。 如果事务插入并提交新行,当前事务在查询时将能够看到它们。
  • 未提交的读 (最不严格且昂贵的级别):允许脏读,事务可以看到其他事务所做的未提交的更改。 在实践中,这个级别对于粗略估计可能很有用,例如查询 COUNT(*) 在桌子上。

Уровень 序列化 最大限度地降低数据竞争的风险,同时实现成本最高,并导致系统上的竞争负载最高。 其他隔离级别更容易实现,但会增加数据争用的可能性。 某些 DBMS 允许您设置自定义隔离级别,其他 DBMS 有很强的偏好,但并非所有级别都受支持。

给定的 DBMS 中经常宣传对隔离级别的支持,但只有仔细研究其行为才能揭示实际发生的情况。

更多开发人员应该了解数据库这一点
不同DBMS不同隔离级别的并发异常回顾

马丁·克莱普曼在他的项目中 隐居 比较不同的隔离级别,讨论并发异常,以及数据库是否能够遵守特定的隔离级别。 Kleppmann 的研究表明数据库开发人员对隔离级别的看法有多么不同。

当难以维持正常状态时,乐观阻塞就会派上用场。

阻塞的成本可能非常高,不仅因为它增加了数据库中的竞争,还因为它需要应用程序服务器不断连接到数据库。 网络分段会加剧独占锁定情况,并导致难以识别和解决的死锁。 在不适合独占锁定的情况下,乐观锁定会有所帮助。

乐观锁 是一种在读取字符串时考虑其版本、校验和或上次修改时间的方法。 这允许您确保在更改条目之前没有原子版本更改:

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

在这种情况下,更新表 products 如果先前有另一个操作对此行进行了更改,则不会执行该操作。 如果没有对该行进行任何其他操作,则会发生一行的更改,我们可以说更新成功。

除了脏读和数据丢失之外还有其他异常

当谈到数据一致性时,重点是可能导致脏读和数据丢失的竞争条件。 然而,数据异常并不止于此。

这种异常现象的一个例子是录音失真 (写倾斜)。 失真很难检测,因为通常不会主动寻找它们。 它们不是由于脏读或数据丢失造成的,而是由于违反了对数据施加的逻辑约束。

例如,让我们考虑一个需要一名操作员随时待命的监控应用程序:

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;

在上述情况下,如果两个事务都成功提交,就会发生记录损坏。 虽然没有脏读或数据丢失,但数据的完整性受到了损害:现在被认为是两个人同时待命。

可序列化隔离、模式设计或数据库约束可以帮助消除写入损坏。 开发人员必须能够在开发过程中识别此类异常,以避免在生产中出现此类异常。 与此同时,在代码库中查找录音失真是极其困难的。 特别是在大型系统中,当不同的开发团队负责基于相同的表实现功能并且在数据访问的细节上不一致时。

数据库和用户并不总是就该做什么达成一致

数据库的关键特性之一是执行顺序的保证,但这个顺序本身对于软件开发人员来说可能并不透明。 数据库按照事务接收的顺序执行事务,而不是按照程序员想要的顺序。 事务的顺序很难预测,尤其是在高负载的并行系统中。

在开发过程中,特别是在使用非阻塞库时,糟糕的样式和低可读性可能会导致用户相信事务是按顺序执行的,而实际上它们可以以任何顺序到达数据库。

乍一看,在下面的程序中,T1和T2是顺序调用的,但是如果这些函数是非阻塞的并且立即以以下形式返回结果 承诺,那么调用的顺序将由它们进入数据库的时刻决定:

result1 = T1() // 真正的结果是承诺
结果2 = T2()

如果需要原子性(即,所有操作必须完成或中止)并且顺序很重要,则操作 T1 和 T2 必须在单个事务中执行。

应用程序级分片可以移到应用程序之外

分片是一种水平分区数据库的方法。 有些数据库可以自动水平分割数据,而其他数据库则不能,或者不太擅长。 当数据架构师/开发人员能够准确预测数据将如何被访问时,他们可以在用户空间中创建水平分区,而不是将这项工作委托给数据库。 这个过程称为“应用级分片” (应用级分片).

不幸的是,这个名称经常会造成一种误解,认为分片存在于应用程序服务中。 事实上,它可以作为数据库前面的一个单独的层来实现。 根据数据增长和模式迭代,分片要求可能变得相当复杂。 某些策略可能受益于无需重新部署应用程序服务器的迭代能力。

更多开发人员应该了解数据库这一点
应用服务器与分片服务分离的架构示例

将分片转移到单独的服务中可以扩展使用不同分片策略的能力,而无需重新部署应用程序。 维特斯 是这种应用级别的分片系统的一个例子。 Vitess为MySQL提供水平分片,并允许客户端通过MySQL协议连接到它。 系统将数据分段到不同的 MySQL 节点中,这些节点彼此之间一无所知。

自动增量可能很危险

自动增量是生成主键的常用方法。 通常情况下,数据库被用作 ID 生成器,并且数据库包含旨在生成标识符的表。 使用自动增量生成主键远非理想的原因有以下几个:

  • 在分布式数据库中,自增是一个严重的问题。 要生成 ID,需要全局锁。 相反,您可以生成 UUID:这不需要不同数据库节点之间的交互。 使用锁自动递增可能会导致争用,并显着降低分布式情况下插入的性能。 某些 DBMS(例如 MySQL)可能需要特殊配置和更加仔细的注意才能正确组织主主复制。 而且配置时很容易出错,导致录音失败。
  • 一些数据库具有基于主键的分区算法。 连续的 ID 可能会导致不可预测的热点,并增加某些分区的负载,而其他分区则保持空闲。
  • 主键是访问数据库中的行的最快方法。 通过更好的方法来识别记录,顺序 ID 可以将表中最重要的列变成充满无意义值的无用列。 因此,只要有可能,请选择全局唯一且自然的主键(例如用户名)。

在决定方法之前,请考虑自动递增 ID 和 UUID 对索引、分区和分片的影响。

过时的数据可能有用并且不需要锁定

多版本并发控制 (MVCC) 实现了上面简要讨论的许多一致性要求。 某些数据库(例如 Postgres、Spanner)使用 MVCC 为事务“提供”快照(数据库的旧版本)。 快照事务也可以序列化以确保一致性。 从旧快照读取时,会读取过时的数据。

读取稍微陈旧的数据可能很有用,例如,当从数据生成分析或计算近似聚合值时。

使用遗留数据的第一个优点是低延迟(特别是如果数据库分布在不同的地理位置)。 第二是只读事务是无锁的。 对于读取大量数据的应用程序来说,这是一个显着的优势,只要它们能够处理过时的数据。

更多开发人员应该了解数据库这一点
应用程序服务器从本地副本读取过期 5 秒的数据,即使太平洋另一边有最新版本

DBMS 会自动清除旧版本,并且在某些情况下,允许您根据请求执行此操作。 例如,Postgres 允许用户执行以下操作 VACUUM 根据请求,并定期自动执行此操作。 Spanner 运行垃圾收集器来删除超过一小时的快照。

任何时间源都会受到失真的影响

计算机科学中最保守的秘密是所有计时 API 都会撒谎。 事实上,我们的机器不知道当前的确切时间。 计算机包含石英晶体,可以产生振动来计时。 然而,它们不够准确,可能会提前/落后于确切时间。 每天的轮班时间可以达到20秒。 因此,我们的计算机时间必须定期与网络时间同步。

NTP 服务器用于同步,但同步过程本身会受到网络延迟的影响。 即使与同一数据中心的 NTP 服务器同步也需要一些时间。 显然,使用公共 NTP 服务器可能会导致更大的失真。

原子钟及其 GPS 同类产品更适合确定当前时间,但它们价格昂贵且需要复杂的设置,因此无法安装在每辆汽车上。 因此,数据中心使用分层方法。 原子时钟和/或 GPS 时钟显示准确的时间,然后通过辅助服务器将其广播到其他计算机。 这意味着每台机器都会经历与准确时间的一定偏移。

应用程序和数据库通常位于不同的计算机上(如果不是位于不同的数据中心),这一事实使情况更加恶化。 因此,时间不仅在分布在不同机器上的数据库节点上会有所不同。 在应用服务器上也会有所不同。

Google TrueTime 采用了完全不同的方法。 大多数人认为,谷歌在这个方向上取得的进展是通过向原子钟和 GPS 时钟的平庸过渡来解释的,但这只是全局的一部分。 TrueTime 的工作原理如下:

  • TrueTime 使用两种不同的源:GPS 和原子钟。 这些时钟具有不相关的故障模式。 [详情请参阅第5页 这里 - 大约。 译),因此它们的联合使用增加了可靠性。
  • TrueTime 有一个不寻常的 API。 它将时间作为时间间隔返回,其中包含测量误差和不确定性。 实际时刻位于区间的上限和下限之间。 Google 的分布式数据库 Spanner 只是等待,直到可以肯定地说当前时间超出范围。 这种方法会给系统带来一些延迟,特别是在主设备的不确定性很高的情况下,但即使在全局分布的情况下也能确保正确性。

更多开发人员应该了解数据库这一点
Spanner 组件使用 TrueTime,其中 TT.now() 返回一个间隔,因此 Spanner 只是休眠,直到可以确信当前时间已超过某个点

确定当前时间的准确性降低意味着 Spanner 操作的持续时间增加和性能下降。 这就是为什么即使不可能获得完全准确的手表,保持尽可能高的准确度也很重要。

延迟有很多含义

如果你询问十几位专家什么是延迟,你可能会得到不同的答案。 在 DBMS 中,延迟通常称为“数据库延迟”,与客户端感知到的延迟不同。 事实是客户端观察到网络延迟和数据库延迟之和。 在调试日益严重的问题时,隔离延迟类型的能力至关重要。 收集和显示指标时,请始终尝试关注这两种类型。

应针对特定交易评估性能要求

有时,DBMS 的性能特征及其限制是根据写/读吞吐量和延迟来指定的。 这提供了关键系统参数的总体概述,但在评估新 DBMS 的性能时,更全面的方法是单独评估关键操作(针对每个查询和/或事务)。 例子:

  • 在相关表中使用指定约束和行填充将新行插入表 X(包含 50 万行)时的写入吞吐量和延迟。
  • 当平均好友数为500时,延迟显示某用户好友的好友。
  • 当用户每小时关注 100 个其他用户 X 个条目时,从用户历史记录中检索前 500 个条目的延迟。

评估和实验可能包括此类关键情况,直到您确信数据库满足性能要求为止。 在收集延迟指标和确定 SLO 时,类似的经验法则也会考虑这种细分。

收集每个操作的指标时请注意高基数。 使用日志、事件收集或分布式跟踪来获取高功率调试数据。 在文章《想要调试延迟吗?» 您可以熟悉延迟调试方法。

嵌套事务可能很危险

并非每个 DBMS 都支持嵌套事务,但当它们这样做时,此类事务可能会导致意外错误,而这些错误并不总是易于检测(也就是说,存在某种异常应该是显而易见的)。

您可以使用可以检测和绕过嵌套事务的客户端库来避免使用嵌套事务。 如果嵌套事务无法放弃,则在实现时要特别小心,以避免出现意外情况,即已完成的事务因嵌套事务而意外中止。

将事务封装在不同的层中可能会导致意外的嵌套事务,并且从代码可读性的角度来看,可能会让人难以理解作者的意图。 看看下面的程序:

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 int64
with newTransaction():
    newSeq := atomic.Increment(&seq)
    Entries.query(newSeq)
    // Other operations...

上述交易每次执行时都会递增序列号,无论最终结果如何。 如果由于网络问题导致提交失败,当您重试时,请求将以不同的序列号执行。

查询规划器可以告诉您很多有关数据库的信息

查询规划器确定如何在数据库中执行查询。 他们还在发送请求之前分析请求并对其进行优化。 规划者只能根据他们掌握的信号提供一些可能的估计。 例如,以下查询的最佳搜索方法是什么?

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

可以通过两种方式检索结果:

  • 全表扫描:您可以查看表中的每个条目并返回具有匹配作者姓名的文章,然后对它们进行排序。
  • 索引扫描:您可以使用索引来查找匹配的 ID,获取这些行,然后对它们进行排序。

查询规划器的工作是确定哪种策略是最好的。 值得考虑的是,查询规划器的预测能力有限。 这可能会导致错误的决定。 DBA 或开发人员可以使用它们来诊断和微调性能不佳的查询。 新版本的DBMS可以配置查询计划器,如果新版本导致性能问题,更新数据库时可以进行自我诊断。 慢速查询日志、延迟问题报告或执行时间统计信息可以帮助识别需要优化的查询。

查询规划器提供的某些指标可能会受到噪音的影响(尤其是在估计延迟或 CPU 时间时)。 调度程序的一个很好的补充是用于跟踪和追踪执行路径的工具。 它们允许您诊断此类问题(可惜,并非所有 DBMS 都提供此类工具)。

在线迁移困难但可能

在线迁移、实时迁移或实时迁移意味着从一个数据库迁移到另一个数据库,而不会造成停机或数据损坏。 如果转换发生在同一个 DBMS/引擎内,则实时迁移会更容易执行。 当需要迁移到具有不同性能和模式要求的新 DBMS 时,情况会变得更加复杂。

有不同的在线迁移模型。 这是其中之一:

  • 在两个数据库中启用双重输入。 现阶段的新数据库还没有所有的数据,只是接受最新的数据。 一旦确定这一点,您就可以继续下一步。
  • 启用从两个数据库的读取。
  • 配置系统,以便主要在新数据库上执行读取和写入。
  • 停止写入旧数据库,同时继续从中读取数据。 现阶段,新数据库仍然缺乏一些数据。 它们应该从旧数据库复制。
  • 旧数据库是只读的。 将旧数据库中丢失的数据复制到新数据库。 迁移完成后,将路径切换到新数据库,停止旧数据库并将其从系统中删除。

如需更多信息,我建议联系 文章,其中详细介绍了 Stripe 基于此模型的迁移策略。

数据库的显着增加意味着不可预测性的增加

数据库的增长会导致与其规模相关的不可预测的问题。 我们对数据库的内部结构了解得越多,就越能预测它的扩展方式。 然而,有些时刻仍然无法预见。
随着基础的增长,之前关于数据量和网络带宽要求的假设和期望可能会变得过时。 这时就会出现重大设计检修、大规模运营改进、重新考虑部署或迁移到其他 DBMS 以避免潜在问题的问题。

但不要认为对现有数据库内部结构的深入了解是唯一必要的。 新的尺度将带来新的未知。 不可预测的痛点、不均匀的数据分布、意外的带宽和硬件问题、不断增加的流量和新的网段将迫使您重新考虑您的数据库方法、数据模型、部署模型和数据库大小。

...

当我开始考虑发表这篇文章时,我原来的清单上已经有五个项目了。 然后来了一个巨大的数字 新主意 关于还有哪些内容可以涵盖。 因此,本文涉及的是最不明显但需要最大程度关注的问题。 然而,这并不意味着这个话题已经结束,我在以后的材料中不会再回到这个话题,也不会对当前的材料做任何改变。

PS

另请阅读我们的博客:

来源: habr.com

添加评论