Bootstrap

Java高并发解决方案——多级缓存(笔记)


对于亿级流量的解决方案

一、传统缓存问题

请求抵达tomcat后,tomcat查询redis,没有再查询数据库。

请求
查询
未命中
客户端
tomcat服务器
redis
数据库

问题:

  • 请求需要经过tomcat处理,tomcat性能将成为整个系统瓶颈
  • redis缓存失效时,会对数据库造成冲击

二、多级缓存

利用请求处理的每个缓解,分别添加缓存,减轻tomcat压力,提升服务性能

  • 浏览器客户端缓存:一般指静态数据
  • nginx本地缓存:它也可以进行编程,把数据缓存到本地,没有的话直接去redis查询
  • tomcat进程缓存:在服务器内部实现,没有最后才会到达数据库
未命中
未命中
未命中
用户
浏览器客户端缓存
nginx本地缓存
redis
tomcat进程缓存
数据库

因此Nginx的压力就会很大,于是就会有nginx集群

反向代理
未命中
未命中
未命中
用户
浏览器客户端缓存
nginx
nginx集群本地缓存
redis
tomcat进程缓存
数据库

需要什么:

  • JVM进程缓存
  • Lua语法
  • 实现多级缓存
  • 缓存同步策略

三、JVM进程缓存

3.1 准备

创建一个数据库镜像的数据挂载目录,进入目录后执行docker命令

docker run -p 3306:3306 --name mysql -v $PWD/conf:/etc/mysql/conf.d -v $PWD/logs:/logs -v $PWD/data:/var/lib/mysql -e MYSQL_ROOT_PASSWORD=123 --privileged -d mysql:5.7.25

创建mysql的默认配置

 touch /tmp/mysql/conf/my.cnf

skip-name-resolve
character_set_server=utf8
datadir=/var/lib/mysql
server-id=1000

docker restart mysql

随后用数据库工具连接远端数据库
如果没有办法连接,检查是否开了端口、防火墙,或者重启docker
在这里插入图片描述
在这里插入图片描述
创建两张表

CREATE TABLE `tb_item` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '商品ID',
  `title` varchar(268) NOT NULL COMMENT '商品标题',
  `name` varchar(128) NOT NULL DEFAULT '' COMMENT '商品名称',
  `price` bigint(20) NOT NULL COMMENT '价格,单位分',
  `image` varchar(200) DEFAULT NULL COMMENT '商品图片',
  `category` varchar(200) DEFAULT NULL COMMENT '类目名称',
  `brand` varchar(100) DEFAULT NULL COMMENT '品牌名称',
  `spec` varchar(200) DEFAULT NULL COMMENT '规格',
  `status` int(1) DEFAULT '1' COMMENT '商品状态 1-正常 2-下架 3-删除',
  `create_time` datetime DEFAULT NULL COMMENT '创建时间',
  `update_time` datetime DEFAULT NULL COMMENT '更新时间',
  PRIMARY KEY (`id`) USING BTREE,
  KEY `status` (`status`) USING BTREE,
  KEY `updated` (`update_time`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=50008 DEFAULT CHARSET=utf8 ROW_FORMAT=COMPACT;
CREATE TABLE `tb_item_stock`(
`item_id` BIGINT(20) AUTO_INCREMENT COMMENT '商品ID',
`stock` BIGINT(20) NOT NULL COMMENT '库存',
`sold` BIGINT(20) NOT NULL COMMENT '卖出',
PRIMARY KEY (`item_id`) USING BTREE 
)ENGINE=INNODB AUTO_INCREMENT=50002 DEFAULT CHARSET=utf8 ROW_FORMAT=COMPACT

搭建项目
首先创建完成结构后,用mybatis-plus解析数据库

package com.yjx23332.item;


import com.baomidou.mybatisplus.generator.FastAutoGenerator;
import com.baomidou.mybatisplus.generator.config.OutputFile;
import com.baomidou.mybatisplus.generator.engine.FreemarkerTemplateEngine;

import java.util.Collections;

public class generator {
    public static void main(String[] args){
        FastAutoGenerator.create("jdbc:mysql://IP地址:3306/test?useUnicode=true&characterEncoding=UTF-8&&useSSL=false", "root", "123")
                .globalConfig(builder -> {
                    builder.author("yjx23332") // 设置作者
                            .enableSwagger() // 开启 swagger 模式
                            .fileOverride() // 覆盖已生成文件
                            .outputDir("D:\\tool\\item-service\\src\\main\\java"); // 指定输出目录
                })
                .packageConfig(builder -> {
                    builder.parent("com.yjx23332.item") // 设置父包名
                            .moduleName("") // 设置父包模块名
                            .pathInfo(Collections.singletonMap(OutputFile.xml, "D:\\tool\\item-service\\src\\main\\resources")); // 设置mapperXml生成路径
                })
                .strategyConfig(builder -> {
                    builder.addInclude("tb_item") // 设置需要生成的表名
                            .addTablePrefix("t_", "c_"); // 设置过滤表前缀
                })
                .templateEngine(new FreemarkerTemplateEngine()) // 使用Freemarker引擎模板,默认的是Velocity引擎模板
                .execute();

    }
}

在这里插入图片描述
添加分页的拦截器

@Configuration
@MapperScan("com.yjx23332.item.mapper")
public class MybatisConfig {
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        //物理分页
        interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
        //防止恶意全表操作
        interceptor.addInnerInterceptor(new BlockAttackInnerInterceptor());
        return interceptor;
    }
}

