用来做缓存的,减少磁盘的 IO 读写操作,适合缓存不需要变动的值。
Redis 并不是多线程的,而是单线程 + 多路 IO 复用。
数据类型
-
string:
二进制安全的数据结构(可以存放例如图片这种二进制数据),默认情况下最大为 512MB。 -
list:
有序可重复的双向链表,最大长度为 2^32 - 1(4,294,967,295)个元素。 -
set:
无序不可重复集合,最大长度和 lists 一样。 -
hash:类型理解为 Java 中的 Map,里面保存类似于 web 请求的键值对参数。
-
zset:在 set 基础上增加了一个评分属性(score),通过这个属性排序成员。
事务
- 事务中的所有命令都是
按顺序执行的,并且事务执行的时候不会处理其它客户端的请求。 - 客户端在调用
EXEC命令之前断开了连接,则不会执行任何操作;调用EXEC命令之后断开连接,会执行事务的所有操作。 - 不支持事务嵌套,抛出:(error) ERR MULTI calls can not be nested。
- 不支持事务回滚。
使用
使用 MULTI 创建一个 Redis 事务,客户端发送命令后并不会立即执行而是将它们排队,一旦调用 EXEC 后,所有的命令都会被执行,而 DISCARD 就是取消事务。
以下示例以原子方式递增 foo 和 bar 键:
> MULTI
OK
> INCR foo
QUEUED
> INCR bar
QUEUED
> EXEC
1) (integer) 1
2) (integer) 1
从上面的会话中可以看出,
EXEC返回一个数组保存了命了的执行结果,执行结果和命令的执行顺序相同。
事务中的错误
一个事务被分为 排队阶段 和 执行阶段:
-
排队阶段:在调用
EXEC方法之前出现错误,例如语法错误或内存限制等情况;出现这种情况,当前的事务将会被丢弃,不会执行任何命令。如果命令成功排队会返回QUEUED,客户端通过判断这个值来主动丢弃当前事务。# 当排队阶段出现错误,执行 EXEC 命令会出现以下错误 # 事务由于先前的错误而被丢弃 # EXECABORT 由于先前的错误而放弃事务。 (error) EXECABORT Transaction discarded because of previous errors. -
执行阶段:在调用
EXEC命令之后出现错误,例如字符串进行自增操作。出现这种情况,还是会继续执行其它命令;对于执行失败的命令,失败信息会放到EXEC返回的数组中。# incr k1 命令肯定会自增失败,所以第二条命了会返回错误 127.0.0.1:6379> multi OK 127.0.0.1:6379(TX)> 127.0.0.1:6379(TX)> set k1 a QUEUED 127.0.0.1:6379(TX)> incr k1 QUEUED 127.0.0.1:6379(TX)> exec 1) OK 2) (error) ERR value is not an integer or out of range
缓存雪崩
比如说,内存淘汰策略,导致大量的缓存同时过期了,然后这个时候大量请求都会直接访问数据库。
缓存中为什么没有这些数据呢?
- 有一大堆缓存的数据过期了
- 被内存淘汰策略淘汰了
- Redis 服务宕机了
解决方案:
- 使用随机过期时间
- 延长过期时间
- 超热数据使用永久 key
- redis 集群
- 降流、限流: 当流量达到一定的阈值, 直接返回 "系统拥挤" 之类的提示
- 加锁: 限制同时访问数据库的请求数
缓存击穿
和缓存雪崩类似,它是因为有一个热点数据过期了,导致大量请求直接访问 Mysql 了。
缓存穿透
请求访问了缓存和数据库中都没有的数据。
解决方案:
- 缓存空对象: 当数据库中也不存在数据的时候,在 Redis 中缓存一个空值;但是这样会造成额外的内存消耗和短期的数据不一致
- 布隆过滤: 在客户端和 Redis 之间增加了布隆过滤器;当布隆过滤中没有数据的时候,就会直接禁止流程继续执行(禁止访问 Redis 和数据库)
- 如果是自增主键的话,就保存最大值到缓存;不是的话就保存所有主键。
- 限制 IP 访问
布隆过滤器禁止流程继续执行,说明数据 100% 不存在;但是允许流程继续执行并不是 100% 准确的,所以还是有一定的穿透风险。
缓存预热
缓存预热就是防止服务器启动后立马宕机了。
可以在服务启动前,将一些热点数据(日常统计)先添加到 Redis 中。
key 过期删除策略和内存淘汰策略
key 过期后不会立即删除,通过过期删除策略删除:
- 定时删除: 给 key 设置过期时间的时候会创建一个关联的定时器,当时间到达时,由定时器自动执行 key 的删除操作;这样做的好处是过期的 key 可以被立即删除,但是会消耗 CPU 降低吞吐量。
- 惰性删除: 访问 key 的时候判断是不是过期,过期就删除 key;这样做的好处是不会占用 CPU 资源,但是会导致过期后 key 还在缓存中。
- 定期删除: 每隔一段时间,随机判断一些 key 是不是过期了,过期了就删除;这样做的好处是减少了 CPU 资源的占用,同时能够清理掉过期的 key,但是执行时常和频率无法确定,和算法又关系。
Redis 选择惰性删除 + 定期删除这两种策略配和使用,以求在合理使用 CPU 时间和避免内存浪费之间取得平衡。
在配置文件 redis.conf 中,可以通过参数
hz <每秒运行多少次>来设定定期删除时间。
内存淘汰策略
在配置文件 redis.conf 中,可以通过参数 maxmemory <bytes> 来设定最大运行内存,只有在 Redis 的运行内存达到了我们设置的最大运行内存, 才会触发内存淘汰策略。
不同位数的操作系统,maxmemory 的默认值是不同的:
- 在 64 位操作系统中,maxmemory 的默认值是 0,表示没有内存大小限制,那么不管用户存放多少数据到 Redis 中,Redis 也不会对可用内存进行检查, 直到 Redis 实例因内存不足而崩溃也无作为。
- 在 32 位操作系统中,maxmemory 的默认值是 3G,因为 32 位的机器最大只支持 4GB 的内存,而系统本身就需要一定的内存资源来支持运行,所以 32 位操作系统限制最大 3 GB 的可用内存是非常合理的,这样可以避免因为内存不足而导致 Redis 实例崩溃。
在配置文件 redis.conf 中,可以通过参数 maxmemory-policy 来设定内存淘汰策略:
- volatile-random: 随机淘汰一些设置了超时时间的 key
- volatile-ttl: 优先淘汰一些即将过期的 key
- volatile-lru: 淘汰设置了过期时间,但偶尔使用的 key
- volatile-lfu: 淘汰设置了过期时间,但已经不怎么用了的 key
- allkeys-random: 随机淘汰一些 key
- allkeys-lru: 淘汰偶尔使用的 key
- allkeys-lfu: 淘汰已经不怎么使用的 key
- noeviction: 关闭内存淘汰策略,如果关闭会抛出内存溢出
LRU(Least Recently Used): 最少使用的, 可以理解为这个 key 偶尔被使用
LFU(Least Frequently Used): 最不常用的, 可以理解为这个 key 已经不怎么用了
random: 随机选择一些 key
ttl: 优先淘汰那些即将过期的 key
volatile: 只会淘汰设置了过期时间的 key
allkeys: 会淘汰所有的 key如果使用了 volatile-lru、volatile-lfu、volatile-random 和 volatile-ttl 策略, 当没有匹配到任何 key 的时候, 它们不会移除任何 key 类似于 noeviction.
不能使用简单的过期策略
- 如果设置时间太短,会造成穿透和雪崩,而且时间太短也没有必要使用缓存了。
- 设置时间太长,导致总是读取到脏数据。
数据一致性
缓存过期、数据添加、删除、更新后才需要更新缓存,保证 MySQL 和 Redis 中的数据是一致的(保证最终一致性):
- 并发量不是很大的情况下可以加分布式锁做双写;
例如: 在并发场景下请求 A 将 c 的值修改为 1, 同时请求 B 将 c 的值修改为 2, 这样就导致数据库和缓存中的值有可能不一样, 这个时候就可以加锁, 让 MySQL 和 Redis 是原子操作. - 通过 MQ 但是要保证消息的顺序一致性
- 通过阿里巴巴的 canal
分布式锁
就是和 Java 中锁的作用是一样的,用来互斥共享资源、保证数据一致、防止数据重复。
常见的分布式锁实现方案:
- Redis
- MySQL
- ZooKeeper
通过 SET 后面加 NX、PX 参数(保证原子)获取锁:
SET lockKey requestId NX PX 30000
- lockKey: 键就是锁的名字。
- requestId: 值就是客户端生成的一个随机字符串,它要保证在足够长的一段时间内,这个值是唯一的。
- NX: 保证 lockKey 不存在的时候才能获取锁。
- PX: 设置过期时间(毫秒),也可以通过 EX 设置过期时间(秒);到了过期时间后,会自动删除 lockKey,这是为了防止服务出现问题后锁没有被释放。
在 Java 中使用 jedis 包的调用方法是:
String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime)
锁超时问题
客户端 1 获取了锁后,但是业务处理时长超过了锁的过期时间, 导致锁被自动释放了,然后客户端 2 获取到锁后,客户端1执行完业务后会释放锁,这个时候其实释放的就是客户端 2 的锁。
通过使用锁续期解决锁超时问题。
Redisson 开源组件实现了锁续期的功能,叫做看门狗;锁的过期时间默认是 30 秒,每隔 10 秒会将锁的过期时间重新设置为 30 秒;当获取锁的服务崩溃后,30 秒后也会释放锁。
最好不要增加锁的释放时间:
- 没办法完全确定业务的处理时间。
- 如果获取锁的服务崩溃了,会一段时间空等待。
释放锁
释放锁的时候通过 lua 脚本保证原子性:
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
这段 Lua 脚本在执行的时候要把前面的 requestId 作为 ARGV[1] 的值传进去,把 lockKey 作为 KEYS[1] 的值传进去。
Redis 主从架构数据复制导致分布式锁出现问题
通常使用 Redis Cluster 或者 哨兵模式 这两种方式实现 Redis 的高可用,而这两种方式都是基于主从架构数据复制实现的,这就导致主从发生重选,分布式锁出现问题:
同步复制和异步复制都会发生这个问题,即使使用了 WAIT 命令也一样;因为从节点还没同步完就被选为了主节点。
还有网络分区也会导致分布式锁出现问题。
Redlock 算法
Redlock 算法解决主节点故障,从节点变为主节点后分布式锁出现问题。
通过这种方式解决问题要求比较高,必须要有 >=5 个 Redis 节点(必须是奇数),这些节点完全互相独立,不存在主从复制或者其它集群协调机制。
获取锁的大概过程:
- 获取当前 Unix 时间戳
- 遍历 5 个实例获取锁,保证获取了一半以上的锁
- 然后再用获取锁的时间减去第一步获取的时间
- 如果获取锁的时间大于了锁的过期时间,就会获取失败。
高并发情况下,指定一个随机的获取超时时间,否则会出现没有一个线程获取锁的情况:
一些问题
问题 1:为什么要在多个实例上加锁?
本质上为了容错,部分实例异常宕机,剩余实例只要超过 N/2+1 依旧可用。
问题 2:为什么步骤 3 加锁成功之后, 还要计算加锁的累计耗时?
因为加锁操作的针对的是分布式中的多个节点,所以耗时肯定是比单个实例耗时更久,至少需要 N/2+1 个网络来回,还要考虑网络延迟、丢包、超时等情况发生,网络请求次数越多,异常的概率越大,所以即使 N/2+1 个节点加锁成功,但如果加锁的累计耗时已经超过了锁的过期时间,那么此时的锁已经没有意义了。
问题 3:为什么释放锁,要操作所有节点,对所有节点都释放锁?
因为当对某一个 Redis 节点加锁时,可能因为网络原因导致加锁“失败”。
注意这个“失败”,指的是 Redis 节点实际已经加锁成功了,但是返回的结果因为网络延迟并没有传到加锁的线程,被加锁线程丢弃了,加锁线程误以为没有成功,于是加锁线程去尝试下一个节点了。
崩溃恢复(AOF 持久化)对 Redlock 算法影响
为了保证 Redlock 算法的安全性,有如下两种手段:
- 持久化配置中设置
fsync=always,性能大大降低。 - 恰当的运维,把崩溃节点进行延迟重启,超过崩溃前所有锁的 TTL 时间之后才加入 Redlock 节点组。
导致 Redis 阻塞的命令
- BRPOP、BLPOP、BRPOPLPUSH 命令:这些命令会阻塞 Redis 进程,直到有列表非空,或者达到超时时间。
- SCAN 命令:这个命令可能会阻塞 Redis 进程一段时间,尤其是在数据集非常大的情况下。
- SORT 命令:这个命令可能会阻塞 Redis 进程一段时间,尤其是在需要进行大量排序的情况下。
- FLUSHALL、FLUSHDB 命令:这些命令会立即清空所有数据库或者当前数据库的所有数据,直到清空操作完成前,Redis 进程会被阻塞。
KEYS 和 SCAN
使用 KEYS 命令时,Redis 主进程会被阻塞或内存不足;推荐使用 SCAN 命令。
SCAN 命令将数据遍历操作分成了好几个小块,在遍历的时候会处理其它客户端的请求。
SCAN 命令使用游标机制避免内存不足,但是会出现数据重复的情况。
Redis 6 多线程
在 Redis 6 中,多线程 I/O 仅用于处理网络 I/O 操作,这种方式可以提高 Redis 在高并发情况下的性能表现,并减少因 I/O 操作而导致的延迟。
但是,指令的执行还是单线程的。
Jedis、lettuce 和 Redisson 对比
Redis 官方推荐的 Java 客户端有Jedis、lettuce 和 Redisson。
Jedis
是老牌的 Redis 的 Java 实现客户端,提供了比较全面的 Redis 命令的支持,其官方网址是:http://tool.oschina.net/uploads/apidocs/redis/clients/jedis/Jedis.html。
优点:
- 支持全面的 Redis 操作特性(可以理解为API比较全面)。
缺点:
- 使用阻塞的 I/O,且其方法调用都是同步的,程序流需要等到 sockets 处理完 I/O 才能执行,不支持异步;
- Jedis 客户端实例不是线程安全的,所以需要通过连接池来使用 Jedis。
lettuce
是一种可扩展的线程安全的 Redis 客户端,支持异步模式。如果避免阻塞和事务操作,如 BLPOP 和 MULTI/EXEC,多个线程就可以共享一个连接。lettuce 底层基于 Netty,支持高级的 Redis 特性,比如哨兵,集群,管道,自动重新连接和Redis数据模型。lettuce 的官网地址是:https://lettuce.io/
优点:
- 支持同步异步通信模式;
- Lettuce 的 API 是线程安全的,如果不是执行阻塞和事务操作,如 BLPOP 和 MULTI/EXEC,多个线程就可以共享一个连接。
Redisson
是一个在 Redis 的基础上实现的 Java 驻内存数据网格(In-Memory Data Grid)。它不仅提供了一系列的分布式的 Java 常用对象,还提供了许多分布式服务。其中包括( BitSet, Set, Multimap, SortedSet, Map, List, Queue, BlockingQueue, Deque, BlockingDeque, Semaphore, Lock, AtomicLong, CountDownLatch, Publish / Subscribe, Bloom filter, Remote service, Spring cache, Executor service, Live Object service, Scheduler service) Redisson 提供了使用Redis 的最简单和最便捷的方法。Redisson 的宗旨是促进使用者对Redis的关注分离(Separation of Concern),从而让使用者能够将精力更集中地放在处理业务逻辑上。Redisson的官方网址是:https://redisson.org/
优点:
- 使用者对 Redis 的关注分离,可以类比 Spring 框架,这些框架搭建了应用程序的基础框架和功能,提升开发效率,让开发者有更多的时间来关注业务逻辑;
- 提供很多分布式相关操作服务,例如,分布式锁,分布式集合,可通过 Redis 支持延迟队列等。
缺点:
- Redisson 对字符串的操作支持比较差。
参考资料
https://redis.io/docs/manual/patterns/distributed-locks/
https://pdai.tech/md/arch/arch-z-lock.html
https://blog.csdn.net/ByteDanceTech/article/details/125814670
https://www.infoq.cn/article/dvaaj71f4fbqsxmgvdce
https://blog.51cto.com/u_15127579/2722185
https://developpaper.com/deep-understanding-of-rediss-simple-dynamic-string/
https://scalegrid.io/blog/top-redis-use-cases-by-core-data-structure-types/
https://gitee.com/mrjackiechan/class-documents/blob/master/Part_04/
https://yunpengn.github.io/blog/2019/05/04/consistent-redis-sql/