多级缓存实现服务高可用背后缓存雪崩和缓存穿透问题的解决方案

多级缓存架构实现服务高可用

传统web服务架构模式

请求从浏览器发起,到达Nginx代理服务器,可以加载静态资源,如html、js、css等;如果是请求动态内容,就会请求tomcat服务,从缓存、数据库、文件服务器加载。有一部分访问量大的数据,可以使用模板引擎技术,在数据发生变更时把动态数据加载到模板中,生成静态页面内容,当请求访问这部分数据时就可以直接加载静态页面内容了。

问题:随着业务量的增加,需要静态化处理的页面达到成千上万个时,每次数据变化都要静态化成千上万个页面,这个静态化操作过程的耗时量可想而知,而且会因为页面静态化过程大大降低Tomcat容器的吞吐量,降低性能。

双层Nginx+多级缓存架构设计

双层Nginx架构

为了避免大量页面使用模板技术进行静态化带来的严重性能问题,使用Nginx+LUA脚本作为分发层,LUA脚本对请求的特殊参数进行与Nginx应用层数量取模运算,把请求分发到Nginx应用层;使用Nginx+LUA+Cache+模板进行应用层缓存处理,LUA脚本完成对应用层Tomcat接口的请求,并把返回数据写入Nginx本地缓存中,Nginx本地缓存的数据读取出来后渲染到模板页面,完成页面显示;通过以上两步操作,完成双层Nginx架构设计。这样频繁访问的热数据加载到应用层Nginx缓存中,并使用模板技术把缓存内容显示出来,请求从Nginx应用层中获取数据就返回了,不需要到达后端缓存服务或者数据库服务,提高了系统架构的健壮性。

分发层Nginx的LUA脚本代码:

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
local uri_args = ngx.req.get_uri_args()
local ecourseId = uri_args["ecourseId"]

local hosts = {"192.168.1.210", "192.168.1.211"}
local hash = ngx.crc32_long(ecourseId)
local index = (hash % 2) + 1
backend = "http://"..hosts[index]

local requestPath = uri_args["requestPath"]
requestPath = "/"..requestPath.."?ecourseId="..ecourseId

local http = require("resty.http")
local httpc = http.new()

local resp, err = httpc:request_uri(backend,{
method = "GET",
path = requestPath
})

if not resp then
ngx.say("request error: ", err)
return
end

ngx.say(resp.body)

httpc:close()

应用层Nginx的LUA脚本代码:

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
54
55
56
57
58
59
60
61
local uri_args = ngx.req.get_uri_args()
local ecourseId = uri_args["ecourseId"]
local authorId = uri_args["authorId"]

local cache_ngx = ngx.shared.my_cache

local ecourseCacheKey = "ecourse_info_"..ecourseId
local authorCacheKey = "author_info_"..authorId

local ecourseCache = cache_ngx:get(ecourseCacheKey)
local authorCache = cache_ngx:get(authorCacheKey)

if ecourseCache == "" or ecourseCache == nil then
local http = require("resty.http")
local httpc = http.new()

local resp, err = httpc:request_uri("http://192.168.31.179:8080",{
method = "GET",
path = "/getEcourseInfo?ecourseId="..ecourseId
})

ecourseCache = resp.body
math.randomseed(tostring(os.time()):reverse():sub(1,7))
local expireTime = math.random(600,1200)
cache_ngx:set(ecourseCacheKey, ecourseCache, expireTime)
end

if authorCache == "" or authorCache == nil then
local http = require("resty.http")
local httpc = http.new()

local resp, err = httpc:request_uri("http://192.168.31.179:8080",{
method = "GET",
path = "/getEcourseAuthorInfo?authorId="..authorId
})

authorCache = resp.body
cache_ngx:set(authorCacheKey, authorCache, 10 * 60)
end

local cjson = require("cjson")
local ecourseCacheJSON = cjson.decode(ecourseCache)
local authorCacheJSON = cjson.decode(authorCache)

local context = {
ecourseId = ecourseCacheJSON.id,
ecourseName = ecourseCacheJSON.name,
ecoursePrice = ecourseCacheJSON.price,
ecoursePictureList = ecourseCacheJSON.pictureList,
ecourseSpecification = ecourseCacheJSON.specification,
ecourseService = ecourseCacheJSON.service,
ecourseColor = ecourseCacheJSON.color,
ecourseSize = ecourseCacheJSON.size,
authorId = authorCacheJSON.id,
authorName = authorCacheJSON.name,
authorLevel = authorCacheJSON.level,
authorGoodCommentRate = authorCacheJSON.goodCommentRate
}

