48 Redis
前言
Redis(Remote Dictionary Server ),即远程字典服务。是一个开源的使用ANSI C语言编写、支持网络、可基于内存亦可持久化的日志型、Key-Value数据库,并提供多种语言的API。
redis会周期性的把更新的数据写入磁盘或者把修改操作写入追加的记录文件,并且在此基础上实现了master-slave(主从)同步。
免费和开源!是当下最热门的 NoSQL 技术之一!也被人们称之为结构化数据库!
- Redis能干嘛?
1、内存存储、持久化,内存中是断电即失、所以说持久化很重要(rdb、aof) 2、效率高,可以用于高速缓存
3、发布订阅系统
4、地图信息分析
5、计时器、计数器(浏览量!)
6、…
分布式缓存
技术选型方案
- Memcached
- Redis
比较常用的是Redis,分布式缓存主要解决的是单机缓存的容量受服务器限制并且无法保存通用信息的问题,因为本地缓存只在当前服务里有效,比如你部署了两个相同的服务在两个不同的物理机上,那么这两个缓存数据是无法共同的。
它们两个的区别主要就是在Redis支持持久化,可以将内存中的数据保存到磁盘中,重启的时候可以再次加载进行使用,而Memcached并不支持持久化,一旦重启,内存中的数据会丢失。
而且Redis的功能更加丰富,比如Redis就具有更多的数据类型(kv,list,set,hash等)
那么为什么需要再项目中使用缓存呢?我们从高并发和高性能的角度来分析这一个问题,我们都清楚内存中的数据读取和写入是明显快于硬盘的,那么如果对于一个项目来说,它对某个数据具有大量的访问,那么每一次都需要从硬盘也就是数据库中读取,那么其效率会很低,而现在有一个数据库是运行在内存中的,每次读取都直接在内存中操作,其速度肯定是优于外存的,这就是高性能;那对于高并发呢?也哼明显,内存的并发量QPS肯定是高于外存,Redis的QPS可以达到30W+,而MySQL之类的数据库的QPS大概在10W+左右。
Redis用途
- 用于缓存
- 用于分布式锁
- 限流
- 消息队列
- 复杂业务场景
分布式锁
参考资料:如何用Redis实现分布式锁
场景描述
假设我们在一个电商平台上遇到一个“秒杀”活动,用户在同一时间段内抢购限量商品。这时,我们需要保证同一时间只有一个用户能够购买到同一件商品,而不是多个用户同时购买导致超卖。这种场景下,Redis 分布式锁是一个非常有效的解决方案。
实现过程
Redis 提供了一个简单的SETNX(SET if Not Exists)命令,可以用于实现分布式锁。
-
步骤1:用户请求抢购商品时,首先使用
SETNX
(SET if Not Exists)命令来加锁,表示该商品正在被处理。SETNX lock:product:1001 1
-
步骤2:如果
SETNX
返回1
,表示成功获得锁,可以继续处理订单逻辑。 -
步骤3:在处理订单完成后,通过
DEL
命令释放锁。DEL lock:product:1001
-
防止死锁:可以为锁设置一个自动过期时间,防止由于服务异常导致锁未能释放的问题。
SET lock:product:1001 1 EX 10 NX
这样,Redis 的分布式锁确保了高并发场景下的资源独占,避免了超卖或库存不足的问题。
对于使用Redis作为分布式锁的话,可能会出现一些问题,下面就是对这些问题的一些简单的介绍。
在上面所介绍的如果直接加锁的话,那么如果在程序加锁之后,如果程序端出现了异常,导致锁没有来得及释放,那么就会导致死锁。
为了避免死锁,所以为为该锁添加了一个过期时间,那么程序崩溃之后,达到过期时间之后,该锁就会自动释放,这样死锁问题就解决了。
总之这两条命令如果不能保证是原子操作,就有潜在的风险导致过期时间设置失败,依旧有可能发生死锁问题。幸好在Redis 2.6.12之后,Redis扩展了SET命令的参数,可以在SET的同时指定EXPIRE时间,这条操作是原子的,例如以下命令是设置锁的过期时间为10秒。
SET lock_key 1 EX 10 NX
但是这样就会导致出现一些问题
这里存在两个严重的问题:
- 锁过期
- 释放了别人的锁
为了避免释放了别人的锁,解决办法是,设置只有一个自己知道的唯一标识进去,比如自己的线程ID。
如果是redis实现,就是SET key unique_value EX 10 NX。之后在释放锁时,要先判断这把锁是否归自己持有,只有是自己的才能释放它。
//释放锁 比较unique_value是否相等,避免误释放
if redis.get("key") == unique_value thenreturn redis.del("key")
可以看到这里有两个操作,一个是get和del,那么就又会出现原子性的问题。
- 客户端1执行GET,判断锁是自己的
- 客户端2执行了SET命令,强制获取到锁(虽然发生概念很低,但要严谨考虑锁的安全性)
- 客户端1执行DEL,却释放了客户端2的锁
那么如何解决呢?答案就是通过Lua脚本,可以把上面的逻辑写成Lua脚本,让Redis执行,因为Redis处理每个请求是单线程执行的,在执行一个Lua脚本其它必须等待,直到这个Lua脚本处理完成,这样一来get+del之间就只能用由一个进程来执行。
//Lua脚本语言,释放锁 比较unique_value是否相等,避免误释放
if redis.call("get",KEYS[1]) == ARGV[1] thenreturn redis.call("del",KEYS[1])
elsereturn 0
end
但是该如何确定锁的过期时间呢?
前面提到过,过期时间如果评估得不好,这个锁就会有提前过期的风险,一种妥协的解决方案是,尽量冗余过期时间,降低锁提前过期的概率,但这个方案并不能完美解决问题。是否可以设置这样的方案,加锁时,先设置一个预估的过期时间,然后开启一个守护线程,定时去检测这个锁的失效时间,如果锁快要过期了,操作共享资源还未完成,那么就自动对锁进行续期,重新设置过期时间。
这是一种比较好的方案,已经有一个库把这些工作都封装好了,它就是Redisson。Redisson是一个Java语言实现的Redis SDK客户端,在使用分布式锁时,它就采用了自动续期的方案来避免锁过期,这个守护线程我们一般叫它看门狗线程。这个SDK提供的API非常友好,它可以像操作本地锁一样操作分布式锁。客户端一旦加锁成功,就会启动一个watch dog看门狗线程,它是一个后台线程,会每隔一段时间(这段时间的长度与设置的锁的过期时间有关)检查一下,如果检查时客户端还持有锁key(也就是说还在操作共享资源),那么就会延长锁key的生存时间。
Redis常见的部署方式对锁的影响
- 单机模式;
- 主从模式;
- 哨兵(sentinel)模式;
- 集群模式;
我们使用Redis时,一般会采用主从集群+哨兵的模式部署,哨兵的作用就是监测redis节点的运行状态。普通的主从模式,当master崩溃时,需要手动切换让slave成为master,使用主从+哨兵结合的好处在于,当master异常宕机时,哨兵可以实现故障自动切换,把slave提升为新的master,继续提供服务,以此保证可用性。
-
客户端1在master上执行SET命令,加锁成功
-
此时,master异常宕机,SET命令还未同步到slave上(主从复制是异步的)
-
哨兵将slave提升为新的master,但这个锁在新的master上丢失了,导致客户端2来加锁成功了,两个客户端共同操作共享资源
集群模式+Redlock实现高可靠的分布式锁
为了避免Redis实例故障而导致的锁无法工作的问题,Redis的开发者 Antirez提出了分布式锁算法Redlock。Redlock算法的基本思路,是让客户端和多个独立的Redis实例依次请求加锁,如果客户端能够和半数以上的实例成功地完成加锁操作,那么我们就认为,客户端成功地获得分布式锁了,否则加锁失败。这样一来,即使有单个Redis实例发生故障,因为锁变量在其它实例上也有保存,所以,客户端仍然可以正常地进行锁操作,锁变量并不会丢失。
来具体看下Redlock算法的执行步骤。Redlock算法的实现要求Redis采用集群部署模式,无哨兵节点,需要有N个独立的Redis实例(官方推荐至少5个实例)。接下来,我们可以分成3步来完成加锁操作。
-
第一步是,客户端获取当前时间。
-
第二步是,客户端按顺序依次向N个Redis实例执行加锁操作。
这里的加锁操作和在单实例上执行的加锁操作一样,使用SET命令,带上NX、EX/PX选项,以及带上客户端的唯一标识。当然,如果某个Redis实例发生故障了,为了保证在这种情况下,Redlock算法能够继续运行,我们需要给加锁操作设置一个超时时间。如果客户端在和一个Redis实例请求加锁时,一直到超时都没有成功,那么此时,客户端会和下一个Redis实例继续请求加锁。加锁操作的超时时间需要远远地小于锁的有效时间,一般也就是设置为几十毫秒。
第三步是,一旦客户端完成了和所有Redis实例的加锁操作,客户端就要计算整个加锁过程的总耗时。
客户端只有在满足两个条件时,才能认为是加锁成功,条件一是客户端从超过半数(大于等于 N/2+1)的Redis实例上成功获取到了锁;条件二是客户端获取锁的总耗时没有超过锁的有效时间。
为什么大多数实例加锁成功才能算成功呢?多个Redis实例一起来用,其实就组成了一个分布式系统。在分布式系统中总会出现异常节点,所以在谈论分布式系统时,需要考虑异常节点达到多少个,也依旧不影响整个系统的正确运行。这是一个分布式系统的容错问题,这个问题的结论是:如果只存在故障节点,只要大多数节点正常,那么整个系统依旧可以提供正确服务。
在满足了这两个条件后,我们需要重新计算这把锁的有效时间,计算的结果是锁的最初有效时间减去客户端为获取锁的总耗时。如果锁的有效时间已经来不及完成共享数据的操作了,我们可以释放锁,以免出现还没完成共享资源操作,锁就过期了的情况。
当然,如果客户端在和所有实例执行完加锁操作后,没能同时满足这两个条件,那么,客户端就要向所有Redis节点发起释放锁的操作。为什么释放锁,要操作所有的节点呢,不能只操作那些加锁成功的节点吗?因为在某一个Redis节点加锁时,可能因为网络原因导致加锁失败,例如一个客户端在一个Redis实例上加锁成功,但在读取响应结果时由于网络问题导致读取失败,那这把锁其实已经在Redis上加锁成功了。所以释放锁时,不管之前有没有加锁成功,需要释放所有节点上的锁以保证清理节点上的残留的锁。
在Redlock算法中,释放锁的操作和在单实例上释放锁的操作一样,只要执行释放锁的 Lua脚本就可以了。这样一来,只要N个Redis实例中的半数以上实例能正常工作,就能保证分布式锁的正常工作了。所以,在实际的业务应用中,如果你想要提升分布式锁的可靠性,就可以通过Redlock算法来实现。
限流
参考资料:我司用了 6 年的 Redis 分布式限流器,可以说是非常厉害了!
一般是通过Redis和Lua脚本的方式来实现限流。
限流方案
计数器
Java内部也可以通过原子类计数器AtomicInteger
、Semaphore
信号量来做简单的限流。
// 限流的个数private int maxCount = 10;// 指定的时间内private long interval = 60;// 原子类计数器private AtomicInteger atomicInteger = new AtomicInteger(0);// 起始时间private long startTime = System.currentTimeMillis();public boolean limit(int maxCount, int interval) {atomicInteger.addAndGet(1);if (atomicInteger.get() == 1) {startTime = System.currentTimeMillis();atomicInteger.addAndGet(1);return true;}// 超过了间隔时间,直接重新开始计数if (System.currentTimeMillis() - startTime > interval * 1000) {startTime = System.currentTimeMillis();atomicInteger.set(1);return true;}// 还在间隔时间内,check有没有超过限流的个数if (atomicInteger.get() > maxCount) {return false;}return true;}
漏桶算法
漏桶算法思路很简单,我们把水比作是请求
,漏桶比作是系统处理能力极限
,水先进入到漏桶里,漏桶里的水按一定速率流出,当流出的速率小于流入的速率时,由于漏桶容量有限,后续进入的水直接溢出(拒绝请求),以此实现限流。
令牌桶算法
令牌桶算法的原理也比较简单,我们可以理解成医院的挂号看病,只有拿到号以后才可以进行诊病。
系统会维护一个令牌(token
)桶,以一个恒定的速度往桶里放入令牌(token
),这时如果有请求进来想要被处理,则需要先从桶里获取一个令牌(token
),当桶里没有令牌(token
)可取时,则该请求将被拒绝服务。令牌桶算法通过控制桶的容量、发放令牌的速率,来达到对请求的限制。
Redis + Lua
很多同学不知道Lua
是啥?个人理解,Lua
脚本和 MySQL
数据库的存储过程比较相似,他们执行一组命令,所有命令的执行要么全部成功或者失败,以此达到原子性。也可以把Lua
脚本理解为,一段具有业务逻辑的代码块。
而Lua
本身就是一种编程语言,虽然redis
官方没有直接提供限流相应的API
,但却支持了 Lua
脚本的功能,可以使用它实现复杂的令牌桶或漏桶算法,也是分布式系统中实现限流的主要方式之一。
相比Redis
事务,Lua脚本
的优点:
- 减少网络开销:使用
Lua
脚本,无需向Redis
发送多次请求,执行一次即可,减少网络传输 - 原子操作:
Redis
将整个Lua
脚本作为一个命令执行,原子,无需担心并发 - 复用:
Lua
脚本一旦执行,会永久保存Redis
中,,其他客户端可复用
Lua
脚本大致逻辑如下:
-- 获取调用脚本时传入的第一个key值(用作限流的 key)
local key = KEYS[1]
-- 获取调用脚本时传入的第一个参数值(限流大小)
local limit = tonumber(ARGV[1])-- 获取当前流量大小
local curentLimit = tonumber(redis.call('get', key) or "0")-- 是否超出限流
if curentLimit + 1 > limit then-- 返回(拒绝)return 0
else-- 没有超出 value + 1redis.call("INCRBY", key, 1)-- 设置过期时间redis.call("EXPIRE", key, 2)-- 返回(放行)return 1
end
- 通过
KEYS[1]
获取传入的key参数 - 通过
ARGV[1]
获取传入的limit
参数 redis.call
方法,从缓存中get
和key
相关的值,如果为null
那么就返回0- 接着判断缓存中记录的数值是否会大于限制大小,如果超出表示该被限流,返回0
- 如果未超过,那么该key的缓存值+1,并设置过期时间为1秒钟以后,并返回缓存值+1
这种方式是本文推荐的方案,具体实现会在后边做细说。
消息队列
Redis ⾃带的 list 数据结构可以作为⼀个简单的队列使⽤。Redis 5.0 中增加的 Stream 类型的数据结构更加适合⽤来做消息队列。它⽐类似于 Kafka,有主题和消费组的概 念,⽀持消息持久化以及 ACK 机制。
Redis可以做消息队列,Redis 5.0 新增加的⼀个数据结构 Stream 可以⽤来做消息队列, Stream ⽀持:
- 发布 / 订阅模式
- 按照消费者组进⾏消费
- 消息持久化( RDB 和 AOF)
不过,和专业的消息队列相⽐,还是有很多⽋缺的地⽅⽐如消息丢失和堆积问题不好解决。因此,我们通常建议是不使⽤ Redis 来做消息队列的,你完全可以选择市⾯上⽐成熟的⼀些消息队列⽐如 RocketMQ、Kafka。
复杂业务场景
通过 Redis 以及 Redis 扩展(⽐如 Redisson)提供的数据结构,我们可以很⽅ 便地完成很多复杂的业务场景⽐如通过 bitmap 统计活跃⽤户、通过 sorted set 维护排⾏榜。
Redis的五大数据类型
Redis是key-value存储系统,key一般为string类型的字符串,value是redis对象(object)。Redis 共有 5 种基本数据类型:String(字符串)、List(列表)、Set(集合)、Hash(散列)、Zset(有序集合)
String 还是 Hash 存储对象数据更好呢?
-
String 存储的是序列化后的对象数据,存放的是整个对象。Hash 是对对象的每个字段单独存储,可以获取部分字段的信息,也可以修改或者添加部分字段,节省⽹络流量。如果对象中某些 字段需要经常变动或者经常需要单独查询对象中的个别字段信息,Hash 就⾮常适合。
-
String 存储相对来说更加节省内存,缓存相同数量的对象数据,String 消耗的内存约是 Hash 的 ⼀半。并且,存储具有多层嵌套的对象时也⽅便很多。如果系统对性能和资源消耗⾮常敏感的 话,String 就⾮常适合。
在绝⼤部分情况,我们建议使⽤ String 来存储对象数据即可! 那根据你的介绍,购物⻋信息⽤ String 还是 Hash 存储更好呢?
购物⻋信息建议使⽤ Hash 存储: ⽤户 id 为 key 商品 id 为 field,商品数量为 value 由于购物⻋中的商品频繁修改和变动,这个时候 Hash 就⾮常适合了!
Redis线程模型
对于读写命令来说,Redis ⼀直是单线程模型。不过,在 Redis 4.0 版本之后引⼊了多线程来执⾏⼀ 些⼤键值对的异步删除操作, Redis 6.0 版本之后引⼊了多线程来处理⽹络请求(提⾼⽹络 IO 读写 性能)。
Redis单线程模型了解?
Redis基于单线程,那么是怎么监听大量的客户端连接呢?
Redis通过IO多路复用程序来监听来自客户端的大量连接,或者说是监听多个socket,它会将感兴趣的事件即类型注册到内核中并监听每个事件是否发生。
这样的好处非常明显:I/O多路复用技术的使用让Redis不需要额外创建多余的线程来监听客户端的大量连接,降低了资源的消耗(和NIO中的Selector组件很像)。
另外,Redis服务器是一个事件驱动程序,服务器需要处理两类事件:
- ⽂件事件(file event) :⽤于处理 Redis 服务器和客户端之间的⽹络 IO。
- 时间事件(time eveat) :Redis 服务器中的⼀些操作(⽐如 serverCron 函数)需要在给定的时 间点执⾏,⽽时间事件就是处理这类定时操作的。
时间事件不需要多花时间了解,我们接触最多的还是 ⽂件事件(客户端进⾏读取写⼊等操作,涉及 ⼀系列⽹络通信)。
Redis 基于 Reactor 模式开发了⾃⼰的⽹络事件处理器:这个处理器被称为⽂件事件处理器 (file event handler)。⽂件事件处理器使⽤ I/O 多路复⽤(multiplexing)程序来同时监听多 个套接字,并根据套接字⽬前执⾏的任务来为套接字关联不同的事件处理器。 当被监听的套接字准备好执⾏连接应答(accept)、读取(read)、写⼊(write)、关 闭 (close)等操作时,与操作相对应的⽂件事件就会产⽣,这时⽂件事件处理器就会调⽤套接字 之前关联好的事件处理器来处理这些事件。 虽然⽂件事件处理器以单线程⽅式运⾏,但通过使⽤ I/O 多路复⽤程序来监听多个套接字,⽂ 件事件处理器既实现了⾼性能的⽹络通信模型,⼜可以很好地与 Redis 服务器中其他同样以单 线程⽅式运⾏的模块进⾏对接,这保持了 Redis 内部单线程设计的简单性。
可以看出,⽂件事件处理器(file event handler)主要是包含 4 个部分:
- 多个 socket(客户端连接)
- IO 多路复⽤程序(⽀持多个客户端连接的关键)
- ⽂件事件分派器(将 socket 关联到相应的事件处理器)
- 事件处理器(连接应答处理器、命令请求处理器、命令回复处理器)
Redis内存管理
Redis 通过⼀个叫做过期字典(可以看作是 hash 表)来保存数据过期的时间。过期字典的键指向 Redis 数据库中的某个 key(键),过期字典的值是⼀个 long long 类型的整数,这个整数保存了 key 所指向的数据库键的过期时间(毫秒精度的 UNIX 时间戳)。
过期的数据的删除策略了解么?
过期数据删除策略就两个:
- 惰性删除:只会在取出 key 的时候才对数据进⾏过期检查。这样对 CPU 最友好,但是可能会 造成太多过期 key 没有被删除。
- 定期删除:每隔⼀段时间抽取⼀批 key 执⾏删除过期 key 操作。并且,Redis 底层会通过限制 删除操作执⾏的时⻓和频率来减少删除操作对 CPU 时间的影响。
定期删除对内存更加友好,惰性删除对CPU更加友好,两者各有千秋,所以Redis采用的是定期删除+惰性删除。
但是,仅仅通过给 key 设置过期时间还是有问题的。因为还是可能存在定期删除和惰性删除漏掉了 很多过期 key 的情况。这样就导致⼤量过期 key 堆积在内存⾥,然后就 Out of memory 了。
怎么解决这个问题呢?答案就是:Redis 内存淘汰机制。
***Redis内存淘汰机制了解么?
Redis提供6种数据淘汰策略:
- volatile-lru:从已设置过期时间的数据集(server.db[i].expires)中挑选最近最少使⽤的数据淘汰;
- volatile-ttl:从已设置过期时间的数据集(server.db[i].expires)中挑选将要过期的数据淘汰;
- volatile-random:从已设置过期时间的数据集(server.db[i].expires)中任意选择数据淘汰;
- allkeys-lru:当内存不⾜以容纳新写⼊数据时,在键空间中,移除最近最少使⽤的 key(这个是最常⽤的);
- allkeys-random:从数据集(server.db[i].dict)中任意选择数据淘汰;
- no-eviction:禁⽌驱逐数据,也就是说当内存不⾜以容纳新写⼊数据时,新写⼊操作会报错。 这个应该没⼈使⽤吧!
- volatile-lfu(least frequently used):从已设置过期时间的数据集(server.db[i].expires)中 挑选最不经常使⽤的数据淘汰
- allkeys-lfu(least frequently used):当内存不⾜以容纳新写⼊数据时,在键空间中,移除最 不经常使⽤的 key;
在 Redis 中,
volatile-lfu
和volatile-lru
是两种不同的内存淘汰策略,用于在内存达到限制时决定哪些键值对应该被淘汰。这两者的主要区别在于它们如何选择要淘汰的数据。1. volatile-lfu(Least Frequently Used)
解释:
volatile-lfu
策略会选择那些使用频率最少的数据进行淘汰。Redis 通过记录键被访问的次数来决定哪些数据不经常使用。适用场景:适合那些有"热点"数据的场景。某些数据可能一段时间内被频繁访问,而其他数据很少被访问,
LFU
能更好地保持频繁使用的数据,淘汰那些长期不被使用的数据。行为示例:
假设 Redis 中有以下键值对及其访问频率(在过期键集合expires
中):key1 -> 10 (访问次数) key2 -> 5 (访问次数) key3 -> 1 (访问次数) key4 -> 20 (访问次数)
当 Redis 内存不够用,需要淘汰键时,
volatile-lfu
会选择访问频率最低的key3
进行淘汰,因为它的使用次数是最少的。2. volatile-lru(Least Recently Used)
解释:
volatile-lru
策略会淘汰那些最近最少使用的数据,而不是看数据的访问频率。Redis 会记录每个键最后一次被访问的时间,选择那些长时间未被访问的键进行淘汰。适用场景:适合那些访问数据的"新鲜度"很重要的场景。即使一个键曾经被频繁访问,但如果长时间未被访问,也可能不再有价值,这时
LRU
策略就会将其淘汰。行为示例:
假设 Redis 中有以下键值对及其最近访问时间(在过期键集合expires
中):key1 -> 2 分钟前访问 key2 -> 5 分钟前访问 key3 -> 1 小时前访问 key4 -> 10 秒前访问
当 Redis 内存不足时,
volatile-lru
会选择key3
进行淘汰,因为它是最近最少被访问的。3. 区别总结
- volatile-lfu:根据访问频率选择最不常使用的键进行淘汰。适合需要保留最常访问数据的场景。
- volatile-lru:根据最后一次访问时间选择最近最少使用的键进行淘汰。适合需要保留最新访问数据的场景。
4. 示例代码说明
假设我们使用 Java 模拟 Redis 的这两种策略(伪代码)。
volatile-lfu 示例:
import java.util.*;public class VolatileLFU {private Map<String, Integer> data = new HashMap<>();private Map<String, Integer> frequency = new HashMap<>();public void put(String key, String value) {data.put(key, value.hashCode());frequency.put(key, frequency.getOrDefault(key, 0) + 1); // 更新访问频率}public void evict() {String leastUsedKey = Collections.min(frequency.entrySet(), Map.Entry.comparingByValue()).getKey();data.remove(leastUsedKey);frequency.remove(leastUsedKey);System.out.println("Evicted key with least frequency: " + leastUsedKey);}public static void main(String[] args) {VolatileLFU cache = new VolatileLFU();cache.put("key1", "value1");cache.put("key2", "value2");cache.put("key3", "value3");cache.evict(); // 淘汰访问次数最少的键} }
volatile-lru 示例:
import java.util.*;public class VolatileLRU {private Map<String, Integer> data = new HashMap<>();private Map<String, Long> lastAccessed = new HashMap<>();public void put(String key, String value) {data.put(key, value.hashCode());lastAccessed.put(key, System.currentTimeMillis()); // 更新最后访问时间}public void evict() {String leastRecentlyUsedKey = Collections.min(lastAccessed.entrySet(), Map.Entry.comparingByValue()).getKey();data.remove(leastRecentlyUsedKey);lastAccessed.remove(leastRecentlyUsedKey);System.out.println("Evicted least recently used key: " + leastRecentlyUsedKey);}public static void main(String[] args) {VolatileLRU cache = new VolatileLRU();cache.put("key1", "value1");cache.put("key2", "value2");cache.put("key3", "value3");cache.evict(); // 淘汰最近最少访问的键} }
5. 总结
- volatile-lfu:基于访问频率淘汰键,适合那些有“热点”数据的场景。
- volatile-lru:基于最后访问时间淘汰键,适合那些数据"新鲜度"较重要的场景。
Redis持久化机制
怎么保证Redis挂掉之后再重启数据可以进行恢复吗?
Redis 的⼀种持久化⽅式叫快照(snapshotting,RDB),另⼀种⽅式是只追加⽂件 (append-only file, AOF)。
Redis 提供了两个命令来⽣成 RDB 快照⽂件:
- save : 主线程执⾏,会阻塞主线程;
- bgsave : ⼦线程执⾏,不会阻塞主线程,默认选项
AOP持久化
与快照持久化相⽐,AOF 持久化的实时性更好,因此已成为主流的持久化⽅案。默认情况下 Redis 没有开启 AOF(append only file)⽅式的持久化,可以通过 appendonly 参数开启:
在关系型数据库通常都是执行命令之前记录日志(方便故障恢复),而Redis AOF持久化机制是在执行完命令之后再记录日志。
这是为了避免额外的检查开销,AOF记录日志会对命令进行语法检查;在命令执行完之后再记录,不会阻塞当前命令的执行。
但是这样也会导致出现Redis宕机从而对应的修改丢失,可能也会阻塞后续其它命令的执行,AOF记录日志实在Redis主线程中进行的。
AOF 重写了解吗? AOF 重写可以产⽣⼀个新的 AOF ⽂件,这个新的 AOF ⽂件和原有的 AOF ⽂件所保存的数据库状 态⼀样,但体积更⼩。 AOF 重写是⼀个有歧义的名字,该功能是通过读取数据库中的键值对来实现的,程序⽆须对现有 AOF ⽂件进⾏任何读⼊、分析或者写⼊操作。
Redis事务
Redis 可以通过 MULTI , EXEC , DISCARD 和 WATCH 等命令来实现事务(transaction)功能。
> MULTI
OK
> SET USER "Guide哥"
QUEUED
> GET USER
QUEUED
> EXEC
1) OK
2) "Guide哥"
DISCARD取消一个事务
> MULTI
OK
> SET USER "Guide哥"
QUEUED
> GET USER
QUEUED
> DISCARD
OK
WATCH 命令⽤于监听指定的键,当调⽤ EXEC 命令执⾏事务时,如果⼀个被 WATCH 命令监 视的键被修改的话,整个事务都不会执⾏,直接返回失败。
> WATCH USER
OK
> MULTI
> SET USER "Guide哥"
OK
> GET USER
Guide哥
> EXEC
ERR EXEC without MULTI
关系型数据库事务的四大特性:原子性、持久性、隔离性和一致性;但是Redis不满足在事务运行错误的情况下进行回滚,即不满足原子性,Redis也定不满足持久性。
Redis事务提供一种将多个命令打包的功能,然后再按顺序执行打包的所有命令,并且不会被中途打断。
除了不满⾜原⼦性之外,事务中的每条命令都会与 Redis 服务器进⾏⽹络交互,这是⽐浪费资源 的⾏为。 因此,Redis 事务是不建议在⽇常开发中使⽤的。
为了解决这个问题,在Redis2.6之后提出了Lua机制,通过使用Lua脚本来批量执行多条Redis命令,而且这些Redis会一次性打包到Redis服务器中执行。
但是通过Lua脚本执行的命令也不会满足原子性,这是因为在一个Lua脚本中的命令如果出现执行错误的命令则会停止执行接下来的命令,但是之前的命令不会回滚。
Redis优化
Redis bigkey
什么是 bigkey? 简单来说,如果⼀个 key 对应的 value 所占⽤的内存比较大,那这个 key 就可以看作是 bigkey。具 体多⼤才算⼤呢?有⼀个不是特别精确的参考标准:string 类型的 value 超过 10 kb,复合类型的 value 包含的元素超过 5000 个(对于复合类型的 value 来说,不⼀定包含的元素越多,占⽤的内存 就越多)。
bigkey对性能的影响很大,在实际的开发中应该尽量避免bigkey。
通过分析RDB文件来得到bigkey,前提是Redis采用的是RDB持久化。
大量key集中过期问题
这一问题是在某一时刻遇到了大量的key过期,而这个清除Redis过期数据是由Redis主线程中执行的,导致客户端请求响应速度减慢。
针对key在某一时刻同时过期,我们提出:为key设置一个随机过期时间,避免造成拥堵;
针对清除过期key导致主线程阻塞,那么我们可以让清除过期key交给一个子线程,避免阻塞主线程,我们称这个方法为lazy-free(惰性删除/延迟释放)
Redis生产问题
缓存穿透
什么是缓存穿透
简单的说就是大量的请求key不存在缓存中,导致大量的请求直接打到了数据库上,导致数据库压力激增。解决办法如下:
-
缓存无效的key;
如果缓存和数据库都查不到某个 key 的数据就写⼀个到 Redis 中去并设置过期时间,具体命令如 下: SET key value EX 10086 。这种⽅式可以解决请求的 key 变化不频繁的情况,如果⿊客恶意攻 击,每次构建不同的请求 key,会导致 Redis 中缓存⼤量⽆效的 key 。很明显,这种⽅案并不能从 根本上解决此问题。如果⾮要⽤这种⽅式来解决穿透问题的话,尽量将⽆效的 key 的过期时间设置 短⼀点⽐如 1 分钟。 另外,这⾥多说⼀嘴,⼀般情况下我们是这样设计 key 的: 表名:列名:主键名:主键值 。
-
布隆过滤器;
布隆过滤器说某个元素存 在,⼩概率会误判。布隆过滤器说某个元素不在,那么这个元素⼀定不在。
布隆过滤器的本质是哈希函数,因为是哈希函数,那么就存在一定的几率出现哈希冲突,所以就会出现布隆过滤器说它不在它一定不在,说它在它不一定在。
缓存雪崩
缓存在同⼀时间⼤⾯积的失效,后⾯的请求都直 接落到了数据库上,造成数据库短时间内承受⼤量请求。 这就好⽐雪崩⼀样,摧枯拉朽之势,数据 库的压⼒可想⽽知,可能直接就被这么多请求弄宕机了。
可以发现缓存雪崩和缓存穿透的区别就是这个key是否真实有效存在的。
有哪些解决办法? 针对 Redis 服务不可⽤的情况:
- 采⽤ Redis 集群,避免单机出现问题整个缓存服务都没办法使⽤。
- 限流,避免同时处理⼤量的请求。
针对热点缓存失效的情况:
- 设置不同的失效时间⽐如随机设置缓存的失效时间。
- 缓存永不失效
如何保证缓存和数据库数据的一致性
参考资料:缓存和数据库一致性问题,看这篇就够了
我们首先就需要明确一下为什么引入缓存之后会出现缓存和数据库数据一致性的问题,在引入缓存之前,我们存取数据都是在一个数据库中进行操作的,但是如果我们引入了缓存之后,系统读的时候会优先从缓存中读取,那么如果缓存中的数据和磁盘中的数据不一致该如何是好?
所以我们就需要认真思考一下该如何解决缓存和数据库数据一致性的问题。
在这里我们需要面对下面几个选择:
- 更新缓存 OR 删除缓存
- 如果是更新缓存,是先更新缓存还是先更新数据库;
- 如果是删除缓存,是先删除缓存还是先更新数据库再删除缓存;
下面我们依次来看看这些方案分别会出现什么问题。
更新缓存
如果我们选择更新缓存这一大方案,那么我们就会出现下面几个选择:先更新数据库再更新缓存OR先更新缓存再更新数据库。
在高并发的情况下,无论是先更新缓存再更新数据库还是先更新数据库再更新缓存都回出现下面这个问题,下面我们以先更新缓存再更新数据库为例来介绍其可能会出现的问题:
删除缓存
先删除缓存,后更新数据库
Fimage-20240911120051711.png&pos_id=img-KoKHFBHc-1728747222745)
先更新数据库,后删除缓存
如果先更新数据库再删除缓存,是不会出现上面这种情况的,但是还会出现下面这个问题:
所以为了解决并发情况的最好解决方案是:先更新数据库后删除缓存;
但是无论是更新缓存还是删除缓存,只要第二步发生失败,那么就会导致数据库和缓存不一致。
保证第二步成功执行就是解决问题的关键,最简单的办法是重试。
异步重试
至此,我们可以得出结论,想要保证数据库和缓存一致性,推荐采用「先更新数据库,再删除缓存」方案,并配合「消息队列」或「订阅变更日志」的方式来做。