新建一个控制层,它用于管理商品信息


package com.yjx23332.item.controller;

import com.yjx23332.item.entity.ItemDTO;
import com.yjx23332.item.entity.TbItemStock;
import com.yjx23332.item.service.ITbItemStockService;
import com.yjx23332.item.service.ItemService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/item")
@Api(tags = "商品相关")
public class ItemController {
    @Autowired
    private ItemService itemService;

    @Autowired
    private ITbItemStockService iTbItemStockService;

    @ApiOperation("分页查询")
    @GetMapping("/list")
    public List<ItemDTO> queryItemPage(@RequestParam(value = "page",defaultValue = "1")Integer page, @RequestParam(value = "size",defaultValue = "5")Integer size){
        return itemService.queryItemPage(page,size);
    }

    @PostMapping
    @ApiOperation("保存商品")
    public void saveItem(@RequestBody ItemDTO itemDTO){
        itemService.saveItem(itemDTO);
    }

    @PutMapping
    @ApiOperation("更新商品信息")
    public void updateItem(@RequestBody ItemDTO itemDTO){
        itemService.updateItem(itemDTO);
    }

    @PutMapping("/stock")
    @ApiOperation("更新商品库存")
    public void updateStock(@RequestBody TbItemStock tbItemStock){
        iTbItemStockService.updateById(tbItemStock);
    }

    @DeleteMapping("/{id}")
    @ApiOperation("用ID删除商品")
    public void deleteById(@PathVariable("id") Long id){
        itemService.deleteById(id);
    }

    @GetMapping("/{id}")
    @ApiOperation("用ID查询商品")
    public ItemDTO findById(@PathVariable("id") Long id){
        return itemService.findById(id);
    }
	@GetMapping("/stock/{id}")
    @ApiOperation("用ID查询库存")
    public TbItemStock findStockById(@PathVariable("id") Long id){
    	return iTbItemStockService.getById(id);
    }
}

新建一个DTO,用于将两个表的数据合在一起

package com.yjx23332.item.entity;


import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;


@ApiModel("商品信息")
@Data
public class ItemDTO {
    @ApiModelProperty("商品信息")
    TbItem tbItem;
    @ApiModelProperty("商品库存")
    TbItemStock tbItemStock;

}


创建一个ItemService抽象类以及实现,用于处理两表一起的操作

package com.yjx23332.item.service.impl;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.yjx23332.item.entity.ItemDTO;
import com.yjx23332.item.entity.TbItem;
import com.yjx23332.item.entity.TbItemStock;
import com.yjx23332.item.service.ITbItemService;
import com.yjx23332.item.service.ITbItemStockService;
import com.yjx23332.item.service.ItemService;
import kotlin.jvm.internal.Lambda;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.stream.Collectors;

@Service
@Slf4j
public class ItemServiceImpl implements ItemService {
    @Autowired
    ITbItemStockService iTbItemStockService;
    @Autowired
    ITbItemService iTbItemService;

    @Override
    public List<ItemDTO> queryItemPage(Integer page, Integer size) {
        List<ItemDTO> result = iTbItemService.page(new Page<TbItem>(page,size),new LambdaQueryWrapper<TbItem>().ne(TbItem::getStatus,3))
                .getRecords().stream().map(e->{
                    ItemDTO itemDTO = new ItemDTO();
                    itemDTO.setTbItem(e);
                    //查询Stock表
                    TbItemStock tbItemStock = iTbItemStockService.getById(itemDTO.getTbItem().getId());
                    itemDTO.setTbItemStock(tbItemStock);
                    return itemDTO;
                }).collect(Collectors.toList());
        return result;
    }

    @Override
    public void saveItem(ItemDTO itemDTO) {
        iTbItemService.save(itemDTO.getTbItem());
        iTbItemStockService.save(itemDTO.getTbItemStock());
    }

    @Override
    public void updateItem(ItemDTO itemDTO) {
        iTbItemService.updateById(itemDTO.getTbItem());
        iTbItemStockService.updateById(itemDTO.getTbItemStock());
    }

    @Override
    public void deleteById(Long id) {
        iTbItemService.removeById(id);
        iTbItemStockService.removeById(id);
    }

    @Override
    public ItemDTO findById(Long id) {
        ItemDTO itemDTO = new ItemDTO();
        itemDTO.setTbItem(iTbItemService.getById(id));
        itemDTO.setTbItemStock(iTbItemStockService.getById(id));
        return itemDTO;
    }
}

3.2 本地缓存与分布式缓存

分布式缓存(redis)

  • 优点:存储容量大、可靠性更好、可以在集群间共享
  • 缺点:访问缓存有网络开销
  • 场景:缓存数据量大,可靠性要求较高,需要在集群见共享

进程本地缓存(HashMap,GuavaCache)

  • 优点:读取本地内存,没有网络开销,速度更快
  • 缺点:存储容量有限,可靠性较低、无法共享
  • 场景:性能要求较高,存储数据量小

3.3 Cafeine

