第四章 查询优化技术之多级缓存

第四章 查询优化技术之多级缓存

缓存设计原则

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

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

  • 第一级 Redis 缓存
    • Redis缓存有集中管理缓存的特点,是常见NoSql数据库组件
  • 第二级 热点缓存本地缓存
    • 热点数据存到JVM本地缓存中
  • 第三级 nginx proxy cache 缓存
    • 所有数据最后都会在nginx服务器上做反向代理,nginx服务器也可以开启proxy cache缓存
  • 第四级 nginx lua 缓存
    • nginx定制lua脚本做nginx内存缓存

Redis集中式缓存

Untitled

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。

Untitled

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

Untitled

总结

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);
    }

注意:ItemModelPromoModel 要实现序列化!!!

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

Untitled

  • 修改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);
    }

}

本地数据热点缓存

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

Untitled

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

Untitled

nginx proxy cache 缓存

Untitled

启用 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天
}

Untitled

Untitled

  • 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 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值。
[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 插载点

Untitled

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

演示 init_by_lua

  • 编写 init.lua 测试文件

Untitled

ngx.log(ngx.ERR,"init lua success");
  • 进入 nginx 的配置文件:nginx.conf

Untitled

  • 可以看到对应的 lua 脚本会在nginx启动的时候执行它

Untitled

演示 content_by_lua

  • 进入 nginx 的配置文件:nginx.conf

Untitled

  • 编写 staticitem.lua 测试文件

Untitled

ngx.say("hello static item lua");
  • 无缝重启 nginx:sbin/nginx -s reload

Untitled

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

Untitled

location /helloworld {
            content_by_lua_file ../lua/helloworld.lua;
        }
  • 无缝重启 nginx:sbin/nginx -s reload,可以发现访问helloworld相当于访问了 /item/get?id=6

Untitled

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

Untitled

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

Untitled

openresty redis 支持

Untitled

  • 我们打算做这种架构,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

Untitled

  • 无缝重启 nginx:sbin/nginx -s reload
  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

猿小羽

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值