Redis7实战加面试题-高阶篇(Redis线程与IO多路复用,BigKey,缓存双写)

Redis线程

面试题:Redis为什么选择单线程?
这种问法其实并不严谨,为啥这么说呢?Redis的版本很多3.x、4.x、6.x,版本不同架构也是不同的,不限定版本问是否单线程也不太严谨。
1 版本3.x ,最早版本,也就是大家口口相传的redis是单线程。
2 版本4.x,严格意义来说也不是单线程,而是负责处理客户端请求的线程是单线程,但是开始加了点多线程的东西(异步删除)。
3 2020年5月版本的6.0.x后及2022年出的7.0版本后,告别了大家印象中的单线程,用一种全新的多线程来解决问题。
Redis7实战加面试题-高阶篇(Redis线程与IO多路复用,BigKey,缓存双写)_第1张图片
Redis是单线程。主要是指Redis的网络IO和键值对读写是由一个线程来完成的,Redis在处理客户端的请求时包括获取 (socket 读)、解析、执行、内容返回 (socket 写) 等都由一个顺序串行的主线程处理,这就是所谓的“单线程”。这也是Redis对外提供键值存储服务的主要流程。
Redis7实战加面试题-高阶篇(Redis线程与IO多路复用,BigKey,缓存双写)_第2张图片
但Redis的其他功能,比如持久化RDB、AOF、异步删除、集群数据同步等等,其实是由额外的线程执行的。
Redis命令工作线程是单线程的,但是,整个Redis来说,是多线程的;
Redis3.x单线程时代但性能依旧很快的主要原因:
1.基于内存操作: Redis的所有数据都存在内存中,因此所有的运算都是内存级别的,所以他的性能比较高;
2.数据结构简单: Redis的数据结构是专门设计的,而这些简单的数据结构的查找和操作的时间大部分复杂度都是О(1),因此性能比较高;
3.多路复用和非阻塞I/O∶Reds使用I/O多路复用功能来监听多个socet连接客户端,这样就可以使用一个线程连接来处理多个请求,减少线程切换带来的开销,同时也避免了I/O阻塞操作
4.避免上下文切换:因为是单线程模型,因此就避免了不必要的上下文切换和多线程竞争,这就省去了多线程切换带来的时间和性能上的消耗,而且单线程不会导致死锁问题的发生
Redis7实战加面试题-高阶篇(Redis线程与IO多路复用,BigKey,缓存双写)_第3张图片
Redis7实战加面试题-高阶篇(Redis线程与IO多路复用,BigKey,缓存双写)_第4张图片
Redis 4.0之前一直采用单线程的主要原因有以下三个:
1 使用单线程模型是 Redis 的开发和维护更简单,因为单线程模型方便开发和调试;
2 即使使用单线程模型也并发的处理多客户端的请求,主要使用的是IO多路复用和非阻塞IO;
3 对于Redis系统来说,主要的性能瓶颈是内存或者网络带宽而并非 CPU。

既然单线程这么好,为什么逐渐又加入了多线程特性?

正常情况下使用 del 指令可以很快的删除数据,而当被删除的 key 是一个非常大的对象时,例如时包含了成千上万个元素的 hash 集合时,那么 del 指令就会造成 Redis 主线程卡顿。
这就是redis3.x单线程时代最经典的故障,大key删除的头疼问题,
由于redis是单线程的,del bigKey …
等待很久这个线程才会释放,类似加了一个synchronized锁,你可以想象高并发下,程序堵成什么样子?

如何解决?
使用惰性删除可以有效的避免 Redis卡顿的问题

比如当我(Redis)需要删除一个很大的数据时,因为是单线程原子命令操作,这就会导致 Redis 服务卡顿,
于是在 Redis 4.0 中就新增了多线程的模块,当然此版本中的多线程主要是为了解决删除数据效率比较低的问题的。
unlink key
flushdb async
flushall async
把删除工作交给了后台的小弟(子线程)异步来删除数据了。
因为Redis是单个主线程处理,redis之父antirez一直强调"Lazy Redis is better Redis".
而lazy free的本质就是把某些cost(主要时间复制度,占用主线程cpu时间片)较高删除操作,
从redis主线程剥离让bio子线程来处理,极大地减少主线阻塞时间。从而减少删除导致性能和稳定性问题。

在Redis 4.0就引入了多个线程来实现数据的异步惰性删除等功能,但是其处理读写请求的仍然只有一个线程,所以仍然算是狭义上的单线程。

redis6/7的多线程特性和IO多路复用入门篇

