緩存與數據庫雙寫一致性的解決方案——附上代碼解決方案

傳統企業中為了解決高并發大流量的問題,通常使用緩存+數據庫的方式來支撐高QPS的訪問,雖然能解決讀QPS的問題,但是同時也引入了新的問題,例如:緩存與數據庫的數據不一致的情況;本博文參考網上相關的博文,詳細的整理下緩存數據庫數據一致性的問題,并且給出基于Java的代碼解決方案

本文參考中華石杉的教程,感謝大神的分享

關于緩存數據庫數據一致性的解決方案,網上有很多,但是大都是偏向理論的,且大多數使用分布式鎖來實現的,分布式鎖也是一種解決方式,但是無疑增加了代碼邏輯的復雜性,本博文主要是使用JVM自帶的緩存隊列+線程池來解決數據一致性的問題,并且針對數據一致性的解決方案通過代碼來體現出來,讓讀者能不僅對數據一致性的原因以及解決方案有更深層次的理解,并且也能落實到代碼上

談談緩存與數據庫數據一致性的問題

系統中引入了緩存,這里我們使用Redis,我們會先將數據緩存在Redis中,當外部請求數據時,我們都是先從Redis中查詢,如果查詢到了直接返回給請求,如果查詢不到,則再到數據中進行查詢,返回給請求,同時再將數據寫入到Redis中。具體的業務邏輯如下圖:

image
  • 用戶發起請求
  • 系統先從緩存中查詢是否有相關的數據,如果存在則直接返回給用戶
  • 如果緩存中不存在,則到數據庫中查詢,查詢到的結果再返回給用戶,同時寫入到緩存中

優點

這樣做的好處是,如果緩存中有數據了,就直接返回,減少了數據庫的訪問壓力,同時也提高了請求響應的數據(少了與數據庫之間的交互)

缺點:

雖然讀數據的性能提升了,但是給數據更新造成了新的麻煩,在高并發的場景中很容易就造成了緩存與數據庫數據一致性的問題

一般情況下,我們寫數據有兩種方式:

  • 先更新緩存,再更新數據庫

針對這種方案,我們來分析下:

  1. 先寫緩存,如果寫入緩存失敗,直接返回,無影響
  2. 寫入緩存之后,再來寫數據庫,測試數據庫寫入失敗,如果不清除緩存中的數據,就會造成緩存與數據庫中的數據不一致
  3. 如果增加清除緩存中的數據,那么清除數據失敗怎么處理
  • 先更新數據庫,再更新緩存

我們再來分析這種方案:

  1. 先更新數據庫,如果更新數據庫失敗,直接返回,無影響
  2. 寫入數據庫成功之后 ,再來更新緩存中的數據,如果更新失敗,則此時緩存中的數據與數據庫中的數據就會不一致,需要添加重試機制,增加代碼量,并且業務邏輯復雜化,
  3. 就算增加了重試機制,如果重試也失敗了,該如何處理

以上的兩種方案都是有缺陷的,那么我們該如何處理呢,我們一步步來分析:

以上兩種方案都是如果一方的更新失敗了,都會造成數據不一致的情況,那么需要想辦法來處理,就算一方失敗了,也不會出現數據不一致的情況。

怎么處理呢?這里我們先這樣處理:

  • 先刪除緩存中的數據,然后再去更新數據庫,最后更新緩存中的數據
  1. 寫請求過來,我們先刪除緩存中的數據,
  2. 刪除成功之后,我們再更新數據庫中的數據,此時如果更新數據庫中的數據失敗,則整個寫請求失敗,直接返回,數據沒有發生變化,此時讀請求過來,發現緩存中沒有對應的數據,則會從數據庫中讀取數據,同時將數據寫入到緩存中,此時緩存中的數據和數據庫中的數據都是一樣的, 不存在數據一致性的問題
  3. 更新數據庫中的數據之后 ,再來更新緩存中的數據,此時更新緩存中的數據失敗,直接返回,數據庫中的數據是最新的數據,開始讀請求過來,發現緩存中沒有對應的數據,則會從數據庫中讀取數據,同時將數據寫入到緩存中,此時緩存中的數據和數據庫中的數據都是一樣的, 不存在數據一致性的問題
  4. 更新緩存成功,此時緩存中的數據和數據庫的數據是一致的,不存在數據一致性的問題

具體的業務邏輯見下圖:
[圖片上傳失敗...(image-a7929e-1566214118828)]

乍一看,這種方案完美的解決了數據一致性的問題,我們不妨再來將業務場景復雜點,并發量再大一點,比如說每秒的讀QPS為1w+,這是我們再來分析下上述方案的業務邏輯:

  1. 用戶寫請求過來,我們還是先刪除緩存,然后再更新數據庫
  2. 在更新數據庫的過程中,此時更新還沒有完成,數據庫的值依舊是原來的舊值,這時一個讀請求過來
  3. 發現緩存中沒有值,就會到數據庫中去查詢數據,然后寫入到緩存中,此時數據庫還沒有更新結束,讀請求獲取的數據依舊是原來的舊數據
  4. 這時數據庫更新完成,但是更新緩存失敗,此時緩存中是用的之前的舊數據與數據庫中的新數據就會出現數據不一致的情況,數據一致性的問題又出現了

具體的業務邏輯如下圖:

  • [ ] 業務流程圖

由此可見,上述的方案也是存在問題的,尤其是并發量很大的情況下,這類現象出現的幾率就很大;對于這種情況我們該如何處理呢?

分析

