故障注入:如果您未尝试破坏系统,则系统将不可靠

哈Ha! 我叫帕维尔·利普斯基(Pavel Lipsky)。 我是一名工程师,我在Sberbank-Technology工作。 我的专长是测试大型分布式系统后端的容错能力和性能。 简而言之,我破坏了别人的程序。 在本文中,我将讨论故障注入-一种测试方法,该方法可让您通过创建人为故障来查找系统中的问题。 我将首先介绍如何使用此方法,然后再讨论该方法本身以及如何使用它。


本文将提供Java示例。 如果您不是用Java编程-没关系,只需了解方法本身和基本原理即可。 Apache Ignite用作数据库,但是相同的方法也适用于任何其他DBMS。 所有示例都可以从我的GitHub下载。

为什么我们需要所有这些?


我将从故事开始。 2005年,我在Rambler工作。 到那时,Rambler用户的数量迅速增长,而我们的两层体系结构“服务器-数据库-服务器-应用程序”已无法应对。 我们考虑了如何解决性能问题,并提请注意内存缓存技术。



什么是memcached? Memcached-随机存取存储器中的哈希表,可通过键访问存储的对象。 例如,您需要获取用户个人资料。 该应用程序访问memcached(2)。 如果其中有一个对象,那么它将立即返回给用户。 如果没有对象,则对数据库进行上诉(3),形成对象并将其放入memcached(4)。 然后,在下一个调用中,我们不再需要对数据库进行资源消耗大的调用-我们将从主内存memcached中获取完成的对象。

由于使用了内存缓存,我们明显卸载了数据库,并且我们的应用程序开始运行得更快。 但是,事实证明,现在还为时过早。 随着生产力的提高,我们遇到了新的挑战。



当您需要更改数据时,应用程序首先对数据库进行更正(2),创建一个新对象,然后尝试将其放入memcached(3)。 也就是说,旧对象必须替换为新对象。 想象一下,这时发生了一件可怕的事情-应用程序与memcached之间的连接断开,memcached服务器甚至应用程序本身崩溃。 因此,应用程序无法更新memcached中的数据。 结果,用户将转到该站点的页面(例如,他的个人资料),看到旧数据,并且不明白为什么会这样。

在功能测试或性能测试期间是否可以检测到此错误? 我认为很可能我们找不到他。 要搜索此类错误,有一种特殊类型的测试-故障注入。

通常在故障注入测试期间,存在一些通常称为“ 浮动”的错误。 它们出现在负载下,系统中有多个用户在工作,发生异常情况-设备故障,断电,网络故障等。

新的Sberbank IT系统


几年前,Sberbank开始构建新的IT系统。 怎么了 以下是来自中央银行网站的统计数据:



列的绿色部分是在自动柜员机上提取现金的次数,蓝色部分是用于支付商品和服务的操作数。 我们看到无现金交易的数量每年都在增长。 几年后,我们将需要能够处理不断增长的工作量并继续为客户提供新服务。 这是创建新的Sberbank IT系统的原因之一。 此外,我们希望减少对西方技术和昂贵的大型机(它们花费数百万美元)的依赖,并转而使用开源技术和低端服务器。

最初,我们在新的Sberbank架构的核心奠定了Apache Ignite技术的基础。 更准确地说,我们使用付费的Gridgain插件。 该技术具有相当丰富的功能:它结合了关系数据库(支持SQL查询),NoSQL,分布式处理以及RAM中数据存储的属性。 此外,当您重新启动时,RAM中的数据将不会在任何地方丢失。 从2.1版开始,Apache Ignite分发了具有SQL支持的Apache Ignite持久数据存储。

我将列出该技术的一些功能:

  • RAM中的存储和数据处理
  • 磁盘存储
  • SQL支持
  • 分布式任务执行
  • 水平缩放

该技术相对较新,因此需要特别注意。

Sberbank的新IT系统实际上由组装在单个云集群中的许多相对较小的服务器组成。 所有节点在结构上相同,对等,执行存储和处理数据的功能。

集群内部分为所谓的单元。 一个像元是8个节点。 每个数据中心有4个节点。


由于我们使用Apache Ignite内存数据网格,因此,所有这些都存储在服务器分布式缓存中。 此外,高速缓存又分为相同的部分-分区。 在服务器上,它们表示为文件。 同一缓存的分区可以存储在不同的服务器上。 对于集群中的每个分区,都有主节点和备份节点。