对于Redis主要的性能瓶颈是内存或者网络带宽而并非CPU。最后Redis的瓶颈可以初步定为:网络IO
Redis7实战加面试题-高阶篇(Redis线程与IO多路复用,BigKey,缓存双写)_第5张图片
在Redis6/7中,非常受关注的第一个新特性就是多线程。这是因为,Redis一直被大家熟知的就是它的单线程架构,虽然有些命令操作可以用后台线程或子进程执行(比如数据删除、快照生成、AOF重写)。但是,从网络IO处理到实际的读写命令处理,都是由单个线程完成的。随着网络硬件的性能提升,Redis的性能瓶颈有时会出现在网络IO的处理上,也就是说,单个主线程处理网络请求的速度跟不上底层网络硬件的速度,为了应对这个问题:采用多个IO线程来处理网络请求,提高网络请求处理的并行度,Redis6/7就是采用的这种方法。
但是,Redis的多IO线程只是用来处理网络请求的,对于读写操作命令Redis仍然使用单线程来处理。这是因为,Redis处理请求时,网络处理经常是瓶颈,通过多个IO线程并行处理网络操作,可以提升实例的整体处理性能。而继续使用单线程执行命令操作,就不用为了保证Lua脚本、事务的原子性,额外开发多线程互斥加锁机制了(不管加锁操作处理),这样一来,Redis线程模型实现就简单了
主线程和IO线程是怎么协作完成请求处理的-精讲版
Redis7实战加面试题-高阶篇(Redis线程与IO多路复用,BigKey,缓存双写)_第6张图片
Unix网络编程中的五种IO模型
1.Blocking lO-阻塞lO
2.NoneBlocking lO-非阻塞IO
3.lO multiplexing - lO多路复用
1)Linux世界─切皆文件:文件描述符、简称FD,句柄。文件描述符(File descriptor)是计算机科学中的一个术语,是一个用于表述指向文件的引用的抽象化概念。文件描述符在形式上是一个非负整数。实际上,它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。在程序设计中,文件描述符这一概念往往只适用于UNIX、Linux这样的操作系统。
Redis7实战加面试题-高阶篇(Redis线程与IO多路复用,BigKey,缓存双写)_第7张图片
2)IO多路复用是什么
—种同步的IO模型,实现一个线程监视多个文件句柄,一旦某个文件句柄就绪就能够通知到对应应用程序进行相应的读写操作,没有文件句柄就绪时就会阻塞应用程序,从而释放CPU资源
I/O∶网络I/O,尤其在操作系统层面指数据在内核态和用户态之间的读写操作
多路:多个客户端连接(连接就是套接字描述符,即socket或者channel)
复用:复用一个或几个线程。
IO多路复用:也就是说一个或一组线程处理多个TCP连接,使用单进程就能够实现同时处理多个客户端的连接,无需创建或者维护过多的进程/线程
一句话:一个服务端进程可以同时处理多个套接字描述符。实现IO多路复用的模型有3种:可以分select->poll->epoll三个阶段来描述。
3).场景体验
模拟一个tcp服务器处理30个客户socket。
假设你是一个监考老师,让30个学生解答一道竞赛考题,然后负责验收学生答卷,你有下面几个选择:
第一种选择(轮询):按顺序逐个验收,先验收A,然后是B,之后是C、D。。。这中间如果有一个学生卡住,全班都会被耽误,你用循环挨个处理socket,根本不具有并发能力。
第二种选择(来一个new一个,1对1服务):你创建30个分身线程,每个分身线程检查一个学生的答案是否正确。 这种类似于为每一个用户创建一个进程或者线程处理连接。
第三种选择(响应式处理,1对多服务),你站在讲台上等,谁解答完谁举手。这时C、D举手,表示他们解答问题完毕,你下去依次检查C、D的答案,然后继续回到讲台上等。此时E、A又举手,然后去处理E和A。。。这种就是IO复用模型。Linux下的select、poll和epoll就是干这个的。
4).IO多路复用模型:
将用户socket对应的文件描述符(FileDescriptor)注册进epoll,然后epoll帮你监听哪些socket上有消息到达,这样就避免了大量的无用操作。此时的socket应该采用非阻塞模式。这样,整个过程只在调用select、poll、epoll这些调用的时候才会阻塞,收发客户消息是不会阻塞的,整个进程或者线程就被充分利用起来,这就是事件驱动,所谓的reactor反应模式。
Redis7实战加面试题-高阶篇(Redis线程与IO多路复用,BigKey,缓存双写)_第8张图片
在单个线程通过记录跟踪每一个Sockek(I/O流)的状态来同时管理多个I/O流. 一个服务端进程可以同时处理多个套接字描述符。
目的是尽量多的提高服务器的吞吐能力。大家都用过nginx,nginx使用epoll接收请求,ngnix会有很多链接进来, epoll会把他们都监视起来,然后像拨开关一样,谁有数据就拨向谁,然后调用相应的代码处理。redis类似同理,这就是IO多路复用原理,有请求就响应,没请求不打扰。
5).小总结
只使用一个服务端进程可以同时处理多个套接字描述符连接
Redis7实战加面试题-高阶篇(Redis线程与IO多路复用,BigKey,缓存双写)_第9张图片
面试题;:redis为什么这么快
IO多路复用+epoll函数使用,才是redis为什么这么快的直接原因,而不是仅仅单线程命令+redis安装在内存中。

4.signal driven lO–信号驱动lO
5.asynchronous lO–异步lO
简单说明:
Redis工作线程是单线程的,但是,整个Redis来说,是多线程的;
主线程和Io线程是怎么协作完成请求处理的:

Redis7实战加面试题-高阶篇(Redis线程与IO多路复用,BigKey,缓存双写)_第10张图片
结论:
Redis7实战加面试题-高阶篇(Redis线程与IO多路复用,BigKey,缓存双写)_第11张图片
Redis7默认是否开启了多线程?
如果你在实际应用中,发现Redis实例的CPU开销不大但吞吐量却没有提升,可以考虑使用Redis7的多线程机制,加速网络处理,进而提升实例的吞吐量
Redis7将所有数据放在内存中,内存的响应时长大约为100纳秒,对于小数据包,Redis服务器可以处理8W到10W的QPS,
这也是Redis处理的极限了,对于80%的公司来说,单线程的Redis已经足够使用了。
在Redis6.0及7后,多线程机制默认是关闭的,如果需要使用多线程功能,需要在redis.conf中完成两个设置

