龍華建設網(wǎng)站企業(yè)郵箱查詢
一、多級緩存
傳統(tǒng)的緩存策略一般是請求到達Tomcat后,先查詢Redis,如果未命中則查詢數(shù)據(jù)庫,如圖:
存在下面的問題:
?請求要經(jīng)過Tomcat處理,Tomcat的性能成為整個系統(tǒng)的瓶頸
?Redis緩存失效時,會對數(shù)據(jù)庫產(chǎn)生沖擊
多級緩存就是充分利用請求處理的每個環(huán)節(jié),分別添加緩存,減輕Tomcat壓力,提升服務性能:
- 瀏覽器訪問靜態(tài)資源時,優(yōu)先讀取瀏覽器本地緩存
- 訪問非靜態(tài)資源(ajax查詢數(shù)據(jù))時,訪問服務端
- 請求到達Nginx后,優(yōu)先讀取Nginx本地緩存
- 如果Nginx本地緩存未命中,則去直接查詢Redis(不經(jīng)過Tomcat)
- 如果Redis查詢未命中,則查詢Tomcat
- 請求進入Tomcat后,優(yōu)先查詢JVM進程緩存
- 如果JVM進程緩存未命中,則查詢數(shù)據(jù)庫
在多級緩存架構中,Nginx內(nèi)部需要編寫本地緩存查詢、Redis查詢、Tomcat查詢的業(yè)務邏輯,因此這樣的nginx服務不再是一個反向代理服務器,而是一個編寫業(yè)務的Web服務器了。
因此這樣的業(yè)務Nginx服務也需要搭建集群來提高并發(fā),再有專門的nginx服務來做反向代理,如圖:
另外,我們的Tomcat服務將來也會部署為集群模式:
可見,多級緩存的關鍵有兩個:
- 一個是在nginx中編寫業(yè)務,實現(xiàn)nginx本地緩存、Redis、Tomcat的查詢
- 另一個就是在Tomcat中實現(xiàn)JVM進程緩存
其中Nginx編程則會用到OpenResty框架結合Lua這樣的語言。
二、JVM進程緩存
為了演示多級緩存的案例,我們先準備一個商品查詢的業(yè)務。
2.1 導入案例
為了演示多級緩存,我們先導入一個商品管理的案例,其中包含商品的CRUD功能。
我們將來會給查詢商品添加多級緩存。
2.1.1 安裝MySQL
1.準備目錄
為了方便后期配置MySQL,我們先準備兩個目錄,用于掛載容器的數(shù)據(jù)和配置文件目錄:
# 進入/tmp目錄
cd /tmp
# 創(chuàng)建文件夾
mkdir mysql
# 進入mysql目錄
cd mysql
2.運行命令
進入mysql目錄后,執(zhí)行下面的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
3.修改配置
在/tmp/mysql/conf目錄添加一個my.cnf文件,作為mysql的配置文件:
# 創(chuàng)建文件
touch /tmp/mysql/conf/my.cnf
文件的內(nèi)容如下:
[mysqld]
skip-name-resolve
character_set_server=utf8
datadir=/var/lib/mysql
server-id=1000
4.重啟
配置修改后,必須重啟容器:
docker restart mysql
2.1.2 導入SQL
接下來,利用Navicat客戶端連接MySQL,然后資料提供的sql文件:item.sql
其中包含兩張表:
- tb_item:商品表,包含商品的基本信息
- tb_item_stock:商品庫存表,包含商品的庫存信息
之所以將庫存分離出來,是因為庫存是更新比較頻繁的信息,寫操作較多。而其他信息修改的頻率非常低。
2.1.3導入Demo工程
導入工程:https://github.com/user0819/item_service.git
項目結構如圖所示:
其中的業(yè)務包括:
- 分頁查詢商品
- 新增商品
- 修改商品
- 修改庫存
- 刪除商品
- 根據(jù)id查詢商品
- 根據(jù)id查詢庫存
業(yè)務全部使用mybatis-plus來實現(xiàn),如有需要請自行修改業(yè)務邏輯。
啟動:注意修改application.yml文件中配置的mysql地址信息:
需要修改為自己的虛擬機地址信息、還有賬號和密碼。
修改后,啟動服務,訪問:http://localhost:8081/item/10001 即可查詢數(shù)據(jù)
2.1.4 導入商品查詢頁面
商品查詢是購物頁面,與商品管理的頁面是分離的。
部署方式如圖:
我們需要準備一個反向代理的nginx服務器,如上圖紅框所示,將靜態(tài)的商品頁面放到nginx目錄中。
頁面需要的數(shù)據(jù)通過ajax向服務端(nginx業(yè)務集群)查詢。
2.1.4.1 運行nginx服務
找到課前資料的nginx目錄:nginx-1.8.0
將其拷貝到一個非中文目錄下,運行這個nginx服務。
運行命令:start nginx.exe
然后訪問 http://localhost/item.html?id=10001即可:
2.1.4.2 反向代理
現(xiàn)在,頁面是假數(shù)據(jù)展示的。我們需要向服務器發(fā)送ajax請求,查詢商品數(shù)據(jù)。
打開控制臺,可以看到頁面有發(fā)起ajax查詢數(shù)據(jù):
而這個請求地址同樣是80端口,所以被當前的nginx反向代理了。
查看nginx的conf目錄下的nginx.conf文件:
其中的關鍵配置如下:
其中的192.168.150.101是虛擬機IP,也就的Nginx業(yè)務集群要部署的地方:
完整內(nèi)容如下:
#user nobody;
worker_processes 1;events {worker_connections 1024;
}http {include mime.types;default_type application/octet-stream;sendfile on;#tcp_nopush on;keepalive_timeout 65;upstream nginx-cluster{server 192.168.150.101:8081;}server {listen 80;server_name localhost;location /api {proxy_pass http://nginx-cluster;}location / {root html;index index.html index.htm;}error_page 500 502 503 504 /50x.html;location = /50x.html {root html;}}
}
2.2 初識Caffeine
緩存在日常開發(fā)中啟動至關重要的作用,由于是存儲在內(nèi)存中,數(shù)據(jù)的讀取速度是非常快的,能大量減少對數(shù)據(jù)庫的訪問,減少數(shù)據(jù)庫的壓力。我們把緩存分為兩類:
- 分布式緩存,例如Redis:
- 優(yōu)點:存儲容量更大、可靠性更好、可以在集群間共享
- 缺點:訪問緩存有網(wǎng)絡開銷
- 場景:緩存數(shù)據(jù)量較大、可靠性要求較高、需要在集群間共享
- 進程本地緩存,例如HashMap、GuavaCache:
- 優(yōu)點:讀取本地內(nèi)存,沒有網(wǎng)絡開銷,速度更快
- 缺點:存儲容量有限、可靠性較低、無法共享
- 場景:性能要求較高,緩存數(shù)據(jù)量較小
我們今天會利用Caffeine框架來實現(xiàn)JVM進程緩存。
Caffeine是一個基于Java8開發(fā)的,提供了近乎最佳命中率的高性能的本地緩存庫。
目前Spring內(nèi)部的緩存使用的就是Caffeine。GitHub地址:https://github.com/ben-manes/caffeine
Caffeine的性能非常好,下圖是官方給出的性能對比:
可以看到Caffeine的性能遙遙領先!
緩存使用的基本API:
@Test
void testBasicOps() {// 構建cache對象Cache<String, String> cache = Caffeine.newBuilder().build();// 存數(shù)據(jù)cache.put("gf", "迪麗熱巴");// 取數(shù)據(jù)String gf = cache.getIfPresent("gf");System.out.println("gf = " + gf);// 取數(shù)據(jù),包含兩個參數(shù):// 參數(shù)一:緩存的key// 參數(shù)二:Lambda表達式,表達式參數(shù)就是緩存的key,方法體是查詢數(shù)據(jù)庫的邏輯// 優(yōu)先根據(jù)key查詢JVM緩存,如果未命中,則執(zhí)行參數(shù)二的Lambda表達式String defaultGF = cache.get("defaultGF", key -> {// 根據(jù)key去數(shù)據(jù)庫查詢數(shù)據(jù)return "柳巖";});System.out.println("defaultGF = " + defaultGF);
}
Caffeine既然是緩存的一種,肯定需要有緩存的清除策略,不然的話內(nèi)存總會有耗盡的時候。
Caffeine提供了三種緩存驅逐策略:
- 基于容量:設置緩存的數(shù)量上限
// 創(chuàng)建緩存對象 Cache<String, String> cache = Caffeine.newBuilder() .maximumSize(1) // 設置緩存大小上限為 1 .build();
- 基于時間:設置緩存的有效時間
// 創(chuàng)建緩存對象 Cache<String, String> cache = Caffeine.newBuilder() // 設置緩存有效期為 10 秒,從最后一次寫入開始計時 .expireAfterWrite(Duration.ofSeconds(10)) .build();
- 基于引用:設置緩存為軟引用或弱引用,利用GC來回收緩存數(shù)據(jù)。性能較差,不建議使用。
注意:在默認情況下,當一個緩存元素過期的時候,Caffeine不會自動立即將其清理和驅逐。而是在一次讀或寫操作后,或者在空閑時間完成對失效數(shù)據(jù)的驅逐。
2.3 實現(xiàn)JVM進程緩存
2.3.1 需求
利用Caffeine實現(xiàn)下列需求:
- 給根據(jù)id查詢商品的業(yè)務添加緩存,緩存未命中時查詢數(shù)據(jù)庫
- 給根據(jù)id查詢商品庫存的業(yè)務添加緩存,緩存未命中時查詢數(shù)據(jù)庫
- 緩存初始大小為100
- 緩存上限為10000
2.3.2 實現(xiàn)
首先,我們需要定義兩個Caffeine的緩存對象,分別保存商品、庫存的緩存數(shù)據(jù)。
在item-service的com.item.config包下定義CaffeineConfig類:
package com.heima.item.config;import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.heima.item.pojo.Item;
import com.heima.item.pojo.ItemStock;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;@Configuration
public class CaffeineConfig {@Beanpublic Cache<Long, Item> itemCache(){return Caffeine.newBuilder().initialCapacity(100).maximumSize(10_000).build();}@Beanpublic Cache<Long, ItemStock> stockCache(){return Caffeine.newBuilder().initialCapacity(100).maximumSize(10_000).build();}
}
然后,修改item-service中的com.item.web包下的ItemController類,添加緩存邏輯:
@RestController
@RequestMapping("item")
public class ItemController {@Autowiredprivate IItemService itemService;@Autowiredprivate IItemStockService stockService;@Autowiredprivate Cache<Long, Item> itemCache;@Autowiredprivate Cache<Long, ItemStock> stockCache;// ...其它略@GetMapping("/{id}")public Item findById(@PathVariable("id") Long id) {return itemCache.get(id, key -> itemService.query().ne("status", 3).eq("id", key).one());}@GetMapping("/stock/{id}")public ItemStock findStockById(@PathVariable("id") Long id) {return stockCache.get(id, key -> stockService.getById(key));}
}
三、Lua語法入門
Nginx編程需要用到Lua語言,因此我們必須先入門Lua的基本語法。
3.1 初識Lua
Lua 是一種輕量小巧的腳本語言,用標準C語言編寫并以源代碼形式開放, 其設計目的是為了嵌入應用程序中,從而為應用程序提供靈活的擴展和定制功能。官網(wǎng):The Programming Language Lua
Lua經(jīng)常嵌入到C語言開發(fā)的程序中,例如游戲開發(fā)、游戲插件等。
Nginx本身也是C語言開發(fā),因此也允許基于Lua做拓展。
3.1.1 HelloWorld
CentOS7默認已經(jīng)安裝了Lua語言環(huán)境,所以可以直接運行Lua代碼。
1)在Linux虛擬機的任意目錄下,新建一個hello.lua文件
2)添加下面的內(nèi)容
print("Hello World!")
3)運行
3.2 變量和循環(huán)
學習任何語言必然離不開變量,而變量的聲明必須先知道數(shù)據(jù)的類型。
3.2.1.Lua的數(shù)據(jù)類型
Lua中支持的常見數(shù)據(jù)類型包括:
另外,Lua提供了type()函數(shù)來判斷一個變量的數(shù)據(jù)類型:
3.2.2 聲明變量
Lua聲明變量的時候無需指定數(shù)據(jù)類型,而是用local來聲明變量為局部變量:
-- 聲明字符串,可以用單引號或雙引號,
local str = 'hello'
-- 字符串拼接可以使用 ..
local str2 = 'hello' .. 'world'
-- 聲明數(shù)字
local num = 21
-- 聲明布爾類型
local flag = true
Lua中的table類型既可以作為數(shù)組,又可以作為Java中的map來使用。數(shù)組就是特殊的table,key是數(shù)組角標而已:
-- 聲明數(shù)組 ,key為角標的 table
local arr = {'java', 'python', 'lua'}
-- 聲明table,類似java的map
local map = {name='Jack', age=21}
Lua中的數(shù)組角標是從1開始,訪問的時候與Java中類似:
-- 訪問數(shù)組,lua數(shù)組的角標從1開始
print(arr[1])
Lua中的table可以用key來訪問:
-- 訪問table
print(map['name'])
print(map.name)
3.2.3 循環(huán)
對于table,我們可以利用for循環(huán)來遍歷。不過數(shù)組和普通table遍歷略有差異。
遍歷數(shù)組:
-- 聲明數(shù)組 key為索引的 table
local arr = {'java', 'python', 'lua'}
-- 遍歷數(shù)組
for index,value in ipairs(arr) doprint(index, value)
end
遍歷普通table
-- 聲明map,也就是table
local map = {name='Jack', age=21}
-- 遍歷table
for key,value in pairs(map) doprint(key, value)
end
3.3 條件控制、函數(shù)
Lua中的條件控制和函數(shù)聲明與Java類似。
3.3.1 函數(shù)
定義函數(shù)的語法:
function 函數(shù)名( argument1, argument2..., argumentn)-- 函數(shù)體return 返回值
end
例如,定義一個函數(shù),用來打印數(shù)組:
function printArr(arr)for index, value in ipairs(arr) doprint(value)end
end
3.3.2 條件控制
類似Java的條件控制,例如if、else語法:
if(布爾表達式)
then--[ 布爾表達式為 true 時執(zhí)行該語句塊 --]
else--[ 布爾表達式為 false 時執(zhí)行該語句塊 --]
end
與java不同,布爾表達式中的邏輯運算是基于英文單詞:
3.3.3 案例
需求:自定義一個函數(shù),可以打印table,當參數(shù)為nil時,打印錯誤信息
function printArr(arr)if not arr thenprint('數(shù)組不能為空!')endfor index, value in ipairs(arr) doprint(value)end
end
四、實現(xiàn)多級緩存
多級緩存的實現(xiàn)離不開Nginx編程,而Nginx編程又離不開OpenResty。
4.1 安裝OpenResty
OpenResty? 是一個基于 Nginx的高性能 Web 平臺,用于方便地搭建能夠處理超高并發(fā)、擴展性極高的動態(tài) Web 應用、Web 服務和動態(tài)網(wǎng)關。
具備下列特點:
- 具備Nginx的完整功能
- 基于Lua語言進行擴展,集成了大量精良的 Lua 庫、第三方模塊
- 允許使用Lua自定義業(yè)務邏輯自定義庫
官方網(wǎng)站: OpenResty? - 開源官方站
4.1.1 安裝
1)安裝開發(fā)庫
首先要安裝OpenResty的依賴開發(fā)庫,執(zhí)行命令:
yum install -y pcre-devel openssl-devel gcc --skip-broken
2)安裝OpenResty倉庫
你可以在你的 CentOS 系統(tǒng)中添加 openresty 倉庫,這樣就可以便于未來安裝或更新我們的軟件包(通過 yum check-update 命令)。運行下面的命令就可以添加我們的倉庫:
yum-config-manager --add-repo https://openresty.org/package/centos/openresty.repo
如果提示說命令不存在,則運行:
yum install -y yum-utils
然后再重復上面的命令
3)安裝OpenResty
然后就可以像下面這樣安裝軟件包,比如 openresty:
yum install -y openresty
4)安裝opm工具
opm是OpenResty的一個管理工具,可以幫助我們安裝一個第三方的Lua模塊。
如果你想安裝命令行工具 opm,那么可以像下面這樣安裝 openresty-opm 包:
yum install -y openresty-opm
5)目錄結構
默認情況下,OpenResty安裝的目錄是:/usr/local/openresty
看到里面的nginx目錄了嗎,OpenResty就是在Nginx基礎上集成了一些Lua模塊。
6)配置nginx的環(huán)境變量
打開配置文件:
vi /etc/profile
在最下面加入兩行:
export NGINX_HOME=/usr/local/openresty/nginx
export PATH=${NGINX_HOME}/sbin:$PATH
NGINX_HOME:后面是OpenResty安裝目錄下的nginx的目錄
然后讓配置生效:
source /etc/profile
4.1.2 啟動和運行
OpenResty底層是基于Nginx的,查看OpenResty目錄的nginx目錄,結構與windows中安裝的nginx基本一致:
所以運行方式與nginx基本一致:
# 啟動nginx
nginx
# 重新加載配置
nginx -s reload
# 停止
nginx -s stop
nginx的默認配置文件注釋太多,影響后續(xù)我們的編輯,這里將nginx.conf中的注釋部分刪除,保留有效部分。
修改/usr/local/openresty/nginx/conf/nginx.conf文件,內(nèi)容如下:
#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;}}
}
在Linux的控制臺輸入命令以啟動nginx:nginx
然后訪問頁面:http://192.168.150.101:8081,注意ip地址替換為你自己的虛擬機IP:
4.1.3 備注
加載OpenResty的lua模塊:
#lua 模塊
lua_package_path "/usr/local/openresty/lualib/?.lua;;";
#c模塊
lua_package_cpath "/usr/local/openresty/lualib/?.so;;";
common.lua
-- 封裝函數(shù),發(fā)送http請求,并解析響應
local function read_http(path, params)local resp = ngx.location.capture(path,{method = ngx.HTTP_GET,args = params,})if not resp then-- 記錄錯誤信息,返回404ngx.log(ngx.ERR, "http not found, path: ", path , ", args: ", args)ngx.exit(404)endreturn resp.body
end
-- 將方法導出
local _M = { read_http = read_http
}
return _M
釋放Redis連接API:
-- 關閉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 thenngx.log(ngx.ERR, "放入redis連接池失敗: ", err)end
end
讀取Redis數(shù)據(jù)的API:
-- 查詢redis的方法 ip和port是redis地址,key是查詢的key
local function read_redis(ip, port, key)-- 獲取一個連接local ok, err = red:connect(ip, port)if not ok thenngx.log(ngx.ERR, "連接redis失敗 : ", err)return nilend-- 查詢redislocal resp, err = red:get(key)-- 查詢失敗處理if not resp thenngx.log(ngx.ERR, "查詢Redis失敗: ", err, ", key = " , key)end--得到的數(shù)據(jù)為空處理if resp == ngx.null thenresp = nilngx.log(ngx.ERR, "查詢Redis數(shù)據(jù)為空, key = ", key)endclose_redis(red)return resp
end
開啟共享詞典:
# 共享字典,也就是本地緩存,名稱叫做:item_cache,大小150m
lua_shared_dict item_cache 150m;
4.2 OpenResty快速入門
我們希望達到的多級緩存架構如圖:
其中:
- windows上的nginx用來做反向代理服務,將前端的查詢商品的ajax請求代理到OpenResty集群
- OpenResty集群用來編寫多級緩存業(yè)務
4.2.1 反向代理流程
現(xiàn)在,商品詳情頁使用的是假的商品數(shù)據(jù)。不過在瀏覽器中,可以看到頁面有發(fā)起ajax請求查詢真實商品數(shù)據(jù)。
這個請求如下:
請求地址是localhost,端口是80,就被windows上安裝的Nginx服務給接收到了。然后代理給了OpenResty集群:
我們需要在OpenResty中編寫業(yè)務,查詢商品數(shù)據(jù)并返回到瀏覽器。
但是這次,我們先在OpenResty接收請求,返回假的商品數(shù)據(jù)。
4.2.2 OpenResty監(jiān)聽請求
OpenResty的很多功能都依賴于其目錄下的Lua庫,需要在nginx.conf中指定依賴庫的目錄,并導入依賴:
1)添加對OpenResty的Lua模塊的加載
修改/usr/local/openresty/nginx/conf/nginx.conf文件,在其中的http下面,添加下面代碼:
#lua 模塊
lua_package_path "/usr/local/openresty/lualib/?.lua;;";
#c模塊
lua_package_cpath "/usr/local/openresty/lualib/?.so;;";
2)監(jiān)聽/api/item路徑
修改/usr/local/openresty/nginx/conf/nginx.conf文件,在nginx.conf的server下面,添加對/api/item這個路徑的監(jiān)聽:
location /api/item {# 默認的響應類型default_type application/json;# 響應結果由lua/item.lua文件來決定content_by_lua_file lua/item.lua;
}
這個監(jiān)聽,就類似于SpringMVC中的@GetMapping("/api/item")做路徑映射。
而content_by_lua_file lua/item.lua則相當于調用item.lua這個文件,執(zhí)行其中的業(yè)務,把結果返回給用戶。相當于java中調用service。
4.2.3 編寫item.lua
1)在/usr/loca/openresty/nginx目錄創(chuàng)建文件夾:lua
2)在/usr/loca/openresty/nginx/lua文件夾下,新建文件:item.lua
3)編寫item.lua,返回假數(shù)據(jù)
item.lua中,利用ngx.say()函數(shù)返回數(shù)據(jù)到Response中
ngx.say('{"id":10001,"name":"SALSA AIR","title":"RIMOWA 21寸托運箱拉桿箱 SALSA AIR系列果綠色 820.70.36.4","price":17900,"image":"https://m.360buyimg.com/mobilecms/s720x720_jfs/t6934/364/1195375010/84676/e9f2c55f/597ece38N0ddcbc77.jpg!q70.jpg.webp","category":"拉桿箱","brand":"RIMOWA","spec":"","status":1,"createTime":"2019-04-30T16:00:00.000+00:00","updateTime":"2019-04-30T16:00:00.000+00:00","stock":2999,"sold":31290}')
4)重新加載配置
nginx -s reload
刷新商品頁面:http://localhost/item.html?id=1001,即可看到效果:
4.3 請求參數(shù)處理
上一節(jié)中,我們在OpenResty接收前端請求,但是返回的是假數(shù)據(jù)。
要返回真實數(shù)據(jù),必須根據(jù)前端傳遞來的商品id,查詢商品信息才可以。
那么如何獲取前端傳遞的商品參數(shù)呢?
4.3.1 獲取參數(shù)的API
OpenResty中提供了一些API用來獲取不同類型的前端請求參數(shù):
4.3.2 獲取參數(shù)并返回
在前端發(fā)起的ajax請求如圖:
可以看到商品id是以路徑占位符方式傳遞的,因此可以利用正則表達式匹配的方式來獲取ID
1)獲取商品id
修改/usr/loca/openresty/nginx/nginx.conf文件中監(jiān)聽/api/item的代碼,利用正則表達式獲取ID:
location ~ /api/item/(\d+) {# 默認的響應類型default_type application/json;# 響應結果由lua/item.lua文件來決定content_by_lua_file lua/item.lua;
}
2)拼接ID并返回
修改/usr/loca/openresty/nginx/lua/item.lua文件,獲取id并拼接到結果中返回:
-- 獲取商品id
local id = ngx.var[1]
-- 拼接并返回
ngx.say('{"id":' .. id .. ',"name":"SALSA AIR","title":"RIMOWA 21寸托運箱拉桿箱 SALSA AIR系列果綠色 820.70.36.4","price":17900,"image":"https://m.360buyimg.com/mobilecms/s720x720_jfs/t6934/364/1195375010/84676/e9f2c55f/597ece38N0ddcbc77.jpg!q70.jpg.webp","category":"拉桿箱","brand":"RIMOWA","spec":"","status":1,"createTime":"2019-04-30T16:00:00.000+00:00","updateTime":"2019-04-30T16:00:00.000+00:00","stock":2999,"sold":31290}')
3)重新加載并測試
運行命令以重新加載OpenResty配置:
nginx -s reload
刷新頁面可以看到結果中已經(jīng)帶上了ID:
4.4 查詢Tomcat
拿到商品ID后,本應去緩存中查詢商品信息,不過目前我們還未建立nginx、redis緩存。因此,這里我們先根據(jù)商品id去tomcat查詢商品信息。我們實現(xiàn)如圖部分:
需要注意的是,我們的OpenResty是在虛擬機,Tomcat是在Windows電腦上。兩者IP一定不要搞錯了。
4.4.1 發(fā)送http請求的API
nginx提供了內(nèi)部API用以發(fā)送http請求:
local resp = ngx.location.capture("/path",{method = ngx.HTTP_GET, -- 請求方式args = {a=1,b=2}, -- get方式傳參數(shù)
})
返回的響應內(nèi)容包括:
- resp.status:響應狀態(tài)碼
- resp.header:響應頭,是一個table
- resp.body:響應體,就是響應數(shù)據(jù)
注意:這里的path是路徑,并不包含IP和端口。這個請求會被nginx內(nèi)部的server監(jiān)聽并處理。
但是我們希望這個請求發(fā)送到Tomcat服務器,所以還需要編寫一個server來對這個路徑做反向代理:
location /path { # 這里是windows電腦的ip和Java服務端口,需要確保windows防火墻處于關閉狀態(tài) proxy_pass http://192.168.150.1:8081; }
原理如圖:
4.4.2 封裝http工具
下面,我們封裝一個發(fā)送Http請求的工具,基于ngx.location.capture來實現(xiàn)查詢tomcat。
1)添加反向代理,到windows的Java服務
因為item-service中的接口都是/item開頭,所以我們監(jiān)聽/item路徑,代理到windows上的tomcat服務。
修改 /usr/local/openresty/nginx/conf/nginx.conf文件,添加一個location:
location /path {# 這里是windows電腦的ip和Java服務端口,需要確保windows防火墻處于關閉狀態(tài)proxy_pass http://192.168.150.1:8081;
}
以后,只要我們調用ngx.location.capture("/item"),就一定能發(fā)送請求到windows的tomcat服務。
2)封裝工具類
之前我們說過,OpenResty啟動時會加載以下兩個目錄中的工具文件:
所以,自定義的http工具也需要放到這個目錄下。
在/usr/local/openresty/lualib目錄下,新建一個common.lua文件:
vi /usr/local/openresty/lualib/common.lua
內(nèi)容如下:
-- 封裝函數(shù),發(fā)送http請求,并解析響應
local function read_http(path, params)local resp = ngx.location.capture(path,{method = ngx.HTTP_GET,args = params,})if not resp then-- 記錄錯誤信息,返回404ngx.log(ngx.ERR, "http請求查詢失敗, path: ", path , ", args: ", args)ngx.exit(404)endreturn resp.body
end
-- 將方法導出
local _M = { read_http = read_http
}
return _M
這個工具將read_http函數(shù)封裝到_M這個table類型的變量中,并且返回,這類似于導出。
使用的時候,可以利用require('common')來導入該函數(shù)庫,這里的common是函數(shù)庫的文件名。
3)實現(xiàn)商品查詢
最后,我們修改/usr/local/openresty/lua/item.lua文件,利用剛剛封裝的函數(shù)庫實現(xiàn)對tomcat的查詢:
-- 引入自定義common工具模塊,返回值是common中返回的 _M
local common = require("common")
-- 從 common中獲取read_http這個函數(shù)
local read_http = common.read_http
-- 獲取路徑參數(shù)
local id = ngx.var[1]
-- 根據(jù)id查詢商品
local itemJSON = read_http("/item/".. id, nil)
-- 根據(jù)id查詢商品庫存
local itemStockJSON = read_http("/item/stock/".. id, nil)
這里查詢到的結果是json字符串,并且包含商品、庫存兩個json字符串,頁面最終需要的是把兩個json拼接為一個json:
這就需要我們先把JSON變?yōu)閘ua的table,完成數(shù)據(jù)整合后,再轉為JSON。
4.4.3 CJSON工具類
OpenResty提供了一個cjson的模塊用來處理JSON的序列化和反序列化。
官方地址: https://github.com/openresty/lua-cjson/
1)引入cjson模塊:
local cjson = require "cjson"
2)序列化:
local obj = {name = 'jack',age = 21
}
-- 把 table 序列化為 json
local json = cjson.encode(obj)
3)反序列化:
local json = '{"name": "jack", "age": 21}'
-- 反序列化 json為 table
local obj = cjson.decode(json);
print(obj.name)
4.4.4 實現(xiàn)Tomcat查詢
下面,我們修改之前的item.lua中的業(yè)務,添加json處理功能:
-- 導入common函數(shù)庫
local common = require('common')
local read_http = common.read_http
-- 導入cjson庫
local cjson = require('cjson')-- 獲取路徑參數(shù)
local id = ngx.var[1]
-- 根據(jù)id查詢商品
local itemJSON = read_http("/item/".. id, nil)
-- 根據(jù)id查詢商品庫存
local itemStockJSON = read_http("/item/stock/".. id, nil)-- JSON轉化為lua的table
local item = cjson.decode(itemJSON)
local stock = cjson.decode(stockJSON)-- 組合數(shù)據(jù)
item.stock = stock.stock
item.sold = stock.sold-- 把item序列化為json 返回結果
ngx.say(cjson.encode(item))
4.4.5 基于ID負載均衡
剛才的代碼中,我們的tomcat是單機部署。而實際開發(fā)中,tomcat一定是集群模式:
因此,OpenResty需要對tomcat集群做負載均衡。
而默認的負載均衡規(guī)則是輪詢模式,當我們查詢/item/10001時:
- 第一次會訪問8081端口的tomcat服務,在該服務內(nèi)部就形成了JVM進程緩存
- 第二次會訪問8082端口的tomcat服務,該服務內(nèi)部沒有JVM緩存(因為JVM緩存無法共享),會查詢數(shù)據(jù)庫
- ...
你看,因為輪詢的原因,第一次查詢8081形成的JVM緩存并未生效,直到下一次再次訪問到8081時才可以生效,緩存命中率太低了。
怎么辦?
如果能讓同一個商品,每次查詢時都訪問同一個tomcat服務,那么JVM緩存就一定能生效了。
也就是說,我們需要根據(jù)商品id做負載均衡,而不是輪詢。
1)原理
nginx提供了基于請求路徑做負載均衡的算法:
nginx根據(jù)請求路徑做hash運算,把得到的數(shù)值對tomcat服務的數(shù)量取余,余數(shù)是幾,就訪問第幾個服務,實現(xiàn)負載均衡。
例如:
- 我們的請求路徑是 /item/10001
- tomcat總數(shù)為2臺(8081、8082)
- 對請求路徑/item/1001做hash運算求余的結果為1
- 則訪問第一個tomcat服務,也就是8081
只要id不變,每次hash運算結果也不會變,那就可以保證同一個商品,一直訪問同一個tomcat服務,確保JVM緩存生效。
2)實現(xiàn)
修改/usr/local/openresty/nginx/conf/nginx.conf文件,實現(xiàn)基于ID做負載均衡。
首先,定義tomcat集群,并設置基于路徑做負載均衡:
upstream tomcat-cluster {hash $request_uri;server 192.168.150.1:8081;server 192.168.150.1:8082;
}
然后,修改對tomcat服務的反向代理,目標指向tomcat集群:
location /item {proxy_pass http://tomcat-cluster;
}
重新加載OpenResty
nginx -s reload
3)測試
啟動兩臺tomcat服務:
同時啟動:
清空日志后,再次訪問頁面,可以看到不同id的商品,訪問到了不同的tomcat服務:
4.5 Redis緩存預熱
Redis緩存會面臨冷啟動問題:
冷啟動:服務剛剛啟動時,Redis中并沒有緩存,如果所有商品數(shù)據(jù)都在第一次查詢時添加緩存,可能會給數(shù)據(jù)庫帶來較大壓力。
緩存預熱:在實際開發(fā)中,我們可以利用大數(shù)據(jù)統(tǒng)計用戶訪問的熱點數(shù)據(jù),在項目啟動時將這些熱點數(shù)據(jù)提前查詢并保存到Redis中。
我們數(shù)據(jù)量較少,并且沒有數(shù)據(jù)統(tǒng)計相關功能,目前可以在啟動時將所有數(shù)據(jù)都放入緩存中。
1)利用Docker安裝Redis
docker run --name redis -p 6379:6379 -d redis redis-server --appendonly yes
2)在item-service服務中引入Redis依賴
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
3)配置Redis地址
spring:redis:host: 192.168.150.101
4)編寫初始化類
緩存預熱需要在項目啟動時完成,并且必須是拿到RedisTemplate之后。
這里我們利用InitializingBean接口來實現(xiàn),因為InitializingBean可以在對象被Spring創(chuàng)建并且成員變量全部注入后執(zhí)行。
package com.heima.item.config;import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.heima.item.pojo.Item;
import com.heima.item.pojo.ItemStock;
import com.heima.item.service.IItemService;
import com.heima.item.service.IItemStockService;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;import java.util.List;@Component
public class RedisHandler implements InitializingBean {@Autowiredprivate StringRedisTemplate redisTemplate;@Autowiredprivate IItemService itemService;@Autowiredprivate IItemStockService stockService;private static final ObjectMapper MAPPER = new ObjectMapper();@Overridepublic void afterPropertiesSet() throws Exception {// 初始化緩存// 1.查詢商品信息List<Item> itemList = itemService.list();// 2.放入緩存for (Item item : itemList) {// 2.1.item序列化為JSONString json = MAPPER.writeValueAsString(item);// 2.2.存入redisredisTemplate.opsForValue().set("item:id:" + item.getId(), json);}// 3.查詢商品庫存信息List<ItemStock> stockList = stockService.list();// 4.放入緩存for (ItemStock stock : stockList) {// 2.1.item序列化為JSONString json = MAPPER.writeValueAsString(stock);// 2.2.存入redisredisTemplate.opsForValue().set("item:stock:id:" + stock.getId(), json);}}
}
4.6 查詢Redis緩存
現(xiàn)在,Redis緩存已經(jīng)準備就緒,我們可以再OpenResty中實現(xiàn)查詢Redis的邏輯了。如下圖紅框所示:
當請求進入OpenResty之后:
- 優(yōu)先查詢Redis緩存
- 如果Redis緩存未命中,再查詢Tomcat
4.6.1 封裝Redis工具
OpenResty提供了操作Redis的模塊,我們只要引入該模塊就能直接使用。但是為了方便,我們將Redis操作封裝到之前的common.lua工具庫中。
修改/usr/local/openresty/lualib/common.lua文件:
1)引入Redis模塊,并初始化Redis對象
-- 導入redis
local redis = require('resty.redis')
-- 初始化redis
local red = redis:new()
red:set_timeouts(1000, 1000, 1000)
2)封裝函數(shù),用來釋放Redis連接,其實是放入連接池
-- 關閉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 thenngx.log(ngx.ERR, "放入redis連接池失敗: ", err)end
end
3)封裝函數(shù),根據(jù)key查詢Redis數(shù)據(jù)
-- 查詢redis的方法 ip和port是redis地址,key是查詢的key
local function read_redis(ip, port, key)-- 獲取一個連接local ok, err = red:connect(ip, port)if not ok thenngx.log(ngx.ERR, "連接redis失敗 : ", err)return nilend-- 查詢redislocal resp, err = red:get(key)-- 查詢失敗處理if not resp thenngx.log(ngx.ERR, "查詢Redis失敗: ", err, ", key = " , key)end--得到的數(shù)據(jù)為空處理if resp == ngx.null thenresp = nilngx.log(ngx.ERR, "查詢Redis數(shù)據(jù)為空, key = ", key)endclose_redis(red)return resp
end
4)導出
-- 將方法導出
local _M = { read_http = read_http,read_redis = read_redis
}
return _M
完整的common.lua:
-- 導入redis
local redis = require('resty.redis')
-- 初始化redis
local red = redis:new()
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 thenngx.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 thenngx.log(ngx.ERR, "連接redis失敗 : ", err)return nilend-- 查詢redislocal resp, err = red:get(key)-- 查詢失敗處理if not resp thenngx.log(ngx.ERR, "查詢Redis失敗: ", err, ", key = " , key)end--得到的數(shù)據(jù)為空處理if resp == ngx.null thenresp = nilngx.log(ngx.ERR, "查詢Redis數(shù)據(jù)為空, key = ", key)endclose_redis(red)return resp
end-- 封裝函數(shù),發(fā)送http請求,并解析響應
local function read_http(path, params)local resp = ngx.location.capture(path,{method = ngx.HTTP_GET,args = params,})if not resp then-- 記錄錯誤信息,返回404ngx.log(ngx.ERR, "http查詢失敗, path: ", path , ", args: ", args)ngx.exit(404)endreturn resp.body
end
-- 將方法導出
local _M = { read_http = read_http,read_redis = read_redis
}
return _M
4.6.2 實現(xiàn)Redis查詢
接下來,我們就可以去修改item.lua文件,實現(xiàn)對Redis的查詢了。
查詢邏輯是:
- 根據(jù)id查詢Redis
- 如果查詢失敗則繼續(xù)查詢Tomcat
- 將查詢結果返回
1)修改/usr/local/openresty/lua/item.lua文件,添加一個查詢函數(shù):
-- 導入common函數(shù)庫
local common = require('common')
local read_http = common.read_http
local read_redis = common.read_redis
-- 封裝查詢函數(shù)
function read_data(key, path, params)-- 查詢本地緩存local val = read_redis("127.0.0.1", 6379, key)-- 判斷查詢結果if not val thenngx.log(ngx.ERR, "redis查詢失敗,嘗試查詢http, key: ", key)-- redis查詢失敗,去查詢httpval = read_http(path, params)end-- 返回數(shù)據(jù)return val
end
2)而后修改商品查詢、庫存查詢的業(yè)務:
3)完整的item.lua代碼:
-- 導入common函數(shù)庫
local common = require('common')
local read_http = common.read_http
local read_redis = common.read_redis
-- 導入cjson庫
local cjson = require('cjson')-- 封裝查詢函數(shù)
function read_data(key, path, params)-- 查詢本地緩存local val = read_redis("127.0.0.1", 6379, key)-- 判斷查詢結果if not val thenngx.log(ngx.ERR, "redis查詢失敗,嘗試查詢http, key: ", key)-- redis查詢失敗,去查詢httpval = read_http(path, params)end-- 返回數(shù)據(jù)return val
end-- 獲取路徑參數(shù)
local id = ngx.var[1]-- 查詢商品信息
local itemJSON = read_data("item:id:" .. id, "/item/" .. id, nil)
-- 查詢庫存信息
local stockJSON = read_data("item:stock:id:" .. id, "/item/stock/" .. id, nil)-- JSON轉化為lua的table
local item = cjson.decode(itemJSON)
local stock = cjson.decode(stockJSON)
-- 組合數(shù)據(jù)
item.stock = stock.stock
item.sold = stock.sold-- 把item序列化為json 返回結果
ngx.say(cjson.encode(item))
4.7 Nginx本地緩存
現(xiàn)在,整個多級緩存中只差最后一環(huán),也就是nginx的本地緩存了。如圖:
4.7.1 本地緩存API
OpenResty為Nginx提供了shard dict的功能,可以在nginx的多個worker之間共享數(shù)據(jù),實現(xiàn)緩存功能。
1)開啟共享字典,在nginx.conf的http下添加配置:
# 共享字典,也就是本地緩存,名稱叫做:item_cache,大小150mlua_shared_dict item_cache 150m;
2)操作共享字典:
-- 獲取本地緩存對象
local item_cache = ngx.shared.item_cache
-- 存儲, 指定key、value、過期時間,單位s,默認為0代表永不過期
item_cache:set('key', 'value', 1000)
-- 讀取
local val = item_cache:get('key')
4.7.2 實現(xiàn)本地緩存查詢
1)修改/usr/local/openresty/lua/item.lua文件,修改read_data查詢函數(shù),添加本地緩存邏輯:
-- 導入共享詞典,本地緩存
local item_cache = ngx.shared.item_cache-- 封裝查詢函數(shù)
function read_data(key, expire, path, params)-- 查詢本地緩存local val = item_cache:get(key)if not val thenngx.log(ngx.ERR, "本地緩存查詢失敗,嘗試查詢Redis, key: ", key)-- 查詢redisval = read_redis("127.0.0.1", 6379, key)-- 判斷查詢結果if not val thenngx.log(ngx.ERR, "redis查詢失敗,嘗試查詢http, key: ", key)-- redis查詢失敗,去查詢httpval = read_http(path, params)endend-- 查詢成功,把數(shù)據(jù)寫入本地緩存item_cache:set(key, val, expire)-- 返回數(shù)據(jù)return val
end
2)修改item.lua中查詢商品和庫存的業(yè)務,實現(xiàn)最新的read_data函數(shù):
其實就是多了緩存時間參數(shù),過期后nginx緩存會自動刪除,下次訪問即可更新緩存。
這里給商品基本信息設置超時時間為30分鐘,庫存為1分鐘。
因為庫存更新頻率較高,如果緩存時間過長,可能與數(shù)據(jù)庫差異較大。
3)完整的item.lua文件:
-- 導入common函數(shù)庫
local common = require('common')
local read_http = common.read_http
local read_redis = common.read_redis
-- 導入cjson庫
local cjson = require('cjson')
-- 導入共享詞典,本地緩存
local item_cache = ngx.shared.item_cache-- 封裝查詢函數(shù)
function read_data(key, expire, path, params)-- 查詢本地緩存local val = item_cache:get(key)if not val thenngx.log(ngx.ERR, "本地緩存查詢失敗,嘗試查詢Redis, key: ", key)-- 查詢redisval = read_redis("127.0.0.1", 6379, key)-- 判斷查詢結果if not val thenngx.log(ngx.ERR, "redis查詢失敗,嘗試查詢http, key: ", key)-- redis查詢失敗,去查詢httpval = read_http(path, params)endend-- 查詢成功,把數(shù)據(jù)寫入本地緩存item_cache:set(key, val, expire)-- 返回數(shù)據(jù)return val
end-- 獲取路徑參數(shù)
local id = ngx.var[1]-- 查詢商品信息
local 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的table
local item = cjson.decode(itemJSON)
local stock = cjson.decode(stockJSON)
-- 組合數(shù)據(jù)
item.stock = stock.stock
item.sold = stock.sold-- 把item序列化為json 返回結果
ngx.say(cjson.encode(item))
五、緩存同步
大多數(shù)情況下,瀏覽器查詢到的都是緩存數(shù)據(jù),如果緩存數(shù)據(jù)與數(shù)據(jù)庫數(shù)據(jù)存在較大差異,可能會產(chǎn)生比較嚴重的后果。
所以我們必須保證數(shù)據(jù)庫數(shù)據(jù)、緩存數(shù)據(jù)的一致性,這就是緩存與數(shù)據(jù)庫的同步。
5.1 數(shù)據(jù)同步策略
緩存數(shù)據(jù)同步的常見方式有三種:
設置有效期:給緩存設置有效期,到期后自動刪除。再次查詢時更新
- 優(yōu)勢:簡單、方便
- 缺點:時效性差,緩存過期之前可能不一致
- 場景:更新頻率較低,時效性要求低的業(yè)務
同步雙寫:在修改數(shù)據(jù)庫的同時,直接修改緩存
- 優(yōu)勢:時效性強,緩存與數(shù)據(jù)庫強一致
- 缺點:有代碼侵入,耦合度高;
- 場景:對一致性、時效性要求較高的緩存數(shù)據(jù)
異步通知:修改數(shù)據(jù)庫時發(fā)送事件通知,相關服務監(jiān)聽到通知后修改緩存數(shù)據(jù)
- 優(yōu)勢:低耦合,可以同時通知多個緩存服務
- 缺點:時效性一般,可能存在中間不一致狀態(tài)
- 場景:時效性要求一般,有多個服務需要同步
而異步實現(xiàn)又可以基于MQ或者Canal來實現(xiàn):
1)基于MQ的異步通知:
解讀:
- 商品服務完成對數(shù)據(jù)的修改后,只需要發(fā)送一條消息到MQ中。
- 緩存服務監(jiān)聽MQ消息,然后完成對緩存的更新
依然有少量的代碼侵入。
2)基于Canal的通知
解讀:
- 商品服務完成商品修改后,業(yè)務直接結束,沒有任何代碼侵入
- Canal監(jiān)聽MySQL變化,當發(fā)現(xiàn)變化后,立即通知緩存服務
- 緩存服務接收到canal通知,更新緩存
代碼零侵入
5.2 安裝Canal
5.2.1 認識Canal
Canal [k?'n?l],譯意為水道/管道/溝渠,canal是阿里巴巴旗下的一款開源項目,基于Java開發(fā)?;跀?shù)據(jù)庫增量日志解析,提供增量數(shù)據(jù)訂閱&消費。
GitHub的地址:https://github.com/alibaba/canal
Canal是基于mysql的主從同步來實現(xiàn)的,MySQL主從同步的原理如下:
- 1)MySQL master 將數(shù)據(jù)變更寫入二進制日志( binary log),其中記錄的數(shù)據(jù)叫做binary log events
- 2)MySQL slave 將 master 的 binary log events拷貝到它的中繼日志(relay log)
- 3)MySQL slave 重放 relay log 中事件,將數(shù)據(jù)變更反映它自己的數(shù)據(jù)
而Canal就是把自己偽裝成MySQL的一個slave節(jié)點,從而監(jiān)聽master的binary log變化。再把得到的變化信息通知給Canal的客戶端,進而完成對其它數(shù)據(jù)庫的同步。
5.2.2 安裝Canal
下面我們就開啟mysql的主從同步機制,讓Canal來模擬salve
5.2.2.1.開啟MySQL主從
Canal是基于MySQL的主從同步功能,因此必須先開啟MySQL的主從功能才可以。
這里以之前用Docker運行的mysql為例:
1.開啟binlog
打開mysql容器掛載的日志文件,我的在/tmp/mysql/conf目錄:
修改文件:
vi /tmp/mysql/conf/my.cnf
添加內(nèi)容:
log-bin=/var/lib/mysql/mysql-bin
binlog-do-db=heima
配置解讀:
- log-bin=/var/lib/mysql/mysql-bin:設置binary log文件的存放地址和文件名,叫做mysql-bin
- binlog-do-db=heima:指定對哪個database記錄binary log events,這里記錄heima這個庫
最終效果:
[mysqld]
skip-name-resolve
character_set_server=utf8
datadir=/var/lib/mysql
server-id=1000
log-bin=/var/lib/mysql/mysql-bin
binlog-do-db=heima
2.設置用戶權限
接下來添加一個僅用于數(shù)據(jù)同步的賬戶,出于安全考慮,這里僅提供對heima這個庫的操作權限。
create user canal@'%' IDENTIFIED by 'canal';
GRANT SELECT, REPLICATION SLAVE, REPLICATION CLIENT,SUPER ON *.* TO 'canal'@'%' identified by 'canal';
FLUSH PRIVILEGES;
重啟mysql容器即可
docker restart mysql
測試設置是否成功:在mysql控制臺,或者Navicat中,輸入命令:
show master status;
5.2.2.2 安裝Canal
1.創(chuàng)建網(wǎng)絡
我們需要創(chuàng)建一個網(wǎng)絡,將MySQL、Canal、MQ放到同一個Docker網(wǎng)絡中:
docker network create heima
讓mysql加入這個網(wǎng)絡:
docker network connect heima mysql
2.安裝Canal
課前資料中提供了canal的鏡像壓縮包: canal.tar
大家可以上傳到虛擬機,然后通過命令導入:
docker load -i canal.tar
然后運行命令創(chuàng)建Canal容器:
docker run -p 11111:11111 --name canal \
-e canal.destinations=heima \
-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=heima\\..* \
--network heima \
-d canal/canal-server:v1.1.5
說明:
- -p 11111:11111:這是canal的默認監(jiān)聽端口
- -e canal.instance.master.address=mysql:3306:數(shù)據(jù)庫地址和端口,如果不知道m(xù)ysql容器地址,可以通過docker inspect 容器id來查看
- -e canal.instance.dbUsername=canal:數(shù)據(jù)庫用戶名
- -e canal.instance.dbPassword=canal :數(shù)據(jù)庫密碼
- -e canal.instance.filter.regex=:要監(jiān)聽的表名稱
表名稱監(jiān)聽支持的語法:
mysql 數(shù)據(jù)解析關注的表,Perl正則表達式.
多個正則之間以逗號(,)分隔,轉義符需要雙斜杠(\\)
常見例子:
1. 所有表:.* or .*\\..*
2. canal schema下所有表: canal\\..*
3. canal下的以canal打頭的表:canal\\.canal.*
4. canal schema下的一張表:canal.test1
5. 多個規(guī)則組合使用然后以逗號隔開:canal\\..*,mysql.test1,mysql.test2
5.3.監(jiān)聽Canal
Canal提供了各種語言的客戶端,當Canal監(jiān)聽到binlog變化時,會通知Canal的客戶端。
我們可以利用Canal提供的Java客戶端,監(jiān)聽Canal通知消息。當收到變化的消息時,完成對緩存的更新。
不過這里我們會使用GitHub上的第三方開源的canal-starter客戶端。地址:https://github.com/NormanGyllenhaal/canal-client
與SpringBoot完美整合,自動裝配,比官方客戶端要簡單好用很多。
5.3.1 引入依賴:
<dependency><groupId>top.javatool</groupId><artifactId>canal-spring-boot-starter</artifactId><version>1.2.1-RELEASE</version>
</dependency>
5.3.2 編寫配置:
canal:destination: heima # canal的集群名字,要與安裝canal時設置的名稱一致server: 192.168.150.101:11111 # canal服務地址
5.3.3 修改Item實體類
通過@Id、@Column、等注解完成Item與數(shù)據(jù)庫表字段的映射:
package com.heima.item.pojo;import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import org.springframework.data.annotation.Id;
import org.springframework.data.annotation.Transient;import javax.persistence.Column;
import java.util.Date;@Data
@TableName("tb_item")
public class Item {@TableId(type = IdType.AUTO)@Idprivate Long id;//商品id@Column(name = "name")private String name;//商品名稱private String title;//商品標題private Long price;//價格(分)private String image;//商品圖片private String category;//分類名稱private String brand;//品牌名稱private String spec;//規(guī)格private Integer status;//商品狀態(tài) 1-正常,2-下架private Date createTime;//創(chuàng)建時間private Date updateTime;//更新時間@TableField(exist = false)@Transientprivate Integer stock;@TableField(exist = false)@Transientprivate Integer sold;
}
5.3.4 編寫監(jiān)聽器
通過實現(xiàn)EntryHandler接口編寫監(jiān)聽器,監(jiān)聽Canal消息。注意兩點:
- 實現(xiàn)類通過@CanalTable("tb_item")指定監(jiān)聽的表信息
- EntryHandler的泛型是與表對應的實體類
package com.heima.item.canal;import com.github.benmanes.caffeine.cache.Cache;
import com.heima.item.config.RedisHandler;
import com.heima.item.pojo.Item;
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<Item> {@Autowiredprivate RedisHandler redisHandler;@Autowiredprivate Cache<Long, Item> itemCache;@Overridepublic void insert(Item item) {// 寫數(shù)據(jù)到JVM進程緩存itemCache.put(item.getId(), item);// 寫數(shù)據(jù)到redisredisHandler.saveItem(item);}@Overridepublic void update(Item before, Item after) {// 寫數(shù)據(jù)到JVM進程緩存itemCache.put(after.getId(), after);// 寫數(shù)據(jù)到redisredisHandler.saveItem(after);}@Overridepublic void delete(Item item) {// 刪除數(shù)據(jù)到JVM進程緩存itemCache.invalidate(item.getId());// 刪除數(shù)據(jù)到redisredisHandler.deleteItemById(item.getId());}
}
在這里對Redis的操作都封裝到了RedisHandler這個對象中,是我們之前做緩存預熱時編寫的一個類,內(nèi)容如下:
package com.heima.item.config;import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.heima.item.pojo.Item;
import com.heima.item.pojo.ItemStock;
import com.heima.item.service.IItemService;
import com.heima.item.service.IItemStockService;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;import java.util.List;@Component
public class RedisHandler implements InitializingBean {@Autowiredprivate StringRedisTemplate redisTemplate;@Autowiredprivate IItemService itemService;@Autowiredprivate IItemStockService stockService;private static final ObjectMapper MAPPER = new ObjectMapper();@Overridepublic void afterPropertiesSet() throws Exception {// 初始化緩存// 1.查詢商品信息List<Item> itemList = itemService.list();// 2.放入緩存for (Item item : itemList) {// 2.1.item序列化為JSONString json = MAPPER.writeValueAsString(item);// 2.2.存入redisredisTemplate.opsForValue().set("item:id:" + item.getId(), json);}// 3.查詢商品庫存信息List<ItemStock> stockList = stockService.list();// 4.放入緩存for (ItemStock stock : stockList) {// 2.1.item序列化為JSONString json = MAPPER.writeValueAsString(stock);// 2.2.存入redisredisTemplate.opsForValue().set("item:stock:id:" + stock.getId(), json);}}public void saveItem(Item item) {try {String json = MAPPER.writeValueAsString(item);redisTemplate.opsForValue().set("item:id:" + item.getId(), json);} catch (JsonProcessingException e) {throw new RuntimeException(e);}}public void deleteItemById(Long id) {redisTemplate.delete("item:id:" + id);}
}