avatar

聚焦Java性能优化 打造亿级流量秒杀系统【学习笔记】04_查询性能优化技术之多级缓存

5-1 本章目标

  • 掌握多级缓存的定义
  • 掌握redis缓存,本地缓存
  • 掌握热点nginx lua缓存

5-2 缓存设计原则概览

缓存设计原则:

  • 用快速存取设备,用内存
  • 将缓存推到离用户最近的地方
  • 脏缓存清理

我们的项目采用多级缓存的架构

  • 第一级 Redis缓存

Redis缓存有集中管理缓存的特点,是常见NoSql数据库组件

  • 第二级 热点缓存本地缓存

热点数据存到JVM本地缓存中

  • 第三级 nginx proxy cache缓存

所有数据最后都会在nginx服务器上做反向代理,nginx服务器也可以开启proxy cache缓存

  • 第四级 nginx lua缓存

nginx定制lua脚本做nginx内存缓存

5-3 Redis集中式缓存介绍

Redis是一个NoSql 基于Key-valule数据库的中间件,是易失的

Redis缓存的几种形式:

  • 单机版:单个redis,目前项目就采用这种设计

优点:架构简单,方便,高性能

缺点:缓存中使用,重启后会丢失,数据丢失,即使有备用的节点解决高可用性,但是仍然不能解决缓存预热问题,因此不适用于数据可靠性要求高的业务+受CPU处理能力限制,CPU性能有瓶颈

1. Redis sentinal哨兵模式

Sentinel(哨兵)是用于监控redis集群中Master状态的工具,是Redis 的高可用性解决方案,sentinel哨兵模式已经被集成在redis2.4之后的版本中。

sentinel系统可以监视一个或者多个redis master服务,以及这些master服务的所有从服务;当某个master服务下线时,自动将该master下的某个从服务升级为master服务替代已下线的master服务继续处理请求

sentinel可以让redis实现主从复制,当一个集群中的master失效之后,sentinel可以选举出一个新的master用于自动接替master的工作,集群中的其他redis服务器自动指向新的master同步数据。一般建议sentinel采取奇数台,防止某一台sentinel无法连接到master导致误切换。其结构如下:

Redis支持主从同步机制,redis2作为redis1的slave从机,同步复制master的内容,当其中一个数据库宕机,应用服务器是很难直接通过找地址来切换成redis2,这时就用到了redis sentinal 哨兵机制。sentinal与redis1和redis2建立长连接,与主机连接是心跳机制,miaosha.jar无需知道redis1,redis2主从关系,只需ask redis sentinal,之后sentinal就response回应redis1为master,redis2为slave

一旦发生redis1坏掉或者发生网络异常,心跳机制就会破坏掉,sentinal更改redis2为master,redis1为slave,变换主从关系,然后发送change给应用服务器,然后miaosha.jar就向redis2进行get、set操作(或者redis读写分离,在master上set,slave上get)——redis 哨兵机制

总结一下:

Sentinal作用:

  • Master状态检测
  • 如果Master异常,则会进行Master-Slave切换,将其中一个Slave作为Master,将之前的Master作为Slave

2.Redis集群cluster模式

一般情况下,使用主从模式加Sentinal监控就可以满足基本需求了,但是当数据量过大一个主机放不下的时候,就需要对数据进行分区,将key按照一定的规则进行计算,并将key对应的value分配到指定的Redis实例上,这样的模式简称Redis集群。

cluster集群配置有多个slave用来读,master用来写,各种redis服务器彼此知道相互关系。

cluster好处:

  • 将数据自动切分到多个节点
  • 当集群某台设备故障时,仍然可以处理请求
  • 节点的fail是集群中超过半数的节点检测失效时才生效

cluster故障转移

1. 节点故障判断

首先,在Redis Cluster中每个节点都存有集群中所有节点的信息。它们之间通过互相ping-pong判断节点是否可以连接。如果有一半以上的节点去ping一个节点的时候没有回应,集群就认为这个节点宕机。

2.slave选举

 当主节点被集群公认为fail状态,那么它的从节点就会发起竞选,如果存在多个从节点,数据越新的节点越有可能发起竞选。集群中其他主节点返回响应信息。

3.结构变更

当竞选从节点收到过半主节点同意,便会成为新的主节点。此时会以最新的Epoch通过PONG消息广播,让Redis Cluster的其他节点尽快的更新集群信息。当原主节点恢复加入后会降级为从节点。