Redis7实战加面试题-高阶篇(Redis线程与IO多路复用,BigKey,缓存双写)_第12张图片
1.设置io-thread-do-reads配置项为yes,表示启动多线程。
2。设置线程个数。关于线程数的设置,官方的建议是如果为 4 核的 CPU,建议线程数设置为 2 或 3,如果为 8 核 CPU 建议线程数设置为 6,线程数一定要小于机器核数,线程数并不是越大越好。

Redis自身出道就是优秀,基于内存操作、数据结构简单、多路复用和非阻塞 I/O、避免了不必要的线程上下文切换等特性,在单线程的环境下依然很快;
但对于大数据的 key 删除还是卡顿厉害,因此在 Redis 4.0 引入了多线程unlink key/flushall async 等命令,主要用于 Redis 数据的异步删除;
而在 Redis6/7中引入了 I/O 多线程的读写,这样就可以更加高效的处理更多的任务了,Redis 只是将 I/O 读写变成了多线程,而命令的执行依旧是由主线程串行执行的,因此在多线程下操作 Redis 不会出现线程安全的问题。
Redis 无论是当初的单线程设计,还是如今与当初设计相背的多线程,目的只有一个:让 Redis 变得越来越快。

BigKey

对于海量数据,通过key * 这个指令遍历,有致命的弊端,在实际环境中最好不要使用。生产上限制keys * / flushdb/flushall等危险命令以防止误删误用?通过配置设置禁用这些命令,redis.conf在SECURITY这一项中
在这里插入图片描述
Redis7实战加面试题-高阶篇(Redis线程与IO多路复用,BigKey,缓存双写)_第13张图片
不用keys*避免卡顿,那该用什么?使用scan命令。类似mysql limit的但不完全相同
scan
Scan命令用于迭代数据库中的数据库键
语法:
Redis7实战加面试题-高阶篇(Redis线程与IO多路复用,BigKey,缓存双写)_第14张图片
特点:
Redis7实战加面试题-高阶篇(Redis线程与IO多路复用,BigKey,缓存双写)_第15张图片
SCAN 命令是一个基于游标的迭代器,每次被调用之后, 都会向用户返回一个新的游标, 用户在下次迭代时需要使用这个新游标作为 SCAN 命令的游标参数, 以此来延续之前的迭代过程。
SCAN 返回一个包含两个元素的数组, 第一个元素是用于进行下一次迭代的新游标, 第二个元素则是一个数组, 这个数组中包含了所有被迭代的元素。如果新游标返回零表示迭代已结束。
SCAN的遍历顺序:
非常特别,它不是从第一维数组的第零位一直遍历到末尾,而是采用了高位进位加法来遍历。之所以使用这样特殊的方式进行遍历,是考虑到字典的扩容和缩容时避免槽位的遍历重复和遗漏。
使用:
Redis7实战加面试题-高阶篇(Redis线程与IO多路复用,BigKey,缓存双写)_第16张图片
多大算Big?
Redis7实战加面试题-高阶篇(Redis线程与IO多路复用,BigKey,缓存双写)_第17张图片
string和二级结构:string是value,最大512MB但是≥10KB就是bigkey。list、hash、set和zset,个数超过5000就是bigkey
哪些危害?
内存不均,集群迁移困难超时删除,大key删除作梗网络流量阻塞
如何发现?
redis-cli --bigkeys
好处:给出每种数据结构Top 1 bigkey,同时给出每种数据类型的键值个数+平均大小
不足:想查询大于10kb的所有key,–bigkeys参数就无能为力了,需要用到memory usage来计算每个键值的字节数
Redis7实战加面试题-高阶篇(Redis线程与IO多路复用,BigKey,缓存双写)_第18张图片
MEMORY USAGE键:计算每个键值的字节数
Redis7实战加面试题-高阶篇(Redis线程与IO多路复用,BigKey,缓存双写)_第19张图片
如何删除?
Redis7实战加面试题-高阶篇(Redis线程与IO多路复用,BigKey,缓存双写)_第20张图片
String:-般用del,如果过于庞大使用unlink。该命令和DEL十分相似:删除指定的key(s),若key不存在则该key被跳过。但是,相比DEL会产生阻塞,该命令会在另一个线程中回收内存,因此它是非阻塞的。 这也是该命令名字的由来:仅将keys从keyspace元数据中删除,真正的删除会在后续异步操作。
hash:使用hscan每次获取少量field-value,再使用hdel删除每个field
Redis7实战加面试题-高阶篇(Redis线程与IO多路复用,BigKey,缓存双写)_第21张图片
Redis7实战加面试题-高阶篇(Redis线程与IO多路复用,BigKey,缓存双写)_第22张图片
list:使用ltrim渐进式逐步删除,直到全部删除完成
Redis7实战加面试题-高阶篇(Redis线程与IO多路复用,BigKey,缓存双写)_第23张图片
Redis7实战加面试题-高阶篇(Redis线程与IO多路复用,BigKey,缓存双写)_第24张图片
set:使用sscan每次获取部分元素,再使用srem命令删除每个元素
Redis7实战加面试题-高阶篇(Redis线程与IO多路复用,BigKey,缓存双写)_第25张图片
Redis7实战加面试题-高阶篇(Redis线程与IO多路复用,BigKey,缓存双写)_第26张图片
zset:使用zscan每次获取部分元素,再使用ZREMRANGEBYRANK命令删除每个元素
Redis7实战加面试题-高阶篇(Redis线程与IO多路复用,BigKey,缓存双写)_第27张图片
Redis7实战加面试题-高阶篇(Redis线程与IO多路复用,BigKey,缓存双写)_第28张图片
BigKey生产调优
redis.conf配置文件LAZY FREEING相关说明:阻塞和非阻塞删除命令
Redis7实战加面试题-高阶篇(Redis线程与IO多路复用,BigKey,缓存双写)_第29张图片
优化配置:
Redis7实战加面试题-高阶篇(Redis线程与IO多路复用,BigKey,缓存双写)_第30张图片

