Redis实战原理解析

Redis简介:Redis 是完全开源免费的,是一个高性能的key-value数据库,目前市面上主流的no-sql数据库有Redis、Memcache、Tair(淘宝自研发),Redis的官网:https://redis.io/

之前的博客已经写过redis的搭建,主从复制以及哨兵机制,本篇博客侧重于讲解底层原理,实战等...开始吧那就

1. Redis应用场景

相信我们程序员都用过或者听过Redis,那么我们首先谈下它有哪些应用场景,博主总结了以下几点:

① Token令牌的生成
② 短信验证码的code
③ 可以实现缓存查询数据,减轻我们的数据库的访问压力 Redis与mysql数据库不同步的问题
④ 分布式锁
⑤ 延迟操作(案例:订单超时未支付,也可以用RabbitMQ解决)
⑥ 分布式消息中间件(发布订阅,一般用的很少)

2. Redis线程模型

首先Redis官方是没有windows版本的,只有Linux版本,那么问题来了,为什么我们Windows本地可以安装? 答案:一些大牛把Linux版的epoll机制改成Select轮训,从而发布到github供我们下载,所以我们Windows上的Redis并不是官方的。

Redis的底层采用NIO中的多路IO复用的机制,能够非常好的支持这样的并发,从而保证线程安全问题;Redis单线程,也就是底层采用一个线程维护多个不同的客户端IO操作。但是Nio在不同的操作系统上实现的方式有所不同,在我们Windows操作系统使用Selector实现轮训时间复杂度是为O(N),而且还存在空轮训的情况,效率非常低, 其次是默认对我们轮训的数据有一定限制,所以支持上万的TCP连接是非常难。所以在Linux操作系统采用epoll实现事件驱动回调,不会存在空轮训的情况,只对活跃的 Socket连接实现主动回调这样在性能上有大大的提升,所以时间复杂度是为O(1)。

所以为什么单线程的Nginx、Redis都能够非常高支持高并发,最终都是Linux中的IO多路复用机制epoll。

Windows - 空轮训,相当于在Selector写了个for循环,一直在轮训,万一别人不给我发数据也去轮训一下,效率非常低;

Linux - epoll,给每一个TCP连接注册一个事件回调,一旦有人给我发数据,我就走我的事件回调,没给我发数据,我就不用去轮训,不会存在空轮训的情况,只对活跃的 Socket连接实现主动回调这样在性能上有大大的提升。

3. Redis - Value的五种数据类型

① String类型  -  <key,value>  【用的最多】

String是Redis最基本的类型,一个key对应一个value,String类型是二进制安全的。意思是Redis的String可以包含任何数据。比如Jpg图片或者序列化的对象, String类型是Redis最基本的数据类型,一个键最大能存储512MB。

命令:set name zhangsan,get name

② hash类型  -   <key,<key1,value>>  案例:购物车

我们可以将Redis中的Hash类型看成具有<key,<key1,value>>,其中同一个key可以有多个不同key值的<key1,value>,所以该类型非常适合于存储值对象的信息。如Username、Password和Age等。如果Hash中包含很少的字段,那么该类型的数据也将仅占用很少的磁盘空间。

命令:hmset user name zhangsan  【解读:key为user,value为hashmap<name,zhangsan>】,hgetall user
可以支持多个key-hmset user name zhangsan age 22

③ list类型   -  案例:秒杀时一个库存对应多个令牌桶token

Redis列表是简单的字符串列表,按照插入顺序排序。你可以添加一个元素到列表的头部(左边)或者尾部(右边)

命令:lpush userlist zhangsan lisi wangwu

④ set  -  特点:value不允许重复,如果重复则覆盖   【基本不用】

Redis 的 Set 是 String 类型的无序集合。集合成员是唯一的,这就意味着集合中不能出现重复的数据。

⑤ zset  -  【基本不用】