主节点存储主分区并处理对它们的请求,将数据复制到存储备份分区的备份节点(备份节点)。

在设计Sberbank的新体系结构时,我们得出的结论是,系统组件可能会并且将会失败。 说,如果您有1000个低端铁服务器集群,那么您将不时遇到硬件故障。 RAM条,网卡和硬盘驱动器等将发生故障。 我们将认为此行为是完全正常的系统行为。 此类情况应得到正确处理,我们的客户不应注意到它们。

但是,仅设计系统的故障抵抗能力是不够的;必须在这些故障期间测试系统。 正如著名的分布式系统研究人员Microsoft Research的Caitie McCaffrey所说:“在重现故障之前,您永远不会知道系统在意外故障期间的行为。”

丢失的更新


让我们举一个简单的例子,一个模拟货币转账的银行应用程序。 该应用程序将包括两部分:Apache Ignite服务器和Apache Ignite客户端。 服务器端是数据仓库。

客户端应用程序连接到Apache Ignite服务器。 创建一个缓存,其中键是帐户ID,值是帐户对象。 总共十个此类对象将存储在缓存中。 在这种情况下,最初我们将在每个帐户上投入100美元(这样就可以进行转帐了)。 因此,所有帐户上的总余额将等于$ 1,000。

CacheConfiguration<Integer, Account> cfg = new CacheConfiguration<>(CACHE_NAME); cfg.setAtomicityMode(CacheAtomicityMode.ATOMIC); try (IgniteCache<Integer, Account> cache = ignite.getOrCreateCache(cfg)) {   for (int i = 1; i <= ENTRIES_COUNT; i++)       cache.put(i, new Account(i, 100));   System.out.println("Accounts before transfers");   printAccounts(cache);   printTotalBalance(cache);   for (int i = 1; i <= 100; i++) {       int pairOfAccounts[] = getPairOfRandomAccounts();       transferMoney(cache, pairOfAccounts[0], pairOfAccounts[1]);   } } ... private static void transferMoney(IgniteCache<Integer, Account> cache, int fromAccountId, int toAccountId) {   Account fromAccount = cache.get(fromAccountId);   Account toAccount = cache.get(toAccountId);   int amount = getRandomAmount(fromAccount.balance);   if (amount < 1) {       return;   }   fromAccount.withdraw(amount);   toAccount.deposit(amount);   cache.put(fromAccountId, fromAccount);   cache.put(toAccountId, toAccount); } 


然后,我们在这10个帐户之间进行100次随机汇款。 例如,$ 50从帐户A转移到另一个帐户B。 示意地,该过程可以表示如下:



系统已关闭,只能在内部进行传输,即 总余额应保持为$ 1000。



启动应用程序。