基于Java8开发的,提供了近乎最佳命中率的高性能的本地缓存库。Spring内部使用的就是Cafeine。

相关具体使用方式请参考Cafeine-官方地址
引入如下依赖

         <dependency>
            <groupId>com.github.ben-manes.caffeine</groupId>
            <artifactId>caffeine</artifactId>
            <version>3.1.1</version>
        </dependency>

3.3.1 手动加载

public class test {
    @Test
    void testBasicOpe(){
        //构建
        Cache<String, String> cache = Caffeine.newBuilder()
                .expireAfterWrite(10, TimeUnit.MINUTES)
                .maximumSize(10_000)//"_"分割符,无意义,方便阅读
                .build();

        //存储
        cache.put("test","测试");
        // 查找一个缓存元素, 没有查找到的时候返回null
        String test = cache.getIfPresent("test");
        System.out.println("test="+test);

        // 查找缓存,如果缓存不存在则生成缓存元素,  如果无法生成则返回null
        test = cache.get("test2", key -> {
            //可在此处写数据库业务
            return "test2";
        });
        System.out.println("test="+test);
        // 移除一个缓存元素
        cache.invalidate("test");
        System.out.println(cache.getIfPresent("test"));
    }
}

在这里插入图片描述

3.3.2 自动加载

@Test
    void testBasicOpe() throws ExecutionException, InterruptedException {
        LoadingCache<String, String> cache = Caffeine.newBuilder()
                .maximumSize(10_000)
                .expireAfterWrite(10, TimeUnit.MINUTES)
                .build(key -> {return "test1";});

        // 查找缓存,如果缓存不存在则生成缓存元素,  如果无法生成则返回null
        String test = cache.get("test1");
        List<String> tests = new ArrayList<String>();
        tests.add("test2");
        tests.add("test3");
        // 批量查找缓存,如果缓存不存在则生成缓存元素
        Map<String, String> test2 = cache.getAll(tests);
        System.out.println(test);
        System.out.println(test2.toString());
    }

在这里插入图片描述

3.3.3 异步手动加载

 @Test
    void testBasicOpe() throws ExecutionException, InterruptedException, TimeoutException {
        AsyncCache<String, String> cache = Caffeine.newBuilder()
                .expireAfterWrite(10, TimeUnit.MINUTES)
                .maximumSize(10_000)
                .buildAsync();
        // 查找缓存元素,如果不存在,则异步生成
        CompletableFuture<String> test = cache.get("test", k -> {return "test3";});
        System.out.println(test.get());
        // 查找一个缓存元素, 没有查找到的时候返回null
         test = cache.getIfPresent("test");
        //会卡在此处,等待有结果后,再继续执行,超时时间为10分钟
        System.out.println(test.get(10,TimeUnit.MINUTES));
        // 添加或者更新一个缓存元素
        cache.put("test", test);
        // 移除一个缓存元素,synchronous()会强制进入同步状态,待到异步执行完成后才会执行invalidate
        //我们可以为操作增加synchronous来保证同步
        cache.synchronous().invalidate("test");
    }

3.3.4 异步自动加载

 @Test
    void testBasicOpe() throws ExecutionException, InterruptedException, TimeoutException {
        AsyncLoadingCache<String, String> cache = Caffeine.newBuilder()
                .maximumSize(10_000)
                .expireAfterWrite(10, TimeUnit.MINUTES)
                // 你可以选择: 去异步的封装一段同步操作来生成缓存元素
                .buildAsync(key -> {return "test1";});
        // 你也可以选择: 构建一个异步缓存元素操作并返回一个future
         //  .buildAsync((key, executor) -> createExpensiveGraphAsync(key, executor));
        // 查找缓存元素,如果其不存在,将会异步进行生成
        CompletableFuture<String> test = cache.get("test1");
        List<String> tests = new ArrayList<String>();
        tests.add("test2");
        tests.add("test3");
        // 批量查找缓存元素,如果其不存在,将会异步进行生成
        CompletableFuture<Map<String, String>> test2 = cache.getAll(tests);
        System.out.println(test.get());
        System.out.println(test2.get().toString());
    }

3.3.5 驱逐策略

