RocketMQ一個新的消費組初次啟動時從何處開始消費呢?

目錄

@(本文目錄)

1、拋出問題

一個新的消費組訂閱一個已存在的Topic主題時,消費組是從該Topic的哪條消息開始消費呢?

首先翻閱DefaultMQPushConsumer的API時,setConsumeFromWhere(ConsumeFromWhere consumeFromWhere)API映入眼帘,從字面意思來看是設置消費者從哪裡開始消費,正是解開該問題的”鑰匙“。ConsumeFromWhere枚舉類圖如下:

  • CONSUME_FROM_MAX_OFFSET
    從消費隊列最大的偏移量開始消費。
  • CONSUME_FROM_FIRST_OFFSET
    從消費隊列最小偏移量開始消費。
  • CONSUME_FROM_TIMESTAMP
    從指定的時間戳開始消費,默認為消費者啟動之前的30分鐘處開始消費。可以通過DefaultMQPushConsumer#setConsumeTimestamp。

是不是點小激動,還不快試試。

需求:新的消費組啟動時,從隊列最後開始消費,即只消費啟動后發送到消息服務器后的最新消息。

1.1 環境準備

本示例所用到的Topic路由信息如下:

Broker的配置如下(broker.conf)

brokerClusterName = DefaultCluster
brokerName = broker-a
brokerId = 0
deleteWhen = 04
fileReservedTime = 48
brokerRole = ASYNC_MASTER
flushDiskType = ASYNC_FLUSH

storePathRootDir=E:/SH2019/tmp/rocketmq_home/rocketmq4.5_simple/store
storePathCommitLog=E:/SH2019/tmp/rocketmq_home/rocketmq4.5_simple/store/commitlog
namesrvAddr=127.0.0.1:9876
autoCreateTopicEnable=false
mapedFileSizeCommitLog=10240
mapedFileSizeConsumeQueue=2000

其中重點修改了如下兩個參數:

  • mapedFileSizeCommitLog
    單個commitlog文件的大小,這裏使用10M,方便測試用。
  • mapedFileSizeConsumeQueue
    單個consumequeue隊列長度,這裏使用1000,表示一個consumequeue文件中包含1000個條目。

1.2 消息發送者代碼

public static void main(String[] args) throws MQClientException, InterruptedException {
    DefaultMQProducer producer = new DefaultMQProducer("please_rename_unique_group_name");
    producer.setNamesrvAddr("127.0.0.1:9876");
    producer.start();
    for (int i = 0; i < 300; i++) {
        try {
            Message msg = new Message("TopicTest" ,"TagA" , ("Hello RocketMQ " + i).getBytes(RemotingHelper.DEFAULT_CHARSET));
            SendResult sendResult = producer.send(msg);
            System.out.printf("%s%n", sendResult);
        } catch (Exception e) {
            e.printStackTrace();
            Thread.sleep(1000);
        }
    }
    producer.shutdown();
}

通過上述,往TopicTest發送300條消息,發送完畢后,RocketMQ Broker存儲結構如下:

1.3 消費端驗證代碼

public static void main(String[] args) throws InterruptedException, MQClientException {
    DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("my_consumer_01");
    consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_LAST_OFFSET);
    consumer.subscribe("TopicTest", "*");
    consumer.setNamesrvAddr("127.0.0.1:9876");
    consumer.registerMessageListener(new MessageListenerConcurrently() {
        @Override
        public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs,
            ConsumeConcurrentlyContext context) {
            System.out.printf("%s Receive New Messages: %s %n", Thread.currentThread().getName(), msgs);
            return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
        }
    });
    consumer.start();
    System.out.printf("Consumer Started.%n");
}

執行上述代碼后,按照期望,應該是不會消費任何消息,只有等生產者再發送消息后,才會對消息進行消費,事實是這樣嗎?執行效果如圖所示:

令人意外的是,竟然從隊列的最小偏移量開始消費了,這就“尷尬”了。難不成是RocketMQ的Bug。帶着這個疑問,從源碼的角度嘗試來解讀該問題,並指導我們實踐。

2、探究CONSUME_FROM_MAX_OFFSET實現原理

對於一個新的消費組,無論是集群模式還是廣播模式都不會存儲該消費組的消費進度,可以理解為-1,此時就需要根據DefaultMQPushConsumer#consumeFromWhere屬性來決定其從何處開始消費,首先我們需要找到其對應的處理入口。我們知道,消息消費者從Broker服務器拉取消息時,需要進行消費隊列的負載,即RebalanceImpl。

溫馨提示:本文不會詳細介紹RocketMQ消息隊列負載、消息拉取、消息消費邏輯,只會展示出通往該問題的簡短流程,如想詳細了解消息消費具體細節,建議購買筆者出版的《RocketMQ技術內幕》書籍。

RebalancePushImpl#computePullFromWhere

public long computePullFromWhere(MessageQueue mq) {
        long result = -1;                                                                                                                                                                                                                  // @1
        final ConsumeFromWhere consumeFromWhere = this.defaultMQPushConsumerImpl.getDefaultMQPushConsumer().getConsumeFromWhere();    
        final OffsetStore offsetStore = this.defaultMQPushConsumerImpl.getOffsetStore();
        switch (consumeFromWhere) {
            case CONSUME_FROM_LAST_OFFSET_AND_FROM_MIN_WHEN_BOOT_FIRST:
            case CONSUME_FROM_MIN_OFFSET:
            case CONSUME_FROM_MAX_OFFSET:
            case CONSUME_FROM_LAST_OFFSET: {                                                                                                                                                                // @2
               // 省略部分代碼
                break;
            }
            case CONSUME_FROM_FIRST_OFFSET: {                                                                                                                                                              // @3
                // 省略部分代碼
                break;
            }
            case CONSUME_FROM_TIMESTAMP: {                                                                                                                                                                  //@4
                // 省略部分代碼
                break;
            }
            default:
                break;
        }
        return result;                                                                                                                                                                                                                  // @5
    }

代碼@1:先解釋幾個局部變量。

  • result
    最終的返回結果,默認為-1。
  • consumeFromWhere
    消息消費者開始消費的策略,即CONSUME_FROM_LAST_OFFSET等。
  • offsetStore
    offset存儲器,消費組消息偏移量存儲實現器。

代碼@2:CONSUME_FROM_LAST_OFFSET(從隊列的最大偏移量開始消費)的處理邏輯,下文會詳細介紹。

代碼@3:CONSUME_FROM_FIRST_OFFSET(從隊列最小偏移量開始消費)的處理邏輯,下文會詳細介紹。

代碼@4:CONSUME_FROM_TIMESTAMP(從指定時間戳開始消費)的處理邏輯,下文會詳細介紹。

代碼@5:返回最後計算的偏移量,從該偏移量出開始消費。

2.1 CONSUME_FROM_LAST_OFFSET計算邏輯

case CONSUME_FROM_LAST_OFFSET: {
    long lastOffset = offsetStore.readOffset(mq, ReadOffsetType.READ_FROM_STORE);   // @1
    if (lastOffset >= 0) {                                                                                                             // @2
        result = lastOffset;
    }
    // First start,no offset
    else if (-1 == lastOffset) {                                                                                                  // @3
        if (mq.getTopic().startsWith(MixAll.RETRY_GROUP_TOPIC_PREFIX)) {               
            result = 0L;
        } else {
            try {
                result = this.mQClientFactory.getMQAdminImpl().maxOffset(mq);                     
            } catch (MQClientException e) {                                                                              // @4
                result = -1;
            }
        }
    } else {
        result = -1;    
    }
    break;
}

代碼@1:使用offsetStore從消息消費進度文件中讀取消費消費進度,本文將以集群模式為例展開。稍後詳細分析。

代碼@2:如果返回的偏移量大於等於0,則直接使用該offset,這個也能理解,大於等於0,表示查詢到有效的消息消費進度,從該有效進度開始消費,但我們要特別留意lastOffset為0是什麼場景,因為返回0,並不會執行CONSUME_FROM_LAST_OFFSET(語義)。

代碼@3:如果lastOffset為-1,表示當前並未存儲其有效偏移量,可以理解為第一次消費,如果是消費組重試主題,從重試隊列偏移量為0開始消費;如果是普通主題,則從隊列當前的最大的有效偏移量開始消費,即CONSUME_FROM_LAST_OFFSET語義的實現。

代碼@4:如果從遠程服務拉取最大偏移量拉取異常或其他情況,則使用-1作為第一次拉取偏移量。

分析,上述執行的現象,雖然設置的是CONSUME_FROM_LAST_OFFSET,但現象是從隊列的第一條消息開始消費,根據上述源碼的分析,只有從消費組消費進度存儲文件中取到的消息偏移量為0時,才會從第一條消息開始消費,故接下來重點分析消息消費進度存儲器(OffsetStore)在什麼情況下會返回0。

接下來我們將以集群模式來查看一下消息消費進度的查詢邏輯,集群模式的消息進度存儲管理器實現為:
RemoteBrokerOffsetStore,最終Broker端的命令處理類為:ConsumerManageProcessor。

ConsumerManageProcessor#queryConsumerOffset
private RemotingCommand queryConsumerOffset(ChannelHandlerContext ctx, RemotingCommand request) throws RemotingCommandException {
    final RemotingCommand response =
        RemotingCommand.createResponseCommand(QueryConsumerOffsetResponseHeader.class);
    final QueryConsumerOffsetResponseHeader responseHeader =
        (QueryConsumerOffsetResponseHeader) response.readCustomHeader();
    final QueryConsumerOffsetRequestHeader requestHeader =
        (QueryConsumerOffsetRequestHeader) request
            .decodeCommandCustomHeader(QueryConsumerOffsetRequestHeader.class);

    long offset =
        this.brokerController.getConsumerOffsetManager().queryOffset(
            requestHeader.getConsumerGroup(), requestHeader.getTopic(), requestHeader.getQueueId());    // @1

    if (offset >= 0) {                                                                                                                                          // @2
        responseHeader.setOffset(offset);
        response.setCode(ResponseCode.SUCCESS);
        response.setRemark(null);
    } else {                                                                                                                                                       // @3
        long minOffset =
            this.brokerController.getMessageStore().getMinOffsetInQueue(requestHeader.getTopic(),
                requestHeader.getQueueId());                                                                                                     // @4
        if (minOffset <= 0
            && !this.brokerController.getMessageStore().checkInDiskByConsumeOffset(                                // @5
            requestHeader.getTopic(), requestHeader.getQueueId(), 0)) {
            responseHeader.setOffset(0L);
            response.setCode(ResponseCode.SUCCESS);
            response.setRemark(null);
        } else {                                                                                                                                                 // @6
            response.setCode(ResponseCode.QUERY_NOT_FOUND);
            response.setRemark("Not found, V3_0_6_SNAPSHOT maybe this group consumer boot first");
        }
    }
    return response;
}

代碼@1:從消費消息進度文件中查詢消息消費進度。

代碼@2:如果消息消費進度文件中存儲該隊列的消息進度,其返回的offset必然會大於等於0,則直接返回該偏移量該客戶端,客戶端從該偏移量開始消費。

代碼@3:如果未從消息消費進度文件中查詢到其進度,offset為-1。則首先獲取該主題、消息隊列當前在Broker服務器中的最小偏移量(@4)。如果小於等於0(返回0則表示該隊列的文件還未曾刪除過)並且其最小偏移量對應的消息存儲在內存中而不是存在磁盤中,則返回偏移量0,這就意味着ConsumeFromWhere中定義的三種枚舉類型都不會生效,直接從0開始消費,到這裏就能解開其謎團了(@5)。

代碼@6:如果偏移量小於等於0,但其消息已經存儲在磁盤中,此時返回未找到,最終RebalancePushImpl#computePullFromWhere中得到的偏移量為-1。

看到這裏,大家應該能回答文章開頭處提到的問題了吧?

看到這裏,大家應該明白了,為什麼設置的CONSUME_FROM_LAST_OFFSET,但消費組是從消息隊列的開始處消費了吧,原因就是消息消費進度文件中並沒有找到其消息消費進度,並且該隊列在Broker端的最小偏移量為0,說的更直白點,consumequeue/topicName/queueNum的第一個消息消費隊列文件為00000000000000000000,並且消息其對應的消息緩存在Broker端的內存中(pageCache),其返回給消費端的偏移量為0,故會從0開始消費,而不是從隊列的最大偏移量處開始消費。

為了知識體系的完備性,我們順便來看一下其他兩種策略的計算邏輯。

2.2 CONSUME_FROM_FIRST_OFFSET

case CONSUME_FROM_FIRST_OFFSET: {
    long lastOffset = offsetStore.readOffset(mq, ReadOffsetType.READ_FROM_STORE);   // @1
    if (lastOffset >= 0) {    // @2
        result = lastOffset;
    } else if (-1 == lastOffset) {  // @3
        result = 0L;
    } else {                                  
        result = -1;                    // @4
    }
    break;
}

從隊列的開始偏移量開始消費,其計算邏輯如下:
代碼@1:首先通過偏移量存儲器查詢消費隊列的消費進度。

代碼@2:如果大於等於0,則從當前該偏移量開始消費。

代碼@3:如果遠程返回-1,表示並沒有存儲該隊列的消息消費進度,從0開始。

代碼@4:否則從-1開始消費。

2.4 CONSUME_FROM_TIMESTAMP

從指定時戳后的消息開始消費。

case CONSUME_FROM_TIMESTAMP: {
    ong lastOffset = offsetStore.readOffset(mq, ReadOffsetType.READ_FROM_STORE);   // @1
    if (lastOffset >= 0) {                                                                                                            // @2
        result = lastOffset;
    } else if (-1 == lastOffset) {                                                                                                 // @3
        if (mq.getTopic().startsWith(MixAll.RETRY_GROUP_TOPIC_PREFIX)) {
            try {
                result = this.mQClientFactory.getMQAdminImpl().maxOffset(mq);
            } catch (MQClientException e) {
                result = -1;
            }
        } else {
            try {
                long timestamp = UtilAll.parseDate(this.defaultMQPushConsumerImpl.getDefaultMQPushConsumer().getConsumeTimestamp(),
                    UtilAll.YYYYMMDDHHMMSS).getTime();
                result = this.mQClientFactory.getMQAdminImpl().searchOffset(mq, timestamp);
            } catch (MQClientException e) {
                result = -1;
            }
        }
    } else {
        result = -1;
    }
    break;
}

其基本套路與CONSUME_FROM_LAST_OFFSET一樣:
代碼@1:首先通過偏移量存儲器查詢消費隊列的消費進度。

代碼@2:如果大於等於0,則從當前該偏移量開始消費。

代碼@3:如果遠程返回-1,表示並沒有存儲該隊列的消息消費進度,如果是重試主題,則從當前隊列的最大偏移量開始消費,如果是普通主題,則根據時間戳去Broker端查詢,根據查詢到的偏移量開始消費。

原理就介紹到這裏,下面根據上述理論對其進行驗證。

3、猜想與驗證

根據上述理論分析我們得知設置CONSUME_FROM_LAST_OFFSET但並不是從消息隊列的最大偏移量開始消費的“罪魁禍首”是因為消息消費隊列的最小偏移量為0,如果不為0,則就會符合預期,我們來驗證一下這個猜想。
首先我們刪除commitlog目錄下的文件,如圖所示:

其消費隊列截圖如下:

消費端的驗證代碼如下:

public static void main(String[] args) throws InterruptedException, MQClientException {
    DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("my_consumer_02");
    consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_LAST_OFFSET);
    consumer.subscribe("TopicTest", "*");
    consumer.setNamesrvAddr("127.0.0.1:9876");
    consumer.registerMessageListener(new MessageListenerConcurrently() {
        @Override
        public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs,
            ConsumeConcurrentlyContext context) {
            System.out.printf("%s Receive New Messages: %s %n", Thread.currentThread().getName(), msgs);
            return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
        }
    });
    consumer.start();
    System.out.printf("Consumer Started.%n");
}

運行結果如下:

並沒有消息存在的消息,符合預期。

4、解決方案

如果在生產環境下,一個新的消費組訂閱一個已經存在比較久的topic,設置CONSUME_FROM_MAX_OFFSET是符合預期的,即該主題的consumequeue/{queueNum}/fileName,fileName通常不會是00000000000000000000,如是是上面文件名,想要實現從隊列的最後開始消費,該如何做呢?那就走自動創建消費組的路子,執行如下命令:

./mqadmin updateSubGroup -n 127.0.0.1:9876 -c DefaultCluster -g my_consumer_05

//克隆一個訂閱了該topic的消費組消費進度
./mqadmin cloneGroupOffset -n 127.0.0.1:9876 -s my_consumer_01 -d my_consumer_05 -t TopicTest

//重置消費進度到當前隊列的最大值
./mqadmin resetOffsetByTime -n 127.0.0.1:9876 -g my_consumer_05 -t TopicTest -s -1

按照上上述命令后,即可實現其目的。

您都看到這裏了,麻煩幫忙點個贊,謝謝您的認可與鼓勵。

作者介紹:
丁威,《RocketMQ技術內幕》作者,RocketMQ 社區佈道師,公眾號: 維護者,目前已陸續發表源碼分析Java集合、Java 併發包(JUC)、Netty、Mycat、Dubbo、RocketMQ、Mybatis等源碼專欄。歡迎加入我的知識星球,構建一個高質量的技術交流社群。

本站聲明:網站內容來源於博客園,如有侵權,請聯繫我們,我們將及時處理【其他文章推薦】

收購3c,收購IPHONE,收購蘋果電腦-詳細收購流程一覽表

網頁設計公司推薦更多不同的設計風格,搶佔消費者視覺第一線

※廣告預算用在刀口上,網站設計公司幫您達到更多曝光效益

※公開收購3c價格,不怕被賤賣!

※自行創業 缺乏曝光? 下一步"網站設計"幫您第一時間規劃公司的門面形象

Spring框架AOP學習總結(下)

目錄

@
在中主要講的是一些Spring的概述、Spring工廠、Spring屬性注入以及IOC入門,其中最重要的是IOC,上一篇中IOC大概講的小結一下:

然後呢這一篇中主要講一下Spring中除了IOC之外的另一個重要的核心:AOP,在Spring中IOC也好,AOP也好,都必須會二者的XML開發以及註解開發,也就是說IOC和AOP的XML開發以及註解開發都要掌握

1、 AOP 的概述

從專業的角度來講(千萬不要問我有多專業,度娘是我表鍋不對是表嫂QAQ):

