日韩黑丝制服一区视频播放|日韩欧美人妻丝袜视频在线观看|九九影院一级蜜桃|亚洲中文在线导航|青草草视频在线观看|婷婷五月色伊人网站|日本一区二区在线|国产AV一二三四区毛片|正在播放久草视频|亚洲色图精品一区

分享

再談緩存的穿透、數(shù)據(jù)一致性和最終一致性問題

 xujin3 2018-06-17

首先回顧一下之前講了什么:

  • 借鑒 Spring Cache 的思想,使用 AOP + Annotation 等技術(shù)將緩存管理與業(yè)務(wù)邏輯之間進行解耦;

  • 使用 CacheWrapper 對緩存數(shù)據(jù)進行“包裝”,不僅能方便獲取緩存的 TTL 值,并且能解決緩存穿透問題;

  • 可以 Spring EL、Ognl、JavaScript 等表達式,進行緩存動態(tài)管理,比如:生成緩存 Key、緩存時間以及判斷是否進行緩存等;

  • 分布式緩存服務(wù)器 (如 Redis、Memcached) 沒有命名空間,而且對鍵名沒有強制要求,可以使用“命名空間”(namespace)防止鍵沖突,增強項目的可維護性;

  • 使用“拿來主義機制”、“自動加載機制 (確切的說是自動刷新)”以及異步刷新等功能減少并發(fā)回源、并發(fā)寫緩存;

  • 顯示“實時性”要求比較高,但又不易于反向生成緩存 Key 的數(shù)據(jù),可以使用 Redis 的 hash 表進行緩存。當數(shù)據(jù)發(fā)生變更時,可以直接刪除整個 hash 表,來達到實時性的要求;

  • 在事務(wù)環(huán)境下,使用 @CacheDeleteTransactional 注解,實現(xiàn)事務(wù)提交后,主動刪除相關(guān)的緩存數(shù)據(jù),以緩解數(shù)據(jù)不一致問題。

具體可以閱讀之前的文章,下面補充三個方面。

緩存穿透問題

緩存穿透是指查詢一個根本不存在的數(shù)據(jù),緩存和數(shù)據(jù)源都不會命中。出于容錯的考慮,如果從數(shù)據(jù)層查不到數(shù)據(jù)則不寫入緩存,即數(shù)據(jù)源返回值為 null 時,不緩存 null。緩存穿透問題可能會使后端數(shù)據(jù)源負載加大,由于很多后端數(shù)據(jù)源不具備高并發(fā)性,甚至可能造成后端數(shù)據(jù)源宕掉。

AutoLoadCache 框架一方面使用“拿來主義”機制,減少回源請求并發(fā)數(shù)、降低數(shù)據(jù)源的負載,另一方面默認將 null 值使用 CacheWrapper“包裝”后進行緩存。但為了避免數(shù)據(jù)不一致及不必要的內(nèi)存占用,建議縮短緩存過期時間,并增加相關(guān)的主動刪除緩存功能,如下面代碼所示 (代碼一):

public interface UserMapper {    /**    * 根據(jù)用戶 id 獲取用戶信息    **/    @Cache(expire = 1200, expireExpression='null == #retVal ? 120: 1200', key = ''user-byid-' + #args[0]')    UserDO getUserById(Long userId);    /**    * 更新用戶信息    **/    @CacheDelete({ @CacheDeleteKey(value = ''user-byid-' + #args[0].id') })    void updateUser(UserDO user);}

通過 expireExpression 動態(tài)設(shè)置緩存過期時間,上面例子中,getUserById 方法如果沒有返回值,緩存時間為 120 秒,有數(shù)據(jù)時緩存時間為 1200 秒。調(diào)用 updateUser 方法時,刪除'user-byid-{userId}'的緩存。

還要記住一點,數(shù)據(jù)層出現(xiàn)異常時,不能捕獲異常后直接返回 null 值,而是盡量把異常往外拋,讓調(diào)用者知道到底發(fā)生了什么事情,以便于做相應(yīng)的處理。

數(shù)據(jù)一致性問題進行補充

一些初學者使用 AutoloadCache 框架進行管理緩存時,以為在原有的代碼中直接加上 @Cache、@CacheDelete 注解后,就完事了。其實并沒這么簡單,不管你有沒有使用 AutoloadCache 框架,都需要考慮同一份數(shù)據(jù)是否會在多次緩存后,造成緩存無法更新的問題。盡量做到 允許修改的數(shù)據(jù)只被緩存一次,而不被多次緩存,保證數(shù)據(jù)更新時,緩存數(shù)據(jù)也能被同步更新,或者方便做主動清除,換句話說就是盡量緩存不可變數(shù)據(jù)。而如果數(shù)據(jù)更新頻率足夠低,那么在業(yè)務(wù)允許的情況下,則可以直接使用最終一致性方案。下面舉個例子說明這個問題:

業(yè)務(wù)背景:用戶表中有 id, name, password, status 字段,name 字段是登錄名。并且注冊成功后,用戶名不允許被修改。

假設(shè)用戶表中的數(shù)據(jù),如下:

下面是 Mybatis 操作用戶表的 Mapper 類 (代碼二):

public interface UserMapper {    /**    * 根據(jù)用戶 id 獲取用戶信息    **/    @Cache(expire = 1200, key = ''user-byid-' + #args[0]')    UserDO getUserById(Long userId);    /**    * 根據(jù)用戶名獲取用戶信息    **/    @Cache(expire = 1200, key = ''user-byname-' + #args[0]')    UserDO getUserByName(String name);    /**    * 根據(jù)動態(tài)組合查詢條件,獲取用戶列表    **/    @Cache(expire = 1200, key = ''user-list-' + #hash(#args[0])')    List listByCondition(UserCondition condition);    /**    * 添加用戶信息    **/    @CacheDelete({ @CacheDeleteKey(value = ''user-byname-' + #args[0].name') })    void addUser(UserDO user);    /**    * 更新用戶信息    **/    @CacheDelete({ @CacheDeleteKey(value = ''user-byid-' + #args[0].id') })    void updateUser(UserDO user);    /**    * 根據(jù)用戶 ID 刪除用戶記錄    **/    @CacheDelete({ @CacheDeleteKey(value = ''user-byid-' + #args[0]') })    void deleteUserById(Long id);}

假設(shè) alice 登錄后馬上進行修改密碼,并重新登錄驗證新密碼是否生效:

  • 1、alice 登錄時,調(diào)用 getUserByName 方法,獲取 User 數(shù)據(jù),進行登錄驗證。這時會緩存數(shù)據(jù):key 為:user-byname-alice;value 為:{'id':1, 'name':'alice', 'password':'123456', 'status': 1}。

  • 2、此時又有人調(diào) getUserById(1) 方法,會在緩存中增加數(shù)據(jù),key 為:user-byid-1,value 為:{'id':1, 'name':'alice', 'password':'123456', 'status': 1}。此時緩存中 user-byname-alice 和 user-byid-1 這兩個緩存 key 對應(yīng)的數(shù)據(jù)完全一樣,即是同一數(shù)據(jù),被緩存了多次

  • 3、alice 修改登錄密碼 (調(diào)用 updateUser 方法),修改數(shù)據(jù)庫中數(shù)據(jù)的同時刪除 user-byid-1 的緩存數(shù)據(jù),但是沒有刪除 user-byname-alice 的數(shù)據(jù)。

  • 4、alice 重新登錄,想驗證新密碼是否生效時,驗證不通過。

問題已經(jīng)清楚了,那該如何解決呢?

我們都知道 ID 是數(shù)據(jù)的唯一標識,而且它是不允許修改的數(shù)據(jù),不用擔心被修改,所以可以對它重復緩存,那么就可以使用 id 作為中間數(shù)據(jù)。為了讓大家更好地理解,將上面的代碼進行重構(gòu) (代碼三):

public interface UserMapper {    /**     * 根據(jù)用戶 id 獲取用戶信息     * @param id     * @return     */    @Cache(expire=3600,           expireExpression='null == #retVal ? 600: 3600',           key=''user-byid-' + #args[0]')    UserDO getUserById(Long id);     /**      * 根據(jù)用戶名獲取用戶 id      * @param name      * @return      */     @Cache(expire = 1200,            expireExpression='null == #retVal ? 120: 1200',            key = ''userid-byname-' + #args[0]')     Long getUserIdByName(String name);     /**     * 根據(jù)動態(tài)組合查詢條件,獲取用戶 id 列表     * @param condition     * @return     **/     @Cache(expire = 600, key = ''userid-list-' + #hash(#args[0])')     List listIdsByCondition(UserCondition condition);    /**     * 添加用戶信息     * @param user     */    @CacheDelete({        @CacheDeleteKey(value = ''userid-byname-' + #args[0].name')    })    int addUser(UserDO user);    /**     * 更新用戶信息     * @param user     * @return     */    @CacheDelete({        @CacheDeleteKey(value=''user-byid-' + #args[0].id', condition='#retVal > 0')    })    int updateUser(UserDO user);    /**    * 根據(jù)用戶 id 刪除用戶記錄    **/    @CacheDelete({        @CacheDeleteKey(value = ''user-byid-' + #args[0]', condition='#retVal > 0')    })    int deleteUserById(Long id);}@Service@Transactional(readOnly=true)public class UserServiceImpl implements UserService {    @Autowired    private UserMapper userMapper;    @Override    public UserDO getUserById(Long id) {        return userMapper.getUserById(id);    }    @Override    public List listByCondition(UserCondition condition) {        List list = new ArrayList<>();        List ids = userMapper.listIdsByCondition(condition);        if(null != ids && ids.size() > 0) {            for(Long id : ids) {                list.add(userMapper.getUserById(id));            }        }        return list;    }    @Override    @CacheDeleteTransactional    @Transactional(rollbackFor=Throwable.class)    public void register(UserDO user) {        Long userId = userMapper.getUserIdByName(user.getName());        if(null != userId) {           throw new RuntimeException('用戶名已被占用');        }        userMapper.addUser(user);    }    @Override    public UserDO doLogin(String name, String password) {        Long userId = userMapper.getUserIdByName(name);        if(null == userId) {            throw new RuntimeException('用戶不存在!');        }        UserDO user = userMapper.getUserById(userId);        if(null == user) {            throw new RuntimeException('用戶不存在!');        }        if(!user.getPassword().equals(password)) {            throw new RuntimeException('密碼不正確!');        }        return user;    }    @Override    @CacheDeleteTransactional    @Transactional(rollbackFor=Throwable.class)    public void updateUser(UserDO user) {        userMapper.updateUser(user);    }    @Override    @CacheDeleteTransactional    @Transactional(rollbackFor=Throwable.class)    public void deleteUserById(Long userId) {        userMapper.deleteUserById(userId);    }}