local template = require("resty.template")
template.render("ecourse.html", context)

PS: OpenResty是打包了resty和lua模块的Nginx版本,能直接使用LUA脚本进行简单开发动态程序。

多级缓存架构

为避免缓存失效后大量的数据访问请求到达MySQL数据库服务层导致MySQL服务被冲垮,需要在数据访问请求到达MySQL服务层之前进行多级缓存处理。在Nginx应用层已经实现了Cache缓存模板数据,在后端应用层中对数据缓存服务进行多层化设计,例如拆分成Redis共享缓存和Ehcache本地缓存,Ehcache缓存作为Redis共享缓存的补充,当应用层Nginx的请求到达缓存服务时,先查询Redis共享缓存中的数据,如果Redis中没有数据,再查询Ehcache本地缓存数据;如果Redis中有数据,就要写入Ehcache本地缓存,并返回请求;如果Redis共享缓存和Ehcache本地缓存中都没有数据,再调用数据生产服务接口从MySQL数据库中获取。

Redis共享缓存+Ehcache本地缓存代码:

1
2
3
4
5
6
7
8
9
10
ECourseInfo eCourseInfo = ecCacheService.getECoureseInfoFromRedis(ecourseId);
log.info("=======从redis中查询课程信息=========");
if (eCourseInfo == null) {
log.info("=======从ehcache中查询课程信息=========");
eCourseInfo = ecCacheService.getECoureseInfoFromEhcache(ecourseId);
}

if (eCourseInfo == null) {
log.info("=======调用MySQL数据生产服务接口获取eCourseInfo数据=======")
}

Ehcache本地缓存配置ehcache.xml:

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
<?xml version="1.0" encoding="UTF-8"?>
<ehcache xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="http://ehcache.org/ehcache.xsd"
updateCheck="false">

<diskStore path="java.io.tmpdir/Tmp_EhCache" />

<!-- 可以配置多个缓存策略,但是如果没有其他的缓存策略,则使用defaultCache缓存策略-->
<!--eternal过期是否可用,false有过期时间,true数据永不过期-->
<!--maxElementsInMemory 存储对象的个数,需要存储对象的大小计算可存储多少个对象 -->
<!-- memoryStoreEvictionPolicy 当达到maxElementsInMemory限制时,Ehcache将会根据指定的策略去清理内存。默认策略是LRU(最近最少使用)。-->
<defaultCache
eternal="false"
maxElementsInMemory="1000"
overflowToDisk="false"
diskPersistent="false"
timeToIdleSeconds="0"
timeToLiveSeconds="0"
memoryStoreEvictionPolicy="LRU" />

<cache
name="local"
eternal="false"
maxElementsInMemory="1000"
overflowToDisk="false"
diskPersistent="false"
timeToIdleSeconds="0"
timeToLiveSeconds="0"
memoryStoreEvictionPolicy="LRU" />

</ehcache>

按照这样的设计,由Nginx本地缓存+Redis共享缓存+缓存服务Ehcache本地缓存构成三级缓存架构,也可以根据实际业务需求,增加更多的缓存层,保护后端MySQL服务。

缓存雪崩问题及对策

缓存雪崩

通过多级缓存策略降低请求对后端Mysql服务的请求数量,但是当多级缓存在高并发场景下出现某个缓存服务接口不可用,大量的请求Blocking在这个接口上,耗费大量的缓存服务器资源,出现连锁反应,导致其他的缓存服务接口不可用,甚至导致缓存服务层全部瘫痪,所有的网络请求如果都被缓存服务层Blocking住,那么处于同一web容器内的所有服务会直接瘫痪;如果网络请求能够绕过缓存服务,所有的请求流量到达MySQL服务接口,那么就会使MySQL的瞬时连接数飙升,很快出现服务宕机,这样的场景是灾难性的,公司的经济损失也是不可估量的。

Redis数据接口请求隔离和服务降级

防止Redis服务接口因为网络问题导致接口服务阻塞或者宕机,可以对Redis的服务接口使用Hystrix进行请求资源隔离和服务降级。

Hystrix Dashboard配置