缓存双写一致性之更新策略探讨

问题:
Redis7实战加面试题-高阶篇(Redis线程与IO多路复用,BigKey,缓存双写)_第31张图片

缓存双写一致性,谈谈你的理解?

1.如果redis中有数据:需要和数据库中的值保持一致
2.如果redis中无数据:数据库中的值要是最新值,且准备回写redis
3.缓存按照操作来分,细分2种
1).只读缓存
2) .读写缓存:
同步直写策略:
写数据库后也同步写redis缓存,缓存和数据库中的数据一致;
对于读写缓存来说,要想保证缓存和数据库中的数据一致,就要采用同步直写策略
异步缓写策略:
正常业务运行中,mysql数据变动了,但是可以在业务上容许出现一定时间后才作用于redis,比如仓库、物流
系统异常情况出现了,不得不将失败的动作重新修补,有可能需要借助kafka或者RabbitMQ等消息中间件,实现重试重写
4.怎么写
Redis7实战加面试题-高阶篇(Redis线程与IO多路复用,BigKey,缓存双写)_第32张图片
采用双检加锁策略:多个线程同时去查询数据库的这条数据,那么我们可以在第一个查询数据的请求上使用一个 互斥锁来锁住它。其他的线程走到这一步拿不到锁就等着,等第一个线程查询到了数据,然后做缓存。后面的线程进来发现已经有缓存了,就直接走缓存。
Redis7实战加面试题-高阶篇(Redis线程与IO多路复用,BigKey,缓存双写)_第33张图片
代码:

package com.atguigu.redis.service;

import com.atguigu.redis.entities.User;
import com.atguigu.redis.mapper.UserMapper;
import io.swagger.models.auth.In;
import lombok.extern.slf4j.Slf4j;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.stereotype.Service;
import org.springframework.web.bind.annotation.PathVariable;

import javax.annotation.Resource;
import java.util.concurrent.TimeUnit;

/**
 * @auther zzyy
 * @create 2021-05-01 14:58
 */
@Service
@Slf4j
public class UserService {
    public static final String CACHE_KEY_USER = "user:";
    @Resource
    private UserMapper userMapper;
    @Resource
    private RedisTemplate redisTemplate;

    /**
     * 业务逻辑没有写错,对于小厂中厂(QPS《=1000)可以使用,但是大厂不行
     * @param id
     * @return
     */
    public User findUserById(Integer id)
    {
        User user = null;
        String key = CACHE_KEY_USER+id;

        //1 先从redis里面查询,如果有直接返回结果,如果没有再去查询mysql
        user = (User) redisTemplate.opsForValue().get(key);

        if(user == null)
        {
            //2 redis里面无,继续查询mysql
            user = userMapper.selectByPrimaryKey(id);
            if(user == null)
            {
                //3.1 redis+mysql 都无数据
                //你具体细化,防止多次穿透,我们业务规定,记录下导致穿透的这个key回写redis
                return user;
            }else{
                //3.2 mysql有,需要将数据写回redis,保证下一次的缓存命中率
                redisTemplate.opsForValue().set(key,user);
            }
        }
        return user;
    }


    /**
     * 加强补充,避免突然key失效了,打爆mysql,做一下预防,尽量不出现击穿的情况。
     * @param id
     * @return
     */
    public User findUserById2(Integer id)
    {
        User user = null;
        String key = CACHE_KEY_USER+id;

        //1 先从redis里面查询,如果有直接返回结果,如果没有再去查询mysql,
        // 第1次查询redis,加锁前
        user = (User) redisTemplate.opsForValue().get(key);
        if(user == null) {
            //2 大厂用,对于高QPS的优化,进来就先加锁,保证一个请求操作,让外面的redis等待一下,避免击穿mysql
            synchronized (UserService.class){
                //第2次查询redis,加锁后
                user = (User) redisTemplate.opsForValue().get(key);
                //3 二次查redis还是null,可以去查mysql了(mysql默认有数据)
                if (user == null) {
                    //4 查询mysql拿数据(mysql默认有数据)
                    user = userMapper.selectByPrimaryKey(id);
                    if (user == null) {
                        return null;
                    }else{
                        //5 mysql里面有数据的,需要回写redis,完成数据一致性的同步工作
                        redisTemplate.opsForValue().setIfAbsent(key,user,7L,TimeUnit.DAYS);
                    }
                }
            }
        }
        return user;
    }

}

数据库和缓存一致性的几种更新策略

目的:达到最终一致性。给缓存设置过期时间,定期清理缓存并回写,是保证最终一致性的解决方案。
我们可以对存入缓存的数据设置过期时间,所有的写操作以数据库为准,对缓存操作只是尽最大努力即可。也就是说如果数据库写成功,缓存更新失败,那么只要到达过期时间,则后面的读请求自然会从数据库中读取新值然后回填缓存,达到一致性,切记,要以mysql的数据库写入库为准。
上述方案和后续落地案例是调研后的主流+成熟的做法,但是考虑到各个公司业务系统的差距,
不是100%绝对正确,不保证绝对适配全部情况,
4种更新策略:
1.先更新数据库,再更新缓存
异常问题1:

