Scala上的无痛回退缓存

在大型或微服务体系结构中,最重要的服务并不总是生产力最高的,有时也不适合高负载。 我们正在谈论后端。 它工作缓慢-浪费时间进行数据处理并等待它与DBMS之间的响应,并且无法扩展。 即使应用程序本身易于扩展,该瓶颈也根本无法扩展。 如何解决这个问题并确保高性能? 当重要信息源处于静默状态时,如何提供系统响应?



如果您的体系结构完全符合Reactive清单,则应用程序的组件会随着负载的增加彼此独立地无限扩展,并且可以承受任何节点的跌落-您知道答案。 但是如果不是这样,那么Oleg NizhnikovOdomontois )将讲述如何在Tinkoff上解决问题的可扩展性问题,方法是在Scala上构建其无痛的Fallback Cache而无需重写应用程序。

注意事项 本文将包含最少的Scala代码和最多一般原则和思想。



后端不稳定或运行缓慢


与后端交互时,平均应用程序速度很快。 但是后端完成了大部分工作,并在内部研磨了大部分数据-需要更多时间。 浪费额外的时间来等待后端和DBMS响应。 即使应用程序本身易于扩展,该瓶颈也根本无法扩展。 如何减轻后端的负担并解决问题?
您的服务
后端
每个答案中的净工作时间:(de)序列化,检查,逻辑,异步成本
53毫秒
785毫秒
等待后端和DBMS
3015毫秒
1932毫秒
节点数
32
2
摘要答案
3070毫秒
2702毫秒

嵌入式缓存


第一个想法是获取数据以读取,请求接收数据并在每个内存节点的级别配置高速缓存。



缓存将一直存在,直到节点重新启动并仅存储最后一块数据为止。 如果应用程序崩溃了,并且最近一个小时,一天或一周都没有来过的新用户进入,则该应用程序将无法执行任何操作。

代理人


第二个选项是代理,它可以代替部分请求或修改应用程序。



但是在代理中,您无法为应用程序本身完成所有工作。

缓存数据库


当后端返回的部分数据可以长时间存储时,第三个选项比较棘手。 需要它们时,即使它们不再相关,我们也会向客户展示。 总比没有好。



将讨论该决定。

后备缓存


这是我们的图书馆。 它嵌入在应用程序中并与后端通信。 通过最小的改进,它可以分析数据结构,生成序列化格式,并借助断路器算法提高容错能力。 如果对类型进行了足够严格的定义,则可以使用可以预先分析类型的任何语言来实现有效的序列化。

组成部分


我们的图书馆看起来像这样。



左侧部分专门用于与此存储库进行交互,其中包括两个重要组件:

  • 负责初始化过程的组件-使用后备缓存之前,DBMS的初步操作;
  • 自动序列化生成模块。

右侧是与回退相关的常规功能。

一切如何运作? 应用程序中间有一些查询,中间有一些用于存储状态的查询。 此表单表示我们从后端收到的针对一个或多个请求的数据。 我们将参数发送到我们的方法,然后从那里获取数据。 这些数据需要以某种方式进行序列化才能存储,因此我们将其包装在代码中。 一个单独的模块对此负责。 我们使用了断路器模式。

储存要求


保质期长-30-500天 。 某些操作可能会花费很长时间,并且所有这些时间都需要存储数据。 因此,我们需要一种可以长时间存储数据的存储。 内存不适合此操作。

大数据量-100 GB-20 TB 。 我们想在缓存中存储数十TB的数据,并且由于增长而需要更多。 将所有这些信息保存在内存中效率很低-并非经常请求大多数数据。 他们躺了很长时间,等待着用户的到来并问他们。 内存不属于这些要求。

高数据可用性 。 服务可能会发生任何事情,但是我们希望DBMS始终保持可用状态。

存储成本低 。 我们将其他数据发送到缓存。 结果,发生开销。 在实施我们的解决方案时,我们希望将其最小化。

支持间隔查询 。 我们的数据库应该不仅可以完整地提取数据,而且还可以按一定时间间隔提取数据:操作列表,一段时间内的用户历史记录。 因此,纯键值不适合。

假设条件


要求缩小了候选人的范围。 我们假定已经实现了其余部分,并做出以下假设,知道为什么我们确实需要Fallback Cache。

不需要两个不同的GET请求之间的数据完整性 。 因此,如果它们显示两个彼此不一致的不同状态,我们将忍受这一点。

不需要数据的相关性和无效性 。 在提出请求时,假定我们拥有所显示的最新版本。

我们从后端发送和接收数据。 该数据的结构是事先已知的

储存选择


作为替代方案,我们考虑了三个主要选项。

