lagou 爪哇 3-3 dubbo 筆記

Apache Dubbo是一款高性能的 Java RPC 框架。其前身是阿里巴巴公司開源的一個高性能、輕量級的開源 Java RPC框架,可以和 Spring 框架無縫集成。

duboo 中文官網
http://dubbo.apache.org/zh/

第一部分:項目架構演變過程

單體架構 到 微服務架構的演變

單一應用架構
當網站流量很小時,只需一個應用,將所有功能都部署在一起,以減少部署節點和成本。此時,用于簡化增刪改查工作量的數據訪問框架(ORM)是關鍵。

垂直應用架構
當訪問量逐漸增大,單一應用增加機器帶來的加速度越來越小,提升效率的方法之一是將應用拆成互不相干的幾個應用,以提升效率。此時,用于加速前端頁面開發的Web框架(MVC)是關鍵。

分布式服務架構
當垂直應用越來越多,應用之間交互不可避免,將核心業務抽取出來,作為獨立的服務,逐漸形成穩定的服務中心,使前端應用能更快速的響應多變的市場需求。此時,用于提高業務復用及整合的分布式服務框架(RPC)是關鍵。

流動計算架構
當服務越來越多,容量的評估,小服務資源的浪費等問題逐漸顯現,此時需增加一個調度中心基于訪問壓力實時管理集群容量,提高集群利用率。此時,用于提高機器利用率的資源調度和治理中心(SOA)是關鍵。

第二部分: Dubbo 架構與實戰

Dubbo的架構


節點角色說明

  • Provider 暴露服務的服務提供方
  • Consumer 調用遠程服務的服務消費方
  • Registry 服務注冊與發現的注冊中心
  • Monitor 統計服務的調用次數和調用時間的監控中心
  • Container 服務運行容器

調用關系說明

  1. 服務容器負責啟動,加載,運行服務提供者。
  2. 服務提供者在啟動時,向注冊中心注冊自己提供的服務。
  3. 服務消費者在啟動時,向注冊中心訂閱自己所需的服務。
  4. 注冊中心返回服務提供者地址列表給消費者,如果有變更,注冊中心將基于長連接推送變更數據給消費者。
  5. 服務消費者,從提供者地址列表中,基于軟負載均衡算法,選一臺提供者進行調用,如果調用失敗,再選另一臺調用。
  6. 服務消費者和提供者,在內存中累計調用次數和調用時間,定時每分鐘發送一次統計數據到監控中心。

Dubbo 架構具有以下幾個特點,分別是連通性、健壯性、伸縮性、以及向未來架構的升級性。

服務注冊中心Zookeeper

通過前面的 Dubbo 架構圖可以看到,Registry(服務注冊中心)在其中起著至關重要的作用。Dubbo官 方推薦使用Zookeeper作為服務注冊中心。Zookeeper 是 Apache Hadoop 的子項目,作為 Dubbo 服務的注冊中心,工業強度較高,可用于生產環境,并推薦使用 。

dubbo的開發案例

在Dubbo中所有的的服務調用都是基于接口去進行雙方交互的。雙方協定好Dubbo調用中的接口,提供者來提供實現類并且注冊到注冊中心上。
調用方則只需要引入該接口,并且同樣注冊到相同的注冊中心上(消費者)。即可利用注冊中心來實現集群感知功能,之后消費者即可對提供者進行調用。

我們所有的項目都是基于Maven去進行創建,這樣相互在引用的時候只需要以依賴的形式進行展現就可以了。

并且這里我們會通過maven的父工程來統一依賴的版本。
程序實現分為以下幾步驟:

  1. 建立maven工程 并且 創建API模塊: 用于規范雙方接口協定
  2. 提供provider模塊,引入API模塊,并且對其中的服務進行實現。將其注冊到注冊中心上,對外來
    統一提供服務。
  3. 提供consumer模塊,引入API模塊,并且引入與提供者相同的注冊中心。再進行服務調用。
  • 注解版
  • XML版
  • 純代碼版

這里推薦用到的注冊中心是 zookeeper, 所以順便可以熟悉一下 zk 的常用命令

cd /Users/ale/Downloads/apache-zookeeper-3.5.8-bin/bin
./zkServer.sh start-foreground  
./zkServer.sh start
./zkServer.sh status 
./zkServer.sh stop

服務端會 qos 22222
23:13:49.846 [main] INFO org.apache.dubbo.qos.server.Server.start(Server.java:109) - [DUBBO] qos-server bind localhost:22222, dubbo version: 2.7.5, current host: 127.0.0.1

客戶端會 qos 綁定失敗
23:14:33.902 [main] WARN org.apache.dubbo.qos.protocol.QosProtocolWrapper.startQosServer(QosProtocolWrapper.java:113) - [DUBBO] Fail to start qos server: , dubbo version: 2.7.5, current host: 127.0.0.1
java.net.BindException: Address already in use

解決辦法, 重新指定客戶端的 qos 端口即可.

Dubbo 的管理控制臺

Dubbo管理控制臺 dubbo-admin

作用
主要包含:服務管理 、 路由規則、動態配置、服務降級、訪問控制、權重調整、負載均衡等管理功能。

如我們在開發時,需要知道 Zookeeper 注冊中心都注冊了哪些服務,有哪些消費者來消費這些服務。我 們可以通過部署一個管理中心來實現。其實管理中心就是一個 web 應用,原來是 war(2.6版本以前)包需 要部署到tomcat即可。現在是jar包可以直接通過java命令運行。

控制臺安裝步驟

1. 從git 上下載項目
https://github.com/apache/dubbo-admin 
或者直接下載zip包
https://codeload.github.com/apache/dubbo-admin/zip/master

2. 在 dubbo-admin-server/src/main/resources/application.properties

# 這里可以修改 tomcat 默認端口, 可選
server.port=7001
# 登錄窗口的用戶名和密碼
spring.root.password=root
spring.guest.password=guest
# 指定注冊中心地址, 這里根據自己的實際需要進行更改
dubbo.registry.address=zookeeper://127.0.0.1:2181

3.切換到項目所在的路徑 使用mvn 打包 
mvn clean package -Dmaven.test.skip=true

4.java 命令運行
java -jar 對應的jar包