在軟件業,AOP為Aspect Oriented Programming的縮寫,意為:面向切面編程,通過預編譯方式和運行期動態代理實現程序功能的統一維護的一種技術。AOP是OOP的延續,是軟件開發中的一個熱點,也是Spring框架中的一個重要內容,是函數式編程的一種衍生范型。利用AOP可以對業務邏輯的各個部分進行隔離,從而使得業務邏輯各部分之間的耦合度降低,提高程序的可重用性,同時提高了開發的效率。

從通俗易懂且不失風趣的角度來講:(來自武哥文章)

面向切面編程的目標就是分離關注點。什麼是關注點呢?就是你要做的事,就是關注點。假如你是個公子哥,沒啥人生目標,天天就是衣來伸手,飯來張口,整天只知道玩一件事!那麼,每天你一睜眼,就光想着吃完飯就去玩(你必須要做的事),但是在玩之前,你還需要穿衣服、穿鞋子、疊好被子、做飯等等等等事情,這些事情就是你的關注點,但是你只想吃飯然後玩,那麼怎麼辦呢?這些事情通通交給別人去干。在你走到飯桌之前,有一個專門的僕人A幫你穿衣服,僕人B幫你穿鞋子,僕人C幫你疊好被子,僕人C幫你做飯,然後你就開始吃飯、去玩(這就是你一天的正事),你幹完你的正事之後,回來,然後一系列僕人又開始幫你干這個干那個,然後一天就結束了!
AOP的好處就是你只需要干你的正事,其它事情別人幫你干。也許有一天,你想裸奔,不想穿衣服,那麼你把僕人A解僱就是了!也許有一天,出門之前你還想帶點錢,那麼你再雇一個僕人D專門幫你干取錢的活!這就是AOP。每個人各司其職,靈活組合,達到一種可配置的、可插拔的程序結構。
從Spring的角度看,AOP最大的用途就在於提供了事務管理的能力。事務管理就是一個關注點,你的正事就是去訪問數據庫,而你不想管事務(太煩),所以,Spring在你訪問數據庫之前,自動幫你開啟事務,當你訪問數據庫結束之後,自動幫你提交/回滾事務!

1、1 為什麼學習 AOP

Spring 的 AOP 的由來:AOP 最早由 AOP 聯盟的組織提出的,制定了一套規範.Spring 將 AOP 思想引入到框架中,必須遵守 AOP 聯盟的規範.

Aop解決實際開發中的一些問題:

  • AOP 解決 OOP 中遇到的一些問題.是 OOP 的延續和擴展.

對程序進行增強:不修改源碼的情況下:

  • AOP 可以進行權限校驗,日誌記錄,性能監控,事務控制.

1、2 AOP底層實現: 代理機制(了解)

Spring 的 AOP 的底層用到兩種代理機制:

  • JDK 的動態代理 :針對實現了接口的類產生代理.
  • Cglib 的動態代理 :針對沒有實現接口的類產生代理. 應用的是底層的字節碼增強的技術 生成當前類的子類對象

spring底層會完成自動代理,實現了接口的類默認使用的是JDK 的動態代理,相反的,沒有實現接口的類默認使用的是Cglib 的動態代理 ,底層代碼可以不懂但這個概念一定要知道,不然會被鄙視的,O(∩_∩)O哈哈~,下面是底層代碼,有興趣的可以了解了解。

JDK 動態代理增強一個類中方法:

public class MyJDKProxy implements InvocationHandler {
        private UserDao userDao;

        public MyJDKProxy(UserDao userDao) {
            this.userDao = userDao;
        }

        // 編寫工具方法:生成代理:
        public UserDao createProxy() {
            UserDao userDaoProxy = (UserDao) Proxy.newProxyInstance(userDao
                    .getClass().getClassLoader(), userDao.getClass()
                    .getInterfaces(), this);
            return userDaoProxy;
        }

        @Override
        public Object invoke(Object proxy, Method method, Object[] args)
                throws Throwable {
            if ("save".equals(method.getName())) {
                System.out.println("權限校驗================");
            }
            return method.invoke(userDao, args);
        }
    }

Cglib 動態代理增強一個類中的方法:

public class MyCglibProxy implements MethodInterceptor {
        private CustomerDao customerDao;

        public MyCglibProxy(CustomerDao customerDao) {
            this.customerDao = customerDao;
        }

        // 生成代理的方法:
        public CustomerDao createProxy() {
            // 創建 Cglib 的核心類:
            Enhancer enhancer = new Enhancer();
            // 設置父類:
            enhancer.setSuperclass(CustomerDao.class);
            // 設置回調:
            enhancer.setCallback(this);
            // 生成代理:
            CustomerDao customerDaoProxy = (CustomerDao) enhancer.create();
            return customerDaoProxy;
        }

        @Override
        public Object intercept(Object proxy, Method method, Object[] args,
                MethodProxy methodProxy) throws Throwable {
            if ("delete".equals(method.getName())) {
                Object obj = methodProxy.invokeSuper(proxy, args);
                System.out.println("日誌記錄================");
                return obj;
            }
            return methodProxy.invokeSuper(proxy, args);
        }
    }

2、 Spring 基於AspectJ 進行 AOP 的開發入門(XML 的方式):

首先,Spring為什麼不直接進行Spring的AOP開發呢,而要基於Aspectj呢,是因為,Spring自己的AOP開發實現方式(傳統的AOP開發)繁瑣且複雜,效率極低,於是傳統的AOP開發基本上棄用了,相反Aspectj的AOP開發效率高,所以AOP開發一般是Spring 的基於 AspectJ 的 AOP 開發。

2.1 AOP 的開發中的相關術語:

Aop是一種非常高深的思想,當然會有非常專業的相關術語了(這彎繞的,你打幾分?)

從專業的角度角度概述定義(相對來說比較枯燥不易理解):

Joinpoint(連接點):所謂連接點是指那些被攔截到的點。在 spring 中,這些點指的是方法,因為 spring 只
支持方法類型的連接點.
Pointcut(切入點):所謂切入點是指我們要對哪些 Joinpoint 進行攔截的定義.
Advice(通知/增強):所謂通知是指攔截到 Joinpoint 之後所要做的事情就是通知.通知分為前置通知,後置
通知,異常通知,最終通知,環繞通知(切面要完成的功能)
Introduction(引介):引介是一種特殊的通知在不修改類代碼的前提下, Introduction 可以在運行期為類
動態地添加一些方法或 Field.
Target(目標對象):代理的目標對象
Weaving(織入):是指把增強應用到目標對象來創建新的代理對象的過程.
spring 採用動態代理織入,而 AspectJ 採用編譯期織入和類裝在期織入
Proxy(代理):一個類被 AOP 織入增強后,就產生一個結果代理類
Aspect(切面): 是切入點和通知(引介)的結合

基於專業的角度實例分析(相對來說易理解,什麼?畫質差?咳咳…1080p藍光畫質…哎哎哎..大哥..別打…別打…別打臉):

2.2引入相應的 jar 包

引入jar包:基礎六個jar包、AOP聯盟jar包、spring的AOPjar包、aspectJ的jar包、spring整合aspectj的jar包

  • spring 的傳統 AOP 的開發的包
    spring-aop-4.2.4.RELEASE.jar
    com.springsource.org.aopalliance-1.0.0.jar

  • aspectJ 的開發包:
    com.springsource.org.aspectj.weaver-1.6.8.RELEASE.jar
    spring-aspects-4.2.4.RELEASE.jar

    2.3 引入 Spring 的配置文件

    引入 AOP 約束:

 <beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:aop="http://www.springframework.org/schema/aop" xsi:schemaLocation="
http://www.springframework.org/schema/beans 
http://www.springframework.org/schema/beans/spring-beans.xsd
http://www.springframework.org/schema/aop 
http://www.springframework.org/schema/aop/spring-aop.xsd">
</beans>

2.4 編寫目標類

創建接口和類:

    public interface OrderDao {
        public void save();

        public void update();

        public void delete();

        public void find();
    }

    public class OrderDaoImpl implements OrderDao {
        @Override
        public void save() {
            System.out.println("保存訂單...");
        }

        @Override
        public void update() {
            System.out.println("修改訂單...");
        }

        @Override
        public void delete() {
            System.out.println("刪除訂單...");
        }

        @Override
        public void find() {
            System.out.println("查詢訂單...");
        }
    }

2.5 目標類的XML配置

<!-- 目標類配置:被增強的類 --> 
<bean id="orderDao" class="com.gx.spring.demo3.OrderDaoImpl"></bean>

2.6 整合 Junit 單元測試

前提:引入 spring-test.jar 測試的jar包,整合 Junit 單元測試之後就不需要每次都重複註冊工廠,只要固定格式在測試類上寫兩個註解,需要的屬性直接注入,之後只關心自己的測試類即可

//固定註解寫法(前提:引入 spring-test.jar 測試的jar包)
    @RunWith(SpringJUnit4ClassRunner.class)
    @ContextConfiguration("classpath:applicationContext.xml")
    public class SpringDemo3 {
        @Resource(name = "orderDao")  //需要的屬性直接注入(前提:引入 spring-test.jar 測試的jar包)
        private OrderDao orderDao;

        @Test
        public void demo1() {
            orderDao.save();
            orderDao.update();
            orderDao.delete();
            orderDao.find();
        }
    }

運行demo出現如下效果:

2.7 通知類型

到這裏,就需要需要對通知類型了解一下(前三者常用):

前置通知 :在目標方法執行之前執行.

後置通知 :在目標方法執行之後執行

如果要獲得後置通知中的返回值,必須注意的是:

環繞通知 :在目標方法執行前和執行后執行

異常拋出通知:在目標方法執行出現 異常的時候 執行
最終通知 :無論目標方法是否出現異常 最終通知都會 執行.

通知類型XML配置

2.8 切入點表達式

execution(表達式)

表達式 : [方法訪問修飾符] 方法返回值 包名.類名.方法名(方法的參數)

切入點表達式所以就是execution( [方法訪問修飾符] 方法返回值 包名.類名.方法名(方法的參數))

其中 [ ] 中的方法訪問修飾符可有可無

切入點表達式各類型例子:

public * com.gx.spring.dao. * .*(..)
com.gx.spring.dao.*.*(..)
com.gx.spring.dao.UserDao+.*(..)
com.gx.spring.dao..*.*(..)

2.9 編寫一個切面類

好了,了解了通知類型以及切入點表達式之後就可以來 編寫一個切面類玩起來了QAQ

public class MyAspectXml {
    // 前置增強
    public void before(){
       System.out.println("前置增強===========");
} }

2.10 配置完成增強

<!-- 配置切面類 --> 
<bean id="myAspectXml" class="com.gx.spring.demo3.MyAspectXml"></bean>
<!-- 進行 aop 的配置 --> 
<aop:config>
<!-- 配置切入點表達式:哪些類的哪些方法需要進行增強 -->
 <aop:pointcut expression="execution(* com.gx.spring.demo3.OrderDao.save(..))" id="pointcut1"/>
<!-- 配置切面 --> 
<aop:aspect ref="myAspectXml"> 
    <aop:before method="before" pointcut-ref="pointcut1"/>
</aop:aspect>
</aop:config>

需要注意的點我都規劃出來了(不用誇我,我知道我長得帥QnQ)

2.11 其他的增強的配置:

<!-- 配置切面類 -->
 <bean id="myAspectXml" class="com.gx.demo3.MyAspectXml"></bean>
    <!-- 進行 aop 的配置 -->
 <aop:config>
    <!-- 配置切入點表達式:哪些類的哪些方法需要進行增強 -->
     <aop:pointcut expression="execution(* com.gx.spring.demo3.*Dao.save(..))" id="pointcut1"/>
     <aop:pointcut expression="execution(* com.gx.spring.demo3.*Dao.delete(..))" id="pointcut2"/>
     <aop:pointcut expression="execution(* com.gx.spring.demo3.*Dao.update(..))" id="pointcut3"/>
     <aop:pointcut expression="execution(* com.gx.spring.demo3.*Dao.find(..))" id="pointcut4"/>
    <!-- 配置切面 --> 
    <aop:aspect ref="myAspectXml">
       <aop:before method="before" pointcut-ref="pointcut1"/>
       <aop:after-returning method="afterReturing"pointcut-ref="pointcut2"/>
       <aop:around method="around" pointcut-ref="pointcut3"/>
       <aop:after-throwing method="afterThrowing" pointcut-ref="pointcut4"/>
       <aop:after method="after" pointcut-ref="pointcut4"/>
    </aop:aspect>
</aop:config>

3、Spring 基於AspectJ 進行 AOP 的開發入門(註解的方式):

3.1創建項目,引入jar包

引入的jar包如下:

3.2引入配置文件

3.3編寫目標類並配置

編寫目標類:

package com.gx.spring.demo1;

public class OrderDao {

    public void save(){
        System.out.println("保存訂單...");
    }
    public void update(){
        System.out.println("修改訂單...");
    }
    public String delete(){
        System.out.println("刪除訂單...");
        return "鄢寒";
    }
    public void find(){
        System.out.println("查詢訂單...");
    }
}

XML配置:

<!-- 配置目標類 -->
    <bean id="orderDao" class="com.gx.spring.demo1.OrderDao">

    </bean>

3.4編寫切面類並配置

編寫切面類

package com.gx.spring.demo1;

import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.After;
import org.aspectj.lang.annotation.AfterReturning;
import org.aspectj.lang.annotation.AfterThrowing;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.Pointcut;

/**
 * 切面類:註解的切面類
 * @author jt
 */
public class MyAspectAnno {

    public void before(){
        System.out.println("前置增強===========");
    }
}

XML配置:

<!-- 配置切面類 -->
    <bean id="myAspect" class="com.gx.spring.demo1.MyAspectAnno">
    
    </bean>

3.5使用註解的AOP對象目標類進行增強

1、在配置文件中打開註解的AOP開發

<!-- 在配置文件中開啟註解的AOP的開發 -->
    <aop:aspectj-autoproxy/>

2、在切面類上使用註解
在類上使用@Aspect註解代表這是一個切面類
在方法上注入屬性@Before(execution表達式)代表前置增強

@Aspect
public class MyAspectAnno {

    @Before(value="execution(* com.gx.spring.demo1.OrderDao.save(..))")
    public void before(){
        System.out.println("前置增強===========");
    }
}

3.6編寫測試類

package com.gx.spring.demo1;

import javax.annotation.Resource;

import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;

/**
 * Spring的AOP的註解開發
 *
 */
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration("classpath:applicationContext.xml")
public class SpringDemo1 {
    @Resource(name="orderDao")
    private static OrderDao orderDao;
    
    public static void main(String[] args) {
        
            orderDao.save();
            orderDao.update();
            orderDao.delete();
            orderDao.find();
        
    }
    
}

測試結果:

4、Spring的註解的AOP的通知類型

4.1@Before :前置通知

@Aspect
public class MyAspectAnno {

    @Before(value="execution(* com.gx.spring.demo1.OrderDao.save(..))")
    public void before(){
        System.out.println("前置增強===========");
    }
}

4.2@AfterReturning :後置通知

後置通知可以獲取方法返回值

// 後置通知:
    @AfterReturning(value="execution(* com.gx.spring.demo1.OrderDao.save(..))")
    public void afterReturning(Object result){
        System.out.println("後置增強==========="+result);
    }

借用一下XML方式的圖,意思意思啦,意思還是那個意思QnQ

4.3@Around :環繞通知

// 環繞通知:
    @Around(value="execution(* com.gx.spring.demo1.OrderDao.save(..))")
    public Object around(ProceedingJoinPoint joinPoint) throws Throwable{
        System.out.println("環繞前增強==========");
        Object obj  = joinPoint.proceed();
        System.out.println("環繞后增強==========");
        return obj;
    }

4.4@AfterThrowing :異常拋出通知

測試前記得製造出個異常qnq

// 異常拋出通知:
    @AfterThrowing(value="execution(* com.gx.spring.demo1.OrderDao.save(..))" throwing="e")
    public void afterThrowing(Throwable e){
        System.out.println("異常拋出增強========="+e.getMessage());
    }

4.5@After :最終通知

// 最終通知
    @After(value="execution(* com.gx.spring.demo1.OrderDao.save(..))")
    public void after(){
        System.out.println("最終增強============");
    }

5、Spring的註解的AOP的切入點的配置

首先,我們發現在Spring 基於AspectJ 進行 AOP 的開發入門(註解的方式)的過程中如果方法過多,通知過多並且作用於一個方法,需求一改變就需要更改相應的源代碼,為了更好的維護,於是有了AOP的切入點的配置,AOP的切入點的配置能很好地決絕改問題!只需要管理AOP的切入點的配置即可!

具體代碼如下:

package com.gx.spring.demo1;

import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.After;
import org.aspectj.lang.annotation.AfterReturning;
import org.aspectj.lang.annotation.AfterThrowing;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.Pointcut;

/**
 * 切面類:註解的切面類
 * @author jt
 */
@Aspect
public class MyAspectAnno {
    // 前置通知:
    @Before(value="MyAspectAnno.pointcut2()")
    public void before(){
        System.out.println("前置增強===========");
    }
    
    // 後置通知:
    @AfterReturning(value="MyAspectAnno.pointcut4()",returning="result")
    public void afterReturning(Object result){
        System.out.println("後置增強==========="+result);
    }
    
    // 環繞通知:
    @Around(value="MyAspectAnno.pointcut3()")
    public Object around(ProceedingJoinPoint joinPoint) throws Throwable{
        System.out.println("環繞前增強==========");
        Object obj  = joinPoint.proceed();
        System.out.println("環繞后增強==========");
        return obj;
    }
    
    // 異常拋出通知:
    @AfterThrowing(value="MyAspectAnno.pointcut1()",throwing="e")
    public void afterThrowing(Throwable e){
        System.out.println("異常拋出增強========="+e.getMessage());
    }
    
    // 最終通知
    @After(value="MyAspectAnno.pointcut1()")
    public void after(){
        System.out.println("最終增強============");
    }
    
    // 切入點註解:
    @Pointcut(value="execution(* com.gx.spring.demo1.OrderDao.find(..))")
    private void pointcut1(){}
    @Pointcut(value="execution(* com.gx.spring.demo1.OrderDao.save(..))")
    private void pointcut2(){}
    @Pointcut(value="execution(* com.gx.spring.demo1.OrderDao.update(..))")
    private void pointcut3(){}
    @Pointcut(value="execution(* com.gx.spring.demo1.OrderDao.delete(..))")
    private void pointcut4(){}
}