第一个是Cassandra 。 优点:高可用性,易扩展性和带有UDT集合的内置序列化机制。

UDT用户定义的类型 ,表示某种类型。 它们使您可以有效地堆叠结构化类型。 类型字段是事先已知的。 这些序列化字段用协议缓冲区中的单独标记标记。 阅读了此结构后,便可以了解基于标签的字段。 元数据足以找出其名称和类型。

Cassandra的另一个优点是,除了分区键之外,它还具有一个附加的群集键 。 这是一个特殊的密钥,因此,数据在一个节点上排序。 这使您可以实现一个选项,例如间隔查询。

Cassandra已经存在了很长时间,有很多监视解决方案 ,而JVM是其中的一个 。 对于可以在其上编写DBMS的平台而言,这不是最有效的选择。 JVM在垃圾回收和开销方面存在问题。

第二个选项是CouchBase 。 优势:数据可访问性,可伸缩性和无模式。

使用CouchBase,您无需考虑序列化。 这既是加也是减,我们不需要控制数据方案。 有全局索引使您可以在整个集群中全局运行间隔查询。

CouchBase是将Memcache添加到常规DBMS 快速缓存中的一种混合方式。 它使您可以自动缓存节点上的所有数据(最热的),并具有很高的可用性。 由于其缓存,如果经常请求相同的数据,CouchBase可以更快。

SchemalessJSON也可以减号。 数据可以存储很长时间,以使应用程序有时间更改。 在这种情况下,CouchBase将要存储和读取的数据结构也将发生变化。 以前的版本可能不兼容。 您只会在读取数据时才了解此内容,而在数据投入生产时则不会了解数据。 我们必须考虑适当的迁移,而这正是我们不想做的。

第三种选择是Tarantool 。 它以其超高速而闻名。 它具有出色的LUA引擎,可让您编写一堆逻辑,这些逻辑将在LuaJit的服务器上直接执行。

另一方面,这是修改后的键值。 数据存储在元组中。 我们需要自己考虑正确的序列化,这并不总是一件显而易见的任务。 Tarantool还具有可扩展性的特定方法。 他怎么了,我们将进一步讨论。

分片/复制


也许我们的应用程序将需要分片/复制 。 三个存储库以不同的方式实现它们。

卡桑德拉提出了一种通常称为“环”的结构。



许多节点可用。 它们每个都将其数据和来自最近节点的数据存储为副本。 如果一个节点丢失,则其旁边的节点可以提供部分数据,直到该节点丢失为止。

分片\复制负责相同的结构。 要解压成10个组件和复制因子3,10个节点就足够了。 每个节点将存储来自相邻节点的2个副本。

在CouchBase中,节点之间的交互结构的结构类似:

  • 节点本身负责标记为活动的数据;
  • CouchBase存储了相邻节点的副本。



如果一个节点退出,则相邻节点(共享节点)将负责维护这部分密钥。

在Tar​​antool中,该体系结构类似于MongoDB。 但有一个细微差别:存在分片组彼此复制。



对于前两种体系结构,如果我们要制作4个分片和3个复制因子,则需要4个节点。 对于Tarantool-12! 但是其缺点被Tarantool保证的速度所抵消。

卡桑德拉


Tarantool中用于分片的可选模块仅在最近才出现。 因此,我们选择了Cassandra DBMS作为主要候选人。 回想一下,我们谈到了它的特定序列化。

自动序列化


SQL协议假定您完全可以自由定义数据模式。

您可以利用此优势。 例如,对数据进行序列化,以便我们的叶状结构的长字段名每次都不会存储在我们的值中。 在这种情况下,我们将有一些描述数据设备的元数据。 UDT本身还会告诉您哪些字段对应于标签和标签。

因此,自动生成的序列化几乎以相同的方式发生。 如果我们拥有一种可以与数据库中的类型一一对应的基本类型,则可以这样做。 Cassandra中也有一组类型Int,Long,String,Double。
应用数据类型
Cassandra中的数据类型
原始类型
(整数,长整数,字符串,双精度,BigDecimal)
原始类型
(int,biging,文本,double,十进制)

如果在某些结构中遇到可选字段,我们将不做任何其他事情。 我们为他指出该字段应变为的类型。 该结构将存储空值。 如果在反序列化级别的结构中发现null,则假定这是缺少值。
应用数据类型
Cassandra中的数据类型
选项[A]


Scala中集合中的所有集合类型都将转换为类型列表。 这些是具有索引匹配元素的有序集合。
应用数据类型
Cassandra中的数据类型
序列[A],列表[A],流[A],向量[A]
冻结<list“ a”>

