Redis --- 多级缓存【一篇文章带你深入了解缓存架构】

打印 上一主题 下一主题

主题 880|帖子 880|积分 2640

传统的缓存策略一样平常是请求到达Tomcat后,先查询Redis,在查询数据库。但是如许会有很多标题:

  • 请求都需要经过Tomcat处理,而Tomcat的性能就会成为整个体系的瓶颈。
  • 倘若Redis缓存失效,会对数据库短时间内造成巨大冲击。

而我们为了解决上面两种标题,提出了多级缓存处理方案。
多级缓存就是充分利用请求处理的每个环节,分别添加缓存,以此来减轻Tomcat的压力,提升服务性能,通常有下面这几层:

  • 浏览器客户端缓存:浏览器可以将静态资源缓存,随后检验数据是否发生变化(无变化直接返回304状态码),假如为304则数据无变化,直接将数据从浏览器中取出。
  •  nginx本地缓存:将非静态资源缓存。
  • Redis缓存
  • Tomcat进程缓存:在服务器内部利用一个map等形式形成一个进程缓存。

所以为了学习多级缓存,我们需要学习以下知识:

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


JVM进程缓存


 1.安装Mysql:

后期做数据同步需要用到MySQL的主从功能,所以需要我们在虚拟机中利用Docker命令来运行一个MySQL容器。不会安装的可以参考下面教程:Linux --- 怎样安装Docker命令而且利用docker安装Mysql【一篇内容直接解决】
(1)准备目次:

为了方便后期设置MySQL,我们先准备两个目次,用于挂载容器的数据和设置文件目次:
  1. # 进入/tmp目录
  2. cd /tmp
  3. # 创建文件夹
  4. mkdir mysql
  5. # 进入mysql目录
  6. cd mysql
复制代码
(2)运行命令:

进入mysql目次后,执行下面的Docker命令:
  1. docker run \
  2. -p 3306:3306 \
  3. --name mysql \
  4. -v $PWD/conf:/etc/mysql/conf.d \
  5. -v $PWD/logs:/logs \
  6. -v $PWD/data:/var/lib/mysql \
  7. -e MYSQL_ROOT_PASSWORD=123 \
  8. --privileged \
  9. -d \
  10. mysql:5.7.25
复制代码
(3)修改设置:

在/tmp/mysql/conf目次添加一个my.cnf文件,作为mysql的设置文件:         
  1. # 创建文件
  2. touch /tmp/mysql/conf/my.cnf
复制代码
文件的内容如下:
  1. [mysqld]
  2. skip-name-resolve
  3. character_set_server=utf8
  4. datadir=/var/lib/mysql
  5. server-id=1000
复制代码
(4)重启:

设置修改后,必须重启容器:
  1. docker restart mysql
复制代码
随后在navicat连接即可: (留意:这里主秘密用虚拟机的IP,可以利用ifconfig命令查察本身的虚拟机IP地址)

 2.运行nginx服务:

我们将一个nginx文件夹拷贝到一个非中文目次下,运行这个nginx服务:
  1. start nginx.exe
复制代码
我们打开控制台,可以看到页面有发起ajax查询数据,而这个请求地址同样是80端口,所以被当前的nginx反向代理了: 

可以查察nginx.conf以此来看详细信息:
  1. http {
  2.     include       mime.types;
  3.     default_type  application/octet-stream;
  4.     sendfile        on;
  5.     #tcp_nopush     on;
  6.     keepalive_timeout  65;
  7.     # nginx的业务集群,可以实现下面三个缓存:nginx的本地缓存、redis缓存、tomcat查询
  8.     upstream nginx-cluster{
  9.         server 192.168.150.101:8081; #对应的nginx业务集群
  10.     }
  11.     server {
  12.         listen       80;
  13.         server_name  localhost;
  14.             location /api {  # 监听/api路径,反向代理到nginx-cluster集群
  15.             proxy_pass http://nginx-cluster;
  16.         }
  17.         location / {
  18.             root   html;
  19.             index  index.html index.htm;
  20.         }
  21.         error_page   500 502 503 504  /50x.html;
  22.         location = /50x.html {
  23.             root   html;
  24.         }
  25.     }
  26. }
复制代码

  1. # nginx的业务集群,可以实现下面三个缓存:nginx的本地缓存、redis缓存、tomcat查询
  2. upstream nginx-cluster{
  3.     server 192.168.150.101:8081; #对应的nginx业务集群
  4. }
复制代码
 而我们上面的代码就相当于下面化红框的部门:


3.本地进程缓存:

进行本地进程缓存我们一样平常利用Caffeine。Caffeine 是一个高性能的 Java 本地缓存库,广泛应用于 Spring Boot 项目中来缓存数据。Caffeine 提供了比 Guava Cache 更高的性能,同时支持高级特性如缓存过期、最大容量、自动加载等。下面有两个Caffeine教学地址:

  • Caffeine官网教学
  • 高并发处理 --- Caffeine内存缓存库
这里不给详细教学,参考下面的方法进行利用即可: 
  1. public class CaffeineTest {
  2.     @Test
  3.     void testBasicOps() {
  4.         // 创建缓存对象
  5.         Cache<String, String> cache = Caffeine.newBuilder().build();
  6.         // 存数据
  7.         cache.put("gf", "迪丽热巴");
  8.         // 取数据,不存在则返回null
  9.         String gf = cache.getIfPresent("gf");
  10.         System.out.println("gf = " + gf);
  11.         // 取数据,不存在则去数据库查询
  12.         String defaultGF = cache.get("defaultGF", key -> {
  13.             // 这里可以去数据库根据 key查询value
  14.             return "柳岩";
  15.         });
  16.         System.out.println("defaultGF = " + defaultGF);
  17.     }
  18. }