如果本文對你有一點點幫助,那麼請點個讚唄,謝謝~

最後,若有不足或者不正之處,歡迎指正批評,感激不盡!如果有疑問歡迎留言,絕對第一時間回復!

歡迎各位關注我的公眾號,一起探討技術,嚮往技術,追求技術,說好了來了就是盆友喔…

本站聲明:網站內容來源於博客園,如有侵權,請聯繫我們,我們將及時處理【其他文章推薦】

※為什麼 USB CONNECTOR 是電子產業重要的元件?

收購3c,收購IPHONE,收購蘋果電腦-詳細收購流程一覽表

網頁設計一頭霧水??該從何著手呢? 找到專業技術的網頁設計公司,幫您輕鬆架站!

※想要讓你的商品在網路上成為最夯、最多人討論的話題?

※高價收購3C產品,價格不怕你比較

※想知道最厲害的台北網頁設計公司推薦台中網頁設計公司推薦專業設計師”嚨底家”!!

一文帶你深入了解 redis 複製技術及主從架構

主從架構可以說是互聯網必備的架構了,第一是為了保證服務的高可用,第二是為了實現讀寫分離,你可能熟悉我們常用的 MySQL 數據庫的主從架構,對於我們 redis 來說也不意外,redis 數據庫也有各種各樣的主從架構方式,在主從架構中會涉及到主節點與從節點之間的數據同步,這個數據同步的過程在 redis 中叫做複製,這在篇文章中,我們詳細的聊一聊 redis 的複製技術和主從架構 ,本文主要有以下內容:

  • 主從架構環境搭建
    • 主從架構的建立方式
    • 主從架構的斷開
  • 複製技術的原理
    • 數據同步過程
    • 心跳檢測
  • 主從拓撲架構
    • 一主一從
    • 一主多從
    • 樹狀結構

主從環境搭建

redis 的實例在默認的情況下都是主節點,所以我們需要修改一些配置來搭建主從架構,redis 的主從架構搭建還是比較簡單的,redis 提供了三種方式來搭建主從架構,在後面我們將就介紹,在介紹之前我們要先了解主從架構的特性:在主從架構中有一個主節點(master)和最少一個從節點(slave),並且數據複製是單向的,只能從主節點複製到從節點,不能由從節點到主節點。

主從架構的建立方式

主從架構的建立有以下三種方式:

  • 在 Redis.conf 配置文件中加入 slaveof {masterHost} {masterPort} 命令,隨 Redis 實例的啟動生效
  • 在 redis-server 啟動命令后加入 –slaveof {masterHost} {masterPort} 參數
  • 在 redis-cli 交互窗口下直接使用命令:slaveof {masterHost} {masterPort}

上面三種方式都可以搭建 Redis 主從架構,我們以第一種方式來演示,其他兩種方式自行嘗試,由於是演示,所以就在本地啟動兩個 Redis 實例,並不在多台機器上啟動 redis 的實例了,我們準備一個端口 6379 的主節點實例,準備一個端口 6480 從節點的實例,端口 6480 的 redis 實例配置文件取名為 6480.conf 並且在裏面添加 slaveof 語句,在配置文件最後加入如下一條語句

slaveof 127.0.0.1 6379

分別啟動兩個 redis 實例,啟動之後他們會自動建立主從關係,關於這背後的原理,我們後面在詳細的聊一聊,先來驗證一下我們的主從架構是否搭建成功,我們先在 6379 master 節點上新增一條數據:

然後再 6480 slave 節點上獲取該數據:

可以看出我們在 slave 節點上已經成功的獲取到了在 master 節點新增的值,說明主從架構已經搭建成功了,我們使用 info replication 命令來查看兩個節點的信息,先來看看主節點的信息

可以看出 6379 端口的實例 role 為 master,有一個正在連接的實例,還有其他運行的信息,我們再來看看 6480 端口的 redis 實例信息

可以看出兩個節點之間相互記錄著對象的信息,這些信息在數據複製時候將會用到。在這裡有一點需要說明一下,默認情況下 slave 節點是只讀的,並不支持寫入,也不建議開啟寫入,我們可以驗證一下,在 6480 實例上寫入一條數據

127.0.0.1:6480> set x 3
(error) READONLY You can't write against a read only replica.
127.0.0.1:6480> 

提示只讀,並不支持寫入操作,當然我們也可以修改該配置,在配置文件中 replica-read-only yes 配置項就是用來控制從服務器只讀的,為什麼只能只讀?因為我們知道複製是單向的,數據只能由 master 到 slave 節點,如果在 salve 節點上開啟寫入的話,那麼修改了 slave 節點的數據, master 節點是感知不到的,slave 節點的數據並不能複製到 master 節點上,這樣就會造成數據不一致的情況,所以建議 slave 節點只讀

主從架構的斷開

主從架構的斷開同樣是 slaveof 命令,在從節點上執行 slaveof no one 命令就可以與主節點斷開追隨關係,我們在 6480 節點上執行 slaveof no one 命令

127.0.0.1:6480> slaveof no one
OK
127.0.0.1:6480> info replication
# Replication
role:master
connected_slaves:0
master_replid:a54f3ba841c67762d6c1e33456c97b94c62f6ac0
master_replid2:e5c1ab2a68064690aebef4bd2bd4f3ddfba9cc27
master_repl_offset:4367
second_repl_offset:4368
repl_backlog_active:1
repl_backlog_size:1048576
repl_backlog_first_byte_offset:1
repl_backlog_histlen:4367
127.0.0.1:6480> 

執行完 slaveof no one 命令之後,6480 節點的角色立馬恢復成了 master ,我們再來看看時候還和 6379 實例連接在一起,我們在 6379 節點上新增一個 key-value

127.0.0.1:6379> set y 3
OK

在 6480 節點上 get y

127.0.0.1:6480> get y
(nil)
127.0.0.1:6480> 

在 6480 節點上獲取不到 y ,因為 6480 節點已經跟 6379 節點斷開的聯繫,不存在主從關係了,slaveof 命令不僅能夠斷開連接,還能切換主服務器,使用命令為 slaveof {newMasterIp} {newMasterPort},我們讓 6379 成為 6480 的從節點, 在 6379 節點上執行 slaveof 127.0.0.1 6480 命令,我們在來看看 6379 的 info replication

127.0.0.1:6379> info replication
# Replication
role:slave
master_host:127.0.0.1
master_port:6480
master_link_status:up
master_last_io_seconds_ago:2
master_sync_in_progress:0
slave_repl_offset:4367
slave_priority:100
slave_read_only:1
connected_slaves:0
master_replid:99624d4b402b5091552b9cb3dd9a793a3005e2ea
master_replid2:0000000000000000000000000000000000000000
master_repl_offset:4367
second_repl_offset:-1
repl_backlog_active:1
repl_backlog_size:1048576
repl_backlog_first_byte_offset:4368
repl_backlog_histlen:0
127.0.0.1:6379> 

6379 節點的角色已經是 slave 了,並且主節點的是 6480 ,我們可以再看看 6480 節點的 info replication

127.0.0.1:6480> info replication
# Replication
role:master
connected_slaves:1
slave0:ip=127.0.0.1,port=6379,state=online,offset=4479,lag=1
master_replid:99624d4b402b5091552b9cb3dd9a793a3005e2ea
master_replid2:a54f3ba841c67762d6c1e33456c97b94c62f6ac0
master_repl_offset:4479
second_repl_offset:4368
repl_backlog_active:1
repl_backlog_size:1048576
repl_backlog_first_byte_offset:1
repl_backlog_histlen:4479
127.0.0.1:6480> 

在 6480 節點上有 6379 從節點的信息,可以看出 slaveof 命令已經幫我們完成了主服務器的切換。

複製技術的原理

redis 的主從架構好像很簡單一樣,我們就執行了一條命令就成功搭建了主從架構,並且數據複製也沒有問題,使用起來確實簡單,但是這背後 redis 還是幫我們做了很多的事情,比如主從服務器之間的數據同步、主從服務器的狀態檢測等,這背後 redis 是如何實現的呢?接下來我們就一起看看

數據複製原理

我們執行完 slaveof 命令之後,我們的主從關係就建立好了,在這個過程中, master 服務器與 slave 服務器之間需要經歷多個步驟,如下圖所示:

slaveof 命令背後,主從服務器大致經歷了七步,其中權限驗證這一步不是必須的,為了能夠更好的理解這些步驟,就以我們上面搭建的 redis 實例為例來詳細聊一聊各步驟。

1、保存主節點信息

在 6480 的客戶端向 6480 節點服務器發送 slaveof 127.0.0.1 6379 命令時,我們會立馬得到一個 OK

127.0.0.1:6480> slaveof 127.0.0.1 6379
OK
127.0.0.1:6480> 

這時候數據複製工作並沒有開始,數據複製工作是在返回 OK 之後才開始執行的,這時候 6480 從節點做的事情是將給定的主服務器 IP 地址 127.0.0.1 以及端口 6379 保存到服務器狀態的 masterhost 屬性和 masterport 屬性裏面

2、建立 socket 連接

在 slaveof 命令執行完之後,從服務器會根據命令設置的 IP 地址和端口,跟主服務器創建套接字連接, 如果從服務器能夠跟主服務器成功的建立 socket 連接,那麼從服務器將會為這個 socket 關聯一個專門用於處理複製工作的文件事件處理器,這個處理器將負責後續的複製工作,比如接受全量複製的 RDB 文件以及服務器傳來的寫命令。同樣主服務器在接受從服務器的 socket 連接之後,將為該 socket 創建一個客戶端狀態,這時候的從服務器同時具有服務器和客戶端兩個身份,從服務器可以向主服務器發送命令請求而主服務器則會向從服務器返回命令回復。

3、發送 ping 命令

從服務器與主服務器連接成功后,做的第一件事情就是向主服務器發送一個 ping 命令,發送 ping 命令主要有以下目的:

  • 檢測主從之間網絡套接字是否可用
  • 檢測主節點當前是否可接受處理命令

在發送 ping 命令之後,正常情況下主服務器會返回 pong 命令,接受到主服務器返回的 pong 回復之後就會進行下一步工作,如果沒有收到主節點的 pong 回復或者超時,比如網絡超時或者主節點正在阻塞無法響應命令,從服務器會斷開複製連接,等待下一次定時任務的調度。

4、身份驗證

從服務器在接收到主服務器返回的 pong 回復之後,下一步要做的事情就是根據配置信息決定是否需要身份驗證:

  • 如果從服務器設置了 masterauth 參數,則進行身份驗證
  • 如果從服務器沒有設置 masterauth 參數,則不進行身份驗證

在需要身份驗證的情況下,從服務器將就向主服務器發送一條 auth 命令,命令參數為從服務器 masterauth 選項的值,舉個例子,如果從服務器的配置里將 masterauth 參數設置為:123456,那麼從服務器將向主服務器發送 auth 123456 命令,身份驗證的過程也不是一帆風順的,可能會遇到以下幾種情況:

  • 從服務器通過 auth 命令發送的密碼與主服務器的 requirepass 參數值一致,那麼將繼續進行後續操作,如果密碼不一致,主服務將返回一個 invalid password 錯誤
  • 如果主服務器沒有設置 requirepass 參數,那麼主服務器將返回一個 no password is set 錯誤

所有的錯誤情況都會令從服務器中止當前的複製工作,並且要從建立 socket 開始重新發起複制流程,直到身份驗證通過或者從服務器放棄執行複製為止

5、發送端口信息

在身份驗證通過後,從服務器將執行 REPLCONF listening 命令,向主服務器發送從服務器的監聽端口號,例如在我們的例子中從服務器監聽的端口為 6480,那麼從服務器將向主服務器發送 REPLCONF listening 6480 命令,主服務器接收到這個命令之後,會將端口號記錄在從服務器所對應的客戶端狀態的 slave_listening_port 屬性了,也就是我們在 master 服務器的 info replication 裏面看到的 port 值。

6、數據複製

數據複製是最複雜的一塊了,由 psync 命令來完成,從服務器會向主服務器發送一個 psync 命令來進行數據同步,在 redis 2.8 版本以前使用的是 sync 命令,除了命令不同之外,在複製的方式上也有很大的不同,在 redis 2.8 版本以前使用的都是全量複製,這對主節點和網絡會造成很大的開銷,在 redis 2.8 版本以後,數據同步將分為全量同步和部分同步。

  • 全量複製:一般用於初次複製場景,不管是新舊版本的 redis 在從服務器第一次與主服務連接時都將進行一次全量複製,它會把主節點的全部數據一次性發給從節點,當數據較大時,會對主節點和網絡造成很大的開銷,redis 的早期版本只支持全量複製,這不是一種高效的數據複製方式

  • 部分複製:用於處理在主從複製中因網絡閃斷等原因造成的數據丟失 場景,當從節點再次連上主節點后,如果條件允許,主節點會補發丟失數據 給從節點。因為補發的數據遠遠小於全量數據,可以有效避免全量複製的過高開銷,部分複製是對老版複製的重大優化,有效避免了不必要的全量複製操作

redis 之所以能夠支持全量複製和部分複製,主要是對 sync 命令的優化,在 redis 2.8 版本以後使用的是一個全新的 psync 命令,命令格式為:psync {runId} {offset},這兩個參數的意義:

  • runId:主節點運行的id
  • offset:當前從節點複製的數據偏移量

也許你對上面的 runid、offset 比較陌生,沒關係,我們先來看看下面三個概念:

1、複製偏移量

參与複製的主從節點都會分別維護自身複製偏移量:主服務器每次向從服務器傳播 N 個字節的數據時,就將自己的偏移量的值加上 N,從服務器每次接收到主服務器傳播的 N個字節的數據時,將自己的偏移量值加上 N。通過對比主從服務器的複製偏移量,就可以知道主從服務器的數據是否一致,如果主從服務器的偏移量總是相同,那麼主從數據一致,相反,如果主從服務器兩個的偏移量並不相同,那麼說明主從服務器並未處於數據一致的狀態,比如在有多個從服務器時,在傳輸的過程中某一個服務器離線了,如下圖所示:

由於從服務器A 在數據傳輸時,由於網絡原因掉線了,導致偏移量與主服務器不一致,那麼當從服務器A 重啟並且與主服務器連接成功后,重新向主服務器發送 psync 命令,這時候數據複製應該執行全量複製還是部分複製呢?如果執行部分複製,主服務器又如何補償從服務器A 在斷線期間丟失的那部分數據呢?這些問題的答案都在複製積壓緩衝區裏面

2、複製積壓緩衝區

複製積壓緩衝區是保存在主節點上的一個固定長度的隊列,默認大小為 1MB,當主節點有連接的從節點(slave)時被創建,這時主節點(master) 響應寫命令時,不但會把命令發送給從節點,還會寫入複製積壓緩衝區,如下圖所示:

因此,主服務器的複製積壓緩衝區裏面會保存着一部分最近傳播的寫命令,並且複製積壓緩衝區會為隊列中的每個字節記錄相應的複製偏移量。所以當從服務器重新連上主服務器時,從服務器通過 psync 命令將自己的複製偏移量 offset 發送給主服務器,主服務器會根據這個複製偏移量來決定對從服務器執行何種數據同步操作:

  • 如果從服務器的複製偏移量之後的數據仍然存在於複製積壓緩衝區裏面,那麼主服務器將對從服務器執行部分複製操作
  • 如果從服務器的複製偏移量之後的數據不存在於複製積壓緩衝區裏面,那麼主服務器將對從服務器執行全量複製操作

3、服務器運行ID

每個 Redis 節點啟動后都會動態分配一個 40 位的十六進制字符串作為運行 ID,運行 ID 的主要作用是用來唯一識別 Redis 節點,我們可以使用 info server 命令來查看

127.0.0.1:6379> info server
# Server
redis_version:5.0.5
redis_git_sha1:00000000
redis_git_dirty:0
redis_build_id:2ef1d58592147923
redis_mode:standalone
os:Linux 3.10.0-957.27.2.el7.x86_64 x86_64
arch_bits:64
multiplexing_api:epoll
atomicvar_api:atomic-builtin
gcc_version:4.8.5
process_id:25214
run_id:7b987673dfb4dfc10dd8d65b9a198e239d20d2b1
tcp_port:6379
uptime_in_seconds:14382
uptime_in_days:0
hz:10
configured_hz:10
lru_clock:14554933
executable:/usr/local/redis-5.0.5/src/./redis-server
config_file:/usr/local/redis-5.0.5/redis.conf
127.0.0.1:6379> 

這裏面有一個run_id 字段就是服務器運行的ID

了解這幾個概念之後,我們一起來看看 psync 命令的運行流程,psync 命令運行流程如下圖所示:

psync 命令的邏輯比較簡單,整個流程分為兩步:

1、從節點發送 psync 命令給主節點,參數 runId 是當前從節點保存的主節點運行ID,參數offset是當前從節點保存的複製偏移量,如果是第一次參与複製則默認值為 -1。

2、主節點接收到 psync 命令之後,會向從服務器返回以下三種回復中的一種:

  • 回復 +FULLRESYNC {runId} {offset}:表示主服務器將與從服務器執行一次全量複製操作,其中 runid 是這個主服務器的運行 id,從服務器會保存這個id,在下一次發送 psync 命令時使用,而 offset 則是主服務器當前的複製偏移量,從服務器會將這個值作為自己的初始化偏移量
  • 回復 +CONTINUE:那麼表示主服務器與從服務器將執行部分複製操作,從服務器只要等着主服務器將自己缺少的那部分數據發送過來就可以了
  • 回復 +ERR:那麼表示主服務器的版本低於 redis 2.8,它識別不了 psync 命令,從服務器將向主服務器發送 sync 命令,並與主服務器執行全量複製

7、命令持續複製

當主節點把當前的數據同步給從節點后,便完成了複製的建立流程。但是主從服務器並不會斷開連接,因為接下來主節點會持續地把寫命令發送給從節點,保證主從數據一致性。

經過上面 7 步就完成了主從服務器之間的數據同步,由於這篇文章的篇幅比較長,關於全量複製和部分複製的細節就不介紹了,全量複製就是將主節點的當前的數據生產 RDB 文件,發送給從服務器,從服務器再從本地磁盤加載,這樣當文件過大時就需要特別大的網絡開銷,不然由於數據傳輸比較慢會導致主從數據延時較大,部分複製就是主服務器將複製積壓緩衝區的寫命令直接發送給從服務器。

心跳檢測

心跳檢測是發生在主從節點在建立複製后,它們之間維護着長連接並彼此發送心跳命令,便以後續持續發送寫命令,主從心跳檢測如下圖所示:

主從節點彼此都有心跳檢測機制,各自模擬成對方的客戶端進行通信,主從心跳檢測的規則如下:

  • 主節點默認每隔 10 秒對從節點發送 ping 命令,判斷從節點的存活性和連接狀態。可通過修改 redis.conf 配置文件裏面的 repl-ping-replica-period 參數來控制發送頻率
  • 從節點在主線程中每隔 1 秒發送 replconf ack {offset} 命令,給主節點 上報自身當前的複製偏移量,這條命令除了檢測主從節點網絡之外,還通過發送複製偏移量來保證主從的數據一致

主節點根據 replconf 命令判斷從節點超時時間,體現在 info replication 統 計中的 lag 信息中,我們在主服務器上執行 info replication 命令:

127.0.0.1:6379> info replication
# Replication
role:master
connected_slaves:1
slave0:ip=127.0.0.1,port=6480,state=online,offset=25774,lag=0
master_replid:c62b6621e3acac55d122556a94f92d8679d93ea0
master_replid2:0000000000000000000000000000000000000000
master_repl_offset:25774
second_repl_offset:-1
repl_backlog_active:1
repl_backlog_size:1048576
repl_backlog_first_byte_offset:1
repl_backlog_histlen:25774
127.0.0.1:6379> 

可以看出 slave0 字段的值最後面有一個 lag,lag 表示與從節點最後一次通信延遲的秒數,正常延遲應該在 0 和 1 之間。如果超過 repl-timeout 配置的值(默認60秒),則判定從節點下線並斷開複製客戶端連接,如果從節點重新恢復,心跳檢測會繼續進行。

主從拓撲架構

Redis的主從拓撲結構可以支持單層或多層複製關係,根據拓撲複雜性可以分為以下三種:一主一從、一主多從、樹狀主從架構

一主一從結構

一主一從結構是最簡單的複製拓撲結構,我們前面搭建的就是一主一從的架構,架構如圖所示:

一主一從架構用於主節點出現宕機時從節點 提供故障轉移支持,當應用寫命令併發量較高且需要持久化時,可以只在從節點上開啟 AOF,這樣既保證數據安全性同時也避免了持久化對主節點的性能干擾。但是這裡有一個坑,需要你注意,就是當主節點關閉持久化功能時, 如果主節點脫機要避免自動重啟操作。因為主節點之前沒有開啟持久化功能自動重啟后數據集為空,這時從節點如果繼續複製主節點會導致從節點數據也被清空的情況,喪失了持久化的意義。安全的做法是在從節點上執行 slaveof no one 斷開與主節點的複製關係,再重啟主節點從而避免這一問題

一主多從架構

一主多從架構又稱為星形拓撲結構,一主多從架構如下圖所示:

一主多從架構可以實現讀寫分離來減輕主服務器的壓力,對於讀佔比較大的場景,可以把讀命令發送到 從節點來分擔主節點壓力。同時在日常開發中如果需要執行一些比較耗時的讀命令,如:keys、sort等,可以在其中一台從節點上執行,防止慢查詢對主節點造成阻塞從而影響線上服務的穩定性。對於寫併發量較高的場景,多個從節點會導致主節點寫命令的多次發送從而過度消耗網絡帶寬,同時也加重了主節點的負載影響服務穩定性。

樹狀主從架構

樹狀主從架構又稱為樹狀拓撲架構,樹狀主從架構如下圖所示:

樹狀主從架構使得從節點不但可以複製主節 數據,同時可以作為其他從節點的主節點繼續向下層複製。解決了一主多從架構中的不足,通過引入複製中 間層,可以有效降低主節點負載和需要傳送給從節點的數據量。如架構圖中,數據寫入節點A 後會同步到 B 和 C節點,B節點再把數據同步到 D 和 E節點,數據實現了一層一層的向下複製。當主節點需要掛載多個從節點時為了避免對主節點的性能干擾,可以採用樹狀主從結構降低主節點壓力。

最後

目前互聯網上很多大佬都有 Redis 系列教程,如有雷同,請多多包涵了。原創不易,碼字不易,還希望大家多多支持。若文中有所錯誤之處,還望提出,謝謝。

歡迎掃碼關注微信公眾號:「平頭哥的技術博文」,和平頭哥一起學習,一起進步。

本站聲明:網站內容來源於博客園,如有侵權,請聯繫我們,我們將及時處理【其他文章推薦】

※為什麼 USB CONNECTOR 是電子產業重要的元件?

收購3c,收購IPHONE,收購蘋果電腦-詳細收購流程一覽表

網頁設計一頭霧水??該從何著手呢? 找到專業技術的網頁設計公司,幫您輕鬆架站!

※想要讓你的商品在網路上成為最夯、最多人討論的話題?

※高價收購3C產品,價格不怕你比較

※想知道最厲害的台北網頁設計公司推薦台中網頁設計公司推薦專業設計師”嚨底家”!!

Three.js – 走進3D的奇妙世界

本文將通過Three.js的介紹及示例帶我們走進3D的奇妙世界。

文章來源:宜信技術學院 & 宜信支付結算團隊技術分享第6期-支付結算部支付研發團隊前端研發高級工程師-劉琳《three.js – 走進3D的奇妙世界》

分享者:宜信支付結算部支付研發團隊前端研發高級工程師-劉琳

原文首發於支付結算團隊公號-“野指針”

隨着人們對用戶體驗越來越重視,Web開發已經不滿足於2D效果的實現,而把目標放到了更加炫酷的3D效果上。Three.js是用於實現web端3D效果的JS庫,它的出現讓3D應用開發更簡單,本文將通過Three.js的介紹及示例帶我們走進3D的奇妙世界。

一、Three.js相關概念

1.1 Three.JS

Three.JS是基於WebGL的Javascript開源框架,簡言之,就是能夠實現3D效果的JS庫。

1.2 WebGL

WebGL是一種Javascript的3D圖形接口,把JavaScript和OpenGL ES 2.0結合在一起。

1.3 OpenGL

OpenGL是開放式圖形標準,跨編程語言、跨平台,Javascript、Java 、C、C++ 、 python 等都能支持OpenG ,OpenGL的Javascript實現就是WebGL,另外很多CAD製圖軟件都採用這種標準。OpenGL ES 2.0是OpenGL的子集,針對手機、遊戲主機等嵌入式設備而設計。

1.4 Canvas

Canvas是HTML5的畫布元素,在使用Canvas時,需要用到Canvas的上下文,可以用2D上下文繪製二維的圖像,也可以使用3D上下文繪製三維的圖像,其中3D上下文就是指WebGL。

二、Three.js應用場景

利用Three.JS可以製作出很多酷炫的3D動畫,並且Three.js還可以通過鼠標、鍵盤、拖拽等事件形成交互,在頁面上增加一些3D動畫和3D交互可以產生更好的用戶體驗。

通過Three.JS可以實現全景視圖,這些全景視圖應用在房產、家裝行業能夠帶來更直觀的視覺體驗。在電商行業利用Three.JS可以實現產品的3D效果,這樣用戶就可以360度全方位地觀察商品了,給用戶帶來更好的購物體驗。另外,使用Three.JS還可以製作類似微信跳一跳那樣的小遊戲。隨着技術的發展、基礎網絡的建設,web3D技術還能得到更廣泛的應用。

三、主要組件

在Three.js中,有了場景(scene)、相機(camera)和渲染器(renderer) 這3個組建才能將物體渲染到網頁中去。

1)場景

場景是一個容器,可以看做攝影的房間,在房間中可以布置背景、擺放拍攝的物品、添加燈光設備等。

2)相機

相機是用來拍攝的工具,通過控制相機的位置和方向可以獲取不同角度的圖像。

3)渲染器

渲染器利用場景和相機進行渲染,渲染過程好比攝影師拍攝圖像,如果只渲染一次就是靜態的圖像,如果連續渲染就能得到動態的畫面。在JS中可以使用requestAnimationFrame實現高效的連續渲染。

3.1 常用相機

1)透視相機

透視相機模擬的效果與人眼看到的景象最接近,在3D場景中也使用得最普遍,這種相機最大的特點就是近大遠小,同樣大小的物體離相機近的在畫面上顯得大,離相機遠的物體在畫面上顯得小。透視相機的視錐體如上圖左側所示,從近端面到遠端面構成的區域內的物體才能显示在圖像上。

透視相機構造器

PerspectiveCamera( fov : Number, aspect : Number, near : Number, far : Number )

  • fov — 攝像機視錐體垂直視野角度
  • aspect — 攝像機視錐體長寬比
  • near — 攝像機視錐體近端面
  • far — 攝像機視錐體遠端面

2)正交相機

使用正交相機時無論物體距離相機遠或者近,在最終渲染的圖片中物體的大小都保持不變。正交相機的視錐體如上圖右側所示,和透視相機一樣,從近端面到遠端面構成的區域內的物體才能显示在圖像上。

正交相機構造器

OrthographicCamera( left : Number, right : Number, top : Number, bottom : Number, near : Number, far : Number )

  • left — 攝像機視錐體左側面
  • right — 攝像機視錐體右側面
  • top — 攝像機視錐體上側面
  • bottom — 攝像機視錐體下側面
  • near — 攝像機視錐體近端面
  • far — 攝像機視錐體遠端面

3.2 坐標系

在場景中,可以放物品、相機、燈光,這些東西放置到什麼位置就需要使用坐標系。Three.JS使用右手坐標系,這源於OpenGL默認情況下,也是右手坐標系。從初中、高中到大學的課堂上,教材中所涉及的幾何基本都是右手坐標系。

上圖右側就是右手坐標系,五指併攏手指放平,指尖指向x軸的正方向,然後把四個手指垂直彎曲大拇指分開,併攏的四指指向y軸的正方向,大拇指指向的就是Z軸的正方向。

在Three.JS中提供了坐標軸工具(THREE.AxesHelper),在場景中添加坐標軸后,畫面會出現3條垂直相交的直線,紅色表示x軸,綠色表示y軸,藍色表示z軸(如下圖所示)。

3.3 示例代碼

/* 場景 */
var scene = new THREE.Scene();
scene.add(new THREE.AxesHelper(10)); // 添加坐標軸輔助線

/* 幾何體 */
// 這是自定義的創建幾何體方法,如果創建幾何體後續會介紹
var kleinGeom = createKleinGeom(); 
scene.add(kleinGeom); // 場景中添加幾何體

/* 相機 */
var camera = new THREE.PerspectiveCamera(45, width/height, 1, 100);
camera.position.set(5,10,25); // 設置相機的位置
camera.lookAt(new THREE.Vector3(0, 0, 0)); // 相機看向原點

/* 渲染器 */
var renderer = new THREE.WebGLRenderer({antialias:true});
renderer.setSize(width, height);
// 將canvas元素添加到body
document.body.appendChild(renderer.domElement);
// 進行渲染
renderer.render(scene, camera);

 

四、幾何體

計算機內的3D世界是由點組成,兩個點能夠組成一條直線,三個不在一條直線上的點就能夠組成一個三角形面,無數三角形面就能夠組成各種形狀的幾何體。

以創建一個簡單的立方體為例,創建簡單的立方體需要添加8個頂點和12個三角形的面,創建頂點時需要指定頂點在坐標系中的位置,添加面的時候需要指定構成面的三個頂點的序號,第一個添加的頂點序號為0,第二個添加的頂點序號為1…

創建立方體的代碼如下:

var geometry = new THREE.Geometry();

// 添加8個頂點
geometry.vertices.push(new THREE.Vector3(1, 1, 1));
geometry.vertices.push(new THREE.Vector3(1, 1, -1));
geometry.vertices.push(new THREE.Vector3(1, -1, 1));
geometry.vertices.push(new THREE.Vector3(1, -1, -1));
geometry.vertices.push(new THREE.Vector3(-1, 1, -1));
geometry.vertices.push(new THREE.Vector3(-1, 1, 1));
geometry.vertices.push(new THREE.Vector3(-1, -1, -1));
geometry.vertices.push(new THREE.Vector3(-1, -1, 1));

// 添加12個三角形的面
geometry.faces.push(new THREE.Face3(0, 2, 1));
geometry.faces.push(new THREE.Face3(2, 3, 1));
geometry.faces.push(new THREE.Face3(4, 6, 5));
geometry.faces.push(new THREE.Face3(6, 7, 5));
geometry.faces.push(new THREE.Face3(4, 5, 1));
geometry.faces.push(new THREE.Face3(5, 0, 1));
geometry.faces.push(new THREE.Face3(7, 6, 2));
geometry.faces.push(new THREE.Face3(6, 3, 2));
geometry.faces.push(new THREE.Face3(5, 7, 0));
geometry.faces.push(new THREE.Face3(7, 2, 0));
geometry.faces.push(new THREE.Face3(1, 3, 4));
geometry.faces.push(new THREE.Face3(3, 6, 4));

 

4.1 正面和反面

創建幾何體的三角形面時,指定了構成面的三個頂點,如: new THREE.Face3(0, 2, 1),如果把頂點的順序改成0,1,2會有區別嗎?

通過下圖可以看到,按照0,2,1添加頂點是順時針方向的,而按0,1,2添加頂點則是逆時針方向的,通過添加頂點的方向就可以判斷當前看到的面是正面還是反面,如果頂點是逆時針方向添加,當前看到的面是正面,如果頂點是順時針方向添加,則當前面為反面。

下圖所看到的面就是反面。如果不好記,可以使用右手沿頂點添加的方向握住,大拇指所在的面就是正面,很像我們上學時學的電磁感應定律。

五、材質

創建幾何體時通過指定幾何體的頂點和三角形的面確定了幾何體的形狀,另外還需要給幾何體添加皮膚才能實現物體的效果,材質就像物體的皮膚,決定了物體的質感。常見的材質有如下幾種:

  • 基礎材質:以簡單着色方式來繪製幾何體的材質,不受光照影響。
  • 深度材質:按深度繪製幾何體的材質。深度基於相機遠近端面,離近端面越近就越白,離遠端面越近就越黑。
  • 法向量材質:把法向量映射到RGB顏色的材質。
  • Lambert材質:是一種需要光源的材質,非光澤表面的材質,沒有鏡面高光,適用於石膏等表面粗糙的物體。
  • Phong材質:也是一種需要光源的材質,具有鏡面高光的光澤表面的材質,適用於金屬、漆面等反光的物體。
  • 材質捕獲:使用存儲了光照和反射等信息的貼圖,然後利用法線方向進行採樣。優點是可以用很低的消耗來實現很多特殊風格的效果;缺點是僅對於固定相機視角的情況較好。

下圖是使用不同貼圖實現的效果:

六、光源

前面提到的光敏材質(Lambert材質和Phong材質)需要使用光源來渲染出3D效果,在使用時需要將創建的光源添加到場景中,否則無法產生光照效果。下面介紹一下常用的光源及特點。

6.1 點光源

點光源類似蠟燭放出的光,不同的是蠟燭有底座,點光源沒有底座,可以把點光源想象成懸浮在空中的火苗,點光源放出的光線來自同一點,且方向輻射向四面八方,點光源在傳播過程中有衰弱,如下圖所示,點光源在接近地面的位置,物體底部離點光源近,物體頂部離光源遠,照到物體頂部的光就弱些,所以頂部會比底部暗些。

6.2 平行光

平行光模擬的是太陽光,光源發出的所有光線都是相互平行的,平行光沒有衰減,被平行光照亮的整個區域接受到的光強是一樣的。

6.3 聚光燈

類似舞台上的聚光燈效果,光源的光線從一個錐體中射出,在被照射的物體上產生聚光的效果。聚光燈在傳播過程也是有衰弱的。

6.4 環境光

環境光是經過多次反射而來的光,環境光源放出的光線被認為來自任何方向,物體無論法向量如何,都將表現為同樣的明暗程度。

環境光通常不會單獨使用,通過使用多種光源能夠實現更真實的光效,下圖是將環境光與點光源混合后實現的效果,物體的背光面不像點光源那樣是黑色的,而是呈現出深褐色,更自然。

七、紋理

在生活中純色的物體還是比較少的,更多的是有凹凸不平的紋路或圖案的物體,要用Three.JS實現這些物體的效果,就需要使用到紋理貼圖。3D世界的紋理是由圖片組成的,將紋理添加在材質上以一定的規則映射到幾何體上,幾何體就有了帶紋理的皮膚。

7.1 普通紋理貼圖

在這個示例中使用上圖左側的地球紋理,在球形幾何體上進行貼圖就能製作出一個地球。

代碼如下:

/* 創建地球 */
function createGeom() {
    // 球體
    var geom = new THREE.SphereGeometry(1, 64, 64);
    // 紋理
    var loader = new THREE.TextureLoader();
    var texture = loader.load('./earth.jpg');
    // 材質
    var material = new THREE.MeshLambertMaterial({
        map: texture
    });
    var earth = new THREE.Mesh(geom, material);
    return earth;
}

 

7.2 反面貼圖實現全景視圖

這個例子是通過在球形幾何體的反面進行紋理貼圖實現的全景視圖,實現原理是這樣的:創建一個球體構成一個球形的空間,把相機放在球體的中心,相機就像在一個球形的房間中,在球體的裏面(也就是反面)貼上圖片,通過改變相機拍攝的方向,就能看到全景視圖了。

材質默認是在幾何體的正面進行貼圖的,如果想要在反面貼圖,需要在創建材質的時候設置side參數的值為THREE.BackSide,代碼如下:

/* 創建反面貼圖的球形 */
// 球體
var geom = new THREE.SphereGeometry(500, 64, 64);
// 紋理
var loader = new THREE.TextureLoader();
var texture = loader.load('./panorama.jpg');
// 材質
var material = new THREE.MeshBasicMaterial({
    map: texture,
    side: THREE.BackSide
});
var panorama = new THREE.Mesh(geom, material);

 

7.3 凹凸紋理貼圖

凹凸紋理利用黑色和白色值映射到與光照相關的感知深度,不會影響對象的幾何形狀,隻影響光照,用於光敏材質(Lambert材質和Phong材質)。

如果只用上圖左上角的磚牆圖片進行貼圖的話,就像一張牆紙貼在上面,視覺效果很差,為了增強立體感,可以使用上圖左下角的凹凸紋理,給物體增加凹凸不平的效果。

凹凸紋理貼圖使用方式的代碼如下:

// 紋理加載器
var loader = new THREE.TextureLoader();
// 紋理
var texture = loader.load( './stone.jpg');
// 凹凸紋理
var bumpTexture = loader.load( './stone-bump.jpg');
// 材質
var material =  new THREE.MeshPhongMaterial( {
    map: texture,
    bumpMap: bumpTexture
} );

 