Redis cluster 高可用性

1. 主节点保护

 当集群中某节点中的所有从实例宕机时,Redis Cluster会将其他节点的非唯一从实例进行副本迁移,成为此节点的从实例。
  这样集群中每个主节点至少有一个slave,使得Cluster 具有高可用。集群中只需要保持 2*master+1 个节点,就可以保持任一节点宕机时,故障转移后继续高可用。

2. 集群fail条件

Redis Cluster保证基本可用的特性,在达到一定条件时才会认定为fail:
1、某个主节点和所有从节点全部挂掉,则集群进入fail状态。
2、如果集群超过半数以上主节点挂掉,无论是否有从节点,集群进入fail状态。
3、如果集群任意主节点挂掉,且当前主节点没有从节点,集群进入fail状态。

5-4 Redis集中式缓存商品详情页接入

在商品详情页添加redis缓存

ItemController:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//商品详情页浏览
@RequestMapping(value = "/get",method = RequestMethod.GET) //浏览时服务端用GET请求
@ResponseBody
public CommonReturnType getItem(@RequestParam(name = "id") Integer id) {

//根据商品的id到redis内获取
ItemModel itemModel = (ItemModel) redisTemplate.opsForValue().get("item_"+id);

//若redis内不存在对应的itemModel,则访问下游service
if(itemModel == null) {
itemModel = itemService.getItemById(id);

//设置itemModel到redis内
redisTemplate.opsForValue().set("item_"+id,itemModel);
redisTemplate.expire("item_"+id,10, TimeUnit.MINUTES);
}

ItemVO itemVO = this.convertVOFromModel(itemModel);
return CommonReturnType.create(itemVO);
}

ItemModel、PromoModel要实现序列化

查看Redis写入的数据,发现会出现如下乱码:

修改config文件夹下的RedisConfig

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
@Component
@EnableRedisHttpSession(maxInactiveIntervalInSeconds = 3600)
public class RedisConfig {
@Bean
public RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory) {
RedisTemplate redisTemplate = new RedisTemplate();
redisTemplate.setConnectionFactory(redisConnectionFactory);

//首先解决key的序列化方式
StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
redisTemplate.setKeySerializer(stringRedisSerializer);

//解决value的序列化方式
Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);
ObjectMapper objectMapper = new ObjectMapper();
SimpleModule simpleModule = new SimpleModule();
simpleModule.addSerializer(DateTime.class,new JodaDateTimeJsonSerializer());
simpleModule.addDeserializer(DateTime.class,new JodaDateTimeJsonDeserializer());
objectMapper.registerModule(simpleModule);

objectMapper.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
jackson2JsonRedisSerializer.setObjectMapper(objectMapper);


redisTemplate.setValueSerializer(jackson2JsonRedisSerializer);

return redisTemplate;
}
}

新建serializer文件夹,做日期DateTime的序列化\反序列化

JodaDateTimeJsonSerializer.java:

1
2
3
4
5
6
public class JodaDateTimeJsonSerializer extends JsonSerializer<DateTime> {
@Override
public void serialize(DateTime dateTime, JsonGenerator jsonGenerator, SerializerProvider serializerProvider) throws IOException {
jsonGenerator.writeString(dateTime.toString("yyyy-MM-dd HH:mm:ss"));
}
}

JodaDateTimeJsonDeserializer.java

1
2
3
4
5
6
7
8
9
public class JodaDateTimeJsonDeserializer extends JsonDeserializer<DateTime> {
@Override
public DateTime deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException, JsonProcessingException {
String dateString = jsonParser.readValueAs(String.class);
DateTimeFormatter formatter = DateTimeFormat.forPattern("yyyy-MM-dd HH:mm:ss");

return DateTime.parse(dateString,formatter);
}
}

5-6 Redis集中式缓存压测

连接到和redis server于数据库 server共享的server上面
top -H
ifconfig
./redis-cli -h 172.31.49.157
select 10
get item_6
采用Jmeter压测工具发现,Average耗时为250多ms,Tps 2000/s 采用top -H查看发现redisserver占用的cpu只有2%,没有到达瓶颈

5-7 本地数据热点缓存

  • 热点数据
  • 脏读非常不敏感
  • 内存可控

