目录
- Redis简介
- Redis为什么使用单线程
- Redis基于单线程,为什么还这么快?
- 为什么要用Redis/为什么要用缓存?
- 为什么要用Redis而不用map/guava做缓存?
- Redis和Map的详细区别
- Redis读写逻辑
- Redis的线程模型
- Redis数据结构
- Redis与Memcached的异同
- Redis 是如何判断数据是否过期?
- Redis 设置过期时间、定期删除、惰性删除
- Redis数据淘汰机制(MySQL里有2000w数据,Redis中只存20w的数据,如何保证Redis中的数据都是热点数据)
- Redis持久化机制(怎么保证 Redis挂掉重启后数据可以进行恢复)
- Redis事务
- 缓存雪崩、缓存穿透、缓存击穿
- 如何解决 Redis 的并发竞争 Key 问题
- 如何保证缓存与数据库双写时的数据一致性?
- Redis的高可用
Redis简介
- Redis是一个速度非常快的非关系型/NoSQL数据库,它不使用表,也不会预定义或强制要求用户对Redis存储的不同数据进行关联;
- Redis为什么速度非常快(读写性能可以达到10万/秒):1 纯内存操作,数据存储在内存中;2 数据结构简单,对数据操作也简单,底层又做了优化;3 单线程:不存在多进程或者多线程导致的切换而消耗CPU资源,也不用去考虑各种锁的问题;
Redis为什么使用单线程
- 易于实现,开发和维护更简单;
- 内部使用了基于epoll的多路复用,使用单线程也能够并发地处理多客户端的请求;
- 基于内存,查找和操作时间复杂度都是O(1),CPU不是其性能瓶颈,瓶颈很可能是机器内存与网络带宽的大小;
- 不是全面单线程,持久化、异步删除、集群数据同步等是由额外线程来执行,防止同步代码占用主线程,导致阻塞;
Redis基于单线程,为什么还这么快?
- 基于内存操作,数据存储在内存中,其所有的运算都是内存级的;
- 数据结构简单,专门为Redis设计的数据结构的查找和操作的时间复杂度都是O(1);
- 单线程模型,避免了多进程、多线程切换消耗CPU资源,也不用考虑各种锁问题;
- 使用IO多路复用功能来监听多个socket连接,这样就可以使用一个线程来处理多个情况,从而减少线程切换带来的开销,同时也避免了IO阻塞操作,极大提高了Redis性能;
为什么要用Redis/为什么要用缓存?
- 主要从”高性能“和”高并发“这两点来看;
- 高性能:如果用户是第一次访问数据库中的某些数据,过程将比较慢,因为是从硬盘上读取的;然后将该用户访问的数据存在缓存中,这样下一次再访问这些数据的时候就可以直接从缓存中获取了;操作缓存就是直接操作内存,所以速度非常快;如果数据库中的对应数据改变,同步改变缓存中相应的数据即可;
- 高并发:直接操作缓存能够承受的请求数远远大于直接访问数据库;因此考虑把数据库中的部分数据复制到缓存中去,这样用户的一部分请求会直接到缓存而不用经过数据库;
为什么要用Redis而不用map/guava做缓存?
- 缓存分本地缓存和分布式缓存。以Java为例,使用自带的Map或guava实现的是本地缓存,生命周期随JVM的销毁而结束,在多实例的情况下,每个实例都需要各自保存一份缓存,缓存不具有一致性;
- Redis称为分布式缓存,在多实例情况下,各实例共用一份缓存数据,缓存具有一致性。缺点是需要保持Redis服务的高可用,整个程序架构比较复杂;
Redis和Map的详细区别
- Redis可以实现分布式的缓存,Map只能存在创建它的程序里;
- Redis的缓存可以持久化,Map是内存对象,程序一重启数据就没了;
- Redis有缓存过期机制,Map无此功能;
- Redis可以用几十G内存来做缓存,Map不行,一般JVM分几个G就很大了;
Redis读写逻辑
读逻辑
写逻辑
高并发一次请求的流程
- 当一个请求到达服务器时,只是把业务数据在Redis上进行读写,而没有对数据库进行任何的操作,这样就能大大提高读写的速度,从而满足高速响应的需求;
- 但是这些缓存的数据仍然需要持久化,也就是存入数据库之中,所以在一个请求操作完Redis的读/写之后,会去判断该高速读/写的业务是否结束,这个判断通常会在秒杀商品为0,红包金额为0时成立,如果不成立,则不会操作数据库;如果成立,则触发事件将Redis 的缓存的数据以批量的形式一次性写入数据库,从而完成持久化的工作。
Redis的线程模型
- Redis基于Reactor模式开发了自己的网络事件处理器:file event handler,它是单线程的;
- file event handler的结构包含4个部分:多个socket、IO多路复用程序、事件分派器、事件处理器(连接应答处理器、命令请求处理器、命令回复处理器);
- 1 I/O多路复用程序会同时监听多个socket,并将socket放入一个队列中排队;2 事件分派器从队列中取出socket01交给连接应答处理器;3 连接应答处理器会将socket01的AE_READABLE事件与命令请求处理器相关联;4 假设客户端执行set操作,这时命令请求处理器会从socket01读取key value,在内存中完成key value的设置;5 在内存中完成设置后,会将socket01的AE_WRITEABLE事件与命令回复处理器相关联;6 命令回复处理器向socket01写入本次操作的结果,然后解除关联;
Redis数据结构
- Redis存储键(key)和5种不同类型的值(value)之间的映射;
- 这5种类型分别是:string(字符串)、hash(哈希表)、list(列表)、set(集合)、zset(有序集合);
string
作为常规的key-value缓存应用,微博数、粉丝数等
hash
是一个string类型的field和value的映射表,适合用于存储对象
list
关注列表、粉丝列表等。
set
在微博中,可以将一个用户所有的关注人存在一个集合中,将其所有粉丝存在一个集合。Redis还为集合提供了求交集、并集、差集等操作,可以非常方便的实现如共同关注、共同喜好、二度好友等功能。
zset
内部使用HashMap和跳跃表(SkipList)来保证数据的存储和有序,HashMap里放的是成员到score的映射,跳跃表按score从小到大保存所有集合元素。
应用场景:排行榜
Redis与Memcached的异同
两者都是非关系型内存键值数据库,主要有以下不同:
- 数据类型:Redis支持5种不同的数据类型,可以更灵活地解决问题;Memcached仅支持字符串类型;
- 数据持久化:Redis支持两种持久化策略:RDB(快照)和AOF(只追加文件);Memcached不支持持久化;
- 分布式:Redis支持分布式;Memcached不支持分布式;
- 内存管理机制:在Redis中,并不是所有数据都一直存储在内存中,可以将一些很久不用的数据交换到磁盘,而Memcached的数据会一直在内存中;
- 线程模型:Redis使用的是单线程的多路IO复用模型;Memcached使用的是多线程非阻塞IO复用模型;
Redis 是如何判断数据是否过期?
Redis 通过一个叫做过期字典redisDb来保存数据过期的时间。过期字典的键指向 Redis 数据库中的某个 key(键),过期字典的值是一个 long long 类型的整数,这个整数保存了 key 所指向的数据库键的过期时间(毫秒精度的 UNIX 时间戳)。
查看剩余存活时间
TTL key #计算key的剩余生存时间,s
PTTL key #计算key的剩余生存时间,ms
返回值:
- 当 key 不存在时,返回 -2 。
- 当 key 存在但没有设置剩余生存时间时,返回 -1 。
- 否则,以秒为单位,返回 key 的剩余生存时间。
Redis 设置过期时间、定期删除、惰性删除
- Redis对存储在 Redis数据库中的数据设置有⼀个过期时间;
- 假设设置了⼀批 key 只能存活1小时,那么1小时后,Redis将对这批key执行定期删除+惰性删除操作;
- 定期删除:Redis默认是每隔 100ms 就随机抽取⼀些设置了过期时间的key,检查其是否过期,如果过期就删除;
- 惰性删除 :数据到达过期时间,不做处理。等下次访问该数据时,如果未过期,返回数据 ;如果发现已过期,删除,返回不存在;
- 仅仅通过设置过期时间还是有问题的。设想:如果定期删除漏掉了很多过期 key,然后也没及时去查,即没走惰性删除,将造成大量过期key堆积在内存里,导致Redis内存块耗尽。这就需要Redis数据淘汰机制来解决这个问题了;
Redis数据淘汰机制(MySQL里有2000w数据,Redis中只存20w的数据,如何保证Redis中的数据都是热点数据)
Redis提供了8种数据淘汰策略:
- volatile-lru:从已设置过期时间的数据集中挑选最近最少使用的数据淘汰;
- volatile-lfu:从已设置过期时间的数据集中挑选最不经常使用的数据淘汰;
- volatile-ttl:从已设置过期时间的数据集中挑选将要过期的数据淘汰;
- volatile-random:从已设置过期时间的数据集中选择任意数据淘汰;
- allkeys-lru:当内存不足以容纳新写入数据时,移除最近最少使用的数据(最常用的淘汰策略);
- allkeys-lfu:当内存不足以容纳新写入数据时,移除最不经常使用的数据;
- allkeys-random:当内存不足以容纳新写入数据时,从数据集中随机选择数据淘汰;
- no-eviction:禁止驱逐数据,即当内存不足以容纳新写入数据时,新写入操作会报错;
Redis持久化机制(怎么保证 Redis挂掉重启后数据可以进行恢复)
为了系统故障或重启机器后恢复数据,我们需要持久化数据即将内存中的数据写入到硬盘里。Redis有两种持久化方式: RDB快照和AOF日志。
RDB持久化是通过定期生成数据快照来实现的。在指定的时间间隔内,Redis会将内存中的数据集快照保存到磁盘上,形成一个RDB文件,也可以通过执行BGSAVE命令来手动触发。优点是快速恢复,因为只需要将单个文件加载到内存中即可恢复数据;以及更小的存储空间,因为RDB文件是压缩的二进制文件。缺点是可能会丢失最后一次快照之后的所有数据;
AOF持久化是通过记录所有写操作到一个日志文件中来实现的。每当执行一个写命令时,这个命令都会被追加到AOF文件的末尾。由于AOF文件记录了所有的写操作,因此可以通过重放这些操作来恢复数据。优点包括更好的数据安全性,因为每个写操作都被记录了。缺点是如果AOF文件很大,那么恢复数据可能需要较长的时间;此外,AOF文件的大小会随着时间的推移而增大。
综上所述,RDB提供了定时备份的快照,适用于需要快速恢复的场景;而AOF提供了连续的写操作日志,适用于需要高数据一致性的场景。
- RDB在redis.conf配置文件中默认有如下配置:
save 900 1 #在900秒(15分钟)之后,如果⾄少有1个key发⽣变化,Redis就会⾃动触发BGSAVE命令创建快照。
save 300 10 #在300秒(5分钟)之后,如果⾄少有10个key发⽣变化,Redis就会⾃动触发BGSAVE命令创建快照。
save 60 10000 #在60秒(1分钟)之后,如果⾄少有10000个key发⽣变化,Redis就会⾃动触发BGSAVE命令创建快照。
- AOF在Redis的配置文件中存在三种不同的 AOF 持久化方式,它们分别是:
appendfsync always #每次有数据修改发生时都会写入AOF文件,这样会严重降低Redis的速度
appendfsync everysec #每秒钟同步⼀次,显式地将多个写命令同步到硬盘
appendfsync no #让操作系统决定何时进行同步
为了兼顾数据和写入性能,用户可以考虑 appendfsync everysec选项 ,让Redis每秒同步⼀次AOF文件,Redis性能几乎没受到任何影响。而且这样即使出现系统崩溃,用户最多只会丢失一秒之内产生的数据。当硬盘忙于执行写入操作的时候,Redis还会优雅的放慢自己的速度以便适应硬盘的最大写入速度。
Redis事务
Redis 事务可以一次执行多个命令, 并且带有以下三个重要的保证:
- 批量操作在发送 EXEC 命令前被放入队列缓存;
- 收到 EXEC 命令后进入事务执行,事务中任意命令执行失败,其余的命令依然被执行;
- 在事务执行过程中,其他客户端提交的命令请求不会插入到事务执行命令序列中;
一个事务从开始到执行会经历以下三个阶段:
- 开始事务;
- 命令入队;
- 执行事务;
单个 Redis 命令的执行是原子性的,但 Redis 没有在事务上增加任何维持原子性的机制,所以 Redis 事务的执行并不是原子性的。
事务可以理解为一个打包的批量执行脚本,但批量指令并非原子化的操作,中间某条指令的失败不会导致前面已做指令的回滚,也不会造成后续的指令不做。
缓存雪崩、缓存穿透、缓存击穿
缓存雪崩
- 概念:缓存同⼀时间大面积失效,导致后面的请求都落到数据库上,造成数据库短时间内承受大量请求而崩掉。或者缓存服务器宕机了,造成了整个缓存的不可用。
- 有哪些解决办法?
- 事前:1 尽量保证整个 Redis集群的高可用性(哨兵模式、集群模式);2 过期时间加随机数,尽量避免缓存同时失效的情况发生;
- 事中:限流&降级,避免数据库崩掉(假设限制每秒就2000个请求,一秒过来5000个请求,此时只有2000个请求会通过限流组件,进入数据库,剩余的3000个请求走降级,调用自己开发好的一个降级的组件,返回一些默认的值,比如友情提示,或空值);
- 事后:利用 Redis持久化机制尽快恢复缓存集群,一旦重启,自动从磁盘上加载数据恢复内存中的数据;
缓存穿透
- 概念:大量请求的 key 不存在于缓存中,导致请求直接落到了数据库上,根本没有经过缓存这一层。举例:某黑客故意制造缓存中不存在的 key 发起大量请求,导致大量请求落到数据库,⼀般 3000 个并发请求就能打死大部分数据库了。
- 有哪些解决办法?
- 做好参数校验:⼀些不合法的参数请求直接抛出异常信息返回给客户端,比如查询的数据库 id 不能小于0等;
- 缓存无效 key : 如果缓存和数据库都查不到某个 key 的数据就写这个key到 Redis中去并设置过期时间。这种方式可以解决请求的 key 变化不频繁的情况,如果黑客恶意攻击,每次构建不同的请求key,会导致 Redis中缓存大量无效的 key,因此这种方案并不能从根本上解决此问题;
- 布隆过滤器:把所有可能存在的请求的值都存放在布隆过滤器中,当用户请求过来,先判断用户发来的请求的值是否存在于布隆过滤器中。不存在的话,直接返回请求参数错误信息给客户端,存在的话才会走下面的流程;
缓存击穿
缓存雪崩是大批热点数据突然失效,而缓存击穿是指某一个热点数据突然失效,导致大量请求直接访问数据库,解决方案是考虑这个热点数据不设过期时间。
如何解决 Redis 的并发竞争 Key 问题
Redis 的并发竞争 Key 的问题也就是多个系统同时对⼀个 key 进行操作,但是最后执行的顺序和我们期望的顺序不同,这样也就导致了结果的不同。
解决方案一:分布式锁+时间戳
(1)如果对这个key操作,不要求顺序
这种情况下,准备一个分布式锁,大家去抢锁,抢到锁就做set操作即可。
(2)如果对这个key操作,要求顺序
假设有一个key1,系统A需要将key1设置为valueA,系统B需要将key1设置为valueB,系统C需要将key1设置为valueC。
期望按照key1的value值按照 valueA–>valueB–>valueC的顺序变化。这种情况下我们在操作数据时需要保存一个时间戳。假设时间戳如下
系统A:key1 {valueA 3:00}
系统B:key1 {valueB 3:05}
系统C:key1 {valueC 3:10}
那么,假设系统B先抢到锁,将key1设置为{valueB 3:05}。接下来系统A抢到锁,发现自己的valueA的时间戳早于缓存中的时间戳,那就不做set操作了。以此类推。
解决方案二:消息队列
在并发量过大的情况下,可以通过消息中间件进行处理,并发读写串行化:把set操作放在队列中使其串行化,必须一个一个执行。
如何保证缓存与数据库双写时的数据一致性?
写请求来了,要更新数据库和缓存,一前一后更新,就可能导致缓存和数据库中的数据在一段时间内不一致。
一般来说,如果允许缓存可以偶尔跟数据库有不一致的情况,也就是说系统不是严格要求 “缓存+数据库” 必须保持一致性的话,最好不要做读请求和写请求串行化的方案: 即读请求和写请求串到一个内存队列里去。串行化之后,会导致系统的吞吐量大幅度降低。
不管是先删除缓存再写数据库,还是先写数据库再删除缓存,都有可能出现数据不一致的情况:
- 先删再写单库情况:1 A删除缓存,还没有来得及写库;2 B来读取,发现缓存为空,则去数据库中读取数据写入缓存,此时缓存中为脏数据;
- 先删再写主从同步读写分离的情况:1 A删除缓存;2 A写主数据库;3 B来读取,发现缓存为空,就去从数据库中读取数据写入缓存,由于主从同步还没同步成功,因此为脏数据;
- 先写再删:1 A读数据,发现没有缓存,从数据库读然后写进缓存时卡顿了;2 B写数据库,删缓存;3 A恢复,老数据写进缓存;
因为写和读是并发的,没法保证顺序,就会出现缓存和数据库的数据不一致的问题。
延时双删策略
具体步骤如下:
- 先删缓存;
- 再写数据库;
- 休眠1秒;
- 再删缓存。
这个1秒怎么确定的,具体该休眠多久呢?
需要评估项目的读数据业务逻辑的耗时+数据库主从同步的耗时。这么做的目的,就是确保读请求结束,写请求可以删除读请求造成的缓存脏数据。
异步淘汰缓存
新增一个读取binlog异步淘汰缓存模块,读取binlog,然后进行异步淘汰。
- 读请求走Redis;
- 写请求走MySQL;
- 更新Redis数据:使用MySQL的binlog来更新到Redis;
Redis更新
数据操作主要分为两块:
- 一个是全量(将全部数据一次写入到Redis)
- 一个是增量(实时更新)
这里说的是增量,指的是mysql的update、insert、delete变更数据。这样一旦MySQL中产生了新的写入、更新、删除等操作,就可以把binlog相关的消息推送至Redis,Redis再根据binlog中的记录,对Redis进行更新,就无需再从业务线去操作缓存内容。
Redis的高可用
-
在使用最简单的单机版 Redis 时,遇到了 Redis 故障宕机后数据无法恢复的问题,因此引入了「数据持久化」,将内存中的数据保存到磁盘上,以便 Redis 重启后能快速恢复数据。在进行数据持久化时,面临如何更高效地将数据保存到磁盘的问题,Redis 提供了 RDB 和 AOF 两种方案,分别对应数据快照和实时命令记录。当对数据完整性要求不高时,可以选择 RDB 持久化方案;如果对数据完整性要求较高,可以选择 AOF 持久化方案。但是AOF 文件体积会随着时间增长变得越来越大,优化方案是使用 AOF rewrite(由于 AOF 文件中记录了每次写操作,但对于同一个 key 可能会发生多次修改,我们只保留最后一次修改的值) 的方式对其进行瘦身,减小文件体积;
-
虽然可以通过数据恢复的方式还原数据,但恢复数据仍需要花费时间,这意味着业务应用仍会受到影响。进一步优化,采用「多副本」的方案,让多个实例保持实时同步,当一个实例故障时,把其他实例提升上来继续提供服务,因此引入了「哨兵」集群,哨兵集群通过互相协商的方式,发现故障节点,并可以自动完成切换,从而大幅降低对业务应用的影响。稳定性:Redis 故障宕机,通过哨兵 + 副本,可以自动完成主从切换性能:读请求量增长,可以再部署多个 slave,实现读写分离,分担读压力;
-
最后,将关注点放在如何支持更大的写流量上,因此引入了「分片集群」来解决这个问题,让多个 Redis 实例分担写压力。面对更大的流量,我们还可以添加新的实例进行横向扩展,进一步提高集群性能;每个节点各自存储一部分数据,所有节点数据之和才是全量数据。制定一个路由规则,对于不同的 key,把它路由到固定一个实例上进行读写。