更好的阅读体验 \huge{\color{red}{更好的阅读体验}} 更好的阅读体验
Redis Cluster 解决方案
基础概念
首先,分析一下主从+哨兵模式带来的问题:
- 在主从 + 哨兵的模式下,仍然只有一个 Master 节点,当并发请求较大时,哨兵模式不能缓解写压力
- 在 Sentinel 模式下,每个节点需要保存全量数据,无法进行海量数据存储
因此,在 Redis 3.0 之后,提供了 Cluster 的解决方案,核心原理是对数据做分片:
- 采用无中心结构
- 每个 master 可以有多个 slave 节点
- 整个集群分片共有 16384 个哈希槽
- 每个 key 通过 CRC16 校验后对 16384 取模来决定放置哪个槽,集群的每个节点负责一部分hash 槽
- 当主节点不可用时,从节点会升级为主节点,原有主节点恢复后会降级为从节点
Redis Cluster 集群策略
故障转移策略
和 Sentinel 类似,Cluster 也存在服务监控和选举规则:
- 主观下线:和 Sentinel 一样采用心跳包检测,当一个节点不能从另一个节点接收到心跳信息,该节点会将它标记为“主观下线”状态
- 客观下线:当半数以上的主节点都将某个节点标记为“主观下线”,那么这个节点会被标记为“客观下线”
当某个节点被标记为客观下线后,会从该主节点的从节点中选举一个从节点作为新的主节点:
- 选举过程主要看从节点的复制偏移量(replica offset)和 runid
- 优先选择复制偏移量最大的节点,如果复制偏移量相同,则选择 runid 最小的节点
注意:
- 若某一主节点及其从节点都不可用,则会导致整个 Redis Cluster 集群不可用
数据分片策略
常见的数据分布策略
顺序分布:
- 根据数据的某些属性进行排序,将数据均匀地分配到不同的存储节点
- 例如,将用户 ID 排序,分区间存入不同的节点
一致性哈希:
- 将整个哈希值空间组成一个虚拟的圆环,然后根据某种哈希算法将数据项映射到该圆环上
- 例如对于 Redis,对节点 id 进行 hash,将其值分布在圆环上
- 发生读写的 key 经过 hash 后,顺时针查圆环上的节点,若未找到,则默认为 0 位置后的第一个节点
如果采用一致性哈希算法,若某个节点挂了,受影响的数据仅仅是此节点到环空间前一个节点(沿着逆时针方向行走遇到的第一个节点)之间的数据,其它不受影响。增加一个节点也同理。
但是当删除节点时,数据再分配会把当前节点所有数据加到它的下一个节点上(缓存抖动)。这样会导致下一个节点使用率暴增,可能会导致挂掉,如果下一个节点挂掉,下下个节点将会承受更大的压力,最终导致集群雪崩。
Redis 哈希槽策略
Redis 并没有使用一致性哈希,而是采用哈希槽的方式进行分片
Redis 集群有16384个哈希槽,每个key通过CRC16校验后对16384取模来决定放置哪个槽:
理论上 CRC16 算法可以得到 2 16 2^{16} 216 个数值,其数值范围在 0-65535 之间,取模运算 key 的时候,应该是CRC16(key)%65535,但是却设计为CRC16(key)%16384,原因是作者在设计的时候做了空间上的权衡,觉得节点最多不可能超过1000个,同时为了保证节点之间通信效率,所以采用了 2 14 2^{14} 214。
具体分片方式如下:
- 把16384槽按照节点数量进行平均分配,由节点进行管理
- 对每个key按照 CRC16规则进行 hash 运算
- 把hash结果对 16383 进行取余
- 把余数发送给 Redis 节点
- 节点接收到数据,验证是否在自己管理的槽编号的范围
- 如果在自己的编号范围内,会把数据存储到数据槽中,返回执行结果
- 否则,会把数据发送给正确的节点,由正确的节点来处理
使用哈希槽的优势:
- 由于一致性哈希会造成缓存抖动和集群雪崩,因此要在原有基础上进行扩容和删减节点变得极为困难
- 使用哈希槽在新增节点时,只需要将其他节点的哈希槽分出一部分给新节点
- 删除节点时,则将该节点的哈希槽再分配给别的节点,之后再删除节点即可
注意:
- Redis Cluster 节点之间共享消息,每个节点会知道哪个节点负责哪个数据槽
- 添加节点后,需要手动给新节点分配哈希槽,从其他节点的哈希槽分来一部分,并且支持哈希槽均衡
分布式锁
本章节的 demo 代码示例,免搭建即开即用:learn-redis-demo
基于 Redis 实现分布式锁
基础实现
基于 Redis 实现分布式锁主要依赖于 SETNX
命令:
SETNX key value
:若不存在 key 则设置 key 值为 value,返回 1- 若 key 已存在,则不做任何操作,返回 0
为了防止某个线程获取锁之后异常结束没有释放锁,导致其他线程调用 SETNX
命令返回 0 而进入死锁,因此加锁后需要设置超时时间
以下是一个简单的 SpringBoot demo:
@RestController
@RequestMapping("/sell")
public class AppController {
@Resource
StringRedisTemplate stringRedisTemplate;
String LOCK = "TICKETSELLER";
String KEY = "TICKET";
@GetMapping("/ticket")
public void sellTicket() {
Boolean isLocked = stringRedisTemplate.opsForValue().setIfAbsent(LOCK, "1");
if (Boolean.TRUE.equals(isLocked)) {
// 设置过期时间 5s
stringRedisTemplate.expire(LOCK, 5, TimeUnit.SECONDS);
try {
// 拿到 ticket 的数量
int ticketCount = Integer.parseInt((String) stringRedisTemplate.opsForValue().get(KEY));
if (ticketCount > 0) {
// 扣减库存
stringRedisTemplate.opsForValue().set(KEY, String.valueOf(ticketCount - 1));
System.out.println("I get a ticket!");
}
} catch (Exception e) {
e.printStackTrace();
} finally {
// 释放锁
stringRedisTemplate.delete(LOCK);
}
} else {
System.out.println("Field");
}
}
}
缺陷分析
加锁和设置过期时间非原子操作
- 我们先是用
SETNX
创建了锁,假如这个服务在创建锁之后由于事故导致直接停机,那么这个锁就是一个永不过期的锁 - 这将导致其他服务无法获取到锁,影响业务的正常进行
解决方案:
- 使用 LUA 脚本来进行加锁和设置过期时间的操作
- 这样可以使得加锁和设置过期时间是一个原子操作
@RestController
@RequestMapping("/sell")
public class AppController {
@Resource
StringRedisTemplate stringRedisTemplate;
String LOCK = "TICKETSELLER";
String KEY = "TICKET";
@GetMapping("/ticket")
public void sellTicket() {
// LUA 脚本
String LUA Script =
"if redis.call('setnx',KEYS[1],ARGV[1]) == 1 " +
"then redis.call('expire',KEYS[1],ARGV[2]) ;" +
"return true " +
"else return false " +
"end";
// 回调函数返回加锁状态
Boolean isLocked = stringRedisTemplate.execute(new RedisCallback<Boolean>() {
@Override
public Boolean doInRedis(RedisConnection connection) throws DataAccessException {
return connection.eval(LUA Script.getBytes(),
ReturnType.BOOLEAN,
1,
LOCK.getBytes(),
"1".getBytes(),
"5".getBytes());
}
});
if (Boolean.TRUE.equals(isLocked)) {
try {
int ticketCount = Integer.parseInt((String) stringRedisTemplate.opsForValue().get(KEY));
if (ticketCount > 0) {
stringRedisTemplate.opsForValue().set(KEY, String.valueOf(ticketCount - 1));
System.out.println("I get a ticket!");
}
} catch (Exception e) {
e.printStackTrace();
} finally {
stringRedisTemplate.delete(LOCK);
}
} else {
System.out.println("Field");
}
}
}
锁的过期时间设置是否合理
假设现有服务 A 和服务 B,A 先拿到锁执行业务,但是由于业务过长导致 A 的锁到期后超时释放:
- 如果 B 的业务还没结束,A 的业务结束进行释放锁的操作,A 就会错误的删除掉 B 加的锁,那 B 的业务执行完就无锁可释了
- 如果 B 服务可以获取到锁了,B 加锁并执行他的业务,由于此时 A 也在执行业务,两个服务共享内存就容易造成超卖问题
针对第一种问题的出现,解决方案很简单,只需要对锁的值做出限制即可:
- 设置加锁 key 的值为唯一,如利用 uid + threadid
- 在释放锁时判断是否是自己的锁,如果是则释放
- 这个释放锁的操作也要保证原子性,因此也需要用 LUA 脚本来实现
@RestController
@RequestMapping("/sell")
public class AppController {
@Resource
StringRedisTemplate stringRedisTemplate;
String LOCK = "TICKETSELLER";
String KEY = "TICKET"; // 记得在 redis 里面设置好 TICKET 的数量
@GetMapping("/ticket")
public void sellTicket() {
String lockLuaScript =
"if redis.call('setnx',KEYS[1],ARGV[1]) == 1 " +
"then redis.call('expire',KEYS[1],ARGV[2]) ;" +
"return true " +
"else return false " +
"end";
// 生产环境替换为 uuid + 线程 id
String VALUE = String.valueOf(Thread.currentThread().getId());
Boolean isLocked = stringRedisTemplate.execute(new RedisCallback<Boolean>() {
@Override
public Boolean doInRedis(RedisConnection connection) throws DataAccessException {
return connection.eval(lockLuaScript.getBytes(),
ReturnType.BOOLEAN,
1,
LOCK.getBytes(),
VALUE.getBytes(), // 用于判断是否为当前线程加的锁
"5".getBytes()
);
}
});
if (Boolean.TRUE.equals</