第四章 查询优化技术之多级缓存
缓存设计原则
- 用快速存取设备,用内存
- 将缓存推到离用户最近的地方
- 脏缓存清理
我们的项目采用多级缓存的架构
- 第一级 Redis 缓存
- Redis缓存有集中管理缓存的特点,是常见NoSql数据库组件
- 第二级 热点缓存本地缓存
- 热点数据存到JVM本地缓存中
- 第三级 nginx proxy cache 缓存
- 所有数据最后都会在nginx服务器上做反向代理,nginx服务器也可以开启proxy cache缓存
- 第四级 nginx lua 缓存
- nginx定制lua脚本做nginx内存缓存
Redis集中式缓存
Redis sentinal 哨兵模式
- 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)
总结
Sentinal作用:
- Master状态检测
- 如果Master异常,则会进行Master-Slave切换,将其中一个Slave作为Master,将之前的Master作为Slave
Redis 集群 cluster 模式
- 一般情况下,使用主从模式加Sentinal监控就可以满足基本需求了,但是当数据量过大一个主机放不下的时候,就需要对数据进行分区,将key按照一定的规则进行计算,并将key对应的value分配到指定的Redis实例上,这样的模式简称Redis集群。
- cluster集群配置有多个slave用来读,master用来写,各种redis服务器彼此知道相互关系。
cluster好处:
- 将数据自动切分到多个节点
- 当集群某台设备故障时,仍然可以处理请求
- 节点的fail是集群中超过半数的节点检测失效时才生效
Redis 集中式缓存商品详情页动态内容实现
- 修改
ItemController.java
@Autowired
private RedisTemplate redisTemplate;
//商品详情页浏览
@RequestMapping(value = "/get", method = {RequestMethod.GET})
@ResponseBody
// 这个@RequestParam表示这个参数是必传的,如果没传则会报ServletRequestBindingException错误,然后被GlobalExceptionHandler类所捕获
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);
}
//将itemModel转为itemVO
ItemVO itemVO = convertVOFromModel(itemModel);
return CommonReturnType.create(itemVO);
}
注意:
ItemModel
、PromoModel
要实现序列化!!!
- 查看Redis写入的数据,发现会出现如下乱码:
- 修改config目录下的
RedisConfig.java
/**
* @author xiexu
* @create 2022-04-26 12:53
*/
@Component
@EnableRedisHttpSession(maxInactiveIntervalInSeconds = 3600)
public class RedisConfig {
@Bean
public RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory) {
RedisTemplate redisTemplate = new RedisTemplate();
redisTemplate.setConnectionFactory(redisConnectionFactory);
// 解决redis中key的序列化方式
StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
redisTemplate.setKeySerializer(stringRedisSerializer);
// 解决redis中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.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
objectMapper.registerModule(simpleModule);
jackson2JsonRedisSerializer.setObjectMapper(objectMapper);
redisTemplate.setValueSerializer(jackson2JsonRedisSerializer);
return redisTemplate;
}
}
- 新建
serializer
目录,做日期DateTime的序列化和反序列化 JodaDateTimeJsonSerializer.java
// 日期DateTime的序列化 -> String
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
// 日期DateTime的反序列化 -> DateTime
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);
}
}
本地数据热点缓存
- 热点数据
- 脏读非常不敏感
- 内存可控
Guava cache
本地数据热点缓存的解决方案类似于 hashmap,key是item_id,value装的是itemModel。而且还要解决高并发问题,我们想到有 Concurrenthashmap,为什么不用呢 ?
- Concurrenthashmap 是分段锁,在JDK1.8之前,采用的是Segment+HashEntry+ReentrantLock 实现的,在1.8后采用 Node+CAS+Synchronized 实现,get操作没有加锁,而put操作加上锁后,会对读锁的性能产生影响
- 热点数据缓存要设置过期时间
Google公司推出了一款 Guava cache 组件,本质上也是一种可并发的 hashmap,特点有:
- 可控制的大小和超过时间
- 可配置的LRU策略(最近最少访问策略,用于内存不足的淘汰机制)
- 线程安全
操作步骤
- 首先在pom文件中加入依赖
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>18.0</version>
</dependency>
- 新建 cacheService 接口,实现读和写两种操作
CacheService.java
// 封装本地缓存操作类
public interface CacheService {
// 存储数据的方法
void setCommonCache(String key, Object value);
// 取数据的方法
Object getFromCommonCache(String key);
}
CacheServiceImpl
实现类
@Service
public class CacheServiceImpl implements CacheService {
private Cache<String, Object> commonCache = null;
@PostConstruct
public void init() {
commonCache = CacheBuilder.newBuilder()
// 设置缓存容器的初始容量为10
.initialCapacity(10)
// 设置缓存中最大可以存储100个key,超过100个key之后会按照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.java
- 先查询本地缓存,若本地缓存没有,则查询redis;若redis也没有,则查询数据库。
- 本地缓存 —> redis缓存 —> 数据库
@Autowired
private RedisTemplate redisTemplate;
@Autowired
private CacheService cacheService;
//商品详情页浏览
@RequestMapping(value = "/get", method = {RequestMethod.GET})
@ResponseBody
// 这个@RequestParam表示这个参数是必传的,如果没传则会报ServletRequestBindingException错误,然后被GlobalExceptionHandler类所捕获
public CommonReturnType getItem(@RequestParam(name = "id") Integer id) {
ItemModel itemModel = null;
// 先取本地缓存
itemModel = (ItemModel) cacheService.getFromCommonCache("item_" + id);
// 若本地缓存不存在,则取redis缓存
if (itemModel == null) {
// 根据商品的id到redis里面获取itemModel
itemModel = (ItemModel) redisTemplate.opsForValue().get("item_" + id);
// 若redis里面不存在对应的itemModel,则到数据库里面取数据
if (itemModel == null) {
itemModel = itemService.getItemById(id);
// 并且把获取到的itemModel设置到redis里面
redisTemplate.opsForValue().set("item_" + id, itemModel);
// 设置redis缓存过期时间为10分钟
redisTemplate.expire("item_" + id, 10, TimeUnit.MINUTES);
}
// 填充本地缓存
cacheService.setCommonCache("item_" + id, itemModel);
}
//将itemModel转为itemVO
ItemVO itemVO = convertVOFromModel(itemModel);
return CommonReturnType.create(itemVO);
}
本地缓存压测验证
- 线程数1000,ramp-up 时间:5s,循环次数:60
nginx proxy cache 缓存
启用 nginx 缓存的条件:
- nginx 可以作为反向代理
- 依靠文件系统存索引级的文件(将请求存于本地文件,在本地磁盘中)
- 依靠内存缓存文件地址
内存缓存文件
的内容value是以文件形式存放在磁盘中的- 但缓存的key是以缓存的方式存放在内存当中,并且缓存key在内存中的内容就是:
内存缓存文件
的地址 - 也就是说 nginx proxy cahce 寻址的key是在内存当中,value在磁盘中,key内存中存储的是value的地址
缓存实现
- 首先连接到 nginx 反向代理的服务器
- 修改 conf 文件
nginx.conf
vim /usr/local/openresty/nginx/conf/nginx.conf
#声明一个cache缓存节点的内容
proxy_cache_path /usr/local/openresty/nginx/tmp_cache levels=1:2 keys_zone=tmp_cache:100m inactive=7d max_size=10g;
//levels=1:2的意思的做一个二级目录,先将对应的url做一次hash,取最后一位做一个文件目录的索引;
//再取一位做第二级目录的索引来完成对应的操作,这样做的目的是文件内容分散到多个目录,减少寻址的消耗
//在nginx内存当中,开了100m大小的内存空间用来存储keys_zone中的所有key
//文件存取7天,最多存储10个G的文件(超过10个G,开始采取LRU的淘汰算法)
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 内存当中。
- 所以不如 nginx 反向代理存的内容更高效,效果不是很理想。
nginx lua 原理
- lua 协程机制
- nginx 协程机制
- nginx lua 插载点
- OpenResty,将 lua 脚本和 nginx 打包在一起
协程机制
协程又叫微线程,最近几年在 Lua 脚本中得以广泛应用。协程,区别于子程序的层级调用,执行过程中,在子程序内部可中断,然后转而执行其他子程序,在适当的时候再返回来接着执行
- 协程不是内部函数调用,类似于中断机制
- 协程区别于多线程就是不需要锁机制,只在某个线程内部,省去了线程切换的开销
- 多进程 + 协程 = 发挥协程的高效性
举个 生产者/消费者的 协程例子:
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 r
和r = 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值。
[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 虚拟机,用来运行 lua 脚本文件
- 工作进程内的所有协程共享同一个 lua 虚拟机
- 每一个外部请求都由一个 lua 协程处理,它们之间的数据是隔离的
- lua 代码在调用 io 等异步接口时,对应的协程就会被挂起,上下文数据保持不变
- 自动保存,不阻塞工作进程
- io 异步操作完成后还原协程上下文,代码继续执行
nginx 处理阶段
typedef enum {
NGX_HTTP_POST_READ_PHASE = 0, //读取请求头,例如要访问的url是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 returnrewrite_by_lua
:重写url规则access_by_lua
:权限验证阶段content_by_lua
:内容输出结点(重要)
演示
init_by_lua
- 编写
init.lua
测试文件
ngx.log(ngx.ERR,"init lua success");
- 进入 nginx 的配置文件:
nginx.conf
- 可以看到对应的 lua 脚本会在nginx启动的时候执行它
演示
content_by_lua
- 进入 nginx 的配置文件:
nginx.conf
- 编写
staticitem.lua
测试文件
ngx.say("hello static item lua");
- 无缝重启 nginx:
sbin/nginx -s reload
OpenResty 实战
- OpenResty由Nginx核心加很多第三方模块组成,默认集成了Lua开发环境,使得Nginx可以作为一个Web Server使用
- 借助于Nginx的事件驱动模型和非阻塞IO (epoll多路复用机制),可以实现高性能的Web应用程序
- OpenResty提供了大量组件如Mysq、Redis、Memcached等等,使得在Nginx上开发Web应用更方便更简单。
openresty hello world
- 新建
helloworld.lua
脚本:
ngx.exec("/item/get?id=6");
- 修改
nginx.conf
location /helloworld {
content_by_lua_file ../lua/helloworld.lua;
}
- 无缝重启 nginx:
sbin/nginx -s reload
,可以发现访问helloworld相当于访问了/item/get?id=6
shared dic:共享内存字典,所有worker进程可见,LRU淘汰策略
- 新建
itemsharedic.lua
脚本:
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.capture("/item/get?id="..id)
item_model = resp.body
set_to_cache("item_"..id,item_model,1*60)
end
ngx.say(item_model)
- 修改
nginx.conf
lua_shared_dict my_cache 128m;
location /luaitem/get {
default_type "application/json";
content_by_lua_file ../lua/itemsharedic.lua;
}
- 无缝重启 nginx:
sbin/nginx -s reload
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
脚本
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:
sbin/nginx -s reload