无序集合集合保证每个值中只有一个元素。 卡桑德拉(Cassandra)也为他们提供了一种特殊的套装类型。
应用数据类型
Cassandra中的数据类型
设置[A]
冻结<set“ a”>

最有可能的是,我们将有很多映射(),尤其是对于字符串键。 卡桑德拉(Cassandra)有一种特殊的地图类型。 它也被键入并具有两个类型参数。 这样我们就可以为任何键创建适当的类型
应用数据类型
Cassandra中的数据类型
地图[K,V]
冻结的<map“ k,v”>

我们在应用程序中定义了自己的数据类型。 在许多语言中,它们称为代数数据类型 。 通过定义类型的命名产品(即结构)来定义它们。 我们将此结构分配给用户定义类型。 结构的每个字段将对应于UDT中的一个字段。
应用数据类型
Cassandra中的数据类型
类型产品:外壳类
UDT

第二种类型是类型的代数和 。 在这种情况下,类型对应于几个先前已知的亚型或亚种。 同样,我们以某种方式为其分配结构。
应用数据类型
Cassandra中的数据类型
类型总和:密封特征\类
UDT

抽象数据类型转换为UDT


我们有一个结构,并将其一对一显示-对于每个字段,我们在Cassandra中创建的UDT中定义该字段:

case class Account ( id: Long, tags: List[String], user: User, finData: Option[FinData] ) create type account ( id bigint, tags: frozen<list<text>>, user frozen<user>, fin_data frozen<fin_data> ) 

原始类型变成原始类型。 在冻结之前,指向预定义类型的链接。 这是Cassandra中的特殊包装,这意味着您无法逐块读取该字段。 包装器被“冻结”到此状态。 与标记一样,我们只能读取或保存用户或列表。

如果我们遇到一个可选字段,那么我们将放弃此特征。 我们仅采用与将要使用的字段类型相对应的数据类型。 如果在这里遇到非值-缺少值-则在相应字段中写入null。 阅读时,我们还将采用非null的对应关系。

如果遇到具有多个已知替代方法的类型,则还要在Cassandra中定义一个新的数据类型。 对于每种选择,在UDT中我们数据类型的字段。

结果,在这种结构中,在任何给定时间只有一个字段不会为空。 如果遇到某种类型的用户,而事实证明它是运行时中主持人的一个实例,那么主持人字段将包含一些值,其余字段为null。 对于管理员-管理员,其余-空。

这使您可以对结构进行如下编码:我们有4个可选字段,我们保证将仅从中写入一个。 Cassandra仅使用一个标签来识别结构中特定字段的存在。 因此,我们获得了没有开销的存储结构。

实际上,要保存用户类型(如果是主持人),它将占用与存储主持人相同的字节数。 再加上一个字节,以显示此处存在哪个特定替代方案。

初始化


初始化是一个初步程序,必须先完成,然后才能使用后备。

这个程序如何运作?

  • 在每个节点上,我们根据显示的类型生成表,类型和查询文本的定义。
  • 从DBMS读取当前模式。 在Cassandra中,只需连接即可轻松实现。 连接后,几乎所有驱动程序中的“会话”对象本身都会抽出与其连接的密钥空间元数据。 然后,您可以看到它们所拥有的。
  • 我们遍历元数据,比较并验证是否允许我们要创建的所有内容以及是否可以进行增量迁移。
  • 如果一切正常,并且可以初始化,我们将执行迁移。
  • 我们正在准备要求。

 sealed trait User case class Anonymous extends User case class Registered extends User case class Moderator extends User case class Admin extends User create type user ( anonymous frozen<anonymous>, registered frozen<registered>, moderator frozen<moderator>, admin frozen<admin> ) 

就是这样 我们有类型查询 。 类型取决于其他类型,也取决于其他类型。 表取决于这些类型。 查询已经取决于它们从中读取数据的表。 初始化将检查所有这些依赖关系,并根据某些规则在DBMS中创建它可以创建的所有内容。

类型迁移


如何确定类型可以增量迁移?



  • 我们了解了如何在DBMS中定义此类型。
  • 如果没有这样的类型,那就是我们提出了一个新的类型-我们创建它。
  • 如果已经存在这种类型,我们将尝试逐字段将现有定义与我们要对此类型给出的定义进行比较。
  • 如果事实证明我们只想添加一些不再存在的字段,则可以这样做。 创建一个可变ALTER TYPE操作的列表,然后启动它们。
  • 如果事实证明我们有某种类型不同的字段,则会产生错误。 例如,有一个列表-变成了地图,或者有一个用户定义类型的链接,我们正在努力使其与众不同。