复制代码
以及缓存驱逐策略:

  1. import com.github.benmanes.caffeine.cache.Cache;
  2. import com.github.benmanes.caffeine.cache.Caffeine;
  3. import org.junit.jupiter.api.Test;
  4. import java.time.Duration;
  5. public class CaffeineTest {
  6.     /*
  7.      基于大小设置驱逐策略:
  8.      */
  9.     @Test
  10.     void testEvictByNum() throws InterruptedException {
  11.         // 创建缓存对象
  12.         Cache<String, String> cache = Caffeine.newBuilder()
  13.                 // 设置缓存大小上限为 1
  14.                 .maximumSize(1)
  15.                 .build();
  16.         // 存数据
  17.         cache.put("gf1", "柳岩");
  18.         cache.put("gf2", "范冰冰");
  19.         cache.put("gf3", "迪丽热巴");
  20.         // 延迟10ms,给清理线程一点时间
  21.         Thread.sleep(10L);
  22.         // 获取数据
  23.         System.out.println("gf1: " + cache.getIfPresent("gf1"));
  24.         System.out.println("gf2: " + cache.getIfPresent("gf2"));
  25.         System.out.println("gf3: " + cache.getIfPresent("gf3"));
  26.     }
  27.     /*
  28.      基于时间设置驱逐策略:
  29.      */
  30.     @Test
  31.     void testEvictByTime() throws InterruptedException {
  32.         // 创建缓存对象
  33.         Cache<String, String> cache = Caffeine.newBuilder()
  34.                 .expireAfterWrite(Duration.ofSeconds(1)) // 设置缓存有效期为 10 秒
  35.                 .build();
  36.         // 存数据
  37.         cache.put("gf", "柳岩");
  38.         // 获取数据
  39.         System.out.println("gf: " + cache.getIfPresent("gf"));
  40.         // 休眠一会儿
  41.         Thread.sleep(1200L);
  42.         System.out.println("gf: " + cache.getIfPresent("gf"));
  43.     }
  44. }
复制代码

Lua语法入门



在上面我们利用 Caffeine 实现 Tomcat 本地进程缓存,那么接下来我们就要利用 Lua 脚本实现 nginx 的本地缓存。
Lua 是一种轻量级、可扩展的脚本语言,广泛应用于嵌入式体系、游戏开辟、Web 应用、Nginx 设置等范畴。Lua 语法简洁,学习曲线平缓,适合快速上手。可以查察下面官网:Lua官网
在我们的Centos是自带Lua环境,可以在Linux虚拟机直接利用。
1.数据范例:


 我们直接输入lua命令就可以打开lua控制台,随后直接就能编译我们想要的结果:

 2.根本语法:

(1)变量和数据范例:

Lua 是动态范例语言,变量的范例是在运行时确定的,所以不需要指定命据范例。 
  1. local x = 10       -- 定义一个局部变量 x
  2. y = 20             -- 定义一个全局变量 y
复制代码
(2)表(table):

表是 Lua 中的唯一数据结构,可以用来体现数组、字典、聚集等。
留意:Lua中数组访问是从角标1开始,而没有角标0。
声明数组key为索引的table:
  1. local t = {}           -- 创建一个空表
  2. t[1] = "Hello"         -- 数组形式
  3. t["name"] = "Lua"      -- 字典形式
  4. print(t[1])             -- 输出:Hello
  5. print(t["name"])        -- 输出:Lua
复制代码
声明table,类似Java的map:
  1. local map = {name='Jack',age='21'}
  2. print(map['name'])
  3. print(map.name)
复制代码
(3)判定与循环:

Lua 支持常见的控制结构,如条件语句和循环。
条件语句:

  1. local a = 10
  2. if a > 5 then
  3.     print("a is greater than 5")
  4. elseif a == 5 then
  5.     print("a is equal to 5")
  6. else
  7.     print("a is less than 5")
  8. end
复制代码
循环: 
  1. local i = 1
  2. -- while循环
  3. while i <= 5 do
  4.     print(i)
  5.     i = i + 1
  6. end
  7. -- for循环
  8. for i = 1, 5 do
  9.     print(i)
  10. end
复制代码

(4)函数:

函数定义
  1. function greet(name)
  2.     return "Hello, " .. name
  3. end
  4. print(greet("Lua"))  -- 输出:Hello, Lua
复制代码
匿名函数
  1. local add = function(a, b)
  2.     return a + b
  3. end
  4. print(add(2, 3))  -- 输出:5
复制代码

OpenResty


OpenResty 是基于 NginxLua 的一个高性能 Web 应用服务器,它将 LuaJIT(高效的 Lua 实现)集成到 Nginx 中,使得开辟者能够用 Lua 语言高效地处理 HTTP 请求、响应和其他各种 Web 功能。它不但可以充分发挥 Nginx 高性能的特点,还结合了 Lua 的灵活性和高效性,极大地扩展了 Nginx 的本事。
OpenResty 提供了大量的库和模块,支持动态 Web 编程,能够实现各种 Web 应用和服务,适适用于开辟 API 网关、动态 Web 应用、负载均衡器、Web 缓存等。下面是官网地址:OpenResty开源官方
1.安装OpenResty:

(1)安装OpenResty的依赖开辟库:

  1. yum install -y pcre-devel openssl-devel gcc --skip-broken
复制代码
(2)安装OpenResty仓库:

  1. yum-config-manager --add-repo https://openresty.org/package/centos/openresty.repo
复制代码
假如提示说命令不存在,则运行:
  1. yum install -y yum-utils
复制代码
然后再重复上面的命令。
(3)安装OpenResty:

  1. yum install -y openresty
复制代码
默认环境下,OpenResty安装的目次是:/usr/local/openresty  
(4)安装opm工具:

opm是OpenResty的一个管理工具,可以资助我们安装一个第三方的Lua模块。
假如我们想安装命令行工具 opm,那么可以像下面如许安装 openresty-opm 包:
  1. yum install -y openresty-opm
复制代码
(5)设置nginx的环境变量:

打开设置文件:(留意:必须在OpenResty目次下运行命令)
  1. vi /etc/profile
复制代码
在最下面加入两行:
  1. export NGINX_HOME=/usr/local/openresty/nginx
  2. export PATH=${NGINX_HOME}/sbin:$PATH
复制代码
NGINX_HOME:背面是OpenResty安装目次下的nginx的目次
然后让设置生效:
  1. source /etc/profile
复制代码
2.启动OpenResty:

OpenResty底层是基于Nginx的,查察OpenResty目次的nginx目次,结构与windows中安装的nginx根本一致,所以运行方式与nginx根本一致:
  1. # 启动nginx
  2. nginx
  3. # 重新加载配置
  4. nginx -s reload
  5. # 停止
  6. nginx -s stop
