直到最近,Odnoklassniki 在 SQL Server 中存储了约 50 TB 实时处理的数据。 对于这样的卷,使用 SQL DBMS 几乎不可能提供快速可靠、甚至数据中心容错的访问。 通常,在这种情况下,会使用 NoSQL 存储之一,但并非所有内容都可以转移到 NoSQL:某些实体需要 ACID 事务保证。
这导致我们使用 NewSQL 存储,即提供 NoSQL 系统的容错性、可扩展性和性能的 DBMS,但同时保持经典系统所熟悉的 ACID 保证。 这种新类型的工业系统很少,所以我们自己实现了这样的系统并投入商业运营。
它是如何运作的以及发生了什么 - 请阅读下面的内容。
如今,Odnoklassniki 每月的独立访问者数量超过 70 万。 我们
我们从 2010 年开始使用 Cassandra,从 0.6 版本开始。 如今,有数十个集群正在运行。 最快的集群每秒处理超过 4 万次操作,最大的存储容量为 260 TB。
然而,这些都是用于存储的普通NoSQL集群
为了跨 SQL Server 节点分布数据,我们使用了垂直和水平
感谢分片并加速 SQL:
- 我们不使用外键约束,因为分片时实体 ID 可能位于另一台服务器上。
- 由于 DBMS CPU 上的额外负载,我们不使用存储过程和触发器。
- 由于上述所有原因以及从磁盘进行的大量随机读取,我们不使用 JOIN。
- 在事务之外,我们使用“读取未提交”隔离级别来减少死锁。
- 我们仅执行短事务(平均短于 100 毫秒)。
- 由于存在大量死锁,我们不使用多行 UPDATE 和 DELETE - 我们一次只更新一条记录。
- 我们总是只对索引执行查询 - 对我们来说具有全表扫描计划的查询意味着数据库超载并导致其失败。
这些步骤使我们能够从 SQL 服务器中获得几乎最大的性能。 然而,问题却越来越多。 让我们看看它们。
SQL 问题
- 由于我们使用了自己编写的分片,因此添加新分片是由管理员手动完成的。 一直以来,可扩展数据副本都没有为请求提供服务。
- 随着表中记录数量的增加,插入和修改的速度会降低;向现有表添加索引时,速度会下降一倍;创建和重新创建索引会伴随停机。
- 在生产环境中使用少量 Windows for SQL Server 会使基础架构管理变得困难
但主要问题是
容错
经典的SQL Server容错能力较差。 假设您只有一台数据库服务器,并且每三年发生一次故障。 在此期间,网站宕机 20 分钟,这是可以接受的。 如果您有 64 台服务器,则该站点每三周就会关闭一次。 如果您有 200 台服务器,那么该站点每周都无法运行。 这是问题。
如何提高 SQL Server 的容错能力? 维基百科邀请我们共同构建
这需要一组昂贵的设备:大量的重复、光纤、共享存储,并且包含的储备不能可靠地工作:大约 10% 的交换以备份节点的故障结束,就像主节点后面的火车一样。
但这种高可用集群的主要缺点是,如果其所在的数据中心出现故障,则可用性为零。 Odnoklassniki 有四个数据中心,我们需要确保其中一个数据中心完全故障时仍能正常运行。
为此我们可以使用
所有这些问题都需要根本性的解决,我们开始详细分析。 这里我们需要熟悉一下SQL Server主要做的事情——事务。
交易简单
让我们从应用 SQL 程序员的角度考虑最简单的事务:将照片添加到相册。 相册和照片存储在不同的盘中。 该相册有一个公共拍照柜台。 那么这样一个交易分为以下几个步骤:
- 我们通过按键阻止专辑。
- 在照片表中创建一个条目。
- 如果照片具有公开状态,则将公开照片计数器添加到相册中,更新记录并提交交易。
或者用伪代码:
TX.start("Albums", id);
Album album = albums.lock(id);
Photo photo = photos.create(…);
if (photo.status == PUBLIC ) {
album.incPublicPhotosCount();
}
album.update();
TX.commit();
我们看到,业务事务最常见的场景是将数据从数据库读取到应用程序服务器的内存中,更改某些内容并将新值保存回数据库。 通常在这样的事务中,我们更新多个实体、多个表。
当执行事务时,可能会发生来自另一个系统的相同数据的并发修改。 例如,反垃圾邮件可能认为用户有些可疑,因此用户的所有照片不应再公开,需要将其发送以进行审核,这意味着将 photo.status 更改为其他值并关闭相应的计数器。 显然,如果在不保证应用程序原子性和竞争修改隔离的情况下发生此操作,如
在 Odnoklassniki 的整个存在过程中,已经编写了许多类似的代码,在一个事务中操作各种业务实体。 基于从 迁移到 NoSQL 的经验
其他同样重要的要求是:
- 如果数据中心发生故障,新存储的读取和写入都必须可用。
- 保持目前的发展速度。 也就是说,当使用新的存储库时,代码量应该大致相同;不需要向存储库添加任何内容、开发解决冲突的算法、维护二级索引等。
- 新存储的速度必须相当高,无论是在读取数据还是处理事务时,这实际上意味着学术上严格的、通用的但缓慢的解决方案,例如,不适用
两阶段提交 . - 自动即时缩放。
- 使用常规的廉价服务器,无需购买奇特的硬件。
- 公司开发人员可以进行存储开发。 换句话说,优先考虑专有或开源解决方案,最好是 Java。
决定,决定
通过分析可能的解决方案,我们得出了两种可能的架构选择:
第一个是采用任何 SQL 服务器并实现所需的容错、扩展机制、故障转移集群、冲突解决以及分布式、可靠且快速的 ACID 事务。 我们认为这个选项非常重要并且是劳动密集型的。
第二种选择是采用现成的 NoSQL 存储,并实现扩展、故障转移集群、冲突解决,并自行实现事务和 SQL。 乍一看,即使是实现 SQL 的任务,更不用说 ACID 事务,看起来也是一项需要数年时间的任务。 但后来我们意识到我们在实践中使用的 SQL 功能集与 ANSI SQL 相差甚远
卡桑德拉和 CQL
那么,Cassandra 有什么有趣的地方,它有哪些功能呢?
首先,在这里您可以创建支持各种数据类型的表;您可以对主键执行 SELECT 或 UPDATE。
CREATE TABLE photos (id bigint KEY, owner bigint,…);
SELECT * FROM photos WHERE id=?;
UPDATE photos SET … WHERE id=?;
为了确保副本数据的一致性,Cassandra 使用
当我们联系三个节点并收到两个节点的响应时的方法称为
Cassandra 的另一个好处是批处理日志,这是一种确保您所做的一批更改要么完全应用要么根本不应用的机制。 这使我们能够解决 ACID 中的 A - 开箱即用的原子性。
Cassandra 中最接近交易的是所谓的“
我们在 Cassandra 中缺少什么
因此,我们必须在 Cassandra 中实现真正的 ACID 事务。 使用它,我们可以轻松实现经典 DBMS 的另外两个方便的功能:一致的快速索引,这将使我们不仅可以通过主键执行数据选择,还可以使用单调自动递增 ID 的常规生成器。
锥体
于是一个新的DBMS诞生了 锥体,由三种类型的服务器节点组成:
- 存储 –(几乎)标准 Cassandra 服务器负责在本地磁盘上存储数据。 随着数据负载和数据量的增长,它们的数量可以轻松扩展到数十甚至数百。
- 交易协调员 - 确保交易的执行。
- 客户端是实现业务操作和发起事务的应用服务器。 这样的客户可能有数千个。
所有类型的服务器都是公共集群的一部分,使用内部 Cassandra 消息协议相互通信,
客户
使用胖客户端模式而不是标准驱动程序。 这样的节点不存储数据,但可以充当请求执行的协调器,即客户端本身充当其请求的协调器:它查询存储副本并解决冲突。 这不仅比需要与远程协调器通信的标准驱动程序更可靠、更快速,而且还允许您控制请求的传输。 在客户端上打开的事务之外,请求将发送到存储库。 如果客户端打开了一个事务,那么该事务内的所有请求都会发送到事务协调器。
C*One 事务协调器
协调器是我们从头开始为 C*One 实现的东西。 它负责管理事务、锁以及事务应用的顺序。
对于每个服务事务,协调器都会生成一个时间戳:每个后续事务都大于前一个事务。 由于 Cassandra 的冲突解决系统基于时间戳(对于两个冲突记录,具有最新时间戳的记录被视为当前记录),因此冲突将始终以有利于后续事务的方式得到解决。 因此我们实现了
闭塞
为了保证隔离性,我们决定使用最简单的方法——基于记录主键的悲观锁。 换句话说,在事务中,必须首先锁定记录,然后才能读取、修改和保存。 只有成功提交后才能解锁记录,以便竞争事务可以使用它。
在非分布式环境中实现这种锁定很简单。 在分布式系统中,有两个主要选择:要么在集群上实现分布式锁定,要么分布式事务,以便涉及相同记录的事务始终由同一协调器提供服务。
由于在我们的例子中,数据已经分布在 SQL 中的本地事务组中,因此决定将本地事务组分配给协调器:一个协调器使用 0 到 9 的令牌执行所有事务,第二个协调器使用 10 到 19 的令牌执行所有事务,等等。 结果,每个协调器实例都成为事务组的主实例。
然后锁可以在协调器的内存中以平庸的 HashMap 的形式实现。
协调器故障
由于一个协调器专门服务于一组事务,因此快速确定其失败的事实非常重要,以便第二次尝试执行事务时会超时。 为了使其快速可靠,我们使用了完全连接的仲裁心跳协议:
每个数据中心至少托管两个协调器节点。 每个协调器定期向其他协调器发送心跳消息,并通知它们其功能以及上次从集群中的哪些协调器接收到的心跳消息。
从其他节点接收到类似的信息作为其心跳消息的一部分,每个协调器根据仲裁原则自行决定哪些集群节点正在运行,哪些不运行:如果节点 X 已从集群中的大多数节点接收到有关正常运行的信息,收到来自节点 Y 的消息,则 , Y 工作。 反之亦然,一旦大多数人报告节点 Y 丢失消息,那么 Y 就会拒绝。 奇怪的是,如果仲裁通知节点 X 它不再接收来自它的消息,那么节点 X 本身就会认为自己发生了故障。
心跳消息发送频率较高,每秒约20次,周期为50ms。 在 Java 中,由于垃圾收集器造成的暂停时间相当长,很难保证应用程序在 50 毫秒内响应。 我们能够使用 G1 垃圾收集器来实现此响应时间,这使我们能够指定 GC 暂停持续时间的目标。 然而,有时收集器暂停超过 50 毫秒(这种情况很少见),这可能会导致错误的故障检测。 为了防止这种情况发生,当远程节点的第一个心跳消息消失时,只有当多个心跳消息连续消失时,协调器才报告远程节点的故障。这就是我们在 200 中检测到协调器节点故障的方法多发性硬化症。
但仅仅快速了解哪个节点已停止运行还不够。 我们需要对此做点什么。
预订
经典方案涉及,如果主节点发生故障,则使用以下之一开始新的选举
假设我们要执行第 50 组中的交易。我们提前确定替换方案,即当主协调器发生故障时,哪些节点将执行第 50 组中的交易。 我们的目标是在数据中心发生故障时维持系统功能。 让我们确定第一个保留将是来自另一个数据中心的节点,第二个保留将是来自第三个数据中心的节点。 该方案被选择一次,并且不会改变,直到集群的拓扑发生变化,即直到新节点进入其中(这种情况很少发生)。 如果旧主站发生故障,选择新活动主站的过程始终如下:第一个备用主站将成为活动主站,如果它已停止运行,则第二个备用站将成为活动主站。
该方案比通用算法更可靠,因为要激活新的主设备,确定旧主设备的故障就足够了。
但客户如何了解现在哪个师傅在工作呢? 在 50 毫秒内向数千个客户端发送信息是不可能的。 当客户端发送打开事务的请求时,可能会出现这种情况,但还不知道该主服务器不再运行,并且该请求将超时。 为了防止这种情况发生,客户会推测性地立即向组主及其两个储备发送打开交易的请求,但只有当前活跃的主才会响应此请求。 客户端将仅与活动主机进行事务内的所有后续通信。
备份主机将收到的不属于自己的事务请求放入未出生事务队列中,并在其中存储一段时间。 如果活动主控器死亡,新主控器会处理从其队列中打开事务的请求并响应客户端。 如果客户端已经与旧主服务器打开了交易,则第二个响应将被忽略(并且显然,这样的交易将不会完成并且将被客户端重复)。
交易如何运作
假设客户端向协调器发送了一个请求,要求为具有这样那样主键的这样那样的实体打开事务。 协调器锁定该实体并将其放入内存中的锁表中。 如有必要,协调器从存储中读取该实体,并将结果数据以事务状态存储在协调器的内存中。
当客户端想要更改事务中的数据时,它会向协调器发送修改实体的请求,协调器将新数据放入内存中的事务状态表中。 这样就完成了录制 - 不会对存储进行任何录制。
当客户端请求自己更改的数据作为活动事务的一部分时,协调器将执行以下操作:
- 如果 ID 已存在于事务中,则从内存中获取数据;
- 如果内存中没有ID,则从存储节点读取缺失的数据,与内存中已有的数据结合,并将结果给出给客户端。
因此,客户端可以读取自己的更改,但其他客户端看不到这些更改,因为它们仅存储在协调器的内存中;它们尚未存储在 Cassandra 节点中。
当客户端发送提交时,协调器将服务内存中的状态保存在记录批次中,并作为记录批次发送到 Cassandra 存储。 商店会做一切必要的事情来确保该包被原子地(完全)应用,并向协调器返回响应,协调器释放锁并向客户端确认事务成功。
而要回滚,协调器只需要释放事务状态占用的内存即可。
由于上述改进,我们实施了 ACID 原则:
- 原子性。 这是保证系统中不会部分记录任何事务;要么其所有子操作都将完成,要么一个都不会完成。 我们通过在 Cassandra 中记录批处理来遵守这一原则。
- 一致性。 根据定义,每笔成功的交易仅记录有效结果。 如果开启事务并执行部分操作后发现结果无效,则进行回滚。
- 隔离。 当执行事务时,并发事务不应影响其结果。 使用协调器上的悲观锁来隔离竞争事务。 对于事务外的读取,在读已提交级别遵守隔离原则。
- 稳定。 无论较低级别出现什么问题(系统中断、硬件故障),成功完成的事务所做的更改都应在操作恢复时保留下来。
按索引读取
我们来看一个简单的表格:
CREATE TABLE photos (
id bigint primary key,
owner bigint,
modified timestamp,
…)
它有一个 ID(主键)、所有者和修改日期。 您需要提出一个非常简单的请求 - 选择所有者的数据,更改日期为“最后一天”。
SELECT *
WHERE owner=?
AND modified>?
为了快速处理这样的查询,在经典的 SQL DBMS 中,您需要按列(所有者、修改)构建索引。 我们可以很容易地做到这一点,因为我们现在有 ACID 保证!
C*One 中的索引
有一个包含照片的源表,其中记录 ID 是主键。
对于索引,C*One 创建一个新表,该表是原始表的副本。 键与索引表达式相同,还包括源表中记录的主键:
现在,“最后一天的所有者”的查询可以重写为从另一个表中进行选择:
SELECT * FROM i1_test
WHERE owner=?
AND modified>?
源表photos和索引表i1中数据的一致性由协调器自动维护。 仅基于数据模式,当收到更改时,协调器不仅会在主表中生成并存储更改,还会在副本中生成更改。 不对索引表执行任何其他操作,不读取日志,也不使用锁。 也就是说,添加索引几乎不消耗任何资源,并且对应用修改的速度几乎没有影响。
使用 ACID,我们能够实现类似 SQL 的索引。 它们是一致的、可扩展的、快速的、可组合的,并且内置于 CQL 查询语言中。 无需更改应用程序代码即可支持索引。 一切都像 SQL 一样简单。 而且最重要的是,索引不会影响对原始事务表的修改的执行速度。
发生了什么
我们三年前开发了C*One并投入商业运营。
我们最终得到了什么? 让我们使用照片处理和存储子系统的示例来评估这一点,照片处理和存储子系统是社交网络中最重要的数据类型之一。 我们谈论的不是照片本身,而是各种元信息。 现在 Odnoklassniki 拥有大约 20 亿条此类记录,系统每秒处理 80 万个读取请求,每秒最多处理 8 个与数据修改相关的 ACID 事务。
当我们使用复制因子 = 1 的 SQL(但在 RAID 10 中)时,照片元信息存储在运行 Microsoft SQL Server(加上 32 个备份)的 11 台计算机组成的高可用集群上。 还分配了 10 台服务器用于存储备份。 总共50辆昂贵的汽车。 同时,系统在额定负载下运行,无备用。
迁移到新系统后,我们收到复制因子 = 3 - 每个数据中心都有一个副本。 该系统由63个Cassandra存储节点和6个协调器机器组成,总共69台服务器。 但这些机器要便宜得多,它们的总成本约为 SQL 系统成本的 30%。 同时,负载保持在30%。
随着 C*One 的引入,延迟也减少了:在 SQL 中,写入操作大约需要 4,5 毫秒。 在 C*One 中 - 大约 1,6 毫秒。 事务时长平均小于40ms,提交2ms完成,读写时长平均2ms。 第 99 个百分位——仅 3-3,1 毫秒,超时次数减少了 100 倍——这一切都归功于投机的广泛使用。
目前,大部分SQL Server节点已经退役;新产品仅使用C*One进行开发。 我们调整了 C*One 以在我们的云中工作
现在我们正在努力将其他存储设施转移到云端——但这是一个完全不同的故事。
来源: habr.com