我們仔細分析上述的情況,可以發現,讀請求和寫請求是并行的,這是導致數據一致性的根本原因,并行的請求會導致數據一致性的問題,那么解決此類問題的思路就有了——將請求串行

具體的業務邏輯如下:

  1. 寫請求過來,將寫請求緩存到緩存隊列中,并且開始執行寫請求的具體操作(刪除緩存中的數據,更新數據庫,更新緩存)
  2. 如果在更新數據庫過程中,又來了個讀請求,將讀請求再次存入到緩存隊列中,等待隊列前的寫請求執行完成,才會執行讀請求
  3. 之前的寫請求刪除緩存失敗,直接返回,此時數據庫中的數據是舊值,并且與緩存中的數據是一致的,不會出現緩存一致性的問題
  4. 寫請求刪除緩存成功,則更新數據庫,如果更新數據庫失敗,則直接返回,寫請求結束,此時數據庫中的值依舊是舊值,讀請求過來后,發現緩存中沒有數據, 則會直接向數據庫中請求,同時將數據寫入到緩存中,此時也不會出現數據一致性的問題
  5. 更新數據成功之后,再更新緩存,如果此時更新緩存失敗,則緩存中沒有數據,數據庫中是新值 ,寫請求結束,此時讀請求還是一樣,發現緩存中沒有數據,同樣會從數據庫中讀取數據,并且存入到緩存中,其實這里不管更新緩存成功還是失敗, 都不會出現數據一致性的問題

具體的業務邏輯如下圖:


image

上述的解決方案是將異步請求串行化,這樣做的好處呢就是隊列上的工作線程完成之后上一個操作數據庫的修改之后,才會執行下一個操作。

上述的解決方案中還有個可以優化的地方,如果在修改數據庫更新緩存的過程中,不斷有讀請求過來怎么處理,隊列中都一次防止每次的讀請求么,不是的,存放大多的隊列只會占用隊列的資源,我們這里可以判斷過濾下讀請求,直接返回,提示用戶刷新下頁面,重新請求數據,這個過程足夠隊列中寫操作執行完成了,讀請求再次請求過來時,可以直接返回緩存即可

注意點

  1. 讀請求長時間阻塞

方案中對讀請求做了異步化,當從緩存中讀取不到數據,則將該讀請求寫入緩存隊列中,此時一定要注意讀請求超時的問題,系統設計要做到每個讀請求都在必須要在超時時間內完返回請求結果。

在該方案中,如果大量的寫請求進入,存放到緩存隊列中,這樣后來的讀請求就會發現在緩存中讀取不到數據,也進入到緩存隊列中,我們這里來做個簡單的假設,如果一個寫請求需要50ms的時間,當隊列中存在4個寫請求的話,就會由200ms的讀請求延遲,一般讀請求200ms的延遲,用戶是可以接受的。

一次類推,如果一下子來了500個寫請求,在單機的基礎上,則需要25000ms,這個時間相當長了,所以這時我們需要分布式來解決這個問題,將訪問的壓力分打給其他的服務實例,比如一個單機,20個隊列,每個隊列中的寫操作需要耗時50ms,則大概需要25個單機就可以hold住每秒500個寫請求了,當然這個緩存隊列的配置還需要跟服務器的內存和實際壓測過程中的情況去調節緩存隊列中的核心線程數和最大線程數。

當然上面只是一個大概數據的估算,在實際生產環境中一般呈現二八定律的,按照個比率來估算每秒的寫請求也是可以的

總結下:當寫請求大量的請求過來的時候,如果此時又有大量的讀請求的話,單機版本的可能會造成讀請求時間過長,我們這里是通過分布式服務的方式來分擔寫請求的訪問壓力,通過分擔的方式加快寫請求的操作,這樣讀請求返回的時間就快了

  1. 讀請求并發量較高

還有一個場景就是大量的讀請求過來,這個場景和上述的場景比較像,比如說每秒有500個寫請求過來,按照上述的方案,會先刪除緩存,此時關于這個緩存會有大量的讀請求過來,我們按照讀寫比例20:1的比率來算,就是一個寫請求對應20個讀請求,那么500個寫請求就會由1w個讀請求,此時如果還是使用單機的話,1w個讀請求(都是緩存被刪除的),此時單機版本肯定是玩不轉的了,我們還是需要水平橫向擴展,通過增加服務處理的實例,來分擔QPS的壓力,但是對于緩存執行更新的操作,還是需要通過Nginx服務器來路由到相同的服務實例上

  1. 熱點數據的路由問題,導致請求傾斜

準確來說這個場景和秒殺比較像,但是場景比較像但是解決的方案則是不一樣的,秒殺有秒殺自己的一套解決方案,這里主要是熱點數據的QPS非常高,我們前面通過Nginx服務器會將對于該熱點數據的請求全部路由到相同的服務實例上,就會造成該服務實例的壓力會很大,這個時候需要根據情況來處理

以上就是我們本次緩存數據庫雙寫一致性的解決方案,該方案能解決一部分的問題,但是在實際的生產場景中,還是需要考慮該方案一些注意的要點,結合自己的業務場景來調整該方案,通過模擬、極限壓測等方式來優化,落地一套相對比較完善的數據一致性的解決方案

代碼實現

以上我們詳細的分析了數據一致性的解決方案的原理和需要注意的地方,下面我們來通過Java代碼來實現該方案