复制代码
nginx的默认设置文件解释太多,影响后续我们的编辑,这里将nginx.conf中的解释部门删除,保留有效部门。
修改/usr/local/openresty/nginx/conf/nginx.conf文件,内容如下:
  1. #user  nobody;
  2. worker_processes  1;
  3. error_log  logs/error.log;
  4. events {
  5.     worker_connections  1024;
  6. }
  7. http {
  8.     include       mime.types;
  9.     default_type  application/octet-stream;
  10.     sendfile        on;
  11.     keepalive_timeout  65;
  12.     server {
  13.         listen       8081;
  14.         server_name  localhost;
  15.         location / {
  16.             root   html;
  17.             index  index.html index.htm;
  18.         }
  19.         error_page   500 502 503 504  /50x.html;
  20.         location = /50x.html {
  21.             root   html;
  22.         }
  23.     }
  24. }
复制代码
在Linux的控制台输入命令以启动nginx:
  1. nginx
复制代码
然后访问页面:http://192.168.88.133:8081,留意ip地址更换为你本身的虚拟机IP,而且要关闭防火墙。随后运行乐成访问网页可以得到下面图片样式。

3.在OpenResty中接受请求: 

在上面的nginx的设置中,我们介绍了下面代码:
  1. # nginx的业务集群,可以实现下面三个缓存:nginx的本地缓存、redis缓存、tomcat查询
  2. upstream nginx-cluster{
  3.     server 192.168.88.133:8081; #对应的nginx业务集群
  4. }
  5. server {
  6.     listen       80;
  7.     server_name  localhost;
  8.         location /api {  # 监听/api路径,反向代理到nginx-cluster集群
  9.         proxy_pass http://nginx-cluster;
  10.     }
  11. }
复制代码
而且当访问 "/api" 的时间,会反向代理访问 nginx 的业务集群,而这个业务集群地址就是我们设置的 OpenResty 的地址,所以接下来我们的任务就需要再 OpenResty 中接受该请求。
起首在 nginx.conf 文件内的 http 下面,添加对 OpenResty 的 Lua 模块的加载:
  1. #lua 模块
  2. lua_package_path "/usr/local/openresty/lualib/?.lua;;";
  3. #c模块     
  4. lua_package_cpath "/usr/local/openresty/lualib/?.so;;";  
复制代码
随后在 nginx.conf 文件内的 server 下面,添加对路径 “/api/item” 的监听:
  1. location /api/item/(\d+) {
  2.     # 响应类型,这里返回json
  3.     default_type application/json;
  4.     # 响应数据由 lua/item.lua 这个文件来决定
  5.     content_by_lua_file lua/item.lua;
  6. }
复制代码
随后我们在/nginx/lua/item.lua文件中添加下面代码就可以实现:
  1. -- 获取路径参数(\d+)
  2. local id = ngx.var[1]
  3. -- 返回结果
  4. ngx.say('{"name":"zhangsan","age":"21"}')
复制代码
  ngx.say 是 OpenResty 中提供的一个函数,它用于向客户端输出数据。这会将数据发送到 HTTP 响应体中,作为客户端吸收到的响应内容。 在这一行代码输出一个 JSON 字符串 {"name":"zhangsan","age":"21"},并作为 HTTP 响应返回给客户端。
    工作流程:

  

  • 当客户端访问一个设置了这段代码的 Nginx 路由时,Nginx 会通过 Lua 执行这行代码。
  • ngx.say() 会将这个 JSON 字符串作为响应返回给客户端。
  • 客户端收到响应后,可以将其作为 JSON 数据进行解析,提取出 name 和 age 的值。
  最后重新加载设置:
  1. nginx -s reload
复制代码
而OpenResty提供了各种API用来获取不同范例的请求参数: 

 4.怎样实现从OpenResty到Tomcat的查询:


   执行流程概述:

  

  • 客户端请求到达 Nginx
  • Nginx 代理请求到 OpenResty,执行 Lua 脚本。
  • OpenResty 的 Lua 脚本向内部路径 /path 发起请求
  • Nginx 代理请求到 Tomcat,Tomcat 处理请求并返回响应。
  • OpenResty 捕获 Tomcat 的响应并返回给客户端
  1. 客户端 → Nginx → OpenResty (Lua 脚本) → Nginx (反向代理) → Tomcat → Nginx (返回) → OpenResty → 客户端
复制代码
下面是案例:

(1)发送Http请求: 

 起首需要查询商品信息,nginx提供了内部的API用来发送Http请求,比方下面例子:
  1. local resp = ngx.location.capture("/path",{ -- /path 是目标路径,表示请求将被发往这个 Nginx 路由
  2.     method = ngx.HTTP_GET, -- 请求方式
  3.     args = {a = 1,b = 2}, -- get方式传参数,最终请求的 URL 会是 /path?a=1&b=2
  4.     body = "c=3&d=4" -- post方式传参数
  5. })
复制代码
  其返回响应内容包括:
  

  • resp.status:响应状态码
  • resp.header:响应头,是一个table
  • resp.body:响应体,就是响应数据
  但是这里的path是路径,并不包含IP以及端标语,所以这个请求会被nginx内部的server监听并处理。但是我们需要将这个请求发送给Tomcat服务器,所以还需要编写一个server来对这个路径做反向代理:
  1. location /path {
  2.     # 这里是windows电脑的ip和Java的服务接口,需要确保windows防火墙处于关闭状态
  3.     proxy_pass http://192.168.150.1:8081;
  4. }
复制代码
上面这段设置的作用是,当访问 /path 路径时,Nginx 会将请求代理到 http://192.168.150.1:8081,即转发到 Windows 电脑上的 Java 服务接口。
(2)函数封装:

假如上面的内容需要反复调用,我们可以将Http查询封装一个函数方法,放到OpenResty函数库中,方便后期利用。
起首在 /usr/local/openresty/lualib 目次下创建common.lua文件:
  1. vi /usr/local/openresty/lualib/common.lua
复制代码
留意:在前面我们设置了下面命令:
  1. #lua 模块
  2. lua_package_path "/usr/local/openresty/lualib/?.lua;;";