java -jar ./target/dubbo-admin-0.0.1-SNAPSHOT.jar

5. 訪問 http://localhost:7001
使用用戶名root 和 密碼root 進行登錄即可.

Dubbo的相關配置

dubbo:application

對應 org.apache.dubbo.config.ApplicationConfig, 代表當前應用的信息

  1. name: 當前應用程序的名稱,在dubbo-admin中我們也可以看到,這個代表這個應用名稱。我們在真正時是時也會根據這個參數來進行聚合應用請求。
  2. owner: 當前應用程序的負責人,可以通過這個負責人找到其相關的應用列表,用于快速定位到責任人。
  3. qosEnable : 是否啟動QoS 默認true
  4. qosPort : 啟動QoS綁定的端口 默認 22222
  5. qosAcceptForeignIp: 是否允許遠程訪問 默認是false

可以在 xml dubbo-demo-consumer.xml中可以進行配置

    <!-- 消費方應用名,用于計算依賴關系,不是匹配條件,不要與提供方一樣 -->
    <dubbo:application name="consumer-of-helloworld-app" owner="chengxu">
        <dubbo:parameter key="qos.enable" value="true"></dubbo:parameter>
        <dubbo:parameter key="qos.port" value="22223"></dubbo:parameter>
        <!--默認是 false-->
        <dubbo:parameter key="qos.accept.foreign.ip" value="false"></dubbo:parameter>
    </dubbo:application>

當然, 也可以在配置文件中進行配置 dubbo-consumer.properties

# 這里寫上應用的名字
dubbo.application.name=service-consumer
dubbo.application.qosEnable=true
dubbo.application.qosPort=true
dubbo.application.qosAcceptForeignIp=true

qos運維相關, 需要使用 telnet 命令. 例如登錄 telnet localhost 22222

dubbo:registry

org.apache.dubbo.config.RegistryConfig, 代表該模塊所使用的注冊中心。一個模塊中的服務可以將
其注冊到多個注冊中心上,也可以注冊到一個上。后面再service和reference也會引入這個注冊中心。

  1. id :如果在當前服務中provider或者consumer中存在多個注冊中心時,則使用需要增加該配置。在一些公司,會通過業務線的不同選擇不同的注冊中心,所以一般都會配置該值。
  2. address : 當前注冊中心的訪問地址。
  3. protocol : 當前注冊中心所使用的協議是什么。也可以直接在 address 中寫入,比如使用zookeeper,就可以寫成 zookeeper://xx.xx.xx.xx:2181
  4. timeout : 當與注冊中心不再同一個機房時,大多會把該參數延長

dubbo:protocol

org.apache.dubbo.config.ProtocolConfig, 指定服務在進行數據傳輸所使用的協議。

  1. id : 在大公司,可能因為各個部門技術棧不同,所以可能會選擇使用不同的協議進行交互。這里在多個協議使用時,需要指定。
  2. name : 指定協議名稱。默認使用 dubbo 。

dubbo:service

org.apache.dubbo.config.ServiceConfig, 用于指定當前需要對外暴露的服務信息,后面也會具體講解。和 dubbo:reference 大致相同。

  1. interface : 指定當前需要進行對外暴露的接口是什么。
  2. ref : 具體實現對象的引用,一般我們在生產級別都是使用Spring去進行Bean托管的,所以這里面一般也指的是Spring中的BeanId。
  3. version : 對外暴露的版本號。不同的版本號,消費者在消費的時候只會根據固定的版本號進行消費。

dubbo:reference 服務消費者引用服務配置。

org.apache.dubbo.config.ReferenceConfig, 消費者的配置,這里只做簡單說明,后面會具體講解。

  1. id : 指定該Bean在注冊到Spring中的id。
  2. interface: 服務接口名
  3. version : 指定當前服務版本,與服務提供者的版本一致。
  4. registry : 指定所具體使用的注冊中心地址。這里面也就是使用上面在 dubbo:registry 中所聲明的id。

dubbo:method

org.apache.dubbo.config.MethodConfig, 用于在制定的 dubbo:service 或者 dubbo:reference 中的更具體一個層級,指定具體方法級別在進行RPC操作時候的配置,可以理解為對這上面層級中的配置針對于具體方法的特殊處理。

  1. name : 指定方法名稱,用于對這個方法名稱的RPC調用進行特殊配置。
  2. async: 是否異步 默認false

dubbo:service 和 dubbo:reference詳解

這兩個在dubbo中是我們最為常用的部分,其中有一些我們必然會接觸到的屬性。并且這里會講到一些設置上的使用方案。

  1. mock: 用于在方法調用出現錯誤時,當做服務降級來統一對外返回結果,后面我們也會對這個方法做更多的介紹。可以放在消費者的 consumer 和 reference 標簽中。
  2. timeout: 用于指定當前方法或者接口中所有方法的超時時間。我們一般都會根據提供者的時長來具體規定。比如我們在進行第三方服務依賴時可能會對接口的時長做放寬,防止第三方服務不穩定導致服務受損。可以放在提供者的service標簽, 消費者的 customer(優先級比 reference 高) 或 reference 標簽.
  3. check: 放在消費者的<customer>下的標簽:用于在啟動時,檢查生產者是否有該服務。我們一般都會將這個值設置為false,不讓其進行檢查。因為如果出現模塊之間循環引用的話,那么則可能會出現相互依賴,都進行check的話,那么這兩個服務永遠也啟動不起來。
  4. retries: 可以放在消費者的<consumer> 或者 <reference>標簽中。用于指定當前服務在執行時出現錯誤或者超時時的重試機制。
    1. 注意提供者是否有冪等,否則可能出現數據一致性問題
    2. 注意提供者是否有類似緩存機制,如出現大面積錯誤時,可能因為不停重試導致雪崩
  5. executes: 用于在提供者的 service 標簽, 來確保最大的并行度。
  • 可能導致集群功能無法充分利用或者堵塞
  • 但是也可以啟動部分對應用的保護功能
  • 可以不做配置,結合后面的熔斷限流使用

其它配置 參考官網-schema 配置, 官網介紹的非常詳細且更新及時

第三部分: Dubbo 高級應用實戰

SPI 負載均衡 異步調用 自定義線程池 路由規則 服務降級

