Redis缓存及性能优化

配置文件调优

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
# 列表对象list
list-max-ziplist-size -2 # 单个ziplist节点最大能存储8kb,超过则进行分裂将数据存储在新的ziplist
list-compress-depth 1 # 0表示所有节点都不压缩,1表示头结点和尾节点不压缩其他节点压缩
# 哈希对象hash
hash-max-ziplist-entries 512 # 元素个数超过512,将改为HashTable编码
hash-max-ziplist-value 64 # 单个元素大小超过64byte,将改为HashTable编码
# 集合对象set
set-max-intset-entries 512 # 存储元素超过512时,使用HashTable编码
# 有序集合对象zset
zset-max-ziplist-entries 128 # 元素个数超过128,将用skiplist编码
zset-max-ziplist-value 64 # 单个元素大小超过64byte,将用skiplist编码

# 持久化相关的
save 60 1000 # 关闭RDB只需要将所有的save保存策略注释掉即可

appendonly yes # 打开AOF功能
appendfsync always # 每次有新命令追加到AOF文件时就执行一次fsync,非常慢也非常安全
appendfsync everysec # 每秒fsync一次,足够快且在故障时只会丢失1秒钟的数据
appendfsync no # 从不fsync,将数据交给操作系统来处理。更快,也更不安全的选择
auto-aof-rewrite-min-size 64mb # aof文件至少达到64M才会自动重写,文件太小恢复速度本来就很快,重写意义不大
auto-aof-rewrite-percentage 100 # aof文件自上一次重写后文件大小增长了100%则再次触发重写

aof-use-rdb-preamble yes # 开启混合持久化,注意必须先开启aof

# 集群相关的
min-replicas-to-write 1 # 写数据成功最少同步的slave数量

maxclients 10000 # redis支持的最大连接数
maxmemory 0 # 最大可使用内存值byte,默认0不限制

# volatile-lru:从已设置过期时间的key中,移出最近最少使用的key进行淘汰
# volatile-ttl:从已设置过期时间的key中,根据过期时间的先后进行删除,越早过期的越先被删除
# volatile-random:从已设置过期时间的key中,随机选择key淘汰
# allkeys-lru:从所有key中选择最近最少使用的进行淘汰
# allkeys-random:从所有key中随机选择key进行淘汰
# noeviction:当内存达到阈值的时候,新写入操作报错
# volatile-lfu:使用LFU算法筛选设置了过期时间的键值对删除最近一段时间被访问次数最少的数据
# allkeys-lfu:使用LFU算法在所有数据中进行筛选删除最近一段时间被访问次数最少的数据
maxmemory_policy noeviction # 当达到maxmemory时的淘汰策略
慢查询日志
1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 查询有关慢日志的配置信息
config get slow*
# 设置慢日志使时间阈值,单位微秒,此处为20毫秒,即超过20毫秒的操作都会记录下来,生产环境建议设置1000,也就是1ms,这样理论上redis并发至少达到1000,如果要求单机并发达到1万以上,这个值可以设置为100
config set slowlog‐log‐slower‐than 20000
# 设置慢日志记录保存数量,如果保存数量已满,会删除最早的记录,最新的记录追加进来。记录慢查询日志时Redis会对长命令做截断操作,并不会占用大量内存,建议设置稍大些,防止丢失日志
config set slowlog‐max‐len 1024
# 将服务器当前所使用的配置保存到redis.conf
config rewrite
# 获取慢查询日志列表的当前长度
slowlog len
# 获取最新的5条慢查询日志。慢查询日志由四个属性组成:标识ID,发生时间戳,命令耗时,执行命令和参数
slowlog get 5
# 重置慢查询日志
slowlog reset
合理设置文件句柄数

操作系统进程试图打开一个文件或句柄,但现在进程打开的句柄数已经达到了上限,继续打开会报错Too many open files

1
2
ulimit ‐a 			# 查看系统文件句柄数,看open files那项
ulimit ‐n 65535 # 设置系统文件句柄数

缓存穿透