我们得到了总余额的期望值-$ 1000。 现在让我们的应用程序复杂一些-让它成为多任务。 实际上,多个客户端应用程序可以使用同一帐户同时工作。 运行两个任务,这将同时在十个帐户之间进行转账。

 CacheConfiguration<Integer, Account> cfg = new CacheConfiguration<>(CACHE_NAME); cfg.setAtomicityMode(CacheAtomicityMode.ATOMIC); cfg.setCacheMode(CacheMode.PARTITIONED); cfg.setIndexedTypes(Integer.class, Account.class); try (IgniteCache<Integer, Account> cache = ignite.getOrCreateCache(cfg)) {  // Initializing the cache.  for (int i = 1; i <= ENTRIES_COUNT; i++)    cache.put(i, new Account(i, 100));  System.out.println("Accounts before transfers");  System.out.println();  printAccounts(cache);  printTotalBalance(cache);  IgniteRunnable run1 = new MyIgniteRunnable(cache, ignite,1);  IgniteRunnable run2 = new MyIgniteRunnable(cache, ignite,2);  List<IgniteRunnable> arr = Arrays.asList(run1, run2);  ignite.compute().run(arr); } ... private void transferMoney(int fromAccountId, int toAccountId) {  Account fromAccount = cache.get(fromAccountId);  Account toAccount = cache.get(toAccountId);  int amount = getRandomAmount(fromAccount.balance);  if (amount < 1) {      return;  }  int fromAccountBalanceBeforeTransfer = fromAccount.balance;  int toAccountBalanceBeforeTransfer = toAccount.balance;  fromAccount.withdraw(amount);  toAccount.deposit(amount);  cache.put(fromAccountId, fromAccount);  cache.put(toAccountId, toAccount); } 



总余额为$ 1296。 客户欢喜,银行蒙受损失。 为什么会这样呢?



在这里,我们看到两个任务如何同时更改帐户A的状态。但是第二个任务设法比第一个任务更早记录其更改。 然后,第一个任务记录其更改,第二个任务进行的所有更改立即消失。 这种异常称为更新丢失问题。

为了使应用程序正常运行,我们的数据库必须支持ACID事务,并且我们的代码应考虑到这一点。

让我们看一下应用程序的ACID属性,以了解为什么它如此重要。



  • A-原子性,原子性。 将对数据库进行所有建议的更改,或者什么都不做。 也就是说,如果我们在第3步和第6步之间失败,则更改不应存储在数据库中
  • C-一致性,完整性。 事务完成后,数据库必须保持一致状态。 在我们的示例中,这意味着A和B的总和应始终相同,总余额为$ 1000。
  • 我-隔离,隔离。 交易不应相互影响。 如果一项交易进行了转帐,而另一项交易在第3步到第6步之后才收到帐户A和B的值,则她认为该系统的资金不足。 这里有些细微之处,我稍后会重点介绍。
  • D-耐久性 在事务将更改提交到数据库之后,这些更改不应由于失败而丢失。

因此,在transferMoney方法中,我们将在交易中进行汇款。

 private void transferMoney(int fromAccountId, int toAccountId) {  try (Transaction tx = ignite.transactions().txStart()) {      Account fromAccount = cache.get(fromAccountId);      Account toAccount = cache.get(toAccountId);      int amount = getRandomAmount(fromAccount.balance);      if (amount < 1) {          return;      }      int fromAccountBalanceBeforeTransfer = fromAccount.balance;      int toAccountBalanceBeforeTransfer = toAccount.balance;          fromAccount.withdraw(amount);      toAccount.deposit(amount);          cache.put(fromAccountId, fromAccount);      cache.put(toAccountId, toAccount);          tx.commit();  } catch (Exception e){      e.printStackTrace();  } } 

启动应用程序。


嗯 交易没有帮助。 总余额为$ 6951! 此应用程序行为有什么问题?

首先,他们选择了ATOMIC缓存类型,即 没有ACID交易支持:

 CacheConfiguration<Integer, Account> cfg = new CacheConfiguration<>(CACHE_NAME); cfg.setAtomicityMode(CacheAtomicityMode.TOMIC); 

其次,txStart方法具有两个很重要的enum类型的重要参数,可以很好地指定它们:lock方法(Apache Ignite中的并发模式)和隔离级别。 根据这些参数的值,事务可以以不同的方式读取和写入数据。 在Apache Ignite中,这些参数设置如下:

 try (Transaction tx = ignite.transactions().txStart( ,  )) { Account fromAccount = cache.get(fromAccountId); Account toAccount = cache.get(toAccountId); ...  tx.commit(); } 

您可以使用PESSIMISTIC(悲观锁)或OPTIMISTIC(乐观锁)作为LOCK METHOD参数的值。 它们在阻塞的瞬间有所不同。 当使用PESSIMISTIC时,该锁在第一次读/写时强加并保持到提交事务为止。 例如,当具有悲观锁的交易从帐户A转移到帐户B时,其他交易将无法读取或写入这些帐户的值,直到进行转移的交易被提交为止。 显然,如果其他事务要访问帐户A和B,则它们被迫等待该事务完成,这对应用程序的整体性能有负面影响。 乐观锁定不会限制对其他事务的数据访问,但是,在提交事务的准备阶段(准备阶段,Apache Ignite使用2PC协议),将执行检查-数据是否随其他事务一起更改? 如果发生更改,则交易将被取消。 在性能方面,OPTIMISTIC将运行得更快,但更适合于没有数据竞争的应用程序。

INSULATION LEVEL参数确定事务之间的隔离程度。 SQL ANSI / ISO标准定义了4种隔离类型,对于每个隔离级别,相同的事务场景可能导致不同的结果。

  • READ_UNCOMMITED是最低的隔离级别。 事务可以看到“脏”的未提交数据。
  • READ_COMMITTED-当事务仅在内部看到敏感数据时
  • REPEATABLE_READ-表示如果在事务内部进行读取,则此读取必须是可重复的。
  • 可SERIALIZABLE-此级别假定最大程度的事务隔离-就像系统中没有其他用户一样。 并行事务的结果就像是按顺序(按顺序)执行一样。 但是,加上高度隔离,我们会降低性能。 因此,您必须谨慎地选择这种隔离级别。

对于许多现代DBMS(Microsoft SQL Server,PostgreSQL和Oracle),默认隔离级别为READ_COMMITTED。 对于我们的示例,这将是致命的,因为它不能保护我们免受丢失的更新的影响。 结果将与我们根本没有使用交易一样。



根据Apache Ignite交易文档 ,适合将锁方法和隔离级别结合使用:

  • PESSIMISTIC REPEATABLE_READ-首次读取或写入数据时施加该锁,并保持到完成。
  • PESSIMISTIC SERIALIZABLE-与PESSIMISTIC REPEATABLE_READ类似
  • OPTIMISTIC SERIALIZABLE-会记住第一次读取后获得的数据版本,如果在提交的准备阶段该版本不同(数据已由另一个事务更改),则该事务将被取消。 让我们试试这个选项。


 private void transferMoney(int fromAccountId, int toAccountId) { try (Transaction tx = ignite.transactions().txStart(OPTIMISTIC, SERIALIZABLE)) { Account fromAccount = cache.get(fromAccountId); Account toAccount = cache.get(toAccountId); int amount = getRandomAmount(fromAccount.balance); if (amount < 1) { return; } int fromAccountBalanceBeforeTransfer = fromAccount.balance; int toAccountBalanceBeforeTransfer = toAccount.balance; fromAccount.withdraw(amount); toAccount.deposit(amount); cache.put(fromAccountId, fromAccount); cache.put(toAccountId, toAccount); tx.commit(); } catch (Exception e){ e.printStackTrace(); } } 


Hooray,得到了$ 1,000,与预期的一样。 在第三次尝试。

负载测试


现在,我们将使测试更加实际-我们将在负载下进行测试。 并添加一个额外的服务器节点。 有许多进行压力测试的工具,在Sberbank,我们使用HP Performance Center。 这是一个非常强大的工具,支持50多种协议,是为大型团队设计的,花费很多钱。 我在JMeter上写了我的示例-它是免费的,可以100%解决我们的问题。 我不想用Java重写代码,因此我将使用JSR223采样器。

我们将从应用程序的类创建一个JAR存档,并将其加载到测试计划中。 要创建并填充缓存,请运行CreateCache类。 初始化缓存后,您可以运行JMeter脚本。


一切都很棒,得到了1,000美元。

群集节点紧急关闭


现在,我们将更具破坏性:在集群操作期间,我们将使两个服务器节点之一崩溃。 通过Gridgain软件包随附的Visor实用程序,我们可以监视Apache Ignite群集并制作不同的数据样本。 在“ SQL查看器”选项卡中,执行SQL查询以获取所有帐户的总体余额。


怎么了 553美元。 客户感到恐惧,银行蒙受声誉损失。 这次我们做错了什么?

事实证明,Apache Ignite中有缓存类型:

  • 已分区-群集中存储了一个或几个备份副本
  • 复制的缓存-所有分区(缓存的所有部分)都存储在一台服务器中。 此类缓存主要适合参考书-很少更改且经常阅读的东西。
  • 本地-全部在一个节点上



我们经常会更改数据,因此我们将选择分区缓存并为其添加其他备份。 也就是说,我们将有两个数据副本-主副本和备份副本。

 CacheConfiguration<Integer, Account> cfg = new CacheConfiguration<>(CACHE_NAME); cfg.setAtomicityMode(CacheAtomicityMode.TRANSACTIONAL); cfg.setCacheMode(CacheMode.PARTITIONED); cfg.setBackups(1); 

我们启动该应用程序。 提醒您,在转帐之前,我们有1000美元。 我们启动并在操作过程中“扑灭”节点之一


在Visor实用程序中,我们进行SQL查询以获取总余额$ 1000。 一切顺利!

可靠性案例


两年前,我们才刚刚开始测试新的Sberbank IT系统。 不知何故,我们去了护送工程师,问:有什么可以打破的? 他们回答我们:一切都会破裂,一切都会测试! 当然,这个答案不适合我们。 我们一起坐下来,分析了故障统计信息,并意识到我们可能遇到的最有可能的情况是节点故障。

此外,这可能由于完全不同的原因而发生。 例如,应用程序可能崩溃,JVM崩溃,操作系统崩溃或硬件故障。



我们将所有可能的失败案例分为4组:

  1. 配套设备
  2. 联播网
  3. 软体类
  4. 其他

他们为他们提出了测试,并称其为可靠性案例。 一个典型的可靠性案例包括对测试前系统状态的描述,重现故障的步骤以及对故障期间预期行为的描述。



可靠性案例:设备


该组包括以下情况:

  • 停电
  • 完全无法访问硬盘
  • 一条硬盘访问路径故障
  • CPU,RAM,磁盘,网络负载

群集为每个分区存储4个相同的副本:一个主分区和三个备份分区。 假设节点由于设备故障而离开群集。 在这种情况下,主分区应移至其他尚存的节点。

还有什么可能发生? 单元中机架丢失。



单元的所有节点都在不同的机架中。 即 机架输出不会导致群集故障或数据丢失。 我们将有四个副本的三个副本。 但是,即使我们失去了整个数据中心,这对我们也不是什么大问题,因为 我们仍然有四个数据的两个副本。

在支持工程师的协助下,某些情况直接在数据中心执行。 例如,关闭硬盘驱动器,关闭服务器或机架的电源。

可靠性案例:网络


为了测试与网络碎片相关的案例,我们使用iptables。 并使用NetEm实用程序进行仿真:

  • 具有不同分配功能的网络延迟
  • 丢包
  • 包重试
  • 重新排序数据包
  • 包失真

我们正在测试的另一个有趣的网络案例是裂脑。 此时群集的所有节点都处于活动状态,但是由于网络分段,它们无法相互通信。 这个术语来自医学,意味着大脑被分为两个半球,每个半球都认为自己是独特的。 集群可能发生相同的情况。


发生在数据中心之间的连接消失了。 例如,去年,由于挖掘机损坏了光缆,Tochka,Otkrytie和Rocketbank银行的银行客户数小时未通过Internet进行交易,终端机不接受卡且ATM机无法工作。 在Twitter上已经有很多有关此事故的文章。

在我们的情况下,应该正确处理裂脑情况。 网格标识裂脑-将群集分为两部分。 一半进入读取模式。 这是存在更多活动节点或协调器位于的一半(集群中最旧的节点)。

可靠性案例:软件


这些是与各种子系统故障有关的情况:

  • DPL ORM-数据访问模块,例如Hibernate ORM
  • 模间传输-模块之间的消息传递(微服务)
  • 测井系统
  • 门禁系统
  • Apache Ignite群集
  • ...

由于大多数软件都是用Java编写的,因此我们很容易遇到Java应用程序固有的所有问题。 测试各种垃圾收集器设置。 Java虚拟机崩溃时运行测试。

对于Apache Ignite集群,有一些特殊情况会出现堆外情况-这是Apache Ignite控制的内存区域。 它比Java堆大得多,并且旨在存储数据和索引。 例如,您可以在此处测试溢出。 我们从堆外溢出,看看当某些数据不适合RAM时群集如何工作,即 从磁盘读取。



其他情况


这些是前三组中未包括的情况。 这些工具包括一些实用程序,它们可以在发生重大事故或将数据迁移到另一个群集时恢复数据。

  • 用于创建数据快照(备份)的实用程序-测试完整快照和增量快照。
  • 恢复到特定的时间点-PITR(时间点恢复)机制。

故障注入实用程序


我记得我报告中的示例链接 。 您可以从官方网站Apache Ignite Downloads下载Apache Ignite发行版 。 现在,如果您突然对该主题感兴趣,我将分享我们在Sberbank使用的实用程序。

构架


配置管理:


Linux实用程序:


负载测试工具:


无论是在现代世界还是在Sberbank中,所有变化都是动态的,很难预测未来几年将使用哪些技术。 但是我确定我们将使用“故障注入”方法。 该方法是通用的-适用于测试任何技术,它确实有效,它有助于捕获许多错误并使我们开发的产品更好。

Source: https://habr.com/ru/post/zh-CN433468/


All Articles