案例背景

电商首页通常都会有广告轮播图,广告轮播图的数据一般需要通过后台接口获得,当并发量较大时会给服务器带来压力。

一般的解决方案是将轮播图数据缓存到Redis中,通过查询Redis中已缓存的数据并返回,这样就能减少对数据库的访问,从而减少服务器的并发压力。

但是我们访问Redis也需要使用Java,Java项目部署在Tomcat中,Tomcat服务器也会面对并发量大的压力。

Nginx服务器的并发性能要远远高于Tomcat,在Nginx中使用Lua脚本就能实现MySQL和Redis的读和写,绕过Tomcat,大大提高首页的并发性能。

问题

测试人员和体验用户反馈首页广告轮播图数据响应时间较长,大概在4s左右(2-5-8原则:S<=2s 优秀 2s<S<=5s 良好 5s<S<=8s 一般 S>8s Bug)

分析

首页有几个地方的广告轮播图数据需要连接后台,接口较多,一起访问时导致页面响应较慢,接口直接访问数据库,查询速度慢

openresty lua 读取post方法报文_spring cloud

第一轮优化

使用Redis缓存,把从数据库查询的广告轮播图数据,全部缓存到Redis中,这样每次查询请求会直接查询Redis的缓存数据并返回。因为广告轮播图数据较少更新,所以会大大减少直接对数据库的查询访问

优化结果

将所有接口的响应时间总和降低为:2~3s

openresty lua 读取post方法报文_redis_02

问题

Tomcat性能遇到瓶颈,如何再一次降低响应时间?

分析

使用Nginx服务器,Nginx的性能和并发量远远高于Tomcat,直接在Nginx服务器中访问Redis,提升响应速度和并发性能

openresty lua 读取post方法报文_nginx_03

第二轮优化

通过OpenResty整合Lua脚本访问Redis,直接将数据返回给前端页面。分为两级缓存,一级缓存是Nginx内部缓存,二级是Redis缓存,首先读取Nginx内部缓存,如果没有再读取Redis缓存。通过缓存预热的定时任务实现MySQL和Redis的数据同步

优化结果

响应时间总和降低为:1~2s

开发步骤

1、缓存预热
2、定时任务
3、缓存读取

缓存预热

使用Lua读取MySQL中的广告轮播图数据,保存到Redis中

openresty lua 读取post方法报文_spring cloud_04

操作流程

这里选择在Linux系统中进行操作,需先安装Nginx、MySQL、Redis、OpenResty、Lua,全部安装完成后

  • 在root用户下,进入 usr/local/openresty/nginx/conf/lua(这里选择把lua脚本的目录创建在此)
  • 创建名为ad_update的lua脚本 vi/vim ad_update.lua(使用vim编辑器创建并打开lua脚本)
  • 在脚本中编辑业务代码
-- 获得URI中的单个变量值
-- http://192.168.237.150:8080/lua?spaceId=1
-- ngx.say(ngx.var.arg_spaceId)打印的结果为1
ngx.say("parameter="..ngx.var.arg_spaceId)

-- 连接mysql
local mysql = require "resty.mysql"
local db = mysql:new()
db:set_timeout(1000)
local ok, err = db:connect{
        host = "127.0.0.1",		--IP
        port = 3306,			--端口
        database = "edu_ad", 	--数据库库名
        user = "root",			--数据库账号
        password = "",			--数据库密码
        charset = "utf8"
}
if not ok then
        ngx.say("mysql connect error",error)	--返回连接失败的结果
        return
end
ngx.say("mysql connect success")	--返回连接成功的结果

-- 按区域编号查询轮播图表的数据
local res, err = db:query("select * from promotion_space space left join promotion_ad ad on space.id = ad.space_id where space.id ="..ngx.var.arg_spaceId)
if not res then
    ngx.say("select error", err)	--返回连接成功的结果
    return
end
db:close()	--关闭连接
local cjson = require "cjson"
ngx.say(cjson.encode(res))	-- 结果转换为json格式