驱逐是需要时间的,即使我们maximumSize设为1,我们一次性放入3个然后独处,它一样存在。

  • 基于时间
    • expireAfterAccess(long, TimeUnit): 一个元素在上一次读写操作后一段时间之后,在指定的时间后没有被再次访问将会被认定为过期项。在当被缓存的元素时被绑定在一个session上时,当session因为不活跃而使元素过期的情况下,这是理想的选择。
    • expireAfter(Expiry): 一个元素将会在指定的时间后被认定为过期项。当被缓存的元素过期时间收到外部资源影响的时候,这是理想的选择
    • expireAfterWrite(long, TimeUnit): 一个元素将会在其创建或者最近一次被更新之后的一段时间后被认定为过期项。在对被缓存的元素的时效性存在要求的场景下,这是理想的选择。
  • 基于容量:
    • 最大容量:如果你的缓存容量不希望超过某个特定的大小,那么记得使用Caffeine.maximumSize(long)。缓存将会尝试通过基于就近度和频率的算法来驱逐掉不会再被使用到的元素。
    • 最大权重:可以借助Caffeine.weigher(Weigher) 方法来界定每个元素的权重。并通过 Caffeine.maximumWeight(long) 方法来界定缓存中元素的总权重来实现。
    • 在基于权重驱逐的策略下,一个缓存元素的权重计算是在其创建和更新时,此后其权重值都是静态存在的,在两个元素之间进行权重的比较的时候,并不会根据进行相对权重的比较。
  • 基于引用:Caffeine 允许你配置你的缓存去让GC去帮助清理缓存当中的元素,其中key支持弱引用,而value则支持弱引用和软引用。记住 AsyncCache不支持软引用和弱引用。
    • Caffeine.weakKeys() 在保存key的时候将会进行弱引用。这允许在GC的过程中,当key没有被任何强引用指向的时候去将缓存元素回收。由于GC只依赖于引用相等性。这导致在这个情况下,缓存将会通过引用相等(==)而不是对象相等 equals()去进行key之间的比较。

    • Caffeine.weakValues() 在保存value的时候将会使用弱引用。这允许在GC的过程中,当value没有被任何强引用指向的时候去将缓存元素回收。由于GC只依赖于引用相等性。这导致在这个情况下,缓存将会通过引用相等(==)而不是对象相等 equals()去进行value之间的比较。

    • Caffeine.softValues() 在保存value的时候将会使用软引用。为了相应内存的需要,在GC过程中被软引用的对象将会被通过LRU算法回收。由于使用软引用可能会影响整体性能,我们还是建议通过使用基于缓存容量的驱逐策略代替软引用的使用。同样的,使用 softValues() 将会通过引用相等(==)而不是对象相等 equals()去进行value之间的比较。

3.4 整合

  1. 为根据ID查询商品的业务添加缓存
  2. 为根据ID查询库存的业务添加缓存

在一个Config里面创建两个缓存即可

package com.yjx23332.item.config;

import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.yjx23332.item.entity.ItemDTO;
import com.yjx23332.item.entity.TbItemStock;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class CaffeineConfig {
    @Bean
    public Cache<Long, ItemDTO> itemCache(){
        return Caffeine.newBuilder()
                .initialCapacity(100)
                .maximumSize(10_000)
                .build();
    }
    @Bean
    public Cache<Long, TbItemStock> stockCache(){
        return Caffeine.newBuilder()
                .initialCapacity(100)
                .maximumSize(10_000)
                .build();
    }
}

我们接下来,再ItemSerivice中修改

	@Autowired
    private Cache<Long,ItemDTO> itemDTOCache;
    @Override
    public ItemDTO findById(Long id) {
        return itemDTOCache.get(id,key->{
            ItemDTO itemDTO = new ItemDTO();
            itemDTO.setTbItem(iTbItemService.getById(key));
            itemDTO.setTbItemStock(iTbItemStockService.getById(key));
            log.info(itemDTO.toString());
            return itemDTO;
        });
    }

itemControll中修改

	@Autowired
    private Cache<Long,TbItemStock> tbItemStockCache;
    
	@GetMapping("/stock/{id}")
    @ApiOperation("用ID查询库存")
    public TbItemStock findStockById(@PathVariable("id") Long id){
        return tbItemStockCache.get(id,key->{
           log.info("查询了数据库");
           return iTbItemStockService.getById(key);
        });
    }

我们通过日志就可以明白是否查询了数据库。

四、LUA脚本

类似于Tomcat+Java
Nginx+LUA

4.1 LUA

一种轻量级的小巧的脚本语言。用标准C语言编写,并以源码形式开放,其设计目的是为了嵌入应用程序中,从而为应用程序提供灵活的扩展核定制功能(常用于游戏开发)。
LUA-官方地址
请依据手册进行安装-安装步骤

curl -R -O http://www.lua.org/ftp/lua-5.4.4.tar.gz
tar zxf lua-5.4.4.tar.gz
cd lua-5.4.4
make all test

4.2 快速开始

可以参考LUA-官方文档

touch hello.lua

运行

在文件中添加

print(“hello world!”);

它不需要编译,直接就可以运行

lua hello.lua
在这里插入图片描述

数据类型

数据类型描述
nil只有值nil属于该类,表示一个无效值(表达式中类似于false)
boolean包含两个值true,false
number数值类型,双精度实浮点数
string字符串由一对双引号或者单引号来表示
function由C或者Lua编写的函数
tableLua中的表(table)其实是一个“关联数组”,数组索引可以是数字,字符串或者表类型。在LUA里面,table的创建是通过“构造表达式”来完成,最简单的构造表达式"{}" ,来创建要给空表

type(): 用来测试给定变量或者值的类型

直接输入Lua可以进入lua控制台。
在这里插入图片描述
在这里插入图片描述

声明变量

他是弱类型语言
local代表局部变量,控制台中一行结束就没了

local str = 'hello'
local num = 21
local flag = true
local arr = {'arr','python','lua'}
local map = {name='Jack',age=21}

访问table

print(arr[1])
print(map['name'])
print(map.name)

字符串拼接

local str = 'hello' local str1 = str..' world' print(str1)

在这里插入图片描述

循环

local arr = {'java','python','lua'}
local map = {name='Jack',age=21}
for index,value in ipairs(arr) do
	print(index,value)
end
for key,value in pairs(map) do
	print(key,value)
end

在这里插入图片描述

函数