Redis 有序集合和集合一样也是string类型元素的集合,且不允许重复的成员。不同的是每个元素都会关联一个double类型的分数。redis正是通过分数来为集合中的成员进行从小到大的排序。

拓展:Redis如何存放一个java对象?

答案: ① json(即String类型),② 二进制

方式①实现:Redis Desktop Manager工具不会乱码

@Component
public class RedisUtils {
    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    public void setString(String key, String value) {
        setString(key, value, null);
    }
    public void setString(String key, String value, Long timeOut) {
        stringRedisTemplate.opsForValue().set(key, value);
        if (timeOut != null) {
            stringRedisTemplate.expire(key, timeOut, TimeUnit.SECONDS);
        }
    }
    public String getString(String key) {
        return stringRedisTemplate.opsForValue().get(key);
    }
}
@RestController
public class RedisController {
    @Autowired
    private RedisUtils redisUtils;

    @GetMapping("/addUser")
    public String addUser(UserEntity userEntity) {
        String json = JSONObject.toJSONString(userEntity);
        redisUtils.setString("userEntity", json);
        return "success";
    }
    @RequestMapping("/getUser")
    public UserEntity getUser(String key) {
        String json = redisUtils.getString(key);
        UserEntity userEntity = JSONObject.parseObject(json, UserEntity.class);
        return userEntity;
    }
}

方式②实现:(注意:需要序列化的对象(如UserEntity)一定要实现Serializable接口) Redis Desktop Manager会乱码

@Component
public class RedisTemplateUtils {
    @Resource //@Resource通过名称注入,注意这里不能用@Autowired
    private RedisTemplate<String, Object> redisTemplate;

    public void setObject(String key, Object value) {
        setObject(key, value, null);
    }
    public void setObject(String key, Object value, Long timeOut) {
        redisTemplate.opsForValue().set(key, value);
        if (timeOut != null) {
            redisTemplate.expire(key, timeOut, TimeUnit.SECONDS);
        }
    }
    public Object getObject(String key) {
        return redisTemplate.opsForValue().get(key);
    }
}
@RestController
public class RedisController {
    @Autowired
    private RedisTemplateUtils redisTemplateUtils;

    @GetMapping("/addUser")
    public String addUser(UserEntity userEntity) {
        redisTemplateUtils.setObject("userEntity", userEntity);
        return "success";
    }
    @RequestMapping("/getUser")
    public UserEntity getUser(String key) {
        return (UserEntity) redisTemplateUtils.getObject(key);
    }
}

两种方式区别:二进制只适合于Java对象,json是通用的。

4. Mysql与Redis数据同步解决方案

方式①:当数据库有数据变动时,直接清空Redis对应的该数据,此次或下次再同步到Redis
方式②:直接采用MQ订阅MySQL binlog日志文件增量同步到Redis中,整个过程采用最终一致性方案。类似于之前博客的主从复制
方式③:使用阿里巴巴的开源框架Canal(后期单独拿一篇博客讲述)

5. Redis的持久化机制

Redis因为某种原因的情况下宕机之后,数据是不会丢失的,原理就是持久化机制 ~

同理,我们用的ehcache也有持久化机制,大部分的缓存框架都会有基本功能:淘汰策略、持久机制。

Redis的持久化的机制有两种:aof、rdb(默认),下面分别讲解:

Redis提供了两种持久化的机制,分别为RDB、AOF实现,RDB采用定时(全量)持久化机制,但是服务器因为某种原因宕机后可能数据会丢失,AOF是基于数据日志操作实现的持久化(增量)。

RDB:(全量同步)

Redis已经帮助我默认开启了rdb存储,以快照的形式将数据持久化到磁盘的是一个二进制的文件dump.rdb, 可以在redis.conf中搜索到,生成的rdb文件为,存放的目录为

Redis会将数据集的快照dump到dump.rdb文件中。此外,我们也可以通过配置文件来修改Redis服务器dump快照的频率,在打开6379.conf文件之后,我们搜索save,可以看到下面的配置信息: 


