Redis-进阶版-线程问题

Redis单线程VS多线程

1、面试题

  • redis到底是单线程还是多线程?

    Redis的版本很多3.x、4.x、6.x,版本不同架构也是不同的,不限定版本问是否单线程也不太严谨。

    redis4之后才慢慢支持多线程,直到redis6/7之后才稳定。

    • 版本3.x,最早版本,也就是大家口口相传的redis是单线程。

    • 版本4.x,严格意义来说也不是单线程,而是负责处理客户端请求的线程是单线程,但是好像开始加了点多线程的东西(异步删除)。

    • 2020年5月版本的6.0.x后及2022年出的7.0版本后,告别了大家印象中的单线程,用一种全新的多线程来解决问题。

      image-20230826084605138

  • IO多路复用听说过吗?

  • redis为什么快?

    ---------->redis为什么那么快的答案就是,IO多路复用+epoll函数使用,才是redis为什么这么快的直接原因,而不是仅仅单线程命令+redis安装在内存中。

2、Redis为什么选择单线程?

Redis是单线程,主要是指Redis的网络IO和键值对读写是由一个线程来完成的,Redis在处理客户端的请求时包括获取(socket 读)、解析、执行、内容返回(socket写)等都由一个顺序串行的主线程处理,这就是所谓的“单线程”。这也是Redis对外提供键值存储服务的主要流程。

image-20230826085141291

但Redis的其他功能,比如持久化RDB、AOF、异步删除、集群数据同步等等,其实是由额外的线程执行的。Redis命令工作线程是单线程的,但是,整个Redis来说,是多线程的;

Redis3.x单线程时代但性能依旧很快的主要原因

  • 基于内存操作:Redis 的所有数据都存在内存中,因此所有的运算都是内存级别的,所以他的性能比较高;

  • 数据结构简单: Redis的数据结构是专门设计的,而这些简单的数据结构的查找和操作的时间大部分复杂度都是O(1),所以性能比较高;

  • 多路复用和非阻塞V/O:Redis使用V/O多路复用功能来监听多个socket连接客户端,这样就可以使用一个线程连接来处理多个请求,减少线程切换带来的开销,同时也避免了I/O阻塞操作;

  • 避免上下文切换:因为是单线程模型,因此就避免了不必要的上下文切换和多线程竞争,这就省去了多线程切换带来的时间和性能上的消耗,而且单线程不会导致死锁问题的发生;

Redis是单线程的。如何利用多个CPU/内核?(官方话) CPU并不是您使用Redis的瓶颈,因为通常Redis要么受内存限制,要么受网络限制。例如,使用在平均Linux系统上运行的流水线Redis每秒可以发送一百万个请求,因此,如果您的应用程序主要使用O(N)或o (log (N) ) 命令,则几乎不会使用过多的CPU。 但是,为了最大程度地利用CPU,您可以在同一框中启动多个Redis实例,并将它们视为不同的服务器。在某个时候,单个盒子可能还不够,因此,如果您要使用多个CPU,则可以开始考虑更早地进行分片的某种方法。 您可以在"分区"页面中找到有关使用多个Redis实例的更多信息。 但是,在Redis 4.0中,我们开始使Redis具有更多线程。目前,这仅限于在后台删除对象,以及阻止通过Redis模块实现的命令,对于将来的版本,计划是使Redis越来越线程化

解读

他的大体意思是说Redis 是基于内存操作的,因此他的瓶颈可能是机器的内存或者网络带宽而并非CPU,既然CPU不是瓶颈,那么自然就采用单线程的解决方案了,况且使用多线程比较麻烦。但是在.Redis 4.0中开始支持多线程了,例如后台删除、备份等功能。

Redis 4.0之前一直采用单线程的主要原因有以下三个

1使用单线程模型是, Redis 的开发和维护更简单,因为单线程模型方便开发和调试; 2即使使用单线程模型也并发的处理多客户端的请求,主要使用的是IO多路复用和非阻塞IO; 3对于Redis系统来说,主要的性能瓶颈是内存或者网络带宽而并非 CPU。

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

虽然说CPU不是Reds的瓶颈,但是现在都是多核时代了,你只有一个单线程......

Redis单线程的一个问题

