傳統緩存方案問題
多級緩存方案
流程
? ? ? ? 1.客戶端瀏覽器緩存頁面靜態資源;
? ? ? ? 2.?客戶端請求到Nginx反向代理;[一級緩存_瀏覽器緩存]
? ? ? ? 3.Nginx反向代理將請求分發到Nginx集群(OpenResty);
????????4.先重Nginx集群OpenResty中獲取Nginx本地緩存數據;[二級緩存_Nginx本地緩存]
? ? ? ? 5.若Nginx本地緩存未命中則在Nginx集群節點上編寫Lua腳本連接操作Redis(重Redis中
? ? ? ? ? ?拿取緩存數據);[三級緩存_Redis緩存]
? ? ? ? 6.如果Nginx集群未重Redis中拿取到數據則Nginx集群將請求分發到Tomcat服務器。在
? ? ? ? ? ?Tomcat服務器中做進程緩存,用戶請求先嘗試重進程緩存中拿取數據,若進程緩存中
? ? ? ? ? ?未獲取到數據則查詢數據庫重數據庫中獲取數據;[四級緩存_JVM進程緩存]
? ? ? ? 7.當數據庫中數據變更時采用canal進行數據庫與緩存中數據同步;
準備工作
1.安裝Mysql
2.導入SQL數據
3.導入提供的工程
4.訪問提供的Nginx
5.學習進程緩存
分布式緩存與本地進程緩存區別
分布式緩存,例如Redis:
????????優點:
????????????????存儲容量更大、可靠性更好、可以在集群間共享 缺點:訪問緩存有網絡開銷
????????場景:
????????????????緩存數據量較大、可靠性要求較高、需要在集群間共享;
進程本地緩存,例如HashMap、GuavaCache:
????????優點:讀取本地內存,沒有網絡開銷,速度更快 缺點:存儲容量有限、可靠性較低、
???????????????????無法共享
????????場景:性能要求較高,緩存數據量較小;
Caffeine
????????Caffeine是一個基于Java8開發的,提供了近乎最佳命中率的高性能的本地緩存庫。目前Spring內部的緩存使用的就是Caffeine。
1.導入依賴
<dependency><groupId>com.github.ben-manes.caffeine</groupId><artifactId>caffeine</artifactId> </dependency>
2.Caffeine簡單使用
@Testvoid testBasicOps() {// 創建緩存對象Cache<String, String> cache = Caffeine.newBuilder().build();// 存數據cache.put("gf", "迪麗熱巴");// 取數據方式一,若key不存在則返回nullString gf = cache.getIfPresent("gf");System.out.println("gf = " + gf);// 取數據方式二,若key不存在則執行自定義的function(可在function中實現查詢數據庫數據)String defaultGF = cache.get("defaultGF", key -> {// 這里可以去數據庫根據 key查詢valuereturn "柳巖";});System.out.println("defaultGF = " + defaultGF);}
2.Caffeine驅逐策略
????????設置進程緩存驅逐策略的目的在于避免過多緩存數據占用Java進程內存;
Caffeine提供了三種緩存驅逐策略:
2.1.基于容量:
????????設置緩存的數量上限(即允許當前緩存對象存入多少個"鍵值對")
// 創建緩存對象
Cache<String, String> cache = Caffeine.newBuilder()? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ??????????????????????????????????????????????????????.maximumSize(1) // 設置緩存大小上限為 1
????????????????????????????????????????????????????????????????.build();
2.2.基于時間:
????????設置緩存的有效時間
// 創建緩存對象(設置緩存有效期為10秒,從最后一次寫入開始計時)
Cache<String, String> cache = Caffeine.newBuilder() ? ? ? ? ????????????????????????????????????????????????????????????????.expireAfterWrite(Duration.ofSeconds(10)) ? ? ? ?
????????????????????????? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? .build();
2.3.基于引用:
????????設置緩存為軟引用或弱引用,利用GC來回收緩存數據。性能較差,不建議使用
3.實現商品查詢本地進程緩存
利用Caffeine實現下列需求:
? ? ? ? ?1.給根據id查詢商品的業務添加緩存,緩存未命中時查詢數據庫;
? ? ? ? ?2.給根據id查詢商品庫存的業務添加緩存,緩存未命中時查詢數據庫;
? ? ? ? ?3.緩存初始大小為100 緩存上限為10000;
3.1.構建商品及庫存Caffeine配置類
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 {/** 構建“商品”查詢緩存Caffeine對象 */@Beanpublic Cache<Long, Item> itemCache(){return Caffeine.newBuilder().initialCapacity(100) // 初始化100.maximumSize(10_000) // 最大10000.build();}/** 構建“商品庫存”查詢緩存Caffeine對象 */@Beanpublic Cache<Long, ItemStock> stockCache(){return Caffeine.newBuilder().initialCapacity(100) // 初始化100.maximumSize(10_000) // 最大10000.build();}
}
2.使用Caffine工具類實現商品及庫存查詢訪問進程緩存
import com.github.benmanes.caffeine.cache.Cache;
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.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("item")
public class ItemController2 {@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));}
}
6.學習Lua
????????Lua 是一種輕量小巧的腳本語言,用標準C語言編寫并以源代碼形式開放, 其設計目的是為了嵌入應用程序中,從而為應用程序提供靈活的擴展和定制功能;
# 官網:
https://www.lua.org/# windows下載地址
https://github.com/rjpcomputing/luaforwindows/releases
6.1.安裝Lua
# ubuntu安裝Lua
https://www.henghost.com/news/article/159738/?jsmc=5470689cd4&jsme=1751873106
# windows安裝流程
https://blog.csdn.net/weixin_41924879/article/details/126041670# 測試Lua
6.2.Lua聲明變量和循環數組或Map
數據類型 | 描述 |
nil | ?這個最簡單,只有值nil屬于該類,表示一個無效值(在條件表達式中相當于false)。 |
boolean | ?包含兩個值:false和true |
number | ?表示雙精度類型的實浮點數 |
string | ?字符串由一對雙引號或單引號來表示 |
function | ?由?C?或?Lua?編寫的函數 |
table | Lua?中的表(table)其實是一個"關聯數組"(associative?arrays),數組的索引可以是數字、 字符串或表類型。在?Lua?里,table?的創建是通過"構造表達式"來完成,最簡單構造表達式是{}, 用來創建一個空表。(Lua中沒有數組和Map但可以使用table類型來表示數組或Map) |
Lua聲明變量?
? ? ? ? Lua聲明變量時不需要指定數據類型,local代表聲明的變量是局部變量;
-- 聲明字符串
local mystr = 'hellow world'
print(mystr)-- 拼接字符串使用 ..
print("A".."B".."C")-- 聲明boolean
local myboolean = true
print(myboolean)-- 聲明數值類型
local mydecimal = 12.6
local mydecimal2 = 180
print(mydecimal)
print(mydecimal2)-- 聲明數組 Key為索引的table(特別說明:訪問數組元素時索引重1開始)
local myarr = {'my','name','is','jack'}
-- 聲明Map Key為指定索引名的table
local mymap = {name = 'zs',age = 18}-- 訪問數組
print(myarr[1])-- 訪問Map
-- Map訪問方式一
print(mymap['name'])
-- Map訪問方式二
print(mymap.name)
Lua循環數組和Map
? ? ? ? 遍歷數組使用ipairs、遍歷Map使用pairs
-- 循環數組 index 和 value 為自定義變量名稱(可變更)index 代表索引 value 代表對應索引值
for index,value in ipairs(myarr)doprint(index,value)end-- 循環Map key 和 value 為自定義變量名稱(可變更)key 代表Map的key value 代表對應key的值
for key,value in pairs(mymap)doprint(key,value)end
6.3.Lua條件控制和函數
Lua定義函數語法
function 函數名(args1,agrs2,agrs3)
? ? ? ? -- 函數體..
? ? ? ? return 返回值
end
定義一個打印數組的函數
-- 定義數組對象
local arr = {100,200,300}-- 定義打印數組函數
function printArr(arr)for index,value in ipairs(arr)doprint(value)end
end-- 調用打印數據函數方法
printArr(arr)
6.4.Lua條件控制
類似Java的 if - else寫法;
if(布爾表達式)
???????? then
???????????????? --[ 布爾表達式為 true 時執行該語句塊 --]
????????else
? ? ? ? ? ? ? ? ?--[ 布爾表達式為 false 時執行該語句塊 --]
end
與Java不同Lua“布爾表達式”中的邏輯運算符是基于英文單詞
操作符 | 描述 | 實例 |
and | 邏輯與操作符。?若?A?為?false,則返回?A,否則返回?B。 | (A?and?B)?為?false。 |
or | 邏輯或操作符。?若?A?為?true,則返回?A,否則返回?B。 | (A?or?B)?為?true。 |
not | 邏輯非操作符。與邏輯運算結果相反,如果條件為?true,邏輯非為?false。 | not(A?and?B)?為?true。 |
?定義打印數組函數當參數為nil時打印錯誤信息
--?定義打印數組函數當參數為nil時打印錯誤信息
function printArr(arr)if(not arr) -- 判斷數組是否為nilthenprint('添加條件判斷、數組不能為空!')return nilendfor index,value in ipairs(arr)doprint(value)end
end
7.學習OpenResty
官方網站: https://openresty.org/cn/
OpenResty?是一個基于 Nginx的高性能 Web 平臺,用于方便地搭建能夠處理超高并發、擴展性極高的動態 Web 應用、Web 服務和動態網關。
????????具備下列特點:
? ? ? ? 1.具備Nginx的完整功能
? ? ? ? 2.基于Lua語言進行擴展,集成了大量精良的 Lua 庫、第三方模塊
? ? ? ? 3.允許使用Lua自定義業務邏輯、自定義庫
7.1. 安裝OpenResty
? ? ? ? Ubuntu18.04.6安裝OpenResty
# 1.切換為root
sudo su root# 2.安裝PCRE
sudo apt -y install libpcre3-dev# 3.安裝OpenSSL:
sudo apt -y install openssl
sudo apt-get -y install libssl-dev# 4.安裝zlib:
sudo apt-get -y install ruby
sudo apt-get -y install zlib1g
sudo apt-get -y install zlib1g.dev# 5.下載openResty包
wget https://openresty.org/download/openresty-1.25.3.2.tar.gz# 6.解壓
tar -zxvf openresty-1.25.3.2.tar.gz# 7.進入openresty目錄
cd openresty-1.25.3.2# 8.創建Nginx用戶、組
sudo groupadd nginx
sudo useradd -r -g nginx -s /sbin/nologin -c "Nginx web server" nginx# 9.指定Nginx組、用戶、安裝目錄(注意:執行此命令時需確認nginx組及nginx用戶必須已存在)
./configure --user=nginx --group=nginx --prefix=/usr/local/openresty# 10.編譯
make# 11.編譯安裝
make install# 12.配置環境變量并刷新配置
vim /etc/profile# 追加環境變量值
export NGINX_HOME=/usr/local/openresty/nginx
export PATH=${NGINX_HOME}/sbin:$PATH# 刷新配置文件
source /etc/profile# 13.安裝好后可去/usr/local/openresty目錄下查看安裝的openResty
7.1.1.調整nginx.conf
? ? ? ? Nginx安裝后nginx.conf文件有很多注釋內容,使用如下內容替換原配置將剔除掉注釋內容;
文件位置:/usr/local/openresty/nginx/conf/nginx.conf
#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;
? ? ? ? }
? ? }
}
7.1.2.啟動Nginx并訪問
# 啟動Nginx(已配置環境變量直接使用nginx)
nginx
訪問地址:?http://yourIP:8081/
7.2.OpenResty初體驗
? ? ? ? 使用OpenResty實現商品詳情頁查詢,在OpenResty中接收這個請求,并返回一段商品假數據;
1.修改OpenResty的nginx.conf文件,在http下面添加對OpenResty的Lua模塊的加載
# 加載lua 模塊
lua_package_path "/usr/local/openresty/lualib/?.lua;;";
# 加載c模塊
lua_package_cpath "/usr/local/openresty/lualib/?.so;;";
2.在nginx.conf的server下面,添加對/api/item這個路徑的監聽
location /api/item {
????????# 響應類型,這里返回?json
???????? default_type application/json;
????????# 響應數據由 lua/item.lua這個文件來決定(lua/item.lua默認在nginx目錄中查找) ????????content_by_lua_file lua/item.lua;
}
3.在/usr/local/openresty/nginx/lua/item.lua編寫模擬數據返回給前端調用處
ngx.say('{"id":10001,"name":"SALSA AIR}')
如圖:
4.重啟nginx
nginx -s reload
5.重啟nginx前此路徑不存在響應404
6.重啟nginx后(該值為我們在opneResty中定義的lua腳本信息返回)
修改后的nginx.conf供參考
#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;
? ? # 加載lua 模塊
? ? lua_package_path "/usr/local/openresty/lualib/?.lua;;";
? ? # 加載c模塊
? ? lua_package_cpath "/usr/local/openresty/lualib/?.so;;";? ? server {
? ? ? ? listen ? ? ? 8081;
? ? ? ? server_name ?localhost;
? ? ? ? location / {
? ? ? ? ? ? root ? html;
? ? ? ? ? ? index ?index.html index.htm;
? ? ? ? }
? ? ? ? location /api/item {
? ? ? ? ? ? # 響應類型,這里返回json
? ? ? ? ? ? default_type application/json;
? ? ? ? ? ? #?響應數據由?lua/item.lua這個文件來決定(lua/item.lua默認在nginx目錄下查找)
? ? ? ? ? ? content_by_lua_file lua/item.lua;
? ? ? ? }
? ? ?? ? ? ? error_page ? 500 502 503 504 ?/50x.html;
? ? ? ? location = /50x.html {
? ? ? ? ? ? root ? html;
? ? ? ? }
? ? }
}
7.3.OpenResty獲取請求參數
? ? ? ? 如上入門案例使用openResty讀取自定義item.lua腳本向調用處返回了我們自定義的數據。后續生產中往往是需要讀取用戶請求的參數針對不同參數返回不同的數據結果;
OpenResty提供了各種API用來獲取不同類型的請求參數:
參數格式 | 參數示例 | 參數解析代碼示例 | 獲取參數方式 |
路徑占位符 | /item/1001 | # 1.正則表達式匹配: location ~ /item/(\d+) { ?????????content_by_lua_file lua/item.lua; } | 匹配到的參數會存入ngx.var數組中,可以用角標獲取 local id = ngx.var[1] |
請求頭 | id:1001 | -- 獲取請求頭,返回值是table類型 local headers = ngx.req.get_headers() | 使用lua語法讀取table類型數據(詳見節點6.2) |
Get請求參數 | ?id=1001 | -- 獲取GET請求參數,返回值是table類型 local getParams = ngx.req.get_uri_args() | 使用lua語法讀取table類型數據 (詳見節點6.2) |
Post表單參數 | id=1001 | -- 讀取請求體 ngx.req.read_body() -- 獲取POST表單參數,返回值是table類型 local postParams = ngx.req.get_post_args() | 使用lua語法讀取table類型數據 (詳見節點6.2) |
JSON參數 | {"id":?1001} | -- 讀取請求體 ngx.req.read_body() -- 獲取body中的json參數,返回值是string類型 local jsonBody = ngx.req.get_body_data() | 使用lua語法讀取table類型數據 (詳見節點6.2) |
7.3.1.獲取參數并動態返回初體驗
? ? ? ? 使用“路徑占位符”方式獲取查詢參數并動態返回;
1.修改nginx.conf
#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;
? ? # 加載lua 模塊
? ? lua_package_path "/usr/local/openresty/lualib/?.lua;;";
? ? # 加載c模塊
? ? lua_package_cpath "/usr/local/openresty/lualib/?.so;;";
? ? server {
? ? ? ? listen ? ? ? 8081;
? ? ? ? server_name ?localhost;
? ? ? ? location / {
? ? ? ? ? ? root ? html;
? ? ? ? ? ? index ?index.html index.htm;
? ? ? ? }
? ? ? ? # 使用正則匹配/api/item/...的參數值(注意location后面的~前后有空格)
? ? ? ? location ~ /api/item/(\d+) {
? ? ? ? ? ? # 響應類型,這里返回json
? ? ? ? ? ? default_type application/json;
? ? ? ? ? ? #?響應數據由?lua/item.lua這個文件來決定(lua/item.lua默認在nginx目錄下查找)
? ? ? ? ? ? content_by_lua_file lua/item.lua;
? ? ? ? }
? ? ?
? ? ? ? error_page ? 500 502 503 504 ?/50x.html;
? ? ? ? location = /50x.html {
? ? ? ? ? ? root ? html;
? ? ? ? }
? ? }
}
2.修改item.lua腳本
-- 獲取正則匹配到的第一個參數值
local id = ngx.var[1]
-- 動態拼接id(Lua語法“..”代表拼接)
ngx.say('{"id":'..id..',"name":"SALSA?AIR}')
3.重啟nginx
nginx -s reload
4.實現效果
?
?
7.4.OpenResty查詢Tomcat服務器數據
? ? ? ? 多級緩存方案流程一臺Nginx將請求反向代理到Nginx業務集群(OpenResty),OpenResty先查詢Redis中是否有數據,Redis未命中則發送http請求訪問Tomcat服務器中的業務數據(此處先實現使用OpenResty向Tomcat發送請求獲取數據。后面再實現使用OpenResty向Redis獲取數據);
案例:
????????獲取請求路徑中的商品id信息,根據id向Tomcat查詢商品信息;
實現步驟:
? ? 這里要修改item.lua,滿足下面的需求:
????????1.獲取請求參數中的id;
????????2.根據id向Tomcat服務發送請求,查詢商品信息;
????????3.根據id向Tomcat服務發送請求,查詢庫存信息;
????????4.組裝商品信息、庫存信息,序列化為JSON格式并返回;
Nginx內部發送Http請求
nginx提供了內部API用以發送http請求:
GET請求格式:
local resp = ngx.location.capture("/item",{
????????method = ngx.HTTP_GET, -- 請求方式
????????args = {a=1,b=2}, -- get方式傳參數
})
POST請求格式:
local resp = ngx.location.capture("/item",{
????????method = ngx.HTTP_POST, -- 請求方式
????????body = "c=3&d=4", -- post方式傳參數
})
返回的響應內容包括:
????????resp.status:響應狀態碼;
????????resp.header:響應頭,是一個table;
????????resp.body:響應體,就是響應數據;
特別注意:
????????這里的/item是路徑,并不包含IP和端口(所以Nginx也不知道把這個請求發往哪里)這個請求會被nginx內部的server監聽。我們希望監聽到/item請求時將這個請求發送到Tomcat服務器所以需要編寫一個server用來監聽/item。當監聽到/item有請求時通過反向代理將請求代理到Tomcat服務器。
7.4.1.編寫server監聽/item路徑
# 監聽/item路徑,Nginx發送Http請求通過此路徑時將請求代理到Tomcat服務器
location /item {
? ? ? # 這里是windows電腦的ip和Java服務端口,需要確保windows防火墻處于關閉狀態
? ? ? proxy_pass http://192.168.3.36:8081;
}
7.4.2.封裝Http查詢的函數
????????我們可以把http查詢的請求封裝為一個函數,放到OpenResty函數庫中,方便后期使用。
1.在/usr/local/openresty/lualib目錄下創建common.lua文件
vim /usr/local/openresty/lualib/common.lua
2.在common.lua中封裝http查詢的函數(這里只封裝GET請求)
--?封裝函數,發送http請求,并解析響應 local function read_http(path, params) -- 定義函數名為read_http 參數為 path、paramslocal resp = ngx.location.capture(path,{ -- 發送Http請求method = ngx.HTTP_GET, -- 請求方式GETargs = params, -- 參數})if not resp then -- 判斷請求響應是否為空(resp為nil 或 false)--?為空 記錄nginx日志,返回404ngx.log(ngx.ERR, "http not found, path: ", path , ", args: ", args)ngx.exit(404) -- 狀態碼404endreturn resp.body -- 返回錯誤信息 end--?將方法導出 local _M = { read_http = read_http } return _M
3.修改item.lua文件使用上面封裝好的Http函數查詢數據
? ?編輯/usr/local/openresty/nginx/lua/item.lua腳本讓其通過Http向Tomcat發送請求查詢數據;
--?引入上面自定義工具模塊,不用寫工具模塊后綴(因為common.lua是放在/usr/local/openresty/lualib目錄下的所以該工具模塊不用寫全路徑,默認在lualib目錄中找) local common = require("common")-- 獲取自定義的Http工具類中的read_http函數 local read_http = common.read_http--?獲取路徑參數 local id = ngx.var[1] -- 形如:http://localhost:8081/item/10001 的請求路徑 獲取到的參數為10001-- lua腳本中字符串拼接使用"..",當id為10001時 如下"/item/".. id 拼接出來的路徑為/item/10001。因為參數在uri中,所以read_http函數第二個參數無需傳值,傳nil用于占位。根據id查詢庫存亦是如此--?根據id查詢商品 local itemJSON = read_http("/item/".. id, nil)--?根據id查詢商品庫存 local itemStockJSON = read_http("/item/stock/".. id, nil)
4.學習OpenResty的cjson模塊用于序列反序列化JSON數據
OpenResty的cjson模塊序列化反序列化Demo
-- 1.引入cjson模塊 local cjson = require "cjson"-- 數據準備(Lua table類型) local?obj?=?{name?=?'jack',age?=?21 } -- 2.將Lua table類型序列化為JSON local?json?=?cjson.encode(obj)-- 數據準備(Lua JSON字符串) local?json?=?'{"name":?"jack",?"age":?21}' -- 3.將Lua JSON字符串反序列化table類型 local?obj?=?cjson.decode(json); print(obj.name)
5.引入OpenResty的cjson模塊用于序列反序列化JSON數據
????????如上查詢到的商品、庫存是JSON數據我們需要將兩部分數據組裝,在Lua中我們無法直接對JSON數據進行操作設值,需要用到OpenResty的JSON處理函數庫cjson;
修改item.lua腳本將查詢到的商品和庫存信息拼接在一起
-- 引入上面自定義工具模塊,不用寫工具模塊后綴(因為common.lua是放在/usr/local/openresty/lualib目錄下的所以該工具模塊不用寫全路徑,默認在lualib目錄中>找) local common = require("common")-- 引入cjson模塊(OpenResty自帶模塊,直接引入即可) local cjson = require("cjson")-- 獲取自定義的Http工具類中的read_http函數 local read_http = common.read_http-- 獲取路徑參數 local id = ngx.var[1] -- 形如:http://localhost:8081/item/10001 的請求路徑 獲取到的參數為10001-- lua腳本中字符串拼接使用"..",當id為10001時 如下"/item/".. id 拼接出來的路徑為/item/10001。因為參數在uri中,所以read_http函數第二個參數無需傳值,傳nil用于占位。根據id查詢庫存亦是如此-- 根據id查詢商品 local itemJSON = read_http("/item/".. id, nil)-- 根據id查詢商品庫存 local itemStockJSON = read_http("/item/stock/".. id, nil)-- 反序列化商品數據(將商品itemJSON數據反序列化為Lua table類型) local item = cjson.decode(itemJSON)-- 反序列化庫存數據(將庫存itemStockJSON數據反序列化為Lua table類型) local stock = cjson.decode(itemStockJSON)-- 將查詢到的庫存數據設置到item商品數據中 item.stock = stock.stock item.sold = stock.sold-- 將Lua table類型item商品序列化為JSON對象 并返回 ngx.say(cjson.encode(item))
6.重啟Nginx
nginx -s relaod
7.5.本章配置供參考(已跑通)
1./usr/local/openresty/lualib/common.lua
-- 封裝函數,發送http請求,并解析響應 local function read_http(path, params) -- 定義函數名為read_http 參數為 path、paramslocal resp = ngx.location.capture(path,{ -- 發送Http請求method = ngx.HTTP_GET, -- 請求方式GETargs = params, -- 參數})if not resp then -- 判斷請求響應是否為空(resp為nil 或 false)-- 為空 記錄nginx日志,返回404ngx.log(ngx.ERR, "http not found, path: ", path , ", args: ", args)ngx.exit(404) -- 狀態碼404endreturn resp.body -- 返回錯誤信息 end-- 將方法導出 local _M = {read_http = read_http } return _M
2./usr/local/openresty/nginx/conf/nginx.conf
#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;# 加載lua 模塊lua_package_path "/usr/local/openresty/lualib/?.lua;;";# 加載c模塊lua_package_cpath "/usr/local/openresty/lualib/?.so;;";server {listen 8081;server_name localhost;location / {root html;index index.html index.htm;}# 使用正則匹配/api/item/...的參數值(注意location后面的~前后有空格)location ~ /api/item/(\d+) {# 響應類型,這里返回jsondefault_type application/json;#?響應數據由?lua/item.lua這個文件來決定(lua/item.lua默認在nginx目錄下查找)content_by_lua_file lua/item.lua;}# 監聽/item路徑,Nginx發送Http請求通過此路徑時將請求代理到Tomcat服務器location /item {# 這里是windows電腦的ip和Java服務端口,需要確保windows防火墻處于關閉狀態proxy_pass http://192.168.3.36:8081; }error_page 500 502 503 504 /50x.html;location = /50x.html {root html;}} }
3./usr/local/openresty/nginx/lua/item.lua
--?引入上面自定義工具模塊,不用寫工具模塊后綴(因為common.lua是放在/usr/local/openresty/lualib目錄下的所以該工具模塊不用寫全路徑,默認在lualib目錄中找) local common = require("common")-- 引入cjson模塊(OpenResty自帶模塊,直接引入即可) local cjson = require("cjson")-- 獲取自定義的Http工具類中的read_http函數 local read_http = common.read_http--?獲取路徑參數 local id = ngx.var[1] -- 形如:http://localhost:8081/item/10001 的請求路徑 獲取到的參數為10001-- lua腳本中字符串拼接使用"..",當id為10001時 如下"/item/".. id 拼接出來的路徑為/item/10001。因為參數在uri中,所以read_http函數第二個參數無需傳值,傳nil用于占位。根據id查詢庫存亦是如此--?根據id查詢商品 local itemJSON = read_http("/item/".. id, nil)--?根據id查詢商品庫存 local itemStockJSON = read_http("/item/stock/".. id, nil)-- 反序列化商品數據(將商品itemJSON數據反序列化為Lua table類型) local item = cjson.decode(itemJSON)-- 反序列化庫存數據(將庫存itemStockJSON數據反序列化為Lua table類型) local stock = cjson.decode(itemStockJSON)-- 將查詢到的庫存數據設置到item商品數據中 item.stock = stock.stock item.sold = stock.sold-- 將Lua table類型item商品序列化為JSON對象 并返回 ngx.say(cjson.encode(item))
7.6.測試截止目前為止已實現效果
1.訪問不同的商品ID,OpenResty發送http請求到Tomcat拿取不同的商品數據。
2.第一次訪問商品ID時會重數據庫中查詢商品信息返回給調用處。第二次攜帶相同商品ID訪問會重Java進程中獲取緩存數據不會重數據庫中查詢數據。
3.通過OpenResty發送http請求到Tomcat,OpenResty的item.lua腳本自動將商品和庫存信息組裝在一起。
1.啟動工程Java工程端口8081
2.啟動反向代理Nginx
3.啟動OpenResty下的Nginx
4.訪問商品
? ? ? ? 4.1.第一次訪問商品ID為10001數據
????????4.2.清空Java后臺第二次訪問商品ID為10001數據
????????4.3.換個商品第一次訪問商品ID為10002數據
????????4.4.清空Java后臺第二次訪問商品ID為10002數據
8.OpenResty中Nginx根據商品ID對Tomcat業務集群實現負載均衡
為什么要負載均衡?
? ? ? ? 生產環境中為了業務本身的健壯性,吞吐量需要對Tomcat業務實現集群進行負載均衡;
為什么需要根據商品ID對Tomcat業務集群實現負載均衡?
? ? ? ? 在前面我們已經實現了對商品、庫存數據進行JVM進程緩存。此時會有一些問題。多臺Tomcat服務器間的進程緩存沒有辦法共享,假如Tomcat業務集群數量有100臺默認采取輪詢的方式進行負載均衡,當第1次訪問id為10001的商品時訪問的是第1臺Tomcat服務器并在上面建立JVM進程緩存,第2次訪問id為10001的商品時訪問的是第2臺Tomcat服務器并在上面建立JVM進程緩存。當第100次訪問id為10001的商品時,前面100臺Tomcat服務器都會建立相同的JVM緩存.這樣緩存非常冗余和沒有必要的,浪費內存空間。我們希望當訪問id為10001時Nginx把請求分發到第1臺Tomcat服務器上去并建立進程緩存,后面訪問N次id為10001的商品Nginx仍然把請求分發到第1臺Tomcat服務器上去訪問第1臺Tomcat服務器上的進程緩存,這樣可以不用重復對不同的服務器建立相同的緩存。
8.1.實現流程
1.修改OpenResty下Nginx配置文件
#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;
? ? # 加載lua 模塊
? ? lua_package_path "/usr/local/openresty/lualib/?.lua;;";
? ? # 加載c模塊
? ? lua_package_cpath "/usr/local/openresty/lualib/?.so;;";
? ? # 添加Tomcat業務集群,采用hash算法對uri進行負載均衡
? ? upstream tomcat-cluster{
? ? ? ? hash $request_uri;
? ? ? ? server 192.168.3.36:8081;
? ? ? ? server 192.168.3.36:8082;
? ? }
? ? server {
? ? ? ? listen ? ? ? 8081;
? ? ? ? server_name ?localhost;
? ? ? ? location / {
? ? ? ? ? ? root ? html;
? ? ? ? ? ? index ?index.html index.htm;
? ? ? ? }
? ? ? ? # 使用正則匹配/api/item/...的參數值(注意location后面的~前后有空格)
? ? ? ? location ~ /api/item/(\d+) {
? ? ? ? ? ? # 響應類型,這里返回json
? ? ? ? ? ? default_type application/json;
? ? ? ? ? ? #?響應數據由?lua/item.lua這個文件來決定(lua/item.lua默認在nginx目錄下查找)
? ? ? ? ? ? content_by_lua_file lua/item.lua;
? ? ? ? }
? ? ? ? # 監聽/item路徑,Nginx發送Http請求通過此路徑時將請求代理到Tomcat服務器
? ? ? ? location /item {
? ? ? ? ? ? # 這里是windows電腦的ip和Java服務端口,需要確保windows防火墻處于關閉狀態
? ? ? ? ? ? proxy_pass http://tomcat-cluster;
? ? ? ? }
? ? ?
? ? ? ? error_page ? 500 502 503 504 ?/50x.html;
? ? ? ? location = /50x.html {
? ? ? ? ? ? root ? html;
? ? ? ? }
? ? }
}
2.重啟Nginx
nginx -s reload
3.開啟8081 8082兩個端口的業務工程
相同工程開啟多個端口
https://blog.csdn.net/qq_64734490/article/details/144052589
4.啟動8081 8082兩個工程
8.2.測試根據商品ID實現對Tomcat業務集群負載均衡
1.1.訪問id為10001的商品
1.2.清空8082服務器上日志再次訪問
2.1.訪問id為10002的商品
2.2.清空8081服務器上日志再次訪問
9.Redis緩存預熱
冷啟動與緩存預熱
冷啟動:
????????服務剛剛啟動時,Redis中并沒有緩存,如果所有商品數據都在第一次查詢時添加緩存,可能會給數據庫帶來較大壓力。
緩存預熱:
????????在實際開發中,我們可以利用大數據統計用戶訪問的熱點數據,在項目啟動時將這些熱點數據提前查詢并保存到Redis中。
在學習階段我們數據較少,在啟動時將所有數據放入Redis中。
實現流程
1.導入依賴
<!-- Redis依賴 --> <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-redis</artifactId> </dependency><!-- Hutool依賴 --> <dependency><groupId>cn.hutool</groupId><artifactId>hutool-all</artifactId><version>5.8.26</version> </dependency>
2.修改application.yml文件
spring:redis:host: 192.168.209.129port: 6379password: 123456database: 0
3.業務工程添加緩存預熱代碼
? ? ? ? 由于學習階段數據較少,我們將所有商品和庫存數據都放在Redis中。
import cn.hutool.json.JSONUtil; import com.heima.item.pojo.Item; import com.heima.item.pojo.ItemStock; import com.heima.item.service.IItemStockService; import com.heima.item.service.impl.ItemService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Component; import javax.annotation.PostConstruct; import java.util.List; /*** 緩存預熱*/ @Component public class RedisInitData {@Autowiredprivate ItemService itemService;@Autowiredprivate IItemStockService itemStockService;@Autowiredprivate StringRedisTemplate redisTemplate;/*** 初始化 商品、庫存數據到緩存*/@PostConstructpublic void initItemAndStock(){// 查詢所有商品數據List<Item> itemList = itemService.list();// 查詢所有庫存數據List<ItemStock> itemStockList = itemStockService.list();// 商品數據預熱寫入Redisfor (Item item : itemList) {redisTemplate.opsForValue().set("item:id:" + item.getId(), JSONUtil.toJsonPrettyStr(item));}// 庫存數據預熱寫入Redisfor (ItemStock stock : itemStockList) {redisTemplate.opsForValue().set("item:stock:id:" + stock.getId(), JSONUtil.toJsonPrettyStr(stock));}} }
4.啟動業務工程 商品 庫存數據均已寫入到Redis
10.OpenResty實現先查詢緩存若未命中則查詢Tomcat服務器數據
10.1.OpenResty連接操作Redis
????????OpenResty提供了操作Redis的模塊,我們只要引入該模塊就能操作Redis;
調整自定義的公共common.lua腳本在其中封裝如下功能
1.導入Redis模塊添加連接Redis代碼
-- 引入Redis模塊(resty是指在openresty安裝目錄下的lualib目錄下的resty目錄。redis是指在resty目錄下的redis.lua文件)
local redis = require("resty.redis")
--?初始化Redis對象
local red = redis:new()
--?設置Redis超時時間(形參分別為:建立連接超時時間 發送請求超時時間 響應結果超時時間) 單位都為毫秒
red:set_timeouts(1000, 1000, 1000)
2.釋放Redis連接
--?關閉redis連接的工具方法,其實是放入連接池(封裝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.向Redis發送請求查詢Redis數據代碼
--?查詢redis的方法?ip和port是redis地址,key是查詢的key(該函數只封裝僅支持Redis Value為字符串類型的數據)
local function read_redis(ip, port,password, key)--?獲取一個連接(返回兩個參數ok,err;ok代表Redis連接是否建立成功,err表示錯誤信息)local ok, err = red:connect(ip, port) if not ok then -- ok 返回為nil(Lua語法中nil代表false) 建立連接失敗ngx.log(ngx.ERR, "連接redis失敗 : ", err)return nilend-- 指定Redis密碼local res, err = red:auth(password)if not res thenngx.log(ngx.ERR, "連接redis失敗 密碼錯誤!: ", err)return nilend--?查詢redis(該函數只封裝僅支持Redis Value為字符串類型的數據)local resp, err = red:get(key) -- 指定key獲取查詢數據--?查詢失敗處理if not resp then -- 響應nil(記錄日志)ngx.log(ngx.ERR, "查詢Redis失敗: ", err, ", key = " , key)end--得到的數據為空處理(即通過Key未查詢到緩存中數據[緩存數據不存在])if resp == ngx.null thenresp = nilngx.log(ngx.ERR,"查詢Redis數據為空, key = ",key)endclose_redis(red) -- 將Redis連接放入連接池中return resp -- 返回結果
end
4.對外暴露封裝好的查詢緩存數據方法read_redis
--?將方法導出
local _M = { read_http = read_http, -- 對外暴露發送http請求方法read_redis = read_redis -- 對外暴露連接Redis查詢數據方法
}
return _M
完整common.lua腳本內容(供參考)
-- 引入Redis模塊(resty是指在openresty安裝目錄下的lualib目錄下的resty目錄。redis是指在resty目錄下的redis.lua文件)
local redis = require("resty.redis")
--?初始化Redis對象
local red = redis:new()
--?設置Redis超時時間(形參分別為:建立連接超時時間 發送請求超時時間 響應結果超時時間) 單位都為毫秒
red:set_timeouts(1000, 1000, 1000)--?關閉redis連接的工具方法,其實是放入連接池(封裝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--?查詢redis的方法?ip和port是redis地址,key是查詢的key(該函數只封裝僅支持Redis Value為字符串類型的數據)
local function read_redis(ip, port,password, key)--?獲取一個連接(返回兩個參數ok,err;ok代表Redis連接是否建立成功,err表示錯誤信息)local ok, err = red:connect(ip, port) if not ok then -- ok 返回為nil(Lua語法中nil代表false) 建立連接失敗ngx.log(ngx.ERR, "連接redis失敗 : ", err)return nilend-- 指定Redis密碼local res, err = red:auth(password)if not res thenngx.log(ngx.ERR, "連接redis失敗 密碼錯誤!: ", err)return nilend--?查詢redis(該函數只封裝僅支持Redis Value為字符串類型的數據)local resp, err = red:get(key) -- 指定key獲取查詢數據--?查詢失敗處理if not resp then -- 響應nil(記錄日志)ngx.log(ngx.ERR, "查詢Redis失敗: ", err, ", key = " , key)end--得到的數據為空處理(即通過Key未查詢到緩存中數據[緩存數據不存在])if resp == ngx.null thenresp = nilngx.log(ngx.ERR,"查詢Redis數據為空, key = ",key)endclose_redis(red) -- 將Redis連接放入連接池中return resp -- 返回結果
end--?封裝函數,發送http請求,并解析響應
local function read_http(path, params) -- 定義函數名為read_http 參數為 path、paramslocal resp = ngx.location.capture(path,{ -- 發送Http請求method = ngx.HTTP_GET, -- 請求方式GETargs = params, -- 參數})if not resp then -- 判斷請求響應是否為空(resp為nil 或 false)--?為空 記錄nginx日志,返回404ngx.log(ngx.ERR, "http not found, path: ", path , ", args: ", args)ngx.exit(404) -- 狀態碼404endreturn resp.body -- 返回錯誤信息
end--?將方法導出
local _M = { read_http = read_http, -- 對外暴露發送http請求方法read_redis = read_redis -- 對外暴露連接Redis查詢數據方法
}
return _M
10.2.修改item.lua
1.修改item.lua封裝一個read_data函數實現先查詢Redis,如果未命中再查詢tomcat
-- 獲取封裝好的Redis工具類中的read_redis函數(item文件中前面已經引入了common.lua模塊故此直接使用common對象獲取read_redis方法)
local read_redis = common.read_redis-- 封裝一個read_data函數實現先查詢Redis,如果未命中再查詢tomcat
local function read_data(key,path,params)-- 查詢Redislocal resp = read_redis("192.168.209.129",6379,"123456",key)if not resp then -- 判斷Redis是否命中ngx.log(ngx.ERR,"Redis查詢失敗或未查詢到數據,嘗試發送http查詢Tomcat數據,Key:",key)-- Redis查詢失敗或未查詢到數據,發送Http查詢Tomcat服務器數據resp = read_http(path,params)end return resp
end
2.查詢商品和庫存時都調用read_data這個函數
--?根據id查詢商品
local itemJSON = read_data("item:id:".. id ,"/item/".. id, nil)--?根據id查詢商品庫存
local itemStockJSON = read_data("item:stock:id:".. id,"/item/stock/".. id, nil)
完整item.lua腳本內容(供參考)
--?引入上面自定義工具模塊,不用寫工具模塊后綴(因為common.lua是放在/usr/local/openresty/lualib目錄下的所以該工具模塊不用寫全路徑,默認在lualib目錄中找)
local common = require("common")
-- 引入cjson模塊(OpenResty自帶模塊,直接引入即可)
local cjson = require("cjson")-- 獲取自定義的Http工具類中的read_http函數
local read_http = common.read_http
-- 獲取封裝好的Redis工具類中的read_redis函數
local read_redis = common.read_redis-- 封裝一個read_data函數實現先查詢Redis,如果未命中再查詢tomcat
local function read_data(key,path,params)-- 查詢Redislocal resp = read_redis("192.168.209.129",6379,"123456",key)if not resp then -- 判斷Redis是否命中ngx.log(ngx.ERR,"Redis查詢失敗或未查詢到數據,嘗試發送http查詢Tomcat數據,Key:",key)-- Redis查詢失敗或未查詢到數據,發送Http查詢Tomcat服務器數據resp = read_http(path,params)end return resp
end--?獲取路徑參數
local id = ngx.var[1] -- 形如:http://localhost:8081/item/10001 的請求路徑 獲取到的參數為10001-- lua腳本中字符串拼接使用"..",當id為10001時 如下"/item/".. id 拼接出來的路徑為/item/10001。因為參數在uri中,所以read_http函數第二個參數無需傳值,傳nil用于占位。根據id查詢庫存亦是如此
--?根據id查詢商品
local itemJSON = read_data("item:id:".. id ,"/item/".. id, nil)--?根據id查詢商品庫存
local itemStockJSON = read_data("item:stock:id:".. id,"/item/stock/".. id, nil)-- 反序列化商品數據(將商品itemJSON數據反序列化為Lua table類型)
local item = cjson.decode(itemJSON)-- 反序列化庫存數據(將庫存itemStockJSON數據反序列化為Lua table類型)
local stock = cjson.decode(itemStockJSON)-- 將查詢到的庫存數據設置到item商品數據中
item.stock = stock.stock
item.sold = stock.sold-- 將Lua table類型item商品序列化為JSON對象 并返回
ngx.say(cjson.encode(item))
10.3.測試OpenResty先查Redis緩存再查Tomcat服務器
1.前面學習緩存數據預熱時已將所有的商品和庫存數據寫入到了緩存中
2.關閉8081 8082兩臺Tomcat服務器
3.訪問商品界面
? ? ? ? 本次是重Redis中獲取的商品庫存數據。
4.啟動Tomcat服務器訪問一個不存在的商品ID看本次是否重Tomcat服務器中查詢
? ? ? ? 由前面可知Redis中存放的商品ID是重10001 ~ 10005。此時我們訪問一個緩存中不存在的Key。此時這個請求會被轉發到Tomcat服務器中先去進程緩存中查找再去數據庫中查找。
4.1.訪問ID為10008的商品(該商品在Redis和數據庫中都不存在)
? ? ? ? 本次請求之所以會響應500是因為在后臺服務器中將查詢為空的null值直接返回;并沒有返回一個JSON格式的數據;
4.2.該請求被分發到Tomcat端口為8082服務器中去數據庫中查詢數據
11.Nginx本地緩存
? ? ? ? 1.Nginx本地緩存更適用于處理那些對時效性要求不高的數據。比如用做熱點數據續期(如微博熱搜);
? ? ? ? 2.Nginx本地緩存只會在同一個進程中的多個worker間共享(多個Nginx集群節點間不共享數據)若Nginx是集群為避免在集群節點上緩存相同的數據,在Nginx反向代理路由到Nginx集群時考慮根據uri進行路由。
多級緩存流程:
? ? ? ? 1.客戶端訪問瀏覽器緩存;(一級緩存_瀏覽器緩存)
? ? ? ? 2.客戶端請求被Nginx反向代理到OpenResty集群。請求先去Nginx本地緩存中查找數據;
? ? ? ? ? ?(二級緩存_Nginx本地緩存)
? ? ? ? 3.Nginx本地緩存未查找到數據 訪問Redis去Redis中獲取數據;(三級緩存_Redis)
? ? ? ? 4.Redis未命中數據則發送Http到Tomcat服務器;
? ? ? ? 5.Tomcat服務器先重JVM進程緩存中查找數據;(四級緩存_JVM進程緩存)
? ? ? ? 6.JVM進程緩存未命中數據,則重數據庫中查詢數據并返回;
11.1.Nginx本地緩存初體驗
????????OpenResty為Nginx提供了shard dict的功能,可以在nginx的多個worker之間共享數據,實現緩存功能。
1.開啟共享字典,在nginx.conf下的http中添加
?#?共享字典,也就是本地緩存,自定義名稱為:item_cache,大小150mlua_shared_dict?item_cache?150m;
2.操作共享字典
--?獲取Nginx本地緩存對象 local?item_cache?=?ngx.shared.item_cache --?存儲,?指定key、value、過期時間(到期自動刪除Nginx本地緩存),單位s,默認為0代表永不過期 item_cache:set('key',?'value',?1000) --?讀取 local?val?=?item_cache:get('key')
11.2.修改item.lua中查詢商品庫存邏輯
? ? ? ? 由原先查詢Redis未命中再查詢Tomcat服務器。變更為 先查詢Nginx本地緩存,Nginx本地緩存未命中則查詢Redis,Redis未命中則查詢Tomcat服務器;
實現需求
? ? ? ? 1.修改item.lua中的read_data函數,優先查詢Nginx本地緩存,未命中時再查詢Redis、Tomcat;
? ? ? ? 2.查詢Redis或Tomcat成功后,將數據寫入本地緩存,并設置有效期;
? ? ? ? 3.商品基本信息,有效期30分鐘;
? ? ? ? 4.庫存信息,有效期5秒;
特別說明:
? ? ? ? 在實際生產開發中,如遇“商品秒殺”功能對于庫存數據的緩存可重這幾個方面考慮;
? ? ? ? 1.對于庫存數據及一些變動很快的數據 不建議對這部分數據添加Nginx本地緩存;
? ? ? ? 2.對于這部分數據若添加本地緩存,可考慮將Nginx本地緩存有效期縮短比如設置庫存有
????????????效期為5秒。比如出現如下場景,數據庫中商品庫存已為0,但是Nginx本地緩存未及
????????????時同步,這時用戶仍然可以通過瀏覽器去下單。當這個下單請求到達Tomcat服務器
????????????時,從后臺拿取出數據庫中的庫存 判斷此次下單是否有效。若無效返回客戶端"活動
? ? ? ? ? ? 火爆,請稍后再試!",此時Nginx本地緩存中的庫存因為鍵過期重新去構建Nginx本
? ? ? ? ? ? 地緩存數據,此時庫存數據已被更新為0;
演示對商品及庫存添加Nginx本地緩存
1.在nginx.conf下的http中添加開啟“共享字典”
#?共享字典(Nginx本地緩存),自定義共享字典名稱為:item_cache,大小150mlua_shared_dict item_cache 150m;
2.修改item.lua中read_data函數實現優先查詢Nginx本地緩存,未命中時再查詢Redis、Tomcat;
-- 導入共享詞典(Nginx本地緩存) local item_cache = ngx.shared.item_cache-- 封裝一個read_data函數實現先查詢Redis,如果未命中再查詢tomcat local function read_data(key,expire,path,params)-- 讀取Nginx本地緩存(item_cache為在nginx.conf中定義的"共享字典"名稱)local val = item_cache:get(key)-- Nginx本地緩存未命中 查詢if not val then ngx.log(ngx.ERR,'Nginx本地緩存未命中 Key:',key,",嘗試Redis查詢..") -- 查詢Nginx本地緩存記錄日志val = read_redis("192.168.209.129",6379,"123456",key)if not val then ngx.log(ngx.ERR,'Redis緩存未命中 Key:',key,",嘗試Http查詢..")val = read_http(path,params) -- 未處理 http查詢不到的情況end end-- 刷新Nginx本地緩存(內容 或 過期時間)item_cache:set(key,val,expire)return val end--?獲取路徑參數 local id = ngx.var[1]--?根據id查詢商品(設置Nginx本地緩存過期時間為1800秒) local itemJSON = read_data("item:id:".. id,1800 ,"/item/".. id, nil) --?根據id查詢商品庫存(設置Nginx本地緩存過期時間為5秒) local itemStockJSON = read_data("item:stock:id:".. id,5,"/item/stock/".. id, nil)
修改后nginx.conf文件(供參考)
#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;
? ? # 加載lua 模塊
? ? lua_package_path "/usr/local/openresty/lualib/?.lua;;";
? ? # 加載c模塊
? ? lua_package_cpath "/usr/local/openresty/lualib/?.so;;";
? ? #?共享字典(Nginx本地緩存),自定義共享字典名稱為:item_cache,大小150m
? ? lua_shared_dict item_cache 150m;
? ? # 添加Tomcat業務集群,采用hash算法對uri進行負載均衡
? ? upstream tomcat-cluster{
? ? ? ? hash $request_uri;
? ? ? ? server 192.168.3.36:8081;
? ? ? ? server 192.168.3.36:8082;
? ? }
? ? server {
? ? ? ? listen ? ? ? 8081;
? ? ? ? server_name ?localhost;
? ? ? ? location / {
? ? ? ? ? ? root ? html;
? ? ? ? ? ? index ?index.html index.htm;
? ? ? ? }? ? ? ? # 使用正則匹配/api/item/...的參數值(注意location后面的~前后有空格)
? ? ? ? location ~ /api/item/(\d+) {
? ? ? ? ? ? # 響應類型,這里返回json
? ? ? ? ? ? default_type application/json;
? ? ? ? ? ? #?響應數據由?lua/item.lua這個文件來決定(lua/item.lua默認在nginx目錄下查找)
? ? ? ? ? ? content_by_lua_file lua/item.lua;
? ? ? ? }? ? ? ? # 監聽/item路徑,Nginx發送Http請求通過此路徑時將請求代理到Tomcat服務器
? ? ? ? location /item {
?? ? ? ? ? ?# 這里是windows電腦的ip和Java服務端口,需要確保windows防火墻處于關閉狀態
?? ? ? ? ? ?proxy_pass http://tomcat-cluster;
? ? ? ? }
? ? ?? ? ? ? error_page ? 500 502 503 504 ?/50x.html;
? ? ? ? location = /50x.html {
? ? ? ? ? ? root ? html;
? ? ? ? }
? ? }
}
修改后的item.lua腳本內容(供參考)
--?引入上面自定義工具模塊,不用寫工具模塊后綴(因為common.lua是放在/usr/local/openresty/lualib目錄下的所以該工具模塊不用寫全路徑,默認在lualib目錄中找)
local common = require("common")
-- 引入cjson模塊(OpenResty自帶模塊,直接引入即可)
local cjson = require("cjson")
-- 獲取自定義的Http工具類中的read_http函數
local read_http = common.read_http
-- 獲取封裝好的Redis工具類中的read_redis函數
local read_redis = common.read_redis
-- 導入共享詞典(Nginx本地緩存)
local item_cache = ngx.shared.item_cache
-- 封裝一個read_data函數實現先查詢Redis,如果未命中再查詢tomcat
local function read_data(key,expire,path,params)
? ? ? ? -- 讀取Nginx本地緩存(item_cache為在nginx.conf中定義的"共享字典"名稱)
? ? ? ? local val = item_cache:get(key)
? ? ? ? -- Nginx本地緩存未命中 查詢
? ? ? ? if not val then
? ? ? ? ? ? ngx.log(ngx.ERR,'Nginx本地緩存未命中 Key:',key,",嘗試Redis查詢..") -- 查詢Nginx本地緩存記錄日志
? ? ? ? ? ? val = read_redis("192.168.209.129",6379,"123456",key)
? ? ? ? ? ? if not val then
? ? ? ? ? ? ? ? ngx.log(ngx.ERR,'Redis緩存未命中 Key:',key,",嘗試Http查詢..")
? ? ? ? ? ? ? ? val = read_http(path,params) -- 未處理 http查詢不到的情況
? ? ? ? ? ? end ? ?
? ? ? ? end
? ? ? ? -- 刷新Nginx本地緩存(內容 或 過期時間)
? ? ? ? item_cache:set(key,val,expire)
? ? ? ? return val
end
?--?獲取路徑參數
local id = ngx.var[1] -- 形如:http://localhost:8081/item/10001 的請求路徑 獲取到的參數為10001
-- lua腳本中字符串拼接使用"..",當id為10001時 如下"/item/".. id 拼接出來的路徑為/item/10001。因為參數在uri中,所以read_http函數第二個參數無需傳值,傳nil用于占位。根據id查詢庫存亦是如此
--?根據id查詢商品(設置Nginx本地緩存過期時間為1800秒)
local itemJSON = read_data("item:id:".. id,1800 ,"/item/".. id, nil)
--?根據id查詢商品庫存(設置Nginx本地緩存過期時間為5秒)
local itemStockJSON = read_data("item:stock:id:".. id,5,"/item/stock/".. id, nil)
-- 反序列化商品數據(將商品itemJSON數據反序列化為Lua table類型)
local item = cjson.decode(itemJSON)
-- 反序列化庫存數據(將庫存itemStockJSON數據反序列化為Lua table類型)
local stock = cjson.decode(itemStockJSON)
-- 將查詢到的庫存數據設置到item商品數據中
item.stock = stock.stock
item.sold = stock.sold
-- 將Lua table類型item商品序列化為JSON對象 并返回
ngx.say(cjson.encode(item))
11.3.測試訪問Nginx本地緩存
1.重啟Nginx
nginx -s reload
2.進入nginx下的logs目錄查看error.log日志輸出
tail -f error.log
3.訪問商品界面
? ? ? ? 由前可知Nginx的本地緩存會在第一次訪問商品時建立,例如第一次訪問ID為10001的商品,會重Redis中加載數據到Nginx本地緩存。當第二次訪問ID為10001的商品時會重Nginx本地緩存中加載數據。
3.1.第一次訪問ID為10001商品
3.2.第二次訪問ID為10001商品
? ? ? ? 第二次訪問,因為商品數據設置的Nginx本地緩存過期時間為30分鐘,庫存數據設置的Nginx本地緩存過期時間為5秒。所以在第二次訪問時庫存數據是重Redis中重新獲取的,而商品數據是重Nginx本地緩存中獲取的。
12.緩存同步
? ? ? ? 前面已實現對商品庫存的多級緩存。現衍生出另外一個需要解決的問題,如何保證數據庫中數據與Redis緩存中數據的一致性。比如數據庫中商品庫存數據變更時我希望有一個中間件能自動將數據庫中變更后的數據自動同步到Redis緩存中去。
常見的緩存同步策略:
? ? ? ? 1.設置有效期:給緩存設置有效期,到期后自動刪除。再次查詢時更新
????????????????優勢:簡單、方便;
????????????????缺點:時效性差,緩存過期之前可能不一致;
????????????????場景:更新頻率較低,時效性要求低的業務;
? ? ? ? 2.同步雙寫:在修改數據庫的同時,直接修改緩存
????????????????優勢:時效性強,緩存與數據庫強一致;
????????????????缺點:有代碼侵入,耦合度高;
????????????????場景:對一致性、時效性要求較高的緩存數據;
? ? ? ? 3.異步通知:修改數據庫時發送事件通知,相關服務監聽到通知后修改緩存數據
????????????????優勢:低耦合,可以同時通知多個緩存服務;
????????????????缺點:時效性一般,可能存在中間不一致狀態;
????????????????場景:時效性要求一般,有多個服務需要同步;
異步通知方案一:
? ? ? ? 基于MQ
異步通知方案二:
? ? ? ? 基于Canal
12.1.基于Canal完成數據庫與緩存數據的一致性
????????Canal譯意為水道/管道/溝渠,canal是阿里巴巴旗下的一款開源項目,基于Java開發。基于數據庫增量日志解析,提供增量數據訂閱&消費。Canal是基于mysql的主從同步來實現的,MySQL主從同步的原理如下:
主從復制步驟:
1.將Master的binary-log日志文件打開,mysql會把所有的DDL,DML,TCL寫入BinaryLog日志文件中;
2.Master會生成一個 log dump 線程,用來給從庫的 i/o線程傳binlog;
3.從庫的i/o線程去請求主庫的binlog,并將得到的binlog日志寫到中繼日志(relaylog)中;
4.從庫的sql線程,會讀取relaylog文件中的日志,并解析成具體操作,通過主從的操作一致,而達到最終數據一致而Canal的原理就是偽裝成Slave從Binlog中復制SQL語句或者數據;
12.2.Canal實現Mysql Redis數據同步流程
? ? ? ? Canal版本與Mysql版本有對照關系。如果使用高版本Mysql如8.0.28,Canal使用1.1.5則在Canal的日志里會出現如下警告。此時換個Canal的版本如1.1.8,Mysql仍使用8.0.28則會解決此問題;
[MultiStageCoprocessor-other-example-0] WARN com.taobao.tddl.dbsync.binlog.LogDecoder - Skipping unrecognized binlog event Unknown from: canal-mysql-bin.000001:2663
Canal下載地址
Canal下載地址https://github.com/alibaba/canal/releases
Mysql部分
1.開啟Mysql主從
????????因為Canal是通過偽裝成slave去獲取Mysql主節點數據,所以第一步開啟Mysql主從;
1.1.修改Mysql的conf目錄下的my.cnf文件
在[mysqld]層級下添加如下兩行代碼
# 指定binlog存放的位置D:\software\Mysql\mysql-8.0.28-winx64\canal-binlog-data\以及binlog文件的名稱為canal-mysql-bin(自定義文件名) log-bin=D://software/Mysql/mysql-8.0.28-winx64/canal-binlog-data/canal-mysql-bin # 指定對heima這個數據庫記錄binlog日志 binlog-do-db=heima
1.2.重啟Mysql后使用show master status命令查看開啟Mysql主從狀態
使用show variables like '%log_bin%'命令檢查Mysql是否開啟主從同步
進入canal-binlog-data目錄中可以看到binlog日志存放在此目錄中
1.3.出于安全考慮我們創建一個名為canal的Mysql用戶專門用于數據同步(不使用root用戶)
#創建用戶cannal 密碼 canal CREATE USER canal IDENTIFIED BY 'canal'; #把所有權限賦予canal,密碼也是canal GRANT SELECT, REPLICATION SLAVE, REPLICATION CLIENT,SUPER ON *.* TO 'canal'@'%'; #刷新權限 flush privileges;
Canal部分
1.安裝Canal
? ? ? ? 安裝Canal前先安裝JDK Canal需要使用JDK;
1.1.修改Canal安裝目錄下conf/example目錄中的instance.properties文件修改內容如下
############### instance.properties完整配置文件內容(標紅部分為需要確認信息)?
#################################################
## mysql serverId , v1.0.26+ will autoGen
# canal.instance.mysql.slaveId=0# enable gtid use true/false
canal.instance.gtidon=false# rds oss binlog
canal.instance.rds.accesskey=
canal.instance.rds.secretkey=
canal.instance.rds.instanceId=
# position info(Mysql主節點信息)
canal.instance.master.address=192.168.3.36:3306
canal.instance.master.journal.name=
canal.instance.master.position=
canal.instance.master.timestamp=
canal.instance.master.gtid=# multi stream for polardbx
canal.instance.multi.stream.on=false# ssl
#canal.instance.master.sslMode=DISABLED
#canal.instance.master.tlsVersions=
#canal.instance.master.trustCertificateKeyStoreType=
#canal.instance.master.trustCertificateKeyStoreUrl=
#canal.instance.master.trustCertificateKeyStorePassword=
#canal.instance.master.clientCertificateKeyStoreType=
#canal.instance.master.clientCertificateKeyStoreUrl=
#canal.instance.master.clientCertificateKeyStorePassword=# table meta tsdb info
canal.instance.tsdb.enable=true
#canal.instance.tsdb.url=jdbc:mysql://127.0.0.1:3306/canal_tsdb
#canal.instance.tsdb.dbUsername=canal
#canal.instance.tsdb.dbPassword=canal#canal.instance.standby.address =
#canal.instance.standby.journal.name =
#canal.instance.standby.position =
#canal.instance.standby.timestamp =
#canal.instance.standby.gtid=# username/password(數據庫賬密)
canal.instance.dbUsername=canal
canal.instance.dbPassword=canal
canal.instance.connectionCharset = UTF-8
# enable druid Decrypt database password
canal.instance.enableDruid=false
#canal.instance.pwdPublicKey=MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBALK4BUxdDltRRE5/zXpVEVPUgunvscYFtEip3pmLlhrWpacX7y7GCMo2/JM6LeHmiiNdH1FWgGCpUfircSwlWKUCAwEAAQ==?table regex
# 指定讀取heima數據庫中的所有表
canal.instance.filter.regex=heima\\..*
# table black regex
canal.instance.filter.black.regex=mysql\\.slave_.*
# table field filter(format: schema1.tableName1:field1/field2,schema2.tableName2:field1/field2)
#canal.instance.filter.field=test1.t_product:id/subject/keywords,test2.t_company:id/name/contact/ch
# table field black filter(format: schema1.tableName1:field1/field2,schema2.tableName2:field1/field2)
#canal.instance.filter.black.field=test1.t_product:subject/product_image,test2.t_company:id/name/contact/ch
# mq config
canal.mq.topic=example
# dynamic topic route by schema or table regex
#canal.mq.dynamicTopic=mytest1.user,topic2:mytest2\\..*,.*\\..*
canal.mq.partition=0
# hash partition config
#canal.mq.enableDynamicQueuePartition=false
#canal.mq.partitionsNum=3
#canal.mq.dynamicTopicPartitionNum=test.*:4,mycanal:6
#canal.mq.partitionHash=test.table:id^name,.*\\..*
#################################################
?2.啟動Canal
1.進入Canal的bin目錄啟動Canal
./startup.sh
2.jps命令查看Canal
3.進入Canal日志目錄查看canal和example兩個目錄中的日志可知Canal已啟動成功
logs/canal/canal.log
logs/example/example.log
Tomcat服務器部分
? ? ? ? 由上可知Canal與Mysql已成功建立連接,接下來只需要我們在Tomcat服務器中編碼,重Canal中拿取到數據表內容變更再相應的對Redis中數據進行更新即可完成數據庫、緩存數據同步;
????????Canal提供了各種語言的客戶端,當Canal監聽到binlog變化時,會通知Canal的客戶端。這里我們會使用GitHub上的第三方開源的canal-starter。
地址:https://github.com/NormanGyllenhaal/canal-client
1.導入Maven依賴
<!-- Canal依賴 --> <dependency><groupId>top.javatool</groupId><artifactId>canal-spring-boot-starter</artifactId><version>1.2.1-RELEASE</version> </dependency>
2.修改application.yml配置
# Canal配置 canal:destination: example # canal 默認實例名稱為example,該實例名稱可以在canal.properties中修改server: 192.168.209.129:11111 # Canal Server默認端口為11111
3.添加Redis序列化配置文件(避免緩存key亂碼)
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; import org.springframework.data.redis.serializer.RedisSerializer; import org.springframework.data.redis.serializer.StringRedisSerializer; /*** 序列化工具*/ @Configuration public class RedisConfig {@Beanpublic RedisTemplate<String,Object> redisTemplate(RedisConnectionFactory connectionFactory){// 創建RedisTemplate對象RedisTemplate<String,Object> template = new RedisTemplate();// 設置連接工廠template.setConnectionFactory(connectionFactory);// 創建Json序列化工具GenericJackson2JsonRedisSerializer jsonRedisSerializer = new GenericJackson2JsonRedisSerializer();StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();// 設置Key的序列化template.setKeySerializer(RedisSerializer.string());template.setHashKeySerializer(RedisSerializer.string());// 設置Value的序列化template.setValueSerializer(jsonRedisSerializer);// 設置Hash采用String的方式序列化Valuetemplate.setHashValueSerializer(stringRedisSerializer);return template;} }
4.表實體字段映射
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 java.util.Date; /*** 特別說明:* Canal不依賴Mybatis,所以不能使用Mybatis相關的注解 來指定“主鍵”、“字段值不存在”;* 如下:* 1.指定主鍵使用 @Id注解;* 2.不存在的數據庫字段使用 @Transient注解;* 3.當數據庫字段名與實體屬性名不一致時使用 @Column(name = "Xxx")注解映射;*/ @Data @TableName("tb_item") public class Item {@TableId(type = IdType.AUTO)@Id /** 標記表中的主鍵字段(Canal需要) */private Long id;//商品idprivate String name;//商品名稱private String title;//商品標題private Long price;//價格(分)private String image;//商品圖片private String category;//分類名稱private String brand;//品牌名稱private String spec;//規格private Integer status;//商品狀態 1-正常,2-下架private Date createTime;//創建時間(數據庫中字段為 create_time實體轉換時自動駝峰)private Date updateTime;//更新時間(數據庫中字段為 update_time實體轉換時自動駝峰)@TableField(exist = false)@Transient /** (Canal需要數據庫中不存在此字段使用@Transient注解) */private Integer stock;@TableField(exist = false)@Transient /** (Canal需要數據庫中不存在此字段使用@Transient注解) */private Integer sold; }
5.編寫Canal監聽類用于數據庫數據變動時同步緩存
import cn.hutool.json.JSONUtil; import com.github.benmanes.caffeine.cache.Cache; import com.heima.item.pojo.Item; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Component; import top.javatool.canal.client.annotation.CanalTable; import top.javatool.canal.client.handler.EntryHandler; /*** @description: 編寫Canal監聽類 用于做數據同步*/ @Component @CanalTable(value = "tb_item") // 編寫Canal監聽的表名 public class CanalHandler implements EntryHandler<Item> {@Autowiredprivate StringRedisTemplate redisTemplate;@Autowiredprivate Cache<Long,Item> itemCache;/*** 當tb_item表執行“新增”操作時會調用此方法。可在此方法中對Redis做新增操作;* */@Overridepublic void insert(Item item) {System.err.println("INSERT " + JSONUtil.toJsonStr(item));// 寫數據到JVM進程緩存itemCache.put(item.getId(),item);// 新增數據到RedisredisTemplate.opsForValue().set("item:id:" + item.getId(), JSONUtil.toJsonStr(item));}/*** 當tb_item表執行“更新”操作時會調用此方法。可在此方法中對Redis做覆蓋操作;* TODO 更新JVM進程緩存(Tomcat集群時如何找到當前JVM緩存數據是存放在哪臺服務器上的?)* */@Overridepublic void update(Item before, Item after) {System.err.println("UPDATE " + JSONUtil.toJsonStr(after));// 更新JVM進程緩存itemCache.put(after.getId(),after);// 更新Redis緩存redisTemplate.opsForValue().set("item:id:" + after.getId(), JSONUtil.toJsonStr(after));}/*** 當tb_item表執行“刪除”操作時會調用此方法。可在此方法中對Redis做刪除操作;* 問題描述:* 后端有 Nginx本地緩存、Redis緩存、JVM進程緩存。各緩存間是如何更新刷新的?* Canal監聽到Mysql數據被刪除時* 1.Nginx本地緩存通過設置合理的過期時間控制緩存的刷新(會存在緩存不一致的情況,可在網上自行尋找解決方案),暫不考慮通過代碼的方式去同步更新Nginx本地緩存;* 2.JVM進程緩存通過設置合理的過期時間控制緩存的刷新(會存在緩存不一致的情況,可在網上自行尋找解決方案),且由前面可知在業務Nginx中查詢商品接口/item/{id}根據* uri做負載均衡(相同商品ID的請求路由到同一臺Tomcat服務器做JVM進程緩存)避免多臺Tomcat服務器緩存相同的JVM進程數據。* 這樣存在一個其它問題在刪除或更新數據庫時如何找到更新的商品ID存放在哪臺服務器中?需要在對應的Tomcat服務器中刪除或更新* JVM進程緩存;* 3.Redis緩存通過Canal直接完成數據庫與Redis緩存的同步;* TODO 刪除JVM進程緩存(Tomcat集群時如何找到當前JVM緩存數據是存放在哪臺服務器上的?)* */@Overridepublic void delete(Item item) {System.err.println("DELETE " + JSONUtil.toJsonStr(item));// 刪除JVM進程緩存// TODO itemCache.invalidate(item.getId());// 刪除Redis緩存redisTemplate.delete("item:id:" + item.getId());} }
6.測試手動更新數據庫查看Redis緩存中的數據是否同步更新
1.修改ID為10001商品的名稱
2.Tomcat服務器
????????8081服務器執行變更Redis緩存數據請求;
3.查看Redis數據
補充說明
? ? ? ? 多臺Canal客戶端(Tomcat服務器集成Canal),當數據庫數據變更時Canal會把變更的數據推送給多臺Canal客戶端中的一臺。不會所有的Canal客戶端都推送,這樣避免了多臺Canal服務器都去更新Redis緩存;