TarantoolDBMS的高性能应该很多人都听说过,包括它丰富的工具套件和一些特定的功能。例如,它有Vinyl,一个非常强大的磁盘存储引擎,并且知道如何处理JSON文档。然而,大多数文章往往忽略了一个关键点:通常情况下,Tarantool仅被视为内存,但实际上它最大的特点是能够在内存中编写代码来高效地处理数据。如果您想了解igorcoding和我如何在Tarantool中构建系统,请继续阅读。如果您使用过Mail.Ru电子邮件服务,您应该知道它可以从其他帐户收集电子邮件。如果支持OAuth协议,那么在收集其他账户的邮件时,我们不需要要求用户提供第三方服务凭证,而是使用OAuthtoken。此外,Mail.RuGroup有很多项目需要通过第三方服务进行授权,需要用户的OAuth令牌才能处理某些应用程序。因此,我们决定构建一个存储和更新令牌的服务。我想每个人都知道OAuth令牌是什么样子的,闭上眼睛回忆一下,OAuth结构由以下3-4个字段组成:{"token_type":"bearer","access_token":"XXXXXX","refresh_token":"YYYYYY","expires_in":3600}访问令牌(access_token)——允许你执行操作、获取用户数据、下载用户的好友列表等;刷新令牌(refresh_token)——允许你刷新获取新的access_token,次数不限;expires_in-令牌到期时间戳或任何其他预定义时间,如果您的access_token到期,您将无法再访问所需的资源。现在让我们看一下该服务的简单框架。想象一下,有一些可以在我们的服务上写入和读取令牌的前端,以及一个单独的续订器,一旦令牌过期,就可以通过它从OAuth服务提供商那里获得新的访问令牌。如上图所示,数据库的结构也很简单。它由两个数据库节点(主和从)组成。为了说明两个数据库节点位于两个数据中心,用垂直虚线分隔。一个数据中心包含主数据库节点及其前端和更新程序,另一个数据中心包含从数据库节点及其前端和访问主数据库节点的更新程序。我们面临的困难我们面临的主要问题是令牌的生命周期(一小时)。详细了解这个项目后,可能有人会问“一小时更新1000万条记录,这真的是高负载服务吗?如果除以一个数字,结果大约是3000rps”。但是,如果由于数据库维护或故障,甚至是服务器故障(任何事情都有可能)导致某些记录没有更新,那么事情就会变得更加麻烦。比如我们的服务(主库)因为某种原因持续中断15分钟,就会造成25%的服务中断(四分之一的token失效,不能再使用);服务中断30分钟,将有一半数据无法更新;如果中断1小时,则所有令牌将失效。假设数据库宕机一个小时,我们重启系统,然后需要快速更新整个1000万个token。这是高负载服务吗?一开始一切都比较顺利,但两年后,我们扩展了逻辑,添加了一些指标,并开始执行一些辅助逻辑……总之,Tarantool耗尽了CPU资源。虽然所有的资源都是递归资源,但是这个结果着实让我们吃惊。幸运的是,系统管理员帮我们安装了当时存货最多的CPU,解决了我们接下来6个月的CPU需求。但这只是权宜之计,我们必须想出解决办法。当时我们学习了一个新版本的Tarantool(我们的系统是用Tarantool1.5写的,这个版本除了Mail.RuGroup外其他地方基本没用过)。Tarantool1.6大力提倡master-masterbackup,于是我们想到:为什么不在master-masterbackup连接的三个数据中心分别创建一个数据库备份呢?这听起来是个好计划。三台主机、三个数据中心和三个更新器都连接到各自的主数据库。即使一两个主机宕机,系统仍然可以工作,对吧?那么这个解决方案的缺点是什么?缺点是我们实际上将向OAuth服务提供商的请求数量增加了三倍,即无论有多少副本,我们都需要更新几乎相同数量的令牌。最直接的解决办法就是想办法让每个节点自己决定谁是领导者,这样只需要更新存储在领导者上的节点。选择领导节点的算法有很多种。其中之一称为Paxos,它非常复杂。我们不知道如何简化它,所以我们决定改用Raft。Raft是一个非常容易理解的算法。谁能沟通,谁就被选为领导者。一旦通信连接失败或其他因素,将重新选举领导者。具体实现方法如下:Tarantool外既没有Raft,也没有Paxos,但是我们可以使用net.box内置的模式,将所有节点连接成一个网状网络(即每个节点连接到所有剩余节点),然后直接连接使用Raft算法选择leader节点。***,所有节点要么成为领导者节点,要么成为追随者节点,要么两者都不是。如果你觉得Raft算法难以实现,下面的Lua代码可以帮助你:)ifself._vote_count>self._nodes_count/2thenlog.info(“[raft-srv]node%dwonelections”,self.id)self:_set_state(self.S.LEADER)self:_set_leader({id=self.id,uuid=self.uuid})self._vote_count=0self:stop_election_timer()self:start_heartbeater()elselog.info("[raft-srv]node%dlostelections",self.id)self:_set_state(self.S.IDLE)self:_set_leader(msgpack.NULL)self._vote_count=0self:start_election_timer()end现在我们向远程服务器(Tarantool的其他副本)发送请求并计算每个节点的选票,如果我们有法定人数,我们就选出了一个领导者,然后发送心跳告诉其他节点我们还活着。Ifweloseanelection,wecaninitiateanotherelection,andaftersometime,wecanvoteorbeelectedleaderagain.只要我们有法定人数并选出领导者,我们就可以将更新者分配给所有节点,但只允许他们为领导者服务。这样我们就可以使流量正常化。由于任务由单个节点分派,因此每个更新者获得大约三分之一的任务。通过这个设置,我们可以丢失任何主机,因为如果一个主机出故障了,我们可以发起另一次选举,updater可以切换到另一个节点。然而,与其他分布式系统一样,quorums也存在一些问题。如果“废弃”节点与各个数据中心失去联系,那么我们就需要一些合适的机制来维持整个系统的正常运行,以及恢复系统完整性的机制。Raft成功实现了这两点:假设Dataline数据中心下线了,那么这个位置的节点就变成了“废弃”节点,也就是说这个节点看不到其他节点,而集群中的其他节点可以看到这个节点丢失,又会触发一次选举,然后新的集群节点(也就是上层节点)被选举为leader,整个系统仍然保持运行,因为节点之间的一致性仍然保持(大部分的节点仍然是相互的)可见)。那么问题来了,与丢失的数据中心关联的更新程序发生了什么?Raft规范没有给这样的节点一个单独的名称,通常,没有法定人数且无法联系领导者的节点处于空闲状态。但是,它可以自己建立网络连接,然后更新令牌。通常,令牌在连接模式下更新,但也许连接到“废弃”节点的更新程序也可以更新令牌。一开始我们不确定这样做是否有意义,会不会导致冗余更新?我们需要在实施系统时弄清楚这一点。我们的第一个想法是不更新:我们有一致性,我们有法定人数,如果我们失去任何成员,我们不应该更新。但是后来我们有了另一个想法,我们来看一下Tarantool中的master-master备份,假设有两个master节点,一个变量(key)X=1,我们在每个节点上同时给这个变量赋一个新值一次,一个赋值2,另一个赋值3。然后,两个节点互相交换备份日志(即X变量的值)。一致性方面,像这样实现主-主备份是不好的(对Tarantool开发人员来说没有冒犯)。如果我们需要严格的一致性,这是行不通的。但是,回想一下,我们的OAuth令牌由两个重要元素组成:更新令牌,基本上有效期为100分钟;访问令牌,有效期为一小时;我们的更新程序有一个刷新功能,可以从更新令牌中检索获取任意数量的访问令牌,并且一旦颁发,它们都将在一个小时内保持有效。让我们考虑以下场景:两个跟随者节点正在与领导者节点交互,他们更新他们的令牌,收到第一个访问令牌,这个访问令牌被复制,所以现在每个节点都有这个访问令牌卡,然后,连接断开,因此其中一个跟随者节点成为“废弃”节点,它没有法定人数,也看不到领导者和其他跟随者,但是,我们允许我们的更新器更新位于“”的节点如果“废弃”节点是没有连接到网络,整个方案将停止运行。不过,更新器可以在发生简单的网络分裂时保持正常运行。一旦网络分裂结束,“被遗弃”的节点重新加入集群,另一次选举或数据exchange被触发。注意第二个和第三个token也是“good”的分割部分更新于依赖,但一旦重新集成,数据一致性就会恢复。通常,需要N/2+1个活动节点(3节点集群有2个活动节点)来保持集群运行。尽管如此,对我们来说,即使是1个活动节点也足够了,发送尽可能多的外部请求。重申一下,我们已经讨论了请求数量的逐渐增加,在网络分裂或节点中断期间,我们能够提供一个活动节点,我们照常更新,并且在绝对分裂的情况下(即当一个集群被划分为最大数量的节点时,每个节点都有一个网络连接),如上所述,向OAuth服务提供者的请求数量将增加三倍。但是,由于此事件的持续时间相对较短,所以还算不错,我们也不想一直在拆分模式下工作。通常,系统处于具有法定人数和网络连接且所有节点都已启动并正在运行的状态。分片还有一个问题:我们已经达到CPU限制,最直接的解决方案是分片。假设我们有两个数据库分片,每个分片都有一个备份,有一个函数是这样的,给定一些键值,我们可以计算出哪个分片有需要的数据。如果我们通过电子邮件进行分片,将一些地址存储在一个分片上,另一部分存储在另一个分片上,我们就很清楚我们的数据在哪里。分片有两种方式。一种是客户端分片。我们选择返回分片数量的连续分片函数,例如CRC32、Guava或Sumbur。此功能在所有客户端上以相同的方式实现。这种方法的一个明显优势是数据库对分片一无所知,您的数据库运行正常,然后分片发生。但是,这种方法也有一个严重的缺陷。一开始,客户很忙。如果你想要一个新的分片,你需要在客户端添加分片逻辑,这里最大的问题是一些客户端可能使用这种模式,而另一些则使用另一种完全不同的模式,而数据库本身不知道有两种不同的模式用于分片。我们选择另一种方法——数据库内部分片。在这种情况下,数据库代码变得更加复杂,但是为了妥协我们可以使用一个简单的客户端,每个连接到数据库的客户端被路由到任何一个节点,由一个特殊的函数来计算哪些节点应该被连接,哪些节点应该被连接。被控制。如前所述,随着数据库变得越来越复杂,客户端为了妥协而变得越来越简单,但随后数据库独自负责其数据。此外,最难做的事情是重新分片,如果你有一堆无法更新的客户端,相比之下,如果数据库管理自己的数据是多么容易。如何实施?六边形代表Tarantool实体。有3个节点组成分片1,另一个3节点集群作为分片2。如果我们将所有节点相互连接,会发生什么?根据Raft,我们可以知道每个集群的状态,谁是leader服务器,谁是follower服务器,也一目了然。由于是集群内连接,我们也可以知道其他分片的状态(比如它的leader分片或follower分片)。一般来说,如果访问第一个分片的用户发现这不是他需要的分片,我们就知道应该引导他到哪里。让我们看一些简单的例子。假设用户向驻留在第一个分片上的键发送请求,并且该请求被第一个分片上的节点接收。该节点知道谁是领导者,因此它将请求重新路由到分片领导者,反之亦然。过来,shardleader读取或写入key,并将结果返回给用户。第二种情况:用户的请求到达第一个分片的同一个节点,但是请求的key在第二个分片。这种情况也可以用类似的方式处理。第一个分片知道第二个分片上谁是leader,然后将请求发送给第二个shard的leader进行转发处理,然后将结果返回给用户。这个方案很简单,但是也有一定的缺陷。最大的问题是连接数。在两个分片的例子中,每个节点都与其他剩余的节点相连,连接数为6*5=30。如果加上一个3节点的分片,那么连接数增加到72个,是不是有点多了?我们如何解决这个问题?我们只需要添加一些Tarantool实例,我们称它们为代理,而不是分片或数据库,使用代理来解决所有分片问题:包括计算键值和定位分片领导者。另一方面,Raft集群保持独立并且只在分片内部工作。当用户访问代理时,代理会计算所需的分片,如果需要leader,则相应地重定向用户;如果它不是领导者,则用户将被重定向到分片中的任何节点。由此产生的复杂度是线性的,并且取决于节点的数量。现在一共有3个节点,每个节点有3个分片,连接数少了好几倍。代理方案的设计考虑了进一步的规模扩展(当分片数大于2时)。当只有2个分片时,连接数保持不变,但当分片数增加时,连接数会急剧下降。分片列表保存在Lua配置文件中,所以如果我们想得到一个新的列表,只需要重新加载代码即可。综上所述,首先我们进行master-master备份,应用Raft算法,然后添加shards和agents。最后,我们得到的是一个单块和一个簇。因此,目前的解决方案似乎比较简单。剩下的就是只读或只写令牌的前端,我们有更新令牌的更新器,获取更新令牌并将其传递给OAuth服务提供者,然后写入新的访问令牌。前面我们说了我们的一些辅助逻辑用完了CPU资源,现在我们要把这些辅助资源移动到另外一个集群。辅助逻辑主要是通讯录相关的。给定一个用户令牌,就会有一个对应的通讯录。通讯录中的数据量与令牌相同。为了不耗尽一台机器上的CPU资源,我们显然需要一个和replica相同的集群,只需要添加一堆更新通讯录的updater(这个任务比较少见,所以通讯录不会用令牌)。***,通过整合这两个集群,我们得到了一个比较简单的完整结构:TokenUpdateQueue可以用标准队列为什么还要用自己的队列呢?这与我们的令牌更新模型有关。令牌一旦发出,有效期为一小时。当令牌即将过期时,需要更新令牌,令牌更新必须在某个时间点之前完成。假设系统中断了,但是我们有一堆过期的token,当我们更新这些token的时候,其他的token正在陆续过期。虽然我们肯定可以全部更新,但是如果我们先更新即将过期的(60秒以内),然后用剩下的资源更新已经过期的不是更合理吗?Tokens)用第三方软件实现这个逻辑不是一件容易的事,但是,对于Tarantool来说,这是一个轻而易举的事情。看一个简单的方案:在Tarantool中有一个存储数据的元组,这个元组的一些ID设置了基本键值。为了得到我们需要的队列,我们??只需要添加两个字段:status(队列令牌状态)和time(过期时间或其他预定义的时间)。现在让我们考虑队列的两个主要功能——放置和获取。Put是写入新数据。给定一些负载,在put的时候设置状态和时间,然后写入数据,也就是新建一个tuple。至于take,就是创建一个基于索引的迭代器,挑出那些等待解决的任务(处于就绪状态的任务),然后检查是否到了接收这些任务的时间,或者这些任务是否已经过期.如果没有任务,则切换到等待模式。除了内置的Lua,Tarantool还有一些所谓的通道,本质上是互连光纤同步原语。任何一根光纤都可以建立一个通道并说“我在这里等着”,其余的光纤可以唤醒该通道并向它发送消息。等待函数(等待任务发布、等待指定时间或其他)创建一个通道,适当地标记通道,将通道放置在某个地方,然后监听。如果我们收到紧急更新令牌,put通知通道,take接收更新任务。Tarantool有一个特殊的功能:如果令牌被意外发出,或者一个更新令牌被拿走,或者只是一个接收任务的现象,Tarantool可以跟踪这三种情况下的客户端中断。我们将每个连接与分配给该连接的任务相关联,并将这些映射保存在会话保存中。假设由于网络中断导致更新过程失败,我们不知道这个令牌是否会被更新并写回数据库。因此,客户端被中断,搜索与失败进程相关的所有任务的会话保存,并自动释放它们。随后,任何已发布的任务都可以使用相同的通道向另一个put发送消息,后者将快速接收并执行该任务。指定,如果你想设置默认值:functionput(data)localt=box.space.queue:auto_increment({'r',--[[status]]util.time(),--[[time]]data--[[anypayload]]})returntendfunctiontake(timeout)localstart_time=util.time()localq_ind=box.space.tokens.index.queuelocal_,whiletruelocalit=util.iter(q_ind,{'r'},{iterator=box.index.GE})_,t=it()iftaken[F.tokens.status]~='thenbreakendlocalleft=(start_time+timeout)—util.time()ifleft<=0thenreturned=q:wait(left)ifttthenbreakendt=q:taken(t)returningfunctionqueue:taken(task)localside=box.session.id()ifself._consumers[side]==nilthenself._consumers[side]={}endlocalk=task[self.f_id]localt=self:set_status(k,'t')self._consumers[sid][k]={util.time(),box.session.peer(sid),t}self._taken[k]=sideturntendfunctionon_disconnect()localsid=box.session.idlocalnow=util.time()ifself._consumers[sid]thenlocalconsumers=self._consumers[sid]fork,resinpairs(consumers)dotime,peer,task=unpack(rec)localv=box.space[self.space].index[self.index_primary]:get({k})ifvandv[self.f_status]=='t'thenv=self:release(v[self.f_id])endendself。_consumers[sid]=nilendendPut只是把用户要插入队列的所有数据都接收到,写入到某个空间。如果是简单的索引FIFO队列,设置状态和当前时间,然后返回任务。跟take有点关系,不过还是比较简单的。我们构建一个等待接收新任务的迭代器。Taken函数只需要将任务标记为“已接收”即可,但很重要的是,taken函数还可以记住哪个任务是哪个进程接收的。On_disconnect函数可以发布一个特定的连接,或者发布一个特定用户收到的所有任务。有选择吗?当然有。我们可以使用任何数据库,但无论我们选择什么数据库,我们都必须设置一个队列来处理外部系统、处理更新等。我们不能只按需更新令牌,因为那样会产生不可预知的工作量,无论如何我们都需要让我们的系统保持活动状态,但是我们也必须对延迟的任务进行排队,并保持数据库和之间的一致性队列,我们??也被迫使用仲裁容错队列。此外,如果我们将数据同时放入RAM和(考虑到工作负载)可能进入内存的队列中,那么我们将消耗更多资源。在我们的解决方案中,数据库存储令牌,队列逻辑只需要7个字节(每个元组只需要额外7个字节来处理队列逻辑!),如果使用其他队列形式,则需要占用更多空间,大约内存容量的两倍。总结首先,我们解决了掉线这个很常见的问题,使用上面的系统让我们摆脱了这个问题。分片帮助我们扩展内存,然后我们将连接数从二次减少到线性,优化业务任务的队列逻辑:如果有延迟,更新所有我们能更新的token,并不是所有这些延迟都是我们的失败可能是谷歌、微软或其他OAuth服务商在服务端改造造成的,进而导致我们这边有大量未更新的token。到数据库内部计算,接近数据,你将拥有便捷、高效、可扩展、灵活的计算体验!
