Redis企业实战
1. 架构设计
1.1 组件选择/多级
缓存的设计要分多个层次,在不同的层次上选择不同的缓存,包括JVM缓存、文件缓存和Redis缓存
-
JVM缓存
JVM缓存就是本地缓存,设计在应用服务器中(tomcat)。
通常可以采用Ehcache和Guava Cache,在互联网应用中,由于要处理高并发,通常选择GuavaCache。
适用: 对性能要求高, 占内存不大, 不经常变化, 允许数据不实时一致
-
文件缓存
这里的文件缓存是基于http协议的文件缓存,一般放在nginx中。
#要缓存文件的后缀,可以在以下设置。 location ~ .*\.(gif|jpg|png|css|js)(.*) { proxy_pass http://ip地址:90; proxy_redirect off; proxy_set_header Host $host; proxy_cache cache_one; proxy_cache_valid 200 302 24h; proxy_cache_valid 301 30d; proxy_cache_valid any 5m; expires 90d; add_header wall "hello."; }
-
redis缓存
分布式缓存,采用主从+哨兵或RedisCluster的方式缓存数据库的数据
1.2 缓存大小
GuavaCache的缓存设置方式
CacheBuilder.newBuilder().maximumSize(num) // 超过num会按照LRU算法来移除缓存
nginx缓存设置:
http {
...
proxy_cache_path /path/to/cache levels=1:2 keys_zone=my_cache:10m max_size=10g inactive=60m use_temp_path=off; server {
proxy_cache mycache;
location / {
proxy_pass http://localhost:8000;
}
}
}
redis缓存设置:
maxmemory=num # 最大缓存量 一般为内存的3/4
maxmemory-policy allkeys lru #
1.3 key数量
官方说Redis单例能处理key:2.5亿个
一个key或是value大小最大是512M
1.4 读写峰值
Redis采用的是基于内存的采用的是单进程单线程模型的 KV 数据库,由C语言编写,官方提供的数据是
可以达到110000+的QPS(每秒内查询次数)。80000的写
1.5 命中率
命中:可以直接通过缓存获取到需要的数据。
不命中:无法直接通过缓存获取到想要的数据,需要再次查询数据库或者执行其它的操作。原因可能是由于缓存中根本不存在,或者缓存已经过期。
通常来讲,缓存的命中率越高则表示使用缓存的收益越高,应用的性能越好(响应时间越短、吞吐量越高),抗并发的能力越强。
由此可见,在高并发的互联网系统中,缓存的命中率是至关重要的指标。
127.0.0.1:6379>info
#Server
redis_version:5.0.5
redis_git_sha1:00000000
redis_git_dirty:0
redis_build_id:e188a39ce7a16352
redis_mode:standalone
os:Linux3.10.0-229.el7.x86_64x86_64 arch_bits:64
#缓存命中
keyspace_hits:1000
#缓存未命中
keyspace_misses:20
used_memory:433264648
expired_keys:1333536
evicted_keys:1547380
#命中率=1000/1000+20=83%
一个缓存失效机制,和过期时间设计良好的系统,命中率可以做到95%以上
影响缓存命中率的因素:
1、缓存的数量越少命中率越高,比如缓存单个对象的命中率要高于缓存集合
2、过期时间越长命中率越高
3、缓存越大缓存的对象越多,则命中的越多
1.6 过期策略
Redis的过期策略是定时删除+惰性删除
1.7 性能监控指标
利用info命令就可以了解Redis的状态了,主要监控指标有
connected_clients:68 #连接的客户端数量
used_memory_rss_human:847.62M#系统给redis分配的内存
used_memory_peak_human:794.42M #内存使用的峰值大小
total_connections_received:619104#服务器已接受的连接请求数量
instantaneous_ops_per_sec:1159#服务器每秒钟执行的命令数量qps
instantaneous_input_kbps:55.85#redis网络入口kps
instantaneous_output_kbps:3553.89#redis网络出口kps
rejected_connections:0#因为最大客户端数量限制而被拒绝的连接请求数量expired_keys:0#因为过期而被自动删除的数据库键数量
evicted_keys:0#因为最大内存容量限制而被驱逐(evict)的键数量
keyspace_hits:0#查找数据库键成功的次数
keyspace_misses:0#查找数据库键失败的次数
Redis监控平台:grafana、prometheus以及redis_exporter。
1.8 缓存预热
缓存预热就是系统启动前,提前将相关的缓存数据直接加载到缓存系统。避免在用户请求的时候,先查询数据库,然后再将数据缓存的问题!用户直接查询实现被预热的缓存数据。
加载缓存思路:
-
数据量不大,可以在项目启动的时候自动进行加载
-
利用定时任务刷新缓存,将数据库的数据刷新到缓存中
2. 缓存问题
2.1 缓存穿透
一般的缓存系统,都是按照key去缓存查询,如果不存在对应的value,就应该去后端系统查找(比如DB)。
缓存穿透是指在高并发下查询key不存在的数据(不存在的key),会穿过缓存查询数据库。导致数据库压力过大而宕机
解决方案:
-
对查询结果为空的情况也进行缓存,缓存时间(ttl)设置短一点,或者该key对应的数据insert了之后清理缓存。
问题:缓存太多空值占用了更多的空间
-
使用布隆过滤器。在缓存之前在加一层布隆过滤器,在查询的时候先去布隆过滤器查询key是否存在,如果不存在就直接返回,存在再查缓存和DB。
布隆过滤器(BloomFilter)是1970年由布隆提出的。它实际上是一个很长的二进制向量和一系列随机hash映射函数。
布隆过滤器可以用于检索一个元素是否在一个集合中。它的优点是空间效率和查询时间都远远超过一般的算法。
布隆过滤器的原理是,当一个元素被加入集合时,通过K个Hash函数将这个元素映射成一个数组中的K 个点,把它们置为1。检索时,我们只要看看这些点是不是都是1就(大约)知道集合中有没有它了:如果这些点有任何一个0,则被检元素一定不在;如果都是1,则被检元素很可能在。这就是布隆过滤器的基本思想。
2.2 缓存雪崩
当缓存服务器重启或者大量缓存集中在某一个时间段失效,这样在失效的时候,也会给后端系统(比如DB)带来很大压力。
突然间大量的key失效了或redis重启,大量访问数据库,数据库崩溃
解决方案:
- 1、key的失效期分散开不同的key设置不同的有效期
- 2、设置二级缓存(数据不一定一致)
- 3、高可用(脏读)
2.3 缓存击穿
对于一些设置了过期时间的key,如果这些key可能会在某些时间点被超高并发地访问,是一种非常“热点”的数据。这个时候,需要考虑一个问题:缓存被“击穿”的问题,这个和缓存雪崩的区别在于这里针对某一key缓存,前者则是很多key。
缓存在某个时间点过期的时候,恰好在这个时间点对这个Key有大量的并发请求过来,这些请求发现缓存过期一般都会从后端DB加载数据并回设到缓存,这个时候大并发的请求可能会瞬间把后端DB压垮。
解决方案:
-
1、用分布式锁控制访问的线程
使用redis的setnx互斥锁先进行判断,这样其他线程就处于等待状态,保证不会有大并发操作去操作数据库。
-
2、不设超时时间,volatile-lru但会造成写一致问题
当数据库数据发生更新时,缓存中的数据不会及时更新,这样会造成数据库中的数据与缓存中的数据的不一致,应用会从缓存中读取到脏数据。可采用延时双删策略处理,这个我们后面会详细讲到。
2.4 数据不一致
缓存和DB的数据不一致的根源:数据源不一样
如何解决: 强一致性很难,追求最终一致性(时间)
互联网业务数据处理的特点: 高吞吐量, 低延迟, 数据敏感性低于金融业
时序控制是否可行?先更新数据库再更新缓存或者先更新缓存再更新数据库本质上不是一个原子操作,所以时序控制不可行
延时双删 保证数据最终一致性:
-
1、先更新数据库同时删除缓存项(key),等读的时候再填充缓存
-
2、2秒后再删除一次缓存项(key)
-
3、设置缓存过期时间ExpiredTime比如10秒或1小时
-
4、将缓存删除失败记录到日志中,利用脚本提取失败记录再次删除(缓存失效期过长7*24)
-
升级方案
通过数据库的binlog来异步淘汰key,利用工具(canal)将binlog日志采集发送到MQ中,然后通过ACK机制确认处理删除缓存。
2.5 数据并发竞争
这里的并发指的是多个redis的client同时set同一个key引起的并发问题。
解决方案:
-
分布式锁+时间戳
-
整体技术方案
这种情况,主要是准备一个分布式锁,大家去抢锁,抢到锁就做set操作。 加锁的目的实际上就是把并行读写改成串行读写的方式,从而来避免资源竞争。
-
redis分布式锁的实现
用SETNX实现分布式锁
-
-
利用消息队列
在并发量过大的情况下,可以通过消息中间件进行处理,把并行读写进行串行化。 把Redis的set操作放在队列中使其串行化,必须的一个一个执行。
2.6 Hot Key
当有大量的请求(几十万)访问某个Redis某个key时,由于流量集中达到网络上限,从而导致这个redis的服务器宕机。造成缓存击穿,接下来对这个key的访问将直接访问数据库造成数据库崩溃,或者访问数据库回填Redis再访问Redis,继续崩溃。
如何发现热key
1、预估热key,比如秒杀的商品、火爆的新闻等
2、在客户端进行统计,实现简单,加一行代码即可
3、如果是Proxy,比如Codis,可以在Proxy端收集
4、利用Redis自带的命令,monitor、hotkeys。但是执行缓慢(不要用)
5、利用基于大数据领域的流式计算技术来进行实时数据访问次数的统计,
比如Storm、Spark Streaming、Flink,这些技术都是可以的。
发现热点数据后可以写到zookeeper中
如何处理热Key:
1、变分布式缓存为本地缓存
发现热key后,把缓存数据取出后,直接加载到本地缓存中。可以采用Ehcache、GuavaCache都可
以,这样系统在访问热key数据时就可以直接访问自己的缓存了。(数据不要求时时一致)
2、在每个Redis主节点上备份热key数据,这样在读取时可以采用随机读取的方式,将访问压力负载到每个Redis上。
3、利用对热点数据访问的限流熔断保护措施
每个系统实例每秒最多请求缓存集群读操作不超过400次,一超过就可以熔断掉,不让请求缓存集群,
直接返回一个空白信息,然后用户稍后会自行再次重新刷新页面之类的。(
首页不行,系统友好性差)通过系统层自己直接加限流熔断保护措施,可以很好的保护后面的缓存集群。
2.7 Big Key
大key指的是存储的值(Value)非常大,常见场景:
热门话题下的讨论
大V的粉丝列表
序列化后的图片
没有及时处理的垃圾数据
大key会大量占用内存,在集群中无法均衡
Redis的性能下降,主从复制异常
在主动删除或过期删除时会操作时间过长而引起服务阻塞
如何发现大key:
1、redis-cli--bigkeys命令。可以找到某个实例5种数据类型(String、hash、list、set、zset)的最大key。
但如果Redis的key比较多,执行该命令会比较慢
2、获取生产Redis的rdb文件,通过rdbtools分析rdb生成csv文件,再导入MySQL或其他数据库中进行分析统计,
根据size_in_bytes统计bigkey
大key的处理:
优化bigkey的原则就是string减少字符串长度,list、hash、set、zset等减少成员数。
1、string类型的bigkey,尽量不要存入Redis中,可以使用文档型数据库MongoDB或缓存到CDN上。
如果必须用Redis存储,最好单独存储,不要和其他的key一起存储。采用一主一从或多从。
2、单个简单的key存储的value很大,可以尝试将对象分拆成几个key-value,使用mget获取值,
这样分拆的意义在于分拆单次操作的压力,将操作压力平摊到多次操作中,降低对redis的IO影响。
2、hash,set,zset,list中存储过多的元素,可以将这些元素分拆。(常见)
3、删除大key时不要使用del,因为del是阻塞命令,删除时会影响性能。
4、使用lazydelete(unlink命令)
删除指定的key(s),若key不存在则该key被跳过。但是,相比DEL会产生阻塞,该命令会在另
一个线程中回收内存,因此它是非阻塞的。这也是该命令名字的由来:仅将keys从key空间中删除,
真正的数据删除会在后续异步操作。
3. 缓存与数据库一致性
3.1 缓存更新策略
- 利用Redis的缓存淘汰策略被动更新LRU、LFU
- 利用TTL被动更新
- 在更新数据库时主动更新(先更数据库再删缓存—-延时双删)
- 异步更新定时任务数据不保证时时一致不穿DB
3.2 不同策略优缺点
3.3 与mybatis整合
见mybatis二级缓存部署demo
4. 分布式锁
4.1 watch
利用Watch实现Redis乐观锁
乐观锁基于CAS(Compare And Swap)思想(比较并替换),是不具有互斥性,不会产生锁等待而消耗资源,但是需要反复的重试,但也是因为重试的机制,能比较快的响应。因此我们可以利用redis来实现乐观锁。具体思路如下:
- 1、利用redis的watch功能,监控这个redisKey的状态值
- 2、获取redisKey的值
- 3、创建redis事务
- 4、给这个key的值+1
- 5、然后去执行这个事务,如果key的值被修改过则回滚,key不加1
示例代码: https://gitee.com/ixinglan/redis-base-demo.git [cluster-lock-demo#WatchLockDemo]
4.2 setnx
-
实现原理
利用Redis的单线程特性对共享资源进行串行化处理
-
实现方式
获取锁:
方式1: set命令实现 –推荐
方式2: setnx命令实现 –并发时会产生问题
释放锁:
方式1:(del命令实现) – 并发
问题在于如果调用jedis.del()方法的时候,这把锁已经不属于当前客户端的时候会解除他人加的 锁。
那么是否真的有这种场景?答案是肯定的,比如客户端A加锁,一段时间之后客户端A解锁,在执行 jedis.del()之前,锁突然过期了,此时客户端B尝试加锁成功,然后客户端A再执行del()方法,则将客 户端B的锁给解除了。
方式2:(redis+lua脚本实现)–推荐
示例代码: https://gitee.com/ixinglan/redis-base-demo.git [cluster-lock-demo#SetNXDemo]
-
存在问题
单机:无法保证高可用
主-从:无法保证数据强一致性, 主机宕机时会造成锁的重复获得
无法续租,超过expireTime后,不能继续使用
-
本质分析
分布式锁是CP模型,Redis集群是AP模型
当业务不需要数据强一致性时,比如:社交场景,就可以使用Redis实现分布式锁
当业务必须要数据的强一致性,即不允许重复获得锁,比如金融场景(重复下单,重复转账)就不要使用
可以使用CP模型实现,比如:zookeeper和etcd。
4.3 Redisson分布式锁
Redisson是架设在Redis基础上的一个Java驻内存数据网格(In-Memory Data Grid)。
Redisson在基于NIO的Netty框架上,生产环境使用分布式锁。
实现原理:
-
加锁机制
如果该客户端面对的是一个redis cluster集群,他首先会根据hash节点选择一台机器。
发送lua脚本到redis服务器上,脚本如下:
"if(redis.call('exists',KEYS[1])==0)then"+ --看有没有锁 "redis.call('hset',KEYS[1],ARGV[2],1);"+ --无锁 "redis.call('pexpire',KEYS[1],ARGV[1]);"+ "returnnil;end;"+ "if(redis.call('hexists',KEYS[1],ARGV[2])==1)then"+ --我加的锁 "redis.call('hincrby',KEYS[1],ARGV[2],1);"+ --重入锁"redis.call('pexpire',KEYS[1],ARGV[1]);"+ "returnnil;end;"+ "returnredis.call('pttl',KEYS[1]);" --不能加锁,返回锁的时间
lua的作用:保证这段复杂业务逻辑执行的原子性。
lua的解释:
KEYS[1]) : 加锁的key
ARGV[1] : key的生存时间,默认为30秒
ARGV[2] : 加锁的客户端ID (UUID.randomUUID()) + “:” + threadId)
第一段if判断语句,就是用“exists myLock”命令判断一下,如果你要加锁的那个锁key不存在的话,你就
进行加锁。如何加锁呢?很简单,用下面的命令:hset myLock 8743c9c0-0795-4907-87fd-6c719a6b4586:1 1
通过这个命令设置一个hash数据结构,这行命令执行后,会出现一个类似下面的数据结构:
myLock :{“8743c9c0-0795-4907-87fd-6c719a6b4586:1”:1 }
接着会执行“pexpire myLock 30000”命令,设置myLock这个锁key的生存时间是30秒。
-
锁互斥机制
那么在这个时候,如果客户端2来尝试加锁,执行了同样的一段lua脚本,会咋样呢?
很简单,第一个if判断会执行“exists myLock”,发现myLock这个锁key已经存在了。接着第二个if判断,判断一下,myLock锁key的hash数据结构中,是否包含客户端2的ID,但是明显不是的,因为那里包含的是客户端1的ID。
所以,客户端2会获取到pttl myLock返回的一个数字,这个数字代表了myLock这个锁key的**剩余生存时间。**比如还剩15000毫秒的生存时间。
此时客户端2会进入一个while循环,不停的尝试加锁。
-
自动延时机制
只要客户端1一旦加锁成功,就会启动一个watch dog看门狗,他是一个后台线程,会每隔10秒检查一下,如果客户端1还持有锁key,那么就会不断的延长锁key的生存时间。
-
可重入锁机制
第一个if判断肯定不成立,“exists myLock”会显示锁key已经存在了。
第二个if判断会成立,因为myLock的hash数据结构中包含的那个ID,就是客户端1的那个ID,也就是“8743c9c0-0795-4907-87fd-6c719a6b4586:1”
此时就会执行可重入加锁的逻辑,他会用:
incrby myLock 8743c9c0-0795-4907-87fd-6c71a6b4586:1 1
通过这个命令,对客户端1的加锁次数,累加1。数据结构会变成:
myLock :{"8743c9c0-0795-4907-87fd-6c719a6b4586:1":2 }
-
释放锁机制
执行lua脚本如下:
#如果key已经不存在,说明已经被解锁,直接发布(publish)redis消息 "if(redis.call('exists',KEYS[1])==0) then"+ "redis.call('publish',KEYS[2],ARGV[1]);"+ "return 1;"+ "end;"+ #key和field不匹配,说明当前客户端线程没有持有锁,不能主动解锁。不是我加的锁不能解锁 "if(redis.call('hexists',KEYS[1],ARGV[3])==0)then"+ "return nil;"+ "end;"+ #将value减1 "local counter=redis.call('hincrby',KEYS[1],ARGV[3],-1);"+ #如果counter>0说明锁在重入,不能删除key "if(counter>0 )then"+ "redis.call('pexpire',KEYS[1],ARGV[2]);"+ "return0;"+ #删除key并且publish解锁消息 "else"+ #删除锁 "redis.call('del',KEYS[1]);"+ "redis.call('publish',KEYS[2],ARGV[1]);"+ "return1;"+ "end;"+ "return nil;",
–KEYS[1]:需要加锁的key,这里需要是字符串类型。 –KEYS[2]:redis消息的ChannelName,一个分布式锁对应唯一的一个channelName: “redisson_lockchannel{”+getName()+“}” –ARGV[1]:reids消息体,这里只需要一个字节的标记就可以,主要标记redis的key已经解锁,再结合redis的Subscribe,能唤醒其他订阅解锁消息的客户端线程申请锁。 –ARGV[2]:锁的超时时间,防止死锁 –ARGV[3]:锁的唯一标识,也就是刚才介绍的id(UUID.randomUUID())+“:”+threadId 如果执行lock.unlock(),就可以释放分布式锁,此时的业务逻辑也是非常简单的。 其实说白了,就是每次都对myLock数据结构中的那个加锁次数减1。 如果发现加锁次数是0了,说明这个客户端已经不再持有锁了,此时就会用: “delmyLock”命令,从redis里删除这个key。 然后呢,另外的客户端2就可以尝试完成加锁了。
4.4 分布式锁特性
-
互斥性
任意时刻,只能有一个客户端获取锁,不能同时有两个客户端获取到锁。
-
同一性
锁只能被持有该锁的客户端删除,不能由其它客户端删除。
-
可重入性
持有某个锁的客户端可继续对该锁加锁,实现锁的续租
-
容错性
锁失效后(超过生命周期)自动释放锁(key失效),其他客户端可以继续获得该锁,防止死锁
4.6 分布式锁的实际应用
- 数据并发竞争
- 防止库存超卖
4.7 zookeeper分布式锁的对比
基于zookeeper临时节点的分布式锁
5. 分布式集群中的session分离
利用spring-session-data-redis(SpringSession),可以实现基于redis来实现的session分离
可参考 session共享问题 教程
6. 阿里redis使用手册
6.1 键值设计
-
key名设计
可读性, 可管理性, 简洁性, 不包含特殊字符
-
value设计
避免bigkey, 防止网卡流量、慢查询,string类型控制在10KB以内,hash、list、set、zset元素个数不要超过5000
非字符串的bigkey,不要使用del删除,使用hscan、sscan、zscan方式渐进式删除,同时要注意防止bigkey过期时间自动删除问题(例如一个200万的zset设置1小时过期,会触发del操作,造成阻塞,而且该操作不会不出现在慢查询中(latency可查)),查找方法和删除方法
选择适合的数据类型
redis不是垃圾桶,建议使用expire设置过期时间(条件允许可以打散过期时间,防止集中过期),不过期的数据重点关注idletime
6.2 命令使用
-
O(N)命令关注N的数量
例如hgetall、lrange、smembers、zrange、sinter等并非不能使用,但是需要明确N的值。有遍历的需求可以使用hscan、sscan、zscan代替
-
禁用命令
禁止线上使用keys、flflushall、flflushdb等,通过redis的rename机制禁掉命令,或者使用scan的方式渐进式处理
-
合理使用select
redis的多数据库较弱,使用数字进行区分,很多客户端支持较差,同时多业务用多数据库实际还是单线程处理,会有干扰
-
使用批量操作提高效率
-
不建议过多使用Redis事务功能
-
Redis集群版本在使用Lua上有特殊要求
- 所有key都应该由 KEYS 数组来传递,redis.call/pcall 里面调用的redis命令,key的位置,必须是KEYS array, 否则直接返回error,"-ERR bad lua script for redis cluster, all the keys that the script uses should be passed using the KEYS arrayrn"
2、所有key,必须在1个slot上,否则直接返回error, “-ERR eval/evalsha command keys must in same slotrn”
-
monitor命令
必要情况下使用monitor命令时,要注意不要长时间使用
6.3 客户端使用
-
避免多个应用使用一个Redis实例
-
使用连接池
-
熔断功能
-
合理的加密
-
淘汰策略
默认策略是volatile-lru,即超过最大内存后,在过期键中使用lru算法进行key的剔除,保证不过期数据不被删除,但是可能会出现OOM问题。
allkeys-lru:根据LRU算法删除键,不管数据有没有设置超时属性,直到腾出足够空间为止。 allkeys-random:随机删除所有键,直到腾出足够空间为止。 volatile-random:随机删除过期键,直到腾出足够空间为止。 volatile-ttl:根据键值对象的ttl属性,删除最近将要过期数据。如果没有,回退到noeviction策略。 noeviction:不会剔除任何数据,拒绝所有写入操作并返回客户端错误信息"(error) OOM
6.4 相关工具
-
数据同步: redis-port
-
big key搜索: redis 大key搜索工具
-
热点key寻找: 内部实现用monitor, 短时间使用facebook的redis-faina
阿里云redis大内核层面解决了热点key问题
6.5 删除bigKey
redis 4.0已经支持key的异步删除,欢迎使用
-
hash删除: hscan+hdel
-
list删除: ltrim
-
set删除: sscan+srem
-
sortedset删除: zscan+zrem