抱歉,您的浏览器无法访问本站
本页面需要浏览器支持(启用)JavaScript
了解详情 >

Redis缓存使用以及注意事项

缓存

​ 缓存,就是将一些需要读取数据放在磁盘或者内存中,由于是追求速度,从而一般放在内存中。

​ 在读取数据的时候,一般是从关系型数据库中读取数据,在数据库层面也可以进行各种优化,例如读性能不足,那么可以添加几个从库,从而数据库的一主多从;例如写性能不足,那么可以分库分表。

​ 在有些场景中,要使用缓存,是因为无法解决读的速度,例如count(*)的操作,无论从数据库的层面如何优化,都不可能提高;还有一种就是sql的执行本身就必须消耗很多资源和时间,例如各种关联查询子查询,这些时候,都可以将这些数据放在缓存当中,从而大大的减轻数据库的压力。

缓存的收益与成本

收益

  • 通过缓存加速读写速度。在内存中读写比硬盘速度快
  • 降低数据库服务器的负载。比如业务端的请求的数据大多数都由Redis服务器来处理,大大减轻MySQL服务器的压力

成本

  • 数据不一致问题,比如Redis服务器与数据库服务器之间的某些数据可能会发生不一致问题,这是由两个服务器的数据更新策略不同引起的
  • 代码维护成本,需要添加数据缓存的逻辑代码
  • 运维成本,比如需要维护RedisCluster

使用场景

  • 使用缓存来降低关系型数据库服务器的负载,比如将某些业务需要读写的数据库服务器中的一些数据存储到缓存服务器中,然后这部分业务就可以直接通过缓存服务器进行数据读写
  • 加快请求响应时间,Redis的数据是存储在内存中的,所以可以大大提高IO响应速度
  • 对于关系型数据库服务器的大量写操作,可以先由Redis服务器进行批量写操作,然后再将Redis服务器中批量写操作的结果写入到数据库服务器中。比如计数器操作,如果要做1千万次计数,不可能每次都要对数据库服务器进行update操作,可以在Redis服务器中通过incr key命令进行计数,批量执行完成后再将最后的结果写入数据库服务器

缓存更新策略

  • 超时删除数据:也就是设置key的过期时间,比如通过expire命令设置的超时key,该策略数据一致性较低,但维护成本也低

  • LRU/LFU/FIFO算法剔除:主要是针对当Redis的缓存数据达到设置的最大内存如何处理的策略,该策略数据一致性很低,但维护成本也低

  • 主动更新:在开发过程中通过编写逻辑代码,控制数据更新,数据一致性高,但维护成本也高

  • 使用情况:数据一致性要求低就使用最大内存时数据淘汰策略,如果数据一致性要求高,就将主动更新和超时删除结合使用,最大内存时数据淘汰策略保底

缓存粒度控制

缓存粒度

​ 以用户信息为例,在MySQL中用户表包含多个字段,通过select语句查询获得用户信息时,究竟是选择缓存用户每个字段的数据,还是选择某几个重要字段的数据进行缓存,缓存所有字段或部分字段就是指缓存粒度,可以理解为缓存粒度就是指缓存对象的数据的完整性

缓存粒度控制

  • 从通用性角度来看,肯定是使用全量属性更好
  • 从占用空间角度来看,部分属性更好
  • 代码维护上来看,全量属性更好,因为如果缓存部分属性需要增删属性时,比较麻烦

缓存穿透

​ 在实际应用中,业务层会先向缓存层发出数据请求,如果过这些请求没有命中(缓存层没有请求的数据),那么就会向关系型数据库层发出请求,从关系型数据库中取出数据回写到缓存层中,并返回给业务层,在下一次请求时就可以从缓存层返回数据;但如果数据库中也没有请求的数据,那么就会返回业务层空值,在后续业务层的请求同一个数据时,缓存层始终都没有数据,那么每次都会向数据库层请求数据,这样就造成了缓存穿透。

产生缓存穿透的原因

  • 业务逻辑代码自身问题
  • 恶意攻击、爬虫等

解决缓存穿透的方法

缓存空对象

​ 即当缓存层、数据库层皆没有业务层请求的数据时,就向缓存层中写入一个null,问题就是可能会需要更多的key,一般会给这些key设置一个较短的过期时间,另一个问题就是缓存层和数据库层出现短时间的数据不一致,这个也可以通过设置过期时间解决。

布隆过滤器

​ 可以理解为将key放置在布隆过滤器中,如果请求数据的key存在,则通过请求,否则阻止请求通过。将所有可能存在的数据的key哈希到一个足够大的bitmap中,一个一定不存在的数据会被这个bitmap拦截掉,从而避免了对底层存储系统的查询压力。

缓存击穿

​ 对于一些设置了过期时间的key,如果这些key可能会在某些时间点被超高并发地访问,是一种非常“热点”的数据。这个时候,需要考虑一个问题:缓存被“击穿”的问题,这个和缓存雪崩的区别在于这里针对某一key缓存,前者则是很多key。缓存在某个时间点过期的时候,恰好在这个时间点对这个Key有大量的并发请求过来,这些请求发现缓存过期一般都会从后端DB加载数据并回设到缓存,这个时候大并发的请求可能会瞬间把后端DB压垮。

解决方案

使用互斥锁(mutex key)

​ 业界比较常用的做法,是使用mutex。简单地来说,就是在缓存失效的时候(判断拿出来的值为空),不是立即去load db,而是先使用缓存工具的某些带成功操作返回值的操作(比如Redis的SETNX或者Memcache的ADD)去set一个mutex key,当操作返回成功时,再进行load db的操作并回设缓存;否则,就重试整个get缓存的方法。

