概述
缓存是高并发场景下提高热点数据访问性能的一个有效手段,在开发项目时会经常使用到。
传统的关系型数据库如 Mysql 已经不能适用所有的场景了,比如秒杀的库存扣减,APP 首页的访问流量高峰等等,都很容易把数据库打崩,所以引入了缓存中间件。
以下是 Redis 的一些优点:
- 异常快 - Redis 非常快,每秒可执行大约 110000 次的设置(SET)操作,每秒大约可执行 81000 次的读取/获取(GET)操作。
- 支持丰富的数据类型 - Redis 支持开发人员常用的大多数数据类型,例如列表,集合,排序集和散列等等。这使得 Redis 很容易被用来解决各种问题,因为我们知道哪些问题可以更好使用地哪些数据类型来处理解决。
- 操作具有原子性 - 所有 Redis 操作都是原子操作,这确保如果两个客户端并发访问,Redis 服务器能接收更新的值。
- 多实用工具 - Redis 是一个多实用工具,可用于多种用例,如:缓存,消息队列(Redis 本地支持发布/订阅),应用程序中的任何短期数据,例如,web 应用程序中的会话,网页命中计数等。
缓存知识点
Memcache
MC 的特点:
- MC 处理请求时使用多线程异步 IO 的方式,可以合理利用 CPU 多核的优势,性能非常优秀;
- MC 功能简单,使用内存存储数据;
- MC 的内存结构以及钙化问题我就不细说了,大家可以查看官网了解下;
- MC 对缓存的数据可以设置失效期,过期后的数据会被清除;
- 失效的策略采用延迟失效,就是当再次使用数据时检查是否失效;
- 当容量存满时,会对缓存中的数据进行剔除,剔除时除了会对过期 key 进行清理,还会按 LRU 策略对数据进行剔除。 另外,使用 MC 有一些限制,这些限制在现在的互联网场景下很致命,成为大家选择Redis、MongoDB的重要原因:
- key 不能超过 250 个字节;
- value 不能超过 1M 字节;
- key 的最大失效时间是 30 天;
- 只支持 K-V 结构,不提供持久化和主从同步功能。
Redis
先简单说一下 Redis 的特点,方便和 MC 比较。
- 与 MC 不同的是,Redis 采用单线程模式处理请求。这样做的原因有 2 个:一个是因为采用了非阻塞的异步事件处理机制;另一个是缓存数据都是内存操作 IO 时间不会太长,单线程可以避免线程上下文切换产生的代价。
- Redis 支持持久化,所以 Redis 不仅仅可以用作缓存,也可以用作 NoSQL 数据库。
- 相比 MC,Redis 还有一个非常大的优势,就是除了 K-V 之外,还支持多种数据格式,例如 list、set、sorted set、hash 等。
- Redis 提供主从同步机制,以及 Cluster 集群部署能力,能够提供高可用服务。
Redis 数据结构
String、Hash、List、Set、SortedSet、HyperLogLog、Geo、Pub/Sub。
Key
DEL key
EXISTS key
KEYS pattern 查找所有符合给定模式(pattern)的 key。
MOVE key db 将当前数据库的 key 移动到给定的数据库 db 当中。
// key 存在于当前数据库
// redis默认使用数据库 0,为了清晰起见,这里再显式指定一次。
redis> SELECT 0
OK
redis> SET song "secret base - Zone"
OK
// 将 song 移动到数据库 1
redis> MOVE song 1
(integer) 1
// song 已经被移走
redis> EXISTS song
(integer) 0
// 使用数据库 1
redis> SELECT 1
OK
// 证实 song 被移到了数据库 1 (注意命令提示符变成了"redis:1",表明正在使用数据库 1)
redis:1> EXISTS song
(integer) 1
PERSIST key 移除 key 的过期时间,key 将持久保持。
RENAMENX key newkey 仅当 newkey 不存在时,将 key 改名为 newkey 。
SCAN cursor [MATCH pattern] [COUNT count] 迭代数据库中的数据库键。
TYPE key 返回 key 所储存的值的类型。
String
Redis 中的字符串是一种动态字符串,这意味着使用者可以修改,它的底层实现有点类似于 Java 中的 ArrayList,有一个字符数组,从源码的 sds.h/sdshdr 文件中可以看到 Redis 底层对于字符串的定义 SDS(Simple Dynamic String)。
注:Redis 规定了字符串的长度不得超过 512 MB。
GETRANGE key start end 返回 key 中字符串值的子字符。
GETSET key value 将给定 key 的值设为 value,并返回 key 的旧值(old value)。
SETEX key seconds value 将值 value 关联到 key,并将 key 的过期时间设为 seconds(以秒为单位)。
SETNX key value 只有在 key 不存在时设置 key 的值。
SETRANGE key offset value 用 value 参数覆写给定 key 所储存的字符串值,从偏移量 offset 开始。
PSETEX key milliseconds value 这个命令和 SETEX 命令相似,但它以毫秒为单位设置 key 的生存时间,而不是像 SETEX 命令那样,以秒为单位。
INCR key
DECR key
List
Redis 的 List 相当于 Java 语言中的 LinkedList,注意它是链表而不是数组。这意味着 list 的插入和删除操作非常快,时间复杂度为 O(1),但是索引定位很慢,时间复杂度为 O(n)。
BLPOP key1 [key2 ] timeout 移出并获取列表的第一个元素,如果列表没有元素会阻塞列表直到等待超时或发现可弹出元素为止。
BRPOP key1 [key2 ] timeout 移出并获取列表的最后一个元素,如果列表没有元素会阻塞列表直到等待超时或发现可弹出元素为止。
LPOP key 移出并获取列表的第一个元素。
RPOP key 移除列表的最后一个元素,返回值为移除的元素。
LPUSH key value1 [value2] 将一个或多个值插入到列表头部。
RPUSH key value1 [value2] 在列表中添加一个或多个值。
Hash
Redis 中的字典相当于 Java 中的 HashMap,内部实现也差不多类似,都是通过“数组+链表”的链地址办法来解决部分哈希冲突,同时这样的结构也吸收了两种不同数据结构的优点。
Redis hash 是一个 string 类型的 field(字段) 和 value(值) 的映射表,hash 特别适合用于存储对象。
字典结构的内部包含两个 hashtable,通常情况下只有一个 hashtable 是有值的,但是在字典扩容缩容时,需要分配新的 hashtable,然后进行渐进式搬迁。
渐进式 rehash
大字典的扩容是比较耗时间的,需要重新申请新的数组,然后将旧字典所有链表中的元素重新挂接到新的数组下面,这是一个 O(n) 级别的操作,作为单线程的 Redis 很难承受这样耗时的过程,所以 Redis 使用 渐进式 rehash 小步搬迁:
渐进式 rehash 会在 rehash 的同时,保留新旧两个 hash 结构,如上图所示,查询时会同时查询两个 hash 结构,然后在后续的定时任务以及 hash 操作指令中,循序渐进的把旧字典的内容迁移到新字典中。当搬迁完成了,就会使用新的 hash 结构取而代之。
扩缩容的条件
正常情况下,当 hash 表中元素的个数等于第一维数组的长度时,就会开始扩容,扩容的新数组是原数组大小的 2 倍。不过如果 Redis 正在做 bgsave(持久化命令),为了减少内存过多分离,Redis 尽量不去扩容,但是如果 hash 表非常满了,达到了第一维数组长度的 5 倍了,这个时候就会强制扩容。
当 hash 表因为元素逐渐被删除变得越来越稀疏时,Redis 会对 hash 表进行缩容来减少 hash 表的第一维数组空间占用。所用的条件是元素个数低于数组长度的 10%,缩容不会考虑 Redis 是否在做 bgsave。
Set
Redis 的集合相当于 Java 语言中的 HashSet,它内部的键值对是无序、唯一的。它的内部实现相当于一个特殊的字典,字典中所有的 value 都是一个值 NULL。
zset
这可能使 Redis 最具特色的一个数据结构了,它类似于 Java 中 SortedSet 和 HashMap 的结合体,一方面它是一个 set,保证了内部 value 的唯一性,另一方面它可以为每个 value 赋予一个 score 值,用来代表排序的权重。
相关概念
常见的淘汰策略有哪些?
一般的剔除策略有 FIFO 淘汰最早数据、LRU 剔除最近最少使用(淘汰最长时间未被使用的)、和 LFU 剔除最近使用频率最低的数据(淘汰一定时期内被访问次数最少的)几种策略。
缓存有哪些类型?
本地缓存就是在进程的内存中进行缓存,比如我们的 JVM 堆中,可以用 LRUMap 来实现,也可以使用 Ehcache 这样的工具来实现。本地缓存是内存访问,没有远程交互开销,性能最好,但是受限于单机容量,一般缓存较小且无法扩展。
分布式缓存可以很好地解决这个问题。分布式缓存一般都具有良好的水平扩展能力,对较大数据量的场景也能应付自如。缺点就是需要进行远程请求,性能不如本地缓存。
为了平衡这种情况,实际业务中一般采用多级缓存,本地缓存只保存访问频率最高的部分热点数据,其他的热点数据放在分布式缓存中。
大量的 key 设置同一时间过期,会出现什么问题?
如果大量的 key 过期时间设置的过于集中,到过期的那个时间点,Redis 可能会出现短暂的卡顿现象。严重的话会出现缓存雪崩,我们一般需要在时间上加一个随机值,使得过期时间分散一些。
电商首页经常会使用定时任务刷新缓存,可能大量的数据失效时间都十分集中,如果失效时间一样,又刚好在失效的时间点大量用户涌入,就有可能造成缓存雪崩。
Redis 分布式锁是什么?
先拿 setnx 来争抢锁,抢到之后,再用 expire 给锁加一个过期时间防止锁忘记了释放。set 指令有非常复杂的参数,可以同时把 setnx 和 expire 合成一条指令来使用。
为什么不要使用 keys*,为什么推荐scan
keys*
这个指令没有 offset、limit 参数,是要一次性吐出所有满足条件的 key,由于 redis 是单线程的,其所有操作都是原子的,而 keys 算法是遍历算法,复杂度是 O(n),如果实例中有千万级以上的 key,这个指令就会导致 Redis 服务卡顿,所有读写 Redis 的其它的指令都会被延后甚至会超时报错,可能会引起缓存雪崩甚至数据库宕机。
我们可以通过配置设置禁用这些命令,在 redis.conf 中,在 SECURITY 这一项中,我们新增以下命令:
rename-command flushall ""
rename-command flushdb ""
rename-command config ""
rename-command keys ""
scan
scan(2.8 版本中加入了 scan)相比 keys* 具备以下特点:
- 复杂度虽然也是 O(n),但是它是通过游标分步进行的,不会阻塞线程;
- 提供 limit 参数,可以控制每次返回结果的最大条数,limit 只是一个 hint,返回的结果可多可少;
- 同 keys* 一样,它也提供模式匹配功能;
- 服务器不需要为游标保存状态,游标的唯一状态就是 scan 返回给客户端的游标整数;
- 返回的结果可能会有重复,需要客户端去重复;
- 遍历的过程中如果有数据修改,改动后的数据能不能遍历到是不确定的;
- 单次返回的结果是空的并不意味着遍历结束,而要看返回的游标值是否为零; 不过, 增量式迭代命令也不是没有缺点的: 举个例子, 使用 SMEMBERS 命令可以返回集合键当前包含的所有元素, 但是对于 SCAN 这类增量式迭代命令来说, 因为在对键进行增量式迭代的过程中, 键可能会被修改, 所以增量式迭代命令只能对被返回的元素提供有限的保证 (offer limited guarantees about the returned elements)。
redis 高级用法有哪些?
Bitmap:位图是支持按 bit 位来存储信息,可以用来实现 布隆过滤器(BloomFilter); HyperLogLog:供不精确的去重计数功能,比较适合用来做大规模数据的去重统计,例如统计 UV; Geospatial:可以用来保存地理位置,并作位置距离计算或者根据半径计算位置等。有没有想过用 Redis 来实现附近的人?或者计算最优地图路径? pub/sub:功能是订阅发布功能,可以用作简单的消息队列。 Pipeline:可以批量执行一组指令,一次性返回全部结果,可以减少频繁的请求应答。 Lua:Redis 支持提交 Lua 脚本来执行一系列的功能。
关于事务?
单个 Redis 命令的执行是原子性的,但 Redis 没有在事务上增加任何维持原子性的机制,所以 Redis 事务的执行并不是原子性的。
事务可以理解为一个打包的批量执行脚本,但批量指令并非原子化的操作,中间某条指令的失败不会导致前面已做指令的回滚,也不会造成后续的指令不做。
如何用 Redis 做消息队列?
Redis 通过 list 数据结构来实现消息队列。主要使用到如下命令:
- lpush 和 rpush 入队列
- lpop 和 rpop 出队列
- blpop 和 brpop 阻塞式出队列
// 使用 list 生产消息
func ProducerMessageList(){
rand.Seed(time.Now().UnixNano())
log.Println("开启生产者。。。。")
for i := 0;i <= 10;i++ {
score := time.Now().Unix()
log.Println("正在生产一条消息...", score, i)
_,err := rdb.LPush(queueListKey,i).Result()
if err != nil {
log.Println(err)
}
time.Sleep(time.Duration(rand.Intn(3)) * time.Second)
}
}
// 使用 list 格式消费消息
func ConsumerMessageList() {
for {
// 设置一个 5 秒的超时时间
// 是因为阻塞读也不能一直阻塞,长时间的阻塞可能会被服务器端主动断开链接,然后会抛出异常,所以这里需要设置一个不是很长的阻塞超时时间。
value, err := rdb.BRPop(5 *time.Second,"queue:list").Result()
if err == redis.Nil{
// 查询不到数据
time.Sleep(1 * time.Second)
continue
}
if err != nil {
// 查询出错
time.Sleep(1 * time.Second)
continue
}
log.Println("消费到数据:", value, "当前时间是:", time.Now().Unix())
time.Sleep(time.Second)
}
}
缺点:
- 不支持重复消费:消费者拉取消息后,这条消息就从 List 中删除了,无法被其它消费者再次消费,即不支持多个消费者消费同一批数据。
- 消息丢失:消费者拉取到消息后,如果发生异常宕机,那这条消息就丢失了。
如何用 Redis 做延时队列?
实现的原理是将生产的数据使用 zadd 命令存入 Redis,将 score 设置成需要延时的时间戳。同时单独运行一个 goroutine,使用 zrangebyscore 命令去截取数据,数据的 score 为当前时间戳。这样一来,到达执行时间的数据将被取出,然后我们取第一个数据,使用 zrem 命令将数据移除队列,移除成功则表示该条消息允许被发送,否则,重新执行以上流程。
// 生产消息
func ProducerMessage() {
rand.Seed(time.Now().UnixNano())
log.Println("开启生产者。。。。")
for i := 0;i <= 5;i++ {
score := time.Now().Unix()
log.Println("正在生产一条消息...", score, i)
rdb.ZAdd("queue:message", redis.Z{
Score: float64(score + 1),// 秒级时间戳 +1,表示延时 1 秒
Member: i,
})
time.Sleep(time.Duration(rand.Intn(3)) * time.Second)
}
}
// 消费消息
func ConsumerMessage() {
log.Println("正在启动消费者...")
for {
// score := time.Now().UnixNano()
values, err := rdb.ZRangeByScore("queue:message", redis.ZRangeBy{
Min: "0",
Max: fmt.Sprint(time.Now().Unix()),
Offset: 0,
Count: 1,
}).Result()
if err != nil {
log.Fatalln(err)
// Redis 查询出错,延迟 1 秒继续
time.Sleep(time.Second)
continue
}
if len(values) == 0 {
// 没有数据,延迟 1 秒继续
// time.Sleep(time.Second)
continue
}
// 由于使用 zrangebyscore 的时候指定了 count = 1,因此此处理论上只会有一条数据
value := values[0]
num, err := rdb.ZRem("queue:message", value).Result()
if err != redis.Nil && err != nil {
log.Println(err)
time.Sleep(time.Second)
continue
}
if num == 1 {
log.Println("消费到数据:", value, "当前时间是:", time.Now().Unix())
// 模拟一个耗时的操作
time.Sleep(2 * time.Second)
}
}
}
如何用 Redis 做发布者订阅者模型 PubSub?
// 发布者
func ProducerMessagePubSub() {
rand.Seed(time.Now().UnixNano())
for i := 0; i <= 10; i++ {
log.Println("正在生产一条消息...", i)
r, err := rdb.Publish("queue:pubsub", i).Result()
if err != nil {
log.Println(err,r)
}
time.Sleep(time.Duration(rand.Intn(3)) * time.Second)
}
}
// 消费者
func ConsumerMessagePubSub(node int){
//订阅频道
pubsub := rdb.Subscribe("queue:pubsub")
// 用管道来接收消息
ch := pubsub.Channel()
// 处理消息
for msg := range ch {
log.Printf("当前节点:%d,消费到数据,channel:%s;message:%s\n", node, msg.Channel, msg.Payload)
}
}
Redis 是怎么持久化的?服务主从数据怎么交互的?
Redis 本身的机制是 AOF 持久化开启且存在 AOF 文件时,优先加载 AOF 文件;AOF 关闭或者 AOF 文件不存在时,加载 RDB 文件;加载 AOF/RDB 文件后,Redis 启动成功;AOF/RDB 文件存在错误时,Redis 启动失败并打印错误信息。
Redis 的数据回写机制是什么?
Redis 的数据回写机制分同步和异步两种:
- 同步回写即 SAVE 命令,主进程直接向磁盘回写数据。在数据大的情况下会导致系统假死很长时间,所以一般不是推荐的。
- 异步回写即 BGSAVE 命令,主进程 fork 后,复制自身并通过这个新的进程回写磁盘,回写结束后新进程自行关闭。由于这样做不需要主进程阻塞,系统不会假死,一般默认会采用这个方法。
全量同步
master 服务器会开启一个后台进程用于将 redis 中的数据生成一个 rdb 文件。与此同时,服务器会缓存所有接收到的来自客户端的写命令(包含增、删、改),当后台保存进程处理完毕后,会将该rdb文件传递给 slave 服务器,而 slave 服务器会将 rdb 文件保存在磁盘并通过读取该文件将数据加载到内存,在此之后 master 服务器会将在此期间缓存的命令通过 redis 传输协议发送给 slave 服务器,然后 slave 服务器将这些命令依次作用于自己本地的数据集上最终达到数据的一致性。
部分同步
在 redis 2.8 版本以前,并不支持部分同步,当主从服务器之间的连接断掉之后,master 服务器和 slave 服务器之间都是进行全量数据同步,但是从 redis 2.8 开始,即使主从连接中途断掉,也不需要进行全量同步,因为从这个版本开始融入了部分同步的概念。部分同步的实现依赖于在 master 服务器内存中给每个 slave 服务器维护了一份同步日志和同步标识,每个 slave 服务器在跟 master 服务器进行同步时都会携带自己的同步标识和上次同步的最后位置。当主从连接断掉之后,slave 服务器隔断时间(默认1s)主动尝试和 master 服务器进行连接,如果从服务器携带的偏移量标识还在 master 服务器上的同步备份日志中,那么就从 slave 发送的偏移量开始继续上次的同步操作,如果 slave 发送的偏移量已经不在 master 的同步备份日志中(可能由于主从之间断掉的时间比较长或者在断掉的短暂时间内 master 服务器接收到大量的写操作),则必须进行一次全量更新。在部分同步过程中,master 会将本地记录的同步备份日志中记录的指令依次发送给 slave 服务器从而达到数据一致。
Redis 的持久化方式有哪些?
Redis 提供了 RDB 和 AOF 两种持久化方式,RDB 是把内存中的数据集以快照形式写入磁盘,实际操作是通过 fork 子进程执行,采用二进制压缩存储;AOF 是以文本日志的形式记录 Redis 处理的每一个写入或删除操作。
RDB
RDB 是 Snapshot 快照存储,是默认的持久化方式。可理解为半持久化模式,即按照一定的策略周期性的将数据保存到磁盘。对应产生的数据文件为 dump.rdb,通过配置文件中的 save 参数来定义快照的周期:
RDB 有它的不足,就是一旦数据库出现问题,那么我们的 RDB 文件中保存的数据并不是全新的。从上次 RDB 文件生成到 Redis 停机这段时间的数据全部丢掉。
AOF
AOF(Append-Only File)比 RDB 方式有更好的持久化性。由于在使用 AOF 持久化方式时,Redis 会将每一个收到的写命令都通过 Write 函数追加到文件中,类似于 MySQL 的 binlog,缺点是相同规模的数据集,AOF 要大于 RDB,AOF 在运行效率上往往会慢于 RDB。当 Redis 重启是会通过重新执行文件中保存的写命令来在内存中重建整个数据库的内容。对应的设置参数为:
下面是来自官方的建议:
通常,如果你要想提供很高的数据保障性,那么建议你同时使用两种持久化方式。
如果你可以接受灾难带来的几分钟的数据丢失,那么你可以仅使用 RDB。
很多用户仅使用了 AOF,但是我们建议,既然 RDB 可以时不时的给数据做个完整的快照,并且提供更快的重启,所以最好还是也使用 RDB。
因此,我们希望可以在未来(长远计划)统一 AOF 和 RDB 成一种持久化模式。
Pipeline 有什么好处,为什么要用 Pipeline?
Redis 客户端与 server 通信,使用的是客户端-服务器(CS)模式;每次交互,都是完整的请求/响应模式。 这意味着通常情况下一个请求会遵循以下步骤:
- 客户端连接服务端,基于特定的端口,发送一个命令,并监听 Socket 返回,通常是以阻塞模式,等待服务端响应。
- 服务端处理命令,并将结果返回给客户端。
对 pipeline 的支持
pipeline 功能在命令行 CLI 客户端 redis-cli 中没有提供,也就是我们不能通过终端交互的方式使用pipeline。redis 的客户端,如 jedis、lettuce 等等都实现了对 pipeline 的支持。
pipeline 为我们节省了哪部分时间?
pipeline 在某些场景下非常有用,比如有多个 command 需要被“及时的”提交,而且他们对相应结果没有互相依赖,对结果响应也无需立即获得,那么 pipeline 就可以充当这种“批处理”的工具;而且在一定程度上,可以较大的提升性能:
- 我们使用 JedisPool 连接池,节省了建立连接 connection 的时间;
- pipeline 节省了多条命令的(发送命令到 server、server 返回结果)往返时间 RTT,包括多次网络 IO、系统调用的消耗。
pipeline 是万金油?
- pipeline 期间将“独占”connection,此期间将不能进行非“管道”类型的其他操作,直到 pipeline 关闭;如果你的 pipeline 的指令集很庞大,为了不干扰链接中的其他操作,你可以为 pipeline 操作新建 Client 连接,让 pipeline 和其他正常操作分离在 2 个 client 连接中。
- 使用 pipeline,如果发送的命令很多的话,建议对返回的结果加标签,当然这也会增加使用的内存。
什么是缓存穿透?
例如,对用户信息进行了缓存,但恶意攻击者使用不存在的用户 id 频繁请求接口,导致查询缓存不命中,然后穿透 DB 查询依然不命中。这时会有大量请求穿透缓存访问到 DB。
解决的办法是,对不存在的用户,在缓存中保存一个空对象进行标记,防止相同 ID 再次访问 DB。不过有时这个方法并不能很好解决问题,可能导致缓存中存储大量无用数据。那么使用 BloomFilter 过滤器,BloomFilter 的特点是存在性检测,如果 BloomFilter 中不存在,那么数据一定不存在;如果 BloomFilter 中存在,实际数据也有可能会不存在。或者做可预期的参数校验。
什么是缓存击穿?
某个热点数据失效时,大量针对这个数据的请求会穿透到数据源。 解决这个问题有如下办法:
- 可以使用互斥锁更新,保证同一个进程中针对同一个数据不会并发请求到 DB,减小 DB 压力。
- 使用随机退避方式,失效时随机 sleep 一个很短的时间,再次查询,如果失败再执行更新。
- 针对多个热点 key 同时失效的问题,可以在缓存时使用固定时间加上一个小的随机数,避免大量热点 key 同一时刻失效。
什么是缓存雪崩?
产生的原因是缓存挂掉,这时所有的请求都会穿透到 DB。 解决方法:
- 在批量往 Redis 存数据的时候,把每个Key的失效时间都加个随机值;或者设置热点数据永远不过期,有更新操作就更新缓存就好了;
- 使用快速失败的熔断策略,减少 DB 瞬间压力;
- 使用主从模式和集群模式来尽量保证缓存服务的高可用。