正常情况下使用del指令可以很快的删除数据,而当被删除的 key是一个非常大的对象时,例如时包含了成千上万个元素的 hash集合时,那么del指令就会造成Redis主线程卡顿。

这就是redis3.x单线程时代最经典的故障,大key删除的头疼问题,

由于redis是单线程的,del bigKey .....I 等待很久这个线程才会释放,类似加了一个synchronized锁,高并发情况下,程序会非常堵。

怎么解决这个问题?

使用惰性删除可以有效的避免 Redis卡顿的问题, Redis 4.0中就新增了多线程的模块,当然此版本中的多线程主要是为了解决删除数据效率比较低的问题的。

使用unlik key

使用FLUSHDB async异步把删除工作交给了后台的小弟(子线程)异步来删除数据了。

从redis主线程剥离让bio子线程来处理,极大地减少主线阻塞时间。从而减少删除导致性能和稳定性问题。

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

在Redis6/7中,非常受关注的第一个新特性就是多线程。

这是因为,Redis一直被大家熟知的就是它的单线程架构,虽然有些命令操作可以用后台线程或子进程执行(比如数据删除、快照生成、AOF重写)。但是,从网络IO处理到实际的读写命令处理,都是由单个线程完成的。

随着网络硬件的性能提升,Redis的性能瓶颈有时会出现在网络IO的处理上,也就是说,单个主线程处理网络请求的速度跟不上底层网络硬件的速度,

但是,Redis的多IO线程只是用来处理网络请求的,对于读写操作命令Redis仍然使用单线程来处理。这是因为,Redis处理请求时,网络处理经常是瓶颈,通过多个IO线程并行处理网络操作,可以提升实例的整体处理性能。而继续使用单线程执行命令操作,就不用为了保证Lua脚本、事务的原子性,额外开发多线程互斥加锁机制了(不管加锁操作处理),这样一来,Redis线程模型实现就简单了

主线程和Io线程是怎么协作完成请求处理的

  • 阶段一: 服务端和客户端建立Socket连接,并分配处理线程

    首先,主线程负责接收建立连接请求。当有客户端请求和实例建立Socket连接时,主线程会创建和客户端的连接,并把Socket放入全局等待队列中。紧接着,主线程通过轮询方法把Socket连接分配给IO线程。

  • 阶段二: I0线程读取并解析请求

    主线程一旦把Socket分配给l0线程,就会进入阻塞状态,等待IO线程完成客户端请求读取和解析。因为有多个IO线程在并行处理,所以,这个过程很快就可以完成。

  • 阶段三:主线程执行请求操作

    等到IO线程解析完请求,主线程还是会以单线程的方式执行这些命令操作。

  • 阶段四:IO线程回写Socket和主线程清空全局队列

    当主线程执行完请求操作后,会把需要返回的结果写入缓冲区,然后,主线程会阻塞等待IO线程,把这些结果回写到Socket中,并返回给客户端。和IO线程读取和解析请求一样,IO线程回写Socket时,也是有多个线程在并发执行,所以回写Socket的速度也很快。等到IO线程回写Socket完毕,主线程会清空全局队列,等待客户端的后续请求。

image-20230826110342851

image-20230826110655630