function 函数名称(参数1,参数2,...,参数n)
	--函数体
	return 返回值
end
function printArr(arr)
	for index,value in ipairs(arr) do
    	print(index,value)
    end
end

local arrTest = {'java','c++','python','lua'}
printArr(arrTest)

在这里插入图片描述

条件控制

lua中与或非用的是单词and、or、not

if 表达式
then 
	语句块
elseif 
then
	语句块
else
	语句块
end
function printArr(arr)
	for index,value in ipairs(arr) do
		if(value == 'java')
		then
			print('条件1')
		elseif(value == 'c++')
		then
			print('条件2')
		else
			print('条件3,4')
		end
    	print(index,value)
    end
end

local arrTest = {'java','c++','python','lua'}
printArr(arrTest)

在这里插入图片描述

4.3 OpenResty

一个基于Nginx的高性能Web平台,用于方便的地搭建能够处理超高并发、扩展性极高的动态Web应用、Web服务和动态网关。

  • 具备Nginx的完整功能
  • 基于Lua语言进行扩展,集成了大量精良的Lua库,第三方模块
  • 允许使用Lua自定义业务逻辑、自定义库

OpenResty官网

4.3.1 安装

依赖库安装

yum install -y pcre-devel openssl-devel gcc --skip-broken

添加仓库地址

yum-config-manager --add-repo https://openresty.org/package/centos/openresty.repo

如过命令不存在,则运行如下命令后,在执行上面语句

yum install -y yum-utils

安装OpenResty

yum install -y openresty

安装opm工具,OpenResty的管理工具

yum install -y openresty-opm

目录默认在

/usr/local/openresty

在这里插入图片描述

vim /etc/profile

加入

export NGINX_HOME=/usr/local/openresty/nginx
export PATH=${NGINX_HOME}/sbin:$PATH

输入命令,让配置生效

source /etc/profile

修改

vim /usr/local/openresty/nginx/conf/nginx.conf

#user nobody
worker_processes  1;

error_log logs/error.log;

events {
    worker_connections  1024;
}


http {
    include       mime.types;
    default_type  application/octet-stream;



    sendfile        on;

    keepalive_timeout  65;


    server {
        listen       8081;
        server_name  localhost;

        location / {
            root   html;
            index  index.html index.htm;
        }

        error_page   500 502 503 504  /50x.html;
        location = /50x.html {
            root   html;
        }
    }
}

开放对应端口,并重新载入

firewall-cmd --zone=public --add-port=8081/tcp --permanent
firewall-cmd --reload
服务管理平台端口放行
在这里插入图片描述

4.3.2 命令

启动

nginx

重新加载配置

nginx -s reload

停止

nginx -s stop

在这里插入图片描述
在这里插入图片描述

4.3.3 配置LUA

在nginx.conf的http下面,添加对OpenResty的LUA模块的加载:

# 加载lua模块
lua_package_path "/usr/local/openresty/lualib/?.lua;;";
# 加载C模块
lua_package_cpath "/usr/local/openresty/lualib/?.so;;";

在nginx.conf的Server下面,添加对/api/item这个路径的监听:

location /api/item {
	# 响应类型
	default_type application/json;
	#响应数据由 lua/.item.lua这个文件决定
	content_by_lua_file lua/item.lua;
}

接下来我们在nginx目录中,新建一个lua目录,在其下创建一个item.lua。

我们不先不写业务,而是写一个假数据返回。

ngx.say('{"id":10001,"name":"SSALSA AIR"}')

接着,我们在本地windows安装nginx,
进入nginx.conf
添加一个配置

http{
...
	# 集群名称 以及内部的服务器
    upstream nginx-cluster{
		server  服务器地址:8081;
    }
	server{
		...
	  		location /api {
	            proxy_pass http://nginx-cluster;
	        }
	 }
}

我们接着用POSTMAN发送消息,可以看到如下结果。
在这里插入图片描述

4.3.4 获取不同类型的请求参数

参数格式参数示例参数解析代码示例
参数格式参数示例参数解析代码示例
路径占位符/item/1001在这里插入图片描述
请求头id:1001local headers = ngx.req.get_headers()
Get请求参数?id=1001local getParams=ngx.req.get_uri_args()
Post表单参数id=1001ngx.req.read_body() ; local postParams = ngx.req.get_post_args()
Json参数{“id”:1001}ngx.req.read_body() ; local jsonBody= ngx.req.get_body_data()

4.3.5 nginx到tomcat

我们修改nginx.conf,为占位符获取参数,并reload配置

location ~ /api/item/(\d+) {
        # 响应类型
            default_type application/json;
        #响应数据由 lua/.item.lua这个文件决定
            content_by_lua_file lua/item.lua;
        }

lua脚本修改

-- 获取路径参数
local id = ngx.var[1]
-- 返回结果
ngx.say('{"id":'..id..',"name":"SSALSA AIR"}')

在这里插入图片描述

接下来我们从lua发送请求到tomcat

local resp = ngx.location.capture("/item",{
	method = ngx.HTTP_GET,  --请求方式
	args = {a=1,b=2},  --get方式传参数
	body = "a=3&d=4 -- post方式传参数
})

我们请求地址,会被nginx截获,因此我们需要再次反向代理,让nginx反向代理到我们的tomcat即可。

