在業務初始階段,流量很少的情況下,透過直接運算元據是可行的操作,但是隨著業務量的增長,使用者的訪問量也隨之增加,在該階段自然需要使用一些手段(快取)來減輕資料庫的壓力;所謂遇事不決,那就加一層。
在當前技術棧中,redis當屬快取的第一梯隊了,但是隨著快取的引入,業務架構和問題也隨之而來。
快取好處:
- 降低後端負載
- 提高讀寫效率,降低響應時間
快取成本:
- 資料一致性成本
- 程式碼維護成本
- 運維成本
場景選擇
快取更新策略
記憶體淘汰:
redis自動進行,當redis記憶體達到我們們設定的max-memery的時候,會自動觸發淘汰機制,淘汰掉一些不重要的資料(可以自己設定策略方式)
寶塔redis配置圖:
超時剔除:當我們給redis設定了過期時間ttl之後,redis會將超時的資料進行刪除,方便我們們繼續使用快取
主動更新:我們可以手動呼叫方法把快取刪掉,通常用於解決快取和資料庫不一致問題
業務場景:
- 低一致性需求:使用記憶體淘汰機制。
- 高一致性需求:主動更新,並以超時剔除作為兜底方案
資料快取不一致的解決方案
-
刪除快取還是更新快取?
-
- 更新快取:每次更新資料庫都更新快取,無效寫操作較多
- 刪除快取(V):更新資料庫時讓快取失效,查詢時再更新快取
-
如何保證快取與資料庫的操作的同時成功或失敗?
-
- 單體系統,將快取與資料庫操作放在一個事務
- 分散式系統,利用TCC等分散式事務方案
-
先操作快取還是先運算元據庫?
-
- 先刪除快取,再運算元據庫
- 先運算元據庫,再刪除快取(V)
結論:先運算元據庫,在操作快取
第一種(淘汰):
假設執行緒1先來,他先把快取刪了,此時執行緒2過來,他查詢快取資料並不存在,此時他寫入快取,當他寫入快取後,執行緒1再執行更新動作時,實際上寫入的就是舊的資料,新的資料被舊資料覆蓋了。
第二種:也會出現一個時差的問題,但是需要滿足以下條件
-
兩個讀寫執行緒同時訪問
-
快取剛好失效(查詢未命中)
-
線上程一寫入快取的時間內,執行緒二要完成資料庫的更新和刪除快取
-
- 快取寫入速度很快
- 寫資料庫一般會先「加鎖」,所以寫資料庫,通常是要比讀資料庫的時間更長的
以上擇優原則先運算元據後刪除快取的
場景實現
該場景實現流程:以下分析結合部分程式碼(聚焦於redis的實現);
完整後端程式碼可在Github中獲取:https://github.com/xbhog/hm-dianping
開發流程:
【查詢店鋪快取流程】
-
從redis中查詢店鋪資訊
- 命中快取:返回店鋪資訊
- 未命中:查詢資料庫(2)
-
查詢資料庫
-
結果為空:店鋪資訊不存在
-
設定店鋪快取
public Result queryById(Long id) {
//從redis查詢商鋪資訊
String shopInfo = stringRedisTemplate.opsForValue().get(SHOP_CACHE_KEY + id);
//命中快取,返回店鋪資訊
if(StrUtil.isNotBlank(shopInfo)){
Shop shop = JSONUtil.toBean(shopInfo, Shop.class);
return Result.ok(shop);
}
//未命中快取
Shop shop = getById(id);
if(Objects.isNull(shop)){
return Result.fail("店鋪不存在");
}
//物件轉字串
stringRedisTemplate.opsForValue().set(SHOP_CACHE_KEY+id,JSONUtil.toJsonStr(shop),30L, TimeUnit.MINUTES);
return Result.ok(shop);
}
在設定店鋪快取的時候,設定了失效時間(保證快取的利用率)---滿足高一致性需求:主動更新,並以超時剔除作為兜底方案;
然後在後臺修改店鋪資訊的時候,先修改資料庫,然後刪除快取;
@Override
@Transactional
public Result updateShopById(Shop shop) {
Long id = shop.getId();
if(ObjectUtil.isNull(id)){
return Result.fail("====>店鋪ID不能為空");
}
log.info("====》開始更新資料庫");
//更新資料庫
updateById(shop);
stringRedisTemplate.delete(SHOP_CACHE_KEY + id);
return Result.ok();
}
這裡有一個點,在方法上設定事務,當資料庫更新成功,刪除快取(相當於更新快取);因為這裡刪除快取後,下次訪問店鋪資訊的時候,查詢資料庫會重新建立快取。
場景問題
雖然上述刪除快取的不管在前還是後面流程異常,都不會影響快取的使用。但是不是雙方一致,而是有所取捨(舍的快取);
保證資料庫和快取都一致的方式:
重試:****無論是先操作快取,還是先運算元據庫,但凡後者執行失敗了,我們就可以發起重試,儘可能地去做「補償」。
- 同步重試(不可取)
- 立即重試很大機率還會失敗
- 重試次數取值
- 重試會佔用當前這個執行緒資源,阻塞操作。
- 非同步重試(MQ)
- canal
非同步重試:RocketMQ
完整後端程式碼可在Github中獲取:https://github.com/xbhog/hm-dianping
RocketMQ叢集的搭建和使用:https://www.cnblogs.com/xbhog/p/17003037.html
在上面店鋪資訊修改的時候,我們更新了資料庫後刪除redis快取,為了避免第二步的執行失敗,我們將redis的操作放到訊息佇列中,由消費者來操作快取。
引用:
- 訊息佇列保證可靠性:寫到佇列中的訊息,成功消費之前不會丟失(重啟專案也不擔心)
- 訊息佇列保證訊息成功投遞:下游從佇列拉取訊息,成功消費後才會刪除訊息,否則還會繼續投遞訊息給消費者(符合我們重試的場景)
至於寫佇列失敗和訊息佇列的維護成本問題:
- 寫佇列失敗:操作快取和寫訊息佇列,「同時失敗」的機率其實是很小的
- 維護成本:我們專案中一般都會用到訊息佇列,維護成本並沒有新增很多
程式碼實現:
配置pom.xml和application.yaml
<rocketmq-spring-boot-starter-version>2.0.3</rocketmq-spring-boot-starter-version>
<dependency>
<groupId>org.apache.rocketmq</groupId>
<artifactId>rocketmq-client</artifactId>
<version>4.9.3</version>
</dependency>
<dependency>
<groupId>org.apache.rocketmq</groupId>
<artifactId>rocketmq-spring-boot-starter</artifactId>
<version>${rocketmq-spring-boot-starter-version}</version>
</dependency>
rocketmq:
name-server: xxx.xxx.xxx.174:9876;xxx.xxx.xxx.246:9876
producer:
group: shopDataGroup
在更新店鋪的操作中引入MQ,非同步傳送資訊:
@Override
@Transactional
public Result updateShopById(Shop shop) {
Long id = shop.getId();
if(ObjectUtil.isNull(id)){
return Result.fail("====>店鋪ID不能為空");
}
log.info("====》開始更新資料庫");
//更新資料庫
updateById(shop);
String shopRedisKey = SHOP_CACHE_KEY + id;
Message message = new Message(TOPIC_SHOP,"shopRe",shopRedisKey.getBytes());
//非同步傳送MQ
try {
rocketMQTemplate.getProducer().send(message);
} catch (Exception e) {
log.info("=========>傳送非同步訊息失敗:{}",e.getMessage());
}
//stringRedisTemplate.delete(SHOP_CACHE_KEY + id);
//int i = 1/0; 驗證異常流程後,
return Result.ok();
}
設定消費者監聽器:
package com.hmdp.mq;
/**
* @author xbhog
* @describe:
* @date 2022/12/21
*/
@Slf4j
@Component
@RocketMQMessageListener(topic = TOPIC_SHOP,consumerGroup = "shopRe",
messageModel = MessageModel.CLUSTERING)
public class RocketMqNessageListener implements RocketMQListener<MessageExt> {
@Resource
private StringRedisTemplate stringRedisTemplate;
@SneakyThrows
@Override
public void onMessage(MessageExt message) {
log.info("========>非同步消費開始");
String body = null;
body = new String(message.getBody(), "UTF-8");
stringRedisTemplate.delete(body);
int reconsumeTimes = message.getReconsumeTimes();
log.info("======>重試次數{}",reconsumeTimes);
if(reconsumeTimes > 3){
log.info("消費失敗:{}",body);
return;
}
throw new RuntimeException("模擬異常丟擲");
}
}
檢視重試結果:
====》開始更新資料庫
36:29.174 DEBUG 69636 --- [nio-8081-exec-2] com.hmdp.mapper.ShopMapper.updateById : ==> Preparing: UPDATE tb_shop SET name=?, type_id=?, area=?, address=?, avg_price=?, sold=?, comments=?, score=?, open_hours=? WHERE id=?
36:29.192 DEBUG 69636 --- [nio-8081-exec-2] com.hmdp.mapper.ShopMapper.updateById : ==> Parameters: 102茶餐廳(String), 1(Long), 大關(String), 金華路錦昌文華苑29號(String), 80(Long), 4215(Integer), 3035(Integer), 37(Integer), 10:00-22:00(String), 1(Long)
36:29.301 DEBUG 69636 --- [nio-8081-exec-2] com.hmdp.mapper.ShopMapper.updateById : <== Updates: 1
36:29.744 INFO 69636 --- [Thread_shopRe_1] com.hmdp.mq.RocketMqNessageListener : ========>非同步消費開始
36:30.011 INFO 69636 --- [Thread_shopRe_1] com.hmdp.mq.RocketMqNessageListener : ======>重試次數0
36:30.014 WARN 69636 --- [Thread_shopRe_1] a.r.s.s.DefaultRocketMQListenerContainer : consume message failed. messageExt:.......
java.lang.RuntimeException: 模擬異常丟擲
.......
36:42.636 INFO 69636 --- [Thread_shopRe_2] com.hmdp.mq.RocketMqNessageListener : ========>非同步消費開始
36:42.689 INFO 69636 --- [Thread_shopRe_2] com.hmdp.mq.RocketMqNessageListener : ======>重試次數1
36:42.689 WARN 69636 --- [Thread_shopRe_2] a.r.s.s.DefaultRocketMQListenerContainer : consume message failed. messageExt:.......
java.lang.RuntimeException: 模擬異常丟擲
.......
37:12.764 INFO 69636 --- [Thread_shopRe_3] com.hmdp.mq.RocketMqNessageListener : ========>非同步消費開始
37:12.820 INFO 69636 --- [Thread_shopRe_3] com.hmdp.mq.RocketMqNessageListener : ======>重試次數2
37:12.821 WARN 69636 --- [Thread_shopRe_3] a.r.s.s.DefaultRocketMQListenerContainer : consume message failed. messageExt:MessageExt .......
java.lang.RuntimeException: 模擬異常丟擲
.......
38:12.896 INFO 69636 --- [Thread_shopRe_4] com.hmdp.mq.RocketMqNessageListener : ========>非同步消費開始
38:12.960 INFO 69636 --- [Thread_shopRe_4] com.hmdp.mq.RocketMqNessageListener : ======>重試次數3
38:12.960 WARN 69636 --- [Thread_shopRe_4] a.r.s.s.DefaultRocketMQListenerContainer : consume message failed. messageExt:MessageExt .......
java.lang.RuntimeException: 模擬異常丟擲
.......
40:13.045 INFO 69636 --- [Thread_shopRe_5] com.hmdp.mq.RocketMqNessageListener : ========>非同步消費開始
40:13.110 INFO 69636 --- [Thread_shopRe_5] com.hmdp.mq.RocketMqNessageListener : ======>重試次數4
40:13.110 INFO 69636 --- [Thread_shopRe_5] com.hmdp.mq.RocketMqNessageListener : 消費失敗:cache:shop:1