正如我们之前讨论的,关系数据库系统在设计时并没有考虑到可扩展性。它们对于实现强数据保证(包括ACID事务)是很有效的,但是关系数据库倾向于基于单台服务器的方式进行部署。
这种特性造成了基于关系数据库的应用程序在规模上的限制。
值得注意的是,数据库服务器可以纵向扩展,这意味着要使用更好的硬件。增加服务器的容量或用更高级的服务器取代它,对于高端需求来说,这是实现起来比采用这些技术更容易的解决方案,但这种扩展方式也是受限的。无论什么时候都应仔细核对预期的系统规模是否足够大。目前,在云计算供应商中,有的服务器拥有高达1TB甚至更大的内存。这完全可以满足大量应用场景的需求。
请注意,这些技术对于扩充上线后的系统的规模是非常有用的,而且适用于大多数关系数据库。
ACID特性的不足之处在于最终一致性(eventual consistency)。其数据处理不是一次性得到处理后的结果的原子操作,而是逐渐转变为最终的状态。即在同一时间系统的每个部分并非都有相同的状态。因此,这种状态变化在系统中传播时会有一定的延迟。这类系统还有一个显著的优点,就是我们可以提高其可用性,因为它不依赖单个节点,任何不可用的节点都可从故障状态恢复。基于集群的分布式特性,这个过程中需要咨询其他各节点,并通过仲裁机制尝试建立足够数量的有效冗余节点。
在考虑是否值得放宽某些ACID特性的约束时,在很大程度上取决于你所关注的应用。关键数据延时或数据损坏问题对系统影响会更大,如果无法接受这种状况,也许并不适合用分布式数据库。
为了扩充数据库系统的规模,首先要了解应用程序的数据访问模型是什么样的。
很常见的一种情况是,数据读取量远高于写入量。或者用SQL术语来说,SELECT语句的数量远多于UPDATE或DELETE语句。这是非常典型的应用场景,在这些应用中,对信息的查询操作远多于对信息的更新操作,例如,针对一份报纸,会有大量的访问来阅读其新闻和文章,但相比之下,新的文章并没有那么多。
针对这种情况的常用模式是创建一个集群,加入一个或多个数据库的只读副本(replica),然后将对数据库的读操作分摊到这些副本中,如图3-2所示。
对数据的所有写操作都会交给主库(primary)节点,然后数据的变更会自动传播到副本节点。因为副本节点同样包含了整个数据库,而唯一的写操作仅存在于主库节点,这样就加大了系统中可以同时运行的读查询的数量。
图3-2 处理多个读查询
大多数关系数据库都支持这种机制,尤其是最常见的MySQL和PostgreSQL数据库。写入节点被配置为主节点,副本节点指向主节点以复制数据。一段时间后,副本节点就有了最新的数据,并与主数据库保持同步。
主节点的所有数据变化都会被自动复制。不过,复制过程会有点延迟,称为复制延时(replication lag)。这意味着刚写入的数据在一段时间内是无法被读取的,这个时间通常少于一秒钟。
复制延时是衡量数据库健康状况的有效指标。如果延时随着时间的推移而增加,则表明集群没有足够的能力处理当前的访问流量,需要对其进行调整。每个节点的网络状况、设备的基本性能都会对延时的大小产生显著影响。
因此,要避免出现的情况是,在外部数据操作中写入并立即读取该数据或相关的数据,因为这可能会导致数据不一致。这个问题可以通过临时保留数据来解决,以避免立即查询,或者以指定读取主节点数据的方式来解决,从而确保数据的一致性,如图3-3所示。
直接读取主节点的方式应当仅用于必要时,因为这样做不符合减少主服务器查询负担的理念,这也是设立多台服务器的原因!
图3-3 指定读取主库节点的查询
这种机制也可以实现数据冗余,因为主库的数据会持续复制到副本中。当主库出现问题时,副本节点可以被提升为新的主节点。
副本服务器与备份(backup)的作用并不完全相同,尽管它可以用于类似的目的。副本的目的是实现快速操作,并保持系统的可用性。备份实现起来更容易、更便宜,让你可以保留数据的历史记录。备份有可能位于与副本完全不一样的位置,而副本节点则需要与主库服务器之间有良好的网络连接。
不要忽视备份系统,即使在有副本可用的情况下。当发生灾难性故障时,备份系统能提供多一层的安全保障。
请注意,这种结构的数据库部署方式可能需要调整应用程序的等级,以了解所有的变化和对不同数据库服务器的访问。有些现成的工具,如Pgpool(用于PostgreSQL)或ProxySQL(用于MySQL),它们作为数据库访问的中间人并重定向数据访问操作。应用程序将查询发送到这些工具提供的代理程序,然后代理程序根据配置将其重定向。类似上文中的读写模式这样的情况,通过工具难以解决,此时可能需要对应用程序代码进行专门的修改。在应用程序中运行这些工具之前,一定要了解这类工具的工作原理,并进行测试和验证。
基于这种部署结构更简单的案例是采用离线副本(offline replica)。离线副本可以来自备份数据,并且不做实时数据更新。这些副本对于创建无须最新信息的查询非常有用,这种情况下,也许每天的快照就足够了。离线副本在统计分析或数据仓库(data warehouse)等应用中很常见。
如果应用程序有较多的写操作,则采用主库/副本架构可能不太合适。太多的写入操作被导向同一台服务器,这样就会形成性能瓶颈。或者,如果系统流量增长得太快,就会超过单台服务器所能承载的写入操作上限。
可采取的解决方案之一,是对数据进行水平分区(horizontal partitioning)。这意味着根据某个特定的键将数据分为不同的数据库,将所有相关的数据放入同一个服务器。每个不同的区(partition)被称为一个分片(shard)。
请注意,“分区”(partitioning)和“分片”(sharding)可以当作同义词,尽管在现实中,分片只是用于水平分区的情况下,将一张表分离到不同的服务器。分区的操作则更广泛,比如把一个表分成两个,或者分成不同的列,这种操作通常不叫分片。
分区键(partition key)被称为分片键(shard key),根据它的值的不同,每一行数据记录将被分配到一个特定的分片,如图3-4所示。
图3-4 分片键
分片这一名称来自电子游戏《网络创世纪》( Ultima Online ),该游戏在20世纪90年代末使用类似的策略创造了一个“多元宇宙”,多位玩家可以在不同的服务器上玩同一个游戏。玩家们称之为“shard”(游戏中译为碎片),因为这些碎片是同一现实的各个方面,其中包含了不同的玩家。shard这个名称延续至今,依然被用来描述这种结构。
所有数据操作都需要先确定适合采取哪种分片。任何涉及两个或更多分片的操作都有可能无法进行,或者只能依次执行。当然,这样就排除了在一个事务中执行这些操作的可能性。无论何时,这些操作的代价都会非常高,应当尽可能避免。当数据分区顺利时,分片是一个很好的主意,而当执行影响多个分片的查询时,采用分片就非常糟糕。
有些NoSQL数据库允许本地分片能自动处理所有这些问题。常见的例子之一是MongoDB,它甚至能够以一种透明的方式在多个分片中运行查询。不过,这些查询通常都会比较慢。
分片键的选择也很关键。一个好的键应该遵循数据之间的自然分区特性,因此不需要执行跨分片的查询。比如,如果用户的数据是独立于其他用户的,这种情况可能发生在照片共享应用中,此时,用户标识符可能就是一个很好的分片键。
还有一个重要的特性就是,分片查询的实现取决于分片键。也就是说每个查询都要有分片键可用,分片键应当作为每个操作的输入。
分片键的另一个属性是,数据最好以分片的方式进行分配,即分片具有相同的大小,或者至少它们足够相似。如果一个分片比其他分片大得多,可能会导致数据不平衡的问题,使得查询的任务分布不够充分,并且可能会导致部分分片存在性能瓶颈。
完全分片
完全分片时,数据会全都被分割成分片,每个操作的输入都包含分片键。分片是根据分片键来确定的。
为了确保分片的均衡,每个键都以在多个分片之间平均分布的方式进行散列。例如,典型的情况是使用mod(取模)操作。现在有8个分片,可以根据一个平均分配的数字来确定数据被划分到哪个分片,如下表所示。
如果分片键不是数字,或者它不是均匀分布的,那么可以使用散列函数。例如,在Python中的实现如下:
这种策略只有在分片键总是可以作为每个操作的输入时才能实现。当这种方式不可行时,我们需要采用其他方法。
改变分片的数量不是一件容易的事,因为每个键的目的地都是由一个固定的公式决定的。不过,在事先做好准备的情况下,增加或减少分片的数量是可行的。
还可以创建指向同一服务器的“虚拟分片”。例如,要创建100个分片,并使用两个服务器,最初的虚拟分片分布是这样的。
如果需要增加服务器的数量,虚拟分片的构成就会变成这样。
调整每个分片所对应的服务器可能需要对程序代码做一些修改,但由于分片键的计算没有变动,所以比较容易处理。也可以反过来做类似的操作,但可能会造成负载不平衡,所以需要谨慎行事。
每次操作都需要根据分片键调整数据的位置。这是比较耗费资源的操作,尤其是在需要交换大量数据的时候。
混合分片
有时无法做到完全分片,而是需要对输入数据进行转换以确定分片键。例如,如果分片键是用户ID,那么当用户登录时就会出现这种情况。用户使用他们的电子邮件信息登录,但邮件地址需要被转换成对应的用户ID,才能确定要搜索的分片信息。
在这种情况下,可以简单地使用一个外部表(external table)来将特定查询的输入转换成分片键,如图3-5所示。
图3-5 转换分片键输入的外部表
因此就会出现这样一种情况,即由某个分片负责这个转换层。该分片可以专门用于此,也可以用于任意其他分片。
请记住,当参数不是分片键时,这种方式对每个可能的输入参数来说都需要转换层,而且需要将所有分片的信息都保存在一个数据库中。所以我们应当对其加以控制,存储尽可能少的信息以避免出现问题。
这种策略也可以用来直接存储从分片键到分片的对应关系,且可供查询,而不是像我们上面看到的那样直接操作,如图3-6所示。
图3-6 将分片键存储到分片中
这样做的不便之处在于,根据分片键确定分片时,需要先在数据库中进行查询,特别是对于一个大的数据库来说。但是这种做法提供了统一的方式来实现对数据分片的修改,因而可以用来调整分片的数量,比如增加或减少分片。而且这些调整可以在无须系统停机的情况下完成。
如果是具体的分片而不仅是分片键被存储在这个转换表中,那么分片键到分片所对应的分配过程就可以逐一进行修改,而且能以连续的方式进行。这个过程大致是这样的:
1.分片键X被分配给参考表中的服务器A,这是起始状态。
2.分片键X的数据从服务器A被复制到服务器B。请注意,还没有涉及分片键X的查询指向服务器B。
3.一旦所有的数据复制完成,参考表中分片键X对应的条目就会被改为服务器B。
4.后续所有关于分片键X的查询都被引导到服务器B。
5.清理服务器A中的分片键X相关的数据。
第3步是关键的一步,需要在所有的数据被复制后,在执行新的写入之前进行。确保这一点的方法是在参考表中设置一个标志,使得在相关操作进行时停止或延迟数据的写入。这个标志将在开始第2步之前设置,并在第3步完成之后移除。
随着时间的推移,以上流程将实现平滑的分片迁移,但它需要足够的工作空间,还有可能需要大量的时间。
缩减系统规模的操作比规模扩展更为复杂,因为扩展时只需增加容量即可具备充足的空间。幸运的是,数据库集群需要进行规模缩减的情况很少,因为大多数应用程序所需资源会随着时间的推移而增长。
请留出充足的时间来完成数据迁移工作。取决于数据集的大小和复杂性的差异,迁移可能需要很长时间,极端情况下可能需要数小时甚至几天。
表分片
对于较小的集群来说,按分片键来实施分片的另一种方法是按服务器来分离表或集合。这意味着表X中的所有查询都会被引导到某个特定的服务器上,而其他表的查询则被引导到另一个服务器上。这种策略只适用于不相关的表,因为不可能在不同服务器的表之间进行连接(join)操作。
请注意,这样通常会被当作呆板的做法,因为没有合理地进行分片,尽管数据组织结构类似。
这种做法并不复杂,但它的灵活性要差一些。建议仅用于相对较小的集群,且其中的一两个表和其他表之间在规模上存在明显不平衡的场景,例如,某个表存储的日志比数据库中其他表大得多,并且它很少被访问。
综上所述,分片的主要优势是:
❍将写操作分散到多个服务器上,增加系统的写吞吐量。
❍数据被存储在多个服务器中,因此可以存储大量的数据,不受单个服务器可存储数据容量的限制。
从本质上讲,采用分片可以创建大型且可扩展的系统。但它也有劣势:
❍分片系统实现比较复杂,在配置多个服务器等方面有一些开销。虽然任何大型系统的部署都会有其问题,但部署分片需要比主库/副本模式更大的工作量,因为其维护和管理需要更周密的计划,所需运维时间也更长。
❍只有少数数据库(如MongoDB)提供了对分片的原生支持,但关系数据库并没有原生实现这一功能。这意味着需要用专门的代码来处理这些复杂问题,因而需要一定的开发投入。
❍数据被分片之后,某些查询将不可能或几乎不可能执行。比如聚合(aggregation)和连接操作,这些依赖于数据分区方式的查询,都将无法实现。分片键需要仔细选择,因为它会对哪些查询可行、哪些不可行产生很大的影响。数据库系统的ACID特性也不复存在,因为有些操作可能会涉及一个以上的分片。总之,分片数据库的灵活性较差。
正如我们所看到的,设计、操作和维护一个分片数据库只对非常大的系统有意义,当系统中的数据操作数量足够大时,才需要这种复杂的系统。