save  900   1          # 在900秒(15分钟)之后,如果至少有1个key发生变化,则dump内存快照。
save  300   10        # 在300秒(5分钟)之后,如果至少有10个key发生变化,则dump内存快照。
save    60   10000  # 在60秒(1分钟)之后,如果至少有10000个key发生变化,则dump内存快照。


AOF:(增量同步)

AOF在Redis的配置文件中存在三种同步策略,它们分别是:


appendfsync always       #每次有数据修改发生时都会写入AOF文件,能够保证数据不丢失,但是效率非常低。

appendfsync everysec   #每秒钟同步一次,可能会丢失1s内的数据,但是效率非常高。【默认 - 建议使用】

appendfsync no              #从不同步。高效但是数据不会被持久化。


Redis默认没有开启aof存储,那么如何开启aof? 非常简单,只需要在redis.conf中把默认的配置改为yes即可,aof同步的文件为

原理:aof是以执行命令的形式实现同步。每次执行都会记录写的操作,如set name zhangsan等都会被记录下来,appendfsync everysec一秒后从缓冲区同步到aof文件中去。

拓展:全量同步(rdb)与增量同步(aof)区别:

① 就是每天定时(避开高峰期)或者是采用一种周期的实现将数据拷贝另外一个地方。频率不是很大,但是可能会造成数据的丢失。
② 增量同步采用行为操作对数据实现同步,频率非常高、对服务器同步的压力也是非常大的、保证数据不丢失。

6. Redis内存淘汰策略

概念:将Redis用作缓存时,如果内存空间用满,就会自动驱逐老的数据,防止内存撑爆。

Redis默认有六种淘汰策略:


noeviction:当内存使用达到阈值的时候,执行命令直接报错 

allkeys-lru:在所有的key中,优先移除最近未使用的key。(推荐)

volatile-lru:在设置了过期时间的键空间中,优先移除最近未使用的key。

allkeys-random:在所有的key中,随机移除某个key。

volatile-random:在设置了过期时间的键空间中,随机移除某个key。

volatile-ttl:在设置了过期时间的键空间中,具有更早过期时间的key优先移除。


在redis.conf文件中,设置Redis 内存大小的限制,比如:

当数据达到限定大小后,会选择配置的策略淘汰数据,通过配置maxmemory-policy置Redis的淘汰策略,默认也是注掉的,如果自定义内存大小的话,建议使用allkeys-lru,即
【关于maxmemory的设置,如果redis的应用场景是作为db使用,那不要设置这个选项,因为db是不能容忍丢失数据的。

如果作为cache使用,则可以启用这个选项(其实既然有淘汰策略,那就是cache了。。。),默认官方没有对maxmemory做限制,理论上默认最大内存限制为当前机器可用内存】

7. Redis自动过期机制

在实际开发过程中经常会遇到一些有时效性数据,比如订单超时功能,30分钟未支付应该将订单改为已失效状态。在关系型数据库中一般都要增加一个字段记录数据的到期时间,然后通过定时任务周期性的进行检查,这种方式性能太差了。Redis本身就对键过期提供了很好的支持。

实现需求:处理订单过期自动取消,比如下单30分钟未支付自动更改订单状态为已失效(超时)

解决方案:① 定时器   ② RabbitMQ的死信/延时队列   ③ 利用Redis的过期机制

Redis过期机制:在Redis中可以使用expire命令设置一个键的存活时间(ttl: time to live),过了这段时间,该键就会自动被删除,EXPIRE命令的使用方法如下:
expire key ttl(单位秒)  【命令返回1表示设置ttl成功,返回0表示键不存在或者设置失败。】

key失效监听实战:

   把RedisMessageListenerContainer注入到Spring容器