5、Unix网络编程中的五种IO模型

  • Blocking lO –阻塞IO

  • NoneBlocking lo –非阻塞IO

  • lO multiplexing - lO多路复用

    • 什么叫IO多路复用

      一种同步的Io模型,实现一个线程监视多个文件句柄,一旦某个文件句柄就绪就能够通知到对应应用程序进行相应的读写操作,没有文件句柄就绪时就会阻塞应用程序,从而释放CPU资源;

    • 概念

      • I/O∶网络I/o,尤其在操作系统层面指数据在内核态和用户态之间的读写操作

      • 多路:多个客户端连接(连接就是套接字描述符,即socket或者channel)

      • 复用:复用一个或几个线程。

      • lO多路复用:

        也就是说一个或一组线程处理多个TCP连接,使用单进程就能够实现同时处理多个客户端的连接,无需创建或者维护过多的进程/线程

      • 一句话:一个服务端进程可以同时处理多个套接字描述符。实现IO多路复用的模型有3种:可以分select->poll->epoll三个阶段来描述。

    • epoll

      模拟一个tcp服务器处理30个客户socket

      假设你是一个监考老师,让30个学生解答一道竞赛考题,然后负责验收学生答卷,你有下面几个选择:

      1. 第一种选择(轮询);按顺序逐个验收,先验收A,然后是B,之后是C、D。。。这中间如果有一个学生卡住,全班都会被耽误,你用循环挨个处理socket,根本不具有并发能力。

      2. 第二种选择(来一个new一个,1对1服务):你创建30个分身线程,每个分身线程检查一个学生的答案是否正确。这种类似于为每一个用户创建一个进程或者线程处理连接。

      3. 第三种选择(响应式处理,1对多服务),你站在讲台上等,谁解答完谁举手。这时C、D举手,表示他们解答问题完毕,你下去依次检查C、D的答案,然后继续回到讲台上等。此时E、A又举手,然后去处理E和A。。。这种就是IO复用模型。Linux下的select、poll和epoll就是干这个的。

    • lO多路复用模型

      将用户socket对应的文件描述符(FileDescriptor)注册进epoll,然后epoll帮你监听哪些socket上有消息到达,这样就避免了大量的无用操作。此时的socket应该采用非阻塞模式。这样,整个过程只在调用select、poll、epol这些调用的时候才会阻塞,收发客户消息是不会阻塞的,整个进程或者线程就被充分利用起来,这就是事件驱动,所谓的reactor反应模式。

    image-20230826165845504

    在单个线程通过记录跟踪每一个Sockek(I/O流)的状态来同时管理多个I/O流.一个服务端进程可以同时处理多个套接字描述符。目的是尽量多的提高服务器的吞吐能力。

    客户端请求服务端时,实际就是在服务端的Socket文件中写入客户端对应的文件描述符(FileDescriptor), 如果有多个客户端同时请求服务端,为每次请求分配一个线程,类似每次来都new一个 如此就会比较耗费服务端资源.....因此,我们只使用一个线程来监听多个文件描述符,这就是IO多路复用 采用多路I/O复用技术可以让单个线程高效的处理多个连接请求一个服务端进程可以同时处理多个套接字描述符。

    小结:只使用一个服务端进程可以同时处理多个套接字描述符连接

    所以,redis为什么那么快的答案就是,IO多路复用+epoll函数使用,才是redis为什么这么快的直接原因,而不是仅仅单线程命令+redis安装在内存中。

  • signal driven lO–信号驱动IO

  • asynchronous lO-异步lO

6、简单说明

Redis工作线程是单线程的,但是,整个Redis来说,是多线程的;

I/O的读和写本身是堵塞的,比如当socket中有数据时,Redis 会通过调用先将数据从内核态空间拷贝到用户态空间,再交给Redis调用,而这个拷贝的过程就是阻寨的,当数据量越大时拷贝所需要的时间就越多,而这些操作都是基于单线程完成的。

image-20230826171515765

从Redis6开始,就新增了多线程的功能来提高I/O的读写性能,他的主要实现思路是将主线程的IO读写任务拆分给一组独立的线程去执行,这样就可以使多个socket的读写可以并行化了,采用多路I/0复用技术可以让单个线程高效的处理多个连接请求(尽量减少网络IO的时间消耗),将最耗时的Socket的读取、请求解析、写入单独外包出去,剩下的命令执行仍然由主线程串行执行并和内存的数据交互。

image-20230826171637881

结合上图可知,网络IO操作就变成多线程化了,其他核心部分仍然是线程安全的,是个不错的折中办法。

Redis6-7将网络数据读写、请求协议解析通过多个IO线程的来处理﹐对于真正的命令执行来说,仍然使用主线程操作。

image-20230826171948777

7、Redis7黑犬认是否开启了多线程?

如果你在实际应用中,发现Redis实例的CPU开销不大但吞吐量却没有提升,可以考虑使用Redis7的多线程机制,加速网络处理,进而提升实例的吞吐量。

在Redis6.0及7后,多线程机制默认是关闭的,如果需要使用多线程功能,需要在redisL.conf中完成两个设置

image-20230826172649649

1.设置io-thread-do-reads配置项为yes,表示启动多线程。 2。设置线程个数。关于线程数的设置,官方的建议是如果为4核的 CPU,建议线程数设置为2或3,如果为8核CPU建议线程数设置为6,线程数一定要小于机器核数,线程数并不是越大越好。