SPI

SPI簡介

SPI 全稱為 (Service Provider Interface) ,是JDK內置的一種服務提供發現機制。 目前有不少框架用它來做服務的擴展發現,簡單來說,它就是一種動態替換發現的機制。使用SPI機制的優勢是實現解耦,使得第三方服務模塊的裝配控制邏輯與調用者的業務代碼分離。

JDK中的SPI

Java中如果想要使用SPI功能,先提供標準服務接口,然后再提供相關接口實現和調用者。這樣就可以通過SPI機制中約定好的信息進行查詢相應的接口實現。

SPI遵循如下約定:

1、當服務提供者提供了接口的一種具體實現后,在 META-INF/services 目錄下創建一個以“接口全限定名”為命名的文件,內容為實現類的全限定名;

com.lagou.service.impl.HumanHelloService    

2、接口實現類所在的 jar 包放在主程序的 classpath 中;
3、主程序通過java.util.ServiceLoader動態裝載實現模塊,它通過掃描META-INF/services 目錄下的配置文件找到實現類的全限定名,把類加載到JVM;

package com.lagou.service.impl;

import com.lagou.service.HelloService;

/**
 * spi:人類打招呼
 */
public class HumanHelloService implements HelloService {
    @Override
    public String sayHello(String name) {
        return "hello my friend";
    }
}

4、SPI的實現類必須攜帶一個無參構造方法;

demo-base\spi-demo-impl\src\main\resources\META-INF\services\com.lagou.service.HelloService

  1. 通過 ServiceLoader ,進行調用
package com.lagou.test;

import com.lagou.service.HelloService;

import java.util.ServiceLoader;

public class JavaSpiMain {

    public static void main(String[] args) {
        // 怎么得到實現了 HelloService 接口的組件呢,這里用到了 ServiceLoader 類
        final ServiceLoader<HelloService> list = ServiceLoader.load(HelloService.class);
        for (HelloService helloService : list) {
            System.out.println("類名:" + helloService.getClass().getName());
            System.out.println(helloService.sayHello("hi!"));
        }
    }
}

Dubbo中的 SPI

dubbo 中大量的使用了 SPI 來作為擴展點,通過實現同一接口的前提下,可以進行定制自己的實現類。
比如比較常見的協議,負載均衡,都可以通過SPI的方式進行定制化,自己擴展。Dubbo中已經存在的所有已經實現好的擴展點

我們使用三個項目來演示Dubbo中擴展點的使用方式,一個主項目main,一個服務接口項目api,一個服務實現項目impl。

api項目創建
(1)導入坐標 dubbo
(2)創建接口
在接口上 使用 @SPI

package com.lagou.service;

import org.apache.dubbo.common.extension.SPI;

@SPI
public interface HelloServiceForDubbo {
    String sayHello(String name);
}

impl項目創建
(1)導入 api項目 的依賴
(2)建立實現類,為了表達支持多個實現的目的,這里分別創建兩個實現。分別為 HumanHelloService 和 DogHelloService 。
(3)SPI進行聲明操作,在 resources 目錄下創建目錄 META-INF/dubbo 目錄,在目錄下創建名稱為com.lagou.dubbo.study.spi.demo.api.HelloService的文件,文件內部配置兩個實現類名稱和對應的全限定名

main項目創建
(1)導入坐標 接口項目 和 實現類項目
(2)創建 DubboSpiMain 和原先調用的方式不太相同, dubbo 有對其進行自我重新實現 需要借助 ExtensionLoader,創建新的運行項目。這里 demo 中的示例和 java中 的功能相同,查詢出所有的已知實現,并且調用

package com.lagou.test;

import com.lagou.service.HelloServiceForDubbo;
import org.apache.dubbo.common.URL;
import org.apache.dubbo.common.extension.ExtensionLoader;

import java.util.Set;

public class DubboSpiMain {

    public static void main(String[] args) {
        // 獲取拓展加載器
        final ExtensionLoader<HelloServiceForDubbo> extensionLoader =
                ExtensionLoader.getExtensionLoader(HelloServiceForDubbo.class);
        // 遍歷所有支持的拓展點 META-INF/dubbo
        final Set<String> extensionNames = extensionLoader.getSupportedExtensions();
        for (String extensionName : extensionNames) {
            // 遍歷拓展點對象
            final HelloServiceForDubbo helloServiceForDubbo = extensionLoader.getExtension(extensionName);
            System.out.println(helloServiceForDubbo.sayHello(""));
    }
}

(3)dubbo自己做SPI的目的

  1. JDK 標準的 SPI 會一次性實例化擴展點所有實現,如果有擴展實現初始化很耗時,但如果沒用上也加載,會很浪費資源
  2. 如果有擴展點加載失敗,則所有擴展點無法使用
  3. 提供了對擴展點包裝的功能(Adaptive),并且還支持通過set的方式對其他的擴展點進行注入

Dubbo SPI中的Adaptive功能

Dubbo 中的 Adaptive功能,主要解決的問題是如何 動態的選擇具體的擴展點。通過 getAdaptiveExtension方法統一對指定接口對應的所有擴展點進行封裝,通過URL的方式對擴展點來進行動態選擇。 (dubbo中所有的注冊信息都是通過URL的形式進行處理的。) 這里同樣采用相同的方式進行實現。

api 中的 HelloService 擴展如下方法, 與原先類似,在sayHello中增加 Adaptive 注解,并且在參數中提供URL參數.注意這里的URL參數的類為 org.apache.dubbo.common.URL。其中@SPI 可以指定一個字符串參數,用于指明該SPI的默認實現。
(2)創建實現類與上面 Service 實現類代碼相似,只需增加URL形參即可
(3)編寫 DubboAdaptiveMain
最后在獲取的時候方式有所改變,需要傳入URL參數,并且在參數中指定具體的實現類參數如:

public class DubboAdaptiveMain {
  public static void main(String[] args) {
    URL url = URL.valueOf("test://localhost/hello?hello.service=dog");
    final HelloService adaptiveExtension = ExtensionLoader.getExtensionLoader(HelloService.class).getAdaptiveExtension();
    adaptiveExtension.sayHello(url);
  }
}

注意:

  • 因為在這里只是臨時測試,所以為了保證 URL 規范,前面的信息均為測試值即可,關鍵的點在于 hello.service 參數,這個參數的值指定的就是具體的實現方式。關于為什么叫 hello.service 是因為這個接口的名稱,其中后面的大寫部分被dubbo自動轉碼為 . 分割。
HelloService.class -> hello.service
  • 通過 getAdaptiveExtension 來提供一個統一的類來對所有的擴展點提供支持(底層對所有的擴展點進行封裝)。
  • 調用時通過參數中增加 URL 對象來實現動態的擴展點使用。
  • 如果URL沒有提供該參數,則該方法會使用默認在 SPI 注解中聲明的實現。
package com.lagou.service;

import org.apache.dubbo.common.URL;
import org.apache.dubbo.common.extension.Adaptive;
import org.apache.dubbo.common.extension.SPI;

/**
 * 指定默認匹配 human
 */
@SPI("human")
public interface HelloServiceForDubbo {
    /**
     * 這里加上 Adaptive 注解,使得有動態的選擇能力
     * @param url
     * @return
     */
    @Adaptive
    String sayHello(URL url);

}

Dubbo調用時攔截操作

與很多框架一樣,Dubbo 也存在攔截(過濾)機制,可以通過該機制在執行目標程序前后執行我們指定的代碼。
Dubbo 的 Filter 機制,是專門為服務提供方和服務消費方調用過程進行攔截設計的,每次遠程方法執行,該攔截都會被執行。這樣就為開發者提供了非常方便的擴展性,比如為 dubbo 接口實現ip白名單功能、監控功能 、日志記錄等。

步驟如下:
(1)實現 org.apache.dubbo.rpc.Filter 接口
(2)使用 org.apache.dubbo.common.extension.Activate 接口進行對類進行注冊,通過group 可以指定生產端、消費端
(3)計算方法運行時間的代碼實現
(4)在 META-INF.dubbo 中新建 org.apache.dubbo.rpc.Filter 文件,并將當前類的全名寫入
注意:一般類似于這樣的功能都是單獨開發依賴的,所以再使用方的項目中只需要引入依賴,在調用接口時,該方法便會自動攔截

異步調用

Dubbo不只提供了堵塞式的的同步調用,同時提供了異步調用的方式。這種方式主要應用于提供者接口響應耗時明顯,消費者端可以利用調用接口的時間去做一些其他的接口調用,利用 Future 模式來異步等待和獲取結果即可。這種方式可以大大的提升消費者端的利用率。 目前這種方式可以通過XML的方式進行引入。

線程池

Dubbo已有線程池

dubbo 在使用時,都是通過創建真實的業務線程池進行操作的。目前已知的線程池模型有兩個和java中的相互對應:

  • fix: 表示創建固定大小的線程池。也是Dubbo默認的使用方式,默認創建的執行線程數為200,并且是沒有任何等待隊列的。所以再極端的情況下可能會存在問題,比如某個操作大量執行時,可能存在堵塞的情況。后面也會講相關的處理辦法。
  • cache: 創建非固定大小的線程池,當線程不足時,會自動創建新的線程。但是使用這種的時候需要注意,如果突然有高TPS的請求過來,方法沒有及時完成,則會造成大量的線程創建,對系統的CPU和負載都是壓力,執行越多反而會拖慢整個系統。

自定義線程池

在真實的使用過程中可能會因為使用 fix模式的線程池,導致具體某些業務場景因為線程池中的線程數量不足而產生錯誤,而很多業務研發是對這些無感知的,只有當出現錯誤的時候才會去查看告警或者通過客戶反饋出現嚴重的問題才去查看,結果發現是線程池滿了。所以可以在創建線程池的時,通過某些手段對這個線程池進行監控,這樣就可以進行及時的擴縮容機器或者告警。下面的這個程序就是這樣子的,會在創建線程池后進行對其監控,并且及時作出相應處理。

(1)線程池實現, 這里主要是基于對 FixedThreadPool 中的實現做擴展出線程監控的部分

package com.lagou;

import org.apache.dubbo.common.URL;
import org.apache.dubbo.common.threadpool.ThreadPool;
import org.apache.dubbo.common.threadpool.support.fixed.FixedThreadPool;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.Map;
import java.util.concurrent.*;

/**
 * @author likai
 * @date 2020/11/21
 */
public class WatchThreadPool extends FixedThreadPool implements Runnable {

    private static final Logger log = LoggerFactory.getLogger(WatchThreadPool.class);

    // 定義一個閾值

    private static final double ALARM_PERCENT = 0.80;
    private final Map<URL, ThreadPoolExecutor> THREAD_POOLS = new ConcurrentHashMap<>();

    // 每隔3秒打印線程使用情況
    public WatchThreadPool() {
        Executors.newSingleThreadScheduledExecutor()
                .scheduleWithFixedDelay(this, 1, 3, TimeUnit.SECONDS);
    }

    @Override
    public Executor getExecutor(URL url) {
        // 從父類中創建線程池
        final Executor executor = super.getExecutor(url);
        if (executor instanceof ThreadPoolExecutor) {
            THREAD_POOLS.put(url, ((ThreadPoolExecutor) executor));
        }
        return executor;
    }