location /item{
	# 注意端口是否开放
	proxy_pass http://IP地址:8081;
}

对于Lua的请求方法,我们可以封装为一个函数,放到OpenResty函数库中。
位置就是我们之前配置的位置

vim /usr/local/openresty/lualib/common.lua

-- 封装函数,发送Http请求,并解析响应
local function read_http(path,params)
	local resp = ngx.location.capture(path,{
		method = ngx.HTTP_GET,
		args = params,
	})
	if not resp then
		-- 记录错误信息,返回404
		ngx.log(ngx.ERR,"http not found , path:",path,"args:",args)
		ngx.exit(404)
	end
	return resp.body
end
-- 将方法导出
local _M = {
	read_http = read_http
}
return _M

修改lua

-- 导入common函数库
-- 导入我们刚写的请求函数
local common = require('common')
local read_http = common.read_http
-- 导入cjson库
local cjson = require('cjson')

-- 获取路径参数
local id = ngx.var[1]

-- 查询商品信息
local itemJSON = read_http("/item/"..id,nil)
-- 查询库存信息
local stockJSON = read_http("/item/stock/"..id,nil)

-- 解析JSON,将它反序列化为table类型
local item = cjson.decode(itemJSON)
local stock = cjson.decode(stockJSON)

-- 组合数据
item.stock = stock.stock
item.sold = stock.sold

-- 返回结果,序列化回去
ngx.say(cjson.encode(item))

tomcat集群
在server中配置

location /item {
	proxy_pass http://tomcat-cluster;
}

在http中配置

upstream tomcat-cluster{
	server IP:端口1
	server IP:端口2
}

因为我们tomcat有进程缓存,那么如果过同一个商品在集群中的各个服务器都被访问了,那么就会冗余。为了避免冗余,可以依据访问路径来产生Hash值,来确保,路径不变时,那么使用的服务器不变。

  • 当然也有问题,就是如果过服务器挂掉,会很麻烦。
upstream tomcat-cluster{
	hash $request_uri;
	server IP:端口1
	server IP:端口2
}

4.3.6 nginx到redis

  • 冷启动:服务器刚刚启动时,Redis中并没有缓存,如果所有商品数据在第一次查询时添加缓存,可能会给数据库带来较大的压力
  • 缓存预热:在实际开发中,我们可以利用大数据统计技术,只需要在项目启动时,提前查询热点数据并保存在Redis

我们先进行缓存预热
现在服务器中部署redis

docker run --name redis -p 6379:6379 -d redis redis-server --appendonly yes

再在项目中导入

		<dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>fastjson</artifactId>
            <version>2.0.7</version>
        </dependency>

在yml文件中,配置redis地址

spring:
  redis:
    host: IP
    port: 6379
package com.yjx23332.item.config;

import com.alibaba.fastjson2.JSON;
import com.yjx23332.item.entity.TbItem;
import com.yjx23332.item.entity.TbItemStock;
import com.yjx23332.item.service.ITbItemService;
import com.yjx23332.item.service.ITbItemStockService;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.core.StringRedisTemplate;

import java.util.List;

@Configuration
public class RedisHandler implements InitializingBean {
    @Autowired
    private StringRedisTemplate redisTemplate;
    @Autowired
    private ITbItemService itemService;
    @Autowired
    private ITbItemStockService iTbItemStockService;

    /**
     * 初始化缓存,在RedisHandler初始化且成员变量完成后,执行
     * 此处我们直接将所有数据放入
     * */
    @Override
    public void afterPropertiesSet() throws Exception {
        List<TbItem> itemList = itemService.list();
        for(TbItem item:itemList){
            String json = JSON.toJSONString(item);
            redisTemplate.opsForValue().set("item:id:"+item.getId().toString(),json);
        }
        List<TbItemStock> itemStocks = iTbItemStockService.list();
        for(TbItemStock itemStock: itemStocks){
            String json = JSON.toJSONString(itemStock);
            redisTemplate.opsForValue().set("itemStock:id:"+itemStock.getItemId().toString(),json);
        }
    }
}


去redis中查看
在这里插入图片描述

我们在看lua部分
连接部分

-- 引入Redis模块
local redis = require("resty.redis")
-- 初始化Redis对象,创建对象
local red = redis:new()
--设置Redis超时时间单位毫秒,建立连接的超时时间、发送请求的超时时间、响应结果的超时时间
red:set_timeouts(1000,1000,1000)

关闭连接的函数,用来把连接放在连接池中,避免每次都新建

-- 关闭redis连接的工具方法,实则是放入连接池
local function close_redis(red)
	local pool_max_idle_time = 10000 -- 连接空闲时间,单位为毫秒
	local pool_size = 100 -- 连接池大小
	-- 设置保存时间,并放入连接池
	local ok,err = red:set_keepalive(pool_max_idle_time,pool_size)
	if not ok then
		ngx.log(ngx.ERR,"放入Redis连接池失败:",err)
	end
end

查询部分