7.4 法線紋理貼圖

法線紋理也是通過影響光照實現凹凸不平視覺效果的,並不會影響物體的幾何形狀,用於光敏材質(Lambert材質和Phong材質)。上圖左下角的法線紋理圖片的RGB值會影響每個像素片段的曲面法線,從而改變物體的光照效果。

使用方式的代碼如下:

// 紋理
var texture = loader.load( './metal.jpg');
// 法線紋理
var normalTexture = loader.load( './metal-normal.jpg');
var material =  new THREE.MeshPhongMaterial( {
    map: texture,
    normalMap: normalTexture
} );

 

7.5 環境貼圖

環境貼圖是將當前環境作為紋理進行貼圖,能夠模擬鏡面的反光效果。在進行環境貼圖時需要使用立方相機在當前場景中進行拍攝,從而獲得當前環境的紋理。立方相機在拍攝環境紋理時,為避免反光效果的小球出現在環境紋理的畫面上,需要將小球設為不可見。

環境貼圖的主要代碼如下:

/* 立方相機 */
var cubeCamera = new THREE.CubeCamera( 1, 10000, 128 );
/* 材質 */
var material = new THREE.MeshBasicMaterial( {
    envMap: cubeCamera.renderTarget.texture
});
/* 鏡面反光的球體 */
var geom = new THREE.SphereBufferGeometry( 10, 32, 16 );
var ball = new THREE.Mesh( geom, material );
// 將立方相機添加到球體
ball.add( cubeCamera );
scene.add( ball );

// 立方相機生成環境紋理前將反光小球隱藏
ball.visible = false;
// 更新立方相機,生成環境紋理
cubeCamera.update( renderer, scene );
balls.visible = true;

// 渲染
renderer.render(scene, camera);

 

八、加載外部3D模型

Three.JS已經內置了很多常用的幾何體,如:球體、立方體、圓柱體等等,但是在實際使用中往往需要用到一些特殊形狀的幾何體,這時可以使用3D建模軟件製作出3D模型,導出obj、json、gltf等格式的文件,然後再加載到Three.JS渲染出效果。

上圖的椅子是在3D製圖軟件繪製出來的,chair.mtl是導出的材質文件,chair.obj是導出的幾何體文件,使用材質加載器加載材質文件,加載完成后得到材質對象,給幾何體加載器設置材質,加載后得到幾何體對象,然後再創建場景、光源、攝像機、渲染器等進行渲染,這樣就等得到如圖的效果。主要的代碼如下:

// .mtl材質文件加載器
var mtlLoader = new THREE.MTLLoader();
// .obj幾何體文件加載器
var objLoader = new THREE.OBJLoader();

mtlLoader.load('./chair.mtl', function (materials) {
    objLoader.setMaterials(materials)
        .load('./chair.obj', function (obj) {
            scene.add(obj);
            …
        });
});

 

九、說明

以上內容對Three.JS的基本使用進行了介紹,文中涉及到的示例源碼已上傳到github,感興趣的同學可以下載查看,下載地址:https://github.com/liulinsp/three-demo。使用時如果有不清楚的地方可以查看Three.JS的官方文檔:https://threejs.org/docs/index.html。

作者:劉琳

本站聲明:網站內容來源於博客園,如有侵權,請聯繫我們,我們將及時處理

【精選推薦文章】

自行創業 缺乏曝光? 下一步"網站設計"幫您第一時間規劃公司的門面形象

網頁設計一頭霧水??該從何著手呢? 找到專業技術的網頁設計公司,幫您輕鬆架站!

評比前十大台北網頁設計台北網站設計公司知名案例作品心得分享

台北網頁設計公司這麼多,該如何挑選?? 網頁設計報價省錢懶人包"嚨底家"

《面試官之你說我聽》:簡明的圖解Redis RDB持久化、AOF持久化

歡迎關注文章這一系列,一起學習

《提升能力,漲薪可待篇》

《面試知識,工作可待篇》

《實戰演練,拒絕996篇》

如果此文對你有幫助、喜歡的話,那就點個讚唄,點個關注唄!

1.持久化

1.1 持久化簡介

持久化(Persistence),持久化是將程序數據在持久狀態和瞬時狀態間轉換的機制,即把數據(如內存中的對象)保存到可永久保存的存儲設備中(如磁盤)。

1.2 redis持久化

redis為內存數據庫,為了防止服務器宕機以及服務器進程退出后,服務器數據丟失,Redis提供了持久化功能,即將Redis中內存數據持久化到磁盤中。Redis 提供了不同級別的持久化方式:

  • RDB持久化方式:可以在指定的時間間隔能對數據進行快照存儲.

  • AOF持久化方式:記錄每次對服務器寫的操作,當服務器重啟的時候會重新執行這些命令來恢復原始的數據,AOF命令以redis協議追加保存每次寫的操作到文件末尾.Redis還能對AOF文件進行後台重寫,使得AOF文件的體積不至於過大.

如果服務器開啟了AOF持久化功能。服務器會優先使用AOF文件還原數據。只有關閉了AOF持久化功能,服務器才會使用RDB文件還原數據

2. RDB持久化

2.1 RDB文件格式

RDB文件是一個經過壓縮的二進制文件(默認的文件名:dump.rdb),由多個部分組成,RDB格式:

2.2 RDB文件持久化創建與載入

在 Redis持久化時, RDB 程序將當前內存中的數據庫狀態保存到磁盤文件中, 在 Redis 重啟動時, RDB 程序可以通過載入 RDB 文件來還原數據庫的狀態。

2.3 工作方式

當 Redis 需要保存 dump.rdb 文件時, 服務器執行以下操作:

  • Redis 調用forks。同時擁有父進程和子進程。

  • 子進程將數據集寫入到一個臨時 RDB 文件中。

  • 當子進程完成對新 RDB 文件的寫入時,Redis 用新 RDB 文件替換原來的 RDB 文件,並刪除舊的 RDB 文件。

這種工作方式使得 Redis 可以從寫時複製(copy-on-write)機制中獲益。

2.4 創建方式

SAVE

同步操作,在執行該命令時,服務器會被阻塞,拒絕客戶端發送的命令請求

    redis> save

BGSAVE

異步操作,在執行該命令時,子進程執行保存工作,服務器還可以繼續讓主線程處理客戶端發送的命令請求

 redis>bgsave

自動創建

由於BGSAVE命令可不阻塞服務器進程下執行,可以讓用戶自定義save屬性,讓服務器每個一段時間自動執行一次BGSAVE命令(即通過配置文件對 Redis 進行設置, 讓它在“ N 秒內數據集至少有 M 個改動”這一條件被滿足時, 自動進行數據集保存操作)。

比如:
/*服務器在900秒之內,對數據庫進行了至少1次修改*/
Save 900   1
/*服務器在300秒之內,對數據庫進行了至少10次修改*/
Save 300   10
/*服務器在60秒之內,對數據庫進行了至少10000次修改*/
Save 60     10000

只要滿足其中一個條件就會執行BGSAVE命令

 

 

2.5 RDB 默認配置

################################ SNAPSHOTTING  ################################
#
# Save the DB on disk:
#在給定的秒數和給定的對數據庫的寫操作數下,自動持久化操作。
#   save <seconds> <changes>
#
save 900 1
save 300 10
save 60 10000

#bgsave發生錯誤時是否停止寫入,一般為yes
stop-writes-on-bgsave-error yes

#持久化時是否使用LZF壓縮字符串對象?
rdbcompression yes

#是否對rdb文件進行校驗和檢驗,通常為yes
rdbchecksum yes

# RDB持久化文件名
dbfilename dump.rdb

#持久化文件存儲目錄
dir ./

 

3. AOF持久化

3.1 AOF持久化簡介

AOF持久化是通過保存Redis服務器所執行的寫命令來記錄數據庫狀態

 

 

AOF持久化功能實現:

  1. append命令追加:當AOF持久化功能處於打開狀態時,服務器執行完一個寫命令會協議格式被執行的命令追加服務器狀態的aof_buf緩衝區的末尾。

    reids>SET KET VAULE //協議格式 \r\n$3\r\nSET\r\n$3\r\nKEY\r\n$5\r\nVAULE\r\n

  2. 文件寫入和同步sync:Redis的服務器進程是一個事件循環,這個文件事件負責接收客戶端的命令請求以及向客戶端發送命令回復。當執行了append命令追加后,服務器會調用flushAppendOnlyFile函數是否需要將AOF緩衝區的內容寫入和保存到AOF文件

    redis> SET msg "Ccww"
  redis> SADD persistence "rdb" "aof"
  redis> RPUSH size 128 256 512

 

3.2 AOF持久化策略

AOF持久化策略(即緩衝區內容寫入和同步sync到AOF中),可以通過配置appendfsync屬性來選擇AOF持久化策略:

  • always:將aof_buf緩衝區中的所有內容寫入並同步到AOF文件,每次有新命令追加到 AOF 文件時就執行一次 fsync。

  • everysec(默認):如果上次同步AOF的時間距離現在超過一秒,先將aof_buf緩衝區中的所有內容寫入到AOF文件,再次對AOF文件進行同步,且同步操作由一個專門線程負責執行。

  • no:將aof_buf緩衝區中的所有內容寫入到AOF文件,但並不對AOF文件進行同步,何時同步由操作系統(OS)決定。

AOF持久化策略的效率與安全性:

  • Always:效率最慢的,但安全性是最安全的,即使出現故障宕機,持久化也只會丟失一個事件 循環的命令數據

  • everysec:兼顧速度和安全性,出現宕機也只是丟失一秒鐘的命令數據

  • No:寫入最快,但綜合起來單次同步是時間是最長的,且出現宕機時會丟失上傳同步AOF文件之後的所有命令數據。

 

3.3 AOF重寫

由於AOF持久化會把執行的寫命令追加到AOF文件中,所以隨着時間寫入命令會不斷增加, AOF文件的體積也會變得越來越大。AOF文件體積大對Reids服務器,甚至宿主服務器造成影響。

為了解決AOF文件體積膨脹的問題,Redis提供了AOF文件重寫(rewrite)功能:

  • 生成一個不保存任何浪費空間的冗餘命令新的AOF文件,且新舊AOF文件保存數據庫狀態一樣的

  • 新的AOF文件是通過讀取數據庫中的鍵值對來實現的,程序無須對現有的AOF文件進行讀入,分析,或者寫入操作。

  • 為防止緩衝區溢出,重寫處理list,hash,set以及Zset時,超過設置常量數量時會多條相同命令記錄一個集合。

  • Redis 2.4 可以通過配置自動觸發 AOF 重寫,觸發參數 auto-aof-rewrite-percentage(觸發AOF文件執行重寫的增長率) 以及 auto-aof-rewrite-min-size(觸發AOF文件執行重寫的最小尺寸)

AOF重寫的作用:

  • 減少磁盤佔用量

  • 加速數據恢復

 

Redis服務器使用單個線程來處理命令請求,服務器大量調用aof_rewrite函數,在AOF重寫期間,則無法處理client發來的命令請求,所以AOF重寫程序放在子進程執行,好處:

  1. 子進程進行AOF重寫期間,服務器進程可以繼續處理命令請求

  2. 子進程帶有服務器進程的數據副本,保證了數據的安全性。

AOF重寫使用子進程會造成數據庫與重寫后的AOF保存的數據不一致,為了解決這種數據不一致,redis使用了AOF重寫緩衝區 實現:

BGREWRITEAOF命令實現原理(只有信號處理函數執行時才對服務器進程造成阻塞):

  • 執行命令,同時將命令追加到AOF緩衝區和AOF重寫緩衝區

  • 當AOF子進程重寫完成后,發送一個信號給父進程,父進程將執行AOF重寫緩衝區中的所有內容寫入到新AOF文件中,新AOF文件保存的數據庫狀態將和服務器當前的數據庫狀態一致。

  • 對新的AOF文件進行改名,原子性地覆蓋現有AOF文件,完成新舊兩個AOF文件替換處理完成。

 

 

3.4 AOF持久化默認參數

############################## APPEND ONLY MODE ###############################

#開啟AOF持久化方式
appendonly no

#AOF持久化文件名
appendfilename "appendonly.aof"
#每秒把緩衝區的數據fsync到磁盤
appendfsync everysec
# appendfsync no
#是否在執行重寫時不同步數據到AOF文件
no-appendfsync-on-rewrite no

# 觸發AOF文件執行重寫的增長率
auto-aof-rewrite-percentage 100
#觸發AOF文件執行重寫的最小size
auto-aof-rewrite-min-size 64mb

#redis在恢復時,會忽略最後一條可能存在問題的指令
aof-load-truncated yes

#是否打開混合開關
aof-use-rdb-preamble yes

4 持久化方式總結與抉擇

4.1 RDB優缺點

RDB的優點

  • RDB是一個非常緊湊的文件,它保存了某個時間點得數據集,非常適用於數據集的備份,比如你可以在每個小時報保存一下過去24小時內的數據,同時每天保存過去30天的數據,這樣即使出了問題你也可以根據需求恢復到不同版本的數據集.

  • 基於RDB文件緊湊性,便於複製數據到一個遠端數據中心,非常適用於災難恢復.

  • RDB在保存RDB文件時父進程唯一需要做的就是fork出一個子進程,接下來的工作全部由子進程來做,父進程不需要再做其他IO操作,所以RDB持久化方式可以最大化redis的性能.

  • 與AOF相比,在恢復大的數據集的時候,RDB方式會更快一些.

RDB的缺點

  • 如果你希望在redis意外停止工作(例如電源中斷)的情況下丟失的數據最少的話,那麼RDB不適合你.雖然你可以配置不同的save時間點(例如每隔5分鐘並且對數據集有100個寫的操作),是Redis要完整的保存整個數據集是一個比較繁重的工作,你通常會每隔5分鐘或者更久做一次完整的保存,萬一在Redis意外宕機,你可能會丟失幾分鐘的數據.

  • RDB 需要經常fork子進程來保存數據集到硬盤上,當數據集比較大的時候,fork的過程是非常耗時的,可能會導致Redis在一些毫秒級內不能響應客戶端的請求.如果數據集巨大並且CPU性能不是很好的情況下,這種情況會持續1秒,AOF也需要fork,但是你可以調節重寫日誌文件的頻率來提高數據集的耐久度.

4.2 AOF的優缺點

AOF的優點:

  • 使用AOF 會讓你的Redis更加耐久:使用不同的fsync策略:無fsync,每秒fsync,每次寫的時候fsync.使用默認的每秒fsync策略,Redis的性能依然很好(fsync是由後台線程進行處理的,主線程會儘力處理客戶端請求),一旦出現故障,你最多丟失1秒的數據.

  • AOF文件是一個只進行追加的日誌文件,所以不需要寫入seek,即使由於某些原因(磁盤空間已滿,寫的過程中宕機等等)未執行完整的寫入命令,你也可使用redis-check-aof工具修復問題.

  • Redis可以在AOF文件體積變得過大時,自動對 AOF 進行重寫: 重寫后的新 AOF 文件包含了恢復當前數據集所需的最小命令集合。 整個重寫操作是絕對安全的,因為 Redis 在創建新 AOF 文件的過程中,會繼續將命令追加到現有的 AOF 文件裏面,即使重寫過程中發生停機,現有的 AOF 文件也不會丟失。 而一旦新 AOF 文件創建完畢,Redis 就會從舊 AOF 文件切換到新 AOF 文件,並開始對新 AOF 文件進行追加操作。

  • AOF 文件有序地保存了對數據庫執行的所有寫入操作, 這些寫入操作以 Redis 協議的格式保存, 因此 AOF 文件的內容非常容易被人讀懂, 對文件進行分析(parse)也很輕鬆。 導出(export) AOF 文件也非常簡單(例如, 如果你不小心執行了 FLUSHALL 命令, 但只要 AOF 文件未被重寫, 那麼只要停止服務器, 移除 AOF 文件末尾的 FLUSHALL 命令, 並重啟 Redis , 就可以將數據集恢復到 FLUSHALL 執行之前的狀態)。

AOF 缺點:

  • 對於相同的數據集來說,AOF 文件的體積通常要大於 RDB 文件的體積。

  • 根據所使用的 fsync 策略,AOF 的速度可能會慢於 RDB 。 在一般情況下, 每秒 fsync 的性能依然非常高, 而關閉 fsync 可以讓 AOF 的速度和 RDB 一樣快, 即使在高負荷之下也是如此。 不過在處理巨大的寫入載入時,RDB 可以提供更有保證的最大延遲時間(latency)。

4.3 如何選擇使用哪種持久化方式?

一般來說, 如果想達到足以媲美 PostgreSQL 的數據安全性, 你應該同時使用兩種持久化功能。

如果你非常關心你的數據, 但仍然可以承受數分鐘以內的數據丟失, 那麼你可以只使用 RDB 持久化。

有很多用戶都只使用 AOF 持久化, 但我們並不推薦這種方式: 因為定時生成 RDB 快照(snapshot)非常便於進行數據庫備份, 並且 RDB 恢複數據集的速度也要比 AOF 恢復的速度要快, 除此之外, 使用 RDB 還可以避免之前提到的 AOF 程序的 bug 。

也歡迎關注公眾號【Ccww筆記】,原創技術文章第一時間推出

如果此文對你有幫助、喜歡的話,那就點個讚唄,點個關注唄!

 

 

 

本站聲明:網站內容來源於博客園,如有侵權,請聯繫我們,我們將及時處理

【精選推薦文章】

智慧手機時代的來臨,RWD網頁設計已成為網頁設計推薦首選

想知道網站建置、網站改版該如何進行嗎?將由專業工程師為您規劃客製化網頁設計及後台網頁設計

帶您來看台北網站建置台北網頁設計,各種案例分享

廣告預算用在刀口上,網站設計公司幫您達到更多曝光效益

堆 堆排序 優先隊列 圖文詳解(Golang實現)

引入

在實際應用中,我們經常需要從一組對象中查找最大值最小值。當然我們可以每次都先排序,然後再進行查找,但是這種做法效率很低。哪么有沒有一種特殊的數據結構,可以高效率的實現我們的需求呢,答案就是堆(heap)

堆分為最小堆和最大堆,它們的性質相似,我們以最小堆為例子。

最小堆

舉例

如上圖所示,就為一個最小堆。

特性

  • 是一棵完全二叉樹