缓存穿透是指查询一个根本不存在的数据, 缓存层存储层都不会命中, 通常出于容错的考虑, 若从存储层查不到数据则不写入缓存层。缓存穿透将导致不存在的数据每次请求都要到存储层去查询, 失去了缓存保护后端存储的意义。

缓存空对象

空对象缓存过期时间设置的短一点,最长不超过5分钟

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
String get(String key) {
// 从缓存中获取数据
String cacheValue = cache.get(key);
// 缓存为空
if (StringUtils.isBlank(cacheValue)) {
// 从存储中获取
String storageValue = storage.get(key);
cache.set(key, storageValue);
// 若存储数据为空,需要设置一个过期时间(300秒)
if (storageValue == null) {
cache.expire(key, 60 * 5);
}
return storageValue;
} else {
return cacheValue; // 缓存非空
}
}
布隆过滤器

对于恶意攻击,向服务器请求大量不存在的数据造成的缓存穿透,可用布隆过滤器先做一次过滤,对于不存在的数据布隆过滤器一般都能够过滤掉,不让请求再往后端发送。布隆过滤器判定某个值存在时,该值可能不存在当判定不存在时,则肯定不存在

布隆过滤器就是一个大型位数组和几个不一样的无偏hash函数。所谓无偏就是能把元素hash值算得比较均匀。向布隆过滤器中添加key时,会使用多个hash函数对key进行hash算得一个整数索引值然后对位数组长度进行取模运算得到一个位置每个hash函数都会算得一个不同的位置。再把位数组这几个位置都置为1就完成了add操作。

向布隆过滤器询问key是否存在时,跟add一样也会把hash的几个位置都算出来,判断位数组中这几个位置是否都为1只要有一个位为0,则说明布隆过滤器中该key不存在。若都是1并不能说明该key就一定存在,只是极有可能存在,因为这些位被置为1可能是因为其它的key存在所致。若该位数组比较稀疏则存在概率就越大,若该位数组比较拥挤则存在概率就越低。

布隆过滤器适用于数据命中不高数据相对固定实时性低通常是数据集较大的应用场景,代码维护较为复杂,但是缓存空间占用很少。使用布隆过滤器需要把所有数据提前放入布隆过滤器,且在增加数据时也要往布隆过滤器里放。布隆过滤器不能删除数据,若要删除得重新初始化布隆过滤器

1
2
3
4
5
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.6.5</version>
</dependency>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class RedissonBloomFilter {
public static void main(String[] args) {
Config config = new Config();
config.useSingleServer().setAddress("redis://localhost:6379");
// 构造Redisson
RedissonClient redisson = Redisson.create(config);
RBloomFilter<String> bloomFilter = redisson.getBloomFilter("nameList");
// 初始化布隆过滤器:预计元素为100000000L,误差率为3%,根据这两个参数会计算出底层的bit数组大小
bloomFilter.tryInit(100000000L,0.03);
// 将eleven插入到布隆过滤器中
bloomFilter.add("eleven");
// 判断下面号码是否在布隆过滤器中
System.out.println(bloomFilter.contains("eleven")); //false
System.out.println(bloomFilter.contains("张三")); //false
System.out.println(bloomFilter.contains("李四")); //true
}
}

缓存失效

大批量缓存在同一时间失效可能导致大量请求同时穿透缓存直达数据库,可能会造成数据库瞬间压力过大甚至挂掉,在批量增加缓存时最好将这一批数据的缓存过期时间设置为一个时间段内的不同时间

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
String get(String key) {
// 从缓存中获取数据
String cacheValue = cache.get(key);
// 缓存为空
if (StringUtils.isBlank(cacheValue)) {
// 从存储中获取
String storageValue = storage.get(key);
cache.set(key, storageValue);
// 设置一个过期时间(300到600之间的一个随机数)
int expireTime = new Random().nextInt(300) + 300;
if (storageValue == null) {
cache.expire(key, expireTime);
}
return storageValue;
} else {
return cacheValue; // 缓存非空
}
}

缓存雪崩