​ SETNX,是「SET if Not eXists」的缩写,也就是只有不存在的时候才设置,可以利用它来实现锁的效果。在redis2.6.1之前版本未实现setnx的过期时间,所以这里给出两种版本代码参考:

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
public String get(key) {

String value = redis.get(key);

if (value == null) { //代表缓存值过期

//设置3min的超时,防止del操作失败的时候,下次缓存过期一直不能load db

if (redis.setnx(key_mutex, 1, 3 * 60) == 1) { //代表设置成功

value = db.get(key);

redis.set(key, value, expire_secs);

redis.del(key_mutex);

} else { //这个时候代表同时候的其他线程已经load db并回设到缓存了,这时候重试获取缓存值即可

sleep(50);

get(key); //重试

}

} else {

return value;

}

}
“提前”使用互斥锁(mutex key)

​ 在value内部设置1个超时值(timeout1), timeout1比实际的memcache timeout(timeout2)小。当从cache读取到timeout1发现它已经过期时候,马上延长timeout1并重新设置到cache。然后再从数据库加载数据并设置到cache中。伪代码如下:

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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
v = memcache.get(key);  
if (v == null) {

if (memcache.add(key_mutex, 3 * 60 * 1000) == true) {

value = db.get(key);

memcache.set(key, value);

memcache.delete(key_mutex);

} else {

sleep(50);

retry();

}

} else {

if (v.timeout <= now()) {

if (memcache.add(key_mutex, 3 * 60 * 1000) == true) {

// extend the timeout for other threads

v.timeout += 3 * 60 * 1000;

memcache.set(key, v, KEY_TIMEOUT * 2);


// load the latest value from db

v = db.get(key);

v.timeout = KEY_TIMEOUT;

memcache.set(key, value, KEY_TIMEOUT * 2);

memcache.delete(key_mutex);

} else {

sleep(50);

retry();

}

}

}
永远不过期

这里的“永远不过期”包含两层意思:

(1) 从redis上看,确实没有设置过期时间,这就保证了,不会出现热点key过期问题,也就是“物理”不过期。

(2) 从功能上看,如果不过期,那不就成静态的了吗?所以我们把过期时间存在key对应的value里,如果发现要过期了,通过一个后台的异步线程进行缓存的构建,也就是“逻辑”过期

从实战看,这种方法对于性能非常友好,唯一不足的就是构建缓存时候,其余线程(非构建缓存的线程)可能访问的是老数据,但是对于一般的互联网功能来说这个还是可以忍受。

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
40
41
String get(final String key) {  

V v = redis.get(key);

String value = v.getValue();

long timeout = v.getTimeout();

if (v.timeout <= System.currentTimeMillis()) {

// 异步更新后台异常执行

threadPool.execute(new Runnable() {

public void run() {

String keyMutex = "mutex:" + key;

if (redis.setnx(keyMutex, "1")) {

// 3 min timeout to avoid mutex holder crash

redis.expire(keyMutex, 3 * 60);

String dbValue = db.get(key);

redis.set(key, dbValue);

redis.delete(keyMutex);

}

}

});

}

return value;

}
资源保护

采用netflix的hystrix,可以做资源的隔离保护主线程池,如果把这个应用到缓存的构建也未尝不可。

总结

四种解决方案:没有最佳只有最合适

解决方案 优点 缺点
简单分布式互斥锁(mutex key) 1. 思路简单2. 保证一致性 1. 代码复杂度增大2. 存在死锁的风险3. 存在线程池阻塞的风险
“提前”使用互斥锁 1. 保证一致性 同上
不过期(本文) 1. 异步构建缓存,不会阻塞线程池 1. 不保证一致性。2. 代码复杂度增大(每个value都要维护一个timekey)。3. 占用一定的内存空间(每个value都要维护一个timekey)。
资源隔离组件hystrix(本文) 1. hystrix技术成熟,有效保证后端。2. hystrix监控强大。 1. 部分

缓存雪崩

​ 如果缓存中部分key集中在一段时间内失效,发生大量的缓存穿透,所有的查询都落在数据库上,造成了缓存雪崩。造成这个问题的原因除了是key失效以外,还可能是缓存集群宕机 。

​ 缓存最关键的地方就是内存,当内存满了之后,会有各种策略将缓存进行失效,在分布式环境下,如果有一个缓存失效,而恰好这个缓存是一个热点数据,前端有10个应用都需要访问这个缓存,并且TPS很高的话,那么全部的线程都会去访问数据库,从而能直接将数据库拖垮。

解决方案

永远不过期

参考 缓存击穿 永远不过期

使用互斥锁(mutex key)

参考 缓存击穿 使用互斥锁(mutex key)

“提前”使用互斥锁(mutex key)

参考 缓存击穿 “提前”使用互斥锁(mutex key)

随机时间失效

​ 可以在原有的失效时间基础上增加一个随机值,比如1-5分钟随机,这样每一个缓存的过期时间的重复率就会降低,就很难引发集体失效的事件。

无底洞问题

​ 通常情况下,可以通过增加集群部署的机器数量来提升性能,但是在2010年,FaceBook发现在部署了3000个节点后发现性能反而下降;也就是说,集群中有更多的机器不代表有更好的性能,但随着数据量和并发处理量的提升,又必须提升集群的机器数量,这就是无底洞问题,这个问题没有好的解决办法,只能是通过在细节方面的优化处理来尽量提高性能,比如优化IO操作、优化Redis集群中的批量命令执行等

评论