复制代码
如许就可以直接自动加载该lua模块。 
之后再common.lua中封装http查询的函数:
  1. local function read_http(path,params)
  2.     local resp = ngx.location.capture(path,{ -- path 是传递的目标路径,表示请求将被发往这个 Nginx 路由
  3.         method = ngx.HTTP_GET, -- 请求方式
  4.         args = params, -- get方式传参数,最终请求的 URL 会是 /path?a=1&b=2
  5.     })
  6.     -- 如果 ngx.location.capture 返回 nil(表示请求失败),则进入错误处理逻辑
  7.     if not resp then
  8.         -- 记录错误信息,返回404
  9.         ngx.log(ngx.ERR,"http not found, path: ",path , ", args: ", args)
  10.         ngx.exit(404)
  11.     end
  12.     return resp.body
  13. end
  14. -- 将方法导出
  15. local _M = {
  16.     read_http = read_http
  17. }
  18. -- 返回模块 _M,使得 read_http 函数可以在其他地方使用。
  19. return _M
复制代码
  总结:

  起首我们利用nginx的API也就是【ngx.location.capture】去发起请求,指定请求路径,请求方式以及请求参数,在这里,我们发起的请求是被nginx捕获,然后需要我们编写一个反向代理也就是【location 地址{}】将发起的请求拦截下来并代理【proxy_pass 地址】到目标服务器(Tomcat)。
  (3)向Tomcat查询数据:

在我们上面对nginx设置时写入了下面代码:
  1. location /api/item/(\d+) {
  2.     # 响应类型,这里返回json
  3.     default_type application/json;
  4.     # 响应数据由 lua/item.lua 这个文件来决定
  5.     content_by_lua_file lua/item.lua;
  6. }
复制代码
在这里请求进入item.lua,随后由这个文件内部执行http查询:
  1. -- 获取路径参数(\d+)
  2. local id = ngx.var[1]
  3. -- 返回结果
  4. ngx.say('{"name":"zhangsan","age":"21"}')
复制代码
起首导入common函数库:
  1. -- 导入common函数库
  2. local common = require('common'); -- 内部是文件名
  3. -- 因为接受的函数返回的是一张表,需要接收内部的read_http
  4. local read_http = common.read_http;
复制代码
然后利用函数read_http去查询商品与库存信息:
  1. -- 查询商品信息:
  2. -- 用..拼接id,随后因为无参传递所以可以直接写nil
  3. local itemJSON = read_http("/item/" ..id, nil);
  4. -- 查询库存信息:
  5. local stockJSON = read_http("/item/stock/" ..id, nil);
复制代码
但是这个是返回的JSON格式,需要我们转换成lua的table,OpenResty提供了一个cjson模块用来处理JSON的序列化和反序列化:
例子:
  1. -- 引入cjson模块
  2. local cjson = require "cjson"
  3. -- 序列化
  4. local obj = {
  5.     name = 'jack',
  6.     age = 21
  7. }
  8. local json = cjson.encode(obj)
  9. -- 反序列化
  10. local json = '{"name": "jack", "age": "21"}'
  11. local obj = cjson.decode(json);
  12. print(obj.name);
复制代码
所以我们可以根据上面实例编写代码,将JSON转化为lua的table:
  1. -- 导入cjson库local cjson = require('cjson')-- 查询商品信息:
  2. -- 用..拼接id,随后因为无参传递所以可以直接写nil
  3. local itemJSON = read_http("/item/" ..id, nil);
  4. -- 查询库存信息:
  5. local stockJSON = read_http("/item/stock/" ..id, nil);
  6. -- 将JSON转化为lua的tablelocal item = cjson.decode(itemJSON);local stock = cjson.decode(stockJSON);-- 组合数据:将库存数据放入商品中并返回item.stock = stock.stock;item.sold = stock.sold;-- 为了返回结果,需要将item序列化为JSONngx.say(cjson.encode(item));
复制代码
完整代码展示:
  1. -- 导入common函数库
  2. local common = require('common'); -- 内部是文件名
  3. -- 因为接受的函数返回的是一张表,需要接收内部的read_http
  4. local read_http = common.read_http;-- 导入cjson库local cjson = require('cjson')-- 获取路径参数(\d+)local id = ngx.var[1]-- 查询商品信息:
  5. -- 用..拼接id,随后因为无参传递所以可以直接写nil
  6. local itemJSON = read_http("/item/" ..id, nil);
  7. -- 查询库存信息:
  8. local stockJSON = read_http("/item/stock/" ..id, nil);
  9. -- 将JSON转化为lua的tablelocal item = cjson.decode(itemJSON);local stock = cjson.decode(stockJSON);-- 组合数据:将库存数据放入商品中并返回item.stock = stock.stock;item.sold = stock.sold;-- 为了返回结果,需要将item序列化为JSONngx.say(cjson.encode(item));
复制代码
随后OpenResty Lua 脚本执行完毕后,终极将响应数据发送给客户端。
团体流程分析:

   在我们这个场景中,客户端发起一个请求到 Nginx,Nginx 转发请求到 OpenResty 的 Lua 脚本,Lua 脚本根据路径参数查询商品和库存信息,最后将合并后的数据以 JSON 格式返回给客户端。
  1. 客户端 → Nginx → OpenResty (Lua 脚本) → Nginx (代理请求) → 后台服务 (Tomcat) → Nginx (响应返回) → OpenResty → 客户端
复制代码
① 起首 客户端发起请求: 客户端向 Nginx 发起一个 HTTP 请求: 
  1. GET /api/item/123
复制代码
② 随后 Nginx 吸收请求并路由到 OpenResty: Nginx 吸收到该请求,并根据设置将其转发给 OpenResty 的 Lua 脚本进行处理:
  1. location /api/item/(\d+) {
  2.     # 响应类型,这里返回json
  3.     default_type application/json;
  4.     # 响应数据由 lua/item.lua 这个文件来决定
  5.     content_by_lua_file lua/item.lua;
  6. }
复制代码
③ 之后 OpenResty 执行 Lua 脚本: 进入 OpenResty 后,item.lua 脚本开始执行。一样平常我们在脚本中写入查询服务器并返回数据给客户端的代码。
在脚本中,通过自封装的 read_http 方法向 Nginx 内部路径发起请求
  1. local itemJSON = read_http("/item/" .. id, nil)
  2. local stockJSON = read_http("/item/stock/" .. id, nil)
复制代码
这些请求会通过 ngx.location.capture 发起,而且是 内部请求,不会直接袒露给客户端。read_http 会发送请求并获取响应。
④ 再然后,Nginx 代理请求到后台服务(如 Tomcat)
  1. location /item/ {
  2.     # 这里是windows电脑的ip和Java的服务接口,需要确保windows防火墙处于关闭状态
  3.     proxy_pass http://192.168.150.1:8081;
  4. }