上面的方案中我們已經討論過,通過JVM待在的緩存隊列來緩存讀寫的請求,并且將所有的請求異步串行化,這里我們使用SpringBoot框架來進行代碼實現

  1. 首先我們先在系統啟動的時候,初始化線程池和緩存隊列
    /**
     * Copyright ? 2018 五月工作室. All rights reserved.
     *
     * @Package com.amos.common.config
     * @ClassName ServletListenerRegistrationConfig
     * @Description 在容器啟動的時候,注冊自定義的Listener
     * 1. 在監聽器中初始化線程池
     * @Author Amos
     * @Modifier
     * @Date 2019/7/14 16:41
     * @Version 1.0
     **/
    @Configuration
    public class ServletListenerRegistrationConfig {
    
        /**
         * 注冊自定義的Bean
         * 并且設置監聽器,該監聽器初始化線程池
         *
         * @return
         */
        @Bean
        public ServletListenerRegistrationBean registrationBean() {
            ServletListenerRegistrationBean servletListenerRegistrationBean = new ServletListenerRegistrationBean();
            servletListenerRegistrationBean.setListener(new InitThreadLocalPoolListen());
            return servletListenerRegistrationBean;
        }
    }
  1. 按照標配,我們使用線程池來存儲線程,當然使用線程池有很多的好處,主要如下:
  • 降低資源消耗

可以利用重復已創建的線程降低線程創建和銷毀的消耗

  • 提高響應速度

當任務到達時,任務可以不需要等到線程創建就能立即執行

  • 提高線程的可管理性

使用線程池可以進行統一分配、調優和監控

我們新建一個類主要是用來創建線程池,一般系統中線程池都是單例的,而且必須是線程安全的, 單例的線程安全有很多中,本博文這里使用靜態內部類的方是來實現單例模式(任性,可以空間換時間)

    /**
     * Copyright ? 2018 五月工作室. All rights reserved.
     *
     * @Package com.amos.common.thread
     * @ClassName RequestThreadPool
     * @Description 請求線程池
     * 1. 使用線程池來管理線程,該線程池必須是單例的
     * 2. 線程池初始化成功后,創建緩存隊列,并且和線程池進行綁定
     * @Author Amos
     * @Modifier
     * @Date 2019/7/14 16:47
     * @Version 1.0
     **/
    @Component
    public class RequestThreadPool {
        /**
         * 核心線程數
         */
        @Value("${request.queue.corePoolSize:10}")
        private Integer corePoolSize;
        /**
         * 線程池最大線程數
         */
        @Value("${request.queue.maximumPoolSize:20}")
        private Integer maximumPoolSize;
    
        /**
         * 線程最大存活時間
         */
        @Value("${request.queue.keepAliveTime:60}")
        private Long keepAliveTime;
    
        /**
         * 初始化線程池 這里我們不使用Executors.newFixedThreadPool()方式,該種方式不推薦使用,
         * 主要是因為默認允許的隊列的長度是Integer.MAX_VALUE,可能會造成OOM
         * 第一個參數:corePoolSize: 線程中核心線程數的最大值(能同時運行的最大的線程數)
         * 第二個參數:maximumPoolSize: 線程池中線程數的最大值
         * 第三個參數:keepAliveTime: 線程存活時間
         * 第四個參數:unit:時間單位
         * 第五個參數:BlockingQueue: 用于緩存任務的隊列 這里使用 ArrayBlockingQueue 這個是有界隊列
         */
        private ExecutorService threadPool = new ThreadPoolExecutor(this.corePoolSize, this.maximumPoolSize,
                this.keepAliveTime, TimeUnit.SECONDS,
                new ArrayBlockingQueue(this.corePoolSize));
    
    
        /**
         * 構造器私有化,這樣就不能通過new來創建實例對象
         * <p>
         * 類實例化的時候 ,初始化隊列的大小,并且綁定隊列和線程池以及隊列與線程的關系
         * <p>
         * 初始化指定數量的隊列
         */
        private RequestThreadPool() {
            /**
             *緩存隊列集合來管理所有的緩存隊列
             */
            RequestQueue requestQueue = RequestQueue.getInstance();
            for (int i = 0; i < this.corePoolSize; i++) {
                /**
                 * 緩存隊列使用Request 接口來作為泛型,將可以將隊列的類型添加定義,同時也可以通過多態的特性來實現子類的擴展
                 * 目前Request只是定義,業務可以之后實現
                 */
                ArrayBlockingQueue<Request> queue = new ArrayBlockingQueue<>(this.corePoolSize);
                requestQueue.add(queue);
                // 線程池和緩存隊列通過線程來綁定
                // 每個線程對應一個隊列
                this.threadPool.submit(new RequestThread(queue));
            }
        }
    
        /**
         * 使用靜態內部類來實現單例的模式(絕對的線程安全)
         */
        private static class Singleton {
            /**
             * 私有的靜態變量,確保該變量不會被外部調用
             */
            private static RequestThreadPool requestThreadPool;
    
            /**
             * 靜態代碼塊在類初始化時執行一次
             */
            static {
                requestThreadPool = new RequestThreadPool();
            }
    
            /**
             * 靜態內部類對外提供實例的獲取方法
             *
             * @return
             */
            public static RequestThreadPool getInstance() {
                return requestThreadPool;
            }
        }
    
        /**
         * 請求線程池類對外提供獲取實例的方法 由于外部類沒有RequestThreadPool的實例對象,所以除了該方法,外部類無法創建額外的RequestThreadPool對象
         *
         * @return
         */
        public static RequestThreadPool getInstance() {
            return Singleton.getInstance();
        }
    
    
    }