BigKey

1、面试题

  • 阿里广告平台,海量数据里查询某一固定前缀的key

  • 小红书,你如何生产上限制keys */flushdb/flushall等危险命令以防止误删误用?

  • 美团,MEMORY USAGE命令你用过吗?

  • BigKey问题,多大算big?你如何发现?如何删除?如何处理?

  • BigKey你做过调优吗?惰性释放lazyfree了解过吗?

  • Morekey问题,生产上redis数据库有1000W记录,你如何遍历? key *可以吗

2、MoreKey案例

  • 大批量往redis里面插入200OW测试数据key

    Linux Bash下面执行,插入100w

    for((i=1;i<=100*10000;i++)); do echo "set k$i v$i" >> /tmp/redisTest.txt; done;

    image-20230827083759327

    使用more查看这100万条记录

    image-20230827083927265

    image-20230827083957342

    通过redis提供的管道--pipe命令插入100W大批量数据,这里大概3秒。

    cat /tmp/redisTest.txt | redis-cli -h 127.0.0.1 ―p 6379 -a 123456 --pipe

    image-20230827084523668

    这个时候keys *就不能乱用了,直接卡死。

    .......自己玩嘛,试试

    image-20230827085401976

    我虚拟机配的是2G4核的,还是挺快的😂,真实生产一定不要这样。

  • 某快递巨头真实生产案例新闻

    image-20230827084859617

    数据量大会导致Redis锁住及CPU飙升,在生产环境中应该禁用或者重命名

    这个指令没有offset、limit 参数,是要一次性吐出所有满足条件的key,由于 redis是单线程的,其所有操作都是原子的,而keys算法是遍历算法,复杂度是O(n),如果实例中有千万级以上的 key,这个指令就会导致Redis服务卡顿,所有读写Redis 的其它的指令都会被延后甚至会超时报错,可能会引起缓存雪崩甚至数据库宕机。

    生产上限制keys */flushdb/flushall等危险命令以防止误删误用?

    • 通过配置设置禁用这些命令,redis.conf在SECURITY这一项中

      image-20230827094908283

      image-20230827095106155

  • 不用keys *避免卡顿,那该用什么

    scan命令,类似于mysql 的 limit但不完全相同

    https://redis.io/commands/scan/英文

    https://redis.com.cn/commands/scan.html中文

    Scan命令用于迭代数据库中的数据库键

    • SCAN迭代当前所选 Redis 数据库中的键集。

    • SSCAN 迭代集合类型的元素。

    • HSCAN 迭代哈希类型的字段及其关联值。

    • ZSCAN 迭代排序集类型的元素及其关联的分数。

    语法

    SCAN cursor MATCH pattern 默认返回10条记录

    基于游标的迭代器,需要基于上一次的游标延续之前的迭代过程以O作为游标开始一次新的迭代,直到命令返回游标0完成一次遍历不保证每次执行都返回某个给定数量的元素,支持模糊查询一次返回的数量不可控,只能是大概率符合count参数

SCAN命令是一个基于游标的迭代器,每次被调用之后,都会向用户返回一个新的游标,用户在下次迭代时需要使用这个新游标作为 SCAN命令的游标参数,以此来延续之前的迭代过程。

SCAN返回一个包含两个元素的数组, 第一个元素是用于进行下一次迭代的新游标, 第二个元素则是一个数组,这个数组中包含了所有被迭代的元素。如果新游标返回零表示迭代已结束。

SCAN的遍历顺序 非常特别,它不是从第一维数组的第零位一直遍历到末尾,而是采用了高位进位加法来遍历。因为要考虑到哈希槽的扩容和缩容问题之所以使用这样特殊的方式进行遍历,是考虑到字典的扩容和缩容时避免槽位的遍历重复和遗漏。

image-20230827101313503

3、BigKey案例

3.1、多大算大