1 先更新mysql的某商品的库存,当前商品的库存是100,更新为99个。
2 先更新mysql修改为99成功,然后更新redis。
3此时假设异常出现,更新redis失败了,这导致mysql里面的库存是99而redis里面的还是100 。
4 上述发生,会让数据库里面和缓存redis里面数据不一致,读到redis脏数据

异常问题2:

【先更新数据库,再更新缓存】,A、B两个线程发起调用
【正常逻辑】
1 A update mysql 100
2 A update redis 100
3 B update mysql 80
4 B update redis 80
【异常逻辑】
多线程环境下,A、B两个线程有快有慢,有前有后有并行
1 A update mysql 100
3 B update mysql 80
4 B update redis 80
2 A update redis 100
最终结果,mysql和redis数据不一致
mysql 80,redis 100

2.先更新缓存,再更新数据库(不推荐,业务上一般把mysql作为底单数据库,保证最后解释
)。异常问题

【先更新缓存,再更新数据库】,A、B两个线程发起调用
【正常逻辑】
1 A update redis 100
2 A update mysql 100
3 B update redis 80
4 B update mysql 80
【异常逻辑】
多线程环境下,A、B两个线程有快有慢有并行
A update redis 100
B update redis 80
B update mysql 80
A update mysql 100
----mysql 100,redis 80

3.先删除缓存,再更新数据库
异常问题:
步骤分析1,先删除缓存,再更新数据库
A线程先成功删除了redis里面的数据,然后去更新mysql,此时mysql正在更新中,还没有结束。(比如网络延时)
B突然出现要来读取缓存数据。
Redis7实战加面试题-高阶篇(Redis线程与IO多路复用,BigKey,缓存双写)_第34张图片

步骤分析2,先删除缓存,再更新数据库
2 此时redis里面的数据是空的,B线程来读取,先去读redis里数据(已经被A线程delete掉了),此处出来2个问题:
2.1 B从mysql获得了旧值
B线程发现redis里没有(缓存缺失)马上去mysql里面读取,从数据库里面读取来的是旧值。
2.2 B会把获得的旧值写回redis
获得旧值数据后返回前台并回写进redis(刚被A线程删除的旧数据有极大可能又被写回了)。
Redis7实战加面试题-高阶篇(Redis线程与IO多路复用,BigKey,缓存双写)_第35张图片
步骤分析3,先删除缓存,再更新数据库
A线程更新完mysql,发现redis里面的缓存是脏数据,A线程直接懵逼了
两个并发操作,一个是更新操作,另一个是查询操作,
A删除缓存后,B查询操作没有命中缓存,B先把老数据读出来后放到缓存中,然后A更新操作更新了数据库。
于是,在缓存中的数据还是老的数据,导致缓存中的数据是脏的,而且还一直这样脏下去了。

4 总结流程:
(1)请求A进行写操作,删除redis缓存后,工作正在进行中,更新mysql…A还么有彻底更新完mysql,还没commit
(2)请求B开工查询,查询redis发现缓存不存在(被A从redis中删除了)
(3)请求B继续,去数据库查询得到了mysql中的旧值(A还没有更新完)
(4)请求B将旧值写回redis缓存
(5)请求A将新值写入mysql数据库
上述情况就会导致不一致的情形出现。
Redis7实战加面试题-高阶篇(Redis线程与IO多路复用,BigKey,缓存双写)_第36张图片
在这里插入图片描述
解决方法:
采用延时双删策略
Redis7实战加面试题-高阶篇(Redis线程与IO多路复用,BigKey,缓存双写)_第37张图片
在这里插入图片描述
双删方案面试题:
这个删除该休眠多久呢?

线程A sleep的时间,就需要大于线程B读取数据再写入缓存的时间。 这个时间怎么确定呢? 第一种方法:
在业务程序运行的时候,统计下线程读数据和写缓存的操作时间,自行评估自己的项目的读数据业务逻辑的耗时,
以此为基础来进行估算。然后写数据的休眠时间则在读数据业务逻辑的耗时基础上加百毫秒即可。
这么做的目的,就是确保读请求结束,写请求可以删除读请求造成的缓存脏数据。 第二种方法:
新启动一个后台监控程序,比如后面要讲解的WatchDog监控程序,会加时

这种同步淘汰策略,吞吐量降低怎么办?
Redis7实战加面试题-高阶篇(Redis线程与IO多路复用,BigKey,缓存双写)_第38张图片
4.先更新数据库,再删除缓存
异常问题:
Redis7实战加面试题-高阶篇(Redis线程与IO多路复用,BigKey,缓存双写)_第39张图片
解决方案
Redis7实战加面试题-高阶篇(Redis线程与IO多路复用,BigKey,缓存双写)_第40张图片
1 可以把要删除的缓存值或者是要更新的数据库值暂存到消息队列中(例如使用Kafka/RabbitMQ等)。
2 当程序没有能够成功地删除缓存值或者是更新数据库值时,可以从消息队列中重新读取这些值,然后再次进行删除或更新。
3 如果能够成功地删除或更新,我们就要把这些值从消息队列中去除,以免重复操作,此时,我们也可以保证数据库和缓存的数据一致了,否则还需要再次进行重试
4 如果重试超过的一定次数后还是没有成功,我们就需要向业务层发送报错信息了,通知运维人员。