我將整個代碼貼出來,方便大家查看,該類的主要用途是系統啟動的時候初始化線程池,并且創建緩存隊列,將隊列和線程池進行綁定

該類中的構造器是private修飾的,這樣處理的目的主要是為了不讓線程池創建之后再創建多余的實例對象,其次也是為了方便在構造器中完成線程池與緩存隊列之間的綁定

既然構造器被私有化了,我們就得提供一個供外部獲取實例的方法,這里我們使用了靜態內部類是實現單例模式,讓線程池的實例保持一個。為什么要使用靜態內部了呢?

  • 外部內加載的時候,不需要立即加載內部類,內部類不被加載,就不會初始化,故而不占用內存
  • 當getInstance被調用時,才會去初始化實例,第一次調用getInstance會導致虛擬機加載實例,這種方法不僅能確保線程的安全,也能保證單例的唯一性

線程池存儲的線程主要是用來處理外部過來的請求,所以緩存對列主要用來對請求進行處理,而且請求隊列必須也是單例的

    /**
     * Copyright ? 2018 五月工作室. All rights reserved.
     *
     * @Project: rabbitmq
     * @ClassName: RequestQueue
     * @Package: com.amos.common.request
     * @author: zhuqb
     * @Description: 請求的隊列
     * <p/>
     * 1. 這里需要使用單例模式來確保請求的隊列的對象只有一個
     * @date: 2019/7/15 0015 下午 14:18
     * @Version: V1.0
     */
    public class RequestQueue {
        /**
         * 構造器私有化,這樣就不能通過new來創建實例對象
         * 這里構造器私有化 這點跟枚舉一樣的,所以我們也可以通過枚舉來實現單例模式,詳見以后的博文
         */
        private RequestQueue() {
        }
    
        /**
         * 內存隊列
         */
        private List<ArrayBlockingQueue<Request>> queues = new ArrayList<ArrayBlockingQueue<Request>>();
    
        /**
         * 私有的靜態內部類來實現單例
         */
        private static class Singleton {
            private static RequestQueue queue;
    
            static {
                queue = new RequestQueue();
            }
    
            private static RequestQueue getInstance() {
                return queue;
            }
        }
    
        /**
         * 獲取 RequestQueue 對象
         *
         * @return
         */
        public static RequestQueue getInstance() {
            return Singleton.getInstance();
        }
    
        /**
         * 向容器中添加隊列
         *
         * @param queue
         */
        public void add(ArrayBlockingQueue<Request> queue) {
            this.queues.add(queue);
        }
    
    }

線程池和緩存隊列通過線程來綁定,一個線程對應一個緩存隊列,在線程里來處理緩存隊列中的邏輯

    /**
     * Copyright ? 2018 五月工作室. All rights reserved.
     *
     * @Project: rabbitmq
     * @ClassName: RequestThread
     * @Package: com.amos.common.thread
     * @author: zhuqb
     * @Description: 執行請求的工作線程
     * <p/>
     * 線程和隊列進行綁定,然后再線程中處理對應的業務邏輯
     * @date: 2019/7/15 0015 下午 14:34
     * @Version: V1.0
     */
    public class RequestThread implements Callable<Boolean> {
        /**
         * 隊列
         */
        private ArrayBlockingQueue<Request> queue;
    
        public RequestThread(ArrayBlockingQueue<Request> queue) {
            this.queue = queue;
        }
    
        /**
         * 方法中執行具體的業務邏輯
         * TODO 這里我們先搭建整理的框架,后面在慢慢處理緩存隊列
         *
         * @return
         * @throws Exception
         */
        @Override
        public Boolean call() throws Exception {
            return true;
        }
    }

然后再監聽器中獲取線程時的實例對象來完成線程池的啟動初始化

    /**
     * Copyright ? 2018 五月工作室. All rights reserved.
     *
     * @Package com.amos.common.listener
     * @ClassName InitThreadLocalPoolListen
     * @Description 系統初始化監聽器 初始隊列
     * @Author Amos
     * @Modifier
     * @Date 2019/7/14 16:44
     * @Version 1.0
     **/
    public class InitThreadLocalPoolListen implements ServletContextListener {
        /**
         * 系統初始化隊列
         *
         * @param sce
         */
        @Override
        public void contextInitialized(ServletContextEvent sce) {
            RequestThreadPool.getInstance();
        }
    
        /**
         * 監聽器銷毀執行的邏輯
         *
         * @param sce
         */
        @Override
        public void contextDestroyed(ServletContextEvent sce) {
    
        }
    }

至此,我們框架的代碼算是搭建完成了,下面我們以商品購買庫存減一的功能來實現緩存與數據庫雙寫一致性性解決方案的代碼實現

代碼邏輯如下

  1. 需要集成redis和mysql數據庫操作
  2. 需要一個處理redis的請求和處理數據庫的請求業務邏輯代碼
  3. 在緩存隊列的線程中執行基于緩存和數據庫雙寫一致性的代碼

接下來我們開始搭建環境,關于Redis的環境搭建可以參考我以前的博文《Redis教程(一)——Redis安裝》,然后我們在SpringBoot中來集成操作Redis的功能,

  • 集成Redis和mysql的數據庫操作

在pom文件中添加springBoot整合redis的依賴

    <!-- 添加SpringBoot集成Redis的依賴-->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
        <version>2.1.6.RELEASE</version>
    </dependency>