-- 连接redis
local redis = require "resty.redis"
local red = redis:new()
red:set_timeout(2000)
local ok,err = red:connect("127.0.0.1",6379)	--连接的本地IP和默认端口
if not ok then
    ngx.say("redis connect error: ", err)	--返回连接成功的结果
    return
end
ngx.say("redis connect success")	--返回连接成功的结果

red:set("ad_space_"..ngx.var.arg_spaceId,cjson.encode(res))	--把MySQL查询的数据缓存到Redis中
local value = red:get("ad_space_"..ngx.var.arg_spaceId)	--查看Redis中该Key缓存的数据
ngx.say("ad_space_"..ngx.var.arg_spaceId.."="..value)	--打印Key,Value
red:close()	--关闭连接
  • 返回到usr/local/openresty/nginx/conf目录下,配置nginx.conf文件 vi/vim nginx.conf
  • 添加如下配置内容
server {
        listen 8083;	--配置端口8083
        default_type  'application/json;charset=utf8';	--返回数据格式和中文编码
        charset utf-8;

        location /ad_update {
        default_type text/html;
        content_by_lua_file conf/lua/ad_update.lua;	--ad_update.lua脚本路径
        }
}
  • 访问测试 http://192.168.237.150:8083/ad_update?spaceId=1,读取数据并缓存成功
  • 完成了从MySQL数据库的数据读取

定时任务

SpringBoot自带Scheduling包提供了任务调度功能

1)配置类添加注解 @EnableScheduling

2)在需要调度的方法上添加 @Scheduled注解

3)配置cron表达式

/**
* {秒数} {分钟} {小时} {日期} {月份} {星期} {年份(可为空)}
* 秒 0-59 , - * /
* 分 0-59 , - * /
* 小时 0-23 , - * /
* 日期 1-31 , - * ? / L W C
* 月份 1-12 或者 JAN-DEC , - * /
* 星期 1-7 或者 SUN-SAT , - * ? / L C #
* 年(可选) 留空, 1970-2099 , - * /
*/
@Scheduled(cron = "*/5 * * * * ?")

常见的cron表达式

*/5 * * * * ? 每隔5秒执行一次
 0 */1 * * * ? 每隔1分钟执行一次
 0 0 5-15 * * ? 每天5-15点整点触发
 0 0/3 * * * ? 每三分钟触发一次
 0 0-5 14 * * ? 在每天下午2点到下午2:05期间的每1分钟触发 
 0 0/5 14 * * ? 在每天下午2点到下午2:55期间的每5分钟触发
 0 0/5 14,18 * * ? 在每天下午2点到2:55期间和下午6点到6:55期间的每5分钟触发
 0 0/30 9-17 * * ? 朝九晚五工作时间内每半小时
 0 0 10,14,16 * * ? 每天上午10点,下午2点,4点 
 
 0 0 12 ? * WED 表示每个星期三中午12点
 0 0 17 ? * TUES,THUR,SAT 每周二、四、六下午五点
 0 10,44 14 ? 3 WED 每年三月的星期三的下午2:10和2:44触发 
 0 15 10 ? * MON-FRI 周一至周五的上午10:15触发
 0 0 23 L * ? 每月最后一天23点执行一次
 0 15 10 L * ? 每月最后一日的上午10:15触发 
 0 15 10 ? * 6L 每月的最后一个星期五上午10:15触发 
 0 15 10 * * ? 2005 2005年的每天上午10:15触发 
 0 15 10 ? * 6L 2002-2005 2002年至2005年的每月的最后一个星期五上午10:15触发 
 0 15 10 ? * 6#3 每月的第三个星期五上午10:15触发