开发人员甚至在开始生产功能之前就可以看到此错误。 我假设完全相同的数据方案位于他的开发环境中。 他发现自己以某种方式创建了不可迁移的数据模式,并且为了避免这些错误,他可以覆盖自动生成的序列化,添加选项,重命名字段或将所有类型和表作为一个整体。

初始化:类型


想象一下,有几种类型的定义:

 case class Product (id: Long, name: ctring, price: BigDecimal) case class UserOffers (valiDate: LocalDate, offers: Seq[Products]) case class UserProducts (user User, products: Map[Date, Product]) case class UserInfo: UserOffers, products: UserProducts) 

案例类 -包含一组字段的类。 这与Rust中的struct类似。

我们将为这4种类型中的每一种类型生成近似的数据定义-我们最终要提高的数据类型:

 CREATE TYPE product (id bigint, name text, price decimal); CREATE TYPE user_offers (valid_date date, offers frozen<list<frozen<offer>>>); CREATE TYPE user_products (user frozen<user>, products frozen<map<date, frozen<product>>); CREATE TYPE user_jnfo (offers: frozen<user_offers>, products: frozen<user_products>); 

user_offers的类型取决于要约的类型,user_products取决于产品的类型,user_info取决于第二和第三种类型。



我们在类型之间有这样的依赖关系,我们想正确地对其进行初始化。 该图显示我们将并行初始化user_offers和user_products。 这并不意味着我们将启动两个并行操作。 不,我们开始所有语句,按顺序进行所有分析,以免在两个并行线程中意外创建相同类型。

但是在纠错级别上存在一些并行性。 如果发生类型错误,则依赖它的所有内容都会提取原始错误。



如果任何并行分支生成了错误,则所有依赖于正常迁移的数据的生成都会正确无误。 如果对表有进一步的定义,并准备好表中的语句,则可以安全地初始化后备缓存的这一部分。 只有部分后端或某些功能会失去通信。 其余的被初始化。



同时初始化的两种类型可能会产生不同的错误。 在这种情况下,依赖于两种类型的功能将产生错误的汇总类型。 在开发环境中初始化其“后备”的开发人员将收到带有错误的完整数据列表。 自然,他可以在这里修复它,并进一步得到错误。 但是,一个完全独立的分支不会关闭我们可能遇到的错误,而不管该分支如何。



初始化:表


接下来,我们创建表。

 def getOffer (user: User, number: Long): Future[OfferData] create table get_offer( key frozen<tuple<frozen<user>, bigint>>PRIMARY KEY, value frozen<friend_data> ) 

这样的请求可以直接启动REST或SOAP请求,在内部创建其他操作,甚至运行多个请求。 这完全取决于您的代码-您如何组织代码。 回退完全无法分析挂起此类存根的方法内部发生的情况。

该方法必须是异步的,因为回退是相同的。

在Scala中,此标签带有特殊的Future类型。 这意味着结果将有一天返回。 确切的时间-未知:也许马上,也许不是。

对于该方法,创建一个表。 该表中的键是与该方法的参数相对应的所有类型的元组。 非键值是结果,它是异步返回的。 对于每个这样的表,我们预先准备两个参数查询:插入数据和读取数据。

 insert into get_offer(key, value) values (?key, ?value); select value from get_offer where key = ?key; 

一切准备就绪,可以与DBMS进行交互。 仍有待发现如何从Fallback读取数据。

断路器


在这里,责任进入了著名的断路器模式区域。



典型的断路器包括三个状态。

已关闭-关闭后端的默认关闭状态 。 原理是,我们首先从后端读取数据,并且只有在无法获取数据的情况下,才进行回退。 如果我们设法获取数据,则无需查看“后备”,而是将数据保存在其中,并且什么也没有发生。

如果问题接连发生,我们认为后端在说谎。 为了避免向其发送大量新请求,我们将其切换为“ 打开”(处于残缺状态) 。 在其中,我们尝试仅从回退中读取数据。 如果仍然无法解决问题,我们会立即返回错误,甚至不要触摸主后端。

过了一会儿,我们决定找出后端是否醒来,并尝试重置Half-Open状态-一种短暂的状态 。 他的一生是一个要求。

在短期状态下,我们选择再次关闭或打开更长的时间。 如果在“半开”状态下我们成功到达“后备状态”并接收到下一个请求,那么我们将进入“关闭”状态。 如果无法通过,我们将恢复开放状态,但时间较长。



我们添加了两个显然与断路器电路无关的其他状态:

  • 强制-强制关闭状态;
  • 反转-打开,关闭状态反转的优先级。

让我们看看他们在做什么。

国家运作原则