大的并不是key 而是key对应的value会很大

  • 参考《阿里云Redis开发规范》

    【强制】:拒绝bigkey(防止网卡流量、慢查询)

    string类型控制在10KB以内,hash、list、set、zset元素个数不要超过5000.

    非字符串的bigkey,不要使用del删除,使用hscan、sscan、zscan方式渐进式删除,同时要注意防止bigkey过期时间自动删除问题(例如一个200万的zset设置1小时过期,会触发del操作,造成阻塞,而且该操作不会出现在慢查询中(latency可查)),

  • string和二级结构

    • string是value,最大512MB但是≥10KB就是bigkey

    • list、hash、set和zset,个数超过5000就是bigkey

      • list:一个列表最多可以包含232-1个元素(4294967295,每个列表超过40亿个元素)。

      • hash:Redis中每个hash可以存储232-1键值对(40多亿)

      • set:集合中最大的成员数为232-1 (4294967295,每个集合可存储40多亿个成员)。

3.2、危害

  • 内存不均,集群迁移困难

  • 超时删除,大key删除作梗

  • 网络流量阻塞

3.3、怎么产生的

  • 社交类:某个明星的粉丝列表,典型的粉丝数暴增

  • 汇总类:某报表,月日年经年累月的积累

3.4、怎么发现

  • redis-cli --bigkeys

    特点:给出每种数据结构Top 1 bigkey,同时给出每种数据类型的键值个数+平均大小;

    不足:想查询大于10kb的所有key,--bigkeys参数就无能为力了,需要用到memory usage来计算每个键值的字节数;

    redis-cli -h 127.0.0.1 -p 6379 -a 123456 --bigkeys

    image-20230827103506573

  • MEMORY USAGE键

    MEMORY USAGE命令给出一个key和它的值在RAM中所占用的字节数。返回的结果是key的值以及为管理该key分配的内存总字节数。 ​ 对于嵌套数据类型,可以使用选项SAMPLES,其中count表示抽样的元素个数,默认值为5。当需要抽样所有元素时,使用SAMPLES 0。

    计算每个字节的键值数

    image-20230827103812570

3.5、怎么删除

非字符串的bigkey,不要使用del删除,使用hscan、sscan、zscan方式渐进式删除,同时要注意防止bigkey过期时间自动删除问题(例如一个200万的zset设置1小时过期,会触发del操作,造成阻塞,而且该操作不会出现在慢查询中(latency可查)),

image-20230827105921024

  • String:一般用del,如果过于庞大使用unlik

  • hash

    使用hscan每次获取少量field-value,再使用hdel删除每个field

    HSCAN key cursor [MATH pattern] [COUNT count]

    java代码操作

    image-20230827110413964

  • list

    使用ltrim渐进式逐步删除,直到全部删除完成

    Redis Ltrim对一个列表进行修剪(trim),就是说,让列表只保留指定区间内的元素,不在指定区间之内的元素都将被删除。 下标0表示列表的第一个元素,以1表示列表的第二个元素,以此类推。你也可以使用负数下标,以-1表示列表的最后一个元素,-2表示列表的倒数第二个元素,以此类推。

    语法

    LTRIM KEY_NAME START STOP

    image-20230827111144726

    java代码操作

    image-20230827111231598

  • set

    使用sscan每次获取部分元素,再使用srem命令删除每个元素

    image-20230827111431241

    image-20230827111525039

  • zset

    使用zscan每次获取部分元素,再使用ZREMRANGEBYRANK命令删除每个元素。

    image-20230827112436580

    image-20230827112315020

4、BigKey生产调优

配置文件中有LAZY FREEING选项,非阻塞性删除

image-20230827112853643

Redis有两个原语来删除键。一种称为DEL,是对象的阻塞删除。这意味着服务器停止处理新命令,以便以同步方式回收与对象关联的所有内存。如果删除的键与一个小对象相关联,则执行DEL命令所需的时间非常短,可与大多数其他命令相媲美 ​ Redis 中的O(1)或 O(log_N)命令。但是,如果键与包含数百万个元素的聚合值相关联,则服务器可能会阻塞很长时间(甚至几秒钟)才能完成操作。 ​ 基于上述原因,Redis还提供了非阻塞删除原语,例如UNLINK(非阻塞DEL)以及FLUSHALL和FLUSHDB命令的ASYNC选项,以便在后台回收内存。这些命令在恒定时间内执行。另一个线程将尽可能快地逐步释放后台中的对象。 ​ FLUSHALL和FLUSHDB的DEL、UNLINK和ASYNC选项是用户控制的。这取决于应用程序的设计,以了解何时使用其中一个是个好主意。然而,作为其他操作的副作用,Redis服务器有时不得不删除键或刷新整个数据库。具体而言,Redis在以下场景中独立于用户调用删除对象:

image-20230827113218774

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

1、面试题

  • 你只要用缓存,就可能会涉及到redis缓存与数据库双存储双写,你只要是双写,就一定会有数据一致性的问题,那么你如何解决一致性问题?

  • 双写一致性,你先动缓存redis还是数据库mysql哪一个? 为什么?

  • 延时双删你做过吗?会有哪些问题?

  • 现在有这样一种情况,微服务查询redis没有数据,但是mysql有数据,为了保证数据双写一致性回写redis你需要注意什么?双检加锁策略你了解过吗?如何避免缓存击穿?

  • redis和mysql双写一定会出纰漏,做不到强一致性,你如何保证最终一致性?

image-20230916103502453

问题:上面业务逻辑你用java怎么写?

2、双写一致性的理解

  • 如果redis中有想要得数据就直接返回这个数据了;

  • 如果redis没有就去调MySQL拿数据

    • 如果说,MySQL中有数据,还需要将数据写回到redis中,保证下一次的缓存命中率;

    • 如果MySQL中都没有,那就不存在这个数据;

2.1、双检加锁策略

多个线程同时去查询数据库的这条数据,那么我们可以在第一个查询数据的请求上使用一个互斥锁来锁住它。共他的线程走到这一步拿不到锁就等着,等第一个线程查询到了数据,然后做缓存。后面的线程进来发现已经有缓存了,就直接走缓存。

一般的中小厂数据量不大可以这样使用

 //中小厂使用这种就行
    public User getUserByID(int id){
        User user;
        String key = CACHE_KEY_USER+id;
        //1、请求先从redis里面查询,如果有数据就直接返回结果,如果没有就去查MySQL
         user = (User) redisTemplate.opsForValue().get(key);
        if (user == null){
            //2、redis里没有数据,去查询MySQL
            user = userMapper.queryByPrimarykey(id);
            if (user == null){
                //3、redis和MySQL都是空的那就是没有,直接返回user
                return user;
            }else {
                //4、如果MySQL中有数据,需要将数据回写到redis,保证下一次的缓存命中率
                redisTemplate.opsForValue().set(key,user);
            }
        }
​
        return user;
    }

大厂有大量的数据请求,得使用双检加锁策略

//数据量大的情况下,加强补充,避免突然key失效了,打爆MySQL,做一下预防,尽量不出现缓存击穿的情况
    public User getUserByID2(int id){
        User user;
        String key = CACHE_KEY_USER+id;
        //1、请求先从redis里面查询,如果有数据就直接返回结果,如果没有就去查MySQL
        user = (User) redisTemplate.opsForValue().get(key);
        if (user == null){
            //2、大厂用,对于高QPS的优化,进来就先加锁,保证不会被其他线程抢写,让其他人先等一下,避免击穿MySQL
            synchronized (User.class){
               user = (User)redisTemplate.opsForValue().get(key);
                //3、再次查询redis还是null的话就去查询MySQL
                if (user == null){
                    // 4 查询MySQL拿到数据,默认MySQL里有数据
                    user = userMapper.queryByPrimarykey(id);
                    if (user == null){//如果MySQL还没有就没有了
                        return null;
                    }else {
                        // 5、MySQL里有数据就回写进redis,完成数据一致性同步工作,setIfAbsent如果没有就写入,加个过期时间,7天以后自动过期
                        redisTemplate.opsForValue().setIfAbsent(key,user,7L, TimeUnit.DAYS);
                    }
                }
            }
        }
        return user;
    }

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

目的就是达到最终一致性

  • 给缓存设置过期时间,定期清理缓存并回写,是保证最终一致性的解决方案。

  • 我们可以对存入缓存的数据设置过期时间,所有的写操作以数据库为准,对缓存操作只是尽最大努力即可。也就是说如果数据库写成功,缓存更新失败,那么只要到达过期时间,则后面的读请求自然会从数据库中读取新值然后回填缓存,达到一致性,切记,要以mygql的数据库写入库为准。

