目录
一.高级特性
1.发布订阅模式
publish 发布
subscribe 订阅
unsubscribe 取消订阅
2.redis事务
redis的单个命令是原子性的,但是多个命令我们该怎么保证原子性呢
事务命令
- multi 开启一个事务 会把后续的操作命令放进队列
- exec 执行事务
- discard 取消事务
- watch 相当于cas乐观锁机制
发生异常情况
- 编译异常:在exec前出现编译异常,事务会discarded也就是会取消这个事务。
- 运行异常:异常前的可以执行成功,异常的无法执行。(我们会发现这跟我们想的原子性不同)
那么如何保证原子性呢? 引入lua脚本
3.lua
为什么使用lua脚本
- 一次发送多个命令,减少网络开销
- 保证原子性
- lua file 可以实现命令的复用
redis中执行lua脚本
redis> eval lua-script key-num [key1 key2 key3.....] [value1value2 value3 ....]
- eval代表执行Lua语言的命令。
- lua-script代表Lua语言脚本内容。
- key-num表示参数中有多少个key,需要注意的是Redis中key是从1开始的,如果没有key的参数,那么写0。
- [key1 key2 key3...]是key作为参数传递给Lua语言,也可以不填,但是需要和key-num的个数对应起来。
- [value1 value2 value3...…]这些参数传递给Lua语言,它们是可填可不填的。
在lua中执行redis命令
redis.call(command, key [param1,param2...])
- command是命令,包括set、get、del等。
- key是被操作的键。
- param1,param2...代表给key的参数。
命令 set zs 500 用lua脚本执行的话是
192.168.115.132:6379> eval "return redis.call('set',KEYS[1],ARGV[1])" 1 zs 500
OK
192.168.115.132:6379> get zs
"500"
4.redis为什么这么快
- 与内存交互,没有磁盘IO
- IO多路复用
- 单线程
- 底层数据结构巧妙设计 比如跳表
二.过期策略
因为我们的redis是一个内存型数据库,我们的数据都是放在内存里面的!但是内存是有大小的! redis,里有个很重要的配置文件,redis.conf里面有 个配置,
# maxmemory <bytes> redis占用的最大内存
如果我们不淘汰,那么它的数据就会满,满了肯定就不能再放数据,发挥不了redis的作用!
过期策略就是把之前设置的expired的key,把已经过期了的淘汰掉。
如果把redis比作冰箱,数据比作菜,那么过期策略就是要把过期了的菜扔掉。
惰性过期
惰性过期是一种被动过期,就是只有当我们访问或者操作key的时候才会判断它是否过期。如果过期了就淘汰。
该策略就可以最大化地节省CPU资源,因为它平时不会去判断,所以也没有啥cpu损耗,因为只有访问的时候我才去判断一下!
但是却对内存非常不友好。因为你不实时过期了,该过期删除的就可能一直堆积在内存里面!极端情况可能出现大量的过期key没有再次被访问,从而不会被清除,占用大量内存。
特点:对cpu比较优化 但是对内存不友好。
源码(expireIfNeeded db.c文件下1302行)
int expireIfNeeded(redisDb *db, robj *key) {
if (!keyIsExpired(db,key)) return 0;
if (server.masterhost != NULL) return 1; /* Delete the key */
server.stat_expiredkeys++;
propagateExpire(db,key,server.lazyfree_lazy_expire);
notifyKeyspaceEvent(NOTIFY_EXPIRED, "expired",key,db->id);
int retval = server.lazyfree_lazy_expire ? dbAsyncDelete(db,key) : dbSyncDelete(db,key);
if (retval) signalModifiedKey(NULL,db,key);
return retval;
定期过期
惰性这种又会可能导致大量无效数据堆积在内存里面,我们总得有个办法来解决吧!不能让他一直堆在内存里面啊!
所以我们就有了一个定期过期策略,虽然实时性比不上定时的,但是也足够解决垃圾数据大量堆积在内存的这种情况!
redis的hash默认使用的是ht[0],ht[1]不会初始化和分配空间。
哈希表dictht是用链地址法来解决碰撞问题的。如果节点数量比哈希表的大小要大很多的话,那么哈希表就会退化成多个链表,哈希表本身的性能优势就不再存在,在这种情况下需要扩容。 Redis里面的这种操作叫做rehash。
那么它怎么做rehash的? ht[0],ht[1]
定期策略是定时去扫描一下key有没有过期。
定多久扫描一次由谁来决定?
redis 时间事件 ServerCron 隔多久去执行一次,hz(配置文件可配)每秒会执行多少次。hz10(默认的)代表100ms执行一次ServerCron。
ServerCron
具体逻辑:
1.扫描的不是所有的key 设置了过期时间的key
2. hash桶的维度去扫描,取到20(可配)个key为止 或者最多取400个桶
第一个hash桶是20个 扫描的就是20个
第一个hash桶是10个,第二个hash桶是50个 会扫描60
3.去删除过期的key
4.扫描的数据如果 过期比例大于10% 说明我有很多的过期数据
5.继续去扫描 按照2 3
6.最多扫描16次
4,5,6步骤是为了达到时间与空间的平衡。
流程图
三.淘汰策略
淘汰策略和过期策略不同,过期策略只会把已经过期了的数据淘汰,而淘汰策略是再我们内存满了的情况下不得已淘汰掉好的数据。
redis提供8种淘汰策略,淘汰策略可以在配置文件中配置
# maxmemory-policy noeviction
上面的是默认的淘汰策略,这种策略就是不会淘汰任何数据,相应的我们也不可以再往里添加数据,只可可读。
下面主要介绍两种最重要:LRU LFU
LRU
LRU,Least Recently Used。淘汰最久未使用的 衡量标准 时间
传统的LRU一般实现方式: hash+双向链表,访问我就放到链头 淘汰我根据末尾淘汰机制。
但是redis并没有采用这种传统的LRU,redis是一种伪LRU的实现方式。
我们先来看一个对象 redisObject ,这个对象就是我们redis的所有数据结构的对外对象!那么它里面有个字段叫做lru!!
源码:server.h 620行
typedef struct redisObject {
unsigned type:4;
unsigned encoding:4;
//LRU 存储的是访问时候的秒单位时间的后24bit!
//LFU 前16位存储时间 后8位代表次数
unsigned lru:LRU_BITS;
/* LRU time (relative to global lru_clock) or \*
LFU data (least significant 8 bits frequency
\* and most significant 16 bits access time). */
int refcount;
void *ptr;
} robj;
这个lru在LRU算法的时候代表的是这个数据的访问时间的秒单位!!但是只有24bit,无符号位,所以这个lru记录的是它访问时候的秒单位时间的后24bit!
那么,我们知道了这个线索,那么我们要得到这个对象多久没访问了,我 们是不是就很简单,用我当前的时间-这个对象的访问时间就可以了!!! 但是这个访问时间是秒单位时间的后24bit!所以,也是用当前的时间的秒单位的后24bit去减!
因为只记录的是24bit的时间所以有一种情况,比如lru记录的是2021年的4月,但他只能记录24bit,所以他只记录的4月,而当前时间是2022年1月,如果用(当前时间-lru)那么会是个负数。所以这种时候要用(当前时间+一轮的时间-lru)。
我们去看下源码evict.c文件
unsigned long long estimateObjectIdleTime(robj *o) {
//获取秒单位时间的最后24位
unsigned long long lruclock = LRU_CLOCK();
//因为只有24位,所有最大的值为2的24次方-1
//超过最大值从0开始,所以需要判断lruclock(当前系统时间)跟缓存 对象的lru字段的大小
if (lruclock >= o->lru) {
//如果lruclock>=robj.lru,返回lruclock-o->lru,再转换 单位
return (lruclock - o->lru) * LRU_CLOCK_RESOLUTION;
} else {
//否则采用lruclock + (LRU_CLOCK_MAX - o->lru),
//得到对象的值越小,返回的值越大,越大越容易被淘汰
return (lruclock + (LRU_CLOCK_MAX - o->lru)) * LRU_CLOCK_RESOLUTION;
}
}
所以,我们可以总结下我们的结论
- Redis数据对象的LRU用的是server.lruclock这个值,server.lruclock又是每隔100ms生成的系统时间的秒单位的后24位!所以server.lruclock可以理解为延迟了100ms的当前时间秒单位的后24位!
- 用server.lruclock 与 对象的lru字段进行比较,因为server.lruclock只获取了当前秒单位时间的后24位,所以肯定有个轮询。所以,我们会判断server.lruclock跟对象的lru字段进行比较,如果 server.lruclock>obj.lru,那么我们用server.lruclock-obj.lru,否则server.lruclock+(LRU_CLOCK_MAX-obj.lru),得到lru越小,那么返回的数据越大,相差越大的就会被淘汰!
当内存满了就会随机抽样5个(可配)然后再进行LRU淘汰。
LFU
LFU,Least Frequently Used。淘汰最不常使用的 衡量标准 使用次数
LFU最常见的问题就是时效性问题。时效性问题就是
有一个热数据,现在在用的 100次
还有1个老数据 去年很火 10000次
这样会导致,新的数据进不去,老的数据出来。
redis如何解决时效性问题?
当被用作LFU的时候,redisObject.lru 前16bit代表的是时间+8bit代表的是次数。
这样就可以访问到一个key多久没访问,和它的访问次数了。我们都用过qq把,qq的会员过期了就会掉成长值的把。同理,我们可以可以让它根据多久没访问,然后减key的使用次数呢。比如一分钟没有使用就让它的次数减一。这样是不是就解决了时效性问题呢。
配置文件有个配置,这个配置就代表每分钟没访问减少多少
lfu-decay-time
源码
unsigned long LFUDecrAndReturn(robj *o) {
//lru字段右移8位,得到前面16位的时间
unsigned long ldt = o->lru >> 8;
//lru字段与255进行&运算(255代表8位的最大值),
//得到8位counter值
unsigned long counter = o->lru & 255;
//如果配置了lfu_decay_time,用LFUTimeElapsed(ldt) 除以配置的值
//LFUTimeElapsed(ldt)源码见下
//总的没访问的分钟时间/配置值,得到每分钟没访问衰减多少
unsigned long num_periods = server.lfu_decay_time ? LFUTimeElapsed(ldt) / server.lfu_decay_time : 0;
if (num_periods)
//不能减少为负数,非负数用couter值减去衰减值
counter = (num_periods > counter) ? 0 : counter - num_periods;
return counter;
}
LFUTimeElapsed
//对象ldt时间的值越小,则说明时间过得越久
unsigned long LFUTimeElapsed(unsigned long ldt) {
//得到当前时间分钟数的后面16位
unsigned long now = LFUGetTimeInMinutes();
//如果当前时间分钟数的后面16位大于缓存对象的16位
//得到2个的差值
if (now >= ldt) return now-ldt;
//如果缓存对象的时间值大于当前时间后16位值,则用65535-ldt+now得到差值
return 65535-ldt+now;
}
所以,LFU减逻辑我们可以总结下:
- 我们可以根据对象的LRU字段的前16位得到对象的访问时间(分钟),根据跟系统时间比较获取到多久没有访问过!
- 根据lfu-decay-time(配置),代表每分钟没访问减少多少counter,不能减成负数
当时如果后8位存储的是次数的话,那么它最多也只能存储255次,那么显然是不够的。
既然不够,那么让它不用每次都加就可以了,能不能让它值越大,我们加的越慢就能解决这个问题!!!
对,redis就是这么干的,但是,redis还加了个东西,让你们自己能主宰它加的速率!!这个东西就是lfu-log-factor!它配置的越大,那么对象的访问次数就会加的越慢!!
源码 (evict.c 328行)
uint8_t LFULogIncr(uint8_t counter) {
//如果已经到最大值255,返回255 ,8位的最大值
if (counter == 255) return 255;
//得到随机数(0-1)
double r = (double)rand()/RAND_MAX;
//LFU_INIT_VAL表示基数值(在server.h配置)
double baseval = counter - LFU_INIT_VAL;
//如果达不到基数值,表示快不行了,baseval =0
if (baseval < 0) baseval = 0;
//如果快不行了,肯定给他加counter
//不然,按照几率是否加counter,同时跟baseval与lfu_log_factor相关
//都是在分子,所以2个值越大,加counter几率越小
double p = 1.0/(baseval*server.lfu_log_factor+1);
if (r < p) counter++;
return counter;
}
所以,LFU加的逻辑我们可以总结下:
- 如果已经是最大值,我们不再加!因为到达255的几率不是很高!可以支撑很大很大的数据量!
- counter属于随机添加,添加的几率根据已有的counter值和配置lfu-log-factor相关,counter值越大,添加的几率越小,lfu-log-factor配置的值越大,添加的几率越小!
四.持久化机制
我们都知道redis是内存数据库,所以当我们的电脑宕机了,数据就丢失了,这很不安全。所以redis还有持久化机制。
RDB Redis DataBase
RDB Redis DataBase 记录快照
redis默认的持久化方式 通过fork()创建子进程异步的生成doum.rdb文件来实现持久化。
什么时候会更新dump.rdb
- 主动:执行save(会阻塞其他命令)或者bgsave命令会主动的更新dump.rdb文件。
- 被动:配置里有个几秒更新了多少个数据的配置或者关闭redis客户端时(shutdown)会触发这个持久化机制
优势:紧凑(rdb是一种压缩文件),适合备份和灾难恢复生成文件过程不影响主进程大数据集恢复速度较快
不足:不能实时持久化,可能丢失数据
AOF Append Only File
AOF Append Only File 记录日志
配置文件中appendonly no所以默认是关闭的 我们可以手动的打开。会生成aof文件,保存的是我们的操作指令。当我们执行一次写命令时,就会保存到aof文件中,可以设置同步时间,常用的是设置成1s同步一次
aof配置
#开关(默认关闭)appendonly no#文件名
appendfilename "appendonly.aof"
appendfsync
no表示不执行fsync,由操作系统保证数据同步到磁盘always表示每次写入都执行fsync,以保证数据同步到磁盘
everysec(默认)表示每秒执行一次fsync,可能会导致丢失这1s数据
重写机制
因为aof文件大小是有限的, 比如我们有一个计数器一直的incr;如果每次+1都存储的话会很浪费aof文件的空间。所以他会有重写机制,重写机制就是根据我们的redis现存的数据重写成aof文件。比如我们incr了100次,那么我重写时只记录最终结果100就好了。
如果当我们正在重写的时候,还在执行的操作命令是否会丢失?
不会的,aof有一个缓存,aof缓存会记录当前执行操作,然后当重写完成后,再把缓存中追加进去。
重写什么时候会触发
auto - aof - rewrite - percentage 100 离上次重写的文件的大小是不是大于一倍auto - aof - rewrite - min - size 64 mb 必须大于 64M 才会触发重新 后面的重写不会根据这个参数
如果RDB和AOF同时存在,会使用AOF来恢复数据,因为AOF的一致性更强,它最多只会丢失1s的数据。
五.缓存穿透、缓存击穿、缓存雪崩
读多写少用缓存,写多读少用队列
1.缓存穿透
缓存穿透:缓存穿透是指缓存中没有、数据库中也没有的数据,用户不断的请求,这个请求会先到我们的缓存也就是redis中拿数据,但是这时我们的缓存中并没有这个数据,然后这个请求会直接的访问我们的数据库,但是我们的数据库中也没有。这种用户多半是攻击者
解决方案:
1.布隆过滤(bloom filter):类似于哈希表的一种算法,用所有可能的查询条件生成一个bitmap,在进行数据库查询之前会使用这个bitmap进行过滤,如果不在其中则直接过滤,从而减轻数据库层面的压力。
2.空值缓存:把第一次查询不到的数据,按该key和空值放入缓存,并设置较短的过期时间(因为该值大概率与业务无关,所以无需存储过久)。
3.封IP (把可能是故意恶性操作的用户封禁)
2.缓存雪崩
缓存雪崩:缓存雪崩是指缓存服务挂了或者缓存中的数据同时失效,导致大量请求直接访问我们的数据库,从而导致给我们的数据库造成压力。
解决方案:
1.因为缓存雪崩大概率是大量热点数据同时过期导致的,那么我们在设置过期时间的时候把过期时间交错开,这样可以在一定程度上避免缓存雪崩。
2.缓存定时预先更新,避免同时失效
3.加互斥锁或者使用队列,针对同一个key只允许一个线程到数据库查询
3.缓存击穿
缓存击穿:缓存击穿是指缓存中没有但数据库中有的数据,这是由于我们系统的并发量很大,同时有大量请求访问一个缓存中没有的数据,所以又同时访问我们的数据库,给数据库瞬间造成压力。
缓存击穿实际上是缓存雪崩的一个特例;击穿与雪崩的区别在于:击穿是对于某一特定的热点数据(相当于缓存中的一个点,击穿肯定是通过一个点来实现击穿),而雪崩是大量的数据。
解决方案:
4.什么是缓存数据一致性问题?怎么解决的?
什么是数据一致性,在并发环境下,可能会导致redis跟DB的数据产生不一致
如果是更新缓存
1.线程A 把DB值从1更新成2,去更新缓存(还没执行)
2.线程B把DB值从2更新成3
3.线程B缓存更新成了3
4.线程A又把值变成了2
我们看,DB是3.而redis是2,导致了数据不一致
先删除缓存行不行
1.线程A删除缓存,再去更新DB(1-2)
2.线程B来查询缓存,发现缓存不存在,我从DB获取值更新到redis,redis变成1
3.线程A把DB值从1更新成2
后删除缓存行么
1.线程A请求缓存,没有缓存,从DB拿到1,还没更新到缓存
2.线程B将1更新为2,并且删除缓存,DB的值为2
3.线程A更新缓存,redis为1
所以,不管怎么样都会有缓存一致性问题,那么怎么解决呢,就是延时双删!我在更新DB之前先删除缓存,更新DB之后再删除缓存!
为了解决后删除缓存的场景,所以,需要延时!