Hystrix Dashboard可以对Hystrix Command接口监控,收集接口的执行性能数据,例如TP90、TP99、TP99.5的QPS数据。

在Hystrix Command所在工程内添加Hystrix Metrics依赖包,实现对接口的扫描监控

1
2
3
4
5
<dependency>
<groupId>com.netflix.hystrix</groupId>
<artifactId>hystrix-metrics-event-stream</artifactId>
<version>1.5.12</version>
</dependency>

注册Hystrix Dashboard收集数据使用的HystrixMeticsStreamServlet

1
2
3
4
5
6
7
@Bean
public ServletRegistrationBean servletRegistrationBean(){
ServletRegistrationBean servletRegistrationBean = new ServletRegistrationBean();
servletRegistrationBean.setServlet(new HystrixMetricsStreamServlet());
servletRegistrationBean.addUrlMappings("/hystrix.stream");
return servletRegistrationBean;
}

配置Hystrix应用集群监控的Turbine,在Turbine的TURBINE-HOME/WEB-INF/classes下创建config.properties

1
2
turbine.ConfigPropertyBasedDiscovery.default.instances=192.68.1.211,192.68.1.212
turbine.instanceUrlSuffix=:8081/hystrix.stream

启动Hystrix Dashboard 和Turbine两个web应用,Hystrix Dashboard用来显示收集的数据,自身实现单点监控,Turbine实现对Hystrix应用集群的监控。

从图片中可以看出每个HystrixCommand执行的TP90、TP99、TP99.5耗时,从而也能计算出每个Command的QPS,根据Dashboard提供的数据,可以进一步优化HystrixCommandThreadPool配置。Hystrix生产环境配置实践

Redis接口限流和降级

使用Hystrix多级降级,并且在二级降级的HystrixCommand中实现fallback silent 或者 fallback stubbed,将Redis的服务接口隔离在单独的线程池内执行,即使当前Redis接口宕机,也不会占用其他接口资源,保证整个web服务的高可用。

特别注意:多级降级时,嵌套的内部Command不能使用外层Command的GroupKey,因为外层Command进行降级请求,很有可能是它所在的线程池资源已耗尽,如果嵌套的内部Command继续使用相同点的GroupKey,嵌套的内部Command就无法获取空闲的线程资源执行Command,导致内嵌Command对应的Hystrix CircuitBreaker直接短路,所有请求都被Reject,执行内嵌Command的fallback降级逻辑,这样多级降级就不发生作用了。

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
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
@Slf4j
public class GetECourseInfoFromRedisCmmd extends HystrixCommand<ECourseInfo> {
private int pkid;

public GetECourseInfoFromRedisCmmd(int pkid){
super(Setter.withGroupKey(HystrixCommandGroupKey.Factory.asKey("RedisGroup"))
.andCommandKey(HystrixCommandKey.Factory.asKey("GetECourseInfoFromRedisCmmd"))
.andThreadPoolPropertiesDefaults(HystrixThreadPoolProperties.defaultSetter()
//高峰时段QPS*TP99耗时+buffer,例如30QPS*0.2S+4=10;
.withCoreSize(10)
.withAllowMaximumSizeToDivergeFromCoreSize(true)
.withMaximumSize(30)
.withKeepAliveTimeMinutes(1)
.withMaxQueueSize(10)
.withQueueSizeRejectionThreshold(15))
.andCommandPropertiesDefaults(HystrixCommandProperties.defaultSetter()
//TP99.5延迟+buffer,例如100ms+50ms
.withExecutionTimeoutInMilliseconds(150)
//根据业务而定
.withCircuitBreakerErrorThresholdPercentage(30)
//10秒内一个窗口处理的请求数,高峰QPS*10;
.withCircuitBreakerRequestVolumeThreshold(40)
.withCircuitBreakerSleepWindowInMilliseconds(60*1000)));
this.pkid = pkid;
}
@Override
protected ECourseInfo run() throws Exception {
JedisCluster jedisCluster = (JedisCluster) SpringContext.getContext().getBean("jedisClusterFactory");
String key = "ecourse_info_"+pkid;
String s = jedisCluster.get(key);
ECourseInfo eCourseInfo = JSONObject.parseObject(s, ECourseInfo.class);
return eCourseInfo;
}

@Override
protected ECourseInfo getFallback() {
return new GetECourseInfoFromBakRedisCmmd(pkid).execute();
}

private static class GetECourseInfoFromBakRedisCmmd extends HystrixCommand<ECourseInfo> {
private int pkid;

public GetECourseInfoFromBakRedisCmmd(int pkid){
super(Setter.withGroupKey(HystrixCommandGroupKey.Factory.asKey("RedisBakGroup"))
.andCommandKey(HystrixCommandKey.Factory.asKey("GetECourseInfoFromBakRedisCmmd"))
.andThreadPoolPropertiesDefaults(HystrixThreadPoolProperties.defaultSetter()
//高峰时段QPS*TP99耗时+buffer,例如30QPS*0.2S+4=10;
.withCoreSize(10)
.withAllowMaximumSizeToDivergeFromCoreSize(true)
.withMaximumSize(30)
.withKeepAliveTimeMinutes(1)
.withMaxQueueSize(10)
.withQueueSizeRejectionThreshold(15))
.andCommandPropertiesDefaults(HystrixCommandProperties.defaultSetter()
//TP99.5延迟+buffer,例如100ms+50ms
.withExecutionTimeoutInMilliseconds(150)
//根据业务而定
.withCircuitBreakerErrorThresholdPercentage(30)
//10秒内一个窗口处理的请求数,高峰QPS*10;
.withCircuitBreakerRequestVolumeThreshold(40)
.withCircuitBreakerSleepWindowInMilliseconds(60*1000)));
this.pkid = pkid;
}
@Override
protected ECourseInfo run() throws Exception {
JedisCluster jedisCluster = (JedisCluster) SpringContext.getContext().getBean("jedisClusterFactory");
String key = "ecourse_info_"+pkid;
String s = jedisCluster.get(key);
ECourseInfo eCourseInfo = JSONObject.parseObject(s, ECourseInfo.class);
return eCourseInfo;
}

/**
* fail silent
* @return
*/
@Override
protected ECourseInfo getFallback() {
return null;
}
}
}