复制代码
 ⑤ 最后在 item.lua 脚本内, OpenResty 将最闭幕果利用 ngx.say() 返回给客户端: 
  1. ngx.say(cjson.encode(item))
复制代码
5.对Tomcat做负载均衡处理:

在上面我们通过向Tomcat发送请求查询数据并封装返回,但是在实际项目中Tomcat会是一个集群,所以我们在OpenResty给Tomcat发送请求时必须对Tomcat实现负载均衡处理。

而做负载均衡处理时也很简单,只需要在 http 内利用 【upstream 集群名称 {tomcat端标语}】 就可以实现对{}内的每一台Tomcat做轮询的负载均衡:
  1. upstream tomcat-cluster {
  2.     server 192.168.150.1:8081;
  3.     server 192.168.150.1:8081;
  4. }
复制代码
而在前面对于反向代理设置,我们需要在 server 内将 /item 路径的请求代理到 Tomcat 集群:
  1. location /item {
  2.     proxy_pass http://tomcat-cluster;
  3. }
复制代码
但是由于不同进程缓存不共享,所以假如我们第一次访问8081,第二次访问8082后,第二次是无法利用8081的缓存的,所以我们需要保证相同的id永远访问相同的服务器,以此来达到利用进程缓存的目标。
那么接下来我们就需要利用 【hash $request_uri】其中request_uri是请求路径,而hash是对这个路径进行哈希运算得到一个哈希值,随后对Tomcat服务器数量取模,如许只要路径稳固就可以保证路径可以精确访问同一台Tomcat服务器上,随后我们在http内改写设置文件:
  1. upstream tomcat-cluster {
  2.     hash $request_uri;
  3.     server 192.168.150.1:8081;
  4.     server 192.168.150.1:8081;
  5. }
复制代码

利用 Redis 实现多级缓存


在上面我们利用OpenResty与Tomcat来配合实现缓存效果,那么为了低落对Tomcat服务器的压力,我们可以在二者之间加入Redis进行优化。

如上图,我们在请求到达OpenResty后优先查询Redis,假如Redis缓存未掷中在查询Tomcat,那么接下来我们围绕这一目标进行讲解。
1.冷启动与缓存预热:

冷启动是指体系或服务在启动时,由于Redis中并没有缓存,假如所有的商品数据都在第一次查询时添加缓存,肯定会给数据库造成较大的压力。
而为了解决这种现象,我们一样平常会接纳缓存预热来解决标题。
缓存预热是指在体系启动或者服务启动时,提前将需要的热点数据加载到缓存中,以便在后续的请求中能够快速响应。缓存预热是为了避免在缓存为空时发生频繁的缓存穿透或缓存未掷中,从而影响体系性能。
而我们可以在体系或服务启动前,提前将一些热点数据存储到缓存中。
利用下面命令在 Linux 虚拟机中创建Redis(我利用的是Centos7): 
  1. docker run --name redis -p 6379:6379 -d redis redis-server --appendonly yes
复制代码
随后我们进行依赖以及设置Redis地址,之后在 /config/RedisHandler 编写初始化类:
   为了实现体系或服务启动后来预热加载,我们可以利用 InitializingBean ,它是 Spring 的一个接口,其中包含一个方法 afterPropertiesSet(),该方法会在 Spring 完成依赖注入后自动调用,适用于需要在 Bean 初始化完成后执行某些操纵的场景。
  别的还需要用到 Spring 默认的 json 处理器 ObjectMapper ,将类序列化成 json 串。
  1. @Component
  2. public class RedisHandler implements InitializingBean {
  3.     @Autowired
  4.     private StringRedisTemplate stringRedisTemplate;
  5.     @Autowired
  6.     private IItemService itemService;
  7.     @Autowired
  8.     private IItemStockService itemStockService;
  9.     // Spring默认的json处理器
  10.     private static final ObjectMapper MAPPER = new ObjectMapper();
  11.     // 这个方法会在Bean创建完成并且Autowired注入以后执行
  12.     @Override
  13.     public void afterPropertiesSet() throws Exception {
  14.         // 初始化缓存
  15.         // 查询商品信息
  16.         List<Item> itemList = itemService.list();
  17.         // 放入缓存
  18.         for (Item item : itemList) {
  19.             // 将 item 序列化为json
  20.             String json = MAPPER.writeValueAsString(item);
  21.             // 存入redis
  22.             stringRedisTemplate.opsForValue().set("item:id:" + item.getId(), json);
  23.         }
  24.     }
  25. }
复制代码
2.实现OpenResty优先查Redis查不到在查Tomcat:

为了在OpenResty中操纵Redis模块,我们可以利用OpenResty内部的模块 “resty.redis” 。
起首引入Redis模块,并初始化Redis对象:
  1. -- 引入Redis模块
  2. local redis = require("resty.redis")
  3. -- 初始化Redis对象
  4. local red = redis:new()
  5. -- 设置Redis超时时间
  6. -- (建立连接的超时时间,发送请求的超时时间,响应结果的超时时间)
  7. red:set_timeouts(1000,1000,1000)   
复制代码
接下来我们就需要进行数据读写操纵,由于会多次创建释放连接,所以在这里我们会创建连接池,别的将释放Redis连接​​​​​方法封装:
  1. -- 关闭Redis连接的工具方法,其实是放入连接池
  2. local function close_redis(red)
  3.     -- 连接的空闲时间,单位是毫秒
  4.     local pool_max_idle_time = 10000
  5.     -- 连接池大小
  6.     local pool_size = 100
  7.     local ok, err = red:set_keepalive(pool_max_idle_time, pool_size)
  8.     if not ok then
  9.         ngx.log(ngx.ERR, "放入Redis连接池失败: ", err)
  10.     end
  11. end
