畅购商城(四):Lua、OpenResty、Canal实现广告缓存与同步


首页广告介绍

流程

在商城的首页,我们会看到很多广告,而很多时候这些广告内容都是固定的,所以每次访问MySQL获取广告内容效率是非常低的,比较好的做法就是用Redis和OpenResty做多级缓存。如果缓存中有数据就访问缓存,没有的话再去MySQL中获取,可以大大提高性能。

表结构

广告的数据是存放在changgou- content数据库中(我的这份资料里面没有这个数据库,我就自己创建了一个)。里面有两张表,一张是tb_content_catrgory(广告分类表),根据页面的不同位置,广告有不同的分类,比如首页轮播,猜你喜欢等;另一张是tb_content(广告表),这张表里存放了广告的数据。

CREATE TABLE `tb_content_category` (
    `id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '类目ID',
    `name` VARCHAR(50) DEFAULT NULL COMMENT '分类名称',
    PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8 COMMENT='内容分类';
INSERT INTO `tb_content_category` VALUES (1, '首页轮播广告');
INSERT INTO `tb_content_category` VALUES (2, '今日推荐A');
INSERT INTO `tb_content_category` VALUES (3, '活动专区');
INSERT INTO `tb_content_category` VALUES (4, '猜你喜欢');

CREATE TABLE `tb_content` (
    `id` BIGINT(20) NOT NULL AUTO_INCREMENT,
    `category_id` BIGINT(20) NOT NULL COMMENT '内容类目ID',
    `title` VARCHAR(200) DEFAULT NULL COMMENT '内容标题', 
    `url` VARCHAR(500) DEFAULT NULL COMMENT '链接',
    `pic` VARCHAR(300) DEFAULT NULL COMMENT '图片绝对路径',
    `status` VARCHAR(1) DEFAULT NULL COMMENT '状态,0无效,1有效',
    `sort_order` INT(11) DEFAULT NULL COMMENT '排序',
    PRIMARY KEY (`id`),
    KEY `category_id` (`category_id`)
) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8;
INSERT INTO `tb_content` VALUES (1, 1, '微信广告', 'https://blog.csdn.net/weixin_43461520', 'https://gitee.com/RobodLee/image_store/raw/master/%E5%BE%AE%E4%BF%A1%E5%85%AC%E4%BC%97%E5%8F%B7.png', '1', 1);

Lua

简介

Lua是一种轻量小巧的脚本语言,用标准C语言编写并以源代码形式开放, 其设计目的是为了嵌入应用程序中,从而为应用程序提供灵活的扩展和定制功能。

安装

cd /usr/local/server                                # 切换到想要下载的目录,随意
curl -R -O http://www.lua.org/ftp/lua-5.3.5.tar.gz     # 下载Lua5.3.5
tar zxf lua-5.3.5.tar.gz                             # 解压 
cd lua-5.3.5                                        # 切换到解压后的目录
make linux test                                     # 安装
-------------------------------------------------------------------------------------
[root@localhost lua-5.3.5]# lua                     # 输入lua,出现下面一行说明安装成功
Lua 5.1.4  Copyright (C) 1994-2008 Lua.org, PUC-Rio

编程方式

Lua有交互式编程和脚本式编程两种方式。

  • 交互式编程

交互式编程是输入lua命令后进入到lua控制台,然后输入lua命令来执行。

[root@localhost lua-5.3.5]# lua
Lua 5.1.4  Copyright (C) 1994-2008 Lua.org, PUC-Rio
> print("Hello World!")
Hello World!
>
  • 脚本式编程

脚本式编程就是创建一个.lua文件,然后输入命令“lua filename.lua”来执行。

基本语法

参考菜鸟教程Lua

OpenResty

简介

OpenResty 是一个强大的 Web 应用服务器,Web 开发人员可以使用 Lua 脚本语言调动 Nginx 支持的各种 C 以及 Lua 模块,更主要的是在性能方面,OpenResty可以快速构造出足以胜任 10K 以上并发连接响应的超高性能 Web 应用系统。就是封装了Nginx,并且集成了Lua脚本,开发人员只需要简单地使用提供的模块就可以实现相关的逻辑,而不再像之前,还需要在nginx中自己编写lua的脚本,再进行调用了。

安装

yum install yum-utils              # 安装yum-utils,为了使用下面一行的命令
 # 添加openresty的仓库,不配置这一行安装不了
 yum-config-manager --add-repo https://openresty.org/package/centos/openresty.repo  
 yum install openresty          # 安装openresty,界面会有提示,一路按y就可以了

安装完成之后,不要忘了启动

service openresty start

启动完成之后,用浏览器访问安装了openresty的虚拟机,如果出现了欢迎界面就说明安装成功了。

配置

虽然现在已经可以访问到OpenResty了,但是为了能够直接加载到root目录下的lua脚本,还需要配置一下。

cd /usr/local/openresty/nginx/conf          # 切换到openresty安装目录下的nginx目录中的conf目录中
vi nginx.conf                             #编辑nginx的配置文件

广告缓存的载入与读取

本节的任务就是:Nginx拦截http://192.168.31.200/read_content?id=1,执行Lua脚本,先从Nginx缓存中加载,没有的话就从Redis中加载,再没有的话就从MySQL中加载,然后MySQL——>Redis——>Nginx——>浏览器。

定义Nginx缓存模块

cd /usr/local/openresty/nginx/conf      # nginx的配置目录
vi nginx.conf       # 编辑nginx的配置文件

在http里面配置Nginx的缓存模块

Lua脚本

然后,准备好lua脚本, 在root/lua目录下创建一个read_content.lua文件 ,填入以下内容:

ngx.header.content_type="application/json;charset=utf8"
local uri_args = ngx.req.get_uri_args();    -- 获取uri中的所有参数
local id = uri_args["id"];      -- 获取名为id的参数
--获取本地缓存
local cache_ngx = ngx.shared.dis_cache; -- 加载Nginx缓存模块,需要先定义
--根据ID 获取本地缓存数据
local contentCache = cache_ngx:get('content_cache_'..id);

--[[
Nginx中有缓存就输出缓存,没有的话就从Redis中加载
--]]
if contentCache == "" or contentCache == nil then
    local redis = require("resty.redis");   -- 依赖Redis模块
    local red = redis:new()             -- 创建Redis对象
    red:set_timeout(2000)   -- 超时
    red:connect("192.168.31.200", 6379)     -- 连接Redis
    local rescontent=red:get("content_"..id);   -- 从Redis中读数据
    -- Redis中没有就从MySQL中加载
    if ngx.null == rescontent then
        local cjson = require("cjson");     -- 依赖json模块
        local mysql = require("resty.mysql");   -- 依赖mysql模块
        local db = mysql:new();     -- 创建mysql对象
        db:set_timeout(2000)    -- 设置过期时间
        -- mysql的参数信息
        local props = { 
            host = "192.168.31.200",
            port = 3306,
            database = "changgou_content",
            user = "root",
            password = "root"
        }
        local res = db:connect(props);  -- 连接mysql
        local select_sql = "select url,pic from tb_content where status ='1' and category_id="..id.." order by sort_order";
        res = db:query(select_sql); --执行sql
        local responsejson = cjson.encode(res); -- 将mysql返回的数据转换成json
        red:set("content_"..id,responsejson);   -- 存到Redis中
        ngx.say(responsejson);          -- 输出
        db:close()      -- 关闭mysql连接
    else
        cache_ngx:set('content_cache_'..id, rescontent, 10*60); -- 把Redis中的数据写到Nginx缓存中,设置过期时间
        ngx.say(rescontent)     -- 输出
    end
    red:close()   -- 关闭Redis连接
else
    ngx.say(contentCache)   -- 输出
end

配置Nginx

现在需要配置一下nginx,让它能够执行该脚本。 编辑上面提到的nginx.conf文件,在http.server中添加图中内容 👇

上面一行的意思是有read_content的请求就执行该lua文件。 重新加载一下文件

cd /usr/local/openresty/nginx/sbin     # 切换到nginx下的sbin目录中
 ./nginx -s road                      # 重新加载文件

最后测试一下:

可以看到,数据正确加载成功了。我在做这个的时候,有一个数据库字段写错了,然后一直不出结果。所以,小伙伴们一定要注意别写错了。

Nginx限流

Nginx限流的方式有两种,一种是控制速率,另一种是控制并发量。

控制速率

控制速率就是限制访问Nginx的数量,如果数量超过限制,就直接拒绝请求,不去处理。

首先我们需要进行一个 限流的配置 ,编辑上面提到的nginx的配置文件,在http里面添加以下内容:

#限流设置
#binary_remote_addr 是一种key,表示基于 remote_addr(客户端IP) 来做限流,binary_ 的目的是压缩内存占用量。
#zone:定义共享内存区来存储访问信息, contentRateLimit:10m 表示一个大小为10M,名字为contentRateLimit的内存区域。1M能存储16000 IP地址的访问信息,10M可以存储16W IP地址访问信息。
#rate 用于设置最大访问速率,rate=10r/s 表示每秒最多处理10个请求。Nginx 实际上以毫秒为粒度来跟踪请求信息,因此 10r/s 实际上是限制:每100毫秒处理一个请求。这意味着,自上一个请求处理完后,若后续100毫秒内又有请求到达,
limit_req_zone $binary_remote_addr zone=contentRateLimit:10m rate=2r/s;


配置完之后我们还需要 使用限流配置 ,在nginx的配置文件中,在http.server.location中使用限流配置

#burst相当于队列,若rate=2r/s同时有4个请求到达,Nginx 会处理第一个请求,剩余3个请求将放入队列,然后每隔500ms从队列中获取一个请求进行处理。若请求数大于4,将拒绝处理多余的请求,直接返回503
#nodelay,配合burst使用,并发处理不延迟,不按(1s/rate)秒/个的速率处理,等到完成之后,按照正常的速率处理
limit_req zone=contentRateLimit burst=4 nodelay; #使用限流配置


最后不要忘了 重新加载文件

cd /usr/local/openresty/nginx/sbin     # 切换到nginx下的sbin目录中
 ./nginx -s road                      # 重新加载文件

控制并发量

控制并发量就是限制一个ip对服务器的连接数。首先我们需要配置一下, 编辑nginx.conf文件,在http下添加如下配置

#根据IP地址来限制,存储内存大小10M,配置名为perip,大小为1m
limit_conn_zone $binary_remote_addr zone=perip:10m;
#根据IP地址来限制,存储内存大小10M,配置名为perserver,大小为1m
limit_conn_zone $server_name zone=perserver:10m;

配置完成之后,我们需要让某一个location使用这个配置,这里,我们让/brand使用这个配置, 在nginx.conf中的http.server.location /brand中添加以下内容

limit_conn perip 10;   #设置单个客户端ip与服务器的连接数为10.
limit_conn perserver 100; #限制与服务器的总连接数为100
#表示这个请求给180主机处理,因为程序运行在主机上,不在虚拟机上
proxy_pass http://192.168.31.180:18081


最后,重新加载一下文件即可。

cd /usr/local/openresty/nginx/sbin     # 切换到nginx下的sbin目录中
 ./nginx -s road                      # 重新加载文件

Canal环境搭建

介绍

Canal可以用来监控数据库数据的变化,从而获得新增数据,或者修改的数据。当数据库发生增删改的时候,会产生一个日志文件,Canal通过读取日志文件,知道哪些数据发生了变化。在这里,我们将更新的数据给到Canal微服务中,然后微服务把数据写到Redis里。

开启binlog模式以及创建MySQL用户

Canal是基于mysql的主从模式实现的,模拟了mysql slave的交互协议,把自己伪装成mysql slave,向mysql master发送dump请求,mysql master收到dump请求,开始推送binary log给slave(也就是canal)canal解析binary log对象(原始为byte流)。所以, MySQL必须得开启binlog模式

docker exec -it mysql /bin/bash     # 进入到mysql中
cd /etc/mysql/mysql.conf.d          #切换到 mysql.conf.d文件夹中
vi mysqld.cnf                       # 编辑 mysqld.cnf文件


因为Canal需要访问数据库,所以我们 给它安排个账号 ,用root账户不太安全。 打开Navicat或者直接命令行,运行 👇

-- 用户名是canal,%表示能在任意机器上登录,密码是123456
-- SELECT查询权限,REPLICATION SLAVE, REPLICATION CLIENT主从复制权限,
-- SUPER ON *.* TO 'canal'@'%':用户canal拥有任意数据库,任意表的这些权限
-- FLUSH PRIVILEGES:刷新权限
create user canal@'%' IDENTIFIED by '123456';
GRANT SELECT, REPLICATION SLAVE, REPLICATION CLIENT,SUPER ON *.* TO 'canal'@'%';
FLUSH PRIVILEGES;

最后 重启一下MySQL容器

docker restart mysql

安装和配置Canal容器

  • 下载镜像
docker pull docker.io/canal/canal-server
  • 安装Canal
docker run -p 11111:11111 --name canal -d docker.io/canal/canal-server  #11111:11111:端口映射

安装完成之后还需要进行配置,

docker exec -it canal /bin/bash     # 进入到canal容器中
cd canal-server/conf                # 切换到配置文件所在的目录


进入到canal.properties中看看,里面配置了Canal的id,端口等信息👇

再来看看instance.properties,这个文件配置了数据库相关的信息👇

配置完成后,设置开机启动,并记得重启canal。

docker update --restart=always canal
docker restart canal

Canal微服务搭建

首先, 在changgou-service下创建一个Module叫changgou-service-canal作为我们的微服务工程 。创建完成后就可以添加所需的依赖了,但是我们添加的依赖包Maven仓库里面没有,需要 手动导入 ,视频中文件我在资料里面没找到,然后我在网上找了一个,小伙伴们如果需要的话可以点击下载%EF%BC%9ALua%E3%80%81OpenResty%E3%80%81Canal%E5%AE%9E%E7%8E%B0%E5%B9%BF%E5%91%8A%E7%BC%93%E5%AD%98%E4%B8%8E%E5%90%8C%E6%AD%A5/spring- boot-starter-canal-master.zip)。下载解压后,打开里面的starter-canal目录,在这个目录下打开控制台,使用mvn install命令进行安装,过程可能有点慢,耐心等待即可。

安装完成后就可以 导入这个依赖 了。

<dependencies>
    <!--canal依赖-->
    <dependency>
        <groupId>com.xpand</groupId>
        <artifactId>starter-canal</artifactId>
        <version>0.0.1-SNAPSHOT</version>
    </dependency>
</dependencies>

微服务怎么能少了 启动类和配置文件 呢👇

@SpringBootApplication(exclude={DataSourceAutoConfiguration.class})
@EnableEurekaClient
@EnableCanalClient
public class CanalApplication {

    public static void main(String[] args) {
        SpringApplication.run(CanalApplication.class,args);
    }
}



server:
  port: 18083
spring:
  application:
    name: canal
eureka:
  client:
    service-url:
      defaultZone: http://127.0.0.1:7001/eureka
  instance:
    prefer-ip-address: true
feign:
  hystrix:
    enabled: true
#hystrix 配置
hystrix:
  command:
    default:
      execution:
        timeout:
          #如果enabled设置为false,则请求超时交给ribbon控制
          enabled: true
        isolation:
          strategy: SEMAPHORE
#canal配置
canal:
  client:
    instances:
      example:
        host: 192.168.31.200
        port: 11111

启动一下,看看有没有问题

哎呀,出问题了。这个问题搞得我一夜都没睡好觉,非常难受,折腾了很长时间。最后把Canal卸了重装,终于搞定了。虚拟机里面有Canal,我就没装直接改改配置就用了,可能是之前哪个地方的配置有问题吧,所以还是得自己装一遍。

再启动一次

终于好了!Canal微服务搭建成功!

广告同步

搭建微服务


如上图,每次执行广告操作的时候,MySQL会记录操作日志,然后将操作日志发送给canal,canal将操作记录发送给canal微服务,canal微服务根据修改的分类ID调用content微服务查询分类对应的所有广告,canal微服务再将所有广告存入到Redis缓存。

首先,我们需要搭建一个广告微服务, 在changgou-service-api中创建一个Module叫changgou-service-content- api 作为API工程,然后 在com.robod.content.pojo包中准备添加两个JavaBean:Content.java和ContentCategory.java

然后 在changgou-service下创建一个changgou-service-content工程 作为广告微服务。添加所需的依赖:

<dependencies>
    <dependency>
        <groupId>com.changgou</groupId>
        <artifactId>changgou-common</artifactId>
        <version>1.0-SNAPSHOT</version>
    </dependency>
    <dependency>
        <groupId>com.changgou</groupId>
        <artifactId>changgou-service-content-api</artifactId>
        <version>1.0-SNAPSHOT</version>
    </dependency>
</dependencies>

最后,添加配置文件和启动类

server:
  port: 18084
spring:
  application:
    name: content
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://192.168.31.200:3306/changgou_content?useUnicode=true&characterEncoding=UTF-8&serverTimezone=UTC
    username: root
    password: root
eureka:
  client:
    service-url:
      defaultZone: http://127.0.0.1:7001/eureka
  instance:
    prefer-ip-address: true
feign:
  hystrix:
    enabled: true
mybatis:
  configuration:
    map-underscore-to-camel-case: true  #开启驼峰功能

#hystrix 配置
hystrix:
  command:
    default:
      execution:
        timeout:
          #如果enabled设置为false,则请求超时交给ribbon控制
          enabled: true
        isolation:
          strategy: SEMAPHORE

logging:
  level:
    com: debug  # 不加这个MyBatis Log插件不打印sql语句



@SpringBootApplication
@EnableEurekaClient
@MapperScan(basePackages = {"com.robod.mapper"})
public class ContentApplication {

    public static void main(String[] args) {
        SpringApplication.run(ContentApplication.class);
    }
}

启动一下👉

广告查询实现

这一步需要完成的功能就是根据广告的分类id去查询出对应的广告集合,所以,我们添加一个叫findByCategoryId的方法去实现这个功能,在各层中实现一下。

/**
 * 根据分类的ID 获取该分类下的所有的广告的列表
 * Controller层 ContentController.java
 */
@GetMapping(value = "/list/category/{id}")
public Result<List<Content>> findByCategoryId(@PathVariable long id){
   List<Content>  contents = contentService.findByCategoryId(id);
   return new Result<>(true,StatusCode.OK,"成功查询出所有的广告",contents);
}
-----------------------------------------------------------------------------
//Service层 ContentServiceImpl.java
@Override
public List<Content> findByCategoryId(long id) {
    return contentMapper.findByCategoryId(id);
}
-------------------------------------------------------------------------------
// Dao层 ContentMapper.java
@Select("select * from tb_content where category_id = #{id} and status = 1")
List<Content> findByCategoryId(long id);

因为我们需要在Canal微服务中调用广告微服务中的方法,所以 在changgou-service-content-api工程中添加feign

@FeignClient(name="content")    //指定微服务的名字
@RequestMapping(value = "/content")
public interface ContentFeign {

    /**
     * 根据分类ID查询所有广告
     * @param id
     * @return
     */
    @GetMapping(value = "/list/category/{id}")
    Result<List<Content>> findByCategoryId(@PathVariable long id);
}

广告同步实现

既然是将数据同步到Redis,那么就需要配置一下Redis, 在Canal微服务中修改application.yml配置文件,添加redis配置

接下来,在启动类中开启feign, 修改CanalApplication,添加@EnableFeignClients注解

//要先在changgou-servie-canal中添加changgou-service-content-api的依赖
@EnableFeignClients(basePackages = {"com.robod.content.feign"})

最后, 在com.robod.canal包中添加一个监听类CanalDataEventListener 去监听数据变化并将变化的数据写到Redis中。

/**
 * @author Robod
 * @date 2020/7/14 10:47
 * 实现MySQL数据监听
 */
@CanalEventListener
public class CanalDataEventListener {
    private final ContentFeign contentFeign;
    private final StringRedisTemplate stringRedisTemplate;

    public CanalDataEventListener(ContentFeign contentFeign, StringRedisTemplate stringRedisTemplate) {
        this.contentFeign = contentFeign;
        this.stringRedisTemplate = stringRedisTemplate;
    }

    /**
     * 监听数据变化,将数据写到Redis中
     * @param eventType
     * @param rowData
     */
    @ListenPoint(
            destination = "example",
            schema = "changgou_content",
            table = {"tb_content","tb_content_category"},
            eventType = {
                    CanalEntry.EventType.INSERT,
                    CanalEntry.EventType.UPDATE,
                    CanalEntry.EventType.DELETE}
    )
    public void onEventListener(CanalEntry.EventType eventType, CanalEntry.RowData rowData) {
        String categoryId = getColumnValue(eventType,rowData);
        List<Content> contents = contentFeign.findByCategoryId(Long.parseLong(categoryId)).getData();
        stringRedisTemplate.boundValueOps("content_"+categoryId).set(JSON.toJSONString(contents));
    }

    private String getColumnValue(CanalEntry.EventType eventType, CanalEntry.RowData rowData) {
        if (eventType == CanalEntry.EventType.UPDATE || eventType == CanalEntry.EventType.INSERT) {
            for (CanalEntry.Column column : rowData.getAfterColumnsList()) {
                if ("category_id".equalsIgnoreCase(column.getName())) {
                    return column.getValue();
                }
            }
        }
        if (eventType == CanalEntry.EventType.DELETE) {
            for (CanalEntry.Column column : rowData.getBeforeColumnsList()) {
                if ("category_id".equalsIgnoreCase(column.getName())) {
                    return column.getValue();
                }
            }
        }
        return "";
    }

}

来测试一下👇

OK!数据库中和Redis中数据同步了。


原文链接:https://blog.csdn.net/weixin_43461520/article/details/107348436