通過上面代碼可看出:

  • 1、緩存操作與業(yè)務(wù)邏輯解耦后,代碼的維護也變得更加方便;

  • 2、只有 getUserById 方法的緩存是直接緩存用戶數(shù)據(jù),其它地方只緩存用戶 ID。數(shù)據(jù)更新時,就不需要再關(guān)心其它數(shù)據(jù)也要同步更新的問題了,更好地保證了數(shù)據(jù)的一致性。

細心的讀者也許會問,如果系統(tǒng)中有一個查詢 status = 1 的用戶列表 (調(diào)用上面的 listIdsByCondition 方法),而這時把這個列表中的用戶 status = 0,緩存中的并沒有把相應(yīng)的 id 排除,那么不就會造成業(yè)務(wù)不正確了嗎?這個主要是要考慮系統(tǒng)可接受這種不正確情況存在多久。這時就需要前端加上相應(yīng)的邏輯來處理這種情況。比如,電商系統(tǒng)中,某商口被下線了,可有些列表頁因緩存沒及時更新,仍然顯示在列表中,但在進入商品詳情頁或者點擊購買時,一定會有商品已下線的提示。

通過上面例子我們發(fā)現(xiàn),需要根據(jù)業(yè)務(wù)特點,思考不同場景下數(shù)據(jù)之間的關(guān)系,這樣才能設(shè)計出好的緩存方案。

有興趣的讀者可以思考一下,上面例子中,如果用戶名允許修改的情況下,相應(yīng)的代碼要做哪些調(diào)整?

如何保證數(shù)據(jù)最終一致?

在數(shù)據(jù)更新時,如果出現(xiàn)緩存服務(wù)不可用的情況,造成無法刪除緩存數(shù)據(jù),當緩存服務(wù)恢復可用時,就可能出現(xiàn)緩存數(shù)據(jù)與數(shù)據(jù)庫中的數(shù)據(jù)不一致的情況。為了解決此問題筆者提供以下幾種方案:

方案一,基于 MQ 的解決方案。如下圖所示:

流程如下:

  • 1、更新數(shù)據(jù)庫數(shù)據(jù);

  • 2、刪除緩存中的數(shù)據(jù),可此時緩存服務(wù)出現(xiàn)不可用情況,造成無法刪除緩存數(shù)據(jù);

  • 3、當刪除緩存數(shù)據(jù)失敗時,將需要刪除緩存的 Key 發(fā)送到消息隊列 (MQ) 中;

  • 4、應(yīng)用自己消費需要刪除緩存 Key 的消息;

  • 5、應(yīng)用接收到消息后,刪除緩存,如果刪除緩存確認 MQ 消息被消費,如果刪除緩存失敗,則讓消息重新入隊列,進行多次嘗試刪除緩存操作。

方案二,基于 Canal 的解決方案。如下圖所示:

流程如下:

  • 1、更新數(shù)據(jù)庫數(shù)據(jù);

  • 2、MySQL 將數(shù)據(jù)更新日志寫入 binlog 中;

  • 3、Canal 訂閱 & 消費 MySQL binlog,并提取出被更新數(shù)據(jù)的表名及 ID;

  • 4、調(diào)用應(yīng)用刪除緩存接口;

  • 5、刪除緩存數(shù)據(jù);

  • 6、Redis 不可用時,將更新數(shù)據(jù)的表名及 ID 發(fā)送到 MQ 中;

  • 7、應(yīng)用接收到消息后,刪除緩存,如果刪除緩存確認 MQ 消息被消費,如果刪除緩存失敗,則讓消息重新入隊列,進行多次嘗試刪除緩存操作,直到緩存刪除成功為止。

像電商詳情頁這種高并發(fā)的場景,要盡量避免用戶請求回源到數(shù)據(jù)庫,所以會把數(shù)據(jù)都持久化到 Redis 中,那么相應(yīng)的緩存架構(gòu)也要做些調(diào)整。

流程如下:

  • 1、更新數(shù)據(jù)庫數(shù)據(jù);

  • 2、MySQL 將數(shù)據(jù)更新日志寫入 binlog 中;

  • 3、Canal 訂閱 & 消費 MySQL binlog,并提取出被更新數(shù)據(jù)的表名及 ID;

  • 4、將更新數(shù)據(jù)的表名及 ID 發(fā)送到 MQ 中;

  • 5、應(yīng)用訂閱 & 消費數(shù)據(jù)更新消息;

  • 6、從數(shù)據(jù)庫中拉取最新的數(shù)據(jù);

  • 7、更新緩存數(shù)據(jù),如果更新緩存失敗,則讓消息重新入隊列,進行多次嘗試更新緩存操作,直到緩存更新成功為止。

此方案中,把數(shù)據(jù)更新的消息發(fā)送到 MQ 中,主要避免數(shù)據(jù)更新洪峰時,造成從數(shù)據(jù)庫獲取數(shù)據(jù)壓力過大,起到削峰的作用。通過 Canal 就可以把最新數(shù)據(jù)發(fā)到 MQ 以及應(yīng)用,為什么還要從數(shù)據(jù)庫中獲取最新數(shù)據(jù)?因為當消息過多時,MQ 消息可能出現(xiàn)積壓,應(yīng)用收到時可能已經(jīng)是“舊”消息,通過去數(shù)據(jù)庫取一次,以保證緩存數(shù)據(jù)是最新的。

總的來說以上幾種方案都借助 MQ 重復消費功能,以實現(xiàn)緩存數(shù)據(jù)最終得以更新。為了避免 MQ 消息積壓,前兩種方案都是先嘗試直接刪除緩存,當出現(xiàn)異常情況時,才使用 MQ 進行補償處理。方案一實現(xiàn)比較簡單,但如果 MQ 出現(xiàn)故障時,還是會造成一些數(shù)據(jù)不一致的情況,而方案二因為增加了刪除緩存流程,延長了緩存數(shù)據(jù)的更新時間,但是可以彌補方案一中因 MQ 故障造成數(shù)據(jù)不一致的情況:Canal 可以重新訂閱和消費 MQ 故障后的 binlog,從而增加了一重保障。 而第三種方案中 Redis 不僅僅是做緩存用了,還有持久化的功能在里面,所以采用更新緩存而不是刪除緩存保證 Redis 的數(shù)據(jù)是最新的。

本文首發(fā)于作者公眾號:京西(ID:tech_top)。

作者介紹

邱家榆,隨行付基礎(chǔ)平臺架構(gòu)師,專注于分布式計算及微服務(wù)。

More

重大革新!Dubbo 3.0來了

面對緩存,有哪些問題需要思考?

其它

隨著互聯(lián)網(wǎng)業(yè)務(wù)的飛速發(fā)展,系統(tǒng)動輒要支持億級流量壓力,架構(gòu)設(shè)計不斷面臨新的挑戰(zhàn)。海量系統(tǒng)設(shè)計、容災、健壯性,架構(gòu)師要考慮多方面的需求做出權(quán)衡。不如來聽聽國內(nèi)外知名互聯(lián)網(wǎng)公司的架構(gòu)師分享架構(gòu)設(shè)計背后的挑戰(zhàn)與問題解決之道。

QCon 北京 2018 目前 8 折報名中,立減 1360 元,有任何問題歡迎咨詢購票經(jīng)理 Hanna,電話:15110019061,微信:qcon-0410。

    本站是提供個人知識管理的網(wǎng)絡(luò)存儲空間,所有內(nèi)容均由用戶發(fā)布,不代表本站觀點。請注意甄別內(nèi)容中的聯(lián)系方式、誘導購買等信息,謹防詐騙。如發(fā)現(xiàn)有害或侵權(quán)內(nèi)容,請點擊一鍵舉報。
    轉(zhuǎn)藏 分享 獻花(0

    0條評論

    發(fā)表

    請遵守用戶 評論公約

    類似文章 更多