-- 查询redis的方法,ip和port是redis地址,key为查询的key
local function read_redis(ip,port,key)
	-- 获取一个连接
	local ok,err = red:connect(ip,port)
	if not ok then
		ngx.log(ngx.ERR,"连接redis失败:",err)
		return nil
	end
	-- 查询 redis
	local resp ,err = red:get(key)
	-- 查询失败处理
	if not resp then
		ngx.log(ngx.ERR,"查询Redis失败:",err,",key = ",key)
	end
	-- 得到的数据为空处理
	if resp == ngx.null then
		resp = nil
		ngx.log(ngx.ERR,"查询Redis数据为空,key = ",key)
	end
	close_redis(red)
	return resp
end

我们将上述所有组合在一起,就是我们可用的方法,将它放入我们之前写得工具类Common中

local redis = require("resty.redis")
-- 初始化Redis对象,创建对象
local red = redis:new()
--设置Redis超时时间单位毫秒,建立连接的超时时间、发送请求的超时时间、响应结果的超时时间
red:set_timeouts(1000,1000,1000)


-- 关闭redis连接的工具方法,实则是放入连接池
local function close_redis(red)
	local pool_max_idle_time = 10000 -- 连接空闲时间,单位为毫秒
	local pool_size = 100 -- 连接池大小
	-- 设置保存时间,并放入连接池
	local ok,err = red:set_keepalive(pool_max_idle_time,pool_size)
	if not ok then
		ngx.log(ngx.ERR,"放入Redis连接池失败:",err)
	end
end



-- 查询redis的方法,ip和port是redis地址,key为查询的key
local function read_redis(ip,port,key)
	-- 获取一个连接
	local ok,err = red:connect(ip,port)
	if not ok then
		ngx.log(ngx.ERR,"连接redis失败:",err)
		return nil
	end
	-- 查询 redis
	local resp ,err = red:get(key)
	-- 查询失败处理
	if not resp then
		ngx.log(ngx.ERR,"查询Redis失败:",err,",key = ",key)
	end
	-- 得到的数据为空处理
	if resp == ngx.null then
		resp = nil
		ngx.log(ngx.ERR,"查询Redis数据为空,key = ",key)
	end
	close_redis(red)
	return resp
end



-- 封装函数,发送Http请求,并解析响应
local function read_http(path,params)
	local resp = ngx.location.capture(path,{
		method = ngx.HTTP_GET,
		args = params,
	})
	if not resp then
		-- 记录错误信息,返回404
		ngx.log(ngx.ERR,"http not found , path:",path,"args:",args)
		ngx.exit(404)
	end
	return resp.body
end


-- 将方法导出
local _M = {
	read_http = read_http,
	read_redis = read_redis
}
return _M

随后修改我们的item.lua

-- 导入common函数库
-- 导入我们刚写的请求函数
local common = require('common')
local read_http = common.read_http
local read_redis = common.read_redis
-- 导入cjson库
local cjson = require('cjson')
-- 封装查询函数
function read_data(key,path,params)
	-- 查询redis
	local resp = read_redis("127.0.0.1",6379,key)
	if not resp then
		ngx.log(ngx.ERR,"redis查询失败,尝试查询http,key:",key)
		-- redis查询失败,去查询http
		resp = read_http(path,params)
	end
	return resp;
end


-- 获取路径参数
local id = ngx.var[1]

-- 查询商品信息
local itemJSON = read_data("item:id:"..id,"/item/"..id,nil)
-- 查询库存信息
local stockJSON = read_data("itemStock:id:"..id,"/item/stock/"..id,nil)

-- 解析JSON,将它反序列化为table类型
local item = cjson.decode(itemJSON)
local stock = cjson.decode(stockJSON)

-- 组合数据
item.stock = stock.stock
item.sold = stock.sold

-- 返回结果,序列化回去
ngx.say(cjson.encode(item))

随后nginx重载配置。

在这里插入图片描述

4.3.7 共享词典

OpenResty为Nginx提供了shared dict的功能,可以在ngxin的多个work之间共享数据,实现缓存。

在nginx.conf 的http下添加配置

# 共享词典也就是本地缓存,名称叫作:item_cache,大小150m
lua_shared_dict item_cache 150m

操作共享词典

local item_cache = ngx.shared.item_cache
-- 存储,指定key,value,过期时间,单位s,默认为0代表永不过期
item_cache:set('key','value',1000)
-- 读取
local val = item_cache:get('key')

五、缓存同步

解决数据一致性问题。

5.1 同步策略

  • 设置有效期:给缓存设置有效期,到期后自动删除。再次查询时更新。
    • 优势:简单、方便
    • 缺点:时效性差,缓存过期之前可能不一致
    • 场景:更新频率较低,时效性要求低的业务
  • 同步双写:在修改数据库的同时,直接修改缓存
    • 优点:时效性强,缓存与数据库强一致
    • 缺点:有代码侵入,耦合度高
    • 场景:对一致性、时效性要求较高的缓存数据
  • 异步通知:修改数据库时发送事件通知,相关服务监听到通知后修改缓存数据
    • 优点:低耦合,可以同时通知多个缓存服务
    • 缺点:时效性一般,可能存在中间不一致状态
    • 场景:时效性要求一般,有多个服务需求同步

MQ模型

修改商品
1.1写入
1.2发布消息
2.1监听消息
2.2更新缓存
请求
item-service
MySQL
MQ
cahce-service
redis

基于Canal的异步通知

修改商品
1写入
2.1监听MySQL的binlog
2.2通知数据变更
2.3更新缓存
请求
item-service
MySQL
canal
cahce-service
redis