如果一顆二叉樹的任何結點,或者是樹恭弘=叶 恭弘,或者左右子樹均非空,則這棵二叉樹稱做滿二叉樹(full binary tree)

如果一顆二叉樹最多只有最下面的兩層結點度數可以小於2,並且最下面一層的結點都集中在該層最左邊的連續位置上,則此二叉樹稱做完全二叉樹(complete binary tree)

  • 局部有序

最小堆對應的完全二叉樹中所有結點的值均不大於其左右子結點的值,且一個結點與其兄弟之間沒有必然的聯繫

二叉搜索樹中,左子 < 父 < 右子

存儲結構

由於堆是一棵完全二叉樹,所以我們可以用順序結構來存儲它,只需要計算簡單的代數表達式,就能夠非常方便的查找某個結點的父結點和子節點,既避免了使用指針來保持結構,又能高效的執行相應操作。

結點i的左子結點為2xi+1,右子結點為2xi+2
結點i的父節點為(i-1)/2

數據結構

// 本例為最小堆
// 最大堆只需要修改less函數即可
type Heap []int

func (h Heap) swap(i, j int) {
    h[i], h[j] = h[j], h[i]
}

func (h Heap) less(i, j int) bool {
    return h[i] < h[j]
}

如上所示,我們使用slice來存儲我們的數據,為了後續方便我們在此定義了 swapless 函數,分別用來交換兩個結點和比較大小。

插入-Push

如上圖所示,首先,新添加的元素加入末尾。為了保持最小堆的性質,需要沿着其祖先的路徑,自下而上依次比較和交換該結點與父結點的位置,直到重新滿足堆的性質為止。

這樣會出現兩種情況,要麼新結點升到最小堆的頂端,要麼到某一位置時發現父結點比新插入的結點關鍵值小。

上面的流程代碼如下:

func (h Heap) up(i int) {
    for {
        f := (i - 1) / 2 // 父親結點
        if i == f || h.less(f, i) {
            break
        }
        h.swap(f, i)
        i = f
    }
}

實現了最核心的 up 操作后,我們的插入操作 push 便很簡單,代碼如下:

// 注意go中所有參數轉遞都是值傳遞
// 所以要讓h的變化在函數外也起作用,此處得傳指針
func (h *Heap) Push(x int) {
    *h = append(*h, x)
    h.up(len(*h) - 1)
}

刪除-Remove

如上圖所示,首先把最末端的結點填入要刪除節點的位置,然後刪除末端元素,同理,這樣做也可能導致破壞最小堆的堆序特性。

為了保持堆的特性,末端元素需要與被刪除位置的父結點做比較,如果小於父結點,就要up(詳細代碼看插入)如果大於父結點,就要再和被刪除位置的子結點做比較,即down,直到該結點下降到小於最小子結點為止。

上面down的流程代碼如下:

func (h Heap) down(i int) {
    for {
        l := 2*i + 1 // 左孩子
        if l >= len(h) {
            break // i已經是恭弘=叶 恭弘子結點了
        }
        j := l
        if r := l + 1; r < len(h) && h.less(r, l) {
            j = r // 右孩子
        }
        if h.less(i, j) {
            break // 如果父結點比孩子結點小,則不交換
        }
        h.swap(i, j) // 交換父結點和子結點
        i = j        //繼續向下比較
    }
}

實現了核心的 down 操作后,我們的 Remove 便很簡單,代碼如下:

// 刪除堆中位置為i的元素
// 返回被刪元素的值
func (h *Heap) Remove(i int) (int, bool) {
    if i < 0 || i > len(*h)-1 {
        return 0, false
    }
    n := len(*h) - 1
    h.swap(i, n) // 用最後的元素值替換被刪除元素
    // 刪除最後的元素
    x := (*h)[n]
    *h = (*h)[0:n]
    // 如果當前元素大於父結點,向下篩選
    if (*h)[i] > (*h)[(i-1)/2] {
        h.down(i)
    } else { // 當前元素小於父結點,向上篩選
        h.up(i)
    }
    return x, true
}

彈出-Pop

當i=0時,Remove 就是 Pop

// 彈出堆頂的元素,並返回其值
func (h *Heap) Pop() int {
    n := len(*h) - 1
    h.swap(0, n)
    x := (*h)[n]
    *h = (*h)[0:n]
    h.down(0)
    return x
}

初始化-Init

在我們講完了堆的核心操作 updown 后,我們來講如何根據一個數組構造一個最小堆。

其實我們可以寫個循環,然後將各個元素依次 push 進去,但是這次我們利用數學規律,直接由一個數組構造最小堆。

首先,將所有關鍵碼放到一維數組中,此時形成的完全二叉樹並不具備最小堆的特徵,但是僅包含恭弘=叶 恭弘子結點的子樹已經是堆。

即在有n個結點的完全二叉樹中,當 i>n/2-1 時,以i結點為根的子樹已經是堆。

func (h Heap) Init() {
    n := len(h)
    // i > n/2-1 的結點為恭弘=叶 恭弘子結點本身已經是堆了
    for i := n/2 - 1; i >= 0; i-- {
        h.down(i)
    }
}

測試

func main() {
    var h = heap.Heap{20, 7, 3, 10, 15, 25, 30, 17, 19}
    h.Init()
    fmt.Println(h) // [3 7 20 10 15 25 30 17 19]

    h.Push(6)
    fmt.Println(h) // [3 6 20 10 7 25 30 17 19 15]

    x, ok := h.Remove(5)
    fmt.Println(x, ok, h) // 25 true [3 6 15 10 7 20 30 17 19]

    y, ok := h.Remove(1)
    fmt.Println(y, ok, h) // 6 true [3 7 15 10 19 20 30 17]

    z := h.Pop()
    fmt.Println(z, h) // 3 [7 10 15 17 19 20 30]
}

完整代碼

堆排序

在講完堆的基礎知識后,我們再來看堆排序就變得非常簡單。利用最小堆的特性,我們每次都從堆頂彈出一個元素(這個元素就是當前堆中的最小值),即可實現升序排序。代碼如下:

// 堆排序
var res []int
for len(h) != 0 { 
    res = append(res, h.Pop())
}
fmt.Println(res)

優先隊列

優先隊列是0個或者多個元素的集合,每個元素都有一個關鍵碼,執行的操作有查找,插入和刪除等。

優先隊列的主要特點是支持從一個集合中快速地查找並移出具有最大值或最小值的元素。

堆是一種很好的優先隊列的實現方法。

參考資料

  • 《數據結構與算法》張銘 王騰蛟 趙海燕 編著
  • GO SDK 1.13.1 /src/container/heap

最後

本文是自己的學習筆記,在刷了幾道LeetCode中關於堆的題目后,感覺應該系統的學習和總結一下這一重要的數據結構了。

強烈建議看Go的源碼中關於heap的實現,仔細感受面向接口編程的思想,和他們的代碼風格以及質量。

本站聲明:網站內容來源於博客園,如有侵權,請聯繫我們,我們將及時處理

【精選推薦文章】

自行創業 缺乏曝光? 下一步"網站設計"幫您第一時間規劃公司的門面形象

網頁設計一頭霧水??該從何著手呢? 找到專業技術的網頁設計公司,幫您輕鬆架站!

評比前十大台北網頁設計台北網站設計公司知名案例作品心得分享

台北網頁設計公司這麼多,該如何挑選?? 網頁設計報價省錢懶人包"嚨底家"

【集合系列】- 深入淺出的分析 Hashtable

一、摘要

在集合系列的第一章,咱們了解到,Map 的實現類有 HashMap、LinkedHashMap、TreeMap、IdentityHashMap、WeakHashMap、Hashtable、Properties 等等。

本文主要從數據結構和算法層面,探討 Hashtable 的實現,如果有理解不當之處,歡迎指正。

二、簡介

Hashtable 一個元老級的集合類,早在 JDK 1.0 就誕生了,而 HashMap 誕生於 JDK 1.2,在實現上,HashMap 吸收了很多 Hashtable 的思想,雖然二者的底層數據結構都是 數組 + 鏈表 結構,具有查詢、插入、刪除快的特點,但是二者又有很多的不同。

打開 Hashtable 的源碼可以看到,Hashtable 繼承自 Dictionary,而 HashMap 繼承自 AbstractMap。

public class Hashtable<K,V>
    extends Dictionary<K,V>
    implements Map<K,V>, Cloneable, java.io.Serializable {
    .....
}

HashMap 繼承自 AbstractMap,HashMap 類的定義如下:

public class HashMap<K,V> extends AbstractMap<K,V>
    implements Map<K,V>, Cloneable, Serializable {
    .....
}

其中 Dictionary 類是一個已經被廢棄的類,翻譯過來的意思是這個類已經過時,新的實現應該實現 Map 接口而不是擴展此類,這一點我們可以從它代碼的註釋中可以看到:

/**
 * <strong>NOTE: This class is obsolete.  New implementations should
 * implement the Map interface, rather than extending this class.</strong>
 */
public abstract
class Dictionary<K,V> {
    ......
}

Hashtable 和 HashMap 的底層是以數組來存儲,同時,在存儲數據通過key計算數組下標的時候,是以哈希算法為主,因此可能會產生哈希衝突的可能性。

通俗的說呢,就是不同的key,在計算的時候,可能會產生相同的數組下標,這個時候,如何將兩個對象放入一個數組中呢?

而解決哈希衝突的辦法,有兩種,一種開放地址方式(當發生 hash 衝突時,就繼續以此繼續尋找,直到找到沒有衝突的hash值),另一種是拉鏈方式(將衝突的元素放入鏈表)。

Java Hashtable 採用的就是第二種方式,拉鏈法!

於是,當發生不同的key通過一系列的哈希算法計算獲取到相同的數組下標的時候,會將對象放入一個數組容器中,然後將對象以單向鏈表的形式存儲在同一個數組下標容器中,就像鏈子一樣,掛在某個節點上,如下圖:

與 HashMap 類似,Hashtable 也包括五個成員變量:

/**由Entry對象組成的數組*/
private transient Entry[] table;

/**Hashtable中Entry對象的個數*/
private transient int count;

/**Hashtable進行擴容的閾值*/
private int threshold;

/**負載因子,默認0.75*/
private float loadFactor;

/**記錄修改的次數*/
private transient int modCount = 0;

具體各個變量含義如下:

  • table:表示一個由 Entry 對象組成的鏈表數組,Entry 是一個單向鏈表,哈希表的key-value鍵值對都是存儲在 Entry 數組中的;
  • count:表示 Hashtable 的大小,用於記錄保存的鍵值對的數量;
  • threshold:表示 Hashtable 的閾值,用於判斷是否需要調整 Hashtable 的容量,threshold 等於容量 * 加載因子;
  • loadFactor:表示負載因子,默認為 0.75;
  • modCount:表示記錄 Hashtable 修改的次數,用來實現快速失敗拋異常處理;

接着來看看Entry這個內部類,Entry用於存儲鏈表數據,實現了Map.Entry接口,本質是就是一個映射(鍵值對),源碼如下:

 private static class Entry<K,V> implements Map.Entry<K,V> {
         /**hash值*/
        final int hash;
        /**key表示鍵*/
        final K key;
        /**value表示值*/
        V value;
        /**節點下一個元素*/
        Entry<K,V> next;
        ......
}

我們再接着來看看 Hashtable 初始化過程,核心源碼如下:

public Hashtable() {
    this(11, 0.75f);
}

this 調用了自己的構造方法,核心源碼如下:

public Hashtable(int initialCapacity, float loadFactor) {
        .....
        //默認的初始大小為 11
        //並且計算擴容的閾值
        this.loadFactor = loadFactor;
        table = new Entry<?,?>[initialCapacity];
        threshold = (int)Math.min(initialCapacity * loadFactor, MAX_ARRAY_SIZE + 1);
}

可以看到 HashTable 默認的初始大小為 11,如果在初始化給定容量大小,那麼 HashTable 會直接使用你給定的大小

擴容的閾值threshold等於initialCapacity * loadFactor,我們在來看看 HashTable 擴容,方法如下:

protected void rehash() {
        int oldCapacity = table.length;
        //將舊數組長度進行位運算,然後 +1
        //等同於每次擴容為原來的 2n+1
        int newCapacity = (oldCapacity << 1) + 1;
        
        //省略部分代碼......
        Entry<?,?>[] newMap = new Entry<?,?>[newCapacity];
}

可以看到,HashTable 每次擴充為原來的 2n+1

我們再來看看 HashMap,如果是執行默認構造方法,會在擴容那一步,進行初始化大小,核心源碼如下:

final Node<K,V>[] resize() {
    int newCap = 0;

    //部分代碼省略......
    newCap = DEFAULT_INITIAL_CAPACITY;//默認容量為 16
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
}

可以看出 HashMap 的默認初始化大小為 16,我們再來看看,HashMap 擴容方法,核心源碼如下:

final Node<K,V>[] resize() {
    //獲取舊數組的長度
    Node<K,V>[] oldTab = table;
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    int newCap = 0;

    //部分代碼省略......
    //當進行擴容的時候,容量為 2 的倍數
    newCap = oldCap << 1;
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
}

可以看出 HashMap 的擴容后的數組數量為原來的 2 倍

也就是說 HashTable 會盡量使用素數、奇數來做數組的容量,而 HashMap 則總是使用 2 的冪作為數組的容量。

我們知道當哈希表的大小為素數時,簡單的取模哈希的結果會更加均勻,所以單從這一點上看,HashTable 的哈希表大小選擇,似乎更高明些。

Hashtable 的 hash 算法,核心代碼如下:

//直接計算key.hashCode()
int hash = key.hashCode();

//通過除法取余計算數組存放下標
// 0x7FFFFFFF 是最大的 int 型數的二進製表示
int index = (hash & 0x7FFFFFFF) % tab.length;

從源碼部分可以看出,HashTable 的 key 不能為空,否則報空指針錯誤!

但另一方面我們又知道,在取模計算時,如果模數是 2 的冪,那麼我們可以直接使用位運算來得到結果,效率要大大高於做除法。所以在 hash 計算數組下標的效率上,HashMap 卻更勝一籌,但是這也會引入了哈希分佈不均勻的問題, HashMap 為解決這問題,又對 hash 算法做了一些改動,具體我們來看看。

HashMap 的 hash 算法,核心代碼如下:

/**獲取hash值方法*/
static final int hash(Object key) {
     int h;
     // h = key.hashCode() 為第一步 取hashCode值(jdk1.7)
     // h ^ (h >>> 16)  為第二步 高位參与運算(jdk1.7)
     return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);//jdk1.8
}

/**獲取數組下標方法*/
static int indexFor(int h, int length) {
    //jdk1.7的源碼,jdk1.8沒有這個方法,但是實現原理一樣的
     return h & (length-1);  //第三步 取模運算
}

HashMap 由於使用了2的冪次方,所以在取模運算時不需要做除法,只需要位的與運算就可以了。但是由於引入的 hash 衝突加劇問題,HashMap 在調用了對象的 hashCode 方法之後,又做了一些高位運算,也就是第二步方法,來打散數據,讓哈希的結果更加均勻。

與此同時,在 jdk1.8 中 HashMap 還引進來紅黑樹實現,當衝突鏈表長度大於 8 的時候,會將鏈表結構改變成紅黑樹結構,讓查詢變得更快,具體實現可以參見《集合系列》中的 HashMap 分析

三、常用方法介紹

3.1、put方法

put 方法是將指定的 key, value 對添加到 map 里。

put 流程圖如下:

打開 HashTable 的 put 方法,源碼如下:

public synchronized V put(K key, V value) {
        //當 value 值為空的時候,拋異常!
        if (value == null) {
            throw new NullPointerException();
        }

        Entry<?,?> tab[] = table;

        //通過key 計算存儲下標
        int hash = key.hashCode();
        int index = (hash & 0x7FFFFFFF) % tab.length;
        
        //循環遍曆數組鏈表
        //如果有相同的key並且hash相同,進行覆蓋處理
        Entry<K,V> entry = (Entry<K,V>)tab[index];
        for(; entry != null ; entry = entry.next) {
            if ((entry.hash == hash) && entry.key.equals(key)) {
                V old = entry.value;
                entry.value = value;
                return old;
            }
        }
        //加入數組鏈表中
        addEntry(hash, key, value, index);
        return null;
}

put 方法中的 addEntry 方法,源碼如下:

private void addEntry(int hash, K key, V value, int index) {
        //新增修改次數
        modCount++;

        Entry<?,?> tab[] = table;
        if (count >= threshold) {
           //數組容量大於擴容閥值,進行擴容
            rehash();
            
            tab = table;
            //重新計算對象存儲下標
            hash = key.hashCode();
            index = (hash & 0x7FFFFFFF) % tab.length;
        }

        //將對象存儲在數組中
        Entry<K,V> e = (Entry<K,V>) tab[index];
        tab[index] = new Entry<>(hash, key, value, e);
        count++;
}

addEntry 方法中的 rehash 方法,源碼如下:

protected void rehash() {
        int oldCapacity = table.length;
        Entry<?,?>[] oldMap = table;

        //每次擴容為原來的 2n+1
        int newCapacity = (oldCapacity << 1) + 1;
        if (newCapacity - MAX_ARRAY_SIZE > 0) {
            if (oldCapacity == MAX_ARRAY_SIZE)
                //大於最大閥值,不再擴容
                return;
            newCapacity = MAX_ARRAY_SIZE;
        }
        Entry<?,?>[] newMap = new Entry<?,?>[newCapacity];

        modCount++;
        //重新計算擴容閥值
        threshold = (int)Math.min(newCapacity * loadFactor, MAX_ARRAY_SIZE + 1);
        table = newMap;
        //將舊數組中的數據複製到新數組中
        for (int i = oldCapacity ; i-- > 0 ;) {
            for (Entry<K,V> old = (Entry<K,V>)oldMap[i] ; old != null ; ) {
                Entry<K,V> e = old;
                old = old.next;

                int index = (e.hash & 0x7FFFFFFF) % newCapacity;
                e.next = (Entry<K,V>)newMap[index];
                newMap[index] = e;
            }
        }
}

總結流程如下:

  • 1、通過 key 計算對象存儲在數組中的下標;
  • 2、如果鏈表中有 key,直接進行新舊值覆蓋處理;
  • 3、如果鏈表中沒有 key,判斷是否需要擴容,如果需要擴容,先擴容,再插入數據;

有一個值得注意的地方是 put 方法加了synchronized關鍵字,所以,在同步操作的時候,是線程安全的。

3.2、get方法

get 方法根據指定的 key 值返回對應的 value。