类似经典的分布式事务问题,只有一个权威答案:最终一致性
小总结:如何选择方案?利弊如何
在大多数业务场景下, 优先使用先更新数据库,再删除缓存的方案(先更库→后删存)。理由如下:
1 先删除缓存值再更新数据库,有可能导致请求因缓存缺失而访问数据库,给数据库带来压力导致打满mysql。
2 如果业务应用中读取数据库和写缓存的时间不好估算,那么,延迟双删中的等待时间就不好设置。
多补充一句:如果使用先更新数据库,再删除缓存的方案
如果业务层要求必须读取一致性的数据,那么我们就需要在更新数据库时,先在Redis缓存客户端暂停并发读请求,等数据库更新完、缓存值删除后,再读取数据,从而保证数据一致性,这是理论可以达到的效果,但实际,不推荐,因为真实生产环境中,分布式下很难做到实时一致性,一般都是最终一致性,请大家参考。
Redis7实战加面试题-高阶篇(Redis线程与IO多路复用,BigKey,缓存双写)_第41张图片

Redis与MySQL数据双写一致性工程落地案例

面试题
Redis7实战加面试题-高阶篇(Redis线程与IO多路复用,BigKey,缓存双写)_第42张图片
采用双检加锁策略
多个线程同时去查询数据库的这条数据,那么我们可以在第一个查询数据的请求上使用一个 互斥锁来锁住它。其他的线程走到这一步拿不到锁就等着,等第一个线程查询到了数据,然后做缓存。后面的线程进来发现已经有缓存了,就直接走缓存
Redis7实战加面试题-高阶篇(Redis线程与IO多路复用,BigKey,缓存双写)_第43张图片

canal

canal [kə’næl],中文翻译为 水道/管道/沟渠/运河,主要用途是用于 MySQL 数据库增量日志数据的订阅、消费和解析,是阿里巴巴开发并开源的,采用Java语言开发;
历史背景是早期阿里巴巴因为杭州和美国双机房部署,存在跨机房数据同步的业务需求,实现方式主要是基于业务 trigger(触发器) 获取增量变更。从2010年开始,阿里巴巴逐步尝试采用解析数据库日志获取增量变更进行同步,由此衍生出了canal项目;
能干嘛:
数据库镜像
数据库实时备份
索引构建和实时维护(拆分异构索引、倒排索引等)
业务cache 刷新
带业务逻辑的增量数据处理
工作原理,面试回答:
传统MySQL主从复制工作原理
Redis7实战加面试题-高阶篇(Redis线程与IO多路复用,BigKey,缓存双写)_第44张图片
MySQL的主从复制将经过如下步骤:
1、当 master 主服务器上的数据发生改变时,则将其改变写入二进制事件日志文件中;
2、salve 从服务器会在一定时间间隔内对 master 主服务器上的二进制日志进行探测,探测其是否发生过改变,
如果探测到 master 主服务器的二进制事件日志发生了改变,则开始一个 I/O Thread 请求 master 二进制事件日志;
3、同时 master 主服务器为每个 I/O Thread 启动一个dump Thread,用于向其发送二进制事件日志;
4、slave 从服务器将接收到的二进制事件日志保存至自己本地的中继日志文件中;
5、salve 从服务器将启动 SQL Thread 从中继日志中读取二进制日志,在本地重放,使得其数据和主服务器保持一致;
6、最后 I/O Thread 和 SQL Thread 将进入睡眠状态,等待下一次被唤醒;
canal工作原理:
Redis7实战加面试题-高阶篇(Redis线程与IO多路复用,BigKey,缓存双写)_第45张图片

mysql-canal-redis双写一致性Coding

java案例,来源出处:https://github.com/alibaba/canal/wiki/ClientExample
MySQL:
1.查看mysql版本:SELECT VERSION();
2.当前的主机二进制日志:show master status;
3.查看SHOW VARIABLES LIKE ‘log_bin’;
Redis7实战加面试题-高阶篇(Redis线程与IO多路复用,BigKey,缓存双写)_第46张图片
4.开启MySQL的binlog写入功能;在mysql安装目录下打开(window是my.ini,linux是my.cnf),提前做好备份

log-bin=mysql-bin #开启 binlog
binlog-format=ROW #选择 ROW 模式
server_id=1 #配置MySQL replaction需要定义,不要和canal的 slaveId重复

ROW模式 除了记录sql语句之外,还会记录每个字段的变化情况,能够清楚的记录每行数据的变化历史,但会占用较多的空间。
STATEMENT模式只记录了sql语句,但是没有记录上下文信息,在进行数据恢复的时候可能会导致数据的丢失情况;
MIX模式比较灵活的记录,理论上说当遇到了表结构变更的时候,就会记录为statement模式。当遇到了数据更新或者删除情况下就会变为row模式;
Redis7实战加面试题-高阶篇(Redis线程与IO多路复用,BigKey,缓存双写)_第47张图片
5.重启mysql
6.再次查看SHOWVARIABLES LIKE ‘log_bin’;
Redis7实战加面试题-高阶篇(Redis线程与IO多路复用,BigKey,缓存双写)_第48张图片
7.授权canal连接MySQL账号:
mysql默认的用户在mysql库的user表里
Redis7实战加面试题-高阶篇(Redis线程与IO多路复用,BigKey,缓存双写)_第49张图片
8.默认没有canal账户,此处新建+授权

DROP USER IF EXISTS ‘canal’@‘%’;
CREATE USER ‘canal’@‘%’ IDENTIFIED BY ‘canal’;
GRANT ALL PRIVILEGES ON . TO ‘canal’@‘%’ IDENTIFIED BY ‘canal’;
FLUSH PRIVILEGES;
SELECT * FROM mysql.user;