本地数据热点缓存的解决方案类似于hashmap,key是item_id,value装的是itemModel。而且还要解决高并发问题,我们想到有Concurrenthashmap,为什么不用呢?

  1. Concurrenthashmap是分段锁,在JDK1.8之前,采用的是Segment+HashEntry+ReentrantLock实现的,在1.8后采用Node+CAS+Synchronized实现,get操作没有加锁,put锁加上后,会对读锁性能有影响
  2. 热点数据缓存要设置过期时间

Google公司推出了一款==Guava cache==组件,本质上也是一种可并发的hashmap,特点有:

  1. 可控制的大小和超过时间
  2. 可配置的LRU策略(最近最少访问策略,用于内存不足的淘汰机制)
  3. 线程安全

下面首先在pom文件中加入依赖

1
2
3
4
5
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>18.0</version>
</dependency>

新建cacheService接口,实现读和写两种操作

CacheService.java

1
2
3
4
5
6
7
8
//封装本地缓存操作类
public interface CacheService {
//存方法
void setCommonCache(String key,Object value);

//取方法
Object getFromCommonCache(String key);
}

CacheServiceImpl实现类

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
@Service
public class CacheServiceImpl implements CacheService {

private Cache<String,Object> commonCache = null;

@PostConstruct //保证Spring加载Bean优先执行这个init方法
public void init() {
commonCache = CacheBuilder.newBuilder()
//设置缓存容器的初始容量为10
.initialCapacity(10)
//设置缓存中最大可以存储100个key,超过100个会按照LRU策略移除缓存项
.maximumSize(100)
//设置写缓存后多少秒过期
.expireAfterWrite(60, TimeUnit.SECONDS).build();
}

@Override
public void setCommonCache(String key, Object value) {
commonCache.put(key, value);
}

@Override
public Object getFromCommonCache(String key) {
//存在返回,不存在返回null
return commonCache.getIfPresent(key);
}
}

ItemController类中实现的原理就是:先查询本地缓存->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
//商品详情页浏览
@RequestMapping(value = "/get",method = RequestMethod.GET) //浏览时服务端用GET请求
@ResponseBody
public CommonReturnType getItem(@RequestParam(name = "id") Integer id) {
ItemModel itemModel = null;

//先取本地缓存
itemModel = (ItemModel) cacheService.getFromCommonCache("item_"+id);

if(itemModel == null) {
//根据商品的id到redis内获取
itemModel= (ItemModel) redisTemplate.opsForValue().get("item_"+id);

//若redis内不存在对应的itemModel,则访问下游service
if(itemModel == null) {
itemModel = itemService.getItemById(id);

//设置itemModel到redis内
redisTemplate.opsForValue().set("item_"+id,itemModel);
redisTemplate.expire("item_"+id,10, TimeUnit.MINUTES);
}
//填充本地缓存
cacheService.setCommonCache("item_"+id,itemModel);
}
ItemVO itemVO = this.convertVOFromModel(itemModel);
return CommonReturnType.create(itemVO);
}

5-9 本地缓存压测验证

线程数1000,ramp-up时间:5s,循环次数:60
Average time 150ms,对应的Tps:3500/s,对应的redis几乎没有任何压力,
缓存机制从redis缓存加载到了jvm缓存之后,减少了多段的网络开销,并且完成了对应的内存访问输出结果,性能提升明显,但是数据更新之后缓存失效,还有JVM容量大小的限制

5-10 nginx proxy cache缓存

启用nginx缓存的条件:

  • nginx可以用作反向代理
  • 依靠文件系统存索引级的文件(将请求存成本地文件,在本地磁盘中)
  • 依靠内存缓存文件地址(接下来有点绕:内存缓存文件的内容value是以文件形式存放在磁盘中,但是缓存的key以缓存的方式在内存中,缓存key在内存的内容就是 —内存缓存文件的地址)也就是说nginx proxy cahce 寻址的key在内存当中,value在磁盘中,key内存中存储的是value的地址

缓存实现

首先连接到nginx反向代理的服务器

修改conf文件nginx.conf

1
2
3
4
5
6
7
8
9
10
11
12
13
#声明一个cache缓存节点的内容
proxy_cache_path /usr/local/openresty/nginx/tmp_cache levels=1:2 keys_zone=tmp_cache:100m inactive=7d max_size=10g;