复制代码
读取Redis数据方法封装:
  1. -- 查询redis的方法 ip和port是redis地址,key是查询的key
  2. local function read_redis(ip, port, key)
  3.     -- 获取一个连接
  4.     local ok, err = red:connect(ip, port)
  5.     if not ok then
  6.         ngx.log(ngx.ERR, "连接redis失败 : ", err)
  7.         return nil
  8.     end
  9.     -- 查询redis
  10.     local resp, err = red:get(key)
  11.     -- 查询失败处理
  12.     if not resp then
  13.         ngx.log(ngx.ERR, "查询Redis失败: ", err, ", key = " , key)
  14.     end
  15.     --得到的数据为空处理
  16.     if resp == ngx.null then
  17.         resp = nil
  18.         ngx.log(ngx.ERR, "查询Redis数据为空, key = ", key)
  19.     end
  20.     close_redis(red)
  21.     return resp
  22. end
复制代码
之后我们将上面三段代码合并:
  1. -- 省略上面对 read_http 的代码------------------------------------- 下面是 read_redis 代码-- 引入Redis模块
  2. local redis = require("resty.redis")
  3. -- 初始化Redis对象
  4. local red = redis:new()
  5. -- 设置Redis超时时间
  6. -- (建立连接的超时时间,发送请求的超时时间,响应结果的超时时间)
  7. red:set_timeouts(1000,1000,1000)    -- 关闭Redis连接的工具方法,其实是放入连接池
  8. local function close_redis(red)
  9.     -- 连接的空闲时间,单位是毫秒
  10.     local pool_max_idle_time = 10000
  11.     -- 连接池大小
  12.     local pool_size = 100
  13.     local ok, err = red:set_keepalive(pool_max_idle_time, pool_size)
  14.     if not ok then
  15.         ngx.log(ngx.ERR, "放入Redis连接池失败: ", err)
  16.     end
  17. end-- 查询redis的方法 ip和port是redis地址,key是查询的keylocal 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    -- 关闭Redis连接    close_redis(red)    return respend-- 将方法导出local _M = {    read_http = read_http,    read_redis = read_redis}return _M
复制代码
那么接下来我们就在item.lua去利用上面封装的方法:
我们直接将查询过程(优先查Redis,查不到查Tomcat)封装一个函数:
  1. -- 封装查询函数
  2. function read_data(key,path,params)
  3.     -- 查询Redis
  4.     local resp = read_redis("127.0.0.1", 6379, key)
  5.     -- 判断查询结果
  6.     if not resp then
  7.         ngx.log(ngx.ERR, "redis查询失败,尝试查询http,key: ",key)
  8.         -- redis查询失败,去查询http
  9.         resp = read_http(path,params)
  10.     end
  11.     return resp
  12. end
复制代码
随后在下面直接调用这个函数即可得到响应数据: 
  1. -- 导入common函数库
  2. local common = require('common'); -- 内部是文件名
  3. -- 因为接受的函数返回的是一张表,需要接收内部的read_http
  4. local read_http = common.read_http;local read_redis = common.read_redis-- 导入cjson库local cjson = require('cjson')-- 封装查询函数
  5. function read_data(key,path,params)
  6.     -- 查询Redis
  7.     local resp = read_redis("127.0.0.1", 6379, key)
  8.     -- 判断查询结果
  9.     if not resp then
  10.         ngx.log(ngx.ERR, "redis查询失败,尝试查询http,key: ",key)
  11.         -- redis查询失败,去查询http
  12.         resp = read_http(path,params)
  13.     end
  14.     return resp
  15. end-- 获取路径参数(\d+)local id = ngx.var[1]-- 查询商品信息:-- 用..拼接id,随后因为无参通报所以可以直接写nillocal itemJSON = read_data("item:id:" .. id, "/item/" .. id, nil);-- 查询库存信息:local stockJSON = read_data("item:stock:id:" .. id, "/item/stock/" .. id, nil);-- 将JSON转化为lua的tablelocal item = cjson.decode(itemJSON);local stock = cjson.decode(stockJSON);-- 组合数据:将库存数据放入商品中并返回item.stock = stock.stock;item.sold = stock.sold;-- 为了返回结果,需要将item序列化为JSONngx.say(cjson.encode(item));
复制代码
3.怎样在nginx添加本地缓存?

我们还想要在请求到OpenResty后优先查找nginx本地缓存,未掷中在查Redis,在未掷中在查Tomcat。
OpenResty为 Nginx 提供了 shared dict 的功能,可以在nginx的多个worker之间共享数据(在nginx中有一个master进程和多个worker进程),实现缓存功能,通常是通过 lua_shared_dict 和 OpenResty 提供的 lua 模块来实现的。   
起首在http内利用【lua_shared_dict】开启共享辞书(本地缓存):
  1. # 共享字典,也就是本地缓存,名称叫做:item_cache,大小150m
  2. lua_shared_dict item_cache 150m; 
复制代码
而操纵共享辞书时,我们先获取本地缓存对象,然后存储后读取:
  1. -- 获取本地缓存对象
  2. local cache = ngx.shared.my_cache  
  3. -- 存储,指定key、value、过期时间(单位s,默认为0代表永不过期)
  4. cache:set("key", "value", 60)  
  5. -- 从缓存中读取数据
  6. local value = cache:get("key")
  7. -- 删除指定键的缓存
  8. cache:delete("key")
  9. -- 清空所有缓存
  10. cache:flush_all()
复制代码
那么接下来我们开启共享辞书后再item.lua内加入查询nginx本地缓存逻辑:
在这里我们需要优先查找nginx本地缓存,未掷中在查Redis,在未掷中在查Tomcat。
留意:我们需要加入过期时间(单元为秒)
  1. -- 获取本地缓存对象
  2. local item_cache = ngx.shared.my_cache  
  3. -- 封装查询函数
  4. function read_data(key, expireTime, path ,params)
  5.     -- 查询本地缓存
  6.     local val = item_cache:get(key)
  7.     if not val then
  8.         ngx.log(ngx.ERR, "本地缓存查询失败,尝试查询Redis,key: ", key)
  9.         -- 查询Redis
  10.         val = read_redis("127.0.0.1", 6379, key)
  11.         -- 判断查询结果
  12.         if not val then
  13.             ngx.log(ngx.ERR, "redis查询失败,尝试查询http,key: ", key)
  14.             -- redis查询失败,去查询http
  15.             val = read_http(path, params)
  16.         end
  17.     end
  18.     -- 查询成功后,先把数据写入本地缓存然后再返回数据
  19.     item_cache:set(key, val, expireTime)
  20.     return val
  21. end