然后編寫Redis的操作功能類

    /**
     * Copyright ? 2018 五月工作室. All rights reserved.
     *
     * @Package com.amos.common.util
     * @ClassName RedisUtils
     * @Description redis的操作類
     * @Author Amos
     * @Modifier
     * @Date 2019/8/18 0:13
     * @Version 1.0
     **/
    @Component
    public class RedisUtils {
        public final Log logger = LogFactory.getLog(this.getClass());
        @Autowired
        private RedisTemplate<String, Object> redisTemplate;
    
        /**
         * 指定鍵緩存實效的時間
         *
         * @param key        指定的鍵
         * @param expireTime 超時時間 毫秒
         * @return
         */
        public boolean expire(String key, long expireTime) {
            AmExcepitonEnum.NOT_NULL.assertNotEmpty(key, "指定的鍵不能為空");
    
            if (expireTime < 0) {
                throw new RabbitMQException("超時時間不能小于0");
            }
            try {
                return this.redisTemplate.expire(key, expireTime, TimeUnit.MICROSECONDS);
            } catch (Exception e) {
                if (this.logger.isDebugEnabled()) {
                    e.printStackTrace();
                }
                return Boolean.FALSE;
            }
        }
    
        /**
         * 判斷是否有指定的key
         *
         * @param key 指定的鍵
         * @return
         */
        public boolean hasKey(String key) {
            AmExcepitonEnum.NOT_NULL.assertNotEmpty(key, "指定的鍵不能為空");
    
            try {
                return this.redisTemplate.hasKey(key);
            } catch (Exception e) {
                if (this.logger.isDebugEnabled()) {
                    e.printStackTrace();
                }
                return Boolean.FALSE;
            }
        }
    
        /**
         * 保存鍵值
         *
         * @param key   保存的鍵
         * @param value 保存的值
         * @return
         */
        public boolean save(String key, Object value) {
            AmExcepitonEnum.NOT_NULL.assertNotEmpty(key, "指定的鍵不能為空");
    
            try {
                this.redisTemplate.opsForValue().set(key, value);
                return Boolean.TRUE;
            } catch (Exception e) {
                if (this.logger.isDebugEnabled()) {
                    e.printStackTrace();
                }
                return Boolean.FALSE;
            }
        }
    
        /**
         * 刪除key
         *
         * @param key
         * @return
         */
        public boolean del(String key) {
            AmExcepitonEnum.NOT_NULL.assertNotEmpty(key, "指定的鍵不能為空");
            try {
                return this.redisTemplate.delete(key);
            } catch (Exception e) {
                if (this.logger.isDebugEnabled()) {
                    e.printStackTrace();
                }
                return Boolean.FALSE;
            }
        }
    
        /**
         * 保存有實效時間的鍵值對
         *
         * @param key
         * @param value
         * @param expireTime 實效時間 單位毫秒
         * @return
         */
        public boolean save(String key, Object value, long expireTime) {
            AmExcepitonEnum.NOT_NULL.assertNotEmpty(key, "鍵值不能為空");
    
            try {
                this.redisTemplate.opsForValue().set(key, value, expireTime, TimeUnit.MICROSECONDS);
                return Boolean.TRUE;
            } catch (Exception e) {
                if (this.logger.isDebugEnabled()) {
                    e.printStackTrace();
                }
                return Boolean.FALSE;
            }
    
        }
    }

至此,我們的集成Redis就已經完成了,可以自己寫個測試類測試下

我們再來整合mybatis

我們在pom文件中添加mybatis的依賴

    <!-- 集成Mybatis -->
    <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
        <scope>runtime</scope>
    </dependency>
    <dependency>
        <groupId>org.mybatis.spring.boot</groupId>
        <artifactId>mybatis-spring-boot-starter</artifactId>
        <version>2.0.1</version>
    </dependency>

接著在創建創建如下的目錄結構:
[圖片上傳失敗...(image-9f4510-1566214118828)]

在application.yml文件中添加

spring:
  datasource:
    driver-class-name: com.mysql.jdbc.Driver
    username: root
    password: 123
    url: jdbc:mysql://localhost:3306/amos?useUnicode=true&characterEncoding=utf8&useSSL=false