闭馆 该方案很大,但是足以从中了解一般原理。 如果一切顺利,并从Fallback读取数据,则我们将Fallback与如何从后端返回结果并行进行。 如果到处都不好,我们将返回错误优先级。

在这两个错误中,选择后端错误。



如果没有错误,我们将与此并行增加计数器,并在请求过多时进入打开状态。



开门 Open的打开状态比较简单-无论发生什么情况,我们都会不断阅读Fallback的内容,过一会儿,我们尝试切换到Half-Open状态。

半开 。 结构中的状态类似于“已关闭”。 不同之处在于,在成功回答的情况下,我们进入了封闭状态。 如果出现故障-我们将延长间隔重新开放。



强制是预热缓存的额外状态 。 当我们用数据填充数据时,它永远不会尝试从Fallback读取数据,而只会添加记录。



相反的是第二种牵强的状态 。 它像持久性缓存一样工作。 当我们想要从后端永久删除负载时,即使数据可能无关紧要,我们也会打开状态。 在“后备”中反向搜索第一个搜索,如果搜索失败,它将转到后端进行处理。



问题所在


对于整个方案,我们遇到了几个问题。 最严重的是了解Cassandra中预备语句的工作方式。 此问题已在4.0版本中得到修复,该版本尚未发布,因此,我将告诉您。

Cassandra旨在同时连接数百万个客户,每个人都在尝试准备准备好的语句。 自然,Cassandra不会准备每个准备好的语句,否则它将耗尽内存。 它根据文本,键空间和查询选项计算MD5参数。 如果她收到与完全相同的MD5完全相同的请求,则将接受已经准备好的请求。 它已经具有有关元数据以及如何处理它的信息。

但是存在版本问题。 我们正在发布一个新版本,它成功地完成了迁移,添加了类型的字段并运行了准备好的语句。 它们返回了我们的状态和元数据的先前版本-类型没有字段。 在读取数据时,我们正在尝试编写其新的必需列,并且面临着它们根本不存在的事实! 卡桑德拉说,这通常是她不知道的另一种类型。

我们按照以下方式处理此问题: 在每个准备好的请求中添加了唯一的文本

 create table get_offer( key frozen<tuple<frozen<user>, bigint>> PRIMARY KEY, value frozen<friend_data>, query_tag text ) insert into get_offer (key, value, query_tag) values (?key, ?value, 'tag_123'); select value as tag_123 from get_offer where key = ?key; 

我们将没有数百万个已连接的客户端,但是每个节点只有一个会话,该会话拥有多个连接。 对于每个准备语句一次。 我们假设,对于应用程序的每个版本或节点的每个起点,都可以生成唯一的文本,这显然可以在我们的请求文本中。

我们添加了一个特殊的领域来欺骗他。 插入时,我们在此字段中写一个常量。 对于每个启动或应用程序版本,它都是唯一的-在库中配置。 读取时,我们将此名称用作所获取值的别名。 请求是完全一样的,我们仍然在做选择值,但是文本是不同的。 Cassandra没有意识到这是相同的请求,因此计算了另一个MD5并使用新的元数据再次准备了该请求。

第二个问题是迁移竞赛 。 例如,我们要进行几次并行迁移。 让我们开始一些注意事项,同时他们将开始计算,他们将运行创建表,创建类型。 这可能导致以下事实:在每个节点上或在每个并行线程中,所有操作都会成功,并且两个表似乎已成功创建。 但是在Cassandra内部,您会感到困惑,并且我们将收到读写超时。

如果您尝试并行化来自多个线程或多个节点的进程,则可以破坏Cassandra。

如果我们知道必须进行回退迁移,则可以在release之前从一个特殊节点进行迁移 。 只有这样,我们才能在发行版中启动所有节点。 所以我们解决了这个问题。

第三个问题是Fallback Cache中缺少数据 。 可能是我们“支持”该方法,它应该存储一年前的历史数据,但实际上我们是昨天启动的。

通过预热解决了问题 。 我们使用了Forced状态并启动了不会与真实用户通信的特殊节点。 它们将采用我们假定的所有可能的键,并将在一个圆圈内预热缓存。 热身进行得如此之快,以至于不会杀死我们正在阅读的后端。

扩展应用程序,后端,大数据和前端-Scala适合所有这些。 11月26日,我们将为Scala开发人员举行一次专业会议 。 样式,方法,针对同一问题的数十种解决方案,使用陈旧且行之有效的方法的细微差别,函数式编程的实践,基本函数式宇宙论的理论-我们将在会议上讨论所有这些问题。 如果您想在9月26日之前分享您的Scala经验或预订机票 ,请申请一份报告。

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


All Articles