Redis7实战加面试题-高阶篇(Redis线程与IO多路复用,BigKey,缓存双写)_第50张图片
canal服务端
1.下载
https://github.com/alibaba/canal/releases/tag/canal-1.1.6
下载Linux版本:canal.deployer-1.1.6.tar.gz
2.解压
解压后整体放入/mycanal路径下
3.配置
修改/mycanal/conf/example路径下instance.properties文件
Redis7实战加面试题-高阶篇(Redis线程与IO多路复用,BigKey,缓存双写)_第51张图片
换成自己的mysql主机master的IP地址
Redis7实战加面试题-高阶篇(Redis线程与IO多路复用,BigKey,缓存双写)_第52张图片
换成自己的在mysql新建的canal账户
Redis7实战加面试题-高阶篇(Redis线程与IO多路复用,BigKey,缓存双写)_第53张图片
4.启动
/opt/mycanal/bin路径下执行/startup.sh
5.查看
判断canal是否启动成功
查看server日志
Redis7实战加面试题-高阶篇(Redis线程与IO多路复用,BigKey,缓存双写)_第54张图片

查看样例example的日志
Redis7实战加面试题-高阶篇(Redis线程与IO多路复用,BigKey,缓存双写)_第55张图片
canal名户端(Java编写业务程序)
SQL脚本

1 随便选个数据库,以你自己为主,本例bigdata,按照下面建表
CREATE TABLE t_user (
id bigint(20) NOT NULL AUTO_INCREMENT,
userName varchar(100) NOT NULL,
PRIMARY KEY (id)
) ENGINE=InnoDB AUTO_INCREMENT=10 DEFAULT CHARSET=utf8mb4

建module
改POM



    4.0.0

    com.atguigu.canal
    canal_demo02
    1.0-SNAPSHOT

    
        org.springframework.boot
        spring-boot-starter-parent
        2.5.14
        
    

    
        UTF-8
        1.8
        1.8
        4.12
        1.2.17
        1.16.18
        5.1.47
        1.1.16
        4.1.5
        1.3.0
    

    
        
        
            com.alibaba.otter
            canal.client
            1.1.0
        
        
        
            org.springframework.boot
            spring-boot-starter-web
        
        
            org.springframework.boot
            spring-boot-starter-actuator
        
        
        
            io.springfox
            springfox-swagger2
            2.9.2
        
        
            io.springfox
            springfox-swagger-ui
            2.9.2
        
        
        
            org.springframework.boot
            spring-boot-starter-data-redis
        
        
            org.apache.commons
            commons-pool2
        
        
        
            org.springframework.boot
            spring-boot-starter-aop
        
        
            org.aspectj
            aspectjweaver
        
        
        
            mysql
            mysql-connector-java
            5.1.47
        
        
        
            com.alibaba
            druid-spring-boot-starter
            1.1.10
        
        
            com.alibaba
            druid
            ${druid.version}
        
        
        
            org.mybatis.spring.boot
            mybatis-spring-boot-starter
            ${mybatis.spring.boot.version}
        
        
        
        
            cn.hutool
            hutool-all
            5.2.3
        
        
            junit
            junit
            ${junit.version}
        
        
            org.springframework.boot
            spring-boot-starter-test
            test
        
        
            log4j
            log4j
            ${log4j.version}
        
        
            org.projectlombok
            lombok
            ${lombok.version}
            true
        
        
        
            javax.persistence
            persistence-api
            1.0.2
        
        
        
            tk.mybatis
            mapper
            ${mapper.version}
        
        
            org.springframework.boot
            spring-boot-autoconfigure
        
        
            redis.clients
            jedis
            3.8.0
        
    

    
        
            
                org.springframework.boot
                spring-boot-maven-plugin
            
        
    


写YML

server.port=5555

# ========================alibaba.druid=====================
spring.datasource.type=com.alibaba.druid.pool.DruidDataSource
spring.datasource.driver-class-name=com.mysql.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/bigdata?useUnicode=true&characterEncoding=utf-8&useSSL=false
spring.datasource.username=root
spring.datasource.password=123456
spring.datasource.druid.test-while-idle=false

主启动

package com.atguigu.canal;

import com.alibaba.druid.spring.boot.autoconfigure.DruidDataSourceAutoConfigure;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

/**
 * @auther zzyy
 * @create 2022-07-27 11:48
 */
@SpringBootApplication
public class CanalDemo02App
{

     //本例不要启动CanalDemo02App实例
}

 

业务类
RedisUtils

package com.atguigu.canal.util;

import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.JedisPoolConfig;

/**
 * @auther zzyy
 * @create 2022-12-22 12:42
 */
public class RedisUtils
{
    public static final String  REDIS_IP_ADDR = "192.168.111.185";
    public static final String  REDIS_pwd = "111111";
    public static JedisPool jedisPool;

    static {
        JedisPoolConfig jedisPoolConfig=new JedisPoolConfig();
        jedisPoolConfig.setMaxTotal(20);
        jedisPoolConfig.setMaxIdle(10);
        jedisPool=new JedisPool(jedisPoolConfig,REDIS_IP_ADDR,6379,10000,REDIS_pwd);
    }

    public static Jedis getJedis() throws Exception {
        if(null!=jedisPool){
            return jedisPool.getResource();
        }
        throw new Exception("Jedispool is not ok");
    }

}

RedisCanalClientExample

package com.atguigu.canal.biz;