数据生产服务层请求隔离和服务降级

数据缓存服务层,除了要跨网络请求Redis接口数据,还要跨网络请求数据生产服务的接口数据。如果大量的请求涌入数据生产服务,除了会给后端数据库带来高并发压力外,还有可能因为缓存层的高并发占用缓存服务接口的资源,导致缓存层服务宕机。

数据生产服务接口多级降级和最终Fallback Stubbed

内外两层Command不能使用相同的GroupKey,原理与缓存服务层的Redis数据接口的多级降级配置原理相同。外层Command调用数据生产服务接口获取数据,第一次降级请求云端历史数据(或其他冷备历史数据),第二次降级实现残缺数据降级,根据请求参数中的数据组装一些初级数据返回。

第二级降级一定要实现Fallback Stubbed,这样才能保证客户端请求有数据返回。

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
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
@Slf4j
public class GetECourseInfoFromServiceCmmd extends HystrixCommand<ECourseInfo> {
private Integer ecourseId;

public GetECourseInfoFromServiceCmmd(Integer ecourseId){
super(Setter.withGroupKey(HystrixCommandGroupKey.Factory.asKey("ECourseInfoServiceGroup"))
.andCommandKey(HystrixCommandKey.Factory.asKey("GetECourseInfoFromServiceCmmd"))
.andThreadPoolPropertiesDefaults(HystrixThreadPoolProperties.defaultSetter()
//高峰时段QPS*TP99耗时+buffer,例如30QPS*0.2S+4=10;
.withCoreSize(10)
.withAllowMaximumSizeToDivergeFromCoreSize(true)
.withMaximumSize(30)
.withKeepAliveTimeMinutes(1)
.withMaxQueueSize(10)
.withQueueSizeRejectionThreshold(15))
.andCommandPropertiesDefaults(HystrixCommandProperties.defaultSetter()
//TP99.5延迟+buffer,例如100ms+50ms
.withExecutionTimeoutInMilliseconds(150)
//根据业务而定
.withCircuitBreakerErrorThresholdPercentage(30)
//10秒内一个窗口处理的请求数,高峰QPS*10;
.withCircuitBreakerRequestVolumeThreshold(40)
.withCircuitBreakerSleepWindowInMilliseconds(60*1000)));
this.ecourseId = ecourseId;
}

@Override
protected ECourseInfo run() throws Exception {

RestTemplate restTemplate = (RestTemplate) SpringContext.getContext().getBean("restTemplate");
ECourseInfo ecourseInfo = restTemplate.getForObject("http://localhost:9090/ecourse?ecourseId=" + ecourseId, ECourseInfo.class);
return ecourseInfo;
}

@Override
protected ECourseInfo getFallback() {
return super.getFallback();
}

private static class GetECourseInfoFromCloudDataCmmd extends HystrixCommand<ECourseInfo>{
private Integer ecourseId;

public GetECourseInfoFromCloudDataCmmd(Integer ecourseId){
super(Setter.withGroupKey(HystrixCommandGroupKey.Factory.asKey("ECourseInfoCloudDataGroup"))
.andCommandKey(HystrixCommandKey.Factory.asKey("GetECourseInfoFromServiceCmmd"))
.andThreadPoolPropertiesDefaults(HystrixThreadPoolProperties.defaultSetter()
//高峰时段QPS*TP99耗时+buffer,例如30QPS*0.2S+4=10;
.withCoreSize(10)
.withAllowMaximumSizeToDivergeFromCoreSize(true)
.withMaximumSize(30)
.withKeepAliveTimeMinutes(1)
.withMaxQueueSize(10)
.withQueueSizeRejectionThreshold(15))
.andCommandPropertiesDefaults(HystrixCommandProperties.defaultSetter()
//TP99.5延迟+buffer,例如100ms+50ms
.withExecutionTimeoutInMilliseconds(150)
//根据业务而定
.withCircuitBreakerErrorThresholdPercentage(30)
//10秒内一个窗口处理的请求数,高峰QPS*10;
.withCircuitBreakerRequestVolumeThreshold(40)
.withCircuitBreakerSleepWindowInMilliseconds(60*1000)));
this.ecourseId = ecourseId;
}

@Override
protected ECourseInfo run() throws Exception {
RestTemplate restTemplate = (RestTemplate) SpringContext.getContext().getBean("restTemplate");
ECourseInfo ecourseInfo = restTemplate.getForObject("http://localhost:9990/ecourse?ecourseId=" + ecourseId, ECourseInfo.class);
return ecourseInfo;
}

/**
* 实现fallback stubbed残缺降级
* @return
*/
@Override
protected ECourseInfo getFallback() {
ECourseInfo eCourseInfo = new ECourseInfo();
eCourseInfo.setPkid(this.ecourseId);
eCourseInfo.setModifyTime(new Date());
eCourseInfo.setAuthorId(11111);
eCourseInfo.setName("stubbed fallback ecourse name");
eCourseInfo.setPrice(123.33);
return eCourseInfo;
}
}
}