复制代码
随后完整代码如下: 
  1. -- 导入common函数库
  2. local common = require('common'); -- 内部是文件名
  3. -- 因为接受的函数返回的是一张表,需要接收内部的read_http
  4. local read_http = common.read_http;local read_redis = common.read_redis-- 导入cjson库local cjson = require('cjson')-- 获取本地缓存对象
  5. local item_cache = ngx.shared.my_cache  
  6. -- 封装查询函数
  7. function read_data(key, expireTime, path ,params)
  8.     -- 查询本地缓存
  9.     local val = item_cache:get(key)
  10.     if not val then
  11.         ngx.log(ngx.ERR, "本地缓存查询失败,尝试查询Redis,key: ", key)
  12.         -- 查询Redis
  13.         val = read_redis("127.0.0.1", 6379, key)
  14.         -- 判断查询结果
  15.         if not val then
  16.             ngx.log(ngx.ERR, "redis查询失败,尝试查询http,key: ", key)
  17.             -- redis查询失败,去查询http
  18.             val = read_http(path, params)
  19.         end
  20.     end
  21.     -- 查询成功后,先把数据写入本地缓存然后再返回数据
  22.     item_cache:set(key, val, expireTime)
  23.     return val
  24. end-- 获取路径参数(\d+)local id = ngx.var[1]-- 查询商品信息:-- 用..拼接id,随后因为无参通报所以可以直接写nillocal itemJSON = read_data("item:id:" .. id, 1800, "/item/" .. id, nil);-- 查询库存信息:local stockJSON = read_data("item:stock:id:" .. id, 60, "/item/stock/" .. id, nil);-- 将JSON转化为lua的tablelocal item = cjson.decode(itemJSON);local stock = cjson.decode(stockJSON);-- 组合数据:将库存数据放入商品中并返回item.stock = stock.stock;item.sold = stock.sold;-- 为了返回结果,需要将item序列化为JSONngx.say(cjson.encode(item));
复制代码
利用下面代码可以查察报错日志
  1. tail -f logs/error.log
复制代码

缓存同步策略


缓存同步(Cache Synchronization)是指在分布式体系中,多个缓存副本的数据一致性标题。当多个缓存存储相同的数据时,需要保证这些缓存副本中的数据保持同步,以避免出现脏数据(比方,一处更新了数据,但其他缓存副本还没及时更新)。 

在大多数缓存,我们都可以接纳异步通知的方式来实现:
第一种就是基于MQ的异步通知:

而第二种就是基于Canal的异步通知:

Canal 是阿里巴巴开源的一个增量订阅&消费的组件,主要用于将数据库的变更实时地同步到其他体系,如缓存或消息队列中。Canal 支持 MySQL、Oracle 等数据库的 binlog(变乱日志)增量订阅,可以将数据库中的变更(如更新、插入、删除等)捕捉并实时推送到卑鄙体系。
1.Canal工作原理: 

Canal 是基于Mysql的主从同步来实现的:MySQL 的主从同步(Master-Slave Replication)是指在一个 MySQL 数据库集群中,将主库(Master)上的数据实时地复制到从库(Slave)中,以便从库能用来读取数据,提高体系的读写性能。主从同步在高可用性和负载均衡中具有广泛应用。
   MySQL主从同步原理:
  MySQL Master 将数据变更写入二进制日志(Binary Log) ,其中记录的数据叫 binary log events,而 MySQL Slave 会开启一个线程将 MySQL Master 的 binary log events 拷贝到它的中继日志(relay log),随后 MySQL Slave 重放 relay log 中变乱,将数据变更反映它本身的数据。
  

 而Canal将本身伪装成MySQL的一个 Slave 节点,从而监听 Master 的 Binary Log 变化,随后再把得到的变化通知给Canal的客户端,以此来完成对其他数据库的同步。

2.安装和设置Canal:

由于Canal是基于MySQL的主从同步功能,因此必须先开启MySQL的主从功能才可以,所以需要我们去修改MySQL的设置文件:
起首进入 /mysql/conf/my.cnf:
  1. vi /tmp/mysql/conf/my.cnf
复制代码
随后添加下面内容:
  1. log-bin=/var/lib/mysql/mysql-bin
  2. binlog-do-db=Eleven
复制代码
  

  • log-bin=/var/lib/mysql/mysql-bin:设置binary log文件的存放地址和文件名,叫做mysql-bin
  • binlog-do-db=Eleven:指定对哪个database记录binary log events,这里记录Eleven这个库
  终极效果:
  1. [mysqld]
  2. skip-name-resolve
  3. character_set_server=utf8
  4. datadir=/var/lib/mysql
  5. server-id=1000log-bin=/var/lib/mysql/mysql-binbinlog-do-db=heima
复制代码
接下来添加一个仅用于数据同步的账户,出于安全思量,这里仅提供对Eleven这个库的操纵权限:
  1. CREATE USER canal@'%' IDENTIFIED BY 'canal';
  2. GRANT SELECT, REPLICATION SLAVE, REPLICATION CLIENT, SUPER ON *.* TO 'canal'@'%' IDENTIFIED BY 'canal';
  3. FLUSH PRIVILEGES;
复制代码
重启mysql容器即可:
  1. docker restart mysql
复制代码
测试设置是否乐成:在mysql控制台,或者Navicat中,输入命令:
  1. show master status;
复制代码
在然后我们需要创建一个网络,将MySQL、Canal、MQ放到同一个Docker网络中:
  1. docker network create eleven
复制代码
让mysql加入这个网络:
  1. docker network connect eleven mysql
复制代码
随后我们将Canal镜像压缩包上传到虚拟机中,随后通过命令导入:
  1. docker load -i canal.tar
复制代码
 然后运行命令创建Canal容器:
  1. docker run -p 11111:11111 --name canal \
  2. -e canal.destinations=eleven \
  3. -e canal.instance.master.address=mysql:3306  \
  4. -e canal.instance.dbUsername=canal  \
  5. -e canal.instance.dbPassword=canal  \
  6. -e canal.instance.connectionCharset=UTF-8 \
  7. -e canal.instance.tsdb.enable=true \
  8. -e canal.instance.gtidon=false  \
  9. -e canal.instance.filter.regex=eleven\\..* \
  10. --network eleven \
  11. -d canal/canal-server:v1.1.5
