文章原创于公众号:程序猿周先森。本平台不定时更新,喜欢我的文章,欢迎关注我的微信公众号。
redis 是一个 key-value 存储系统。它支持存储的 value 类型相对更多,包括 string(字符串)、list(链表)、set(集合)、zset(sorted set – 有序集合) 和 hash(哈希类型)。这些数据类型都支持 push/pop、add/remove 及取交集并集和差集及更丰富的操作,而且这些操作都是原子性的。在此基础上,redis 支持各种不同方式的排序。为了保证效率,数据都是缓存在内存中。区别的是 redis 会周期性的把更新的数据写入磁盘或者把修改操作写入追加的记录文件,并且在此基础上实现了主从同步。简单来说 Redis 就是一个数据库,不过与传统数据库不同的是 Redis 的数据是存在内存中的,所以存写速度非常快,因此 Redis 被广泛应用于缓存方向。Redis 也经常用来做分布式锁。Redis 提供了多种数据类型来支持不同的业务场景。除此之外,Redis 支持事务、持久化、LUA 脚本、LRU 驱动事件、多种集群方案。
本篇文章将从下列几个方向讲解 Redis:
- 为什么要用 Redis 实现缓存?
- 为什么要用 Redis 而不用 map/guava 做缓存
- Redis 和 Memcached 的区别
- Redis 常见数据结构以及使用场景分析
- Redis 设置过期时间
- Redis 内存淘汰机制
- Redis 持久化机制
- Redis 事务
- 缓存雪崩和缓存穿透问题解决方案
- 如何解决 Redis 的并发竞争 Key 问题
- 如何保证缓存与数据库数据一致性
- 为什么要用 Redis 做缓存?
第一个问题先抛出来,既然选择使用 Redis 作缓存,其实主要从“高性能”和“高并发”来进行理解。
高性能
- 用户首次访问数据,从数据库读取,效率较低
- 将用户访问数据保存缓存中,二次读取则可以直接从缓存中读取,效率更高
- 数据库若数据发生改变,则同步更新缓存中数据即可。
因为从数据库读取数据,是从硬盘中读取数据,所以效率较低。如果将数据存入缓存中,二次读取从缓存读取,从缓存读取数据是直接操作内存,所以效率非常之高。
高并发
直接操作缓存能够承受的请求是远远大于直接访问数据库的,所以我们可以考虑把数据库中的部分数据转移到缓存中去,这样用户的一部分请求可以不用操作数据库,提高高并发能力。
为什么要用 Redis 而不用 map/guava 做缓存
缓存分为本地缓存和分布式缓存。以 Java 为例,使用自带的 map /guava 实现的是本地缓存,最主要的特点是轻量以及快速,生命周期随着 JVM 的销毁而结束。而且在多实例状态下缓存不具有唯一性。使用 Redis 作缓存称为分布式缓存,在多实例状态下共用一份缓存数据,缓存具有一致性。
Redis 和 Memcached 的区别
- Redis 支持常见数据类型:Redis 不仅仅支持简单的 key/value 类型的数据,同时还提供 string(字符串)、list(链表)、set(集合)、zset(sorted set – 有序集合) 和 hash(哈希类型) 等数据结构的存储。而 Memcache 只支持简单的数据类型 String。
- Redis 支持数据的持久化,可以将内存中的数据保持在磁盘中,重启的时候可以再次加载进行使用,而 Memecache 把数据全部存在内存之中。
- 集群模式:Memcached 没有原生的集群模式,需要依靠客户端来实现往集群中分片写入数据;但是 Redis 目前是原生支持 Cluster 模式的。
- Memcached 是多线程,非阻塞 IO 复用的网络模型;Redis 使用单线程的多路 IO 复用模型。
贴一张对比图可能看起来更加明显:
Redis 常见数据结构以及使用场景分析
String
- 常用命令:set、get、decr、incr、mget 等。
String 数据结构是简单的 Key-Value 类型,Value 可以是 string 或者数字。常规 Key-Value 缓存应用;常规计数:博客数,阅读数等。
Hash
- 常用命令:hget、hset、hgetall 等。
Hash 特别适合用于存储对象。
List
- 常用命令:lpush、rpush、lpop、rpop、lrange 等。
链表是 Redis 最重要的数据结构之一,Redis List 为一个双向链表,支持反向查找和遍历,更方便操作,不过带来了额外的内存开销。
Set
- 常用命令:sadd、spop、smembers、sunion 等。
Set 其实和 List 都是列表的选项,Set 是可以自动去重的。当需要存储一个不出现重复数据的列表数据,Set 是一个最好的选择。你可以基于 Set 轻易实现交集、并集、差集的操作。
Sorted Set
- 常用命令:zadd、zrange、zrem、zcard 等。
Sorted Set 相比 Set 增加了一个权重参数 Score,使得集合中的元素能够按 Score 进行有序排列。
Redis 设置过期时间
Redis 可以对存储在缓存中的数据设置过期时间。作为一个缓存数据库,这是非常实用的功能。之前写过一篇前后端交互的文章讲过,Token 或者一些登录信息,尤其是短信验证码都是有时间限制的,按照传统的数据库处理方式,一般都是自己判断过期,这样无疑会严重影响项目性能。而有一个好的方案其实就是将这些验证信息存入 Redis 设置过期时间,如果设置了存活时间 30 分钟,那么半小时之后这些数据就会从 Redis 中进行删除。那说到删除,Redis 是如果做到对这些数据进行删除的呢:
- 定期删除:Redis 默认是每隔 100ms 就随机抽取一些设置了过期时间的 Key,检查其是否过期,如果过期就删除。为什么是随机抽取而不是检查所有 key?因为你如果设置的 key 成千上万,每 100 毫秒都将所有存在的 key 检查一遍,会给 CPU 带来比较大的压力。
- 惰性删除:定期删除可能会导致很多过期 Key 到了时间并没有被删除掉。用户在获取 key 的时候,redis 会检查一下,这个 key 如果设置过期时间那么是否过期了,如果过期就删除这个 key。
但是只是使用定期删除 + 惰性删除的删除机制还是存在一个致命问题:如果定期删除漏掉了很多过期 Key,而且用户长时间也没有使用到这些过期 key,就会导致这些过期 key 堆积在内存里,导致 Redis 内存块被消耗殆尽。所以有了 Redis 内存淘汰机制的诞生。
Redis 内存淘汰机制
Redis 提供 6 种数据淘汰策略:
- volatile-lru:从已设置过期时间的数据集中挑选最近最少使用的数据淘汰。
- volatile-ttl:从已设置过期时间的数据集中挑选将要过期的数据淘汰。
- volatile-random:从已设置过期时间的数据集中任意选择数据淘汰。
- allkeys-lru:当内存不足以容纳新写入数据时移除最近最少使用的 key。
- allkeys-random:从数据集中任意选择数据淘汰。
- no-enviction:当内存不足以容纳新写入数据时,新写入操作会报错。
Redis 持久化机制
怎么保证 Redis 宕机之后再重启 Redis 后数据可以进行恢复?很多时候我们需要持久化数据也就是将内存中的数据写入到硬盘里面。Redis 持久化支持两种不同的持久化操作。接下来,我们来简单聊聊 Redis 的两种持久化机制 RDB 和 AOF。
快照持久化(RDB)
RDB 持久化是指在指定的时间间隔内将内存中的数据集快照写入磁盘,实际操作过程是 fork 一个子进程,先将数据集写入临时文件,写入成功后,再替换之前的文件,用二进制压缩存储。RDB 是 Redis 默认的持久化方式,会在对应的目录下生产一个 dump.rdb 文件,重启会通过加载 dump.rdb 文件恢复数据。
优点:
- 只有一个文件 dump.rdb,方便持久化;
- 容灾性好,一个文件可以保存到安全的磁盘;
- 性能最大化,fork 子进程来完成写操作,让主进程继续处理命令,所以是 IO 最大化(使用单独子进程来进行持久化,主进程不会进行任何 IO 操作,保证了 redis 的高性能 );
- 如果数据集偏大,RDB 的启动效率会比 AOF 更高。
缺点:
- 数据安全性低。
- 如果当数据集较大时,可能会导致整个服务器停止服务几百毫秒,甚至是 1 秒钟。
快照持久化是 Redis 默认采用的持久化方式,在 redis.conf 配置文件中已经进行配置:
- save 900 1:在 15 分钟内,如果至少有 1 个 key 发生变化,Redis 就会自动触发 BGSAVE 命令创建快照。
- save 300 10:在 5 分钟内,如果至少有 10 个 key 发生变化,Redis 就会自动触发 BGSAVE 命令创建快照。
- save 60 10000:在 1 分钟之后,如果至少有 10000 个 key 发生变化,Redis 就会自动触发 BGSAVE 命令创建快照。
AOF 持久化
AOF 持久化是以日志的形式记录服务器所处理的每一个写、删除操作,查询操作不会记录,以文本的方式记录,文件中可以看到详细的操作记录。她的出现是为了弥补 RDB 的不足(数据的不一致性),所以它采用日志的形式来记录每个写操作,并追加到文件中。Redis 重启的会根据日志文件的内容将写指令从前到后执行一次以完成数据的恢复工作。与快照持久化相比,AOF 持久化的实时性更好,因此已成为主流的持久化方案。默认情况下 Redis 没有开启 AOF 持久化,可以通过设置 appendonly 参数开启:
- appendonly yes
开启 AOF 持久化后每执行一条会更改 Redis 中的数据的命令,Redis 就会将该命令写入硬盘中的 AOF 文件。AOF 文件的保存位置和 RDB 文件的位置相同,都是通过 dir 参数设置的,默认的文件名是 appendonly.aof。
在 Redis 的配置文件中存在三种不同的 AOF 持久化方式,它们分别是:
- appendfsync always:每次有数据修改发生时都会写入 AOF 文件
- appendfsync everysec:每秒钟同步一次,将多个写命令同步到硬盘
- appendfsync no:让操作系统决定何时进行同步
用户可以使用 appendfsync everysec 选项,让 Redis 每秒同步一次 AOF 文件,这样 Redis 性能几乎不会受到影响,而且这样即使出现宕机,用户最多只会丢失一秒之内产生的数据。当硬盘忙于执行写入操作的时候,Redis 还会优雅的放慢自己的速度以便适应硬盘的最大写入速度。
优点:
- 数据安全性更高,AOF 持久化可以配置 appendfsync 属性
- 通过 append 模式写文件,即使中途服务器宕机,可以通过 redis-check-aof 工具解决数据一致性问题。
- AOF 机制的 rewrite 模式。
缺点:
- AOF 文件比 RDB 文件大,且恢复速度慢;数据集大的时候,比 rdb 启动效率低。
- 根据同步策略的不同,AOF 在运行效率上往往会慢于 RDB。
Redis 4.0 对于持久化机制的优化
- Redis 4.0 支持 RDB 和 AOF 的混合持久化,不过默认是关闭状态。
- 开启混合持久化,AOF 重写的时候就直接把 RDB 的内容写到 AOF 文件开头。
- AOF 里面的 RDB 部分是压缩格式不再是 AOF 格式,可读性较差。
Redis 事务
- 命令:MULTI、EXEC、WATCH 等。
事务提供了一种按顺序地执行多个命令的机制。并且在事务执行期间,服务器会将事务中的所有命令都执行完毕,然后才去处理其他客户端的命令请求。事务总是具有原子性、一致性和隔离性,并且当 Redis 运行在某种特定的持久化模式下时,事务也具有持久性。
缓存雪崩
缓存处理过程: 接收到请求请求,先从缓存中取数据,取到直接返回结果,取不到时从数据库中取,数据库取到更新缓存,并返回结果,数据库也没取到,那直接返回空结果。
缓存雪崩: 缓存雪崩是指缓存中数据大批量到过期时间,而查询数据量巨大,引起数据库压力过大甚至 down 机。
解决办法:
- 缓存数据的过期时间设置随机,防止同一时间大量数据过期现象发生。
- 如果缓存数据库是分布式部署,将热点数据均匀分布在不同搞得缓存数据库中。
- 设置热点数据永远不过期。
缓存穿透
简介: 缓存穿透是指缓存和数据库中都没有的数据,而用户不断发起请求,如发起为 id 为“-1”的数据或 id 为特别大不存在的数据。这时的用户很可能是攻击者,攻击会导致数据库压力过大。
解决办法:
- 接口层增加校验,如用户鉴权校验,id 做基础校验,id<= 0 的直接拦截;
- 从缓存取不到的数据,在数据库中也没有取到,这时也可以将 key-value 对写为 key-null,缓存有效时间可以设置 30 秒
缓存击穿
简介: 缓存击穿是指缓存中没有但数据库中有的数据,这时由于并发用户特别多,同时读缓存没读到数据,又同时去数据库去取数据,引起数据库压力瞬间增大
解决方法:
- 设置热点数据永远不过期。
- 加互斥锁
解决 Redis 并发竞争 Key 问题
问题描述: 多客户端同时并发写一个 key,可能本来应该先到的数据后到了,导致数据版本错了。或者是多客户端同时获取一个 key,修改值之后再写回去,只要顺序错了,数据就错了。一个 key 的值是 1,本来按顺序修改为 2,3,4,最后是 4,但是顺序变成了 4,3,2,最后变成了 2.
我个人认为比较好的方案是分布式锁 + 时间戳:
1. 整体技术方案
这种情况,主要是准备一个分布式锁,大家去抢锁,加锁的目的实际上就是把并行读写改成串行读写的方式,从而来避免资源竞争。利用 SETNX 非常简单地实现分布式锁。
2. 时间戳
由于 key 的操作需要顺序执行,所以需要保存一个时间戳判断顺序。假设系统 B 先抢到锁,将 key1 设置为 {ValueB 7:05}。接下来系统 A 抢到锁,发现自己的 key1 的时间戳早于缓存中的时间戳(7:00<=7:05),那就不做 set 操作了。
3. 什么是分布式锁
分布式锁可以基于很多种方式实现,比如 zookeeper、redis 等,不管哪种方式实现,基本原理是不变的:用一个状态值表示锁,对锁的占用和释放通过状态值来标识。
保证缓存与数据库双写时的数据一致性
可能对大部分来说最先想到的方案就是读请求和写请求串行化,串到一个内存队列里去。但是这个方案有着特别大的缺点:它也会导致系统的吞吐量大幅度降低,用比正常情况下多几倍的机器去支撑线上的一个请求。
最经典的缓存 + 数据库读写的模式。
读的时候,先读缓存,缓存没有的话,就读数据库,然后取出数据后放入缓存,同时返回响应。
更新的时候,先更新数据库,然后再删除缓存。
如果喜欢我的文章,欢迎关注我的个人公众号:程序猿周先森。
**