单线程
redis单线程?:
redis4之前是单线程,redis4之后慢慢变成多线程。
为什么以前是单线程:
主要是指Redis的网络IO和键值对读写是由一个线程来完成的,Redis在处理客户端的请求时包括获取 (socket 读)、解析、执行、内容返回 (socket 写) 等都由一个顺序串行的主线程处理,这就是所谓的“单线程”。这也是Redis对外提供键值存储服务的主要流程。但Redis的其他功能,比如持久化RDB、AOF、异步删除、集群数据同步等等,其实是由额外的线程执行的。
Redis命令工作线程是单线程的,但是,整个Redis来说,是多线程的;
单线程为什么这么快?
1.基于内存操作,处理快
2.数据结构简单
3.多路复用和非阻塞IO:单线程依旧快
4.避免上下文切换:单线程不用切换这样比较快
redis4以前采用单线程的原因:
1 使用单线程模型是 Redis 的开发和维护更简单,因为单线程模型方便开发和调试;
2 即使使用单线程模型也并发的处理多客户端的请求,主要使用的是IO多路复用和非阻塞IO;
3 对于Redis系统来说,主要的性能瓶颈是内存或者网络带宽而并非 CPU。
怎么会变成多线程?
1.硬件发展了,都是多核,
2.单线程痛点,一般key很小,但是一个大key(后来采用lazy free惰性删除),这样在删除大key的时候(类似了sychonized锁),就**比较卡顿.多线程提出了,**例如unlink key/flush all来异步删除。这样提升了性能。
lazy free的本质就是把某些cost(主要时间复制度,占用主线程cpu时间片)较高删除操作,从redis主线程剥离让bio子线程来处理,极大地减少主线阻塞时间。从而减少删除导致性能和稳定性问题。
网络IO:
redis性能影响因素:网络IO(用多线程处理)+CPU+内存;CPU+内存不是重要因素。redis性能瓶颈是,网络IO。使用多线程来处理网络请求,而后使用单线程处理命令
五种IO模型:
对于LINUX一切都是文件,文件描述符。
每个连接都会有一个文件句柄。IO多路复用就是一个线程监听多个句柄,一旦有文件句柄就绪就能够通知到对应应用程序进行。没有文件句柄就绪就会阻塞。使用了select-poll-epoll进行。
select(),循环方式(不行),监听方式(有人来就处理1对1);响应式方式(1对多);一个线程处理多个IO。
redis7.0默认是关闭了多线程的模式,需要进行开启。如果你在实际应用中,发现Redis实例的CPU开销不大但吞吐量却没有提升,可以考虑使用Redis7的多线程机制,加速网络处理,进而提升实例的吞吐量
总结:
- Redis自身出道就是优秀,基于内存操作、数据结构简单、多路复用和非阻塞 I/O、避免了不必要的线程上下文切换等特性,在单线程的环境下依然很快;
- 但对于大数据的 key 删除还是卡顿厉害,因此在 Redis 4.0 引入了多线程unlink key/flushall async 等命令,主要用于 Redis 数据的异步删除;
- 而在 Redis6/7中引入了 I/O 多线程的读写,这样就可以更加高效的处理更多的任务了,Redis 只是将 I/O 读写变成了多线程,而命令的执行依旧是由主线程串行执行的,因此在多线程下操作 Redis 不会出现线程安全的问题。
BIGKEY:
大批量往redis中插入数据。
案例编写:
for((i=1;i<=100*10000;i++)); do echo “set k i v i v ivi” >> /tmp/redisTest.txt ;done;
cat /tmp/redisTest.txt | /opt/redis-7.0.0/src/redis-cli -h 127.0.0.1 -p 6379 -a 111111 --pipe
禁止使用:keys * ,flushall ,flushdb等命令需要被禁止:**在security这个地方禁止。**禁止 **rename-command keys “”**这种方式就能
又想用查询keys
scan,hscan,zscan
scan cursor [MATCH pattern] [COUNT count]
游标迭代器,scan
- cursor : 游标位。
- MATCH:匹配规则。
- COUNT:多少数量。
大key实际是value大。
什么危害:
- 内存分布不均,集群迁移困难
- 超时删除,大key删除作梗
- 网络流量拥塞
如何产生的:
- 社交类:粉丝数量。
- 汇总报表:日积月累的增加:
如何发现:
- redis-cli --bigkeys进行计算。
- memory usage key 计算字节数。
如何删除大key:
非string的不要使用del,其他数据类型使用渐进式删除。
例如:
string :del或者unlink
hash:hscan+hdel:先删除filed再删除key
list:ltrim
set:sscan+srem+del
zset:scan+zremrangebyrank+del
思路,先变小,再del删除。
layfree:
默认使用阻塞的删除方法,打开redis.conf参看lazy freeing。
双写一致性:
面试题目:
双写一致性:
情景:
如果redis中有数据:需要和数据库中的值相同
如果redis中没有数据:数据库中的值要是最新值,且准备写回redis
缓存按照操作来分,细分两种:
- 只读缓存,不进行回写。
- 读写缓存,立刻回写redis缓存。
读写缓存:
1.同步直写:很快不要有延时,
2.异步缓写:可以有一定的延时,
一般数据库中有,再写回redis,下次访问redis就可以了。这种思路比较简单。存在问题,访问量大的时候,很多请求进入mysql,很多请求回写redis。使用双检加锁策略。
双检加锁:
多个线程访问mysql数据库需要限流,访问数据库的时候只能由一个线程访问,然后通过两次检查,使用一个互斥锁来进行限定,类似单例模式。
user=(User) redisTemplate.opsForValue().get(key);
if(user==null){
sychronized(UserService.class){
user=(User) redisTemplate.opsForValue().get(key);
if(user==null){
//todo
}else{
//回写redis。
redisTemplate
}
}
}
数据库和缓存一致性:
给缓存设置过期时间,定期清理缓存并回写,是保证最终一致性的解决方案。
给缓存设置过期时间,所有写操作以数据库为准,对缓存只是做最大努力,如果数据库写成功,缓存更新失败,那么只要达到过期时间,后面的读者自然会更新缓存,达到一致性,切记,要以mysql的写入库为准。
两种情况,可以停机,不可以停机。
可以停机的一致性:挂牌,然后单线程更新缓存。
不能停机的一致性:下面四种可能
- 先更新数据库,再更新缓存
- 先更新缓存,再更新数据库
- 先删除缓存,再更新数据库
- 先更新数据库,再删除缓存
1.先更新数据库,再更新缓存:
mysql更新后,回写redis可能造成redis和mysql数据不一致。
- 1.读到redis脏数据
- 2.多线程回写的时候会由先后问题。
【先更新数据库,再更新缓存】,A、B两个线程发起调用
【正常逻辑】
1 A update mysql 100
2 A update redis 100
3 B update mysql 80
4 B update redis 80【异常逻辑】多线程环境下,A、B两个线程有快有慢,有前有后有并行
1 A update mysql 100
3 B update mysql 80
4 B update redis 80
2 A update redis 100
最终结果,mysql和redis数据不一致,o(╥﹏╥)o,
mysql80,redis100
2.先更新缓存,再更新数据库:
(1)不推荐,最后都是以mysql为准。
(2)多线程下有可能出现问题。
【先更新缓存,再更新数据库】,A、B两个线程发起调用
【正常逻辑】
1 A update redis 100
2 A update mysql 100
3 B update redis 80
4 B update mysql 80【异常逻辑】多线程环境下,A、B两个线程有快有慢有并行
A update redis 100
B update redis 80
B update mysql 80
A update mysql 100
----mysql100,redis80
3.先删除缓存,再更新数据库:
多线程情况下。
(1)请求A进行写操作,删除redis缓存后,工作正在进行中,更新mysql…A还么有彻底更新完mysql,还没commit
(2)请求B开工查询,查询redis发现缓存不存在(被A从redis中删除了)
(3)请求B继续,去数据库查询得到了mysql中的旧值(A还没有更新完)
(4)请求B将旧值写回redis缓存
(5)请求A将新值写入mysql数据库
上述情况就会导致不一致的情形出现。
时间 | 线程A | 线程B | 出现的问题 |
---|---|---|---|
t1 | 请求A进行写操作,删除缓存成功后,工作正在mysql进行中… | ||
t2 | |||
1 缓存中读取不到,立刻读mysql,由于A还没有对mysql更新完,读到的是旧值 |
2 还把从mysql读取的旧值,写回了redis | 1 A还没有更新完mysql,导致B读到了旧值
2 线程B遵守回写机制,把旧值写回redis,导致其它请求读取的还是旧值,A白干了。 |
| t3 | A更新完mysql数据库的值,over |
| redis是被B写回的旧值,
mysql是被A更新的新值。
出现了,数据不一致问题。 |
总结一下:
先删除缓存,再更新数据库 | 如果数据库更新失败或超时或返回不及时,导致B线程请求访问缓存时发现redis里面没数据,缓存缺失,B再去读取mysql时,从数据库中读取到旧值,还写回redis,导致A白干了,o(╥﹏╥)o |
---|
延迟双删处理情况3:
在A回写redis的时候,对redis中的数据再进行删除。这样可以去除B写的数据。
4.先更新数据库,再删除缓存:
先更新数据库,再删除缓存
时间 | 线程A | 线程B | 出现的问题 |
---|---|---|---|
t1 | 更新数据库中的值… | ||
t2 | |||
缓存中立刻命中,此时B读取的是缓存旧值。 | A还没有来得及删除缓存的值,导致B缓存命中读到旧值。 | ||
t3 | 更新缓存的数据,over | ||
先更新数据库,再删除缓存 | 假如缓存删除失败或者来不及,导致请求再次访问redis时缓存命中,读取到的是缓存旧值。 |
---|
保证最终一致性:
1.可以把要删除的缓存值或者是要更新的数据库值暂存到消息队列中(例如使用Kafka/RabbitMQ等)。
2.当程序没有能够成功地删除缓存值或者是更新数据库值时,可以从消息队列中重新读取这些值,然后再次进行删除或更新。
3.如果能够成功地删除或更新,我们就要把这些值从消息队列中去除,以免重复操作,此时,我们也可以保证数据库和缓存的数据一致了,否则还需要再次进行重试
4.如果重试超过的一定次数后还是没有成功,我们就需要向业务层发送报错信息了,通知运维人员。
如何选择方案?利弊如何
在大多数业务场景下,优先使用先更新数据库,再删除缓存的方案(先更库→后删存)。理由如下:
1先删除缓存值再更新数据库,有可能导致请求因缓存缺失而访问数据库,给数据库带来压力导致打满
2如果业务应用中读取数据库和写缓存的时间不好估算,那么,延迟双删中的等待时间就不好设置。
多补充一句:如果使用先更新数据库,再删除缓存的方案
如果业务层要求必须读取一致性的数据,那么我们就需要在更新数据库时,先在Redis缓存客户端暂停并发读请求,等数据库更新完、缓存值删除后,再读取数据,从而保证数据一致性,这是理论可以达到的效果,但实际,不推荐,因为真实生产环境中,分布式下很难做到实时一致性,一般都是最终一致性,请大家参考
策略 | 高并发多线程条件下 | 问题 | 现象 | 解决方案 |
---|---|---|---|---|
先删除redis缓存,再更新mysql | 无 | 缓存删除成功但数据库更新失败 | Java程序从数据库中读到旧值 | 再次更新数据库,重试 |
有 | 缓存删除成功但数据库更新中… | |||
有并发读请求 | 并发请求从数据库读到旧值并回写到redis,导致后续都是从redis读取到旧值 | 延迟双删 | ||
先更新mysql,再删除redis缓存 | 无 | 数据库更新成功,但缓存删除失败 | Java程序从redis中读到旧值 | 再次删除缓存,重试 |
有 | 数据库更新成功但缓存删除中… | |||
有并发读请求 | 并发请求从缓存读到旧值 | 等待redis删除完成,这段时间有 | ||
数据不一致,短暂存在。 |
双写案例落地:
mysql出现修改,可以返回给redis,需要有一种技术能够监听到mysql的变动,通知redis。Cannel用于处理这种情况。
Cannel:
作用:
1数据库镜像数据库实时备份
2索引构建和实时维护(拆分异构索引、倒排索引等)
3业务cache刷新
4带业务逻辑的增量数据处理
cannel原理:
mysql主从复制原理:
MySQL的主从复制将经过如下步骤:
1、当 master 主服务器上的数据发生改变时,则将其改变写入二进制事件日志文件中;
2、salve 从服务器会在一定时间间隔内对 master 主服务器上的二进制日志进行探测,探测其是否发生过改变,
如果探测到 master 主服务器的二进制事件日志发生了改变,则开始一个 I/O Thread 请求 master 二进制事件日志;
3、同时 master 主服务器为每个 I/O Thread 启动一个dump Thread,用于向其发送二进制事件日志;
4、slave 从服务器将接收到的二进制事件日志保存至自己本地的中继日志文件中;
5、salve 从服务器将启动 SQL Thread 从中继日志中读取二进制日志,在本地重放,使得其数据和主服务器保持一致;
6、最后 I/O Thread 和 SQL Thread 将进入睡眠状态,等待下一次被唤醒;
**Cannel原理:**模仿了mysql的复制原理。
canel案例解析:
1.开启二进制文件
2.创建canel用户
3.给canel用户权限
数据结构案例落地:
面试题目:
1.在移动应用中,需要统计每天的新增用户数和第2天的留存用户数;在电商网站的商品评论中,需要统计评论列表中的最新评论;
2.在签到打卡中,需要统计一个月内连续打卡的用户数;
3.在网页访问记录中,需要统计独立访客(Unique Visitor,UV)量。
常见设计:
- 聚合统计:SDIFF差集、SUNION并集、SINTER交集、SINTERCARD交集
用于判断用户之间的关系,
- 排序统计:zset,
用于排行,
- 二值统计:bitmap,
例如打卡相关的。
- 基数统计:hyperloglog,
不重复的元素的数据,UV、PV、DAU、MAU、用于统计巨量数量,不用太准确。
UV、PV、DAU、MAU介绍:hyperloglog
UV(unique visitor): 独立访客,需要考虑去重
PV(page view):页面访问量,也就是不用去重
DAU(daily active user):日活跃用户量
MAU(Monthly active user):月活跃用户量
HyperLogLog:
只需要12kb就能统计2^64这么多个数据的基数。
牺牲绝对准确率来换取空间,误差在0.81%左右
Redis使用了214=16384个桶,按照上面的标准差,误差为0.81%,精度相当高。Redis使用一个long型哈希值的前14个比特用来确定桶编号,剩下的50个比特用来做基数估计。而26=64,所以只需要用6个比特表示下标值,在一般情况下,一个HLL数据结构占用内存的大小为16384*6/ 8= 12kB,Redis将这种情况称为密集(dense)存储。
BitMap:
由0,1构成的bit数组,
相关统计信息:
日活统计
连续签到打开
最近一周的活跃用户
统计指定用户一年之中的登录天数
某用户按照一年365天,哪几天登录,哪几天没有登录,全年中登录天数由多少?
布隆过滤器:
问题:
用于解决判断在集合中是否存在。快速的具体数据是否在集合中。
布隆过滤器:数组+hash函数的结构。一般set会有一定小瑕疵所以需要布隆过滤器。布隆过滤器特点:一个元素如果判断结果:存在时,元素不—定存在,但是判断结果为不存在时,则—定不存在。
布隆过滤器特点:
高效插入和查询,占用空间少,返回结果是不确定性,不够完美。重点就是判断为存在的是否可能存在(可能有多个元素在一个坑位),不存在的是否肯定不存在。尽量不要删除元素(因为一个元素可能是多个)。
原理:
判断一个数据需要有多个hash函数,一个hash函数对应一个坑位,需要有每个hash计算的坑位都为1才能判断为有该条数据。可以使用redis实现布隆过滤器。
1.一个bitmap数组:
2.进行多次hash()判断位置赋值为1。
3.多个hash()判断是否为1是否存在。
使用是否,初始化bitmap尽量大避免扩容,如果后期需要扩容通过分配更大的过滤器,然后add旧数据进去,不要删除。
自研布隆过滤器:
可以解决缓存穿透问题,使用redis+bitmap构成布隆过滤器解决。
- 把mysql数据预热到布隆过滤器,这样非法数据无法进入。
- 将黑名单放入到布隆过滤器
- 安全网址的存储
架构:
布隆过滤器初始化(白名单):
需要初始化白名单
优缺点:
优点:占用空间少,不存放具体数据和内容
缺点:不能删除,可能有误判。
布谷鸟过滤器:
为了解决布隆过滤器不能删除元素的问题,布谷鸟过滤器横空出世.
预热,雪崩,击穿,穿透:
缓存一致性:
关于Redis与MySQL之间的数据一致性问题其实也考虑过很多方案,比如先删后改,延时双删等等很多方案,但是在高并发情况下还是会造成数据的不一致性,所以关于DB与缓存之间的强一致性一定要保证的话那么就对于这部分数据不要缓存,操作直接走DB,但是如果这个数据比较热点的话那么还是会给DB造成很大的压力,所以在我们的项目中还是采用先删再改+过期的方案来做的,虽然也会存在数据的不一致,但是勉强也能接受,因为毕竟使用缓存访问快的同时也能减轻DB压力,而且本身采用缓存就需要接受一定的数据延迟性和短暂的不一致性,我们只能采取合适的策略来降低缓存和数据库间数据不一致的概率,而无法保证两者间的强一致性。合适的策略包括合适的缓存更新策略,合适的缓存淘汰策略,更新数据库后及时更新缓存、缓存失败时增加重试机制等。
预热:
mysql给redis,例如预热布隆过滤器。
方案:
- 比较懒,让用户查询的时候,然后返回给redis,这样第一个客户比较慢
- 使用中间件来进行,编写程序自动进行。写一个初始化类进行自动初始化。
缓存雪崩:
redis挂机了或者一时间大量key过期,且大量请求来到,导致大量数据查询DB。
解决方案。
- 热点key永不过期或者错开过期时间,
- 高可用集群。
- 多缓存结合,预防雪崩。ehcache本地缓存。都不用访问redis。
- 服务降级,流量降级
- 人名币玩家,云数据库redis。
- 使用分布式锁或MQ队列串行请求,不会大量请求落入DB
- 缓存预热
缓存穿透:
不合理的请求,不能击中缓存,查询DB也不行,例如userId=-1,然后黑客通过肉鸡服务器攻击查询DB,使得宕机,整个系统瘫痪。解决方案
- 对IP限流与黑名单,避免同一IP一瞬间发送大量请求。
- 对于请求做非法校验,对写带非法参数的请求进行过滤。
- 对于不存在的数据在Redis中设置“Not Data”并设置短过期时间。
- 如果黑客使用不同的垃圾key。布隆过滤器(自研或者Guava)可以解决。
缓存击穿:
缓存雪崩是大量key过期,而击穿是一个热点key过期,大量请求查询数据最终落到DB。解决方案:
- 热点key永不过期
- 做好redis监控,设置串行化
- 双检加索策略
总结:
手写分布式锁:
redis的用法:
数据共享,分布式Session分布式锁、全局ID、计算器、点赞位统计 、购物车 、轻量级消息队列,redis集群保证AP,zookeeper保障的是CP,分布式锁可能丢失:因为一主两从可能丢失。
可靠的锁的原则
1独占性,2高可用性,3防止死锁(兜底方案),4不乱抢(不能释放别人的),5可重入性。6.自动续期
分布式锁的自研与测试:
模拟高并发工具:jmeter工具。最终思路:可重入锁+Lua脚本+redis命令。
以下是版本迭代
1.使用本地的进行就有问题。
本地通过
2.使用redis的 setnx进行处理。
上锁:setnx zzyyredislock uuid:线程ID
获取锁失败的时候,if判断进行然后递归进行。
释放锁:del zzyyredislock
存在问题:
1.由于递归容易栈溢出,不推荐使用while(使用自旋)替代if
2.没有设置过期时间,这样如果微服务宕机,那么锁不会释放死锁。
添加过期时间stringredisTemplate.expire(key,30L,TimeUnit.SECONDS).
3.解决,设置key和过期时间不原子的问题:
使用stringRedisTemplate.opsForValue().setIfAbsent(key,uuidValue,30L,TimeUnit.SECONDS);
加锁和过期时间必须同一行,原子性。
4.A线程过期时间内没有释放锁,然后别人B获取了锁,B删除了锁。
5.释放锁的时候进行判断value是否自己的锁,只能够删除自己的锁。
6.上面的判断和删除并不是一次性的。这次通过lua脚本进行删除。
lua脚本入门:
eval就是开启,return返回脚本执行结果。
EVAL “return ‘hello lua’” 0
一般编写redis命令为:
EVAL “”
redisTemplate.execute(脚本,keys…,args…);
7.可重复锁+设计模式
原理讲解:
可能在业务中,调用了方法A,方法A中进行了加锁逻辑,这样需要重复获取锁,可重入锁一定程度上避免了死锁。
Sychronized是可重入锁,原理是:有一个线程指针和一个计数器。于是容易,lock的可重入锁,原理是:与sychronized有区别。setnx就不能满足计数器了,使用hset才能进行redis锁,key1key2value的结构。key1是锁,key2是线程信息,value是加锁了几次。
hset流程:
使用lua版本,进行加锁和解锁的操作。!!!!!!!
lock逻辑:
1.判断key是否存在,是否是自己的,
if redis.call('exists',KEYS[1]) == 0 or redis.call('hexists',KEYS[1],ARGV[1]) == 1 then
redis.call('hincrby',KEYS[1],ARGV[1],1)
redis.call('expire',KEYS[1],ARGV[2])
return 1
else
return 0
end
2.设置key
unlock逻辑:
1.判断锁是否自己的
2.判断重入次数,判断是否可以进行删除。
if redis.call('HEXISTS',KEYS[1],ARGV[1]) == 0 then
return nil
elseif redis.call('HINCRBY',KEYS[1],ARGV[1],-1) == 0 then
return redis.call('del',KEYS[1])
else
return 0
end
有人说可以使用threadLocal进行判断锁的数量。
自研一个RedisDistributedLock类。实现Lock接口,写4/6个方法。
8.工厂模式:如果锁用zookeeper或者mysql实现还比较麻烦
引入工厂模式,创建不同版本(zookeeper、mysql、redis)的分布式锁。
9.自动续期:
有的情况下,业务逻辑时间过长,还没有结束,锁过期了。这时候需要进行锁续期。
Redis是AP:快,单机无法保证A,所以要集群,可能还是会有
Zookeeper是CP:牺牲了A高可用
Eureka是AP:
Nacos是AP:
写一个加钟的lua脚本:
自动进行续期的lua脚本:
if redis.call('HEXISTS',KEYS[1],ARGV[1]) == 1 then
return redis.call('expire',KEYS[1],ARGV[2])
else
return 0
end
续期后但是业务挂机了这样还是有锁,后台扫描业务是否执行完成?这里老师没有讲
手写RedLock:
紧接上一章。
分布式锁的关键点:
实现lock规范:加锁,自旋,续期,可重入
实现unlock规范:可重入的解锁
上一章的问题:只有一个redis锁服务器,如果这个机器挂了,然后就不行了。故障迁移是不够的。
原理:
RedLock使用多个实例来进行分布式锁。我们使用多个master相互独立,不用复制。这里使用来保证CP模式。**容错公式:**官方推荐使用5台来进行部署。
问题:
删除锁可能有bug,释放锁,必须判断是否是自己的锁。自己需要自己完成这个业务逻辑。
redisson的优势:
使用watchdog进行续命,在锁关闭之前一直进行续期,默认30s续期,每个时间进行续期。会起一个定时任务在锁没有被释放且快要过期的时候会续期
使用:
多机进行管理redis锁,主要保证CP。
这个锁的算法实现了多redis实例的情况,相对于单redis节点来说,优点在于防止了单节点故障造成整个服务停止运行的情况且在多节点中锁的设计,及多节点同时崩溃等各种意外情况有自己独特的设计方法。
**Redisson 分布式锁支持 MultiLock 机制可以将多个锁合并为一个大锁,**对一个大锁进行统一的申请加锁以及释放锁。
最低保证分布式锁的有效性及安全性的要求如下:
1.互斥;任何时刻只能有一个client获取锁
2.释放死锁;即使锁定资源的服务崩溃或者分区,仍然能释放锁
3.容错性;只要多数redis节点(一半以上)在使用,client就可以获取和释放锁
网上讲的基于故障转移实现的redis主从无法真正实现Redlock:
因为redis在进行主从复制时是异步完成的,比如在clientA获取锁后,主redis复制数据到从redis过程中崩溃了,导致没有复制到从redis中,然后从redis选举出一个升级为主redis,造成新的主redis没有clientA 设置的锁,这是clientB尝试获取锁,并且能够成功获取锁,导致互斥失效;
不能使用redLock了(已经被淘汰了),需要使用multiLock了。
multiLock:
池子和单机信息,
8.3.多重锁
八种淘汰和三种删除策略:
redis默认内存,maxmemory 这个参数可以配置内存大小。默认为0(标识不限制内存)。一般推荐为物理内存的3/4,可以通过配置和命令两种方式进行修改。如果超过内存,也会OOM。为了防止内存爆炸,就需要淘汰。
三种键的删除策略:
key的删除策略:
- 定时删除:设置键的过期时间同时设置一个定时器,当键过期后,定时器马上删除该键
- 惰性删除:key过期后不删除,当有请求使用该key的时候,检测是否过期,然后进行删除。
- 定期删除:每隔一段时间进行随机抽取一些设置了过期时间的key进行删除。类似于定时删除和惰性删除的中间策略。(会有漏网之鱼)
- redis采用定期+惰性删除的策略。
八种淘汰策略:
在memory management,在内存
LRU,最近最少用的。
LFU,最近最不常用的。
数据存放在内存中,如果我们没有设置过期时间,那么回浪费内存,redis5.0之前有五种淘汰策略,redis5.0之后有8种策略。大体分为四种类型lru,lfu,random,ttl。
策略 | 概述 |
---|---|
volatile-lru | 从已设置过期时间的数据集中挑选-最近最少用的 |
volatile-ttl | 从已设置过期时间的数据集中挑选-即将过期的,ttl值越大优先被淘汰 |
volatile-lfu | 从已设置过期时间的数据集中挑选-频率低的数据淘汰 |
volatile-random | 从已设置过期时间的数据集中挑选-随机淘汰 |
allkeys-lru | 从数据集中-最近最少用的 |
allkeys-lfu | 从数据集中-频率低的数据淘汰 |
allkeys-random | 从数据集中-随机淘汰 |
no-enviction | (默认),不会驱逐数据 |
**一般用allkeys-lru,保存最新的。
**使用命令:用config或者redis配置
内存模型:
内存分类:
通过info memory方式查询内存信息:
used_memory
used_memory_rss
mem_fragmentation_ratio
mem_allocator
redis内存除了存储数据还要存储一些其他信息,例如,进程本身运行需要的内存、缓冲内存、内存碎片等信息。
共享内存:
通过引用计数refcount判断是否被引用。有多个引用指向同一个对象就是引用计数,目前只支持整数值的字符串对象作为共享对象。
因为整数值的字符串对象判断是否相等比较简单,而其他数据类型判断需要花费时间,
目前:redis服务器初始化会创建10000个字符对象,分别是0-9999的整数值。
数据类型源码:
常见数据类型:
- SDS动态字符串:有长度,buf,可以存储二进制文件,不会缓冲区溢出。
- 双向链表:
- 压缩列表ziplist:
- 哈希表hashtable:
- 跳表skiplist:
- 整数集合intset:
- 快速列表quicklist:
- 紧凑列表listpack:
总览:
object.c+dict.c最基本。key就是一个string,value是各种类型的object对象。
总览
redisobjec就是高度抽象的对象。
总体大纲:
string | hash | list | set | zset | |
---|---|---|---|---|---|
redis6 | SDS | intset/hashtable | quicklist/ziplist | ziplist/hashtable | skiplist/ziplist |
redis7 | SDS | intset/hashtable | quicklist/listpack | listpack/hashtable | skiplist/listpack |
Redis6 | Redis7 |
---|---|
String=SDS | |
set=intset/hashtable | |
zSet=skiplist/ziplist | |
list=quicklist+ziplist | |
hash=hashtable/ziplist | String=SDS |
set=intset/hashtable | |
zSet=skiplist/listpack(紧凑列表) | |
list=quicklist+listpack | |
hash=hashtable/listpack |
redis7后,ziplist没有了被listpack代替。
Listpack替代了ziplist。
介绍:
type 查看类型:
object encoding key 查看编码:
typedef struct redisObject {
unsigned type:4;
unsigned encoding:4;
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;
同一数据类型有不同编码方式:
五大数据结构类型:
DEBUG OBJECT key工具:(OBJECT ENCODING age的兄弟),
Value at: 内存地址
refcount: 引用次数
encoding: 物理编码类型
serializedlength: 序列化后的长度(注意这里的长度是序列化后的长度,保存为rdb文件时使用了该算法,不是真正存贮在内存的大小),会对字串做一些可能的压缩以便底层优化
lru:记录最近使用时间戳
lru_seconds_idle:空闲时间
这个命令不可以直接使用,需要开启(改配置)。
解析:String类型:
SDS三大物理编码方式:
int (本质long,最长19位):浮点数使用embstr存储
embstr(长度小于44byte):
raw(长度大于44byte):
为什么重新有SDS?
没有复用底层c的不用char[] 是因为这样需要遍历读取慢。
使用SDS(如下图)读取快, 申请释放内存更加方便,二进制数据更加安全。
len用于获取长度,alloc方便判断长度时间复杂度O(1),flags判断类型,buf[]数组。
set k1 v1这行命令发生了什么。
|
| C语言 | SDS |
| — | — | — |
| 字符串长度处理 | 需要从头开始遍历,直到遇到 ‘\0’ 为止,时间复杂度O(N) | 记录当前字符串的长度,直接读取即可,时间复杂度 O(1) |
| 内存重新分配 | 分配内存空间超过后,会导致数组下标越级或者内存分配溢出 | 空间预分配
SDS 修改后,len 长度小于 1M,那么将会额外分配与 len 相同长度的未使用空间。如果修改后长度大于 1M,那么将分配1M的使用空间。
惰性空间释放
有空间分配对应的就有空间释放。SDS 缩短时并不会回收多余的内存空间,而是使用 free 字段将多出来的空间记录下来。如果后续有变更操作,直接使用 free 中记录的空间,减少了内存的分配。 |
| 二进制安全 | 二进制数据并不是规则的字符串格式,可能会包含一些特殊的字符,比如 ‘\0’ 等。前面提到过,C中字符串遇到 ‘\0’ 会结束,那 ‘\0’ 之后的数据就读取不上了 | 根据 len 长度来判断字符串结束的,二进制安全的问题就解决了 |
set k1 v1 底层使用setCommand命令,后面会进行类型(int,embstr,raw)判断。
set k1 123:
Redis 启动时会预先建立 10000 个分别存储 0~9999 的 redisObject 变量作为共享对象,这就意味着如果 set字符串的键值在 0~10000 之间的话,则可以 直接指向共享对象 而不需要再建立新对象,此时键值不占空间!
embstr:这里空间紧凑的分配算法。
raw:申请一块大的空间,未达到域值会变成raw的情况,对于(embstr(不可变的))修改了,所以就会变成raw。
1 int | Long类型整数时,RedisObject中的ptr指针直接赋值为整数数据,不再额外的指针再指向整数了,节省了指针的空间开销。 |
---|---|
2 embstr | 当保存的是字符串数据且字符串小于等于44字节时,embstr类型将会调用内存分配函数,只分配一块连续的内存空间,空间中依次包含 redisObject 与 sdshdr 两个数据结构,让元数据、指针和SDS是一块连续的内存区域,这样就可以避免内存碎片 |
3 raw | 当字符串大于44字节时,SDS的数据量变多变大了,SDS和RedisObject布局分家各自过,会给SDS分配多的空间并用指针指向SDS结构,raw 类型将会调用两次内存分配函数,分配两块内存空间,一块用于包含 redisObject结构,而另一块用于包含 sdshdr 结构 |
解析:Hash
两种编码方式:
redis6 | redis7 |
---|---|
ziplist/hashtable | listpack/hashtable |
ziplist:
**hash-max-ziplist-entries(默认512)**:使用压缩列表保存时哈希集合中的最大元素个数。
hash-max-ziplist-value(默认64byte):使用压缩列表保存时哈希集合中单个元素的最大长度。
Hash类型键的字段个数 小于** hash-max-ziplist-entries** 并且每个字段名和字段值的长度 小于 hash-max-ziplist-value 时,
Redis才会使用 OBJ_ENCODING_ZIPLIST来存储该键,前述条件任意一个不满足则会转换为 OBJ_ENCODING_HT的编码方式,不会返回
hashtable具体结构:
hset底层调用的是:hashSetCommand();
ziplist解析:
使用时间换空间,节约内存,适用于字段少,字段值小的场景。ziplist是一个经过特殊编码的双向链表,它不存储指向前一个链表节点prev和指向下一个链表节点的指针next而是存储上一个节点长度和当前节点长度,通过牺牲部分读写性能,来换取高效的内存空间利用率,节约内存,是一种时间换空间的思想。只用在字段个数少,字段值小的场景里面
压缩结点成:zlentry的数组。
主要记录了:
前一个结点长度:用于遍历。
当前节点长度:
实例数据
为什么又list了还要ziplist:?
没有维护前后的指针,存储长度是更加节约空间,而且在redis中这些entry很可以能连续的,因为这样更加节约空间。
ziplist的每个节点的长度是可以不一样的,而我们面对不同长度的节点又不可能直接sizeof(entry),所以ziplist只好将一些必要的偏移量信息记录在了每一个节点里,使之能跳到上一个节点或下一个节点。
总结:
ziplist为了节省内存,采用了紧凑的连续存储。
ziplist是一个双向链表,可以在时间复杂度为 O(1) 下从头部、尾部进行 pop 或 push。
新增或更新元素可能会出现连锁更新现象(致命缺点导致被listpack替换)。
不能保存过多的元素,否则查询效率就会降低,数量小和内容小的情况下可以使用。
压缩列表新增某个元素或修改某个元素时,如果空间不不够,压缩列表占用的内存空间就需要重新分配。而当新插入的元素较大时,可能会导致后续元素的 prevlen 占用空间都发生变化,从而引起「连锁更新」问题,导致每个元素的空间都要重新分配,造成访问压缩列表性能的下降。压缩列表每个节点正因为需要保存前一个节点的长度字段,就会有连锁更新的隐患,特殊情况下产生的连续多次空间扩展操作就叫做**「连锁更新」**
listpack:
hash-max-listpack-entries:使用压缩列表保存时哈希集合中的最大元素个数。
hash-max-listpack-value:使用压缩列表保存时哈希集合中单个元素的最大长度。
Hash类型键的字段个数 小于 hash-max-listpack-entries且每个字段名和字段值的长度 小于 hash-max-listpack-value 时,
Redis才会使用OBJ_ENCODING_LISTPACK来存储该键,前述条件任意一个不满足则会转换为 OBJ_ENCODING_HT的编码方式
listpack 是 Redis 设计用来取代掉 ziplist 的数据结构,它通过每个节点记录自己的长度且放在节点的尾部,来彻底解决掉了 ziplist 存在的连锁更新的问题。
解析:List
redis6 | redis7 |
---|---|
quicklist+ziplist(用这个实现) | quicklist+listpack(用这个实现) |
quicklist:一种双向链表。
这是Redis6的案例说明
(1) ziplist压缩配置:list-compress-depth 0
表示一个quicklist两端不被压缩的节点个数。这里的节点是指quicklist双向链表的节点,而不是指ziplist里面的数据项个数
参数list-compress-depth的取值含义如下:
0: 是个特殊值,表示都不压缩。这是Redis的默认值。
1: 表示quicklist两端各有1个节点不压缩,中间的节点压缩。
2: 表示quicklist两端各有2个节点不压缩,中间的节点压缩。
3: 表示quicklist两端各有3个节点不压缩,中间的节点压缩。
依此类推…
(2) ziplist中entry配置:list-max-ziplist-size -2
当取正值的时候,表示按照数据项个数来限定每个quicklist节点上的ziplist长度。比如,当这个参数配置成5的时候,表示每个quicklist节点的ziplist最多包含5个数据项。当取负值的时候,表示按照占用字节数来限定每个quicklist节点上的ziplist长度。这时,它只能取-1到-5这五个值,
每个值含义如下:
-5: 每个quicklist节点上的ziplist大小不能超过64 Kb。(注:1kb => 1024 bytes)
-4: 每个quicklist节点上的ziplist大小不能超过32 Kb。
-3: 每个quicklist节点上的ziplist大小不能超过16 Kb。
-2: 每个quicklist节点上的ziplist大小不能超过8 Kb。(-2是Redis给出的默认值)
-1: 每个quicklist节点上的ziplist大小不能超过4 Kb。
quicklist:
redis7:
使用listpack代替了ziplist。
set:
set=intset/hashtable
Redis用intset或hashtable存储set。如果元素都是整数类型,就用intset存储。
如果不是整数类型,就用hashtable(数组+链表的存来储结构)。key就是元素的值,value为null。
集合元素都是longlong类型并且元素个数<=set-max-intset-entries编码就是intset,反之就是hashtable
zset:
redis6 | redis7 |
---|---|
ziplist(64)/skiplist | listpack(64)/skiplist |
当有序集合中包含的元素数量超过服务器属性 server.zset_max_ziplist_entries 的值(默认值为 128),或者有序集合中新添加元素的 member 的长度大于服务器属性 server.zset_max_ziplist_value 的值(**默认值为 64 **)时.redis会使用跳跃表作为有序集合的底层实现。否则会使用ziplist作为有序集合的底层实现
skiplist:
对于一个单链表来讲,即便链表中存储的数据是有序的,如果我们要想在其中查找某个数据,也只能从头到尾遍历链表。遍历比较慢
想要在链表上进行一些优化,空间换时间,加索引两两取首,
时间复杂度,log(n),顶层只有两个结点
空间复杂度,空间复杂度(n);
读多写少比较使用,维护耗费时间,
Redis之epoll:
BIO(一个进程处理一个网络连接),这样会非常卡顿,开销大。需要有一个进程处理多个网络连接,这是可以通过循环遍历的方式,这样遍历太耗时。这是linux由IO事件来通知线程由网络连接这样更加高效。
多路:多个客户端连接(连接就是套接字描述符,即 socket或者channel),指的是多条TCP连接复用:用一个进程来处理多条的连接,使用单进程就能够实现同时处理多个客户端的连接
多路复用发展select->poll->epoll。
为什么单线程这么快?
五种IO模型:
BIO,NIO,IO多路复用,信号驱动IO,异步IO。
多路复用:
本身我是通信专业,于是通信专业中的各种复用技术FDMA,TDMA,CDMA,OFDMA,使得多个信号在信道中传输,这里的多路复用就是多个请求在同一个线程中的复用技术。然后由一个处理线程处理(接收机)。
其中select、poll、epoll就是处理线程(接收机)处理多个请求连接(又叫文件描述符,因为linux中)。
实例:微信小红包
业务描述:
1 各种节假日,发红包+抢红包,不说了,100%高并发业务要求,不能用mysql来做
2 一个总的大红包,会有可能拆分成多个小红包,总金额= 分金额1+分金额2+分金额3…分金额N
3 每个人只能抢一次,你需要有记录,比如100块钱,被拆分成10个红包发出去,总计有10个红包,抢一个少一个,总数显示(10/6)直到完,需要记录那些人抢到了红包,重复抢作弊不可以。
4 有可能还需要你计时,完整抢完,从发出到全部over,耗时多少?
5 红包过期,或者群主人品差,没人抢红包,原封不动退回。
6 红包过期,剩余金额可能需要回退到发红包主账户下
由于是高并发不能用mysql来做,只能用redis,那需要要redis的什么数据类型?
发红包,抢红包,记红包,拆红包
1拆红包:二倍均值法,用于拆红包
2倍均值法
剩余红包金额为M,剩余人数为N,那么有如下公式:
每次抢到的金额=随机区间(O,(剩余红包金额M÷剩余人数N )×2)
这个公式,保证了每次随机金额的平均值是相等的,不会因为抢红包的先后顺序而造成不公平。
2发红包:使用list来进行存储红包,lpush。就是一个红包key。
3抢红包:放进redis,lpop,
4记红包:盘点,防止作弊,使用hset记录红包
使用注意事项:
大key问题:
什么是大key,指的是value过大的key。对于string类型,value字节数大于10kB就是大key,对于hash/set/zset/list等数据类型,元素个数大于5000个或者字节数大于10kB就是大key。
带了什么危害?读取成本高,容易导致慢查询,主从复制异常,服务阻塞等情况。业务侧看起来就是redis超时报错。
解决方案:
1、拆分大key为小key,一个string拆分为多个string。才分规则可以自己定一个。
2、进行通过压缩的方式。gzip,snappy,lz4等。
3、对于集合类型hash,list,set,zset。1.可以通过hash取余方式判断属于那个key,2.分区冷热,例如排行榜只缓存前10页数据,后续走db。
热key问题:
使用localcache缓存在业务侧,降低redis的QPS。Java中的Guava等。为命中本地缓存再访问redis。
使用redis代理的热key承载能力。