@Configuration
public class RedisListenerConfig {
    @Bean
    RedisMessageListenerContainer container(RedisConnectionFactory connectionFactory) {
        RedisMessageListenerContainer container = new RedisMessageListenerContainer();
        container.setConnectionFactory(connectionFactory);
        return container;
    }
}

   编写Redis监听器

@Component
public class RedisKeyExpirationListener extends KeyExpirationEventMessageListener {

    public RedisKeyExpirationListener(RedisMessageListenerContainer listenerContainer) {
        super(listenerContainer);
    }
    @Autowired
    private OrderMapper orderMapper;
    /**
     * 【监听】 - 当我们key失效的时候执行该方法
     */
    @Override
    public void onMessage(Message message, byte[] pattern) {
        String expireKey = message.toString();
        System.out.println(expireKey + "失效啦~");
        // 前缀判断 - 每个服务,前缀事先商量好,防止冲突 eg:orderToken_XXXXXXX  // 或者是根据库区分
        OrderEntity orderEntity = orderMapper.getOrderNumber(expireKey);
        if (orderEntity == null) {
            return;
        }
        // 获取订单状态
        Integer orderStatus = orderEntity.getOrderStatus();
        // 如果该订单状态为待支付的情况下,直接将该订单修改为已经超时 (0待支付 1已支付 2已失效)
        if (orderStatus == 0) {
            orderMapper.updateOrderStatus(expireKey, 2);
            // 库存在加上1...此处省略,自行实现
        }
        // 不设置超时时间,但是可能被淘汰回收策略回收掉,也会走回调方法
    }
}
@Data
public class OrderEntity {
    private Long id;
    private String orderName;
    private Integer orderStatus;
    private String orderToken;
    private String orderNumber;
    public OrderEntity(Long id, String orderName, String orderNumber, String orderToken) {
        this.id = id;
        this.orderName = orderName;
        this.orderNumber = orderNumber;
        this.orderToken = orderToken;
    }
}
@Mapper
public interface OrderMapper {
    @Insert("insert into order_table values (null,#{orderName},0,#{orderToken},#{orderNumber})")
    int insertOrder(OrderEntity OrderEntity);

    @Select("SELECT id,order_name,order_status,order_token,order_number  FROM order_table " +
            "where order_token=#{orderToken};")
    OrderEntity getOrderNumber(String orderToken);

    @Update("update order_table set order_status=#{orderStatus} where order_token=#{orderToken}")
    int updateOrderStatus(String orderToken, Integer orderStatus);
}
@Component
public class RedisUtils {
    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    public void setString(String key, String value) {
        setString(key, value, null);
    }
    public void setString(String key, String value, Long timeOut) {
        stringRedisTemplate.opsForValue().set(key, value);
        if (timeOut != null) {
            stringRedisTemplate.expire(key, timeOut, TimeUnit.SECONDS);
        }
    }
    public String getString(String key) {
        return stringRedisTemplate.opsForValue().get(key);
    }
}
@RestController
public class OrderController {
    @Autowired
    private OrderMapper orderMapper;
    @Autowired
    private RedisUtils redisUtils;

    @RequestMapping("/addOrder")
    public String addOrder() {
        // 1.提前生成订单token 临时且唯一
        String orderToken = UUID.randomUUID().toString();
        Long orderNumber = System.currentTimeMillis();
        // 2.将我们的token存放到redis中
        redisUtils.setString(orderToken, orderNumber + "", 60L); // 60秒
        OrderEntity orderEntity = new OrderEntity(null, "腾讯视频年度vip", orderNumber + "", orderToken);
        return orderMapper.insertOrder(orderEntity) > 0 ? "success" : "fail";
    }
}

  启动项目,调用addOrder接口,会看到数据库新增一条数据:

60秒后,redis中的该key删除,进入监听器,并根据该key判断订单状态,如果为1,则不操作,如果依旧为0,则改为2:

【原理】:

① 创建订单的时候绑定一个订单token 存放在redis中(有效期只有30分钟)  key为token,value为订单编号。

② 对该key绑定过期事件回调,判断状态从而修改订单状态。

8. Redis缓存雪崩&缓存击穿&缓存穿透

【缓存穿透】

缓存穿透是值指定key不存在的key,频繁的高并发查询,导致缓存是无法命中,如上图所示,当黑客利用for循环,随机生成orderNumber去访问getOrder接口,则每次大概率会穿透Redis进入MySQL数据库,导致大量IO操作,从而会导致数据库的压力非常大。

解决方案:

① 接口实现api的限流、防御ddos(模拟请求)、接口频率限制、网关实现黑名单(核心)
② 从数据库和Redis如果都查询不到数据的情况下,将数据库的空值写入到缓存中,加上短时间的有效期 【针对于黑客使用相同的key进行攻击,一定程度上可以减轻频繁数据库IO操作】
③ 布隆过滤器

  

默认情况下,数组值为0,数组取值(0或1)
为什么要有三个hash函数计算出3个hash值?防止冲突,减少重叠的概率

布隆过滤器:布隆过滤器适用于判断一个元素在集合中是否存在,但是可能会存在误判的问题。

Bloom Filter基本实现原理采用位数组与联合函数一起实现,实现的原理采用二进制向量数组和随机映射hash函数。

为什么存在误判问题?

因为有可能在查询的时候,会根据key计算hash值,该值可能与布隆过滤器存放的其它元素的hash值产生重叠,即上图中的sex,hash值数组对应的下表都为1,即代表布隆过滤器中存在该key,而实际上该key是不存在的。

如何减少冲突(误判)概率?

二进制数组长度设置大一点,代码直接把fpp调低一点即可:

BloomFilter<Integer> integerBloomFilter = BloomFilter.create(Funnels.integerFunnel(), size, 0.001);

代码实现&应用场景:

在上图代码中我们可以用redis很好的降低数据库访问压力,那么用户频繁访问redis,也会对redis造成很大压力,这时候我们就引入布隆过滤器,如果不存在直接不走redis,直接返回null给客户端即可:

<!--引入布隆过滤器 -->
<dependency>
    <groupId>com.google.guava</groupId>
    <artifactId>guava</artifactId>
    <version>22.0</version>
</dependency>
@Mapper
public interface OrderMapper {

    @Insert("insert into order_table values (null,#{orderName},0,#{orderToken},#{orderNumber})")
    int insertOrder(OrderEntity OrderEntity);

    @Select("select * from order_table where id=#{id}")
    OrderEntity getById(Integer orderId);

    @Select("select id from order_table")
    List<Integer> getOrderIds();
}
@RestController
public class OrderController {

    @Autowired
    private OrderMapper orderMapper;
    @Autowired
    private RedisUtils redisUtils;
    @Autowired
    private RedisTemplateUtils redisTemplateUtils;

    BloomFilter<Integer> integerBloomFilter;

    @RequestMapping("/addOrder")
    public String addOrder() {
        // 1.提前生成订单token 临时且唯一
        String orderToken = UUID.randomUUID().toString();
        Long orderNumber = System.currentTimeMillis();
        // 2.将我们的token存放到rdis中
        redisUtils.setString(orderToken, orderId + "", 10L);
        OrderEntity orderEntity = new OrderEntity(null, "QQ黄钻", orderNumber + "", orderToken);
        return orderMapper.insertOrder(orderEntity) > 0 ? "success" : "fail";
    }