//做一个二级目录,先将对应的url做一次hash,取最后一位做一个文件目录的索引;
//在取一位做第二级目录的索引来完成对应的操作,文件内容分散到多个目录,减少寻址的消耗
//在nginx内存当中,开了100m大小的空间用来存储keys_zone中的所有的key
//文件存取7天,文件系统组多存取10个G

location / {
proxy_cache tmp_cache;
proxy_cache_key &uri;
proxy_cache_valid 200 206 304 302 7d;//只有后端返回的状态码是这些,对应的cache操作才会生效,缓存周期7天
}

sbin/nginx -s reload重启服务器

性能压测

nginx的缓存本质上缓存读取的内容还是本地的文件,并没有把对应的文件缓存在nginx内存中,所以不如nginx反向代理存的内容更高效。不是很理想

5-11/14 nginx lua原理

  • lua协程机制
  • nginx协程机制
  • nginx插载点
  • OpenResty,将lua脚本和nginx打包在一起

协程机制

协程又叫微线程,最近几年在Lua脚本中得以广泛应用。协程,区别于子程序的层级调用,执行过程中,在子程序内部可中断然后转而执行其他子程序,在适当的时候再返回来接着执行

  1. 协程不是内部函数调用,类似于中断机制
  2. 协程区别于多线程就是不需要锁机制,只在某个线程内部,省去了线程切换的开销
  3. 多进程+协程 发挥协程的高效性

举个 生产者/消费者的 协程例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
def consumer():
r = ''
while True:
n = yield r
if not n:
return
print('[CONSUMER] Consuming %s...' % n)
r = '200 OK'

def produce(c):
c.send(None)
n = 0
while n < 5:
n = n + 1
print('[PRODUCER] Producing %s...' % n)
r = c.send(n)
print('[PRODUCER] Consumer return: %s' % r)
c.close()

c = consumer()
produce(c)

整个代码关键点在于n = yield rr = c.send(n)这两处。

生产者先执行循环n=n+1,运行到 r = c.send(n)这句:将n通过send()传递给consumer,此时n = yield r 接受send的传递值,n=1,往下执行 r = '200 ok',再执行到n=yield r 时候,yield返回r,切换到produce函数,输出打印 retur 的r值。

运行结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
[PRODUCER] Producing 1...
[CONSUMER] Consuming 1...
[PRODUCER] Consumer return: 200 OK
[PRODUCER] Producing 2...
[CONSUMER] Consuming 2...
[PRODUCER] Consumer return: 200 OK
[PRODUCER] Producing 3...
[CONSUMER] Consuming 3...
[PRODUCER] Consumer return: 200 OK
[PRODUCER] Producing 4...
[CONSUMER] Consuming 4...
[PRODUCER] Consumer return: 200 OK
[PRODUCER] Producing 5...
[CONSUMER] Consuming 5...
[PRODUCER] Consumer return: 200 OK

总结一下:

  • 依附于线程的内存模型,切换开销小
  • 遇到阻塞归还对应的执行权限,代码同步
  • 协程在线程中串行访问,无需加锁

nginx协程机制

  • nginx的每一个Worker进程都是在epoll或queue这种事件模型之上,封装成协程;
  • 每一个请求都有一个协程进行处理
  • 即使ngx_lua需要运行lua,相对与C有一定的开销,但依旧能保证高并发的能力;

运行机制:

  • nginx每个工作进程创建一个lua虚拟机

  • 工作进程内的所有协程共享同一个vm

  • 每一个外部请求都是由一个lua协程处理,之间数据隔离;

  • lua代码调用io等异步接口时,协程被挂起,上下文数据保持不变;

  • 自动保存,不阻塞工作进程

  • io异步操作完成后还原协程上下文,代码继续执行

nginx处理阶段

