微服務(wù)實(shí)戰(zhàn)項(xiàng)目-學(xué)成在線(xiàn)-項(xiàng)目?jī)?yōu)化(redis緩存優(yōu)化)
1 優(yōu)化需求
視頻播放頁(yè)面用戶(hù)未登錄也可以訪(fǎng)問(wèn),當(dāng)用戶(hù)觀(guān)看試學(xué)課程時(shí)需要請(qǐng)求服務(wù)端查詢(xún)數(shù)據(jù),接口如下:
1、根據(jù)課程id查詢(xún)課程信息。
2、根據(jù)文件id查詢(xún)視頻信息。
這些接口在用戶(hù)未認(rèn)證狀態(tài)下也可以訪(fǎng)問(wèn),如果接口的性能不高,當(dāng)高并發(fā)到來(lái)很可能耗盡整個(gè)系統(tǒng)的資源,將整個(gè)系統(tǒng)壓垮,所以特別需要對(duì)這些暴露在外邊的接口進(jìn)行優(yōu)化。
下邊對(duì) 根據(jù)課程id查詢(xún)課程信息
接口進(jìn)行優(yōu)化,下邊的內(nèi)容將此接口簡(jiǎn)稱(chēng)為課程查詢(xún)接口。
接口地址:http://www.51xuecheng.cn/open/content/course/whole/{courseId}
2 壓力測(cè)試
2.1 性能指標(biāo)
對(duì)接口進(jìn)行優(yōu)化之前需要對(duì)接口進(jìn)行壓力測(cè)試,不僅接口需要壓力測(cè)試,整個(gè)微服務(wù)在發(fā)布前也是需要經(jīng)歷壓力測(cè)試的,因?yàn)閴毫y(cè)試可以暴露功能測(cè)試所發(fā)現(xiàn)不了的問(wèn)題。
功能測(cè)試即是對(duì)系統(tǒng)的功能按用戶(hù)需求進(jìn)行測(cè)試,比如:添加一門(mén)課程,根據(jù)需求文檔先準(zhǔn)備測(cè)試數(shù)據(jù),再通過(guò)前端界面將一門(mén)課程添加到系統(tǒng),測(cè)試是否可以操作成功。整個(gè)過(guò)程就是測(cè)試軟件是否可以實(shí)現(xiàn)用戶(hù)的需求。
壓力測(cè)試是通過(guò)測(cè)試工具制造大規(guī)模的并發(fā)請(qǐng)求去訪(fǎng)問(wèn)系統(tǒng),測(cè)試系統(tǒng)是否經(jīng)受住壓力。
比如:一個(gè)在線(xiàn)學(xué)習(xí)網(wǎng)站,上線(xiàn)要求該網(wǎng)站可以支持1萬(wàn)用戶(hù)同時(shí)在線(xiàn),此時(shí)就需要模擬1萬(wàn)并發(fā)請(qǐng)求去訪(fǎng)問(wèn)網(wǎng)站的關(guān)鍵業(yè)務(wù)流程,比如:測(cè)試點(diǎn)播學(xué)習(xí)流程,測(cè)試系統(tǒng)是否可以抗住1萬(wàn)并發(fā)請(qǐng)求。
一些功能測(cè)試時(shí)無(wú)法發(fā)現(xiàn)的問(wèn)題在壓力測(cè)試時(shí)就會(huì)發(fā)現(xiàn),比如:內(nèi)存泄露、線(xiàn)程安全、IO異常等問(wèn)題。
壓力測(cè)試常用的性能指標(biāo)如下:
1、吞吐量
吞吐量是系統(tǒng)每秒可以處理的事務(wù)數(shù),也稱(chēng)為T(mén)PS(Transaction Per Second)。
比如:一次點(diǎn)播流程,從請(qǐng)求進(jìn)入系統(tǒng)到視頻畫(huà)圖顯示出來(lái)這整個(gè)流程就是一次事務(wù)。
所以吞吐量并不是一次數(shù)據(jù)庫(kù)事務(wù),它是完成一次業(yè)務(wù)的整體流程。
2、響應(yīng)時(shí)間
響應(yīng)時(shí)間是指客戶(hù)端請(qǐng)求服務(wù)端,從請(qǐng)求進(jìn)入系統(tǒng)到客戶(hù)端拿到響應(yīng)結(jié)果所經(jīng)歷的時(shí)間。響應(yīng)時(shí)間包括:最大響應(yīng)時(shí)間、最小響應(yīng)時(shí)間、平均響應(yīng)時(shí)間。
3、每秒查詢(xún)數(shù)
每秒查詢(xún)數(shù)即QPS(Queries-per-second),它是衡量查詢(xún)接口的性能指標(biāo),比如:商品信息查詢(xún),
一秒可以請(qǐng)求該接口查詢(xún)商品信息的次數(shù)就是QPS。
拿查詢(xún)接口舉例,一次查詢(xún)請(qǐng)求內(nèi)部不會(huì)再去請(qǐng)求其它接口,此時(shí) QPS=TPS
如果一次查詢(xún)請(qǐng)求內(nèi)容需要遠(yuǎn)程調(diào)用另一個(gè)接口查詢(xún)數(shù)據(jù),此時(shí) QPS=2 * TPS
4、錯(cuò)誤率
錯(cuò)誤率 是一批請(qǐng)求發(fā)生錯(cuò)誤的請(qǐng)求占全部請(qǐng)求的比例。
不同的指標(biāo)其要求不同,比如現(xiàn)在進(jìn)行接口優(yōu)化,優(yōu)化后的接口響應(yīng)時(shí)間應(yīng)該越來(lái)越小,吞吐量越來(lái)越大,以及QPS值也是越大越好,錯(cuò)誤率要保持在一個(gè)很小的范圍。
另外除了關(guān)注這些性能指標(biāo)以外還要關(guān)注系統(tǒng)的負(fù)載情況:
1、CPU使用率,不高于85%
2、內(nèi)存利用率,不高于 85%
3、網(wǎng)絡(luò)利用率,不高于 80%
4、磁盤(pán)IO
磁盤(pán)IO的性能指標(biāo)是IOPS (Input/Output Per
Second)即每秒的輸入輸出量(或讀寫(xiě)次數(shù))。
如果過(guò)大說(shuō)明IO操作密集,IO過(guò)大也會(huì)影響性能指標(biāo)。
2.2 安裝Jmeter
Apache JMeter 是 Apache 組織基于 Java
開(kāi)發(fā)的壓力測(cè)試工具,用于對(duì)軟件做壓力測(cè)試。
下載Jmeter
https://jmeter.apache.org/download_jmeter.cgi
下載,解壓,進(jìn)入bin目錄修改jmeter.properties,設(shè)置中文和字體
language=zh_CN
jmeter.hidpi.mode=true
jmeter.hidpi.scale.factor=1.8
jsyntaxtextarea.font.family= Hack
jsyntaxtextarea.font.size=25
jmeter.toolbar.icons.size=32x32
雙擊運(yùn)行bin目錄下的jmeter.bat文件。
界面如下圖:
2.3 壓力測(cè)試
樣本數(shù):200個(gè)線(xiàn)程,每個(gè)線(xiàn)程請(qǐng)求100次,共20000次
壓力機(jī):通常壓力機(jī)是單獨(dú)的客戶(hù)端。
測(cè)試gateway+content
吞吐量180左右
測(cè)試content
吞吐量300左右
2.4 優(yōu)化日志
內(nèi)容管理日志級(jí)別改為info級(jí)別.
單獨(dú)請(qǐng)求內(nèi)容管理測(cè)試,吞吐量達(dá)到1500左右
3 緩存優(yōu)化
3.1 redis緩存
測(cè)試用例是根據(jù)id查詢(xún)課程信息,這里不存在復(fù)雜的SQL,也不存在數(shù)據(jù)庫(kù)連接不釋放的問(wèn)題,暫時(shí)不考慮數(shù)據(jù)庫(kù)方面的優(yōu)化。
課程發(fā)布信息的特點(diǎn)的是查詢(xún)較多,修改很少,這里考慮將課程發(fā)布信息進(jìn)行緩存。
課程信息緩存的流程如下:
在nacos配置redis-dev.yaml(group=xuecheng-plus-common)
spring:
redis:
host: 192.168.101.65
port: 6379
password: redis
database: 0
lettuce:
pool:
max-active: 20
max-idle: 10
min-idle: 0
timeout: 10000
在content-api微服務(wù)加載redis-dev.yaml
shared-configs:
- data-id: redis-${spring.profiles.active}.yaml
group: xuecheng-plus-common
refresh: true
在content-service微服務(wù)中添加依賴(lài)
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
<version>2.6.2</version>
</dependency>
定義查詢(xún)緩存接口:
/**
* @description 查詢(xún)緩存中的課程信息
* @param courseId
* @return com.xuecheng.content.model.po.CoursePublish
* @author Mr.M
* @date 2022/10/22 16:15
*/
public CoursePublish getCoursePublishCache(Long courseId);
接口實(shí)現(xiàn)如下:
public CoursePublish getCoursePublishCache(Long courseId){
//查詢(xún)緩存
Object jsonObj = redisTemplate.opsForValue().get("course:" + courseId);
if(jsonObj!=null){
String jsonString = jsonObj.toString();
System.out.println("=================從緩存查=================");
CoursePublish coursePublish = JSON.parseObject(jsonString, CoursePublish.class);
return coursePublish;
} else {
System.out.println("從數(shù)據(jù)庫(kù)查詢(xún)...");
//從數(shù)據(jù)庫(kù)查詢(xún)
CoursePublish coursePublish = getCoursePublish(courseId);
if(coursePublish!=null){
redisTemplate.opsForValue().set("course:" + courseId, JSON.toJSONString(coursePublish));
}
return coursePublish;
}
}
}
修改controller接口調(diào)用代碼
@ApiOperation("獲取課程發(fā)布信息")
@ResponseBody
@GetMapping("/course/whole/{courseId}")
public CoursePreviewDto getCoursePublish(@PathVariable("courseId") Long courseId) {
//查詢(xún)課程發(fā)布信息
CoursePublish coursePublish = coursePublishService.getCoursePublishCache(courseId);
// CoursePublish coursePublish = coursePublishService.getCoursePublish(courseId);
if(coursePublish==null){
return new CoursePreviewDto();
}
//課程基本信息
CourseBaseInfoDto courseBase = new CourseBaseInfoDto();
BeanUtils.copyProperties(coursePublish, courseBase);
//課程計(jì)劃
List<TeachplanDto> teachplans = JSON.parseArray(coursePublish.getTeachplan(), TeachplanDto.class);
CoursePreviewDto coursePreviewInfo = new CoursePreviewDto();
coursePreviewInfo.setCourseBase(courseBase);
coursePreviewInfo.setTeachplans(teachplans);
return coursePreviewInfo;
}
重新測(cè)試請(qǐng)求內(nèi)容管理服務(wù)課程查詢(xún)接口。
吞吐量達(dá)到2700左右,增加了近一倍。
3.2 緩存穿透問(wèn)題
3.2.1 什么是緩存穿透
使用緩存后代碼的性能有了很大的提高,雖然性能有很大的提升但是控制臺(tái)打出了很多"從數(shù)據(jù)庫(kù)查詢(xún)"的日志,明明判斷了如果緩存存在課程信息則從緩存查詢(xún),為什么要有這么多從數(shù)據(jù)庫(kù)查詢(xún)的請(qǐng)求的?
這是因?yàn)椴l(fā)數(shù)高,很多線(xiàn)程會(huì)同時(shí)到達(dá)查詢(xún)數(shù)據(jù)庫(kù)代碼處去執(zhí)行。
我們分析下代碼:
如果存在惡意攻擊的可能,如果有大量并發(fā)去查詢(xún)一個(gè)不存在的課程信息會(huì)出現(xiàn)什么問(wèn)題呢?
比如去請(qǐng)求/content/course/whole/181,查詢(xún)181號(hào)課程,該課程并不在課程發(fā)布表中。
進(jìn)行壓力測(cè)試發(fā)現(xiàn)會(huì)去請(qǐng)求數(shù)據(jù)庫(kù)。
大量并發(fā)去訪(fǎng)問(wèn)一個(gè)數(shù)據(jù)庫(kù)不存在的數(shù)據(jù),由于緩存中沒(méi)有該數(shù)據(jù)導(dǎo)致大量并發(fā)查詢(xún)數(shù)據(jù)庫(kù),這個(gè)現(xiàn)象要緩存穿透。
緩存穿透可以造成數(shù)據(jù)庫(kù)瞬間壓力過(guò)大,連接數(shù)等資源用完,最終數(shù)據(jù)庫(kù)拒絕連接不可用。
3.2.2 解決緩存穿透
如何解決緩存穿透?
1、對(duì)請(qǐng)求增加校驗(yàn)機(jī)制
比如:課程Id是長(zhǎng)整型,如果發(fā)來(lái)的不是長(zhǎng)整型則直接返回。
2、使用布隆過(guò)濾器
什么是布隆過(guò)濾器,以下摘自百度百科:
布隆過(guò)濾器可以用于檢索一個(gè)元素是否在一個(gè)集合中。如果想要判斷一個(gè)元素是不是在一個(gè)集合里,一般想到的是將所有元素保存起來(lái),然后通過(guò)比較確定。鏈表,樹(shù)等等數(shù)據(jù)結(jié)構(gòu)都是這種思路.
但是隨著集合中元素的增加,我們需要的存儲(chǔ)空間越來(lái)越大,檢索速度也越來(lái)越慢(O(n),O(logn))。不過(guò)世界上還有一種叫作散列表(又叫哈希表,Hash
table)的數(shù)據(jù)結(jié)構(gòu)。它可以通過(guò)一個(gè)Hash函數(shù)將一個(gè)元素映射成一個(gè)位陣列(Bit
array)中的一個(gè)點(diǎn)。這樣一來(lái),我們只要看看這個(gè)點(diǎn)是不是1就可以知道集合中有沒(méi)有它了。這就是布隆過(guò)濾器的基本思想。
布隆過(guò)濾器的特點(diǎn)是,高效地插入和查詢(xún),占用空間少;查詢(xún)結(jié)果有不確定性,如果查詢(xún)結(jié)果是存在則元素不一定存在,如果不存在則一定不存在;另外它只能添加元素不能刪除元素,因?yàn)閯h除元素會(huì)增加誤判率。
比如:將商品id寫(xiě)入布隆過(guò)濾器,如果分3次hash此時(shí)在布隆過(guò)濾器有3個(gè)點(diǎn),當(dāng)從布隆過(guò)濾器查詢(xún)?cè)撋唐穒d,通過(guò)hash找到了該商品id在過(guò)濾器中的點(diǎn),此時(shí)返回1,如果找不到一定會(huì)返回0。
所以,為了避免緩存穿透我們需要緩存預(yù)熱將要查詢(xún)的課程或商品信息的id提前存入布隆過(guò)濾器,添加數(shù)據(jù)時(shí)將信息的id也存入過(guò)濾器,當(dāng)去查詢(xún)一個(gè)數(shù)據(jù)時(shí)先在布隆過(guò)濾器中找一下如果沒(méi)有到到就說(shuō)明不存在,此時(shí)直接返回。
實(shí)現(xiàn)方法有:
Google工具包Guava實(shí)現(xiàn)。
redisson 。
2、緩存空值或特殊值
請(qǐng)求通過(guò)了第一步的校驗(yàn),查詢(xún)數(shù)據(jù)庫(kù)得到的數(shù)據(jù)不存在,此時(shí)我們?nèi)匀蝗ゾ彺鏀?shù)據(jù),緩存一個(gè)空值或一個(gè)特殊值的數(shù)據(jù)。
但是要注意:如果緩存了空值或特殊值要設(shè)置一個(gè)短暫的過(guò)期時(shí)間。
public CoursePublish getCoursePublishCache(Long courseId) {
//查詢(xún)緩存
Object jsonObj = redisTemplate.opsForValue().get("course:" + courseId);
if(jsonObj!=null){
String jsonString = jsonObj.toString();
if(jsonString.equals("null"))
return null;
CoursePublish coursePublish = JSON.parseObject(jsonString, CoursePublish.class);
return coursePublish;
} else {
//從數(shù)據(jù)庫(kù)查詢(xún)
System.out.println("從數(shù)據(jù)庫(kù)查詢(xún)數(shù)據(jù)...");
CoursePublish coursePublish = getCoursePublish(courseId);
//設(shè)置過(guò)期時(shí)間300秒
redisTemplate.opsForValue().set("course:" + courseId, JSON.toJSONString(coursePublish),30, TimeUnit.SECONDS);
return coursePublish;
}
}
再測(cè)試,雖然還存在個(gè)別請(qǐng)求去查詢(xún)數(shù)據(jù)庫(kù),但不是所有請(qǐng)求都去查詢(xún)數(shù)據(jù)庫(kù),基本上都命中緩存。
3.3 緩存雪崩
3.3.1 什么是緩存雪崩
緩存雪崩是緩存中大量key失效后當(dāng)高并發(fā)到來(lái)時(shí)導(dǎo)致大量請(qǐng)求到數(shù)據(jù)庫(kù),瞬間耗盡數(shù)據(jù)庫(kù)資源,導(dǎo)致數(shù)據(jù)庫(kù)無(wú)法使用。
造成緩存雪崩問(wèn)題的原因是是大量key擁有了相同的過(guò)期時(shí)間,比如對(duì)課程信息設(shè)置緩存過(guò)期時(shí)間為10分鐘,在大量請(qǐng)求同時(shí)查詢(xún)大量的課程信息時(shí),此時(shí)就會(huì)有大量的課程存在相同的過(guò)期時(shí)間,一旦失效將同時(shí)失效,造成雪崩問(wèn)題。
3.3.2 解決緩存雪崩
如何解決緩存雪崩?
1、使用同步鎖控制查詢(xún)數(shù)據(jù)庫(kù)的線(xiàn)程
使用同步鎖控制查詢(xún)數(shù)據(jù)庫(kù)的線(xiàn)程,只允許有一個(gè)線(xiàn)程去查詢(xún)數(shù)據(jù)庫(kù),查詢(xún)得到數(shù)據(jù)后存入緩存。
synchronized(obj){
//查詢(xún)數(shù)據(jù)庫(kù)
//存入緩存
}
2、對(duì)同一類(lèi)型信息的key設(shè)置不同的過(guò)期時(shí)間
通常對(duì)一類(lèi)信息的key設(shè)置的過(guò)期時(shí)間是相同的,這里可以在原有固定時(shí)間的基礎(chǔ)上加上一個(gè)隨機(jī)時(shí)間使它們的過(guò)期時(shí)間都不相同。
示例代碼如下:
//設(shè)置過(guò)期時(shí)間300秒
redisTemplate.opsForValue().set("course:" + courseId, JSON.toJSONString(coursePublish),300+new Random().nextInt(100), TimeUnit.SECONDS);
3、緩存預(yù)熱
不用等到請(qǐng)求到來(lái)再去查詢(xún)數(shù)據(jù)庫(kù)存入緩存,可以提前將數(shù)據(jù)存入緩存。使用緩存預(yù)熱機(jī)制通常有專(zhuān)門(mén)的后臺(tái)程序去將數(shù)據(jù)庫(kù)的數(shù)據(jù)同步到緩存。
3.4 緩存擊穿
3.4.1 什么是緩存擊穿
緩存擊穿是指大量并發(fā)訪(fǎng)問(wèn)同一個(gè)熱點(diǎn)數(shù)據(jù),當(dāng)熱點(diǎn)數(shù)據(jù)失效后同時(shí)去請(qǐng)求數(shù)據(jù)庫(kù),瞬間耗盡數(shù)據(jù)庫(kù)資源,導(dǎo)致數(shù)據(jù)庫(kù)無(wú)法使用。
比如某手機(jī)新品發(fā)布,當(dāng)緩存失效時(shí)有大量并發(fā)到來(lái)導(dǎo)致同時(shí)去訪(fǎng)問(wèn)數(shù)據(jù)庫(kù)。
3.4.2 解決緩存擊穿
如何解決緩存擊穿?
1、使用同步鎖控制查詢(xún)數(shù)據(jù)庫(kù)的線(xiàn)程
使用同步鎖控制查詢(xún)數(shù)據(jù)庫(kù)的代碼,只允許有一個(gè)線(xiàn)程去查詢(xún)數(shù)據(jù)庫(kù),查詢(xún)得到數(shù)據(jù)庫(kù)存入緩存。
synchronized(obj){
//查詢(xún)數(shù)據(jù)庫(kù)
//存入緩存
}
2、熱點(diǎn)數(shù)據(jù)不過(guò)期
可以由后臺(tái)程序提前將熱點(diǎn)數(shù)據(jù)加入緩存,緩存過(guò)期時(shí)間不過(guò)期,由后臺(tái)程序做好緩存同步。
下邊使用synchronized對(duì)代碼加鎖。
public CoursePublish getCoursePublishCache(Long courseId){
synchronized(this){
//查詢(xún)緩存
String jsonString = (String) redisTemplate.opsForValue().get("course:" + courseId);
if(StringUtils.isNotEmpty(jsonString)){
if(jsonString.equals("null"))
return null;
CoursePublish coursePublish = JSON.parseObject(jsonString, CoursePublish.class);
return coursePublish;
}else{
System.out.println("=========從數(shù)據(jù)庫(kù)查詢(xún)==========");
//從數(shù)據(jù)庫(kù)查詢(xún)
CoursePublish coursePublish = getCoursePublish(courseId);
//設(shè)置過(guò)期時(shí)間300秒
redisTemplate.opsForValue().set("course:" + courseId, JSON.toJSONString(coursePublish),300, TimeUnit.SECONDS);
return coursePublish;
}
}
}
測(cè)試,吞吐量有1300左右
對(duì)上邊的代碼進(jìn)行優(yōu)化,對(duì)查詢(xún)緩存的代碼不用synchronized加鎖控制,只對(duì)查詢(xún)數(shù)據(jù)庫(kù)進(jìn)行加鎖,如下:
public CoursePublish getCoursePublishCache(Long courseId){
//查詢(xún)緩存
Object jsonObj = redisTemplate.opsForValue().get("course:" + courseId);
if(jsonObj!=null){
String jsonString = jsonObj.toString();
CoursePublish coursePublish = JSON.parseObject(jsonString, CoursePublish.class);
return coursePublish;
}else{
synchronized(this){
Object jsonObj = redisTemplate.opsForValue().get("course:" + courseId);
if(jsonObj!=null){
String jsonString = jsonObj.toString();
CoursePublish coursePublish = JSON.parseObject(jsonString, CoursePublish.class);
return coursePublish;
}
System.out.println("=========從數(shù)據(jù)庫(kù)查詢(xún)==========");
//從數(shù)據(jù)庫(kù)查詢(xún)
CoursePublish coursePublish = getCoursePublish(courseId);
//設(shè)置過(guò)期時(shí)間300秒
redisTemplate.opsForValue().set("course:" + courseId, JSON.toJSONString(coursePublish),300, TimeUnit.SECONDS);
return coursePublish;
}
}
}
測(cè)試,查詢(xún)數(shù)據(jù)庫(kù)只發(fā)生一次,整個(gè)測(cè)試過(guò)程的吞吐量在3800左右。
3.4.3 小結(jié)
1)緩存穿透:
去訪(fǎng)問(wèn)一個(gè)數(shù)據(jù)庫(kù)不存在的數(shù)據(jù)無(wú)法將數(shù)據(jù)進(jìn)行緩存,導(dǎo)致查詢(xún)數(shù)據(jù)庫(kù),當(dāng)并發(fā)較大就會(huì)對(duì)數(shù)據(jù)庫(kù)造成壓力。緩存穿透可以造成數(shù)據(jù)庫(kù)瞬間壓力過(guò)大,連接數(shù)等資源用完,最終數(shù)據(jù)庫(kù)拒絕連接不可用。
解決的方法:
緩存一個(gè)null值。
使用布隆過(guò)濾器。
2)緩存雪崩:
緩存中大量key失效后當(dāng)高并發(fā)到來(lái)時(shí)導(dǎo)致大量請(qǐng)求到數(shù)據(jù)庫(kù),瞬間耗盡數(shù)據(jù)庫(kù)資源,導(dǎo)致數(shù)據(jù)庫(kù)無(wú)法使用。
造成緩存雪崩問(wèn)題的原因是是大量key擁有了相同的過(guò)期時(shí)間。
解決辦法:
使用同步鎖控制
對(duì)同一類(lèi)型信息的key設(shè)置不同的過(guò)期時(shí)間,比如:使用固定數(shù)+隨機(jī)數(shù)作為過(guò)期時(shí)間。
3)緩存擊穿
大量并發(fā)訪(fǎng)問(wèn)同一個(gè)熱點(diǎn)數(shù)據(jù),當(dāng)熱點(diǎn)數(shù)據(jù)失效后同時(shí)去請(qǐng)求數(shù)據(jù)庫(kù),瞬間耗盡數(shù)據(jù)庫(kù)資源,導(dǎo)致數(shù)據(jù)庫(kù)無(wú)法使用。
解決辦法:
使用同步鎖控制
設(shè)置key永不過(guò)期
無(wú)中生有是穿透,布隆過(guò)濾null隔離。
緩存擊穿key過(guò)期, 鎖與非期解難題。
大量過(guò)期成雪崩,過(guò)期時(shí)間要隨機(jī)。
面試必考三兄弟,可用限流來(lái)保底。
限流技術(shù)方案:
alibaba/Sentinel
nginx+Lua
3.5 分布式鎖
3.5.1 本地鎖的問(wèn)題
上邊的程序使用了同步鎖解決了緩存擊穿、緩存雪崩的問(wèn)題,保證同一個(gè)key過(guò)期后只會(huì)查詢(xún)一次數(shù)據(jù)庫(kù)。
如果將同步鎖的程序分布式部署在多個(gè)虛擬機(jī)上則無(wú)法保證同一個(gè)key只會(huì)查詢(xún)一次數(shù)據(jù)庫(kù),如下圖:
一個(gè)同步鎖程序只能保證同一個(gè)虛擬機(jī)中多個(gè)線(xiàn)程只有一個(gè)線(xiàn)程去數(shù)據(jù)庫(kù),如果高并發(fā)通過(guò)網(wǎng)關(guān)負(fù)載均衡轉(zhuǎn)發(fā)給各個(gè)虛擬機(jī),此時(shí)就會(huì)存在多個(gè)線(xiàn)程去查詢(xún)數(shù)據(jù)庫(kù)情況,因?yàn)樘摂M機(jī)中的鎖只能保證該虛擬機(jī)自己的線(xiàn)程去同步執(zhí)行,無(wú)法跨虛擬機(jī)保證同步執(zhí)行。
我們將虛擬機(jī)內(nèi)部的鎖叫本地鎖,本地鎖只能保證所在虛擬機(jī)的線(xiàn)程同步執(zhí)行。
下邊進(jìn)行測(cè)試:
啟動(dòng)三個(gè)內(nèi)容管理服務(wù):
通過(guò)網(wǎng)關(guān)訪(fǎng)問(wèn)課程查詢(xún),網(wǎng)關(guān)通過(guò)負(fù)載均衡將請(qǐng)求轉(zhuǎn)發(fā)給三個(gè)服務(wù)。
通過(guò)測(cè)試發(fā)現(xiàn),有兩個(gè)服務(wù)各有一次數(shù)據(jù)庫(kù)查詢(xún),這說(shuō)明本地鎖無(wú)法跨虛擬機(jī)保證同步執(zhí)行。
3.5.2 什么是分布鎖
本地鎖只能控制所在虛擬機(jī)中的線(xiàn)程同步執(zhí)行,現(xiàn)在要實(shí)現(xiàn)分布式環(huán)境下所有虛擬機(jī)中的線(xiàn)程去同步執(zhí)行就需要讓多個(gè)虛擬機(jī)去共用一個(gè)鎖,虛擬機(jī)可以分布式部署,鎖也可以分布式部署,如下圖:
虛擬機(jī)都去搶占同一個(gè)鎖,鎖是一個(gè)單獨(dú)的程序提供加鎖、解鎖服務(wù),誰(shuí)搶到鎖誰(shuí)去查詢(xún)數(shù)據(jù)庫(kù)。
該鎖已不屬于某個(gè)虛擬機(jī),而是分布式部署,由多個(gè)虛擬機(jī)所共享,這種鎖叫分布式鎖。
3.5.3 分布式鎖的實(shí)現(xiàn)方案
實(shí)現(xiàn)分布式鎖的方案有很多,常用的如下:
1、基于數(shù)據(jù)庫(kù)實(shí)現(xiàn)分布鎖
利用數(shù)據(jù)庫(kù)主鍵唯一性的特點(diǎn),或利用數(shù)據(jù)庫(kù)唯一索引的特點(diǎn),多個(gè)線(xiàn)程同時(shí)去插入相同的記錄,誰(shuí)插入成功誰(shuí)就搶到鎖。
2、基于redis實(shí)現(xiàn)鎖
redis提供了分布式鎖的實(shí)現(xiàn)方案,比如:SETNX、set nx、redisson等。
拿SETNX舉例說(shuō)明,SETNX命令的工作過(guò)程是去set一個(gè)不存在的key,多個(gè)線(xiàn)程去設(shè)置同一個(gè)key只會(huì)有一個(gè)線(xiàn)程設(shè)置成功,設(shè)置成功的的線(xiàn)程拿到鎖。
3、使用zookeeper實(shí)現(xiàn)
zookeeper是一個(gè)分布式協(xié)調(diào)服務(wù),主要解決分布式程序之間的同步的問(wèn)題。zookeeper的結(jié)構(gòu)類(lèi)似的文件目錄,多線(xiàn)程向zookeeper創(chuàng)建一個(gè)子目錄(節(jié)點(diǎn))只會(huì)有一個(gè)創(chuàng)建成功,利用此特點(diǎn)可以實(shí)現(xiàn)分布式鎖,誰(shuí)創(chuàng)建該結(jié)點(diǎn)成功誰(shuí)就獲得鎖。
3.5.4 Redis NX實(shí)現(xiàn)分布式鎖
redis實(shí)現(xiàn)分布式鎖的方案可以在redis.cn網(wǎng)站查閱,地址http://www.redis.cn/commands/set.html
使用命令: SET resource-name anystring NX EX max-lock-time 即可實(shí)現(xiàn)。
NX:表示key不存在才設(shè)置成功。
EX:設(shè)置過(guò)期時(shí)間
這里啟動(dòng)三個(gè)ssh客戶(hù)端,連接redis: docker exec -it redis redis-cli
先認(rèn)證: auth redis
同時(shí)向三個(gè)客戶(hù)端發(fā)送測(cè)試命令如下:
表示設(shè)置lock001鎖,value為001,過(guò)期時(shí)間為30秒
SET lock001 001 NX EX 30
命令發(fā)送成功,觀(guān)察三個(gè)ssh客戶(hù)端發(fā)現(xiàn)只有一個(gè)設(shè)置成功,其它兩個(gè)設(shè)置失敗,設(shè)置成功的請(qǐng)求表示搶到了lock001鎖。
如何在代碼中使用Set nx去實(shí)現(xiàn)分布鎖呢?
使用spring-boot-starter-data-redis 提供的api即可實(shí)現(xiàn)set nx。
添加依賴(lài):
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
<version>2.6.2</version>
</dependency>
添加依賴(lài)后,在bean中注入restTemplate。
我們先分析一段偽代碼如下:
if(緩存中有){
返回緩存中的數(shù)據(jù)
}else{
獲取分布式鎖
if(獲取鎖成功){
try{
查詢(xún)數(shù)據(jù)庫(kù)
}finally{
釋放鎖
}
}
}
1、獲取分布式鎖
使用redisTemplate.opsForValue().setIfAbsent(key,vaue)獲取鎖
這里考慮一個(gè)問(wèn)題,當(dāng)set
nx一個(gè)key/value成功1后,這個(gè)key(就是鎖)需要設(shè)置過(guò)期時(shí)間嗎?
如果不設(shè)置過(guò)期時(shí)間當(dāng)獲取到了鎖卻沒(méi)有執(zhí)行finally這個(gè)鎖將會(huì)一直存在,其它線(xiàn)程無(wú)法獲取這個(gè)鎖。
所以執(zhí)行set nx時(shí)要指定過(guò)期時(shí)間,即使用如下的命令
SET resource-name anystring NX EX max-lock-time
具體調(diào)用的方法是:redisTemplate.opsForValue().setIfAbsent(K var1, V
var2, long var3, TimeUnit var5)
2、如何釋放鎖
釋放鎖分為兩種情況:key到期自動(dòng)釋放,手動(dòng)刪除。
1)key到期自動(dòng)釋放的方法
因?yàn)殒i設(shè)置了過(guò)期時(shí)間,key到期會(huì)自動(dòng)釋放,但是會(huì)存在一個(gè)問(wèn)題就是
查詢(xún)數(shù)據(jù)庫(kù)等操作還沒(méi)有執(zhí)行完時(shí)key到期了,此時(shí)其它線(xiàn)程就搶到鎖了,最終重復(fù)查詢(xún)數(shù)據(jù)庫(kù)執(zhí)行了重復(fù)的業(yè)務(wù)操作。
怎么解決這個(gè)問(wèn)題?
可以將key的到期時(shí)間設(shè)置的長(zhǎng)一些,足以執(zhí)行完成查詢(xún)數(shù)據(jù)庫(kù)并設(shè)置緩存等相關(guān)操作。
如果這樣效率會(huì)低一些,另外這個(gè)時(shí)間值也不好把控。
2)手動(dòng)刪除鎖
如果是采用手動(dòng)刪除鎖可能和key到期自動(dòng)刪除有所沖突,造成刪除了別人的鎖。
比如:當(dāng)查詢(xún)數(shù)據(jù)庫(kù)等業(yè)務(wù)還沒(méi)有執(zhí)行完時(shí)key過(guò)期了,此時(shí)其它線(xiàn)程占用了鎖,當(dāng)上一個(gè)線(xiàn)程執(zhí)行查詢(xún)數(shù)據(jù)庫(kù)等業(yè)務(wù)操作完成后手動(dòng)刪除鎖就把其它線(xiàn)程的鎖給刪除了。
要解決這個(gè)問(wèn)題可以采用刪除鎖之前判斷是不是自己設(shè)置的鎖,偽代碼如下:
if(緩存中有){
返回緩存中的數(shù)據(jù)
}else{
獲取分布式鎖: set lock 01 NX
if(獲取鎖成功){
try{
查詢(xún)數(shù)據(jù)庫(kù)
}finally{
if(redis.call("get","lock")=="01"){
釋放鎖: redis.call("del","lock")
}
}
}
}
以上代碼第11行到13行非原子性,也會(huì)導(dǎo)致刪除其它線(xiàn)程的鎖。
查看文檔上的說(shuō)明:http://www.redis.cn/commands/set.html
在調(diào)用setnx命令設(shè)置key/value時(shí),每個(gè)線(xiàn)程設(shè)置不一樣的value值,這樣當(dāng)線(xiàn)程去刪除鎖時(shí)可以先根據(jù)key查詢(xún)出來(lái)判斷是不是自己當(dāng)時(shí)設(shè)置的vlaue,如果是則刪除。
這整個(gè)操作是原子的,實(shí)現(xiàn)方法就是去執(zhí)行上邊的lua腳本。
Lua
是一個(gè)小巧的腳本語(yǔ)言,redis在2.6版本就支持通過(guò)執(zhí)行Lua腳本保證多個(gè)命令的原子性。
什么是原子性?
這些指令要么全成功要么全失敗。
以上就是使用Redis
Nx方式實(shí)現(xiàn)分布式鎖,為了避免刪除別的線(xiàn)程設(shè)置的鎖需要使用redis去執(zhí)行Lua腳本的方式去實(shí)現(xiàn),這樣就具有原子性,但是過(guò)期時(shí)間的值設(shè)置不存在不精確的問(wèn)題。
3.5.5 Redisson實(shí)現(xiàn)分布式鎖
3.5.5.1 什么是Redisson
再查閱 文檔http://www.redis.cn/commands/set.html
點(diǎn)擊鏈接查看
我們選用Java的實(shí)現(xiàn)方案 https://github.com/redisson/redisson
Redisson的文檔地址:https://github.com/redisson/redisson/wiki/Table-of-Content
Redisson底層采用的是Netty
框架。支持Redis
2.8以上版本,支持Java1.6+以上版本。Redisson是一個(gè)在Redis的基礎(chǔ)上實(shí)現(xiàn)的Java駐內(nèi)存數(shù)據(jù)網(wǎng)格(In-Memory
Data
Grid)。它不僅提供了一系列的分布式的Java常用對(duì)象,還提供了許多分布式服務(wù)。其中包括(BitSet,
Set, Multimap, SortedSet, Map, List, Queue, BlockingQueue, Deque,
BlockingDeque, Semaphore, Lock, AtomicLong, CountDownLatch, Publish /
Subscribe, Bloom filter, Remote service, Spring cache, Executor service,
Live Object service, Scheduler service) 。
使用Redisson可以非常方便將Java本地內(nèi)存中的常用數(shù)據(jù)結(jié)構(gòu)的對(duì)象搬到分布式緩存redis中。
也可以將常用的并發(fā)編程工具如:AtomicLong、CountDownLatch、Semaphore等支持分布式。
使用RScheduledExecutorService 實(shí)現(xiàn)分布式調(diào)度服務(wù)。
支持?jǐn)?shù)據(jù)分片,將數(shù)據(jù)分片存儲(chǔ)到不同的redis實(shí)例中。
支持分布式鎖,基于Java的Lock接口實(shí)現(xiàn)分布式鎖,方便開(kāi)發(fā)。
下邊使用Redisson將Queue隊(duì)列的數(shù)據(jù)存入Redis,實(shí)現(xiàn)一個(gè)排隊(duì)及出隊(duì)的接口。
添加redisson的依賴(lài)
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.11.2</version>
</dependency>
從課程資料目錄拷貝singleServerConfig.yaml到config工程下
在redis配置文件中添加:
spring:
redis:
redisson:
#配置文件目錄
config: classpath:singleServerConfig.yaml
#config: classpath:clusterServersConfig.yaml
redis集群配置clusterServersConfig.yaml.
Redisson相比set nx實(shí)現(xiàn)分布式鎖要簡(jiǎn)單的多,工作原理如下:
加鎖機(jī)制
線(xiàn)程去獲取鎖,獲取成功: 執(zhí)行l(wèi)ua腳本,保存數(shù)據(jù)到redis數(shù)據(jù)庫(kù)。
線(xiàn)程去獲取鎖,獲取失敗:
一直通過(guò)while循環(huán)嘗試獲取鎖,獲取成功后,執(zhí)行l(wèi)ua腳本,保存數(shù)據(jù)到redis
WatchDog自動(dòng)延期看門(mén)狗機(jī)制
第一種情況:在一個(gè)分布式環(huán)境下,假如一個(gè)線(xiàn)程獲得鎖后,突然服務(wù)器宕機(jī)了,那么這個(gè)時(shí)候在一定時(shí)間后這個(gè)鎖會(huì)自動(dòng)釋放,你也可以設(shè)置鎖的有效時(shí)間(當(dāng)不設(shè)置默認(rèn)30秒時(shí)),這樣的目的主要是防止死鎖的發(fā)生
第二種情況:線(xiàn)程A業(yè)務(wù)還沒(méi)有執(zhí)行完,時(shí)間就過(guò)了,線(xiàn)程A
還想持有鎖的話(huà),就會(huì)啟動(dòng)一個(gè)watch
dog后臺(tái)線(xiàn)程,不斷的延長(zhǎng)鎖key的生存時(shí)間。
lua腳本-保證原子性操作
主要是如果你的業(yè)務(wù)邏輯復(fù)雜的話(huà),通過(guò)封裝在lua腳本中發(fā)送給redis,而且redis是單線(xiàn)程的,這樣就保證這段復(fù)雜業(yè)務(wù)邏輯執(zhí)行的原子性
具體使用RLock操作分布鎖,RLock繼承JDK的Lock接口,所以他有Lock接口的所有特性,比如lock、unlock、trylock等特性,同時(shí)它還有很多新特性:強(qiáng)制鎖釋放,帶有效期的鎖,。
public interface RRLock {
//----------------------Lock接口方法-----------------------
/**
* 加鎖 鎖的有效期默認(rèn)30秒
*/
void lock();
/**
* 加鎖 可以手動(dòng)設(shè)置鎖的有效時(shí)間
*
* @param leaseTime 鎖有效時(shí)間
* @param unit 時(shí)間單位 小時(shí)、分、秒、毫秒等
*/
void lock(long leaseTime, TimeUnit unit);
/**
* tryLock()方法是有返回值的,用來(lái)嘗試獲取鎖,
* 如果獲取成功,則返回true,如果獲取失敗(即鎖已被其他線(xiàn)程獲?。?,則返回false .
*/
boolean tryLock();
/**
* tryLock(long time, TimeUnit unit)方法和tryLock()方法是類(lèi)似的,
* 只不過(guò)區(qū)別在于這個(gè)方法在拿不到鎖時(shí)會(huì)等待一定的時(shí)間,
* 在時(shí)間期限之內(nèi)如果還拿不到鎖,就返回false。如果如果一開(kāi)始拿到鎖或者在等待期間內(nèi)拿到了鎖,則返回true。
*
* @param time 等待時(shí)間
* @param unit 時(shí)間單位 小時(shí)、分、秒、毫秒等
*/
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
/**
* 比上面多一個(gè)參數(shù),多添加一個(gè)鎖的有效時(shí)間
*
* @param waitTime 等待時(shí)間
* @param leaseTime 鎖有效時(shí)間
* @param unit 時(shí)間單位 小時(shí)、分、秒、毫秒等
* waitTime 大于 leaseTime
*/
boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException;
/**
* 解鎖
*/
void unlock();
}
lock():
此方法為加鎖,但是鎖的有效期采用默認(rèn)30秒
如果主線(xiàn)程未釋放,且當(dāng)前鎖未調(diào)用unlock方法,則進(jìn)入到watchDog機(jī)制
如果主線(xiàn)程未釋放,且當(dāng)前鎖調(diào)用unlock方法,則直接釋放鎖
3.5.5.2 分布式鎖避免緩存擊穿
下邊使用分布式鎖修改查詢(xún)課程信息的接口。
//Redisson分布式鎖
public CoursePublish getCoursePublishCache(Long courseId){
//查詢(xún)緩存
String jsonString = (String) redisTemplate.opsForValue().get("course:" + courseId);
if(StringUtils.isNotEmpty(jsonString)){
if(jsonString.equals("null")){
return null;
}
CoursePublish coursePublish = JSON.parseObject(jsonString, CoursePublish.class);
return coursePublish;
}else{
//每門(mén)課程設(shè)置一個(gè)鎖
RLock lock = redissonClient.getLock("coursequerylock:"+courseId);
//獲取鎖
lock.lock();
try {
jsonString = (String) redisTemplate.opsForValue().get("course:" + courseId);
if(StringUtils.isNotEmpty(jsonString)){
CoursePublish coursePublish = JSON.parseObject(jsonString, CoursePublish.class);
return coursePublish;
}
System.out.println("=========從數(shù)據(jù)庫(kù)查詢(xún)==========");
//從數(shù)據(jù)庫(kù)查詢(xún)
CoursePublish coursePublish = getCoursePublish(courseId);
redisTemplate.opsForValue().set("course:" + courseId, JSON.toJSONString(coursePublish),1,TimeUnit.DAYS);
return coursePublish;
}finally {
//釋放鎖
lock.unlock();
}
}
}
啟動(dòng)多個(gè)內(nèi)容管理服務(wù)實(shí)例,使用JMeter壓力測(cè)試,只有一個(gè)實(shí)例查詢(xún)一次數(shù)據(jù)庫(kù)。
測(cè)試Redisson自動(dòng)續(xù)期功能。文章來(lái)源:http://www.zghlxwxcb.cn/news/detail-653337.html
在查詢(xún)數(shù)據(jù)庫(kù)處添加休眠,觀(guān)察鎖是否會(huì)自動(dòng)續(xù)期。文章來(lái)源地址http://www.zghlxwxcb.cn/news/detail-653337.html
try {
Thread.sleep(60000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
到了這里,關(guān)于微服務(wù)實(shí)戰(zhàn)項(xiàng)目-學(xué)成在線(xiàn)-項(xiàng)目?jī)?yōu)化(redis緩存優(yōu)化)的文章就介紹完了。如果您還想了解更多內(nèi)容,請(qǐng)?jiān)谟疑辖撬阉鱐OY模板網(wǎng)以前的文章或繼續(xù)瀏覽下面的相關(guān)文章,希望大家以后多多支持TOY模板網(wǎng)!