    @RequestMapping("/getOrder")
    public OrderEntity getOrder(Integer orderId) {
        // 0.判断我们的布隆过滤器
        if (!integerBloomFilter.mightContain(orderId)) {
            System.out.println("从布隆过滤器中查询不存在");
            return null;
        }
        // 如果正好碰巧,orderId在redis和mysql都没有,也没关系,
        // 就算布隆过滤器产生了误判的情况,也不会影响整个业务,顶多穿透了布隆而已

        // 1.先查询Redis中数据是否存在
        OrderEntity orderRedisEntity = (OrderEntity) redisTemplateUtils.getObject(orderId + "");
        if (orderRedisEntity != null) {
            System.out.println("直接从Redis中返回数据");
            return orderRedisEntity;
        }
        // 2. 查询数据库的内容
        System.out.println("从DB查询数据");
        OrderEntity orderDBEntity = orderMapper.getOrderById(orderId);
        if (orderDBEntity != null) {
            System.out.println("将Db数据放入到Redis中");
            redisTemplateUtils.setObject(orderId + "", orderDBEntity);
        }
        return orderDBEntity;
    }

    /**
     * 从数据库预热id到布隆过滤器中(预热机制)
     * @return
     */
    @RequestMapping("/dbToBulong")
    public String dbToBulong() {
        List<Integer> orderIds = orderMapper.getOrderIds();
        integerBloomFilter = BloomFilter.create(Funnels.integerFunnel(), orderIds.size(), 0.01);
        for (int i = 0; i < orderIds.size(); i++) {
            // 添加到我们的布隆过滤器中
            integerBloomFilter.put(orderIds.get(i));
        }
        return "success";
    }
}

首先用预热机制,把所有订单id存到布隆过滤器

  

分别请求:http://localhost:8080/getOrder?orderId=1,http://localhost:8080/getOrder?orderId=2,

http://localhost:8080/getOrder?orderId=1,http://localhost:8080/getOrder?orderId=2,会发现控制台打印信息:

        

请求一个不存在的id,http://localhost:8080/getOrder?orderId=3,会发现直接返回null,并打印从布隆查询不存在。

由此可见,使用布隆过滤器可以有效减少redis的压力。

【缓存击穿】

在高并发的情况下,当一个热点key(经常使用到key)过期时,因为访问该key请求过多,多个请求同时发现该缓存key过期,这时候同时查询数据库,同时向Redis写入缓存数据,对我们数据库压力非常大。

做电商项目的时候,把这个热点key就称为"爆款"。

解决方案:

分布式集群环境 - 使用分布式锁技术:多个请求同时只要谁能够获取到锁,谁就能够去数据库查询将数据查询的结果放入Redis中(直接在上面代码的红色箭头处上锁即可)
单机环境用Lock锁或者Synchronized
软过期 - 对热点key设置无限有效期或者异步延长时间

【缓存雪崩】

缓存雪崩,是指在某一个时间段,缓存集中过期失效,突然给数据库产生了巨大的压力,甚至击垮数据库的情况。

产生雪崩的原因之一,比如马上就要到双十一零点,很快就会迎来一波抢购,这波商品时间比较集中的放入了缓存,假设缓存一个小时。那么到了凌晨一点钟的时候,这批商品的缓存就都过期了。而对这批商品的访问查询,都落到了数据库上,对于数据库而言,就会产生周期性的压力波峰。

解决方案:对不用的数据使用不同的失效时间,或者采用失效时间加上随机因子。

例如:做电商项目的时候,一般是采取不同分类商品,缓存不同周期。在同一分类中的商品,加上一个随机因子。这样能尽可能分散缓存过期时间,而且,热门类目的商品缓存时间长一些,冷门类目的商品缓存时间短一些,也能节省缓存服务的资源。

小结:缓存穿透是指key不存在情况下,缓存击穿是指击穿 单个热点key失效的在并发的查询的情况下,缓存雪崩是指多个key失效的情况下(在某一个时间段,缓存集中过期失效)。

 9. Redis哨兵原理,分片,平滑迁移,缩容扩容...

等待更新ing...

发布了45 篇原创文章 · 获赞 20 · 访问量 3万+

猜你喜欢

转载自blog.csdn.net/AkiraNicky/article/details/103301777