假如说可以停机更新的情况

  • 挂牌报错,凌晨升级,温馨提示,服务降级

  • 单线程,这样重量级的数据操作最好不要多线程

一般不会停机的,下面讨论4种更新策略

3.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
这样没有问题,遵循先来后到了,可是高并发情况下不会这么老实
​
异常情况
1 A update mysql 100
2 B update mysql 80
3 B update redis 80
4 A update redis 100
​
这样最终结果就不对了,MySQL中是80,redis却是100,数据不一致

3.2、先更新缓存,再更新数据库(不建议这样)

业务上一般把mysql作为底单数据库,保证最后解释

会出现的异常:

【正常逻辑】
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
最后,mysql100,redis80 数据又不一致了

#####

3.3、先删除缓存,再更新数据库

假设A B两个线程,A去删除redis里面的数据,B是查询来的

步骤:

  1. A线程去redis成功删除了数据,然后去更新MySQL,此时假设MySQL正在更新中,还没有完成,这个时候B线程突然来读取缓存数据。

  2. B线程到redis一看没有数据(已经被A删除了)就会去MySQL里找,但是这个时候的MySQL的数据还没更新完,B读的还是旧的数据,B就会把这个旧的数据读取出来,回写到redis里,那么redis里的数据就是旧的脏数据了,A线程删除的数据极大的可能又被回写了。

  3. 两个并发操作,一个是更新操作,另一个是查询操作,A删除缓存后,B查询操作没有命中缓存,B先把老数据读出来后放到缓存中,然后A更新操作更新了数据库。于是,在缓存中的数据还是老的数据,导致缓存中的数据是脏的,而且还一直这样脏下去了。这时,redis中的数据是旧的,而数据库中的是新的,那么数据就不一致了。

问题解决方法:

延时双删策略

A线程加上sleep的这段时间,就是为了让线程B能够先从数据库读取数据,再把缺失的数据写入缓存,然后,线程A再进行删除。所以,线程A sleep的时间,就需要大于线程B读取数据再写入缓存的时间(就让B走完这一趟流程的时间,完成更新数据库)。 ​ 这样一来,其它线程读取数据时,会发现缓存缺失,所以会从数据库中读取最新值。因为这个方案会在第一次删除缓存值后,延迟一段时间再次进行删除,所以我们也把它叫做“延迟双删”。

延时双删面试题

  • 这个删除该休眠多久呢?

    线程A sleep的时间,就需要大于线程B读取数据再写入缓存的时间。

    怎么确定这个时间?

    1. 在业务程序运行的时候,统调下线程读数据和写缓存的操作时间,自行评估自己的项目的读数据业务逻辑的耗时,以此为基础来进行估算。然后写数据的休眠时间则在读数据业务逻辑的耗时基础上加百毫秒即可。

      这么做的目的,就是确保读请求结束,写请求可以删除读请求造成的缓存脏数据。

    2. 新启动一个后台监控程序,比如WatchDog监控程序,

  • 这种同步淘汰策略,吞吐量降低怎么办?

    image-20230917171945732

3.4、先更新数据库,再删除缓存(推荐)

会引出的问题:

image-20230917172346456

如果删除缓存失败的解决方法

image-20230917172934274

流程:

  1. 更新数据库数据;

  2. 数据库会将操作信息写入binlog日志当中;

  3. 订阅程序提取出所需要的数据以及key;

  4. 另起一段非业务代码,获得该信息;

  5. 尝试删除缓存操作,发现删除失败;

  6. 将这些信息发送至消息队列;

  7. 重新从消息队列中获得该数据,重试操作。

  1. 可以把要删除的缓存值或者是要更新的数据库值暂存到消息队列中(例如使用Kafka/RabbitMQ等)。

  2. 当程序没有能够成功地删除缓存值或者是更新数据库值时,可以从消息队列中重新读取这些值,然后再次进行删除或更新。

  3. 如果能够成功地删除或更新,我们就要把这些值从消息队列中去除,以免重复操作,此时,我们也可以保证数据库和缓存的数据一致了,否则还需要再次进行重试

  4. 如果重试超过的一定次数后还是没有成功,我们就需要向业各层发送报错信息了,通知运维人员。

总结

image-20230917173924645