缓存雪崩是指缓存层支撑不住宕掉后,大量请求打向后端存储层。由于缓存层承载着大量请求,有效地保护了存储层,但若缓存层由于某些原因不能提供服务,如超大并发缓存层支撑不住,或者由于缓存设计不好,类似大量请求访问bigkey,导致缓存能支撑的并发急剧下降,于是大量请求打到存储层,存储层调用量暴增,造成存储层也会级联宕机的情况。

预防和解决缓存雪崩问题, 可从以下三个方面进行着手。

  • 事前保证缓存层服务高可用性,比如使用Redis Sentinel哨兵模式Redis Cluster集群模式
  • 事中:依赖隔离组件为后端限流熔断并降级。如使用SentinelHystrix限流降级组件。可针对不同数据采取不同的处理方式。当业务应用访问的是非核心数据时,暂时停止从缓存中查询这些数据,而是直接返回预定义的默认降级信息、空值或是错误提示信息;当业务应用访问的是核心数据时,仍然允许查询缓存,如果缓存缺失,也可以继续通过数据库读取。
  • 事后开启Redis持久化机制,能尽快恢复缓存集群
  • 提前演练。在项目上线前,演练缓存层宕掉后,应用以及后端的负载情况以及可能出现的问题,在此基础上做一些预案设定。

热KEY重建优化

使用缓存+过期时间策略既可以加速数据读写,又保证数据定期更新,这种模式基本能够满足绝大部分需求。但若当前key是一个热点key并发量非常大,或重建缓存不能在短时间完成,可能是一个复杂计算如复杂的SQL、多次IO、多个依赖等, 可能会对应用造成致命的危害。

在缓存失效的瞬间,有大量线程来重建缓存,造成后端负载加大,甚至可能会让应用崩溃,要解决该问题主要就是要避免大量线程同时重建缓存。可利用互斥锁来解决只允许一个线程重建缓存,其他线程等待重建缓存的线程执行完,重新从缓存获取数据即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
String get(String key) {
// 从Redis中获取数据
String value = redis.get(key);
// 如果value为空, 则开始重构缓存
if (value == null) {
// 只允许一个线程重建缓存, 使用nx, 并设置过期时间ex
String mutexKey = "mutext:key:" + key;
if (redis.set(mutexKey, "1", "ex 180", "nx")) { // 分布式锁
// 从数据源获取数据
value = db.get(key);
// 回写Redis, 并设置过期时间
redis.setex(key, timeout, value);
// 删除key_mutex
redis.delete(mutexKey);
} else { // 其他线程休息50毫秒后重试
Thread.sleep(50);
get(key);
}
}
return value;
}

缓存与数据库双写不一致

在大并发下,同时操作数据库与缓存会存在数据不一致性问题

缓存与数据库双写不一致

对于并发几率很小的数据,这种几乎不用考虑该问题,很少会发生缓存不一致,可给缓存数据加上过期时间,每隔一段时间触发读的主动更新即可。就算并发很高,若业务上能容忍短时间的缓存数据不一致,缓存加上过期时间依然可以解决大部分业务对于缓存的要求。

若不能容忍缓存数据不一致,可以通过加读写锁保证并发读写写写的时候按顺序排好队,读读的时候相当于无锁。也可用阿里开源的canal通过监听数据库的binlog日志及时的去修改缓存,但是引入了新的中间件,增加了系统的复杂度。

以上针对的都是读多写少的情况加入缓存提高性能,若写多读多的情况又不能容忍缓存数据不一致,那就没必要加缓存了,可直接操作数据库。放入缓存的数据应该是对实时性、一致性要求不是很高的数据。切记不要为了用缓存,同时又要保证绝对的一致性做大量的过度设计和控制,增加系统复杂性。

性能优化

KEY设计

KEY的设计以业务名为前缀,用逗号分割,在保证语义的前提下,控制KEY的长度,不要包含空格、换行、单双引号等特殊字符。

bigkey

对于value值要拒绝bigkey防止网卡流量限制以及慢查询,对于字符串类型value超过10kb就是bigkey;非字符串类型元素个数不要超过5000非字符串的bigkey不要使用del删除,使用hscansscanzscan方式渐进式删除,同时要注意防止bigkey过期时间自动删除问题

