如果您正在阅读本文,这将意味着您对 Redis 是什么以及如何在 web 和商业应用程序中使用它有相当多的了解。除此之外,假设您对它所能容纳的数据结构以及如何在应用程序中使用它们也有相当多的了解,这是公平的。
在本章中,我们将继续讨论在生产环境中部署 Redis 应用程序需要采取的步骤。嗯,在生产环境中部署总是很棘手,需要对体系结构和业务需求有更深入的了解。由于我们无法设想应用程序必须满足的业务需求,但我们始终可以抽象出非功能需求,因此大多数应用程序都具有并创建了读者认为合适的模式。
当我们思考或谈论生产环境时,会想到的一些最常见的非功能性需求如下所示:
- 表演
- 可利用性
- 可伸缩性
- 可管理性
- 安全
所有提到的非功能性需求总是以我们为部署架构创建蓝图的方式来解决。接下来,我们将考虑这些非功能性需求,并将它们映射到我们将讨论的集群模式。
计算机集群由一组松散或紧密连接的计算机组成,这些计算机协同工作,因此在许多方面,它们可以被视为一个单一的系统。此信息的来源为http://en.wikipedia.org/wiki/Computer_cluster 。
我们对系统进行集群有多种原因。企业有增长的需求,这些需求必须与成本效益和解决方案的未来路线图相匹配;因此,选择集群解决方案总是有意义的。一台处理所有通信量的大型计算机总是可取的,但垂直可扩展性的问题是芯片计算能力的上限。此外,与具有相同计算能力的一组较小机器相比,较大机器的成本总是更高。除了成本效益之外,集群可以处理的其他非功能性需求还有性能、可用性和可伸缩性。然而,拥有集群也会增加可管理性、可维护性和安全性。
随着现代网站产生的流量,集群不仅是一个低成本的选择,而且是唯一剩下的选择。考虑到这一点,让我们看看各种集群模式,看看它们如何与 Redis 相适应。可以为基于 Redis 的集群开发两种模式:
- 大师
- 主从
这种集群模式是为应用程序创建的,在这些应用程序中,读取和写入非常频繁,节点之间的状态在任何给定时间点都需要相同。
从非功能性需求的角度来看,在主设置中可以看到以下行为:
主-主集群模式中的 getter 和 setter
在这种设置中,读取和写入的性能非常高。由于请求在主节点之间进行负载平衡,因此主节点上的单个负载减少,从而产生更好的性能。由于 Redis 本身不具备此功能,因此必须在机箱外提供此功能。保持在主集群前面的写复制器和读负载平衡器可以实现这一点。我们在这里所做的是,如果有写入请求,数据将写入所有主节点,并且所有读取请求可以在任何主节点之间分割,因为所有主节点中的数据都处于一致状态。
我们必须记住的另一个方面是,当数据量非常大时。如果数据量很高,那么我们需要在主节点设置中创建碎片(节点)。单个主节点中的这些碎片可以基于密钥分发数据。在本章后面,我们将讨论 Redis 中的分片功能。
分片环境中的读写操作
数据的可用性很高,或者更确切地说取决于为复制而维护的主节点的数量。所有主节点的数据状态都是相同的,因此即使其中一个主节点停机,其余的主节点也可以满足请求。在这种情况下,应用程序的性能将下降,因为请求必须在其余的主节点之间共享。如果数据分布在主节点内的各个分片上,如果其中一个分片出现故障,则其他主节点中的副本分片可以满足对该分片的请求。这仍将使受影响的主节点保持工作状态(但不会完全工作)。
在这种情况下,可伸缩性问题有点棘手。设置新主节点时,必须注意以下事项:
- 新主节点必须与其他主节点处于相同的状态。
- 新主节点的信息必须添加到客户机 API 中,因为客户机可以在管理数据写入和数据读取的同时获取此新主节点。
- 对于这些任务,需要一段停机时间,这将影响可用性。此外,在调整主节点中的主节点或分片节点的大小之前,必须考虑数据量,以避免出现这些情况。
这种集群模式的可管理性需要在节点级别和客户机级别进行努力。这是因为 Redis 没有为此模式提供内置机制。由于执行数据复制和数据加载的责任由客户机适配器承担,因此需要解决以下问题:
- 客户端适配器必须考虑节点(主节点或分片)故障时的可用性。
- 如果添加了新的主节点,客户端适配器必须考虑可用性。
- 应该避免在已经配置的分片生态系统中添加新的分片,因为分片技术基于客户端适配器生成的唯一密钥。这取决于在应用程序开始时配置的分片节点,添加新的分片将干扰已设置的分片和其中的数据。这将使整个应用程序处于不一致的状态。这个新碎片将从主节点生态系统中的其他碎片复制一些数据。因此,选择这样做的方法是引入一致散列来生成分配主节点的唯一密钥。
Redis 是一个重量非常轻的数据存储,从安全角度来看,它提供的功能非常有限。这里的期望是,Redis 节点将在一个安全的环境中进行配置,在这个环境中,责任是不受限制的。然而,Redis 确实提供了某种形式的安全性,即连接到节点的用户名/密码身份验证。此机制有其局限性,因为密码以明文形式存储在Config
文件中。另一种形式的安全性是混淆命令,使其不能意外调用。在集群模式中,我们正在讨论它的用途有限,更多的是从程序的角度。
这种模式有一些灰色地带,我们在决定采用它之前需要注意。要使此模式在生产环境中工作,需要有计划的停机时间。如果一个节点发生故障,并且向集群添加了一个新节点,则此停机时间非常重要。此新节点必须与其他副本主节点具有相同的状态。另一件需要注意的事情是数据容量规划,如果低估了这一点,那么在分片环境中进行水平扩展将是一个问题。在下一节中,我们将运行一个示例,在该示例中,我们添加了另一个节点,并看到了不同的数据分布,这可以为我们提供问题的提示。数据清除是 Redis 服务器没有解决的另一个功能,因为它意味着将所有数据保存在内存中。
切分是一种水平分割数据并将其放置在不同节点(机器)中的机制。这里,驻留在不同节点或机器中的每个数据分区形成一个碎片。分片技术在将数据存储扩展到多个节点或机器时非常有用。如果切分操作正确,可以提高系统的整体性能。
它还可以克服购买更大机器的需要,并可以用更小的机器完成工作。切分可以提供部分容错,因为如果一个节点发生故障,那么到达该特定节点的请求将无法得到服务,除非所有其他节点都能满足传入的请求。
Redis 没有提供直接的机制来支持内部数据分片,因此为了实现数据分区,在分割数据时必须从客户端 API 应用技术。由于 Redis 是一个键值数据存储,因此可以基于可映射到节点的算法生成唯一 ID。因此,如果出现读取、写入、更新或删除请求,算法可以生成相同的唯一密钥,或者将其定向到可以执行操作的映射节点。
我们在本书中使用的客户机 API Jedis 确实提供了一种基于密钥的数据切分机制。让我们尝试一个示例,看看数据在节点之间的分布情况。
从至少两个节点开始。该程序已在前几章中讨论过。在当前示例中,我们将在端口 6379 上启动一个节点,在 6380 上启动另一个节点。第一个碎片节点应类似于以下屏幕截图:
第一个碎片节点的屏幕截图
第二个碎片节点的外观应与以下屏幕截图类似:
第二个碎片节点的屏幕截图
让我们打开编辑器并输入以下程序:
package org.learningredis.chap8;
import java.util.Arrays;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisShardInfo;
import redis.clients.jedis.ShardedJedis;
public class Test {
public static void main(String[] args) {
Test test = new Test();
test.evaluateShard();
}
private void evaluateShard() {
// Configure Jedis sharded connection pool.
JedisShardInfo shard_1 = new JedisShardInfo("localhost", 6379);
JedisShardInfo shard_2 = new JedisShardInfo("localhost", 6380);
ShardedJedis shardedJedis = new ShardedJedis(Arrays.asList(shard_1, shard_2));
// Looping to set values in the shard we have created..
for (int i = 0; i < 10; i++) {
shardedJedis.set("KEY-" + i, "myvalue-" + i);
}
// Lets try to read all the values from SHARD -1
for (int i = 0; i < 10; i++) {
Jedis jedis = new Jedis("localhost", 6379);
if (jedis.get("KEY-" + i) != null) {
System.out.println(jedis.get("KEY-" + i) + " : this is stored in SHARD-1");
}
}
// Lets try to read all the values from SHARD -2
for (int i = 0; i < 10; i++) {
Jedis jedis = new Jedis("localhost", 6380);
if (jedis.get("KEY-" + i) != null) {
System.out.println(jedis.get("KEY-" + i) + " : this is stored in SHARD-2");
}
}
// Lets try to read data from the sharded jedis.
for (int i = 0; i < 10; i++) {
if (shardedJedis.get("KEY-" + i) != null) {
System.out.println(shardedJedis.get("KEY-" + i));
}
}
}
}
控制台输出中的响应如下:
myvalue-1 : this is stored in SHARD-1
myvalue-2 : this is stored in SHARD-1
myvalue-4 : this is stored in SHARD-1
myvalue-6 : this is stored in SHARD-1
myvalue-9 : this is stored in SHARD-1
myvalue-0 : this is stored in SHARD-2
myvalue-3 : this is stored in SHARD-2
myvalue-5 : this is stored in SHARD-2
myvalue-7 : this is stored in SHARD-2
myvalue-8 : this is stored in SHARD-2
myvalue-0
myvalue-1
myvalue-2
myvalue-3
myvalue-4
myvalue-5
myvalue-6
myvalue-7
myvalue-8
myvalue-9
可对样品进行以下观察:
- 数据分布是随机的,这基本上取决于用于分布程序或碎片的散列算法
- 同一程序多次执行,结果相同。这意味着哈希算法与它为密钥创建的哈希一致。
- 如果密钥改变,那么数据的分布将不同,因为将为相同的给定密钥生成新的散列码;因此,一个新的目标碎片。
添加新碎片而不清理其他碎片:
-
Start a new master at 6381:
-
让我们键入一个新的程序,其中添加了客户端的新碎片信息:
package org.learningredis.chap8; import java.util.Arrays; import redis.clients.jedis.Jedis; import redis.clients.jedis.JedisShardInfo; import redis.clients.jedis.ShardedJedis; public class Test { public static void main(String[] args) { Test test = new Test(); test.evaluateShard(); } private void evaluateShard() { // Configure Jedis sharded connection pool. JedisShardInfo shard_1 = new JedisShardInfo("localhost", 6379); JedisShardInfo shard_2 = new JedisShardInfo("localhost", 6380); JedisShardInfo shard_3 = new JedisShardInfo("localhost", 6381); ShardedJedis shardedJedis = new ShardedJedis(Arrays.asList(shard_1, shard_2, shard_3)); // Looping to set values in the shard we have created.. for (int i = 0; i < 10; i++) { shardedJedis.set("KEY-" + i, "myvalue-" + i); } // Lets try to read all the values from SHARD -1 for (int i = 0; i < 10; i++) { Jedis jedis = new Jedis("localhost", 6379); if (jedis.get("KEY-" + i) != null) { System.out.println(jedis.get("KEY-" + i) + " : this is stored in SHARD-1"); } } // Lets try to read all the values from SHARD -2 for (int i = 0; i < 10; i++) { Jedis jedis = new Jedis("localhost", 6380); if (jedis.get("KEY-" + i) != null) { System.out.println(jedis.get("KEY-" + i) + " : this is stored in SHARD-2"); } } // Lets try to read all the values from SHARD -3 for (int i = 0; i < 10; i++) { Jedis jedis = new Jedis("localhost", 6381); if (jedis.get("KEY-" + i) != null) { System.out.println(jedis.get("KEY-" + i) + " : this is stored in SHARD-3"); } } // Lets try to read data from the sharded jedis. for (int i = 0; i < 10; i++) { if (shardedJedis.get("KEY-" + i) != null) { System.out.println(shardedJedis.get("KEY-" + i)); } } } }
-
结果将是,如下所示,我们可以看到
SHARD_1
和SHARD_2
的数据在SHARD_3
中得到复制。由于之前的执行,此复制数据只是SHARD_1
和SHARD_2
中较旧的数据。在生产环境中,这可能是危险的,因为它增加了无法解释的死数据:myvalue-1 : this is stored in SHARD-1 myvalue-2 : this is stored in SHARD-1 myvalue-4 : this is stored in SHARD-1 myvalue-6 : this is stored in SHARD-1 myvalue-9 : this is stored in SHARD-1 myvalue-0 : this is stored in SHARD-2 myvalue-3 : this is stored in SHARD-2 myvalue-5 : this is stored in SHARD-2 myvalue-7 : this is stored in SHARD-2 myvalue-8 : this is stored in SHARD-2 myvalue-4 : this is stored in SHARD-3 myvalue-6 : this is stored in SHARD-3 myvalue-7 : this is stored in SHARD-3 myvalue-8 : this is stored in SHARD-3 myvalue-9 : this is stored in SHARD-3 myvalue-0 myvalue-1 myvalue-2 myvalue-3 myvalue-4 myvalue-5 myvalue-6 myvalue-7 myvalue-8 myvalue-9
-
为同一组数据添加一个新的主节点,并清理
SHARD_1
和SHARD_2
节点中所有以前的数据,结果如下:The response in the output console should be as follows myvalue-1 : this is stored in SHARD-1 myvalue-2 : this is stored in SHARD-1 myvalue-0 : this is stored in SHARD-2 myvalue-3 : this is stored in SHARD-2 myvalue-5 : this is stored in SHARD-2 myvalue-4 : this is stored in SHARD-3 myvalue-6 : this is stored in SHARD-3 myvalue-7 : this is stored in SHARD-3 myvalue-8 : this is stored in SHARD-3 myvalue-9 : this is stored in SHARD-3 myvalue-0 myvalue-1 myvalue-2 myvalue-3 myvalue-4 myvalue-5 myvalue-6 myvalue-7 myvalue-8 myvalue-9
我们可以看到数据被干净地分布在所有碎片中,没有重复,例如旧数据被清理。
这种集群模式是为读取频率非常高而写入频率较低的应用程序创建的。此模式工作所需的另一个条件是具有有限的数据大小,或者换句话说,数据容量可以适合为主机配置的硬件(从机也需要相同的硬件配置)。由于要求满足频繁读取,因此此模式还具有水平扩展的能力。我们必须记住的另一点是,从机中的复制可能会有一个时间延迟,这可能导致过时的数据得到服务。业务需求应该适合该场景。
这种模式的解决方案是将所有的写操作都写入主设备,并让从设备满足所有的读操作。从机的读取需要负载平衡,以满足性能要求。
Redis 提供了一种内置功能,可以进行主从配置,其中可以对主设备进行写操作,也可以对从设备进行读操作。
主从模式中的 getter 和 setter
从非功能性需求的角度来看,可以在主从式设置中看到的行为将在以下部分中讨论。
在这种设置中,写入的性能非常高。这是因为所有写操作都发生在单个主节点上,并且写操作的频率比假设中提到的要低。由于读取请求在从节点之间进行负载平衡,因此从节点上的单个负载减少,从而产生更好的性能。由于 Redis 本质上提供从机读取,因此除了负载平衡外,无需在机箱外提供任何内容。保持在从属节点前面的读取负载平衡器将完成此任务。我们在这里所做的是,如果存在写入请求,数据将写入主节点,所有读取请求将在所有从节点之间分割,因为所有从节点中的数据都处于最终一致状态。
在这种情况下需要注意的是,由于主设备推送新的更新数据和从设备更新数据之间的时间差,可能会出现从设备继续提供过时数据的情况。
主从集群模式中的可用性需要不同的方法,一种用于主节点,另一种用于从节点。最简单的部分是从机可用性。说到奴隶的可用性,这很容易处理,因为奴隶比主人多,即使其中一个奴隶倒下,也有其他奴隶来满足请求。在主节点的情况下,由于只有一个主节点,所以如果该节点发生故障,则会出现问题。虽然读操作将继续进行而不受影响,但写操作将停止。为了消除数据丢失,可以做两件事:
- 在主节点前面有一个消息队列,这样即使在主节点关闭的情况下,写入消息也会持续存在,稍后可以写入。
- Redis 提供了一种称为 Sentinel 的机制或观察者,可以使用它。关于哨兵的讨论已经在接下来的部分中进行了。
在这种情况下,可伸缩性的问题有点棘手,因为我们这里有两种类型的节点,它们都解决了不同的目的。这里的可伸缩性不在于跨多个服务器分发数据,而在于扩展性能。以下是一些特点:
- 主节点的大小必须根据需要保留在 RAM 中以提高性能的数据容量来确定
- 在运行时,从节点可以连接到集群,但最终它们将到达与主节点相同的状态,并且从属节点的硬件能力应该与主节点保持一致。
- 新的从属节点应注册到负载平衡器中,以便负载平衡器分发数据
这种集群模式的可管理性只需要在主节点和从节点级别以及客户端级别进行少量工作。这是因为 Redis 提供了一种内置机制来支持这种模式。由于执行数据复制和数据加载的责任是从节点,因此剩下的就是管理主节点和客户端适配器。
需要处理以下意见:
-
客户机适配器必须考虑从节点故障时的可用性。适配器必须足够智能,以避免从节点停机。
-
如果添加了新的从属节点,客户端适配器必须考虑可用性。
-
The client adaptor has to have a temporary persistence mechanism in case the master goes down.
主节点的容错
Redis 是一个非常轻量级的数据存储,从安全角度来看,它提供的功能非常有限。这里的期望是,Redis 节点将在一个安全的环境中进行配置,在这个环境中,责任是不受限制的。然而,Redis 确实提供了某种形式的安全性,即连接到节点的用户名/密码身份验证。此机制有其局限性,因为密码以明文形式存储在Config
文件中。另一种形式的安全性是混淆命令,使其不能意外调用。
在集群模式中,我们讨论的是它的用途有限,更多的是从程序的角度。另一个好的实践是使用单独的 API,这样程序就不会意外地写入从属节点(尽管这会导致错误)。以下是讨论的一些 API:
- 写 API:该组件应该是与主节点交互的程序,因为主节点可以在主从节点中进行写操作
- 读取 API:该组件应该与程序交互,从机必须获取记录
在决定采用这种模式之前,我们需要注意的灰色区域很少。最大的问题之一是数据大小。容量调整应根据主机的垂直缩放能力进行。必须为从属设备提供相同的硬件功能。另一个问题是主机将数据复制到从机时可能发生的延迟。这有时会导致在某些情况下提供过时的数据。另一个需要关注的领域是,如果主设备中存在故障转移,哨兵选择新主设备所需的时间。
此模式最适合 Redis 用作缓存引擎的场景。如果它被用作缓存引擎,那么最好在数据达到一定大小后将其逐出。在接下来的部分中,我们可以在 Redis 中使用收回策略来管理数据大小。
数据存储提供处理故障场景的能力。这些功能是内置的,不会以处理容错的方式暴露自己。Redis 从简单的键值数据存储开始,已经发展成为一个提供独立节点来处理故障管理的系统。该系统称为哨兵。
Sentinel 背后的思想是,它是一个独立的节点,跟踪主节点和其他从节点。当主节点发生故障时,它会将从属节点提升为主节点。如前所述,在主从式场景中,主设备用于写入,从设备用于读取,因此当从设备升级为主设备时,它具有读取和写入的能力。所有其他奴隶都将成为这个新奴隶主的奴隶。下图显示了 Sentinel 的工作原理:
哨兵的工作
现在让我们举一个例子来演示从 Redis 2.6 版本开始 Sentinel 是如何工作的。Sentinel 在 Windows 机器上运行时遇到问题,因此最好在*NIX 机器上执行此示例。步骤如下:
-
Start a master node as shown:
-
Start a slave as shown. Let's call it slave:
-
Let's start Sentinel as shown next:
-
让我们编写一个程序,我们将在其中执行以下操作:
- 写信给主人
- 从大师那里读
- 给奴隶写信
- 阻止主人
- 关闭主机后从主机读取数据
- 关闭主设备后读取从设备
- 给奴隶写信
- 哨兵配置
-
让我们输入程序:
package simple.sharded; import redis.clients.jedis.Jedis; public class TestSentinel { public static void main(String[] args) { TestSentinel testSentinel = new TestSentinel(); testSentinel.evaluate(); } private void evaluate() { System.out.println("-- start the test ---------"); this.writeToMaster("a","apple"); this.readFromMaster("a"); this.readFromSlave("a"); this.writeToSlave("b", "ball"); this.stopMaster(); this.sentinelKicks(); try{ this.readFromMaster("a"); }catch(Exception e){ System.out.println(e.getMessage()); } this.readFromSlave("a"); this.sentinelKicks(); this.sentinelKicks(); this.writeToSlave("b", "ball"); this.readFromSlave("b"); System.out.println("-- end of test ------ -----"); } private void sentinelKicks() { try { Thread.currentThread().sleep(10000); } catch (InterruptedException e) { e.printStackTrace(); } } private void stopMaster() { Jedis jedis = ConnectionUtill.getJedisConnection("10.207.78.5", 6381); jedis.shutdown(); } private void writeToSlave(String key , String value) { Jedis jedis = ConnectionUtill.getJedisConnection("10.207.78.5", 6382); try{ System.out.println(jedis.set(key, value)); }catch(Exception e){ System.out.println(e.getMessage()); } } private void readFromSlave(String key) { Jedis jedis = ConnectionUtill.getJedisConnection("10.207.78.5", 6382); String value = jedis.get(key); System.out.println("reading value of '" + key + "' from slave is :" + value); } private void readFromMaster(String key) { Jedis jedis = ConnectionUtill.getJedisConnection("10.207.78.5", 6381); String value = jedis.get(key); System.out.println("reading value of '" + key + "' from master is :" + value); } private void writeToMaster(String key , String value) { Jedis jedis = ConnectionUtill.getJedisConnection("10.207.78.5", 6381); System.out.println(jedis.set(key, value)); } }
-
您应该能够看到您编写的程序的以下结果:
-
Write to the master:
-
Read from the master:
-
Write to a slave:
-
Stop the master:
-
Read from the master after shutting the master:
-
Read from the slave after shutting the master:
-
Write to the slave:
-
在默认 Sentinel 配置中添加以下文本:
sentinel monitor slave2master 127.0.0.1 6382 1 sentinel down-after-milliseconds slave2master 10000 sentinel failover-timeout slave2master 900000 sentinel can-failover slave2master yes sentinel parallel-syncs slave2master 1
-
让我们理解前面代码中添加的五行代码的含义。默认 Sentinel 将具有在默认端口中运行的主机的信息。如果已在其他主机或端口中启动主机,则必须在 Sentinel 文件中进行相应的更改。
- Sentinel monitor slave2master 127.0.0.1 63682 1:提供从节点的主机和端口信息。除此之外,
1
表示哨兵之间的法定人数协议,以便在船长失败时达成一致。在我们的例子中,因为我们只运行一个哨兵,所以提到的值是1
。 - 哨兵在毫秒后关闭 slave2master 10000:这是无法联系到主机的时间。这个想法是让哨兵继续 ping 主控器,如果主控器没有响应或响应错误,哨兵就会介入并开始其活动。如果 Sentinel 检测到主节点已关闭,则会将节点标记为
SDOWN
。但单凭这一点无法决定主机是否停机,必须在所有哨兵之间达成协议才能启动故障切换活动。当哨兵们达成协议,说主人已经倒下了,那就叫做ODOWN
状态。把这看作是在一个倒下的主人被驱逐和一个新主人被选择之前,哨兵中的民主。 - Sentinel failover timeout slave2master 900000:这是以毫秒为单位指定的时间,负责整个故障切换过程的整个时间跨度。当检测到故障转移时,Sentinel 请求在所有配置的从属设备中写入新主设备的配置。
- Sentinel parallel syncs slave2master 1:此配置表示故障转移事件后同时重新配置的从机数量。如果我们服务于只读从机的读取查询,我们将希望保持该值较低。这是因为在同步发生时,所有从机都无法访问。
在本章中,我们学习了如何使用集群技术来最大限度地提高性能和处理不断增长的数据集。除此之外,我们还了解了数据的可用性处理以及故障处理的能力。虽然 Redis 提供了一些技术,但我们也看到了如果我们不需要 Sentinel,如何使用其他技术。在下一章中,我们将重点介绍如何维护 Redis。