import com.alibaba.fastjson.JSONObject;
import com.alibaba.otter.canal.client.CanalConnector;
import com.alibaba.otter.canal.client.CanalConnectors;
import com.alibaba.otter.canal.protocol.CanalEntry.*;
import com.alibaba.otter.canal.protocol.Message;
import com.atguigu.canal.util.RedisUtils;
import redis.clients.jedis.Jedis;
import java.net.InetSocketAddress;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.TimeUnit;

/**
 * @auther zzyy
 * @create 2022-12-22 12:43
 */
public class RedisCanalClientExample
{
    public static final Integer _60SECONDS = 60;
    public static final String  REDIS_IP_ADDR = "192.168.111.185";

    private static void redisInsert(List columns)
    {
        JSONObject jsonObject = new JSONObject();
        for (Column column : columns)
        {
            System.out.println(column.getName() + " : " + column.getValue() + "    update=" + column.getUpdated());
            jsonObject.put(column.getName(),column.getValue());
        }
        if(columns.size() > 0)
        {
            try(Jedis jedis = RedisUtils.getJedis())
            {
                jedis.set(columns.get(0).getValue(),jsonObject.toJSONString());
            }catch (Exception e){
                e.printStackTrace();
            }
        }
    }


    private static void redisDelete(List columns)
    {
        JSONObject jsonObject = new JSONObject();
        for (Column column : columns)
        {
            jsonObject.put(column.getName(),column.getValue());
        }
        if(columns.size() > 0)
        {
            try(Jedis jedis = RedisUtils.getJedis())
            {
                jedis.del(columns.get(0).getValue());
            }catch (Exception e){
                e.printStackTrace();
            }
        }
    }

    private static void redisUpdate(List columns)
    {
        JSONObject jsonObject = new JSONObject();
        for (Column column : columns)
        {
            System.out.println(column.getName() + " : " + column.getValue() + "    update=" + column.getUpdated());
            jsonObject.put(column.getName(),column.getValue());
        }
        if(columns.size() > 0)
        {
            try(Jedis jedis = RedisUtils.getJedis())
            {
                jedis.set(columns.get(0).getValue(),jsonObject.toJSONString());
                System.out.println("---------update after: "+jedis.get(columns.get(0).getValue()));
            }catch (Exception e){
                e.printStackTrace();
            }
        }
    }

    public static void printEntry(List entrys) {
        for (Entry entry : entrys) {
            if (entry.getEntryType() == EntryType.TRANSACTIONBEGIN || entry.getEntryType() == EntryType.TRANSACTIONEND) {
                continue;
            }

            RowChange rowChage = null;
            try {
                //获取变更的row数据
                rowChage = RowChange.parseFrom(entry.getStoreValue());
            } catch (Exception e) {
                throw new RuntimeException("ERROR ## parser of eromanga-event has an error,data:" + entry.toString(),e);
            }
            //获取变动类型
            EventType eventType = rowChage.getEventType();
            System.out.println(String.format("================> binlog[%s:%s] , name[%s,%s] , eventType : %s",
                    entry.getHeader().getLogfileName(), entry.getHeader().getLogfileOffset(),
                    entry.getHeader().getSchemaName(), entry.getHeader().getTableName(), eventType));

            for (RowData rowData : rowChage.getRowDatasList()) {
                if (eventType == EventType.INSERT) {
                    redisInsert(rowData.getAfterColumnsList());
                } else if (eventType == EventType.DELETE) {
                    redisDelete(rowData.getBeforeColumnsList());
                } else {//EventType.UPDATE
                    redisUpdate(rowData.getAfterColumnsList());
                }
            }
        }
    }


    public static void main(String[] args)
    {
        System.out.println("---------O(∩_∩)O哈哈~ initCanal() main方法-----------");

        //=================================
        // 创建链接canal服务端
        CanalConnector connector = CanalConnectors.newSingleConnector(new InetSocketAddress(REDIS_IP_ADDR,
                11111), "example", "", "");
        int batchSize = 1000;
        //空闲空转计数器
        int emptyCount = 0;
        System.out.println("---------------------canal init OK,开始监听mysql变化------");
        try {
            connector.connect();
            //connector.subscribe(".*\\..*");
            connector.subscribe("bigdata.t_user");
            connector.rollback();
            int totalEmptyCount = 10 * _60SECONDS;
            while (emptyCount < totalEmptyCount) {
                System.out.println("我是canal,每秒一次正在监听:"+ UUID.randomUUID().toString());
                Message message = connector.getWithoutAck(batchSize); // 获取指定数量的数据
                long batchId = message.getId();
                int size = message.getEntries().size();
                if (batchId == -1 || size == 0) {
                    emptyCount++;
                    try { TimeUnit.SECONDS.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); }
                } else {
                    //计数器重新置零
                    emptyCount = 0;
                    printEntry(message.getEntries());
                }
                connector.ack(batchId); // 提交确认
                // connector.rollback(batchId); // 处理失败, 回滚数据
            }
            System.out.println("已经监听了"+totalEmptyCount+"秒,无任何消息,请重启重试......");
        } finally {
            connector.disconnect();
        }
    }
}

题外话:
java程序下connector.subscribe配置的过滤正则
Redis7实战加面试题-高阶篇(Redis线程与IO多路复用,BigKey,缓存双写)_第56张图片
关闭资源代码简写
try-with-resources释放资源
Redis7实战加面试题-高阶篇(Redis线程与IO多路复用,BigKey,缓存双写)_第57张图片

下一篇:

Redis7实战加面试题-高阶篇(案例落地实战bitmap/hyperloglog/GEO)

你可能感兴趣的:(redis,redis,缓存,java)