Redis
一、概述
-
Redis 是速度非常快的非关系型(NoSQL)内存键值数据库,可以存储键和五种不同类型的值之间的映射。
-
键的类型只能为字符串,值支持五种数据类型:字符串、列表、集合、散列表、有序集合。
-
Redis 支持很多特性,例如将内存中的数据持久化到硬盘中,使用复制来扩展读性能,使用分片来扩展写性能。
-
只要作为缓存,存储经常访问的数据或者经常更改的数据。
二、数据类型
数据类型 | 存储值 | 操作 | 应用 |
---|---|---|---|
STRING | 字符串、整数 或者浮点 数 | 字符串或字符串一部分执行操、整数或者浮点数执行自增或者自减操作(set,get,decr,incr,mget) | 常规计数、微博数、粉丝数等 |
LIST | 列表 | 两端压入或者弹出元素、对单个或者多个元素进行修剪、保留以一个范围内的元素(lpush,rpush,lpop,rpop,lrange) | 双向链表、关注列表、粉丝列表、消息列;lrange可以实现分页查询(微博下拉分页) |
SET | 无序集合 | 添加、获取、移除单个元素、检查一个元素是否存在于集、计算交集、并集、差集、从集合里面随机获取元素。去重(sadd,spop,smembers,sunion) | 共同关注、共同粉丝、共同喜好等功能、list排重功能 |
HASH | 键值对的无序散列表 | 添加、获取、移除单个键值对、获取所有键值对检查某个键是否存在(hget,hset,hgetall) | 适合存储对象、用户信息、商品信息(购物车);后期可以仅仅修改对象中某个字段的值 |
ZSET | 有序集合 | 添加、获取、删除元素、根据分值范围或者成员来获取、计算一个键的排名(zadd,zrange,zrem,zcard)增加了一个权重参数score,可以依据其进行有序排列 | 实时排行、礼物排行榜、在线用户列表 |
zset 的数据结构
zset底层的存储结构包括ziplist或skiplist,在同时满足以下两个条件的时候使用ziplist,其他时候使用skiplist,两个条件如下:
- 有序集合保存的元素数量小于128个
- 有序集合保存的所有元素的长度小于64字节
当ziplist作为zset的底层存储结构时候,每个集合元素使用两个紧挨在一起的压缩列表节点来保存,第一个节点保存元素的成员,第二个元素保存元素的分值。
当skiplist作为zset的底层存储结构的时候,使用skiplist按序保存元素及分值,使用dict来保存元素和分值的映射关系。
ziplist 结构
ziplist作为zset的存储结构时,格式如下图,紧挨着的是元素memeber和分值socore,整体数据是有序格式。
skiplist数据结构
skiplist作为zset的存储结构,整体存储结构如下图,核心点主要是包括一个dict对象和一个skiplist对象。dict保存key/value,key为元素,value为分值;skiplist保存的有序的元素列表,每个元素包括元素和分值。两种数据结构下的元素指向相同的位置。
三、Redis存储结构
3.1 字典
- redis的存储结构从外层往内层依次是redisDb、dict、dictht、dictEntry。
- redis的Db默认情况下有15个,每个redisDb内部包含一个dict的数据结构。
- redis的dict内部包含dictht的数组,数组个数为2,主要用于hash扩容使用。
- dictht内部包含dictEntry的数组,可以理解就是hash的桶,然后如果冲突通过挂链法解决,冲突的时候将欣新节点添加到表头位置。
3.1.1 字典的数据结构
typedef struct dict {
// 特定类型的函数,针对不同类型的键值对创建多态字典而设置的
// 指向dictType结构的指针,每一个dictType结构保存了一组用于操作特定类型键值对的函数
dictType *type;
// 私有数据,保存了需要传给那些类型特定函数的可选参数
void *privdata;
// 哈希表,有两个哈希表,进行扩容的时候使用
dictht ht[2];
// rehash 索引,当rehash不在进行时,值为 -1
long rehashidx; /* rehashing not in progress if rehashidx == -1 */
unsigned long iterators; /* number of iterators currently running */
} dict;
/* This is our hash table structure. Every dictionary has two of this as we
* implement incremental rehashing, for the old to the new table.
* 使用渐进的 rehash 操作,将旧的键值对 rehash 到 另一个 dictht 上面
*/
typedef struct dictht {
dictEntry **table; // 哈希表数组,数组中没用元素有指向一个 dictEntry结构的指针
unsigned long size; //哈希表大小
unsigned long sizemask; // 哈希表大小掩码,用于计算索引值 等于 size - 1
unsigned long used; // 哈希表已经拥有的结点数量
} dictht;
typedef struct dictEntry {
// 键
void *key;
// 值
union {
void *val;
uint64_t u64;
int64_t s64;
double d;
} v;
// 指向下一个哈希表结点,形成链表,用来解决冲突问题
struct dictEntry *next;
} dictEntry;
3.1.2 哈希算法
// 使用字典设置的哈希函数,计算键 key 的哈希值
hash = dict->type->hashFunction(key);
// 使用哈希表的 sizemask 属性和哈希值,计算出索引值
// 根据情况不同, ht[x] 可以是 ht[0] 或者 ht[1]
index = hash & dict->ht[x].sizemask;
3.1.3 rehash
哈希表的键值对会增加或减少,为了让哈希表负载因子维持在一个合理的范围之内,当哈希表保存的键值对太多或者太少时,程序要对哈希表的大小进行相应的扩展或者收缩。
- 为字典的ht[1]哈希表分配空间,这个空间大小取决于要执行的操作:
如果执行的是扩展操作,则ht[1]的大小为第一个大于等ht[0].used*2的2^n;
如果执行的收缩操作,则ht[1]的大小为第一个大于等于ht[0].used的2^n; - 将保存在ht[0]中的所有键值对rehash到ht[1]上面:rehash指的是重新计算键的哈希值和索引值,然后将键值对放置到ht[1]的指定位置上。
- 当ht[0]包含的所有键值对都迁移到ht[1]之后,释放ht[0],将ht[1]设置为ht[0],并在ht[1]新创建一个空白哈希表,为下一次rehash做准备 (交换角色)
3.1.4 渐进式的 rehash
rehash是分多次,渐进式的完成的。当服务器包含很多键值对,要一次性的将这些键值对全部rehash到ht[1] 中,庞大的操作量会加重服务器的负担
渐进式的完成就是把拷贝结点数据的过程平摊到后续的操作中,而不是一次性拷贝;所谓平摊到后续的操作中,就是对节点操作,例如再次插入,查找,删除,修改时都会进行拷贝。
要想实现这个过程,一个hash结构必须要有以下字段:
- 两个hash表。一个表拷贝到另一个表的容器
- 一个标识rehashidx来表明是否在进行rehash中。如果是,那么对节点的操作启动rehash过程。
何时启动rehash?当hash结构的第一个hash表ht[0]达到扩容条件就可以启动了。此时重新调整并分配新的空间,将hash结构的第二个hash表ht[1]指向这个空间。
rehash的过程很简单,具体过程为:
- 通过rehashidx索引找到要搬移节点的位置,如果是空,则向后找
- 计算要搬移节点的hash值,得出要插入到新hash表的位置
- 写入到新节点中,如果节点是链式的,则还要搬移后面所有在链表中的节点
- 在 rehash 进行期间, 每次对字典执行添加、删除、查找或者更新操作时, 程序除了执行指定的操作以外, 还会顺带将 ht[0] 哈希表在 rehashidx 索引上的所有键值对 rehash 到 ht[1]
- 当 rehash 工作完成之后, 程序将 rehashidx 属性的值增一
- 更新hash表计数、
- 当ht[0] 的所有键值对都rehash完毕,rehashidx = -1,表示完成
- 让ht[0]指向新的hash容器。这样ht[0]永远是那个要被搬移的对象,dt[1]是搬移过程中的中转
渐进式rehash执行期间的哈希表操作
因为在渐进式rehash的过程中,字典会同时使用ht[0]和ht[1]两个哈希表,所以在渐进式rehash进行期间,字典的删除、查找、更新等操作都是在两个表上进行的。
例如,查找操作会先在ht[0]上进行,如果没找到再在ht[1]上进行。添加操作的键值对会一律保存到ht[1]中,这一措施保证ht[0]包含的键值对只会减少不会增加。
3.2 跳表
跳表就是一个多级索引的链表,将链表每一层抽取结点形成索引。Redis 跳表也是 zset(有序集合)的实现方式。在查找时,从上层指针开始查找,找到对应的区间之后再到下一层去查找。
几种数据集合查询的比较
数据结构 | 实现原理 | key查 询方式 | 查找效率 | 存储大小 | 插入、删除效率 |
---|---|---|---|---|---|
Hash | 哈希表 | **支持单key ** | 接近O(1) | 小,除了数据没有额外的存储 | O(1) |
B+树 | 平衡二叉树扩展而来 | 单key,范围,分页 | O(Log(n) | 除了数据,还多了左右指针,以及叶子节点指针 | O(Log(n),需要调整树的结构,算法比较复杂 |
跳表 | 有序链表扩展而来 | 单key,分页 | O(Log(n) | 除了数据,还多了指针,但是每个节点的指针小于<2,所以比B+树占用空间小 | O(Log(n),只用处理链表,算法比较简单 |
3.2.1 为什么 Redis 使用跳表?不使用B+索引
B+树叶子节点存储数据,非叶子节点存储索引,查询数据时首先要根据索引查询到叶子节点,再到叶子节点所指向的地址去磁盘读取数据;这涉及到 I /O 操作。
B+树索引的原理
因为B+树的原理是 叶子节点存储数据,非叶子节点存储索引,B+树的每个节点可以存储多个关键字,它将节点大小设置为磁盘页的大小,充分利用了磁盘预读的功能。每次读取磁盘页时就会读取一整个节点,每个叶子节点还有指向前后节点的指针,为的是最大限度的降低磁盘的IO;因为数据在内存中读取耗费的时间是从磁盘的IO读取的百万分之一
跳表的优点
而Redis是基于内存的不需要I/O操作,并且跳表与B+树相比有很少的内存占用,B+树有2个以上的指针,而跳表的指针数平均为1/(1-p)(p为结点具有的指针概率,如Redis为 1/4);在删除和修改操作上跳表只需修改相邻结点的指针,而B树要分裂或合并结点来调整树;且跳表具有较简单的实现(基于链表)
四、为什么使用Redis作为缓存系统
- 高性能:当做用户和数据库之间的缓存,可以有效提高访问速度。用户从数据库中读取数据是从硬盘上读取数据,而从缓存中读取数据就是从内存中读取数据,这样速度更快。
- 高并发:当多个用户并发的修改或者访问数据库时,尤其是访问数据库数据库为了保持数据的一致性会加锁,这会严重影响数据。
五、Redis过期时间设置
Redis中有个设置时间过期的功能,即对存储在 redis 数据库中的值可以设置一个过期时间。作为一个缓存数据库,这是非常实用的。如我们一般项目中的 token 或者一些登录信息,尤其是短信验证码都是有时间限制的,按照传统的数据库处理方式,一般都是自己判断过期,这样无疑会严重影响项目性能。
我们 set key 的时候,都可以给一个 expire time,就是过期时间,通过过期时间我们可以指定这个 key 可以存活的时间。
如何删除过期的Key?
- 定期删除:每隔100ms 随机抽取 一些过期时间的key,检查是否删除,若过期则删除。(如果数据量过大遍历过期的key 会带来性能损耗)。这种方法能够及时释放内存,但是大规模的删除会消耗CPU资源
- 惰性删除:当程序读写一个 key 时,来判断这个key是否过期,如果过期再将其删除。这样能够避免CPU在固定时间去排查过期的key,但是内存存在大量过期的key时,会造成内存空间的浪费。
- 按规则删除:结合两种策略结合起来。Redis 中懒删除是内置策略,可以对定时删除设置执行时间和频率。(hz 选项:设置定期频率,每秒执行多少次,越大CPU消耗越大;最大内存:超过最大内存触发一个清除策略。
六、Redis内存淘汰策略
当客户端会发起需要更多内存的申请的时候,Redis检查内存使用情况,如果实际使用内存已经超出maxmemory,Redis就会根据用户配置的淘汰策略选出无用的key;
-
LRU淘汰
LRU(Least recently used,最近最少使用)算法根据数据的历史访问记录来进行淘汰数据,其核心思想是“如果数据最近被访问过,那么将来被访问的几率也更高”
- dictGetRandomKeys随机获取指定数目的dictEntry,默认选择5个键。
- 将获取的的dictEntry进行下sort按照最近时间进行排序。
- 选择最近使用时间最久远的数据进行过期
- 每次过期的数据其实是采样的结果数据中的最近未被访问数据而非全局的。
-
TTL淘汰
- Redis 数据结构中保存了键值对过期时间的表,即 redisDb.expires。和 LRU 数据淘汰机制类似,TTL 数据淘汰机制是这样的:从过期时间的表中随机挑选几个键值对,取出其中 TTL (剩余过期时间)最大的键值对(将要过期的)淘汰。同样你会发现,redis 并不是保证取得所有过期时间的表中最快过期的键值对,而只是随机挑选的几个键值对
-
随机淘汰
- 在随机淘汰的场景下获取待删除的键值对,随机找hash桶再次hash指定位置的dictEntry
配置文件中的淘汰策略
- 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:禁止驱逐数据,也就是说当内存不足以容纳新写入数据时,新写入操作会报错。可以保证数据不被丢失
4.0版本后增加以下两种:
- volatile-lfu:从已设置过期时间的数据集(server.db[i].expires)中挑选最不经常使用的数据淘汰
- allkeys-lfu:当内存不足以容纳新写入数据时,在键空间中,移除最不经常使用的key
如何选择?
- LRU 和 Random:知道某些数据的访问频率较高或者较低,以及无法预测数据的访问频率时,可使用 lru,访问概率相等时 可以使用random
- TTL:如果研发者需要通过设置不同的ttl来判断数据过期的先后顺序,可采用ttl
- allkeys 和 volatie:知道或确定一些数据能够淘汰掉时或者希望某些确定数据能长期被保存,可以设值过期时间,淘汰机制就可以采用 volatie。设置expire会消耗额外的内存,如果计划避免Redis内存在此项上的浪费,或者不知到哪些确切的数据时要保存或者淘汰。可以选用allkeys
七、Redis 持久化
持久化就是将内存写入到硬盘上,为了设备故障或者重启机器而恢复数据和数据备份
快照持久化(RDB)
-
可以通过设置快照来保存某个时间点上的数据副本,快照创立后可以使用快照进行备份,可以复制到其他服务器上从而创建其他服务器的副本(对于主从结构的Redis集群来说)
-
也可以将快照留在原地以重启服务器的时候使用
-
默认持久化方式
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(append-only file)持久化
-
实时性更好,每执行一定条数的数据命令,Redis就会将该命令写入到硬盘中的AOF文件中
-
存储在
appendonly.aof
文件中和rdb文件一样的位置 -
AOF工作流程操作:命令写入 (append)、文件同步(sync)、文件重写(rewrite)、重启加载 (load)
-
将所有写入命令追加到
aof_buf
缓冲区当中 -
缓冲区根据对应的策略向硬盘做同步操作
-
AOF文件越来越大则需要对AOF进行重写,达到压缩的目的
为什么重写?旧的AOF文件包含很多无效命令,重写使用进程内数据直接生成,新的AOF只确保最终数据的写入命令。多条写命令可以合并为1个,降低占用空间;(手动触发 调用bgrewriteaof命令、自动触发)
-
服务器重启可以加载进行数据恢复
-
-
三种配置方式:
appendfsync always
: 每次有数据修改发生时都会写入AOF文件,这样会严重降低Redis的速度appendfsync everysec
:每秒钟同步一次,显示地将多个写命令同步到硬盘appendfsync no
:让操作系统决定何时进行同步
4.0 持久机制的优化
混合模式,AOF重写的时候直接把RDB的内容直接写到AOF文件头。可以结合 RDB 和 AOF 的优点, 快速加载同时避免丢失过多的数据。缺点是 AOF 里面的 RDB 部分是压缩格式不再是 AOF 格式,可读性较差。
八、Redis 事务
可以一次执行多个命令,本质是一组命令的集合。一个事务中的所有命令都会序列化,按顺序串行化的执行而不会被其他命令插入
一个队列中,一次性、顺序性、排他性的执行一系列命令
Redis 事务的三个阶段
- 开启:以MULTI 开启一个事务
- 入队:将多个命令入队到事务中,接到这些命令不会立即执行,而是放到等待执行的事务队列里面
- 执行:由EXEC命令触发事务
Redis 事务的三个特征
-
单独的隔离操作:事务中的所有命令都会序列化、按顺序地执行。事务在执行的过程中,不会被其他客户端发送来的命令请求所打断。
-
不支持回滚(不保证原子性):
-
如果一个指令在入队的时候就出错(比如语法错误),这个错误会立即返回给客户端; 这个事务接着就会被抛弃掉
127.0.0.1:6379> MULTI OK 127.0.0.1:6379> INCR a b c (error) ERR wrong number of arguments for 'incr' command 127.0.0.1:6379> EXEC (error) EXECABORT Transaction discarded because of previous errors. 127.0.0.1:6379>
-
至于那些在 EXEC命令执行之后所产生的错误, 并没有对它们进行特别处理: 即使事务中有某个/某些命令在执行时产生了错误, 事务中的其他命令仍然会继续执行
为什么不支持回滚?
如果你有使用关系式数据库的经验, 那么 “Redis 在事务失败时不进行回滚,而是继续执行余下的命令”这种做法可能会让你觉得有点奇怪。
以下是这种做法的优点:
- Redis 命令只会因为错误的语法而失败(并且这些问题不能在入队时发现),或是命令用在了错误类型的键上面:这也就是说,从实用性的角度来说,失败的命令是由编程错误造成的,而这些错误应该在开发的过程中被发现,而不应该出现在生产环境中。
- 因为不需要对回滚进行支持,所以 Redis 的内部可以保持简单且快速。
-
-
关系型数据库支持回滚:一致性??
有种观点认为 Redis 处理事务的做法会产生 bug , 然而需要注意的是, 在通常情况下, 回滚并不能解决编程错误带来的问题。 举个例子, 如果你本来想通过 INCR命令将键的值加上 1 , 却不小心加上了 2 , 又或者对错误类型的键执行了 INCR, 回滚是没有办法处理这些情况的。
Watch 指令
watch指令类似于乐观锁,在事务提交时,如果watch监控的多个KEY中任何KEY的值已经被其他客户端更改,则使用EXEC执行事务时,事务队列将不会被执行,同时返回Nullmulti-bulk应答以通知调用者事务执行失败。
127.0.0.1:6379> WATCH mykey
OK
###### 客户端A ##############
127.0.0.1:6379> INCR mykey # mykey 这个key 被更改
(integer) 4
####### 客户端B #############
127.0.0.1:6379> MULTI # 开启事务
OK
127.0.0.1:6379> SET mykey 6 # 在事务中对监视的key进行修改
QUEUED
127.0.0.1:6379> EXEC # 修改失败
(nil)
其实开启 watch 在多个客户端的时候,可能存在竞争,watch会监视 key 有没有被改动过。比如 如果客户端 A 和 B 都读取了键原来的值, 比如 10 , 那么两个客户端都会将键的值设为 11 , 但正确的结果应该是 12 才对
使用上面的代码,假设客户端A的代码和客户端B的代码同时发生。 如果在 WATCH 执行之后, EXEC 执行之前, 有其他客户端修改了 mykey
的值, 那么当前客户端的事务就会失败。 程序需要做的, 就是不断重试这个操作, 直到没有发生碰撞为止(不断尝试这个事务)。
这种形式的锁被称作乐观锁, 它是一种非常强大的锁机制。 并且因为大多数情况下, 不同的客户端会访问不同的键, 碰撞的情况一般都很少, 所以通常并不需要进行重试。
WATCH 使得 EXEC 命令需要有条件地执行: 事务只能在所有被监视键都没有被修改的前提下执行, 如果这个前提不能满足的话,事务就不会被执行。
WATCH 命令可以被调用多次。 对键的监视从 WATCH 执行之后开始生效, 直到调用 EXEC 为止。
用户还可以在单个 WATCH 命令中监视任意多个键, 就像这样:
redis> WATCH key1 key2 key3
OK
当 EXEC 被调用时, 不管事务是否成功执行, 对所有键的监视都会被取消。
另外, 当客户端断开连接时, 该客户端对键的监视也会被取消。
使用无参数的 UNWATCH 命令可以手动取消对所有键的监视。 对于一些需要改动多个键的事务, 有时候程序需要同时对多个键进行加锁, 然后检查这些键的当前值是否符合程序的要求。 当值达不到要求时, 就可以使用 UNWATCH 命令来取消目前对键的监视, 中途放弃这个事务, 并等待事务的下次尝试。
使用 WATCH 实现 ZPOP
WATCH 可以用于创建 Redis 没有内置的原子操作。举个例子, 以下代码实现了原创的 ZPOP 命令, 它可以原子地弹出有序集合中分值(score)最小的元素:
WATCH zset
element = ZRANGE zset 0 0
MULTI
ZREM zset element
EXEC
程序只要重复执行这段代码, 直到 EXEC 的返回值不是 nil-reply 回复即可
九、缓存雪崩和缓存穿透
一般的数据处理流程:
用户发出请求,如请求数据经过缓存处理的话,一般先从缓存中取数据,若存在直接返回结果;若不存在,则丛数据库获取并更新缓存,返回结果。若数据库和缓存都没有则返回空值。
缓存雪崩:
缓存在同一时间大面积实效(如数据大批量达到过期时间),后面的请求都会落在数据库上,造成数据库在短时间内承受不了大量的请求而崩掉。
解决方案:
- 缓存数据的过期时间设置为随机,防止同一时间大量数据过期的现象发生
- 缓存数据库是分布式存储,将热点数据均匀分布在不同服务器上
- 设置热点数据永远不过期
缓存穿透:
缓存穿透说简单点就是大量请求的 key 根本不存在于缓存中,导致请求直接到了数据库上,根本没有经过缓存这一层。如黑客存中不存在的 key 发起大量请求,导致大量请求落到数据库。
解决方案
- 接口层增加参数校验
- 设置无效缓存key:数据库没有数据时,写一个空的key的到缓存中: key - null。针对经常变化的请求key,可以设置无效的key的缓存时间短一点避免存内存中存在大量的无效key
- 布隆过滤器增加在用户和缓存之间,存放所有请求的合法值。用户请求首先会判断请求的值是否存在布隆过滤器中,不存在直接返回给客户端。
缓存击穿:
缓存中没有数据但是数据库中有数据,这个时候出现大量并发用户读缓存读不到,去数据库中读数据造成数据库压力瞬间增大。
解决方案:
- 互斥锁,若缓存中无数据,对数据库的读取数据加锁
- 设置数据不过期
十、Redis 为什么那么快
-
redis是基于内存的数据库,请求都是基于内存的操作;
-
数据结构简单,没有像B树那么复杂的数据结构;
-
单线程,避免了上下文切换、多线程竞争
-
采用非阻塞多路 I/O 复用技术可以让单个线程高效的处理多个连接请求,尽量减少网络 IO 的时间消耗
十一、Redis 如何保证与数就可读写一致
可以偶尔不一致。如果必须要保持一致:读写串行化,读请求和写请求串行化,保存到一个内存队列中
Cache Aside Pattern
- 读的时候先读缓存,如果缓存没有读数据库,读出数据放入缓存,同时返回相应
- 更新的时候,先更新数据库,再删除缓存
读写问题分析
为什么是删除缓存,而不是更新缓存?
(1)线程安全角度: 如果有请求A和请求B进行更新操作:线程A更新数据库,线程B更新数据库,线程B更新缓存,线程A更新缓存。按道理来说应该A先更新缓存,但是B却更新了缓存,这就导致了脏数据。
(2)业务场景角度:
- 写场景多,读场景少的业务需求。这种方法导致频繁更新数据
- 写入数据库的值,不是直接写入缓存的,而是经过一系列负责的计算再写入缓存。
比如可能更新了某个表的一个字段,然后其对应的缓存,是需要查询另外两个表的数据并进行运算,才能计算出缓存最新的值的。
- 频繁更新的缓存需要查询其他表才能做计算,更新的代价大。
- 访问频率不高,存在大量冷数据。若删除缓存的话只需要用缓存时才会算缓存,并不需要依赖于对应的数据库频繁更新而增加开销
先删除缓存再更新数据库?
缓存不一致分析:先删掉缓存的话,同时一个请求更新操作,另一个请求查询操作
问题:数据发生了变更,先删除了缓存,然后要去修改数据库,此时还没修改。一个请求过来,去读缓存,发现缓存空了,去查询数据库,查到了修改前的旧数据,放到了缓存中。随后数据变更的程序完成了数据库的修改。数据库和缓存中的数据不一样了。
解决思路:延时双删策略:先删除缓存、再修改数据库,休眠1s再淘汰缓存。可以将 1s内造成的脏数据再次删除。(1s如何确定:确保读请求结束后,写请求可以删除读请求造成的缓存脏数据)但是读请求读的还是脏数据。
如果mysql的读写分离架构怎么办?
两个请求,一个请求A进行更新操作,另一个请求B进行查询操作。
(1)请求A进行写操作,删除缓存
(2)请求A将数据写入数据库了,
(3)请求B查询缓存发现,缓存没有值
(4)请求B去从库查询,这时,还没有完成主从同步,因此查询到的是旧值
(5)请求B将旧值写入缓存
(6)数据库完成主从同步,从库变为新值
上述情形,就是数据不一致的原因。还是使用双删延时策略,只是,睡眠时间修改为在主从同步的延时时间基础上,加几百ms。
采用这种同步淘汰策略,吞吐量降低怎么办?
将第二次删除作为异步的。 自己起一个线程,异步删除。这样,写的请求就不用沉睡一段时间后了,再返回。这么做,加大吞吐量。
先更新数据库,再更新缓存
缓存不一致分析: 后删除缓存后删除缓存失败
问题:先更新数据库,再删除缓存,删除缓存失败?如果删除缓存失败了,那么会导致数据库中是新数据,缓存中是旧数据,数据就出现了不一致。
解决思路:重试删除方案
- 更新缓存中的数据
- 缓存删除失败
- 将要删除的key发至消息队列
- 消费自己的信息,获取到要删除的key
- 继续重试删除的操作,直到成功
肯能造成业务代码的大量侵入,可以启动一个订阅程序去订阅数据库的binlog,获取需要操作的程序。
十二、Redis 和 Memcached 区别
Redis 相比 Memcached 来说,拥有更多的数据结构,能支持更丰富的数据操作。如果需要缓存能够支持更复杂的结构和操作, Redis 会是不错的选择。
Redis 原生支持集群模式:
在 redis3.x 版本中,便能支持 Cluster 模式,而 Memcached 没有原生的集群模式,需要依靠客户端来实现往集群中分片写入数据。
性能对比:
由于 Redis 只使用单核,而 Memcached 可以使用多核,所以平均每一个核上 Redis 在存储小数据时比 Memcached 性能更高。而在 100k 以上的数据中,Memcached 性能要高于 Redis,虽然 Redis 最近也在存储大数据的性能上进行优化,但是比起 Remcached,还是稍有逊色。
参考
- https://www.jianshu.com/p/bfecf4ccf28b
- https://blog.youkuaiyun.com/diweikang/article/details/94406186
- https://www.cnblogs.com/DeepInThought/p/10720132.html
- https://cyc2018.github.io/CS-Notes/#/notes/Redis