案例背景
电商首页通常都会有广告轮播图,广告轮播图的数据一般需要通过后台接口获得,当并发量较大时会给服务器带来压力。
一般的解决方案是将轮播图数据缓存到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)
分析
首页有几个地方的广告轮播图数据需要连接后台,接口较多,一起访问时导致页面响应较慢,接口直接访问数据库,查询速度慢
第一轮优化
使用Redis缓存,把从数据库查询的广告轮播图数据,全部缓存到Redis中,这样每次查询请求会直接查询Redis的缓存数据并返回。因为广告轮播图数据较少更新,所以会大大减少直接对数据库的查询访问
优化结果
将所有接口的响应时间总和降低为:2~3s
问题
Tomcat性能遇到瓶颈,如何再一次降低响应时间?
分析
使用Nginx服务器,Nginx的性能和并发量远远高于Tomcat,直接在Nginx服务器中访问Redis,提升响应速度和并发性能
第二轮优化
通过OpenResty整合Lua脚本访问Redis,直接将数据返回给前端页面。分为两级缓存,一级缓存是Nginx内部缓存,二级是Redis缓存,首先读取Nginx内部缓存,如果没有再读取Redis缓存。通过缓存预热的定时任务实现MySQL和Redis的数据同步
优化结果
响应时间总和降低为:1~2s
开发步骤
1、缓存预热
2、定时任务
3、缓存读取
缓存预热
使用Lua读取MySQL中的广告轮播图数据,保存到Redis中
操作流程
这里选择在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秒触发任务
操作流程
- 编写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缓存
操作流程
与上面编写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和端口
}
}
- 测试首页页面效果