bigkey会导致redis阻塞、网络拥堵等问题,每次获取要产生的网络流量较大,一般服务器会采用单机多实例的方式来部署,bigkey可能会对其他实例也造成影响。过期删除若未使用Redis 4.0的过期异步删除lazyfree-lazy-expire yes,则可能阻塞Redis。可通过bigkey拆分成几个段储存从而解决bigkey问题。

命令使用

O(N)命令关注N的数量,如hgetalllrangesmemberszrangesinter等并非不能使用,但是需要明确N的值,有遍历的需求可使用hscansscanzscan代替,禁止线上使用keysflushallflushdb等,通过redis的rename机制禁掉命令,或者使用scan的方式渐进式处理。

redis的多数据库较弱,使用数字进行区分,很多客户端支持较差,同时多业务用多数据库实际还是单线程处理会有干扰,要合适使用数字进行分区。

过期策略

惰性删除 | 被动删除

当读或写key时,才对key进行检测,若已经达到过期时间,则删除。若这些过期的key没有被访问,那么他就一直无法被删除,而且一直占用内存。

定期删除 | 主动删除

每隔一段时间对数据库做一次检查,删除里面的过期key。由于不可能对所有key去做轮询来删除,所以redis会每次随机取一些key去做检查和删除。

当前已用内存超过maxmemory限定时,触发主动清理策略

定期+惰性都没有删除过期的key

每次定期随机查询key的时候没有删掉,这些key也没有做查询的话,就会导致这些key一直保存无法被删除,这时候就会走到redis内存淘汰机制

  • volatile-lru:从已设置过期时间的key中,移出最近最少使用的key进行淘汰
  • volatile-ttl:从已设置过期时间的key中,根据过期时间的先后进行删除,越早过期的越先被删除
  • volatile-random:从已设置过期时间的key中,随机选择key淘汰
  • allkeys-lru:从所有key中选择最近最少使用的进行淘汰
  • allkeys-random:从所有key中随机选择key进行淘汰
  • noeviction:当内存达到阈值的时候,新写入操作报错
  • volatile-lfu:使用LFU算法筛选设置了过期时间的键值对删除最近一段时间被访问次数最少的数据
  • allkeys-lfu:使用LFU算法在所有数据中进行筛选删除最近一段时间被访问次数最少的数据

LRU & LFU

LRU算法是以最近一次访问时间作为参考淘汰很久没被访问过的数据,LFU算法以次数作为参考淘汰最近一段时间被访问次数最少的数据

存在热点数据时LRU的效率很好,但偶发性周期性批量操作会导致LRU命中率急剧下降,缓存污染情况比较严重。这时使用LFU可能更好点。

根据自身业务类型,配置好maxmemory-policy,默认是noeviction推荐使用volatile-lru。若不设置最大内存,当Redis内存超出物理内存限制时,内存数据会开始和磁盘产生频繁的交换swap,会让Redis性能急剧下降。当Redis运行在主从模式时,只有主结点才会执行过期删除策略,然后把删除操作del key同步到从结点删除数据。

连接池预热

使用带有连接池的数据库,可以有效控制连接,同时提高效率

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
List<Jedis> minIdleJedisList = new ArrayList<Jedis>(jedisPoolConfig.getMinIdle());
for (int i = 0; i < jedisPoolConfig.getMinIdle(); i++) {
Jedis jedis = null;
try {
jedis = pool.getResource();
minIdleJedisList.add(jedis);
jedis.ping();
} catch (Exception e) {
logger.error(e.getMessage(), e);
} finally {
//注意,这里不能马上close将连接还回连接池,否则最后连接池里只会建立1个连接。。
//jedis.close();
}
}
//统一将预热的连接还回连接池
for (int i = 0; i < jedisPoolConfig.getMinIdle(); i++) {
Jedis jedis = null;
try {
jedis = minIdleJedisList.get(i);
//将连接归还回连接池
jedis.close();
} catch (Exception e) {
logger.error(e.getMessage(), e);
} finally {
}
}