? 作者主頁(yè):歡迎來(lái)到我的技術(shù)博客??
? 個(gè)人介紹:大家好,本人熱衷于Java后端開(kāi)發(fā),歡迎來(lái)交流學(xué)習(xí)哦!( ̄▽?zhuān)?~*
?? 如果文章對(duì)您有幫助,記得關(guān)注、點(diǎn)贊、收藏、評(píng)論??????
?? 您的支持將是我創(chuàng)作的動(dòng)力,讓我們一起加油進(jìn)步吧?。?!????
一、短信登錄
1.1 基于Session實(shí)現(xiàn)登錄流程
發(fā)送驗(yàn)證碼:
用戶(hù)在提交手機(jī)號(hào)后,會(huì)校驗(yàn)手機(jī)號(hào)是否合法,如果不合法,則要求用戶(hù)重新輸入手機(jī)號(hào)。
如果手機(jī)號(hào)合法,后臺(tái)此時(shí)生成對(duì)應(yīng)的驗(yàn)證碼,同時(shí)將驗(yàn)證碼進(jìn)行保存,然后再通過(guò)短信的方式將驗(yàn)證碼發(fā)送給用戶(hù)。
短信驗(yàn)證碼登錄、注冊(cè):
用戶(hù)將驗(yàn)證碼和手機(jī)號(hào)進(jìn)行輸入,后臺(tái)從session中拿到當(dāng)前驗(yàn)證碼,然后和用戶(hù)輸入的驗(yàn)證碼進(jìn)行校驗(yàn),如果不一致,則無(wú)法通過(guò)校驗(yàn),如果一致,則后臺(tái)根據(jù)手機(jī)號(hào)查詢(xún)用戶(hù),如果用戶(hù)不存在,則為用戶(hù)創(chuàng)建賬號(hào)信息,保存到數(shù)據(jù)庫(kù),無(wú)論是否存在,都會(huì)將用戶(hù)信息保存到session中,方便后續(xù)獲得當(dāng)前登錄信息。
校驗(yàn)登錄狀態(tài):
用戶(hù)在請(qǐng)求時(shí)候,會(huì)從cookie中攜帶者JsessionId到后臺(tái),后臺(tái)通過(guò)JsessionId從session中拿到用戶(hù)信息,如果沒(méi)有session信息,則進(jìn)行攔截,如果有session信息,則將用戶(hù)信息保存到threadLocal中,并且放行。
1.2 實(shí)現(xiàn)發(fā)送短信驗(yàn)證功能
頁(yè)面流程
- 發(fā)送驗(yàn)證碼
@Override
public Result sendCode(String phone, HttpSession session) {
// 1.校驗(yàn)手機(jī)號(hào)
if (RegexUtils.isPhoneInvalid(phone)) {
// 2.如果不符合,返回錯(cuò)誤信息
return Result.fail("手機(jī)號(hào)格式錯(cuò)誤!");
}
// 3.符合,生成驗(yàn)證碼
String code = RandomUtil.randomNumbers(6);
// 4.保存驗(yàn)證碼到 session
session.setAttribute("code",code);
// 5.發(fā)送驗(yàn)證碼
log.debug("發(fā)送短信驗(yàn)證碼成功,驗(yàn)證碼:{}", code);
// 返回ok
return Result.ok();
}
- 登錄
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
// 1.校驗(yàn)手機(jī)號(hào)
String phone = loginForm.getPhone();
if (RegexUtils.isPhoneInvalid(phone)) {
// 2.如果不符合,返回錯(cuò)誤信息
return Result.fail("手機(jī)號(hào)格式錯(cuò)誤!");
}
// 3.校驗(yàn)驗(yàn)證碼
Object cacheCode = session.getAttribute("code");
String code = loginForm.getCode();
if(cacheCode == null || !cacheCode.toString().equals(code)){
//3.不一致,報(bào)錯(cuò)
return Result.fail("驗(yàn)證碼錯(cuò)誤");
}
//一致,根據(jù)手機(jī)號(hào)查詢(xún)用戶(hù)
User user = query().eq("phone", phone).one();
//5.判斷用戶(hù)是否存在
if(user == null){
//不存在,則創(chuàng)建
user = createUserWithPhone(phone);
}
//7.保存用戶(hù)信息到session中
session.setAttribute("user",user);
return Result.ok();
}
1.3 實(shí)現(xiàn)登錄攔截功能
- 攔截器代碼
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//1.獲取session
HttpSession session = request.getSession();
//2.獲取session中的用戶(hù)
Object user = session.getAttribute("user");
//3.判斷用戶(hù)是否存在
if(user == null){
//4.不存在,攔截,返回401狀態(tài)碼
response.setStatus(401);
return false;
}
//5.存在,保存用戶(hù)信息到Threadlocal
UserHolder.saveUser((User)user);
//6.放行
return true;
}
}
- 讓攔截器生效
@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 登錄攔截器
registry.addInterceptor(new LoginInterceptor())
.excludePathPatterns(
"/shop/**",
"/voucher/**",
"/shop-type/**",
"/upload/**",
"/blog/hot",
"/user/code",
"/user/login"
).order(1);
// token刷新的攔截器
registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).addPathPatterns("/**").order(0);
}
}
1.4 隱藏用戶(hù)敏感信息
我們通過(guò)瀏覽器觀察到此時(shí)用戶(hù)的全部信息都在,這樣極為不靠譜,所以我們應(yīng)當(dāng)在返回用戶(hù)信息之前,將用戶(hù)的敏感信息進(jìn)行隱藏,采用的核心思路就是書(shū)寫(xiě)一個(gè)UserDto對(duì)象,這個(gè)UserDto對(duì)象就沒(méi)有敏感信息了,我們?cè)诜祷厍埃瑢⒂杏脩?hù)敏感信息的User對(duì)象轉(zhuǎn)化成沒(méi)有敏感信息的UserDto對(duì)象,那么就能夠避免這個(gè)尷尬的問(wèn)題了。
在登錄方法處修改
//7.保存用戶(hù)信息到session中
session.setAttribute("user", BeanUtils.copyProperties(user,UserDTO.class));
在攔截器處:
//5.存在,保存用戶(hù)信息到Threadlocal
UserHolder.saveUser((UserDTO) user);
在UserHolder處:將user對(duì)象換成UserDTO
public class UserHolder {
private static final ThreadLocal<UserDTO> tl = new ThreadLocal<>();
public static void saveUser(UserDTO user){
tl.set(user);
}
public static UserDTO getUser(){
return tl.get();
}
public static void removeUser(){
tl.remove();
}
}
1.5 session共享問(wèn)題
每個(gè)tomcat中都有一份屬于自己的session,假設(shè)用戶(hù)第一次訪(fǎng)問(wèn)第一臺(tái)tomcat,并且把自己的信息存放到第一臺(tái)服務(wù)器的session中,但是第二次這個(gè)用戶(hù)訪(fǎng)問(wèn)到了第二臺(tái)tomcat,那么在第二臺(tái)服務(wù)器上,肯定沒(méi)有第一臺(tái)服務(wù)器存放的session,所以此時(shí) 整個(gè)登錄攔截功能就會(huì)出現(xiàn)問(wèn)題,我們能如何解決這個(gè)問(wèn)題呢?早期的方案是session拷貝,就是說(shuō)雖然每個(gè)tomcat上都有不同的session,但是每當(dāng)任意一臺(tái)服務(wù)器的session修改時(shí),都會(huì)同步給其他的Tomcat服務(wù)器的session,這樣的話(huà),就可以實(shí)現(xiàn)session的共享了。
但是這種方案具有兩個(gè)大問(wèn)題:
-
每臺(tái)服務(wù)器中都有完整的一份session數(shù)據(jù),服務(wù)器壓力過(guò)大。
-
session拷貝數(shù)據(jù)時(shí),可能會(huì)出現(xiàn)延遲。
所以咱們后來(lái)采用的方案都是基于redis來(lái)完成,我們把session換成redis,redis數(shù)據(jù)本身就是共享的,就可以避免session共享的問(wèn)題了。
1.6 Redis代替session的業(yè)務(wù)流程
1.6.1 設(shè)計(jì)key的結(jié)構(gòu)
首先我們要思考一下利用redis來(lái)存儲(chǔ)數(shù)據(jù),那么到底使用哪種結(jié)構(gòu)呢?由于存入的數(shù)據(jù)比較簡(jiǎn)單,我們可以考慮使用String,或者是使用哈希,如下圖,如果使用String,同學(xué)們注意他的value,用多占用一點(diǎn)空間,如果使用哈希,則他的value中只會(huì)存儲(chǔ)他數(shù)據(jù)本身,如果不是特別在意內(nèi)存,其實(shí)使用String就可以啦。
1.6.2 設(shè)計(jì)key的具體細(xì)節(jié)
所以我們可以使用String結(jié)構(gòu),就是一個(gè)簡(jiǎn)單的key,value鍵值對(duì)的方式,但是關(guān)于key的處理,session他是每個(gè)用戶(hù)都有自己的session,但是redis的key是共享的,咱們就不能使用code了。
在設(shè)計(jì)這個(gè)key的時(shí)候,我們之前講過(guò)需要滿(mǎn)足兩點(diǎn):
-
key要具有唯一性
-
key要方便攜帶
如果我們采用phone:手機(jī)號(hào)這個(gè)的數(shù)據(jù)來(lái)存儲(chǔ)當(dāng)然是可以的,但是如果把這樣的敏感數(shù)據(jù)存儲(chǔ)到redis中并且從頁(yè)面中帶過(guò)來(lái)畢竟不太合適,所以我們?cè)诤笈_(tái)生成一個(gè)隨機(jī)串token,然后讓前端帶來(lái)這個(gè)token就能完成我們的整體邏輯了。
1.6.3 整體訪(fǎng)問(wèn)流程
當(dāng)注冊(cè)完成后,用戶(hù)去登錄會(huì)去校驗(yàn)用戶(hù)提交的手機(jī)號(hào)和驗(yàn)證碼,是否一致,如果一致,則根據(jù)手機(jī)號(hào)查詢(xún)用戶(hù)信息,不存在則新建,最后將用戶(hù)數(shù)據(jù)保存到redis,并且生成token作為redis的key,當(dāng)我們校驗(yàn)用戶(hù)是否登錄時(shí),會(huì)去攜帶著token進(jìn)行訪(fǎng)問(wèn),從redis中取出token對(duì)應(yīng)的value,判斷是否存在這個(gè)數(shù)據(jù),如果沒(méi)有則攔截,如果存在則將其保存到threadLocal中,并且放行。
1.7 基于Redis實(shí)現(xiàn)短信登錄
UserServiceImpl代碼
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
// 1.校驗(yàn)手機(jī)號(hào)
String phone = loginForm.getPhone();
if (RegexUtils.isPhoneInvalid(phone)) {
// 2.如果不符合,返回錯(cuò)誤信息
return Result.fail("手機(jī)號(hào)格式錯(cuò)誤!");
}
// 3.從redis獲取驗(yàn)證碼并校驗(yàn)
String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
String code = loginForm.getCode();
if (cacheCode == null || !cacheCode.equals(code)) {
// 不一致,報(bào)錯(cuò)
return Result.fail("驗(yàn)證碼錯(cuò)誤");
}
// 4.一致,根據(jù)手機(jī)號(hào)查詢(xún)用戶(hù) select * from tb_user where phone = ?
User user = query().eq("phone", phone).one();
// 5.判斷用戶(hù)是否存在
if (user == null) {
// 6.不存在,創(chuàng)建新用戶(hù)并保存
user = createUserWithPhone(phone);
}
// 7.保存用戶(hù)信息到 redis中
// 7.1.隨機(jī)生成token,作為登錄令牌
String token = UUID.randomUUID().toString(true);
// 7.2.將User對(duì)象轉(zhuǎn)為HashMap存儲(chǔ)
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
CopyOptions.create()
.setIgnoreNullValue(true)
.setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));
// 7.3.存儲(chǔ)
String tokenKey = LOGIN_USER_KEY + token;
stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);
// 7.4.設(shè)置token有效期
stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES);
// 8.返回token
return Result.ok(token);
}
1.8 解決狀態(tài)登錄刷新問(wèn)題
1.8.1 初始方案思路總結(jié)
在這個(gè)方案中,他確實(shí)可以使用對(duì)應(yīng)路徑的攔截,同時(shí)刷新登錄token令牌的存活時(shí)間,但是現(xiàn)在這個(gè)攔截器他只是攔截需要被攔截的路徑,假設(shè)當(dāng)前用戶(hù)訪(fǎng)問(wèn)了一些不需要攔截的路徑,那么這個(gè)攔截器就不會(huì)生效,所以此時(shí)令牌刷新的動(dòng)作實(shí)際上就不會(huì)執(zhí)行,所以這個(gè)方案他是存在問(wèn)題的。
1.8.2 優(yōu)化方案
既然之前的攔截器無(wú)法對(duì)不需要攔截的路徑生效,那么我們可以添加一個(gè)攔截器,在第一個(gè)攔截器中攔截所有的路徑,把第二個(gè)攔截器做的事情放入到第一個(gè)攔截器中,同時(shí)刷新令牌,因?yàn)榈谝粋€(gè)攔截器有了threadLocal的數(shù)據(jù),所以此時(shí)第二個(gè)攔截器只需要判斷攔截器中的user對(duì)象是否存在即可,完成整體刷新功能。
1.8.3 代碼實(shí)現(xiàn)
RefreshTokenInterceptor
public class RefreshTokenInterceptor implements HandlerInterceptor {
private StringRedisTemplate stringRedisTemplate;
public RefreshTokenInterceptor(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1.獲取請(qǐng)求頭中的token
String token = request.getHeader("authorization");
if (StrUtil.isBlank(token)) {
return true;
}
// 2.基于TOKEN獲取redis中的用戶(hù)
String key = LOGIN_USER_KEY + token;
Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key);
// 3.判斷用戶(hù)是否存在
if (userMap.isEmpty()) {
return true;
}
// 5.將查詢(xún)到的hash數(shù)據(jù)轉(zhuǎn)為UserDTO
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
// 6.存在,保存用戶(hù)信息到 ThreadLocal
UserHolder.saveUser(userDTO);
// 7.刷新token有效期
stringRedisTemplate.expire(key, LOGIN_USER_TTL, TimeUnit.MINUTES);
// 8.放行
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// 移除用戶(hù)
UserHolder.removeUser();
}
}
LoginInterceptor
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1.判斷是否需要攔截(ThreadLocal中是否有用戶(hù))
if (UserHolder.getUser() == null) {
// 沒(méi)有,需要攔截,設(shè)置狀態(tài)碼
response.setStatus(401);
// 攔截
return false;
}
// 有用戶(hù),則放行
return true;
}
}
二、商戶(hù)查詢(xún)緩存
2.1 什么是緩存
緩存(Cache),就是數(shù)據(jù)交換的緩沖區(qū),俗稱(chēng)的緩存就是緩沖區(qū)內(nèi)的數(shù)據(jù),一般從數(shù)據(jù)庫(kù)中獲取,存儲(chǔ)于本地代碼。
2.1.1 為什么要使用緩存
一句話(huà):因?yàn)?strong>速度快,好用
緩存數(shù)據(jù)存儲(chǔ)于代碼中,而代碼運(yùn)行在內(nèi)存中,內(nèi)存的讀寫(xiě)性能遠(yuǎn)高于磁盤(pán),緩存可以大大降低 用戶(hù)訪(fǎng)問(wèn)并發(fā)量帶來(lái)的 服務(wù)器讀寫(xiě)壓力。
實(shí)際開(kāi)發(fā)過(guò)程中,企業(yè)的數(shù)據(jù)量,少則幾十萬(wàn),多則幾千萬(wàn),這么大數(shù)據(jù)量,如果沒(méi)有緩存來(lái)作為"避震器",系統(tǒng)是幾乎撐不住的,所以企業(yè)會(huì)大量運(yùn)用到緩存技術(shù);
但是緩存也會(huì)增加代碼復(fù)雜度和運(yùn)營(yíng)的成本:
2.1.2 如何使用緩存
實(shí)際開(kāi)發(fā)中,會(huì)構(gòu)筑多級(jí)緩存來(lái)使系統(tǒng)運(yùn)行速度進(jìn)一步提升,例如:本地緩存與redis中的緩存并發(fā)使用。
-
瀏覽器緩存:主要是存在于瀏覽器端的緩存
-
應(yīng)用層緩存: 可以分為tomcat本地緩存,比如之前提到的map,或者是使用redis作為緩存。
-
數(shù)據(jù)庫(kù)緩存: 在數(shù)據(jù)庫(kù)中有一片空間是 buffer pool,增改查數(shù)據(jù)都會(huì)先加載到mysql的緩存中。
-
CPU緩存: 當(dāng)代計(jì)算機(jī)最大的問(wèn)題是 cpu性能提升了,但內(nèi)存讀寫(xiě)速度沒(méi)有跟上,所以為了適應(yīng)當(dāng)下的情況,增加了cpu的L1,L2,L3級(jí)的緩存。
2.2 添加客戶(hù)緩存
在我們查詢(xún)商戶(hù)信息時(shí),我們是直接操作從數(shù)據(jù)庫(kù)中去進(jìn)行查詢(xún)的,大致邏輯是這樣,直接查詢(xún)數(shù)據(jù)庫(kù)那肯定慢咯,所以我們需要增加緩存:
@GetMapping("/{id}")
public Result queryShopById(@PathVariable("id") Long id) {
//這里是直接查詢(xún)數(shù)據(jù)庫(kù)
return shopService.queryById(id);
}
2.2.1 緩存模型和思路
標(biāo)準(zhǔn)的操作方式就是查詢(xún)數(shù)據(jù)庫(kù)之前先查詢(xún)緩存,如果緩存數(shù)據(jù)存在,則直接從緩存中返回,如果緩存數(shù)據(jù)不存在,再查詢(xún)數(shù)據(jù)庫(kù),然后將數(shù)據(jù)存入redis。
2.2.2. 代碼實(shí)現(xiàn)
代碼思路:如果緩存有,則直接返回,如果緩存不存在,則查詢(xún)數(shù)據(jù)庫(kù),然后存入redis。
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Override
public Result queryById(Long id) {
String key = "cache:shop:" + id; //給每一個(gè)店鋪設(shè)置一個(gè)key,以便于區(qū)別
//1.從redis查詢(xún)商品緩存
String shopJson = stringRedisTemplate.opsForValue().get(key);
//2.判斷是否存在
if (StrUtil.isNotBlank(shopJson)) {
//3.存在,直接返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
//4.不存在,根據(jù)id查詢(xún)數(shù)據(jù)庫(kù)
Shop shop = getById(id);
//5.不存在,返回錯(cuò)誤
if (shop == null) {
return Result.fail("店鋪不存在!");
}
//6.存在,寫(xiě)入redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop));
//7.返回
return Result.ok(shop);
}
2.3 緩存更新策略
緩存更新是redis為了節(jié)約內(nèi)存而設(shè)計(jì)出來(lái)的一個(gè)東西,主要是因?yàn)閮?nèi)存數(shù)據(jù)寶貴,當(dāng)我們向redis插入太多數(shù)據(jù),此時(shí)就可能會(huì)導(dǎo)致緩存中的數(shù)據(jù)過(guò)多,所以redis會(huì)對(duì)部分?jǐn)?shù)據(jù)進(jìn)行更新,或者把他叫為淘汰更合適。
-
內(nèi)存淘汰: redis自動(dòng)進(jìn)行,當(dāng)redis內(nèi)存達(dá)到咱們?cè)O(shè)定的max-memery的時(shí)候,會(huì)自動(dòng)觸發(fā)淘汰機(jī)制,淘汰掉一些不重要的數(shù)據(jù)(可以自己設(shè)置策略方式)。
-
超時(shí)剔除: 當(dāng)我們給redis設(shè)置了過(guò)期時(shí)間ttl之后,redis會(huì)將超時(shí)的數(shù)據(jù)進(jìn)行刪除,方便咱們繼續(xù)使用緩存。
-
主動(dòng)更新: 我們可以手動(dòng)調(diào)用方法把緩存刪掉,通常用于解決緩存和數(shù)據(jù)庫(kù)不一致問(wèn)題。
2.3.1 數(shù)據(jù)庫(kù)緩存不一致解決方案
由于我們的 緩存的數(shù)據(jù)源來(lái)自于數(shù)據(jù)庫(kù) ,而數(shù)據(jù)庫(kù)的 數(shù)據(jù)是會(huì)發(fā)生變化的 ,因此,如果當(dāng)數(shù)據(jù)庫(kù)中 數(shù)據(jù)發(fā)生變化,而緩存卻沒(méi)有同步 ,此時(shí)就會(huì)有 一致性問(wèn)題存在 ,其后果是:
用戶(hù)使用緩存中的過(guò)時(shí)數(shù)據(jù),就會(huì)產(chǎn)生類(lèi)似多線(xiàn)程數(shù)據(jù)安全問(wèn)題,從而影響業(yè)務(wù),產(chǎn)品口碑等;怎么解決呢?有如下幾種方案:
-
Cache Aside Pattern
人工編碼方式:緩存調(diào)用者在更新完數(shù)據(jù)庫(kù)后再去更新緩存,也稱(chēng)之為雙寫(xiě)方案。 -
Read/Write Through Pattern
: 由系統(tǒng)本身完成,數(shù)據(jù)庫(kù)與緩存的問(wèn)題交由系統(tǒng)本身去處理。 -
Write Behind Caching Pattern
:調(diào)用者只操作緩存,其他線(xiàn)程去異步處理數(shù)據(jù)庫(kù),實(shí)現(xiàn)最終一致。
2.3.2 數(shù)據(jù)庫(kù)與緩存不一致采用什么方案
綜合考慮使用方案一,但是方案一調(diào)用者如何處理呢?這里有幾個(gè)問(wèn)題
操作緩存和數(shù)據(jù)庫(kù)時(shí)有三個(gè)問(wèn)題需要考慮:
如果采用第一個(gè)方案,那么假設(shè)我們每次操作數(shù)據(jù)庫(kù)后,都操作緩存,但是中間如果沒(méi)有人查詢(xún),那么這個(gè)更新動(dòng)作實(shí)際上只有最后一次生效,中間的更新動(dòng)作意義并不大,我們可以把緩存刪除,等待再次查詢(xún)時(shí),將緩存中的數(shù)據(jù)加載出來(lái)
-
刪除緩存還是更新緩存?
- 更新緩存:每次更新數(shù)據(jù)庫(kù)都更新緩存,無(wú)效寫(xiě)操作較多
- 刪除緩存:更新數(shù)據(jù)庫(kù)時(shí)讓緩存失效,查詢(xún)時(shí)再更新緩存
-
如何保證緩存與數(shù)據(jù)庫(kù)的操作的同時(shí)成功或失敗?
- 單體系統(tǒng),將緩存與數(shù)據(jù)庫(kù)操作放在一個(gè)事務(wù)
- 分布式系統(tǒng),利用TCC等分布式事務(wù)方案
應(yīng)該具體操作緩存還是操作數(shù)據(jù)庫(kù),我們應(yīng)當(dāng)是先操作數(shù)據(jù)庫(kù),再刪除緩存,原因在于,如果你選擇第一種方案,在兩個(gè)線(xiàn)程并發(fā)來(lái)訪(fǎng)問(wèn)時(shí),假設(shè)線(xiàn)程1先來(lái),他先把緩存刪了,此時(shí)線(xiàn)程2過(guò)來(lái),他查詢(xún)緩存數(shù)據(jù)并不存在,此時(shí)他寫(xiě)入緩存,當(dāng)他寫(xiě)入緩存后,線(xiàn)程1再執(zhí)行更新動(dòng)作時(shí),實(shí)際上寫(xiě)入的就是舊的數(shù)據(jù),新的數(shù)據(jù)被舊數(shù)據(jù)覆蓋了。
- 先操作緩存還是先操作數(shù)據(jù)庫(kù)?
- 先刪除緩存,再操作數(shù)據(jù)庫(kù)
- 先操作數(shù)據(jù)庫(kù),再刪除緩存
總結(jié):
緩存更新策略的最佳實(shí)踐方案:
-
低一致性需求:使用Redis自帶的內(nèi)存淘汰機(jī)制
-
高一致性需求:主動(dòng)更新,并以超時(shí)剔除作為兜底方案
-
讀操作:
- 緩存命中則直接返回
- 緩存未命中則查詢(xún)數(shù)據(jù)庫(kù),并寫(xiě)入緩存,設(shè)定超時(shí)時(shí)間
-
寫(xiě)操作:
- 先寫(xiě)數(shù)據(jù)庫(kù),然后再刪除緩存
- 要確保數(shù)據(jù)庫(kù)與緩存操作的原子性
-
2.4 實(shí)現(xiàn)店鋪的緩存與數(shù)據(jù)庫(kù)雙寫(xiě)一致
核心思路如下:
修改ShopController中的業(yè)務(wù)邏輯,滿(mǎn)足下面的需求:
-
根據(jù)id查詢(xún)店鋪時(shí),如果緩存未命中,則查詢(xún)數(shù)據(jù)庫(kù),將數(shù)據(jù)庫(kù)結(jié)果寫(xiě)入緩存,并設(shè)置超時(shí)時(shí)間
-
根據(jù)id修改店鋪時(shí),先修改數(shù)據(jù)庫(kù),再刪除緩存
修改重點(diǎn)代碼1:修改ShopServiceImpl的queryById方法
設(shè)置redis緩存時(shí)添加過(guò)期時(shí)間
@Override
public Result queryById(Long id) {
String key = "cache:shop:" + id; //給每一個(gè)店鋪設(shè)置一個(gè)key,以便于區(qū)別
//1.從redis查詢(xún)商品緩存
String shopJson = stringRedisTemplate.opsForValue().get(key);
//2.判斷是否存在
if (StrUtil.isNotBlank(shopJson)) {
//3.存在,直接返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
//4.不存在,根據(jù)id查詢(xún)數(shù)據(jù)庫(kù)
Shop shop = getById(id);
//5.不存在,返回錯(cuò)誤
if (shop == null) {
return Result.fail("店鋪不存在!");
}
//6.存在,寫(xiě)入redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop),30L, TimeUnit.MINUTES);
//7.返回
return Result.ok(shop);
}
修改重點(diǎn)代碼2
代碼分析:通過(guò)之前的淘汰,我們確定了采用刪除策略,來(lái)解決雙寫(xiě)問(wèn)題,當(dāng)我們修改了數(shù)據(jù)之后,然后把緩存中的數(shù)據(jù)進(jìn)行刪除,查詢(xún)時(shí)發(fā)現(xiàn)緩存中沒(méi)有數(shù)據(jù),則會(huì)從mysql中加載最新的數(shù)據(jù),從而避免數(shù)據(jù)庫(kù)和緩存不一致的問(wèn)題。
@Override
@Transactional
public Result update(Shop shop) {
Long id = shop.getId();
if (id == null) {
return Result.fail("店鋪id不能為空");
}
//1.更新數(shù)據(jù)庫(kù)
updateById(shop);
//2.刪除緩存
stringRedisTemplate.delete("cache:shop:" + id);
return Result.ok();
}
2.5 緩存穿透問(wèn)題的解決思路
緩存穿透:緩存穿透是指客戶(hù)端請(qǐng)求的數(shù)據(jù)在緩存和數(shù)據(jù)庫(kù)中都不存在,這樣緩存永遠(yuǎn)不會(huì)生效,這樣請(qǐng)求都會(huì)打到數(shù)據(jù)庫(kù)。
常見(jiàn)的解決方案有兩種:
-
緩存空對(duì)象
- 優(yōu)點(diǎn):實(shí)現(xiàn)簡(jiǎn)單,維護(hù)方便
- 缺點(diǎn):
- 額外的內(nèi)存消耗
- 可能造成短期的不一致
-
布隆過(guò)濾
- 優(yōu)點(diǎn):內(nèi)存占用較少,沒(méi)有多余key
- 缺點(diǎn):
- 實(shí)現(xiàn)復(fù)雜
- 存在誤判可能
緩存空對(duì)象思路分析: 當(dāng)我們客戶(hù)端訪(fǎng)問(wèn)不存在的數(shù)據(jù)時(shí),先請(qǐng)求redis,但是此時(shí)redis中沒(méi)有數(shù)據(jù),此時(shí)會(huì)訪(fǎng)問(wèn)到數(shù)據(jù)庫(kù),但是數(shù)據(jù)庫(kù)中也沒(méi)有數(shù)據(jù),這個(gè)數(shù)據(jù)穿透了緩存,直擊數(shù)據(jù)庫(kù),我們都知道數(shù)據(jù)庫(kù)能夠承載的并發(fā)不如redis這么高,如果大量的請(qǐng)求同時(shí)過(guò)來(lái)訪(fǎng)問(wèn)這種不存在的數(shù)據(jù),這些請(qǐng)求就都會(huì)訪(fǎng)問(wèn)到數(shù)據(jù)庫(kù),簡(jiǎn)單的解決方案就是哪怕這個(gè)數(shù)據(jù)在數(shù)據(jù)庫(kù)中也不存在,我們也把這個(gè)數(shù)據(jù)存入到redis中去,這樣,下次用戶(hù)過(guò)來(lái)訪(fǎng)問(wèn)這個(gè)不存在的數(shù)據(jù),那么在redis中也能找到這個(gè)數(shù)據(jù)就不會(huì)進(jìn)入到數(shù)據(jù)庫(kù)了。
布隆過(guò)濾: 布隆過(guò)濾器其實(shí)采用的是哈希思想來(lái)解決這個(gè)問(wèn)題,通過(guò)一個(gè)龐大的二進(jìn)制數(shù)組,走哈希思想去判斷當(dāng)前這個(gè)要查詢(xún)的這個(gè)數(shù)據(jù)是否存在,如果布隆過(guò)濾器判斷存在,則放行,這個(gè)請(qǐng)求會(huì)去訪(fǎng)問(wèn)redis,哪怕此時(shí)redis中的數(shù)據(jù)過(guò)期了,但是數(shù)據(jù)庫(kù)中一定存在這個(gè)數(shù)據(jù),在數(shù)據(jù)庫(kù)中查詢(xún)出來(lái)這個(gè)數(shù)據(jù)后,再將其放入到redis中,假設(shè)布隆過(guò)濾器判斷這個(gè)數(shù)據(jù)不存在,則直接返回。這種方式優(yōu)點(diǎn)在于節(jié)約內(nèi)存空間,存在誤判,誤判原因在于:布隆過(guò)濾器走的是哈希思想,只要哈希思想,就可能存在哈希沖突。
2.6 編碼解決商品查詢(xún)的緩存穿透問(wèn)題
核心思路如下:
在原來(lái)的邏輯中,我們?nèi)绻l(fā)現(xiàn)這個(gè)數(shù)據(jù)在mysql中不存在,直接就返回404了,這樣是會(huì)存在緩存穿透問(wèn)題的。
現(xiàn)在的邏輯中:如果這個(gè)數(shù)據(jù)不存在,我們不會(huì)返回404 ,還是會(huì)把這個(gè)數(shù)據(jù)寫(xiě)入到Redis中,并且將value設(shè)置為空,當(dāng)再次發(fā)起查詢(xún)時(shí),我們?nèi)绻l(fā)現(xiàn)命中之后,判斷這個(gè)value是否是null,如果是null,則是之前寫(xiě)入的數(shù)據(jù),證明是緩存穿透數(shù)據(jù),如果不是,則直接返回?cái)?shù)據(jù)。
@Override
public Result queryById(Long id) {
String key = "cache:shop:" + id; //給每一個(gè)店鋪設(shè)置一個(gè)key,以便于區(qū)別
//1.從redis查詢(xún)商品緩存
String shopJson = stringRedisTemplate.opsForValue().get(key);
//2.判斷是否存在
if (StrUtil.isNotBlank(shopJson)) {
//3.存在,直接返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
//走到這一步,shopJson是null或者空值
//判斷命中的是否是空值
if (shopJson == "") {
//返回一個(gè)錯(cuò)誤信息
return Result.fail("店鋪信息不存在!");
}
//4.不存在,根據(jù)id查詢(xún)數(shù)據(jù)庫(kù)
Shop shop = getById(id);
//5.不存在,返回錯(cuò)誤
if (shop == null) {
//將空值寫(xiě)入redis
stringRedisTemplate.opsForValue().set(key, "",2L, TimeUnit.MINUTES);
return Result.fail("店鋪不存在!");
}
//6.存在,寫(xiě)入redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop),30L, TimeUnit.MINUTES);
//7.返回
return Result.ok(shop);
}
小總結(jié):
緩存穿透產(chǎn)生的原因是什么?
- 用戶(hù)請(qǐng)求的數(shù)據(jù)在緩存中和數(shù)據(jù)庫(kù)中都不存在,不斷發(fā)起這樣的請(qǐng)求,給數(shù)據(jù)庫(kù)帶來(lái)巨大壓力
緩存穿透的解決方案有哪些?
- 緩存null值
- 布隆過(guò)濾
- 增強(qiáng)id的復(fù)雜度,避免被猜測(cè)id規(guī)律
- 做好數(shù)據(jù)的基礎(chǔ)格式校驗(yàn)
- 加強(qiáng)用戶(hù)權(quán)限校驗(yàn)
- 做好熱點(diǎn)參數(shù)的限流
2.7 緩存雪崩問(wèn)題及解決思路
緩存雪崩: 緩存雪崩是指在同一時(shí)段大量的緩存key同時(shí)生效或者Redis宕機(jī),導(dǎo)致大量請(qǐng)求到達(dá)數(shù)據(jù)庫(kù),帶來(lái)的巨大壓力。
解決方案:
- 給不同的Key的TTL添加隨機(jī)值
- 利用Redis集群提高服務(wù)的可用性
- 給緩存業(yè)務(wù)添加降級(jí)限流策略
- 給業(yè)務(wù)添加多級(jí)緩存
2.8 緩存擊穿問(wèn)題及解決思路
緩存擊穿: 緩存擊穿問(wèn)題也叫熱點(diǎn)Key問(wèn)題,就是一個(gè)被高并發(fā)訪(fǎng)問(wèn)并且緩存重建業(yè)務(wù)較復(fù)雜的key突然失效了,無(wú)數(shù)的請(qǐng)求訪(fǎng)問(wèn)會(huì)在瞬間給數(shù)據(jù)庫(kù)帶來(lái)巨大的沖擊。
常見(jiàn)的解決方案有兩種:
- 互斥鎖
- 邏輯過(guò)期
邏輯分析:假設(shè)線(xiàn)程1在查詢(xún)緩存之后,本來(lái)應(yīng)該去查詢(xún)數(shù)據(jù)庫(kù),然后把這個(gè)數(shù)據(jù)重新加載到緩存的,此時(shí)只要線(xiàn)程1走完這個(gè)邏輯,其他線(xiàn)程就都能從緩存中加載這些數(shù)據(jù)了,但是假設(shè)在線(xiàn)程1沒(méi)有走完的時(shí)候,后續(xù)的線(xiàn)程2,線(xiàn)程3,線(xiàn)程4同時(shí)過(guò)來(lái)訪(fǎng)問(wèn)當(dāng)前這個(gè)方法, 那么這些線(xiàn)程都不能從緩存中查詢(xún)到數(shù)據(jù),那么他們就會(huì)同一時(shí)刻來(lái)訪(fǎng)問(wèn)查詢(xún)緩存,都沒(méi)查到,接著同一時(shí)間去訪(fǎng)問(wèn)數(shù)據(jù)庫(kù),同時(shí)的去執(zhí)行數(shù)據(jù)庫(kù)代碼,對(duì)數(shù)據(jù)庫(kù)訪(fǎng)問(wèn)壓力過(guò)大。
解決方案一、使用鎖來(lái)解決:
因?yàn)殒i能實(shí)現(xiàn)互斥性。假設(shè)線(xiàn)程過(guò)來(lái),只能一個(gè)人一個(gè)人的來(lái)訪(fǎng)問(wèn)數(shù)據(jù)庫(kù),從而避免對(duì)于數(shù)據(jù)庫(kù)訪(fǎng)問(wèn)壓力過(guò)大,但這也會(huì)影響查詢(xún)的性能,因?yàn)榇藭r(shí)會(huì)讓查詢(xún)的性能從并行變成了串行,我們可以采用tryLock方法 + double check來(lái)解決這樣的問(wèn)題。
假設(shè)現(xiàn)在線(xiàn)程1過(guò)來(lái)訪(fǎng)問(wèn),他查詢(xún)緩存沒(méi)有命中,但是此時(shí)他獲得到了鎖的資源,那么線(xiàn)程1就會(huì)一個(gè)人去執(zhí)行邏輯,假設(shè)現(xiàn)在線(xiàn)程2過(guò)來(lái),線(xiàn)程2在執(zhí)行過(guò)程中,并沒(méi)有獲得到鎖,那么線(xiàn)程2就可以進(jìn)行到休眠,直到線(xiàn)程1把鎖釋放后,線(xiàn)程2獲得到鎖,然后再來(lái)執(zhí)行邏輯,此時(shí)就能夠從緩存中拿到數(shù)據(jù)了。
解決方案二、邏輯過(guò)期方案
方案分析:我們之所以會(huì)出現(xiàn)這個(gè)緩存擊穿問(wèn)題,主要原因是在于我們對(duì)key設(shè)置了過(guò)期時(shí)間,假設(shè)我們不設(shè)置過(guò)期時(shí)間,其實(shí)就不會(huì)有緩存擊穿的問(wèn)題,但是不設(shè)置過(guò)期時(shí)間,這樣數(shù)據(jù)不就一直占用我們內(nèi)存了嗎,我們可以采用邏輯過(guò)期方案。
我們把過(guò)期時(shí)間設(shè)置在 redis的value中,注意:這個(gè)過(guò)期時(shí)間并不會(huì)直接作用于redis,而是我們后續(xù)通過(guò)邏輯去處理。假設(shè)線(xiàn)程1去查詢(xún)緩存,然后從value中判斷出來(lái)當(dāng)前的數(shù)據(jù)已經(jīng)過(guò)期了,此時(shí)線(xiàn)程1去獲得互斥鎖,那么其他線(xiàn)程會(huì)進(jìn)行阻塞,獲得了鎖的線(xiàn)程他會(huì)開(kāi)啟一個(gè) 線(xiàn)程去進(jìn)行 以前的重構(gòu)數(shù)據(jù)的邏輯,直到新開(kāi)的線(xiàn)程完成這個(gè)邏輯后,才釋放鎖, 而線(xiàn)程1直接進(jìn)行返回,假設(shè)現(xiàn)在線(xiàn)程3過(guò)來(lái)訪(fǎng)問(wèn),由于線(xiàn)程線(xiàn)程2持有著鎖,所以線(xiàn)程3無(wú)法獲得鎖,線(xiàn)程3也直接返回?cái)?shù)據(jù),只有等到新開(kāi)的線(xiàn)程2把重建數(shù)據(jù)構(gòu)建完后,其他線(xiàn)程才能走返回正確的數(shù)據(jù)。
這種方案巧妙在于,異步的構(gòu)建緩存,缺點(diǎn)在于在構(gòu)建完緩存之前,返回的都是臟數(shù)據(jù)。
進(jìn)行對(duì)比
互斥鎖方案: 由于保證了互斥性,所以數(shù)據(jù)一致,且實(shí)現(xiàn)簡(jiǎn)單,因?yàn)閮H僅只需要加一把鎖而已,也沒(méi)其他的事情需要操心,所以沒(méi)有額外的內(nèi)存消耗,缺點(diǎn)在于有鎖就有死鎖問(wèn)題的發(fā)生,且只能串行執(zhí)行性能肯定受到影響。
邏輯過(guò)期方案: 線(xiàn)程讀取過(guò)程中不需要等待,性能好,有一個(gè)額外的線(xiàn)程持有鎖去進(jìn)行重構(gòu)數(shù)據(jù),但是在重構(gòu)數(shù)據(jù)完成前,其他的線(xiàn)程只能返回之前的數(shù)據(jù),且實(shí)現(xiàn)起來(lái)麻煩。
2.9 利用互斥鎖解決緩存擊穿問(wèn)題
核心思路:相較于原來(lái)從緩存中查詢(xún)不到數(shù)據(jù)后直接查詢(xún)數(shù)據(jù)庫(kù)而言,現(xiàn)在的方案是 進(jìn)行查詢(xún)之后,如果從緩存沒(méi)有查詢(xún)到數(shù)據(jù),則進(jìn)行互斥鎖的獲取,獲取互斥鎖后,判斷是否獲得到了鎖,如果沒(méi)有獲得到,則休眠,過(guò)一會(huì)再進(jìn)行嘗試,直到獲取到鎖為止,才能進(jìn)行查詢(xún)。
如果獲取到了鎖的線(xiàn)程,再去進(jìn)行查詢(xún),查詢(xún)后將數(shù)據(jù)寫(xiě)入redis,再釋放鎖,返回?cái)?shù)據(jù),利用互斥鎖就能保證只有一個(gè)線(xiàn)程去執(zhí)行操作數(shù)據(jù)庫(kù)的邏輯,防止緩存擊穿。
操作鎖的代碼:
核心思路就是利用redis的setnx方法來(lái)表示獲取鎖,該方法含義是redis中如果沒(méi)有這個(gè)key,則插入成功,返回1,在stringRedisTemplate中返回true, 如果有這個(gè)key則插入失敗,則返回0,在stringRedisTemplate返回false,我們可以通過(guò)true,或者是false,來(lái)表示是否有線(xiàn)程成功插入key,成功插入的key的線(xiàn)程我們認(rèn)為他就是獲得到鎖的線(xiàn)程。
//獲取互斥鎖
private boolean tryLock(String key) {
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
//釋放鎖
private void unlock(String key) {
stringRedisTemplate.delete(key);
}
操作代碼:
//緩存擊穿
public Shop queryWithMutex(Long id) {
String key = "cache:shop:" + id; //給每一個(gè)店鋪設(shè)置一個(gè)key,以便于區(qū)別
//1.從redis查詢(xún)商品緩存
String shopJson = stringRedisTemplate.opsForValue().get(key);
//2.判斷是否存在
if (StrUtil.isNotBlank(shopJson)) {
//3.存在,直接返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return shop;
}
//走到這一步,shopJson是null或者空值
//判斷命中的是否是空值
if (shopJson == "") {
//返回一個(gè)錯(cuò)誤信息
return null;
}
//4. 實(shí)現(xiàn)緩存重建
//4.1 獲取互斥鎖
String lockKey = "lock:shop:" + id;
Shop shop = null;
try {
boolean isLock = tryLock(lockKey);
//4.2 判斷是否獲取成功
if (!isLock) {
//4.3 失敗,則休眠重試
Thread.sleep(50);
return queryWithMutex(id);
}
//4.4 成功,根據(jù)id查詢(xún)數(shù)據(jù)庫(kù)
shop = getById(id);
//5.不存在,返回錯(cuò)誤
if (shop == null) {
//將空值寫(xiě)入redis
stringRedisTemplate.opsForValue().set(key, "",2L, TimeUnit.MINUTES);
return null;
}
//6.存在,寫(xiě)入redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop),30L, TimeUnit.MINUTES);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
//7.釋放互斥鎖
unlock(lockKey);
}
return shop;
}
2.10 利用邏輯過(guò)期解決緩存擊穿問(wèn)題
需求:修改根據(jù)id查詢(xún)商鋪的業(yè)務(wù),基于邏輯過(guò)期方式來(lái)解決緩存擊穿問(wèn)題
思路分析:當(dāng)用戶(hù)開(kāi)始查詢(xún)r(jià)edis時(shí),判斷是否命中,如果沒(méi)有命中則直接返回空數(shù)據(jù),不查詢(xún)數(shù)據(jù)庫(kù),而一旦命中后,將value取出,判斷value中的過(guò)期時(shí)間是否滿(mǎn)足,如果沒(méi)有過(guò)期,則直接返回redis中的數(shù)據(jù),如果過(guò)期,則在開(kāi)啟獨(dú)立線(xiàn)程后直接返回之前的數(shù)據(jù),獨(dú)立線(xiàn)程去重構(gòu)數(shù)據(jù),重構(gòu)完成后釋放互斥鎖。
如果封裝數(shù)據(jù):因?yàn)楝F(xiàn)在redis中存儲(chǔ)的數(shù)據(jù)的value需要帶上過(guò)期時(shí)間,此時(shí)要么你去修改原來(lái)的實(shí)體類(lèi),要么你新建一個(gè)實(shí)體類(lèi),我們采用第二個(gè)方案,這個(gè)方案,對(duì)原來(lái)代碼沒(méi)有侵入性。
- 新建實(shí)體類(lèi)
@Data
public class RedisData {
private LocalDateTime expireTime;
private Object data;
}
- 在ShopServiceImpl 新增此方法,利用單元測(cè)試進(jìn)行緩存預(yù)熱
public void saveShop2Redis(Long id, Long expireSeconds) {
//1.查詢(xún)店鋪數(shù)據(jù)
Shop shop = getById(id);
//2.封裝邏輯過(guò)期時(shí)間
RedisData redisData = new RedisData();
redisData.setData(shop);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
//3.寫(xiě)入redis
stringRedisTemplate.opsForValue().set("cache:shop:" + id, JSONUtil.toJsonStr(redisData));
}
- 在測(cè)試類(lèi)中
@Test
void testSaveShop() {
shopService.saveShop2Redis(1L, 10L);
}
- ShopServiceImpl
public Shop queryWithLogicalExpire(Long id) {
String key = "cache:shop:" + id; //給每一個(gè)店鋪設(shè)置一個(gè)key,以便于區(qū)別
//1.從redis查詢(xún)商品緩存
String shopJson = stringRedisTemplate.opsForValue().get(key);
//2.判斷是否存在
if (StrUtil.isBlank(shopJson)) {
//3.不存在,返回null
return null;
}
//4.命中,需要先把json反序列化為對(duì)象
RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
LocalDateTime expireTime = redisData.getExpireTime();
//5.判斷是否過(guò)期
if (expireTime.isAfter(LocalDateTime.now())) {
//5.1未過(guò)期,直接返回店鋪信息
return shop;
}
//5.2已過(guò)期,需要緩存重建
//6.緩存重建
//6.1獲取互斥鎖
String lockKey = "lock:shop:" + id;
boolean isLock = tryLock(lockKey);
//6.2判斷是否獲取鎖成功
if (isLock) {
// 6.3 成功,開(kāi)啟獨(dú)立線(xiàn)程,實(shí)現(xiàn)緩存重建
CACHE_REBUILD_EXECUTOR.submit(() ->{
try {
// 重建緩存
this.saveShop2Redis(id, 20L);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
//釋放鎖
unlock(lockKey);
}
});
}
//6.4返回過(guò)期的商鋪信息
return shop;
}
2.11 封裝Redis工具類(lèi)
基于StringRedisTemplate封裝一個(gè)緩存工具類(lèi),滿(mǎn)足下列需求:
-
方法1:將任意Java對(duì)象序列化為json并存儲(chǔ)在string類(lèi)型的key中,并且可以設(shè)置TTL過(guò)期時(shí)間
-
方法2:將任意Java對(duì)象序列化為json并存儲(chǔ)在string類(lèi)型的key中,并且可以設(shè)置邏輯過(guò)期時(shí)間,用于處理緩存擊穿問(wèn)題
-
方法3:根據(jù)指定的key查詢(xún)緩存,并反序列化為指定類(lèi)型,利用緩存空值的方式解決緩存穿透問(wèn)題
-
方法4:根據(jù)指定的key查詢(xún)緩存,并反序列化為指定類(lèi)型,需要利用邏輯過(guò)期解決緩存擊穿問(wèn)題將邏輯進(jìn)行封裝
@Slf4j
@Component
public class CacheClient {
private final StringRedisTemplate stringRedisTemplate;
//創(chuàng)建一個(gè)線(xiàn)程池
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
public CacheClient(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
public void set(String key, Object value, Long time, TimeUnit unit) {
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, unit);
}
public void setWithLogicalExpire(String key, Object value, Long time, TimeUnit unit) {
// 設(shè)置邏輯過(guò)期
RedisData redisData = new RedisData();
redisData.setData(value);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));
// 寫(xiě)入Redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
}
//緩存穿透
public <R,ID> R queryWithPassThrough(
String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit){
String key = keyPrefix + id;
// 1.從redis查詢(xún)商鋪緩存
String json = stringRedisTemplate.opsForValue().get(key);
// 2.判斷是否存在
if (StrUtil.isNotBlank(json)) {
// 3.存在,直接返回
return JSONUtil.toBean(json, type);
}
// 判斷命中的是否是空值
if (json != null) {
// 返回一個(gè)錯(cuò)誤信息
return null;
}
// 4.不存在,根據(jù)id查詢(xún)數(shù)據(jù)庫(kù)
R r = dbFallback.apply(id);
// 5.不存在,返回錯(cuò)誤
if (r == null) {
// 將空值寫(xiě)入redis
stringRedisTemplate.opsForValue().set(key, "", 30L, TimeUnit.MINUTES);
// 返回錯(cuò)誤信息
return null;
}
// 6.存在,寫(xiě)入redis
this.set(key, r, time, unit);
return r;
}
//邏輯過(guò)期解決緩存擊穿
public <R, ID> R queryWithLogicalExpire(
String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {
String key = keyPrefix + id;
// 1.從redis查詢(xún)商鋪緩存
String json = stringRedisTemplate.opsForValue().get(key);
// 2.判斷是否存在
if (StrUtil.isBlank(json)) {
// 3.存在,直接返回
return null;
}
// 4.命中,需要先把json反序列化為對(duì)象
RedisData redisData = JSONUtil.toBean(json, RedisData.class);
R r = JSONUtil.toBean((JSONObject) redisData.getData(), type);
LocalDateTime expireTime = redisData.getExpireTime();
// 5.判斷是否過(guò)期
if(expireTime.isAfter(LocalDateTime.now())) {
// 5.1.未過(guò)期,直接返回店鋪信息
return r;
}
// 5.2.已過(guò)期,需要緩存重建
// 6.緩存重建
// 6.1.獲取互斥鎖
String lockKey = "lock:shop:" + id;
boolean isLock = tryLock(lockKey);
// 6.2.判斷是否獲取鎖成功
if (isLock){
// 6.3.成功,開(kāi)啟獨(dú)立線(xiàn)程,實(shí)現(xiàn)緩存重建
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
// 查詢(xún)數(shù)據(jù)庫(kù)
R newR = dbFallback.apply(id);
// 重建緩存
this.setWithLogicalExpire(key, newR, time, unit);
} catch (Exception e) {
throw new RuntimeException(e);
}finally {
// 釋放鎖
unlock(lockKey);
}
});
}
// 6.4.返回過(guò)期的商鋪信息
return r;
}
//互斥鎖解決緩存擊穿
public <R, ID> R queryWithMutex(
String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {
String key = keyPrefix + id;
// 1.從redis查詢(xún)商鋪緩存
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2.判斷是否存在
if (StrUtil.isNotBlank(shopJson)) {
// 3.存在,直接返回
return JSONUtil.toBean(shopJson, type);
}
// 判斷命中的是否是空值
if (shopJson != null) {
// 返回一個(gè)錯(cuò)誤信息
return null;
}
// 4.實(shí)現(xiàn)緩存重建
// 4.1.獲取互斥鎖
String lockKey = "lock:shop:" + id;
R r = null;
try {
boolean isLock = tryLock(lockKey);
// 4.2.判斷是否獲取成功
if (!isLock) {
// 4.3.獲取鎖失敗,休眠并重試
Thread.sleep(50);
return queryWithMutex(keyPrefix, id, type, dbFallback, time, unit);
}
// 4.4.獲取鎖成功,根據(jù)id查詢(xún)數(shù)據(jù)庫(kù)
r = dbFallback.apply(id);
// 5.不存在,返回錯(cuò)誤
if (r == null) {
// 將空值寫(xiě)入redis
stringRedisTemplate.opsForValue().set(key, "", 30L, TimeUnit.MINUTES);
// 返回錯(cuò)誤信息
return null;
}
// 6.存在,寫(xiě)入redis
this.set(key, r, time, unit);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}finally {
// 7.釋放鎖
unlock(lockKey);
}
// 8.返回
return r;
}
//獲取互斥鎖
private boolean tryLock(String key) {
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
//釋放鎖
private void unlock(String key) {
stringRedisTemplate.delete(key);
}
}
在 ShopServiceImpl
中:文章來(lái)源:http://www.zghlxwxcb.cn/news/detail-534985.html
@Resource
private CacheClient cacheClient;
@Override
public Result queryById(Long id) {
// 解決緩存穿透
Shop shop = cacheClient
.queryWithPassThrough("CACHE:SHOP:", id, Shop.class, this::getById, 30L, TimeUnit.MINUTES);
// 互斥鎖解決緩存擊穿
// Shop shop = cacheClient
// .queryWithMutex("CACHE:SHOP:", id, Shop.class, this::getById, 30L TimeUnit.MINUTES);
// 邏輯過(guò)期解決緩存擊穿
// Shop shop = cacheClient
// .queryWithLogicalExpire("CACHE:SHOP:", id, Shop.class, this::getById, 20L, TimeUnit.SECONDS);
if (shop == null) {
return Result.fail("店鋪不存在!");
}
// 7.返回
return Result.ok(shop);
}
?
非常感謝您閱讀到這里,如果這篇文章對(duì)您有幫助,希望能留下您的點(diǎn)贊?? 關(guān)注?? 分享?? 留言??thanks!??!文章來(lái)源地址http://www.zghlxwxcb.cn/news/detail-534985.html
到了這里,關(guān)于仿大眾點(diǎn)評(píng)項(xiàng)目 —— Day01【短信登錄、商戶(hù)查詢(xún)緩存】的文章就介紹完了。如果您還想了解更多內(nèi)容,請(qǐng)?jiān)谟疑辖撬阉鱐OY模板網(wǎng)以前的文章或繼續(xù)瀏覽下面的相關(guān)文章,希望大家以后多多支持TOY模板網(wǎng)!