"30 * * * * ?" 每半分钟触发任务
"30 10 * * * ?" 每小时的10分30秒触发任务
"30 10 1 * * ?" 每天1点10分30秒触发任务
"30 10 1 20 * ?" 每月20号1点10分30秒触发任务
"30 10 1 20 10 ? *" 每年10月20号1点10分30秒触发任务
"30 10 1 20 10 ? 2011" 2011年10月20号1点10分30秒触发任务
"30 10 1 ? 10 * 2011" 2011年10月每天1点10分30秒触发任务
"30 10 1 ? 10 SUN 2011" 2011年10月每周日1点10分30秒触发任务
"15,30,45 * * * * ?" 每15秒,30秒,45秒时触发任务
"15-45 * * * * ?" 15到45秒内,每秒都触发任务
"15/5 * * * * ?" 每分钟的每15秒开始触发,每隔5秒触发一次
"15-30/5 * * * * ?" 每分钟的15秒到30秒之间开始触发,每隔5秒触发一次
"0 0/3 * * * ?" 每小时的第0分0秒开始,每三分钟触发一次
"0 15 10 ? * MON-FRI" 星期一到星期五的10点15分0秒触发任务
"0 15 10 L * ?" 每个月最后一天的10点15分0秒触发任务
"0 15 10 LW * ?" 每个月最后一个工作日的10点15分0秒触发任务
"0 15 10 ? * 5L" 每个月最后一个星期四的10点15分0秒触发任务
"0 15 10 ? * 5#3" 每个月第三周的星期四的10点15分0秒触发任务

在线cron生成器链接

操作流程

  • 编写HttpMyUtils工具类,用于代替开发人员发送执行lua脚本的请求,更新最新的广告轮播图数据缓存到Redis中
package com.blb.ad_service.utils;


import java.io.*;
import java.net.HttpURLConnection;
import java.net.URL;

/**
 * @author dell
 */
public class HttpMyUtils {