get 流程圖如下:

打開 HashTable 的 get 方法,源碼如下:

public synchronized V get(Object key) {
        Entry<?,?> tab[] = table;
        //通過key計算節點存儲下標
        int hash = key.hashCode();
        int index = (hash & 0x7FFFFFFF) % tab.length;
        for (Entry<?,?> e = tab[index] ; e != null ; e = e.next) {
            if ((e.hash == hash) && e.key.equals(key)) {
                return (V)e.value;
            }
        }
        return null;
}

同樣,有一個值得注意的地方是 get 方法加了synchronized關鍵字,所以,在同步操作的時候,是線程安全的。

3.3、remove方法

remove 的作用是通過 key 刪除對應的元素。

remove 流程圖如下:

打開 HashTable 的 remove 方法,源碼如下:

public synchronized V remove(Object key) {
        Entry<?,?> tab[] = table;
        //通過key計算節點存儲下標
        int hash = key.hashCode();
        int index = (hash & 0x7FFFFFFF) % tab.length;
        Entry<K,V> e = (Entry<K,V>)tab[index];
        //循環遍歷鏈表,通過hash和key判斷鍵是否存在
        //如果存在,直接將改節點設置為空,並從鏈表上移除
        for(Entry<K,V> prev = null ; e != null ; prev = e, e = e.next) {
            if ((e.hash == hash) && e.key.equals(key)) {
                modCount++;
                if (prev != null) {
                    prev.next = e.next;
                } else {
                    tab[index] = e.next;
                }
                count--;
                V oldValue = e.value;
                e.value = null;
                return oldValue;
            }
        }
        return null;
}

同樣,有一個值得注意的地方是 remove 方法加了synchronized關鍵字,所以,在同步操作的時候,是線程安全的。

四、總結

總結一下 Hashtable 與 HashMap 的聯繫與區別,內容如下:

  • 1、雖然 HashMap 和 Hashtable 都實現了 Map 接口,但 Hashtable 繼承於 Dictionary 類,而 HashMap 是繼承於 AbstractMap;
  • 2、HashMap 可以允許存在一個為 null 的 key 和任意個為 null 的 value,但是 HashTable 中的 key 和 value 都不允許為 null;
  • 3、Hashtable 的方法是同步的,因為在方法上加了 synchronized 同步鎖,而 HashMap 是非線程安全的;

儘管,Hashtable 雖然是線程安全的,但是我們一般不推薦使用它,因為有比它更高效、更好的選擇 ConcurrentHashMap,在後面我們也會講到它。

最後,引入來自 HashTable 的註釋描述:

If a thread-safe implementation is not needed, it is recommended to use HashMap in place of Hashtable. If a thread-safe highly-concurrent implementation is desired, then it is recommended to use java.util.concurrent.ConcurrentHashMap in place of Hashtable.

簡單來說就是,如果你不需要線程安全,那麼使用 HashMap,如果需要線程安全,那麼使用 ConcurrentHashMap。

HashTable 已經被淘汰了,不要在新的代碼中再使用它。

五、參考

1、JDK1.7&JDK1.8 源碼

2、

作者:炸雞可樂
出處:

本站聲明:網站內容來源於博客園,如有侵權,請聯繫我們,我們將及時處理

【精選推薦文章】

智慧手機時代的來臨,RWD網頁設計已成為網頁設計推薦首選

想知道網站建置、網站改版該如何進行嗎?將由專業工程師為您規劃客製化網頁設計及後台網頁設計

帶您來看台北網站建置台北網頁設計,各種案例分享

廣告預算用在刀口上,網站設計公司幫您達到更多曝光效益

預訓練語言模型整理(ELMo/GPT/BERT…)

目錄

簡介

2018年ELMo/GPT/BERT的相繼提出,不斷刷新了各大NLP任務排行榜,自此,NLP終於找到了一種方法,可以像計算機視覺那樣進行遷移學習,被譽為NLP新時代的開端。
與計算機視覺領域預訓練模型不同的是,其通過採用自監督學習的方法,將大量的無監督文本送入到模型中進行學習,即可得到通用的預訓練模型,而NLP領域中無監督文本數據要多少有多少,2019年發布的後續研究工作(GPT2、Roberta、T5等)表明,採用更大的數據、更強大的煉丹爐可以不斷提高模型性能表現,至少目前看來還沒有達到上限。同時,如何縮減模型參數也成為了另一個研究熱點,並有相應的論文在今年發表(ALBERT、ELECTRA)。這一類工作為NLP研發者趟通並指明了一條光明大道:就是通過自監督學習,把大量非監督的文本充分利用起來,並將其中的語言知識編碼,對各種下游NLP任務產生巨大的积極作用。
為何預訓練語言模型能夠達到如此好的效果?主要有如下幾點:

  • word2vec等詞向量模型訓練出來的都是靜態的詞向量,即同一個詞,在任何的上下文當中,其向量表徵是相同的,顯然,這樣的一種詞向量是無法體現一個詞在不同語境中的不同含義的。
  • 我們採用預訓練模型來代替詞向量的關鍵在於,其能夠更具上下文的不同,對上下文中的詞提取符合其語境的詞表徵,該詞表徵向量為一個動態向量,即不同上下文輸入預訓練模型后,同一個詞的詞表徵向量在兩個上下文中的詞表徵是不同的。
    本文將對一下幾個模型進行簡單的總結,主要關注點在於各大模型的主要結構,預訓練任務,以及創新點:
  • ELMo
  • GPT
  • BERT
  • BERT-wwm
  • ERNIE_1.0
  • XLNET
  • ERNIE_2.0
  • RoBERTa
  • (ALBERT/ELECTRA)

預訓練任務簡介

總的來說,預訓練模型包括兩大類:自回歸語言模型與自編碼語言模型

自回歸語言模型

通過給定文本的上文,對當前字進行預測,訓練過程要求對數似然函數最大化,即:
\[max_{\theta} \ logp_{\theta}(x) = \sum_{t=1}^{T}log \ p_{\theta}(x_t|x_{<t})\]

代表模型:ELMo/GPT1.0/GPT2.0/XLNet
優點:該模型對文本序列聯合概率的密度估計進行建模,使得該模型更適用於一些生成類的NLP任務,因為這些任務在生成內容的時候就是從左到右的,這和自回歸的模式天然匹配。
缺點:聯合概率是按照文本序列從左至右進行計算的,因此無法得到包含上下文信息的雙向特徵表徵;

自編碼語言模型

BERT系列的模型為自編碼語言模型,其通過隨機mask掉一些單詞,在訓練過程中根據上下文對這些單詞進行預測,使預測概率最大化,即
\[max_{\theta} \ logp_{\theta}(\bar{x}|\hat{x}) \approx \sum_{t=1}^{T}log \ m_tp_{\theta}(x_t|\hat{x}) = \sum_{t=1}^{T}log \ m_tlog\frac{exp(H_{\theta}(\hat{x})_t^Te(x_t))}{\sum_{x’}exp(H_{\theta}(\hat{x})_t^Te(x’))}\]

其本質為去噪自編碼模型,加入的 [MASK] 即為噪聲,模型對 [MASK] 進行預測即為去噪。
優點:能夠利用上下文信息得到雙向特徵表示
缺點:其引入了獨立性假設,即每個 [MASK] 之間是相互獨立的,這使得該模型是對語言模型的聯合概率的有偏估計;另外,由於預訓練中 [MASK] 的存在,使得模型預訓練階段的數據與微調階段的不匹配,使其難以直接用於生成任務。

預訓練模型的簡介與對比

ELMo

原文鏈接:

ELMo為一個典型的自回歸預訓練模型,其包括兩個獨立的單向LSTM實現的單向語言模型進行自回歸預訓練,不使用雙向的LSTM進行編碼的原因正是因為在預訓練任務中,雙向模型將提前看到上下文表徵而對預測結果造成影響。因此,ELMo在本質上還是屬於一個單向的語言模型,因為其只在一個方向上進行編碼錶征,只是將其拼接了而已

細節

  • 引入雙向語言模型,其實是2個單向語言模型(前向和後向)的集成,這樣做的原因在上一節已經解釋過了,用共享詞向量來進行預訓練;
  • 通過保存預訓練好的2層biLSTM,提取每層的詞表徵用於下游任務;

ELMo的下游使用

  • 對於每一個字符,其每一層的ELMo表徵均為輸入詞向量與該層的雙向編碼錶征拼接而成,即:
    \[R_k = \{x^{LM}_k, \overrightarrow{h}^{LM}_{k,j}, \overleftarrow{h}^{LM}_{k,j} | j = 1, …, L\} = \{h^{LM}_{k,j}|j = 0, …, L\}\]

  • 對於下游任務而言,我們需要把所有層的ELMo表徵整合為一個單獨的向量,最簡單的方式是只用最上層的表徵,而更一般的,我們採用對所有層的ELMo表徵採取加權和的方式進行處理,即:
    \[ELMo^{task}_k = E(R_k; \theta ^{task}) = \gamma ^{task}\sum_{j=0}^L s^{task}h^{LM}_{k,j}\]

其中\(s^{task}\)可以作為學習參數,為一個歸一化的權重因子,用於表示每一層的詞向量在整體的重要性。\(\gamma ^{task}\)為縮放參數,允許具體的task模型去放縮 ELMo 的大小,因為ELMo的表徵分佈與具體任務的表徵分佈不一定是一樣的,可以將其作為一個輔助特徵參數。

  • 得到ELMo表徵之後,則需要將其用於下游任務中去,注意,ELMo的微調過程中,並不是嚴格意義上的微調,預訓練模型部分通常是固定的,不參与到後續訓練當中。具體的,有以下幾種操作方法:
    • 方法一:直接將ELMo表徵與詞向量拼接,輸入到下游任務當中去;
    • 方法二:直接將ELMo表徵與下游模型的輸出層拼接
    • 另外,還可以在ELMo模型中加入dropout, 以及採用 L2 loss的方法來提升模型。

GPT/GPT2

GPT:
GPT2:

GPT

GPT是“Generative Pre-Training”的簡稱,從名字上就可以看出其是一個生成式的預訓練模型,即與ELMo類似,是一個自回歸語言模型。與ELMo不同的是,其採用多層Transformer Decoder作為特徵抽取器,多項研究也表明,Transformer的特徵抽取能力是強於LSTM的。

細節

  • 由於GPT仍然是一個生成式的語言模型,因此需要採用Mask Multi-Head Attention的方式來避免預測當前詞的時候會看見之後的詞,因此將其稱為單向Transformer,這也是首次將Transformer應用於預訓練模型,預測的方式就是將position-wise的前向反饋網絡的輸出直接送入分類器進行預測
  • 此外整個GPT的訓練包括預訓練和微調兩個部分,或者說,對於具體的下游任務,其模型結構也必須採用與預訓練相同的結構,區別僅在於數據需要進行不同的處理

微調

對於帶有標籤\(y\)的監督數據\([x_1, …, x_m]\),我們直接將其輸入到已經完成預訓練的模型中,然後利用最後一個位置的輸出對標籤進行預測,即
\[P(y|x^1, …, x^m) = softmax(h_l^mW_y)\]

其中,\(W_y\)為分類器的參數,\(h_l^m\)為最後一層最後一個位置的輸出。則最大化優化目標即為:
\[ L_2(C) = \sum_{(x, y)}^{T}log \ P(y|x^1, …, x^m)\]

具體的,對於不同的微調任務,我們需要對數據進行如下處理:

GPT2

GPT2 與 GPT 的大致模型框架和預訓練目標是一致的,而區別主要在於以下幾個方面:

  • 其使用了更大的模型
  • 使用了數量更大、質量更高、涵蓋範圍更廣的預訓練數據
  • 採用了無監督多任務聯合訓練的方式,即對於輸入樣本,給予一個該樣本所屬的類別作為引導字符串,這使得該模型能夠同時對多項任務進行聯合訓練,並增強模型的泛化能力

其他的就不深究了

優缺點

BERT

原文鏈接:

BERT 的特徵抽取結構為雙向的 Transformer,簡單來說,就直接套用了 Attention is all you need 中的 Transformer Encoder Block 結構,雖然相比於GPT,僅僅是從單向的變為雙向的,但這也意味着 BERT 無法適用於自回歸語言模型的預訓練方式,因此,BERT提出了兩種預訓練任務來對其模型進行預訓練。

BERT的預訓練

Task 1: MLM

由於BERT需要通過上下文信息,來預測中心詞的信息,同時又不希望模型提前看見中心詞的信息,因此提出了一種 Masked Language Model 的預訓練方式,即隨機從輸入預料上 mask 掉一些單詞,然後通過的上下文預測該單詞,類似於一個完形填空任務。

在預訓練任務中,15%的 Word Piece 會被mask,這15%的 Word Piece 中,80%的時候會直接替換為 [Mask] ,10%的時候將其替換為其它任意單詞,10%的時候會保留原始Token

  • 沒有100%mask的原因
    • 如果句子中的某個Token100%都會被mask掉,那麼在fine-tuning的時候模型就會有一些沒有見過的單詞
  • 加入10%隨機token的原因
    • Transformer要保持對每個輸入token的分佈式表徵,否則模型就會記住這個[mask]是token ’hairy‘
    • 另外編碼器不知道哪些詞需要預測的,哪些詞是錯誤的,因此被迫需要學習每一個token的表示向量
  • 另外,每個batchsize只有15%的單詞被mask的原因,是因為性能開銷的問題,雙向編碼器比單項編碼器訓練要更慢

Task 2: NSP

僅僅一個MLM任務是不足以讓 BERT 解決閱讀理解等句子關係判斷任務的,因此添加了額外的一個預訓練任務,即 Next Sequence Prediction。

具體任務即為一個句子關係判斷任務,即判斷句子B是否是句子A的下文,如果是的話輸出’IsNext‘,否則輸出’NotNext‘。

訓練數據的生成方式是從平行語料中隨機抽取的連續兩句話,其中50%保留抽取的兩句話,它們符合IsNext關係,另外50%的第二句話是隨機從預料中提取的,它們的關係是NotNext的。這個關係保存在圖4中的[CLS]符號中

輸入表徵

BERT的輸入表徵由三種Embedding求和而成:

  • Token Embeddings:即傳統的詞向量層,每個輸入樣本的首字符需要設置為[CLS],可以用於之后的分類任務,若有兩個不同的句子,需要用[SEP]分隔,且最後一個字符需要用[SEP]表示終止
  • Segment Embeddings:為\([0, 1]\)序列,用來在NSP任務中區別兩個句子,便於做句子關係判斷任務
  • Position Embeddings:與Transformer中的位置向量不同,BERT中的位置向量是直接訓練出來的

Fine-tunninng

對於不同的下游任務,我們僅需要對BERT不同位置的輸出進行處理即可,或者直接將BERT不同位置的輸出直接輸入到下游模型當中。具體的如下所示:

  • 對於情感分析等單句分類任務,可以直接輸入單個句子(不需要[SEP]分隔雙句),將[CLS]的輸出直接輸入到分類器進行分類
  • 對於句子對任務(句子關係判斷任務),需要用[SEP]分隔兩個句子輸入到模型中,然後同樣僅須將[CLS]的輸出送到分類器進行分類
  • 對於問答任務,將問題與答案拼接輸入到BERT模型中,然後將答案位置的輸出向量進行二分類並在句子方向上進行softmax(只需預測開始和結束位置即可)
  • 對於命名實體識別任務,對每個位置的輸出進行分類即可,如果將每個位置的輸出作為特徵輸入到CRF將取得更好的效果。

缺點

  • BERT的預訓練任務MLM使得能夠藉助上下文對序列進行編碼,但同時也使得其預訓練過程與中的數據與微調的數據不匹配,難以適應生成式任務
  • 另外,BERT沒有考慮預測[MASK]之間的相關性,是對語言模型聯合概率的有偏估計
  • 由於最大輸入長度的限制,適合句子和段落級別的任務,不適用於文檔級別的任務(如長文本分類);
  • 適合處理自然語義理解類任務(NLU),而不適合自然語言生成類任務(NLG)

ELMo/GPT/BERT對比,其優缺點

ELMo/GPT/BERT 均為在2018年提出的三個模型,且性能是依次提高的,這裏將其放在一起對比,來看看這三者之間的主要區別有哪些

  • ELMo 的特徵提取器為LSTM,特徵抽取能力明顯較Transformer更弱,且并行能力較差
  • ELMo/GPT 均為單向語言模型,即自回歸語言模型,天生適合用於處理生成式任務,但這種特性也決定了無法提取上下文信息用於序列編碼
  • BERT採用雙向Transformer作為特徵抽取結構,能夠有效提取上下文信息用於序列編碼

BERT-wwm

原文鏈接:
Github鏈接:

Whole Word Masking (wwm),暫翻譯為全詞Mask或整詞Mask,是哈工大訊飛聯合實驗室提出的BERT中文預訓練模型的升級版本,主要更改了原預訓練階段的訓練樣本生成策略。 簡單來說,原有基於WordPiece的分詞方式會把一個完整的詞切分成若干個子詞,在生成訓練樣本時,這些被分開的子詞會隨機被mask。

在全詞Mask中,如果一個完整的詞的部分WordPiece子詞被mask,則同屬該詞的其他部分也會被mask,即全詞Mask。這樣的做法強制模型預測整個的詞,而不是詞的一部分,即對同一個詞不同字符的預測將使得其具有相同的上下文,這將加強同一個詞不同字符之間的相關性,或者說引入了先驗知識,使得BERT的獨立性假設在同一個詞的預測上被打破,但又保證了不同的詞之間的獨立性。

作者將全詞Mask的方法應用在了中文中,使用了中文維基百科(包括簡體和繁體)進行訓練,並且使用了哈工大LTP作為分詞工具,即對組成同一個詞的漢字全部進行Mask。這樣一個簡單的改進,使得同樣規模的模型,在中文數據上的表現獲得了全方位的提升

RoBERTa

從模型結構上看,RoBERTa基本沒有什麼太大創新,最主要的區別有如下幾點:

  • 移除了NSP這個預訓練任務,效果變得更好
  • 動態改變mask策略,把數據複製10份,然後統一進行隨機mask;

  • 其他的區別就在於學習率/數據量/batch_size 等

ERNIE(艾尼) 1.0