mybatis:
  config-location: classpath:/mybatis/config/mybatis-config.xml
  mapper-locations: classpath:/mybatis/mapper/*.xml

在 classpath:/mybatis/config/mybatis-config.xml中添加

    <?xml version="1.0" encoding="UTF-8"?>
    <!DOCTYPE configuration
            PUBLIC "-//mybatis.org//DTD Config 3.0//EN"
            "http://mybatis.org/dtd/mybatis-3-config.dtd">
    <configuration>
        <settings>
            <setting name="mapUnderscoreToCamelCase" value="true"/>
        </settings>
        <typeAliases>
            <package name="com.amos.doublewriterconsistence.entity"/>
        </typeAliases>
    </configuration>

其余的配置主要是常見的mybatis的Mapper和Entity,這里我就不詳細列舉出來了,可以參考我的Gitee的源碼,我里面添加了詳細的注釋,方便閱讀
doubleWriterConsistence

至此SpringBoot集成Mybatis的框架也完成了,大家可以自己編寫測試類進行測試功能是否正常,同時我們也完成了代碼邏輯中的第一點,接下來是我們此次代碼的重點,著重講解下,如果使用緩存隊列來實現一致性的功能代碼

  • 新增業務處理緩存和數據庫的業務邏輯代碼

這里我們先將設計業務邏輯的代碼編寫出來

  1. 庫存的操作方法

統一提供庫存的方法: 從數據庫中查詢,更新數據庫,從緩存中查詢,刪除緩存數據,保存緩存數據

    /**
     * Copyright ? 2018 五月工作室. All rights reserved.
     *
     * @Project: double-writer-consistence
     * @ClassName: InventoryServiceImpl
     * @Package: com.amos.doublewriterconsistence.service
     * @author: amos
     * @Description:
     * @date: 2019/8/19 0019 下午 14:23
     * @Version: V1.0
     */
    @Service
    public class InventoryServiceImpl implements InventoryService {
        public final Logger logger = LoggerFactory.getLogger(this.getClass());
    
        @Autowired
        InventoryMapper inventoryMapper;
    
        @Autowired
        RedisUtils redisUtils;
    
        /**
         * 刪除庫存的緩存
         *
         * @param key
         * @return
         */
        @Override
        public Boolean removeInventoryCache(String key) {
            this.logger.info("移除庫存:{} 的緩存", key);
            key = InventoryKeyUtils.getInventoryKey(key);
            return this.redisUtils.del(key);
        }
    
        /**
         * 更新數據庫庫存記錄
         *
         * @param inventory
         */
        @Override
        public void updateInventory(Inventory inventory) {
            this.logger.info("更新庫存:{} 的庫存記錄", inventory.getId());
            this.inventoryMapper.update(inventory);
        }
    
        /**
         * 保存庫存的緩存記錄
         *
         * @param inventory
         * @return
         */
        @Override
        public Boolean saveInventoryCache(Inventory inventory) {
            AmExcepitonEnum.NOT_NULL.assertNotEmpty(inventory);
            String key = InventoryKeyUtils.getInventoryKey(inventory.getId());
            this.logger.info("保存緩存數據的Key:{}", key);
            return this.redisUtils.save(key, inventory);
        }
    
        /**
         * 獲取指定key的緩存值
         *
         * @param key
         * @return
         */
        @Override
        public Inventory getInventoryCache(String key) {
            key = InventoryKeyUtils.getInventoryKey(key);
            Object object = this.redisUtils.get(key);
            return JSONObject.parseObject(JSONObject.toJSONString(object), Inventory.class);
        }
    
        /**
         * 根據id查詢庫存記錄
         *
         * @param id
         * @return
         */
        @Override
        public Inventory selectById(String id) {
            return this.inventoryMapper.selectById(id);
        }
    
        /**
         * 設置空值在緩存中的失效時間
         *
         * @param inventoryKey 鍵值
         * @param expireTime   失效時間
         */
        @Override
        public void saveNullForCache(String inventoryKey, long expireTime) {
            AmExcepitonEnum.NOT_NULL.assertNotEmpty(inventoryKey);
            String key = InventoryKeyUtils.getInventoryKey(inventoryKey);
            this.logger.info("保存空值,Key:{}", key);
            this.redisUtils.save(key, "", expireTime);
        }
    }
  1. 讀取數據的代碼邏輯

一個讀請求過來,我們需要從數據庫中讀取對應的緩存記錄,并且將該數據保存到緩存中,由于我們需要將所有的請求都是通過緩存隊列來處理的,所以緩存的操作類應該實現Request接口,在定義好的方法中實現緩存讀取的操作

我們在緩存讀取的操作類中添加了isForceFresh字段來為過濾多重讀請求提供支持

    /**
     * Copyright ? 2018 五月工作室. All rights reserved.
     *
     * @Project: rabbitmq
     * @ClassName: InventoryCacheRequestImpl
     * @Package: com.amos.common.request.impl
     * @author: amos
     * @Description: 處理緩存的業務請求
     * 緩存這邊我們需要在數據庫中查詢出對應的數據,然后將數據寫入到緩存中
     * 由此我們需要獲取庫存的id,根據id獲取庫存的數據
     * 然后將庫存數據寫入到緩存中 數據中的key是庫存ID的標識,value是查詢出來的緩存數據
     * @date: 2019/8/19 0019 上午 8:59
     * @Version: V1.0
     */
    public class InventoryCacheRequest implements Request {
        public final Logger logger = LoggerFactory.getLogger(this.getClass());
        /**
         * 庫存的id
         */
        private String inventoryId;
        private InventoryService inventoryService;
        /**
         * 是否需要更新緩存
         * 數據更新該值是false
         */
        private Boolean isForceFresh;
    
        public InventoryCacheRequest(String inventoryId, InventoryService inventoryService, Boolean isForceFresh) {
            this.inventoryId = inventoryId;
            this.inventoryService = inventoryService;
            this.isForceFresh = isForceFresh;
        }
    
        /**
         * 1. 根據id到數據庫中查詢對應的庫存數據
         * 2. 查詢到了則將數據保存到緩存中
         * 3. 如果查詢不到的話則將對應的空數據保存到緩存中,并且設置失效時間
         * 這里的查詢不到數據也保存到緩存中,主要是為了防止惡意請求,以防通過不斷的循環一個查找不到記錄的id來不斷的請求數據庫,給數據庫造成了訪問壓力,占用系統的資源
         * 同時,也給緩存數據設置失效時間,方便數據發生變化時,及時提供變更后的數據
         */
        @Override
        public void process() {
            // 首先從數據庫中查詢對應的庫存數據
            Inventory inventory = this.inventoryService.selectById(this.inventoryId);
            this.logger.info("庫存緩存操作——查詢數據庫數據:" + JSONObject.toJSONString(inventory));
            if (StringUtils.isEmpty(inventory)) {
                // 查詢不到數據的話,對應的key存儲空字符串,并且設置失效時間
                this.inventoryService.saveNullForCache(InventoryKeyUtils.getInventoryKey(this.inventoryId), 1000);
            } else {
                this.logger.info("庫存緩存操作——保存緩存數據:" + JSONObject.toJSONString(inventory));
                this.inventoryService.saveInventoryCache(inventory);
            }
        }
    
    
        @Override
        public String getInventoryId() {
            return this.inventoryId;
        }
    
        @Override
        public Boolean isForceRefresh() {
            return this.isForceFresh;
        }
    }
  1. 更新數據的代碼邏輯

