Redis 应用与原理(三)

更好的阅读体验 \huge{\color{red}{更好的阅读体验}} 更好的阅读体验

Redis Cluster 解决方案


基础概念


首先,分析一下主从+哨兵模式带来的问题:

image-20240318202940569

  • 在主从 + 哨兵的模式下,仍然只有一个 Master 节点,当并发请求较大时,哨兵模式不能缓解写压力
  • 在 Sentinel 模式下,每个节点需要保存全量数据,无法进行海量数据存储

因此,在 Redis 3.0 之后,提供了 Cluster 的解决方案,核心原理是对数据做分片:

image-20240318203511203

  • 采用无中心结构
  • 每个 master 可以有多个 slave 节点
  • 整个集群分片共有 16384 个哈希槽
  • 每个 key 通过 CRC16 校验后对 16384 取模来决定放置哪个槽,集群的每个节点负责一部分hash 槽
  • 当主节点不可用时,从节点会升级为主节点,原有主节点恢复后会降级为从节点

Redis Cluster 集群策略


故障转移策略


image-20240318204921709

和 Sentinel 类似,Cluster 也存在服务监控和选举规则:

  • 主观下线:和 Sentinel 一样采用心跳包检测,当一个节点不能从另一个节点接收到心跳信息,该节点会将它标记为“主观下线”状态
  • 客观下线:当半数以上的主节点都将某个节点标记为“主观下线”,那么这个节点会被标记为“客观下线”

当某个节点被标记为客观下线后,会从该主节点的从节点中选举一个从节点作为新的主节点:

  • 选举过程主要看从节点的复制偏移量(replica offset)和 runid
  • 优先选择复制偏移量最大的节点,如果复制偏移量相同,则选择 runid 最小的节点

注意

  • 若某一主节点及其从节点都不可用,则会导致整个 Redis Cluster 集群不可用

数据分片策略


常见的数据分布策略

顺序分布

  • 根据数据的某些属性进行排序,将数据均匀地分配到不同的存储节点
  • 例如,将用户 ID 排序,分区间存入不同的节点

一致性哈希

  • 将整个哈希值空间组成一个虚拟的圆环,然后根据某种哈希算法将数据项映射到该圆环上
  • 例如对于 Redis,对节点 id 进行 hash,将其值分布在圆环上
  • 发生读写的 key 经过 hash 后,顺时针查圆环上的节点,若未找到,则默认为 0 位置后的第一个节点

如果采用一致性哈希算法,若某个节点挂了,受影响的数据仅仅是此节点到环空间前一个节点(沿着逆时针方向行走遇到的第一个节点)之间的数据,其它不受影响。增加一个节点也同理。

但是当删除节点时,数据再分配会把当前节点所有数据加到它的下一个节点上(缓存抖动)。这样会导致下一个节点使用率暴增,可能会导致挂掉,如果下一个节点挂掉,下下个节点将会承受更大的压力,最终导致集群雪崩。


Redis 哈希槽策略

Redis 并没有使用一致性哈希,而是采用哈希槽的方式进行分片

Redis 集群有16384个哈希槽,每个key通过CRC16校验后对16384取模来决定放置哪个槽:

image-20240318215405104

理论上 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</
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

浪漫主义狗

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值