5.2 canal

利用MySQL的主从同步原理,将自己作为MySQL的一个从服务节点,然后通知相关节点,以此完成同步。

5.2.1 MySQL主从配置

修改

vim /tmp/mysql/conf/my.cnf

添加内容

  • binlog位置以及监控哪一个数据库

log-bin=/var/lib/mysql/mysql-bin
binlog-do-db=test

重启数据库

docker restart mysql

会有如下文件
在这里插入图片描述
没有则考虑

  1. 是否有读写权限
  2. 是否有**[mysqld]**标志或者其他未配置的基础信息
    在这里插入图片描述接下来创建一个用户,用于canal的操作
create user canal@'%' IDENTIFIED by 'canal';
GRANT SELECT ,REPLICATION SLAVE,REPLICATION CLIENT,SUPER ON *.* TO 'canal'@'%' IDENTIFIED by 'canal';
FLUSH PRIVILEGES;

随后重启

docker restart mysql

可在查看自己用户
在这里插入图片描述
查看当前binlog

show master status;
或者
show binary logs;

通过主库与从库的Position对比,从库小于主库则说明要更新了
在这里插入图片描述

为它们创建一个网络

docker network create masterslave

随后让mysql加入这个wangluo

docker network connect masterslave mysql

5.2.2 安装

docker pull canal/canal-server:latest

部署镜像

docker run -p 11111:11111 --name canal -e canal.destinations=test -e canal.instance.master.address=mysql:3306 -e canal.instance.dbUsername=canal -e canal.instance.dbPassword=canal -e canal.instance.connectionCharset=UTF-8 -e canal.instance.tsdb.enable=true -e canal.instance.gtidon=false -e canal.instance.filter.regex=test\\..* --network masterslave -d canal/canal-server:latest

接下来开放对应端口即可。

成功启动后。
我们进入容器

docker exec -it canal bash

可以查看运行日志

tail -f canal-server/logs/canal/canal.log

通过
可以看到同步信息

tail -f canal-server/logs/test/test.log

在这里插入图片描述

5.2.3 监听后更新Redis

导入该依赖

		<dependency>
            <groupId>top.javatool</groupId>
            <artifactId>canal-spring-boot-starter</artifactId>
            <version>1.2.1-RELEASE</version>
        </dependency>

配置yml

canal:
  destination: test #要与canal-server运行时设置的名称一致
  server: IP地址:11111

笔者这里图方便,直接写在一起了

修改RedisHandler

package com.yjx23332.item.config;

import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONException;
import com.yjx23332.item.entity.TbItem;
import com.yjx23332.item.entity.TbItemStock;
import com.yjx23332.item.service.ITbItemService;
import com.yjx23332.item.service.ITbItemStockService;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.core.StringRedisTemplate;

import java.util.List;

@Configuration
public class RedisHandler implements InitializingBean {
    @Autowired
    private StringRedisTemplate redisTemplate;
    @Autowired
    private ITbItemService itemService;
    @Autowired
    private ITbItemStockService iTbItemStockService;

    /**
     * 初始化缓存,在RedisHandler初始化且成员变量完成后,执行
     * 此处我们直接将所有数据放入
     * */
    @Override
    public void afterPropertiesSet() throws Exception {
        List<TbItem> itemList = itemService.list();
        for(TbItem item:itemList){
            String json = JSON.toJSONString(item);
            redisTemplate.opsForValue().set("item:id:"+item.getId().toString(),json);
        }
        List<TbItemStock> itemStocks = iTbItemStockService.list();
        for(TbItemStock itemStock: itemStocks){
            String json = JSON.toJSONString(itemStock);
            redisTemplate.opsForValue().set("itemStock:id:"+itemStock.getItemId().toString(),json);
        }
    }
    public void saveItem(TbItem item){
        try {
            String json = JSON.toJSONString(item);
            redisTemplate.opsForValue().set("item:id:" + item.getId(), json);
        }
        catch (JSONException ex){
            throw new RuntimeException(ex);
        }
    }
    public void deleteItemById(Long id){
        redisTemplate.delete("item:id:" + id);
    }

}

这里实体类的标记与Mybatis-plus类似

  1. @Id:注解来标记ID
  2. @Column:注解来标记表中与属性名不一致的字段
  3. @Transient:标记不属于表中的字段

全部更新操作同理,此处省略。

package com.yjx23332.item.listener;

import com.yjx23332.item.config.RedisHandler;
import com.yjx23332.item.entity.TbItem;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import top.javatool.canal.client.annotation.CanalTable;
import top.javatool.canal.client.handler.EntryHandler;

@CanalTable("tb_item")
@Component
public class ItemHandler implements EntryHandler<TbItem> {
    @Autowired
    private RedisHandler redisHandler;
    @Override
    public void delete(TbItem tbItem) {
        redisHandler.deleteItemById(tbItem.getId());
    }

    @Override
    public void insert(TbItem tbItem) {
        redisHandler.saveItem(tbItem);
    }

    @Override
    public void update(TbItem before, TbItem after) {
        redisHandler.saveItem(after);
    }
}

参考文献

[1]黑马程序员Java微服务
[2]Cafeine-官方地址
[3]LUA-官方地址
[4]OpenResty官网

;