提高數據更新的請求操作,數據更新過程中,我們需要先刪除緩存中的數據,然后再更新數據庫中的數據

    /**
     * Copyright ? 2018 五月工作室. All rights reserved.
     *
     * @Package com.amos.consumer.service.impl
     * @ClassName InventoryServiceImpl
     * @Description 數據更新操作
     * 1. 先刪除緩存中的數據
     * 2. 再更新數據庫中的數據
     * @Author Amos
     * @Modifier
     * @Date 2019/8/18 22:16
     * @Version 1.0
     **/
    public class InventoryDBRequest implements Request {
    
        public final Logger logger = LoggerFactory.getLogger(this.getClass());
        private Inventory inventory;
    
        private InventoryService inventoryService;
    
        /**
         * 構造器
         *
         * @param inventory
         * @param inventoryService
         */
        public InventoryDBRequest(Inventory inventory, InventoryService inventoryService) {
            this.inventory = inventory;
            this.inventoryService = inventoryService;
        }
    
        /**
         * 庫存數據庫操作
         * 1. 先刪除緩存中對應的數據
         * 2. 更新數據庫中的數據
         */
        @Override
        public void process() {
            this.logger.info("數據庫操作——移除緩存中的數據");
            // 首先刪除緩存中的數據
            this.inventoryService.removeInventoryCache(this.inventory.getId());
            // 為了測試 所以這里操作時間長點
            try {
                this.logger.info("數據庫操作——等待3秒操作");
                Thread.sleep(3000);
            } catch (Exception e) {
                e.printStackTrace();
            }
            // 再更新數據庫中的數據
            this.logger.info("數據庫操作——更新數據庫中的數據");
            this.inventoryService.updateInventory(this.inventory);
        }
    
        /**
         * 接口返回庫存記錄的ID
         *
         * @return
         */
        @Override
        public String getInventoryId() {
            return this.inventory.getId();
        }
    
        /**
         * 始終不更新
         *
         * @return
         */
        @Override
        public Boolean isForceRefresh() {
            return Boolean.FALSE;
        }
    }
  • 在隊列中整合緩存和數據庫的業務邏輯處理

上面我們已經完成了讀數據和數據更新的功能,現在我們需要在后臺的隊列處理中,進行相關的業務處理,業務的處理我們已經定義了process公用方法了,現在主要的邏輯是在數據更新過程中如何過濾多次的讀請求