    public static void getReq(String path) {
        try {
            URL url = new URL(path);
            HttpURLConnection conn = (HttpURLConnection) url.openConnection();
            conn.setConnectTimeout(5000);
            conn.setRequestMethod("GET");
            if (conn.getResponseCode() == 200) {
                BufferedReader in = new BufferedReader(new InputStreamReader(conn.getInputStream()));
                String s = null;
                while ((s = in.readLine()) != null) {
                    System.out.println(s);
                }
                in.close();
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    public static void postReq(String path, String args) {
        try {
            URL url = new URL(path);
            HttpURLConnection conn = (HttpURLConnection) url.openConnection();
            conn.setConnectTimeout(5000);
            conn.setRequestMethod("POST");
            conn.setRequestProperty("Content-Type", "application/x-www-form-urlencoded");
            //发送数据
            conn.setDoOutput(true);
            OutputStream out = conn.getOutputStream();
            out.write(args.getBytes("UTF-8"));
            if (conn.getResponseCode() == 200) {
                BufferedReader in = new BufferedReader(new InputStreamReader(conn.getInputStream()));
                String s = null;
                while ((s = in.readLine()) != null) {
                    System.out.println(s);
                }
                in.close();
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    public static void putReq(String path, String args) {
        try {
            URL url = new URL(path);
            HttpURLConnection conn = (HttpURLConnection) url.openConnection();
            conn.setConnectTimeout(5000);
            conn.setRequestMethod("PUT");
            conn.setRequestProperty("Content-Type", "application/json");
            //发送数据
            conn.setDoOutput(true);
            OutputStream out = conn.getOutputStream();
            out.write(args.getBytes("UTF-8"));
            if (conn.getResponseCode() == 200) {
                BufferedReader in = new BufferedReader(new InputStreamReader(conn.getInputStream()));
                String s = null;
                while ((s = in.readLine()) != null) {
                    System.out.println(s);
                }
                in.close();
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    public static void deleteReq(String path) {
        try {
            URL url = new URL(path);
            HttpURLConnection conn = (HttpURLConnection) url.openConnection();
            conn.setConnectTimeout(5000);
            conn.setRequestMethod("DELETE");
            if (conn.getResponseCode() == 200) {
                BufferedReader in = new BufferedReader(new InputStreamReader(conn.getInputStream()));
                String s = null;
                while ((s = in.readLine()) != null) {
                    System.out.println(s);
                }
                in.close();
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
  • 在SpringBoot项目的Config包下新建Schedule配置类,在类上添加@EnableScheduling注解,在方法上添加 @Scheduled注解,配置cron表达式
package com.blb.ad_service.config;

import com.blb.ad_service.utils.HttpMyUtils;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.Scheduled;

/**
 * 更新广告缓存资源的定时任务
 *
 * @author dell
 */
@EnableScheduling
@Configuration
public class Schedule {

    @Scheduled(cron = "*/5 * * * * ?") //配置cron表达式,每隔5秒执行一次
    public void updateRedis() {
        System.out.println("开始更新Redis缓存的数据");
        HttpMyUtils.getReq("http://192.168.237.150:8083/ad_update?spaceId=1");
        System.out.println("此次Redis缓存结束");
    }
}
  • 项目运行,测试结果
  • 完成定时发送查询数据库的请求

缓存读取

前端通过发送请求,读取缓存中的数据

分为两级缓存,一级缓存是Nginx内部缓存,二级缓存是Redis缓存,先读取内部缓存,如果没有再读取Redis缓存

openresty lua 读取post方法报文_lua_05

操作流程

与上面编写ad_update.lua脚本类似

  • 在root用户下,进入 usr/local/openresty/nginx/conf/lua(这里选择把lua脚本的目录创建在此)
  • 创建名为ad_load的lua脚本 vi/vim ad_load.lua(使用vim编辑器创建并打开lua脚本)
  • 在脚本中编辑业务代码
-- 获得sid参数
local uri_args = ngx.req.get_uri_args()
local sid = uri_args["sid"]

-- 读取内部缓存
local cache = ngx.shared.dis_cache:get('ad_space_'..sid)
if cache == nil then
   -- 内部缓存没有读取redis
   local redis = require "resty.redis"	
   local red = redis:new()
   red:set_timeout(2000)
   local ok, err = red:connect("127.0.0.1", 6379)
   local res = red:get('ad_space_'..sid)
   ngx.say(res)
   red:close()	--关闭连接
   -- 保存到内部缓存
   ngx.shared.dis_cache:set('ad_space_'..sid, res, 10*60)
else
   ngx.say(cache)
end
  • 返回到usr/local/openresty/nginx/conf目录下,配置nginx.conf文件 vi/vim nginx.conf
  • 在之前添加的配置后面追加如下内容
server {
        listen 8083;
        default_type  'application/json;charset=utf8';
        charset utf-8;

        location /ad_update {
                default_type text/html;
                content_by_lua_file conf/lua/ad_update.lua;
        }

        location /ad_load {
                default_type text/html;
                content_by_lua_file conf/lua/ad_load.lua;
        }
}
  • 在http模块加内部缓存配置,lua_shared_dict dis_cache 5m;
http {
    lua_shared_dict dis_cache 5m;
    include       mime.types;
    default_type  application/octet-stream;
	后面的配置省略......
}
  • 访问测试 http://192.168.237.150:8083/ad_load?sid=1,读取缓存数据成功
  • 完成了从缓存中的数据读取

前端Vue页面

  • 修改前端访问的请求路径,直接访问缓存中已经缓存完成的广告轮播图数据
methods: {
	//顶部轮播图图片
	loadShufflingFigure() {
	    axios({
	        url: 'http://192.168.237.150:8083/ad_load?sid=1',
	        method: 'get',
	    }).then(res => {
	        this.adList = res.data;
	    }).catch(err => {
	        this.$message.error("获取广告轮播图数据失败!");
	    })
	},
}
  • 在之前修改的nginx.conf文件中,继续追加如下内容。通过Nginx的反向代理,访问http://192.168.237.150:8083/,代理http://192.168.110.39:8080/,完成首页广告轮播图的显示
server {
         listen 8083;
         default_type  'application/json;charset=utf8';
         charset utf-8;

         location /ad_update {
                 default_type text/html;
                 content_by_lua_file conf/lua/ad_update.lua;
         }

         location /ad_load {
                 default_type text/html;
                 content_by_lua_file conf/lua/ad_load.lua;
         }

         location / {
                 root html;
                 index index.html index.htm;
                 proxy_pass http://192.168.110.39:8080/;	--前端项目启动的IP和端口
         }
 }
  • 测试首页页面效果

首页优化全部完成