    @Override
    public void run() {
        // 遍歷線程池,如果超出指定的部分,進行操作,比如接入公司的告警系統或者短信平臺
        for (Map.Entry<URL, ThreadPoolExecutor> entry : THREAD_POOLS.entrySet()) {
            final URL url = entry.getKey();
            final ThreadPoolExecutor executor = entry.getValue();
            // 當前執行中的線程數
            final int activeCount = executor.getActiveCount();
            // 總計線程數
            final int poolSize = executor.getCorePoolSize();
            double used = (double) activeCount / poolSize;
            final int usedNum = (int) (used * 100);
            log.info("線程池執行狀態:[{}/{}]:{}%", activeCount, poolSize, usedNum);
            if (used >= ALARM_PERCENT) {
                log.error("超出警戒值!host:{}, 當前已使用量:{}%, URL:{}",
                        url.getIp(), usedNum, url);
            }
        }
    }
}

(2)SPI聲明,創建文件 META-INF/dubbo/org.apache.dubbo.common.threadpool.ThreadPool

watching=包名.線程池名

(3)在服務提供方項目引入該依賴
(4)在服務提供方項目中設置使用該線程池生成器

dubbo.provider.threadpool=watching

(5)接下來需要做的就是模擬整個流程,因為該線程當前是每1秒抓一次數據,所以我們需要對該方法的提供者超過1秒的時間(比如這里用休眠 Thread.sleep ),消費者則需要啟動多個線程來并行執行,來模擬整個并發情況。
(6)在調用方則嘗試簡單通過for循環啟動多個線程來執行 查看服務提供方的監控情況

路由規則

路由是決定一次請求中需要發往目標機器的重要判斷,通過對其控制可以決定請求的目標機器。我們可以通過創建這樣的規則來決定一個請求會交給哪些服務器去處理。

路由規則快速入門

(1)提供兩個提供者(一臺本機作為提供者,一臺為其他的服務器),每個提供者會在調用時可以返回不同的信息以區分提供者。
(2)針對于消費者,我們這里通過一個死循環,每次等待用戶輸入,再進行調用,來模擬真實的請求情況。通過調用的返回值 確認具體的提供者。
(3)我們通過ipconfig來查詢到我們的IP地址,并且單獨啟動一個客戶端,來進行如下配置(這里假設我們希望隔離掉本機的請求,都發送到另外一臺機器上)。

public class DubboRouterMain {
  public static void main(String[] args) {
    RegistryFactory registryFactory =
ExtensionLoader.getExtensionLoader(RegistryFactory.class).getAdaptiveExtension();

Registry registry = registryFactory.getRegistry(URL.valueOf("zookeeper://127.0.0.1:2181"));

registry.register(URL.valueOf("condition://0.0.0.0/com.lagou.service.HelloService?category=routers&force=true&dynamic=true&rule=" + URL.encode("=> host != 你的機器ip不能是127.0.0.1")));
  }
}

(4)通過這個程序執行后,我們就通過消費端不停的發起請求,看到真實的請求都發到了除去本機以外的另外一臺機器上。

路由規則詳解

通過上面的程序,我們實際本質上就是通過在zookeeper中保存一個節點數據,來記錄路由規則。消費者會通過監聽這個服務的路徑,來感知整個服務的路由規則配置,然后進行適配。這里主要介紹路由配置的參數。具體請參考文檔, 這里只對關鍵的參數做說明。

  • route:// 表示路由規則的類型,支持條件路由規則和腳本路由規則,可擴展,必填
  • 0.0.0.0 表示對所有 IP 地址生效,如果只想對某個 IP 的生效,請填入具體 IP,必填
  • com.lagou.service.HelloService 表示只對指定服務生效,必填
  • category=routers 表示該數據為動態配置類型,必填
  • dynamic : 是否為持久數據,當指定服務重啟時是否繼續生效。必填
  • runtime : 是否在設置規則時自動緩存規則,如果設置為true則會影響部分性能。
  • rule : 是整個路由最關鍵的配置,用于配置路由規則。
    ... => ... 在這里 => 前面的就是表示消費者方的匹配規則,可以不填(代表全部)。 => 后方則必須填寫,表示當請求過來時,如果選擇提供者的配置。官方這塊兒也給出了詳細的示例,可以按照那里來講。其中使用最多的便是 host 參數。 必填

路由與上線系統結合

當公司到了一定的規模之后,一般都會有自己的上線系統,專門用于服務上線。方便后期進行維護和記錄的追查。我們去想象這樣的一個場景,一個dubbo的提供者要準備進行上線,一般都提供多臺提供者來同時在線上提供服務。這時候一個請求剛到達一個提供者,提供者卻進行了關閉操作。那么此次請求就應該認定為失敗了。所以基于這樣的場景,我們可以通過路由的規則,把預發布(灰度)的機器進行從機器列表中移除。并且等待一定的時間,讓其把現有的請求處理完成之后再進行關閉服務。同時,在啟動時,同樣需要等待一定的時間,以免因為尚未重啟結束,就已經注冊上去。等啟動到達一定時間之后,再進行開啟流量操作。

實現主體思路
1.利用zookeeper的路徑感知能力,在服務準備進行重啟之前將當前機器的IP地址和應用名寫入zookeeper。
2.服務消費者監聽該目錄,讀取其中需要進行關閉的應用名和機器IP列表并且保存到內存中。
3.當前請求過來時,判斷是否是請求該應用,如果是請求重啟應用,則將該提供者從服務列表中移除。

(1)引入 Curator 框架,用于方便操作Zookeeper
(2)編寫Zookeeper的操作類,用于方便進行zookeeper處理
(3)編寫需要進行預發布的路徑管理器,用于緩存和監聽所有的待灰度機器信息列表。
(4)編寫路由類(實現 org.apache.dubbo.rpc.cluster.Router ),主要目的在于對ReadyRestartInstances 中的數據進行處理,并且移除路由調用列表中正在重啟中的服務。
(5)由于 Router 機制比較特殊,所以需要利用一個專門的 outerFactory 來生成,原因在于并不是所有的都需要添加路由,所以需要利用 @Activate 來鎖定具體哪些服務才需要生成使用。
(6)對 RouterFactory 進行注冊,同樣放入到
META-INF/dubbo/org.apache.dubbo.rpc.cluster.RouterFactory 文件中。

restartInstances=com.lagou.router.RestartingInstanceRouterFactory

(7)將dubbo-spi-router項目引入至 consumer 項目的依賴中。
(8)這時直接啟動程序,還是利用上面中所寫好的 consumer 程序進行執行,確認各個 provider 可以正常執行。
(9)單獨寫一個 main 函數來進行將某臺實例設置為啟動中的狀態,比如這里我們認定為當前這臺機器中的 service-provider 這個提供者需要進行重啟操作。

ReadyRestartInstances.create().addRestartingInstance("service-provider", "正在重新啟動的機器IP");

(10)執行完成后,再次進行嘗試通過 consumer 進行調用,即可看到當前這臺機器沒有再發送任何請求
(11)一般情況下,當機器重啟到一定時間后,我們可以再通過 removeRestartingInstance 方法對這個機器設定為既可以繼續執行。
(12)調用完成后,我們再次通過 consumer 去調用,即可看到已經再次恢當前機器的請求參數。

第四部分: Dubbo 源碼分析

Dubbo的整體設計 服務注冊與發現的源碼分析

作業

編程題一:將Web請求 IP 透傳到 Dubbo 服務中

通過擴展Dubbo的Filter(TransportIPFilter),完成Web請求的真實IP透傳到Dubbo服務當中,并在Dubbo服務中打印請求的IP

題目要求:

  1. 構建一個Web項目(A),提供一個HTTP接口;構建2個Dubbo服務(B和C),各提供一個Dubbo接口,被Web項目調用(如下圖所示)
  1. 從 Web 項目獲取請求的IP,通過 TransportIPFilter 完成把 IP 設置到RpcContext 中
  2. 在兩個 Dubbo項目(B和C)中,從 RpcContext 獲取IP并打印到控制臺,B和C都應該正確打印IP
  3. 注意:不可在業務方法調用Dubbo接口前采用硬編碼的方式設置IP

編程題二:簡易版Dubbo方法級性能監控

在真實業務場景中,經常需要對各個業務接口的響應性能進行監控(常用指標為:TP90、TP99)

下面通過擴展 Dubbo的Filter(TPMonitorFilter),完成簡易版本 Dubbo 接口方法級性能監控,記錄下TP90、TP99請求的耗時情況

題目要求:

  1. 編寫一個Dubbo服務,提供3個方法(methodA、methodB、methodC),每方法都實現了隨機休眠0-100ms

  2. 編寫一個消費端程序,不斷調用 Dubbo 服務的3個方法(建議利用線程池進行并行調用,確保在1分鐘內可以被調用2000次以上)

  3. 利用 TPMonitorFilter 在消費端記錄每個方法的請求耗時時間(異步調用不進行記錄)

  4. 每隔 5s 打印一次最近1分鐘內每個方法的TP90、TP99的耗時情況


作業1

分析與思路

  1. dubbo-api 提供接口
public interface HelloService {

    String sayHello(String name);

}
  1. dubbo-provider 和 dubbo-provider2 提供對接口的實現。 這里以 dubbo-provider 舉例。
@Service(interfaceClass = HelloService.class,retries = 0)
@Component
public class HelloServiceImpl implements HelloService {

    @Override
    public String sayHello(String name) {
        String requestIp = RpcContext.getContext().getAttachment(Constants.REQUEST_IP);
        System.out.println("provider1: sayHello + " +requestIp);
        return "provider1: sayHello + " +requestIp;
    }
}

并在 resoruces下添加配置文件

transportIPFilter=com.lagou.filter.TransportIPFilter
  1. dubbo-consumer 使用了 過濾器進行遠程 ip 的暫存
/**
 * 需要加入 注解 或者 手動開啟,否則過濾器不生效
 */
@Component
@WebFilter(urlPatterns = "/hello")
public class IPFilter implements Filter {

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain)
            throws IOException, ServletException {
        try {
            final String remoteAddr = servletRequest.getRemoteAddr();
            System.out.println("過濾器 doFilter 方法獲得 remoteAddr = " + remoteAddr);
            IPThreadLocal.set(servletRequest.getRemoteAddr());
            filterChain.doFilter(servletRequest, servletResponse);
        } finally {
            IPThreadLocal.remove();
        }
    }
}
  1. dobbo 過濾器進行數據的取出和傳遞(TransportIPFilter完成把IP設置到 RpcContext)
/**
 * 給過濾器起一個名字
 */
@Activate
public class TransportIPFilter implements Filter {

    @Override
    public Result invoke(Invoker<?> invoker, Invocation invocation) throws RpcException {
        // 獲得 IP
        final String ip = IPThreadLocal.get();
        RpcContext.getContext().getAttachments().put(Constants.REQUEST_IP, ip);
        System.out.println("dubbo Filter 存入ip" + ip);
        return invoker.invoke(invocation);
    }
}
  1. 這其中涉及到了我寫在 dubbo-api 模塊的工具類 IPThreadLocal.
public class IPThreadLocal {

    private static final ThreadLocal<String> THREAD_LOCAL = new ThreadLocal<String>();

    public static void set(String ip) {
        THREAD_LOCAL.set(ip);
    }

    public static String get() {
        return THREAD_LOCAL.get();
    }

    public static void remove() {
        THREAD_LOCAL.remove();
    }
}
  1. 分別啟動 provider 和 customer 端,通過瀏覽器進行訪問。customer 模塊的 controller 如下。
@RestController
public class HelloController {

    @Reference(loadbalance = "roundrobin")
    private HelloService dubboTestApi;
    
    @GetMapping("/hello")
    public String hello() {
        return this.dubboTestApi.sayHello("dubbo");
    }
}

作業2 思路

  1. dubbo-api 模塊提供接口, dubbo-provider 提供實現, dubbo-consumer 模塊在消費的時候傳參毫秒數,用于控制休眠一定的毫秒數.

dubbo-api 用于提供接口

/**
 * 定義一個基礎接口
 */
public interface HelloService {

    String methodA(String name, int mills);

    String methodB(String name, int mills);

    String methodC(String name, int mills);

}

provider 對其進行實現

public class HelloServiceImpl implements HelloService {

