大规模互联网应用Redis架构要点(转)
通常,为了提高网站响应速度,总是把热点数据保存在内存中而不是直接从后端数据库中读取。Redis是一个很好的Cache工具。大型网站应用,热点数据量往往巨大,几十G上百G是很正常的事儿,在这种情况下,如何正确架构Redis呢?
首先,无论我们是使用自己的物理主机,还是使用云服务主机,内存资源往往是有限制的,scale up不是一个好办法,我们需要scale out横向可伸缩扩展,这需要由多台主机协同提供服务,即分布式多个Redis实例协同运行。
其次,目前硬件资源成本降低,多核CPU,几十G内存的主机很普遍,对于主进程是单线程工作的Redis,只运行一个实例就显得有些浪费。同时,管理一个巨大内存不如管理相对较小的内存高效。因此,实际使用中,通常一台机器上同时跑多个Redis实例。
Redis 3正式推出了官方集群技术,解决了多Redis实例协同服务问题。Redis Cluster可以说是服务端Sharding分片技术的体现,即将键值按照一定算法合理分配到各个实例分片上,同时各个实例节点协调沟通,共同对外承担一致服务。
多Redis实例服务,比单Redis实例要复杂的多,这涉及到定位、协同、容错、扩容等技术难题。这里,我们介绍一种轻量级的客户端Redis Sharding技术。
Redis Sharding可以说是Redis Cluster出来之前,业界普遍使用的多Redis实例集群方法。其主要思想是采用哈希算法将Redis数据的key进行散列,通过hash函数,特定的key会映射到特定的Redis节点上。这样,客户端就知道该向哪个Redis节点操作数据。Sharding架构如图:
庆幸的是,java redis客户端驱动jedis,已支持Redis Sharding功能,即ShardedJedis以及结合缓存池的ShardedJedisPool。
Jedis的Redis Sharding实现具有如下特点:
1. 采用一致性哈希算法(consistent hashing),将key和节点name同时hashing,然后进行映射匹配,采用的算法是MURMUR_HASH。采用一致性哈希而不是采用简单类似哈希求模映射的主要原因是当增加或减少节点时,不会产生由于重新匹配造成的rehashing。一致性哈希只影响相邻节点key分配,影响量小。
2.为了避免一致性哈希只影响相邻节点造成节点分配压力,ShardedJedis会对每个Redis节点根据名字(没有,Jedis会赋予缺省名字)会虚拟化出160个虚拟节点进行散列。根据权重weight,也可虚拟化出160倍数的虚拟节点。用虚拟节点做映射匹配,可以在增加或减少Redis节点时,key在各Redis节点移动再分配更均匀,而不是只有相邻节点受影响。
3.ShardedJedis支持keyTagPattern模式,即抽取key的一部分keyTag做sharding,这样通过合理命名key,可以将一组相关联的key放入同一个Redis节点,这在避免跨节点访问相关数据时很重要。
下面我们用Jedis实际操作下:
1.pom.xml中配置jedis jar包
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>2.7.2</version>
</dependency>
2.spring配置文件中配置ShardedJedisPool
<bean id="poolConfig" class="redis.clients.jedis.JedisPoolConfig">
<property name="maxTotal" value="4096"/>
<property name="maxIdle" value="200"/>
<property name="maxWaitMillis" value="3000"/>
<property name="testOnBorrow" value="true" />
<property name="testOnReturn" value="true" />
</bean>
<bean id = "shardedJedisPool" class = "redis.clients.jedis.ShardedJedisPool">
<constructor-arg index="0" ref="poolConfig"/>
<constructor-arg index="1">
<list>
<bean class="redis.clients.jedis.JedisShardInfo">
<constructor-arg index="0" value="192.168.1.119" type="String"/>
<!-- shard name -->
<constructor-arg index="1" value="Shard-1" type="String"/>
<constructor-arg index="2" value="6379" type="int"/>
<!-- timeout,default is 2 sec -->
<constructor-arg index="3" value="2000" type="int"/>
<!-- weight,default is 1 -->
<constructor-arg index="4" value="1" type="int"/>
</bean>
<bean class="redis.clients.jedis.JedisShardInfo">
<constructor-arg index="0" value="192.168.1.119" type="String"/>
<constructor-arg index="1" value="Shard-2" type="String"/>
<constructor-arg index="2" value="6479" type="int"/>
<constructor-arg index="3" value="2000" type="int"/>
<constructor-arg index="4" value="1" type="int"/>
</bean>
<bean class="redis.clients.jedis.JedisShardInfo">
<constructor-arg index="0" value="192.168.1.119" type="String"/>
<constructor-arg index="1" value="Shard-3" type="String"/>
<constructor-arg index="2" value="6579" type="int"/>
<constructor-arg index="3" value="2000" type="int"/>
<constructor-arg index="4" value="1" type="int"/>
</bean>
<bean class="redis.clients.jedis.JedisShardInfo">
<constructor-arg index="0" value="192.168.1.119" type="String"/>
<constructor-arg index="1" value="Shard-4" type="String"/>
<constructor-arg index="2" value="6679" type="int"/>
<constructor-arg index="3" value="2000" type="int"/>
<constructor-arg index="4" value="2" type="int"/>
</bean>
</list>
</constructor-arg>
</bean>
3.编写测试代码
@Test
public void basicOpTestForSharded(){
ShardedJedis jedis = shardedJedisPool.getResource();
long begin = System.currentTimeMillis();
for(int i=0;i<10000; i++){
jedis.set("person." + i + ".name", "frank");
jedis.set("person." + i + ".city", "beijing");
String name = jedis.get("person." + i + ".name");
String city = jedis.get("person." + i + ".city");
assertEquals("frank",name);
assertEquals("beijing",city);
jedis.del("person." + i + ".name");
Boolean result = jedis.exists("person." + i + ".name");
assertEquals(false,result);
result = jedis.exists("person." + i + ".city");
assertEquals(true,result);
}
long end = System.currentTimeMillis();
for(Jedis myJedis: jedis.getAllShards()){
System.out.println("redis shard: " +
myJedis.getClient().getHost() + ":" + myJedis.getClient().getPort());
System.out.println("redis shard size: " + myJedis.dbSize());
}
System.out.println("total time: " + (end-begin)/1000);
jedis.close();
}
4.运行代码
可以看到,最终的10000个键值,被合理分配到四个Redis实例中,由于Shard-4的weight权重是其它三个的1倍,我们看到,分配给Shard-4节点的键值数也大致是其它三个的1倍,整个键值数比例基本符合1:1:1:2。
扩容问题R
Redis Sharding采用客户端Sharding方式,服务端Redis还是一个个相对独立的Redis实例节点,没有做任何变动。同时,我们也不需要增加额外的中间处理组件,这是一种非常轻量、灵活的Redis多实例集群方法。
当然,Redis Sharding这种轻量灵活方式必然在集群其它能力方面做出妥协。比如扩容,当想要增加Redis节点时,尽管采用一致性哈希,毕竟还是会有key匹配不到而丢失,这时需要键值迁移。
作为轻量级客户端sharding,处理Redis键值迁移是不现实的,这就要求应用层面允许Redis中数据丢失或从后端数据库重新加载数据。但有些时候,击穿缓存层,直接访问数据库层,会对系统访问造成很大压力。有没有其它手段改善这种情况?
Redis作者给出了一个比较讨巧的办法--presharding,即预先根据系统规模尽量部署好多个Redis实例,这些实例占用系统资源很小,一台物理机可部署多个,让他们都参与sharding,当需要扩容时,选中一个实例作为主节点,新加入的Redis节点作为从节点进行数据复制。数据同步后,修改sharding配置,让指向原实例的Shard指向新机器上扩容后的Redis节点,同时调整新Redis节点为主节点,原实例可不再使用。
presharding是预先分配好足够的分片,扩容时只是将属于某一分片的原Redis实例替换成新的容量更大的Redis实例。参与sharding的分片没有改变,所以也就不存在key值从一个区转移到另一个分片区的现象,只是将属于同分片区的键值从原Redis实例同步到新Redis实例。
节点故障问题R
并不是只有增删Redis节点引起键值丢失问题,更大的障碍来自Redis节点突然宕机。在《Redis持久化》一文中已提到,为不影响Redis性能,尽量不开启AOF和RDB文件保存功能,可架构Redis主备模式,主Redis宕机,数据不会丢失,备Redis留有备份。
这样,我们的架构模式变成一个Redis节点切片包含一个主Redis和一个备Redis。在主Redis宕机时,备Redis接管过来,上升为主Redis,继续提供服务。主备共同组成一个Redis节点,通过自动故障转移,保证了节点的高可用性。则Sharding架构演变成:
Redis Sentinel提供了主备模式下Redis监控、故障转移功能达到系统的高可用性。下面我们搭建一主一从并利用Sentinel进行监控。
1.搭建主从架构,一主一从
主端口号是6379,从端口号是6479,此步略,参看redis持久性一文。
2.构建Sentinel系统
Redis Sentinel其实也是Redis,只不过是以Sentinel模式启动。在Sentinel模式下,Redis只接受有限的几个命令,主要是监控Redis实例是否发生故障,在主Redis发生故障的前提下,进行故障转移,在可用从Redis实例中挑选一个上升为主Redis,同时其它从Redis的主Redis重定向到这个新的主Redis。原有故障的主Redis如重新上线,也会降级为从Redis,指向新的主Redis。
这样看来,Sentinel又成为一个关键的节点,如果Sentinel节点发生故障,那整个HA高可用将变成不可用,故通常情况下,Sentinel本身是处于集群状态的。
多个Sentinel实例集群,那由谁执行故障转移呢?这需要选举一个Sentinel作为主Sentinel,如果一半以上同意,这个Sentinel将选举为主Sentinel负责执行故障转移操作。故一般Sentinel集群为单数,如3个,2个Sentinel集群是无效的。
在本例中,我们只搭建一个Sentinel实例作为监控节点。
Sentinel启动需要指定配置文件,我们来看下sentinel.conf中几个主要参数:
port 26379 监控系统端口号
sentinel monitor Shard-1 192.168.1.146 6379 1 监控名为Shard-1的节点,且其主Redis的IP是192.168.1.146,端口号为6379,同时有1个Sentinel认为其下线,那此主Redis就认为是有效的客观下线状态,需要执行故障转移。
sentinel down-after-milliseconds Shard-1 30000 sentinel实时监控主Redis,如发现30秒没反应,则主观认为其已下线。
sentinel parallel-syncs Shard-1 1 故障转移,从Redis重新同时指向新主Redis的个数。
sentinel failover-timeout Shard-1 180000 故障转移超时判定,缺省3分钟
如下命令启动sentinel:
redis-sentinel /etc/sentinel.conf >> /var/log/sentinel.log &
查看sentinel.log,日志如图:
3.故障转移测试
我们试着shutdown掉主Redis,看看sentinel和从redis反应:
我们看到,Shard-1的主Redis从6379这个实例转到了6479这个实例
可以看到,6479这个Redis实例由从升级到主,系统完成了自动故障转移。
我们再重新启动6379原主Redis, 查看日志:
此时,这个Redis已降级为从Redis,同步6479主Redis。
Jedis提供了JedisSentinelPool类可以访问Sentinel监控的主从Redis组成的节点。在我们的架构方案中,它只是作为一个分片节点如Shard-1存在。Jedis并没有提供分片节点是主从模式下的驱动,好在Jedis是开源产品,我们可以根据JedisSentinelPool主逻辑方式得到各分片的最新主Redis信息,这就组成了ShardedJedisPool所需要的JedisShardInfo列表参数,然后按照JedisSentinelPool重新初始化pool的方式重新初始化ShardedJedisPool中的pool。
读写分离R
高访问量下,即使采用Sharding分片,一个单独节点还是承担了很大的访问压力,这时我们还需要进一步分解。通常情况下,应用访问Redis读操作量和写操作量差异很大,读常常是写的数倍,这时我们可以将读写分离,而且读提供更多的实例数。
可以利用主从模式实现读写分离,主负责写,从负责只读,同时一主挂多个从。在Sentinel监控下,还可以保障节点故障的自动监测。这时,上述sharding架构下每个单节点进一步演化为一主多从。如下:
同样,Jedis没有提供Sharding状态下一主多从节点的访问驱动,我们还是根据ShardedJedisPool和JedisSentinelPool源码实现机理做相应改造,从sentinel那里得到可用从redis实例信息,并将读相关操作按照一定算法合理分配到这些可用从Redis节点,分担主节点压力。