【Redis場景2】快取更新策略(雙寫一致)

2022-12-25 18:00:32

在業務初始階段,流量很少的情況下,通過直接運算元據是可行的操作,但是隨著業務量的增長,使用者的存取量也隨之增加,在該階段自然需要使用一些手段(快取)來減輕資料庫的壓力;所謂遇事不決,那就加一層。

在當前技術棧中,redis當屬快取的第一梯隊了,但是隨著快取的引入,業務架構和問題也隨之而來。

快取好處:

  1. 降低後端負載
  2. 提高讀寫效率,降低響應時間

快取成本:

  1. 資料一致性成本
  2. 程式碼維護成本
  3. 運維成本

場景選擇

快取更新策略

記憶體淘汰:

redis自動進行,當redis記憶體達到咱們設定的max-memery的時候,會自動觸發淘汰機制,淘汰掉一些不重要的資料(可以自己設定策略方式)

寶塔redis設定圖:

超時剔除:當我們給redis設定了過期時間ttl之後,redis會將超時的資料進行刪除,方便咱們繼續使用快取

主動更新:我們可以手動呼叫方法把快取刪掉,通常用於解決快取和資料庫不一致問題

業務場景:

  1. 低一致性需求:使用記憶體淘汰機制。
  2. 高一致性需求:主動更新,並以超時剔除作為兜底方案

資料快取不一致的解決方案

  • 刪除快取還是更新快取?

    • 更新快取:每次更新資料庫都更新快取,無效寫操作較多
    • 刪除快取(V):更新資料庫時讓快取失效,查詢時再更新快取
  • 如何保證快取與資料庫的操作的同時成功或失敗?

    • 單體系統,將快取與資料庫操作放在一個事務
    • 分散式系統,利用TCC等分散式事務方案
  • 先操作快取還是先運算元據庫?

    • 先刪除快取,再運算元據庫
    • 先運算元據庫,再刪除快取(V)

結論:先運算元據庫,在操作快取

第一種(淘汰):

假設執行緒1先來,他先把快取刪了,此時執行緒2過來,他查詢快取資料並不存在,此時他寫入快取,當他寫入快取後,執行緒1再執行更新動作時,實際上寫入的就是舊的資料,新的資料被舊資料覆蓋了。

第二種:也會出現一個時差的問題,但是需要滿足以下條件

  1. 兩個讀寫執行緒同時存取

  2. 快取剛好失效(查詢未命中)

  3. 線上程一寫入快取的時間內,執行緒二要完成資料庫的更新和刪除快取

    1. 快取寫入速度很快
    2. 寫資料庫一般會先「加鎖」,所以寫資料庫,通常是要比讀資料庫的時間更長的

以上擇優原則先運算元據後刪除快取的

場景實現

該場景實現流程:以下分析結合部分程式碼(聚焦於redis的實現);

完整後端程式碼可在Github中獲取:https://github.com/xbhog/hm-dianping

開發流程:

【查詢店鋪快取流程】

  1. 從redis中查詢店鋪資訊

    1. 命中快取:返回店鋪資訊
    2. 未命中:查詢資料庫(2)
  2. 查詢資料庫

  3. 結果為空:店鋪資訊不存在

  4. 設定店鋪快取

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();
}

這裡有一個點,在方法上設定事務,當資料庫更新成功,刪除快取(相當於更新快取);因為這裡刪除快取後,下次存取店鋪資訊的時候,查詢資料庫會重新建立快取。

場景問題

雖然上述刪除快取的不管在前還是後面流程異常,都不會影響快取的使用。但是不是雙方一致,而是有所取捨(舍的快取);

保證資料庫和快取都一致的方式:

重試:****無論是先操作快取,還是先運算元據庫,但凡後者執行失敗了,我們就可以發起重試,儘可能地去做「補償」。

  1. 同步重試(不可取)
  • 立即重試很大概率還會失敗
  • 重試次數取值
  • 重試會佔用當前這個執行緒資源,阻塞操作。
  1. 非同步重試(MQ)
  2. 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