    @Override
    public String methodA(String name, int mills) {
        try {
            TimeUnit.MILLISECONDS.sleep(mills);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("method A : " + name);
        return "method A : " + name;
    }

    @Override
    public String methodB(String name, int mills) {
        try {
            TimeUnit.MILLISECONDS.sleep(mills);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("method B : " + name);
        return "method B : " + name;

    }

    @Override
    public String methodC(String name, int mills) {
        try {
            TimeUnit.MILLISECONDS.sleep(mills);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("method C : " + name);
        return "method C : " + name;
    }
}
  1. dubbo-consumer 模塊中利用線程池進行并行調用 dubbo 的方法
ExecutorService executorService = Executors.newFixedThreadPool(3);
        // 確保在1分鐘內可以被調用 2000 次以上
        HelloService helloService = (HelloService) context.getBean("helloService");
        for (int i = 0; i < 2300; i++) {
            executorService.execute(new HelloServiceThread(helloService));
        }

這里涉及到真正干活的 HelloServiceThread 線程類

class HelloServiceThread extends Thread {

    private final HelloService helloService;

    HelloServiceThread(HelloService helloService) {
        super();
        this.helloService = helloService;
    }

    @Override
    public void run() {
        try {
            TimeUnit.MILLISECONDS.sleep(30);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        final int i = ThreadLocalRandom.current().nextInt(3);
        String result = null;
        switch (i) {
            case 0:
                result = helloService.methodA("", ThreadLocalRandom.current().nextInt(100));
                break;
            case 1:
                result = helloService.methodB("", ThreadLocalRandom.current().nextInt(100));
                break;
            case 2:
                result = helloService.methodC("", ThreadLocalRandom.current().nextInt(100));
                break;
            default:
                break;

        }
        // 調用結果
        System.out.println(Thread.currentThread().getName() + " result = " + Objects.toString(result, ""));
    }
}

XmlConsumer 用于不停的進行消費,確保在1分鐘內可以被調用 2000 次以上,這里用到了 線程池。

public class XmlConsumer {

    public static void main(String[] args) throws Exception {
        ClassPathXmlApplicationContext context = new ClassPathXmlApplicationContext(new String[]{"classpath:dubbo-demo-consumer.xml"});

        // 起始時間
        long startMills = Instant.now().toEpochMilli();
        ExecutorService executorService = Executors.newFixedThreadPool(3);
        // 確保在1分鐘內可以被調用 2000 次以上
        HelloService helloService = (HelloService) context.getBean("helloService");
        for (int i = 0; i < 2300; i++) {
            executorService.execute(new HelloServiceThread(helloService));
        }
        executorService.shutdown();
        executorService.awaitTermination(3, TimeUnit.MINUTES);

        // 終止時間
        long endMills = Instant.now().toEpochMilli();
        System.out.println("-----總耗時" + (endMills - startMills) + "-----");

    }
}
  1. spi-filter模塊下的 自定義 dubbo 過濾器 TPMonitorFilter , 分別進行方法的請求的統計 和 TP90 和 99的結果輸出。

以下是 TPMonitorFilter 的代碼片段,這里可以拿到方法名和耗時時間。

@Override
    public Result invoke(Invoker<?> invoker, Invocation invocation) throws RpcException {
        long epochMilli = Instant.now().toEpochMilli();
        Result invoke = invoker.invoke(invocation);

        final LocalDateTime now = LocalDateTime.now();
        final Instant endInstant = now.atZone(ZoneId.systemDefault()).toInstant();
        // 方法耗時
        int costMills = (int) (endInstant.toEpochMilli() - epochMilli);
        // 方法名稱
        String methodName = invocation.getMethodName();
        ...
        ...

對方法耗時進行統計

/**
         * recordArray 共計 60 等份
         *
         *      調用時間 1ms 2ms 3ms 4ms 5ms
         * 方法methodA   1   2   3
         * 方法methodB   3   3   1       5
         * 方法methodC   1       8   3
         */
        // localDateTime 對象包含了時分秒信息,取出秒 作為下標
        int index = now.getSecond();
        // 這里肯定不為 null, 若時間比對成功則則 進行 put
        if (recordArray[index].isDateTimeEquals(now)) {
            Integer value = recordArray[index].get(methodName, costMills);
            if (value != null) {
                recordArray[index].put(methodName, costMills, value + 1);
            } else {
                recordArray[index].put(methodName, costMills, 1);
            }
        } else {
            // 否則表示鐘或小時至少有一個不存在則清空該 table 后重新put
            recordArray[index].clear();
            recordArray[index].setDateTime(now);
            // 秒不相等,判斷分是否相等。 若相等。
            // 0~1 1_2 58~59 59~60(0)  一看到1分0秒,就表示完結了。   3分1秒    4分0秒       9分3秒 ~ 10分2秒
            // 0             59                                   181~182  240~241      543~544 ~ 602~603
            recordArray[index].put(methodName, costMills, 1);
        }

這里用到了自定義數據類型 RecordTable

/**
 * 一種類似表格的數據接口,類似 Map<A, Map<B,C>>類型。
 */
class RecordTable {

    /**
     * 總記錄數
     */
    private static int count = 0;

    /**
     * 內部使用線程安全的 Google guava 包下的 Table 類型
     */
    private final Table<String, Integer, Integer> table = Tables.synchronizedTable(HashBasedTable.create());

    /**
     * 包含時間信息,記錄為 1 秒
     */
    private LocalDateTime dateTime;

    public static RecordTable[] newArray() {
        count = 0;

        // 共計 60 等份
        int length = 60;
        final RecordTable[] array = new RecordTable[length];
        final LocalDateTime now = LocalDateTime.now();
        for (int i = 0; i < 60; i++) {
            array[i] = new RecordTable();
            array[i].setDateTime(now);
        };
        return array;
    }

    public RecordTable() {}

    public Integer get(@Nullable Object rowKey, @Nullable Object columnKey) {
        return table.get(rowKey, columnKey);
    }

    public Integer put(String rowKey, Integer columnKey, Integer value) {
        count++;
        System.out.println("put 的次數 =" +  count);
        return table.put(rowKey, columnKey, value);
    }

    /**
     * 若 日期 和 時分秒 相等則認為是相等
     *
     * @param obj
     * @return
     */
    public boolean isDateTimeEquals(Object obj) {
        if (obj instanceof LocalDateTime) {
            LocalDateTime other = (LocalDateTime) obj;
            if (this.dateTime != null && this.dateTime.toLocalDate().equals(other.toLocalDate())) {
                final LocalTime localTime = this.dateTime.toLocalTime();
                final LocalTime otherLocalTime = other.toLocalTime();
                return localTime.getHour() == otherLocalTime.getHour()
                        && localTime.getMinute() == otherLocalTime.getMinute()
                        && localTime.getSecond() == otherLocalTime.getSecond();
            }
            return false;
        }
        return false;
    }

    public void clear() {
        table.clear();
    }

    public Map<String, Map<Integer, Integer>> rowMap() {
        return table.rowMap();
    }

    public boolean isEmpty(){
        return table.isEmpty();
    }

    public LocalDateTime getDateTime() {
        return dateTime;
    }

    public void setDateTime(LocalDateTime dateTime) {
        this.dateTime = dateTime;
    }
}

對請求耗時進行處理,打印 TP90 和 TP99信息。

    @Override
    public void run() {
        // 每隔 5s 打印一次最近1分鐘內每個方法的TP90、TP99的耗時情況
        System.out.println("----------每5s一次的任務開始----------");

        // 總行數(最大值為 length)
        int totalColumnSize = 0;
        // 總記錄數(執行方法的總次數)
        int totalSize = 0;
        int aTotalSize = 0;
        int bTotalSize = 0;
        int cTotalSize = 0;

        List<Integer> listA = new ArrayList<>(800);
        List<Integer> listB = new ArrayList<>(800);
        List<Integer> listC = new ArrayList<>(800);

        for (int i = 0; i < recordArray.length; i++) {
            if (recordArray[i] == null || recordArray[i].isEmpty()) {
                continue;
            }

            // 行記錄數 = (方法A+B+C調用的總次數)
            int size = 0;
            // 每行方法A的調用次數
            int methodAtotalSize = 0;
            int methodBtotalSize = 0;
            int methodCtotalSize = 0;

            final Map<String, Map<Integer, Integer>> rowMap = recordArray[i].rowMap();
            for (Map.Entry<String, Map<Integer, Integer>> entry : rowMap.entrySet()) {
                final String methodName = entry.getKey();

                final Map<Integer, Integer> value = entry.getValue();
                if (value != null) {
                    for (Map.Entry<Integer, Integer> valueEntry : value.entrySet()) {
                        // 消耗毫秒數
                        Integer costMills = valueEntry.getKey();
                        // 消費次數
                        final Integer count = valueEntry.getValue();
                        size += count;

                        if ("methodA".equalsIgnoreCase(methodName)) {
                            methodAtotalSize += count;
                            for (int j = 0; j < count; j++) {
                                listA.add(costMills);
                            }
                        }
                        else if ("methodB".equalsIgnoreCase(methodName)) {
                            methodBtotalSize += count;
                            for (int j = 0; j < count; j++) {
                                listB.add(costMills);
                            }
                        }
                        else if ("methodC".equalsIgnoreCase(methodName)) {
                            methodCtotalSize += count;
                            for (int j = 0; j < count; j++) {
                                listC.add(costMills);
                            }
                        }
                    }

                    if ("methodA".equalsIgnoreCase(methodName)) {
                        aTotalSize += methodAtotalSize;
                    } else if ("methodB".equalsIgnoreCase(methodName)) {
                        bTotalSize += methodBtotalSize;
                    } else if ("methodC".equalsIgnoreCase(methodName)) {
                        cTotalSize += methodCtotalSize;
                    }
                }
            }
            totalColumnSize++;
            totalSize += size;

            System.out.println("行記錄數" + size
                    + "其中A記錄數 = " + methodAtotalSize
                    + ", B記錄數 = " + methodBtotalSize
                    + ", C記錄數 = " + methodCtotalSize
                    + ", 下標為" + i
                    + ", 時間為: " + recordArray[i].getDateTime()
                    + ", " + recordArray[i].rowMap());
        }
        System.out.println("總記錄數 = " + totalSize + "("+ aTotalSize +", " + bTotalSize  +", " + cTotalSize +")"
                + ", 總行數 = " + totalColumnSize + "\n------每5s一次的任務結束-----");

        // A的 top 90
        int aTop90 = (int) Math.ceil(aTotalSize * 0.9D) -1;
        int aTop99 = (int) Math.ceil(aTotalSize * 0.99D) - 1;
        // 升序取第 %90 位 或 逆序取第 %10
        System.out.println("top A T90下標=" + aTop90 + ", T99 下標=" + aTop99);

        Object[] a = listA.toArray();
        Arrays.sort(a, (Comparator) new TPComparator());
        System.out.println(Arrays.toString(a));
        System.out.println("top A T90 =" + a[aTop90] + ", T99 =" + a[aTop99] + "\n---");

        // B的 top 90
        int bTop90 = (int) Math.ceil(bTotalSize * 90D / 100) - 1;
        int bTop99 = (int) Math.ceil(bTotalSize * 99D / 100) - 1;
        System.out.println("top B T90下標=" + bTop90 + ", T99下標=" + bTop99);
        Object[] b = listB.toArray();
        Arrays.sort(b, (Comparator) new TPComparator());
        System.out.println(Arrays.toString(b));
        System.out.println("top B T90 =" + b[bTop90] + ", T99 =" + b[bTop99] + "\n---");

        // C的 top 90
        int cTop90 = (int) Math.ceil(cTotalSize * 90D / 100) - 1;
        int cTop99 = (int) Math.ceil(cTotalSize * 99D / 100) - 1;
        System.out.println("top C T90下標=" + cTop90 + ", T99下標=" + cTop99);
        Object[] c = listC.toArray();
        Arrays.sort(c, (Comparator) new TPComparator());
        System.out.println(Arrays.toString(c));
        System.out.println("top C T90 =" + c[cTop90] + ", T99 =" + c[cTop99] + "\n---");
    }
}

TP 指標到底是啥?
TP 指標: TP50:指在一個時間段內(如5分鐘),統計該方法每次調用所消耗的時間,并將這些時間按從小到大的順序進行排序,取第 50% 的那個值作為 TP50 值;配置此監控指標對應的報警閥值后,需要保證在這個時間段內該方法所有調用的消耗時間至少有 50% 的值要小于此閥值,否則系統將會報警。TP90,TP99,TP999 與 TP50值計算方式一致,它們分別代表著對方法的不同性能要求,TP50相對較低,TP90則比較高,TP99,TP999 則對方法性能要求很高

作者:Edison Tian
鏈接:https://www.zhihu.com/question/41110088/answer/199437630
來源:知乎
著作權歸作者所有。商業轉載請聯系作者獲得授權,非商業轉載請注明出處。

參考

dubbo入門和springboot集成dubbo小例子 - 會當臨絕頂forever - 博客園
https://www.cnblogs.com/baijinqiang/p/10848259.html

dubbo-spring-boot-project/dubbo-spring-boot-samples at master · apache/dubbo-spring-boot-project
https://github.com/apache/dubbo-spring-boot-project/tree/master/dubbo-spring-boot-samples

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