缓存穿透问题及对策

缓存穿透

当一个请求从浏览器经过三级缓存查询后,没有查到数据,就会到达数据库层查询数据,如果数据库也没有查到数据就返回NULL了。如果大量的这类请求并发访问服务接口,必然会导致请求到达数据库接口层,增大数据库的压力,这个现象就是缓存穿透。

后端缓存层缓存空数据

只要把数据库返回的Null数据结合请求参数放入缓存层,这样相同参数的请求再查询数据时,就能从缓存层获得空数据,而不用去查询数据库。而对应参数的数据在数据库中产生时,及时删除缓存即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//如果从两级缓存中还是查不到,应该去数据生产服务中查询,同一个缓存服务实例接收到多个并发查询并更新redis缓存,就可能出现因为执行先后造成的新数据被旧数据覆盖的冲突问题。并发请求改为异步内存队列处理。
if (eCourseInfo == null) {
//从数据管理服务查询数据,存入内存队列,异步按顺序写入缓存。
GetECourseInfoFromServiceCmmd getECourseInfoFromServiceCmmd = new GetECourseInfoFromServiceCmmd(ecourseId);
eCourseInfo = getECourseInfoFromServiceCmmd.execute();

//防止多级缓存被穿透,即使从课程服务接口从数据库中没有查询到课程信息,也要在此处实例化一个对象 爆炸
if (eCourseInfo == null) {
eCourseInfo = new ECourseInfo(ecourseId);
}

//放入队列,等待队列处理线程把数据更新到redis
//此处,线程执行保存数据到redis中时,可能有分布式并发问题,所以要使用分布式锁,解决并发冲突。
MultiThreadUpdateRedisQueue.Singleton.getInstance().put(eCourseInfo);
}
谢谢你请我吃糖果