作者認為BERT在中文文本中的MLM預訓練模型很容易使得模型提取到字搭配這種低層次的語義信息,而對於短語以及實體層次的語義信息抽取能力是較弱的。因此將外部知識引入大規模預訓練語言模型中,提高在知識驅動任務上的性能。具體有如下三個層次的預訓練任務:

  • Basic-Level Masking: 跟bert一樣對單字進行mask,很難學習到高層次的語義信息;
  • Phrase-Level Masking: 輸入仍然是單字級別的,mask連續短語;
  • Entity-Level Masking: 首先進行實體識別,然後將識別出的實體進行mask。

ERNIE 2.0

ERNIE 2.0相比於 1.0 來說,主要的改進在於採取 Multi-task learning(多任務同時學習,同時學習的任務數量逐漸增多)以及 Continue-Learning(不同任務組合輪番學習)的機制。其訓練任務包括了三個級別的任務:

  • 詞級別:
    • Knowledge Masking(短語Masking)
    • Capitalization Prediction(大寫預測)
    • Token-Document Relation Prediction(詞是否會出現在文檔其他地方)
  • 結構級別
    • Sentence Reordering(句子排序分類)
    • Sentence Distance(句子距離分類)
  • 語義級別:
    • Discourse Relation(句子語義關係)
    • IR Relevance(句子檢索相關性)

XLNet

XLNet針對自回歸語言模型單向編碼以及BERT類自編碼語言模型的有偏估計的缺點,提出了一種廣義自回歸語言預訓練方法。

提出背景

  • 傳統的語言模型(自回歸語言模型AR天然適合處理生成任務,但是無法對雙向上下文進行表徵;
  • 而自編碼語言模型(AE)雖然可以實現雙向上下文進行表徵,但是:
    • BERT系列模型引入獨立性假設,沒有考慮預測[MASK]之間的相關性;
    • MLM預訓練目標的設置造成預訓練過程和生成過程不一致;
    • 預訓練時的[MASK]噪聲在finetune階段不會出現,造成兩階段不匹配問題;
  • XLNet提出了一種排列語言模型(PLM),它綜合了自回歸模型和自編碼模型的優點,同時避免他們的缺點

排列語言模型(Permutation Language Model,PLM)

排列語言模型的思想就是在自回歸和自編碼的方式中間額外添加一個步驟,即可將兩者完美統一起來,具體的就是希望語言模型從左往右預測下一個字符的時候,不僅要包含上文信息,同時也要能夠提取到對應字符的下文信息,且不需要引入Mask符號。即在保證位置編碼不變的情況下,將輸入序列的順序打亂,然後預測的順序還是按照原始的位置編碼順序來預測的,但是相應的上下文就是按照打亂順序的上下文來看了,這樣以來,預測對象詞的時候,可以隨機的看到上文信息和下文信息。另外,假設序列長度為\(T\),則我們如果遍歷\(T!\)種分解方法,並且模型參數是共享的,PLM就一定可以學習到預測詞的所有上下文信息。但顯然,遍歷\(T!\)種上下文計算量是十分大的,XLNet採用的是一個部分預測的方法(Partial Prediction),為了減少計算量,作者只對隨機排列后的末尾幾個詞進行預測,並使得如下期望最大化:
\[max_{\theta} \ E_{Z \sim Z_T}[\sum_{t = 1}^{T}logp_{\theta}(x_{z_t}|x_{z < t})]\]

Two-Stream Self-Attention

直接用標準的Transformer來建模PLM,會出現沒有目標(target)位置信息的問題。即在打亂順序之後,我們並不知道下一個要預測的詞是一個什麼詞,這將導致用相同上文預測不同目標的概率是相同的。

XLNet引入了雙流自注意力機制(Two-Stream Self-Attention)來解決這個問題。Two-Stream Self-Attention表明了其有兩個分離的Self-Attention信息流:

  • Query Stream 就為了找到需要預測的當前詞,這個信息流的Self-Attention的Query輸入是僅包含預測詞的位置信息,而Key和Value為上下文中包含內容信息和位置信息的輸入,表明我們無法看見預測詞的內容信息,該信息是需要我們去預測的;
  • Content Stream 主要為 Query Stream 提供其它詞的內容向量,其Query輸入為包含預測詞的內容信息和位置信息,Value和Key的輸入為選中上下文的位置信息和內容信息;

兩個信息流的輸出同樣又作為對應的下一層的雙信息流的輸入。而隨機排列機制實際上是在內部用Mask Attention的機制實現的。

Transformer-XL

Transformer-XL是 XLNet 的特徵抽取結構,其相比於傳統的Transformer能捕獲更長距離的單詞依賴關係。

原始的Transformer的主要缺點在於,其在語言建模中會受到固定長度上下文的限制,從而無法捕捉到更長遠的信息。

Transformer-XL採用片段級遞歸機制(segment-level recurrence mechanism)和相對位置編碼機制(relative positional encoding scheme)來對Transformer進行改進。

  • 片段級遞歸機制:指的是當前時刻的隱藏信息在計算過程中,將通過循環遞歸的方式利用上一時刻較淺層的隱藏狀態,這使得每次的計算將利用更大長度的上下文信息,大大增加了捕獲長距離信息的能力。

  • 相對位置編碼:Transformer本身引入了三角函數向量作為位置編碼向量。而Transformer-XL復用了上文的信息,這就導致位置編碼出現重疊,因此採用了訓練的方式得到相對位置編碼向量。

ALBERT

未完待續…

參考鏈接

本站聲明:網站內容來源於博客園,如有侵權,請聯繫我們,我們將及時處理

【精選推薦文章】

自行創業 缺乏曝光? 下一步"網站設計"幫您第一時間規劃公司的門面形象

網頁設計一頭霧水??該從何著手呢? 找到專業技術的網頁設計公司,幫您輕鬆架站!

評比前十大台北網頁設計台北網站設計公司知名案例作品心得分享

台北網頁設計公司這麼多,該如何挑選?? 網頁設計報價省錢懶人包"嚨底家"

Golang 入門系列(十六)鎖的使用場景主要涉及到哪些?讀寫鎖為什麼會比普通鎖快

前面已經講過很多Golang系列知識,感興趣的可以看看以前的文章,,

接下來要說的是golang的鎖的使用場景主要涉及到哪些?讀寫鎖為什麼會比普通鎖快。

 

一、什麼場景下需要用到鎖

當程序中就一個線程的時候,是不需要加鎖的,但是通常實際的代碼不會只是單線程,有可能是多個線程同時訪問公共資源,所以這個時候就需要用到鎖了,那麼關於鎖的使用場景主要涉及到哪些呢?

1. 多個線程在讀相同的數據時
2. 多個線程在寫相同的數據時
3. 同一個資源,有讀又有寫時

 

二、Go中鎖分為兩種:

  • 互斥鎖 (sync.Mutex)
  • 讀寫鎖 (sync.RWMutex 底層依賴Mutex實現  )

互斥鎖是併發程序對公共資源訪問限制最常見的方式。在Go中,sync.Mutex 提供了互斥鎖的實現。

當一個goroutine獲得了Mutex后,其他goroutine只能等待,除非該goroutine釋放這個Mutex。

互斥鎖結構:

type Mutex struct {
    state int32
    sema  uint32
}

1. 鎖定狀態值為1,未鎖定狀態鎖未0 。

2. Lock()加鎖、Unlock解鎖。

 

讀寫鎖則是對讀寫操作進行加鎖。需要注意的是多個讀操作之間不存在互斥關係,這樣提高了對共享資源的訪問效率。

Go中讀寫鎖由 sync.RWMutex 提供,RWMutex在讀鎖佔用的情況下,會阻止寫,但不阻止讀。RWMutex在寫鎖佔用情況下,會阻止任何其他goroutine(無論讀和寫)進來,整個鎖相當於由該goroutine獨佔。

讀寫鎖結構:

type RWMutex struct {
    w           Mutex  // held if there are pending writers
    writerSem   uint32 // semaphore for writers to wait for completing readers
    readerSem   uint32 // semaphore for readers to wait for completing writers
    readerCount int32  // number of pending readers
    readerWait  int32  // number of departing readers
}

1. RWMutex是單寫多讀鎖,該鎖可以加多個讀鎖或者一個寫鎖。

2. 讀鎖佔用的情況會阻止寫,不會阻止讀,多個goroutine可以同時獲取讀鎖。

3. 寫鎖會阻止其他gorotine不論讀或者寫進來,整個鎖由寫鎖goroutine佔用 與第一條共用示範代碼

4. 適用於讀多寫少的場景

三、如何使用互斥鎖

Mutex為互斥鎖,Lock() 加鎖,Unlock() 解鎖,使用Lock() 加鎖后,便不能再次對其進行加鎖,直到利用Unlock()解鎖對其解鎖后,才能再次加鎖.適用於讀寫不確定場景,即讀寫次數沒有明顯的區別,並且只允許只有一個讀或者寫的場景,所以該鎖恭弘=叶 恭弘叫做全局鎖。

互斥鎖只能鎖定一次,當在解鎖之前再次進行加鎖,便會無法加鎖。如果在加鎖前解鎖,便會報錯”panic: sync: unlock of unlocked mutex”。 

package main
import ("fmt"
    "sync"
)

var (
    count int
    lock sync.Mutex
)

func main() {
    for i := 0; i < 2; i++ {
        go func() {
            for i := 1000000; i > 0; i-- {
                lock.Lock()
                count ++
                lock.Unlock()
            }
            fmt.Println(count)
        }()
    }

    fmt.Scanf("\n") //等待子線程全部結束
}

運行結果:
1952533
2000000 //最後的線程打印輸出

對於上面的程序,a作為一個公共的資源,所以對a的改變、讀寫等操作都需要加鎖。

 

需要注意的問題:

  1. 不要重複鎖定互斥鎖
  2. 不要忘記解鎖互斥鎖,必要時使用 defer 語句
  3. 不要在多個函數之間直接傳遞互斥鎖

 

四、如何使用讀寫鎖

讀寫鎖的場景主要是在多線程的安全操作下,並且讀的情況多於寫的情況,也就是說既滿足多線程操作的安全性,也要確保性能不能太差,這時候,我們可以考慮使用讀寫鎖。當然你也可以簡單暴力直接使用互斥鎖(Mutex)。

Lock() 寫鎖,如果在添加寫鎖之前已經有其他的讀鎖和寫鎖,則lock就會阻塞直到該鎖可用,為確保該鎖最終可用,已阻塞的 Lock 調用會從獲得的鎖中排除新的讀取器,即寫鎖權限高於讀鎖,有寫鎖時優先進行寫鎖定。

Unlock() 寫鎖解鎖,如果沒有進行寫鎖定,則就會引起一個運行時錯誤。

RLock() 讀鎖,當有寫鎖時,無法加載讀鎖,當只有讀鎖或者沒有鎖時,可以加載讀鎖,讀鎖可以加載多個,所以適用於"讀多寫少"的場景。

RUnlock() 讀鎖解鎖,RUnlock 撤銷單次RLock 調用,它對於其它同時存在的讀取器則沒有效果。若 rw 並沒有為讀取而鎖定,調用 RUnlock 就會引發一個運行時錯誤。

package main
import ("fmt"
    "sync"
)

var (
    count int
    rwLock sync.RWMutex
)

func main() {
    for i := 0; i < 2; i++ {
        go func() {
            for i := 1000000; i > 0; i-- {
                rwLock.Lock()
                count ++
                rwLock.Unlock()
            }
            fmt.Println(count)
        }()
    }

    fmt.Scanf("\n") //等待子線程全部結束
}

運行結果:
1968637
2000000 

看着挺複雜的,其實簡單來說就是:

  1. 讀鎖不能阻塞讀鎖

  2. 讀鎖需要阻塞寫鎖,直到所有讀鎖都釋放

  3. 寫鎖需要阻塞讀鎖,直到所有寫鎖都釋放

  4. 寫鎖需要阻塞寫鎖

 

五、最後

以上,就把golang中各種鎖的使用場景及怎麼使用互斥鎖和讀寫鎖等相關內容介紹完了,希望能對大家有所幫助。

 

本站聲明:網站內容來源於博客園,如有侵權,請聯繫我們,我們將及時處理

【精選推薦文章】

智慧手機時代的來臨,RWD網頁設計已成為網頁設計推薦首選

想知道網站建置、網站改版該如何進行嗎?將由專業工程師為您規劃客製化網頁設計及後台網頁設計

帶您來看台北網站建置台北網頁設計,各種案例分享

廣告預算用在刀口上,網站設計公司幫您達到更多曝光效益

基於Galera Cluster多主結構的Mysql高可用集群

Galera Cluster特點

1、多主架構:真正的多點讀寫的集群,在任何時候讀寫數據,都是最新的 2、同步複製:集群不同節點之間數據同步,沒有延遲,在數據庫掛掉之後,數據不會丟失 3、併發複製:從節點APPLY數據時,支持并行執行,更好的性能 4、故障切換:在出現數據庫故障時,因支持多點寫入,切換容易 5、熱插拔:在服務期間,如果數據庫掛了,只要監控程序發現的夠快,不可服務時間就會非常少。在節點故障期間,節點本身對集群的影響非常小 6、自動節點克隆:在新增節點,或者停機維護時,增量數據或者基礎數據不需要人工手動備份提供,Galera Cluster會自動拉取在線節點數據,最終集群會變為一致 7、對應用透明:集群的維護,對應用程序是透明的 

Galera Cluster工作過程

客戶端發送更新指令到mysql服務器,服務器回復OK,但客戶端有可能以事務方式執行,並沒有發送確認指令(commit);當客戶端發送commit指令確認后,mysql服務器會把數據庫的更新複製到同一個集群的其他節點;集群中的所有節點會對數據庫的更新進行校驗,檢查更新完的數據與數據庫中的數據是否衝突,如果不衝突,服務器端會回復OK;如果更新的數據與集群中的任意一個節點數據發生衝突,則都會回復error 

實現Galera Cluster集群

至少需要三台機器;並且Galera Cluster也是一個數據庫服務,下載Galera Cluster必須卸載服務器現有的mysql數據庫服務

master1配置

[root@centos7 ~]# vim /etc/yum.repos.d/base.repo #編輯yum源倉庫 [mysql] name=mysql baseurl=https://mirrors.tuna.tsinghua.edu.cn/mariadb/mariadb-10.0.38/yum/centos7-amd64/ gpgcheck=0 enabled=1 [root@centos7 ~]# scp /etc/yum.repos.d/base.repo 192.168.38.37:/etc/yum.repos.d/mysql.repo #發送給另外兩個主節點 [root@centos7 ~]# scp /etc/yum.repos.d/base.repo 192.168.38.47:/etc/yum.repos.d/mysql.repo [root@centos7 ~]# yum install MariaDB-Galera-server -y [root@centos7 ~]# vim /etc/my.cnf.d/server.cnf #編輯galera配置文件 [galera] wsrep_provider=/usr/lib64/galera/libgalera_smm.so #啟用galera模塊 wsrep_cluster_address="gcomm://192.168.38.7,192.168.38.37,192.168.38.47" #指定集群中節點的IP binlog_format=row #二進制日誌格式必須為行 default_storage_engine=InnoDB #存儲引擎 innodb_autoinc_lock_mode=2 bind-address=0.0.0.0 #綁定本機的所有IP wsrep_cluster_name='mycluster' #設置集群名 wsrep_node_name='node1' #設置節點名 wsrep_node_address='192.168.38.7' #指定本節點的IP [root@centos7 ~]# service mysql start --wsrep-new-cluster #第一次啟動,三個節點中必須有一個節點添加--wsrep-new-cluster參數啟動 

master2

[root@localhost ~]# yum install MariaDB-Galera-server -y #yum源不用配,前面master1主節點已經把yum源和galera配置文件發送到所有節點中 [root@localhost ~]# vim /etc/my.cnf.d/server.cnf [galera] wsrep_cluster_name='mycluster' #上面的galera信息不用修改,修改一下本節點的信息 wsrep_node_name='node2' wsrep_node_address='192.168.38.37' [root@localhost ~]# service mysql start 

master3

[root@localhost ~]# yum install MariaDB-Galera-server -y [root@localhost ~]# vim /etc/my.cnf.d/server.cnf [galera] wsrep_cluster_name='mycluster' wsrep_node_name='node3' wsrep_node_address='192.168.38.47' [root@localhost ~]# service mysql start 

在一個主節點更新數據,會同步到這個集群的其他主節點上;但是假如三個主節點同時創建一張db1表,會發現有兩個主節點報錯,一個主節點成功;這個就是galera cluster的工作特性;最先執行創建表的主節點詢問其餘主節點數據是否發生衝突,不發生衝突則創建表,一個主節點成功創建了db1表,其餘兩個主節點創建的時候也回去詢問,但是有一主節點已經創建完畢,所以會發生數據衝突,則其餘兩個主節點都會報錯

SHOW VARIABLES LIKE 'wsrep_%'; #可以在mysql中查詢集群的相關狀態信息 SHOW STATUS LIKE 'wsrep_%'; SHOW STATUS LIKE 'wsrep_cluster_size'; 

往現有集群中添加一個主節點master4

通過master1,把yum源倉庫文件和galera的配置文件都拷貝給master4

[root@localhost ~]# yum install MariaDB-Galera-server -y [root@localhost ~]# vim /etc/my.cnf.d/server.cnf [galera] wsrep_cluster_address="gcomm://192.168.38.7,192.168.38.37,192.168.38.47,192.168.38.57" #把新的節點master4的IP添加上去 wsrep_cluster_name='mycluster' wsrep_node_name='node4' wsrep_node_address='192.168.38.57' [root@localhost ~]# service mysql start 

其餘所有節點的galera的配置文件都需要添加新的主節點的IP,添加完之後重啟服務

[root@localhost ~]# vim /etc/my.cnf.d/server.cnf wsrep_cluster_address="gcomm://192.168.38.7,192.168.38.37,192.168.38.47,192.168.38.57" [root@localhost ~]# service mysql restart [root@localhost ~]# mysql -e 'SHOW STATUS LIKE "wsrep_cluster_size";' #集群中有4個節點 +--------------------+-------+ | Variable_name | Value | +--------------------+-------+ | wsrep_cluster_size | 4 | +--------------------+-------+

本站聲明:網站內容來源於博客園,如有侵權,請聯繫我們,我們將及時處理

【精選推薦文章】

自行創業 缺乏曝光? 下一步"網站設計"幫您第一時間規劃公司的門面形象

網頁設計一頭霧水??該從何著手呢? 找到專業技術的網頁設計公司,幫您輕鬆架站!

評比前十大台北網頁設計台北網站設計公司知名案例作品心得分享

台北網頁設計公司這麼多,該如何挑選?? 網頁設計報價省錢懶人包"嚨底家"