還記得我們上面的isForceFresh字段么,這里我們主要是根據這個字段來判斷是否是重復的讀請求,下面是代碼,代碼中有詳細的注釋說明,方便閱讀

     /**
     * 方法中執行具體的業務邏輯
     *
     * @return
     * @throws Exception
     */
    @Override
    public Boolean call() throws Exception {
        try {
            while (true) {
                // ArrayBlockingQueue take方法 獲取隊列排在首位的對象,如果隊列為空或者隊列滿了,則會被阻塞住
                Request request = this.queue.take();
                Boolean forceFresh = request.isForceRefresh();
                // 如果需要更新的話
                if (!forceFresh) {
                    RequestQueue requestQueue = RequestQueue.getInstance();
                    Map<String, Boolean> tagMap = requestQueue.getTagMap();
                    // 如果是請求緩存中的數據
                    if (request instanceof InventoryCacheRequest) {
                        Boolean tag = tagMap.get(request.getInventoryId());
                        // 如果tag為空 則說明讀取緩存的操作
                        if (null == tag) {
                            tagMap.put(request.getInventoryId(), Boolean.FALSE);
                        }
                        // tag為不為空,并且為true時,說明上一個請求是更新數據庫的
                        // 那么此時我們需要將標志位修改為False
                        if (tag != null && tag) {
                            tagMap.put(request.getInventoryId(), Boolean.FALSE);
                        }

                        // tag不為空,并且為false時,說明前面已經有數據庫+緩存的請求了,
                        // 那么這個請求應該是讀請求,可以直接過濾掉了,不要添加到隊列中
                        if (tag != null && !tag) {
                            return Boolean.TRUE;
                        }

                    } else if (request instanceof InventoryDBRequest) {
                        // 如果是更新數據庫的操作
                        tagMap.put(request.getInventoryId(), Boolean.TRUE);
                    }
                }
                // 執行請求處理
                this.logger.info("緩存隊列執行+++++++++++++++++,{}", request.getInventoryId());
                request.process();
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
        return Boolean.TRUE;
    }

至此,處理業務邏輯的代碼我們已經完成了,這里需要注意一點,所有的請求我們都需要打入到緩存隊列中來執行下,所以同一庫存,我們需要他打入到同一緩存隊列中進行處理,如何來實現這個這功能呢? 這里我們使用hash值取模的方式來實現

    /**
     * Copyright ? 2018 五月工作室. All rights reserved.
     *
     * @Project: double-writer-consistence
     * @ClassName: RequestAsyncProcessServiceImpl
     * @Package: com.amos.doublewriterconsistence.service.impl
     * @author: amos
     * @Description:
     * @date: 2019/8/19 0019 下午 15:23
     * @Version: V1.0
     */
    @Service
    public class RequestAsyncProcessServiceImpl implements RequestAsyncProcessService {
    
        public final Logger logger = LoggerFactory.getLogger(this.getClass());
    
        /**
         * 路由到指定的緩存隊列中
         * doubleWriterConsistence
         *
         * @param request
         */
        @Override
        public void route(Request request) {
            try {
                // 做請求的路由,根據每個請求的商品id,路由到對應的內存隊列中去
                ArrayBlockingQueue<Request> queue = this.getRoutingQueue(request.getInventoryId());
                // 將請求放入對應的隊列中,完成路由操作
                queue.put(request);
            } catch (Exception e) {
                if (this.logger.isDebugEnabled()) {
                    e.printStackTrace();
                }
            }
        }
    
        /**
         * 根據庫存記錄路由到指定的緩存隊列
         *
         * @param key
         * @return
         */
        private ArrayBlockingQueue<Request> getRoutingQueue(String key) {
            RequestQueue requestQueue = RequestQueue.getInstance();
            int h;
            int hash = (key == null) ? 0 : (h = key.hashCode()) ^ (h >> 16);
            // 對hash值取模,將hash值路由到指定的內存隊列中,比如內存隊列大小8
            // 用內存隊列的數量對hash值取模之后,結果一定是在0~7之間
            // 所以任何一個商品id都會被固定路由到同樣的一個內存隊列中去的
            int index = (requestQueue.size() - 1) & hash;
            this.logger.info("路由的緩存隊列為:{}", index);
            return requestQueue.getQueue(index);
        }
    }

到這里,我們就已經完成了相關代碼的開發,接下來需要我們進行編寫測試代碼來測試下功能是否正常

    /**
     * Copyright ? 2018 五月工作室. All rights reserved.
     *
     * @Project: double-writer-consistence
     * @ClassName: InventoryController
     * @Package: com.amos.doublewriterconsistence.web
     * @author: amos
     * @Description: 主要測試
     * 1. 所有的請求是否從緩存隊列中走
     * 2. 通過延遲數據的操作,看看讀請求是否有等待
     * 3,讀請求通過之后,相同的讀請求是否直接返回
     * 4. 讀請求的數據是否從緩存中獲取
     * @date: 2019/8/19 0019 下午 15:31
     * @Version: V1.0
     */
    @RestController
    @RequestMapping(value = "/inventory")
    public class InventoryController {
        public final Logger logger = LoggerFactory.getLogger(this.getClass());
    
        @Autowired
        InventoryService inventoryService;
    
        @Autowired
        RequestAsyncProcessService requestAsyncProcessService;
    
        /**
         * 更新庫存的數據記錄
         * 1. 將更新數據的記錄路由到指定的隊列中
         * 2. 后臺不斷的將從隊列中取值去處理
         *
         * @param inventory
         * @return
         */
        @PostMapping(value = "/updateInventory")
        public Result updateInventory(@RequestBody Inventory inventory) {
            try {
                Request request = new InventoryDBRequest(inventory, this.inventoryService);
                this.requestAsyncProcessService.route(request);
                return ResultWapper.success();
            } catch (Exception e) {
                if (this.logger.isDebugEnabled()) {
                    e.printStackTrace();
                }
                this.logger.error(e.getMessage());
                return ResultWapper.error(e.getMessage());
            }
        }
    
        /**
         * 獲取庫存記錄
         * 如果在在一定時間內獲取不到數據,則直接從數據庫中獲取,并且數據寫入到緩存中
         *
         * @param id
         * @return
         */
        @GetMapping(value = "/getInventory/{id}")
        public Result getInventory(@PathVariable("id") String id) {
            this.logger.info("獲取庫存記錄:{}", id);
            Inventory inventory = null;
            try {
                Request request = new InventoryCacheRequest(id, this.inventoryService, Boolean.FALSE);
                this.requestAsyncProcessService.route(request);
                long startTime = System.currentTimeMillis();
                long waitTime = 0L;
                // 不斷循環從緩存中獲取數據
                // 如果在在一定時間內獲取不到數據,則直接從數據庫中獲取,并且數據寫入到緩存中
                while (true) {
                    if (waitTime > 3000) {
                        break;
                    }
                    inventory = this.inventoryService.getInventoryCache(id);
                    if (null != inventory) {
                        this.logger.info("從緩存中獲取到數據");
                        return ResultWapper.success(inventory);
                    } else {
                        Thread.sleep(20);
                        waitTime = System.currentTimeMillis() - startTime;
                    }
    
                }
    
                // 直接從數據庫中獲取數據
                inventory = this.inventoryService.selectById(id);
                if (null != inventory) {
                    request = new InventoryCacheRequest(id, this.inventoryService, Boolean.TRUE);
                    this.requestAsyncProcessService.route(request);
                    return ResultWapper.success(inventory);
                }
                return ResultWapper.error("查詢不到數據");
            } catch (Exception e) {
                if (this.logger.isDebugEnabled()) {
                    e.printStackTrace();
                }
                this.logger.error(e.getMessage());
                return ResultWapper.error(e.getMessage());
            }
        }
    }

上面詳細的代碼可以參考我的Gitee——doubleWriterConsistence

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,363評論 6 532
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,497評論 3 416
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,305評論 0 374
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,962評論 1 311
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,727評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,193評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,257評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,411評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,945評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,777評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,978評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,519評論 5 359
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,216評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,642評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,878評論 1 286
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,657評論 3 391
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,960評論 2 373

推薦閱讀更多精彩內容