1
2
3
4
5
6
7
8
9
10
11
12
13
typedef enum {
NGX_HTTP_POST_READ_PHASE = 0, //读取请求头,例如get还是post,cookie中有哪些方法
NGX_HTTP_SERVER_REWRITE_PHASE, //执行rewrite - rewrite_handler,uri与location匹配前,修改uri的阶段,用于重定向
NGX_HTTP_FIND_CONFIG_PHASE, //根据uri替换location
NGX_HTTP_REWRITE_PHASE, //根据替换结果继续执行rewrite - rewrite_handler,上一阶段找到location块后再修改uri
NGX_HTTP_POST_REWRITE_PHASE, //执行rewrite后处理,防止重写URL后导致的死循环
NGX_HTTP_PREACCESS_PHASE, //认证预处理 请求限制,连接限制 -limit_conn_handler -limit_req_handler
NGX_HTTP_ACCESS_PHASE, //认证处理 - auth_basic_handler,access_handler,让HTTP模块判断是否允许这个请求进入Nginx服务器
NGX_HTTP_POST_ACCESS_PHASE, //认证后处理, 认证不通过, 丢包, 向用户发送拒绝服务的错误码,用来响应上一阶段的拒绝
NGX_HTTP_TRY_FILES_PHASE, //尝试try标签,为访问静态文件资源而设置
NGX_HTTP_CONTENT_PHASE, //内容处理 - static_handler 处理HTTP请求内容的阶段
NGX_HTTP_LOG_PHASE //日志处理 - log_handler 处理完请求后的日志记录阶段
} ngx_http_phases;

nginx lua插载点

Nginx提供了许多再执行lua脚本的挂载方案,用的最多的几个nginx lua插载点

  • init_by_lua:系统启动时调用;
  • init_worker_by_lua:worker进程启动时调用;
  • set_by_lua:nginx变量用复杂lua return
  • access_by_lua:权限验证阶段
  • content_by_lua:内容输出结点(重要)

content_by_lua展示:

5-15 OpenResty实战

  • OpenResty由Nginx核心加很多第三方模块组成,默认集成了Lua开发环境,使得Nginx可以作为一个Web Server使用

  • 借助于Nginx的事件驱动模型和非阻塞IO(epoll多路复用机制),可以实现高性能的Web应用程序

  • OpenResty提供了大量组件如Mysq、Redis、Memcached等等,使得在Nginx上开发Web应用更方便更简单。

下面进行OpenResty实践操作

openresty hello world

新建helloworld.lua脚本:

1
ngx.exec("/item/get?id=6");

修改nginx.conf

1
2
3
location /helloworld {
content_by_lua_file ../lua/helloworld.lua;
}

重新reload,可以发现和访问正常url一样

shared dic:共享内存字典,所有worker进程可见

新建itemsharedic.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
function get_from_cache(key)
local cache_ngx = ngx.shared.my_cache
local value = cache_ngx:get(key)
return value
end

function set_to_cache(key,value,exptime)
if not exptime then
exptime = 0
end
local cache_ngx = ngx.shared.my_cache
local succ,err,forcible = cache_ngx:set(key,value,exptime)
return succ
end

local args = ngx.req.get_uri_args()
local id = args["id"]
local item_model = get_from_cache("item_"..id)
if item_model == nil then
local resp = ngx.location.cature("/item/get?id="..id)
item_model = resp.body
set_to_cache("item_"..id,item_model,1*60)
end
ngx.say(item_model)

修改nginx.conf

1
2
3
4
5
6
lua_shared_dict my_cache 128m;

location /luaitem/get {
default_type "application/json";
content_by_lua_file ../lua/itemsharedic.lua;
}

openresty redis支持

我们打算做这种架构,nginx通过读redis slave的内容,来兼顾内容的更新问题,redis自身有master/slave的主从机制。

若nginx可以连接到redis上,进行只读不写,若redis内没有对应的数据,那就回源到miaoshaserver上面,然后对应的miaoshaserver也判断一下redis内有没有对应的数据,

若没有,回源mysql读取,读取之后放入redis中 ,那下次h5对应的ajax请求就可以直接在redis上做一个读的操作,nginx不用管数据的更新机制,下游服务器可以填充redis,nginx只需要实时的感知redis内数据的变化,在对redis添加一个redis slave,redis slave通过redis master做一个主从同步,更新对应的脏数据。

新建itemredis.lua脚本

1
2
3
4
5
6
7
8
9
10
11
12
local args = ngx.req.get_uri_args()
local id = args["id"]
local redis = require "resty.redis"
local cache = redis:new()
local ok,err = cache:connect("172.26.241.149",6379)
local item_model = cache:get("item_"..id)
if item_model == ngx.null or item_model == nil then
local resp = ngx.location.capture("/item/get?id="..id)
item_model = resp.body
end

ngx.say(item_model)

修改nginx.conf,重启nginx

浏览网页成功

文章作者: SkironYong
文章链接: https://skironyong.github.io/SkironYong.github.io/posts/757b4eea.html
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 SkironYong
打赏
  • 微信
    微信
  • 支付寶
    支付寶

评论