复制代码
随后测试Canal是否于MySQL连接乐成:
  1. docker -exec -it canal bash
  2. tail -f canal-server/logs/canal/canal/canal.log
  3. tail -f canal-server/logs/eleven/eleven.log
复制代码
3.Canal的客户端:


由于官方提供的API比较麻烦,所以我们利用GitHub上开源的canal-starter。
起首引入依赖:
  1. <dependency>
  2.     <groupId>top.javatool</groupId>
  3.     <artifactId>canal-spring-boot-starter</artifactId>
  4.     <version>1.2.1-RELEASE</version>
  5. </dependency>
复制代码
随后导入设置:
  1. canal:
  2.   destination: eleven            # Canal 实例名,确保与 Canal 配置一致
  3.   server: 192.168.150.101:11111  # Canal 服务器地址
复制代码
随后我们需要去监听Canal:
起首需要定义ItemHandler类,而且该类需要去实现 EntryHandler<Item> 接口以此对 Item 范例的数据变更进行处理(指定表关联的实体类),而且还需要对类加入注解@CanalTable("表名")以此来表明这个类用于监听数据库表 tb_item 的数据变动,随后Canal 会将数据库表变更的数据推送到这个处理类的相应方法(insert、update、delete)。
那怎样将表中数据变成item实体类呢?所以我们需要先再item实体类添加Canal内部的注解去标记录体类与表中的映射关系:
  1. @Data
  2. @TableName("tb_item")
  3. public class Item {
  4.     @TableId(type = IdType.AUTO)
  5.     @Id
  6.     private Long id;//商品id
  7.     @Column(name = "name")
  8.     private String name;//商品名称
  9.     private String title;//商品标题
  10.     private Long price;//价格(分)
  11.     private String image;//商品图片
  12.     private String category;//分类名称
  13.     private String brand;//品牌名称
  14.     private String spec;//规格
  15.     private Integer status;//商品状态 1-正常,2-下架
  16.     private Date createTime;//创建时间
  17.     private Date updateTime;//更新时间
  18.     @TableField(exist = false)
  19.     @Transient
  20.     private Integer stock;
  21.     @TableField(exist = false)
  22.     @Transient
  23.     private Integer sold;
  24. }
复制代码

 @TableName("tb_item"):告诉 MyBatis-Plus 该实体类 Item 对应的是数据库中的 tb_item 表。 
@TableId(type = IdType.AUTO):标明该字段是数据库表中的主键,并指定主键的生成策略。type = IdType.AUTO 体现主键由数据库自动生成(比方自增)。
@Id:用于标记该字段为主键。在代码中与 @TableId 配合利用,主要是为了支持一些需要与 JPA 相关的功能,比方通过 Spring Data 来进行数据库操纵时,能够精确识别主键字段。
@Column(name = "name"):JPA 的注解,指定该字段对应的数据库表列名。假如字段名与数据库列名不同,可以通过这个注解来映射。
@TableField(exist = false):告诉 MyBatis-Plus 该字段不需要与数据库表的列进行映射。
@Transient:JPA 的注解,标记字段为暂时的,不会映射到数据库表中。

 下面就是ItemHandler的代码:
  1. @CanalTable("tb_item")
  2. @Component
  3. public class ItemHandler implements EntryHandler<Item> {
  4.     @Autowired
  5.     private RedisHandler redisHandler;
  6.     @Autowired
  7.     private Cache<Long, Item> itemCache;
  8.    
  9.     @Override
  10.     public void insert(Item item) {
  11.         // 写数据到JVM进程缓存
  12.         itemCache.put(item.getId(), item);
  13.         // 写数据到redis
  14.         redisHandler.saveItem(item);
  15.     }
  16.    
  17.     @Override
  18.     public void update(Item before, Item after) {
  19.         // 写数据到JVM进程缓存
  20.         itemCache.put(after.getId(), after);
  21.         // 写数据到redis
  22.         redisHandler.saveItem(after);
  23.     }
  24.    
  25.     @Override
  26.     public void delete(Item item) {
  27.         // 删除数据到JVM进程缓存
  28.         itemCache.invalidate(item.getId());
  29.         // 删除数据到redis
  30.         redisHandler.deleteItemById(item.getId());
  31.     }
  32. }
复制代码
随后写出RedisHandler的代码操纵Redis缓存:
  1. @Component
  2. public class RedisHandler implements InitializingBean {
  3.     @Autowired
  4.     private StringRedisTemplate redisTemplate;
  5.    
  6.     @Autowired
  7.     private IItemService itemService;
  8.     @Autowired
  9.     private IItemStockService stockService;
  10.    
  11.     private static final ObjectMapper MAPPER = new ObjectMapper();
  12.    
  13.     @Override
  14.     public void afterPropertiesSet() throws Exception {
  15.         // 初始化缓存
  16.         // 1.查询商品信息
  17.         List<Item> itemList = itemService.list();
  18.         // 2.放入缓存
  19.         for (Item item : itemList) {
  20.             // 2.1.item序列化为JSON
  21.             String json = MAPPER.writeValueAsString(item);
  22.             // 2.2.存入redis
  23.             redisTemplate.opsForValue().set("item:id:" + item.getId(), json);
  24.         }
  25.    
  26.         // 3.查询商品库存信息
  27.         List<ItemStock> stockList = stockService.list();
  28.         // 4.放入缓存
  29.         for (ItemStock stock : stockList) {
  30.             // 2.1.item序列化为JSON
  31.             String json = MAPPER.writeValueAsString(stock);
  32.             // 2.2.存入redis
  33.             redisTemplate.opsForValue().set("item:stock:id:" + stock.getId(), json);
  34.         }
  35.     }
  36.    
  37.     public void saveItem(Item item) {
  38.         try {
  39.             String json = MAPPER.writeValueAsString(item);
  40.             redisTemplate.opsForValue().set("item:id:" + item.getId(), json);
  41.         } catch (JsonProcessingException e) {
  42.             throw new RuntimeException(e);
  43.         }
  44.     }
  45.    
  46.     public void deleteItemById(Long id) {
  47.         redisTemplate.delete("item:id:" + id);
  48.     }
  49. }
复制代码
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。
回复

使用道具 举报

0 个回复

正序浏览

快速回复

您需要登录后才可以回帖 登录 or 立即注册

本版积分规则

羊蹓狼

金牌会员
这个人很懒什么都没写!

标签云

快速回复 返回顶部 返回列表