spark應用執行流程

1.Spark的應用執行機制

用戶提交一個Application到Spark集群執行的基礎流程如下圖所示:


image.png

(1)Driver進程啟動,構建Spark Application的運行環境(啟動SparkContext),SparkContext向資源管理器(可以是Standalone、Mesos或YARN)注冊并申請運行Executor資源;

(2)資源管理器接到SparkContext申請后,根據與woker之間心跳信息,決定在哪些worker上啟動Executor;

(3)Worker的Executor啟動后,會向SparkContext注冊。與此同時,SparkContext解析Application,劃分job構建成DAG圖,將DAG圖分解成Stage,并把Taskset發送給Task Scheduler。

(4)Task Scheduler將Task發放給Executor運行同時SparkContext將應用程序代碼發放給Executor。

(5)Task在Executor上運行,運行完畢釋放所有資源。

2.Spark 作業提交運行詳細流程

image.png
  1. 通過SparkSubmit提交job后,Client就開始構建 spark context,即 application 的運行環境(使用本地的Client類的main函數來創建spark context并初始化它)

  2. client模式下提交任務,Driver在客戶端本地運行;cluster模式下提交任務的時候,Driver是運行在集群上

  3. SparkContext連接到ClusterManager(Master),向資源管理器注冊并申請運行Executor的資源(內核和內存)

  4. Master根據SparkContext提出的申請,根據worker的心跳報告,來決定到底在那個worker上啟動executor

  5. Worker節點收到請求后會啟動executor

  6. executor向SparkContext注冊,這樣driver就知道哪些executor運行該應用

  7. SparkContext將Application代碼發送給executor(如果是standalone模式就是StandaloneExecutorBackend)

  8. 同時SparkContext解析Application代碼,構建DAG圖,提交給DAGScheduler進行分解成stage,stage被發送到TaskScheduler。

  9. TaskScheduler負責將Task分配到相應的worker上,最后提交給executor執行

  10. executor會建立Executor線程池,開始執行Task,并向SparkContext匯報,直到所有的task執行完成

  11. 所有Task完成后,SparkContext向Master注銷

3.Job的調度執行流程

應用程序是一系列RDD的操作,Driver解析代碼時遇到Action算子,就會觸發Job的提交(實際底層實現上,Action算子最后調用rubJob函數提交Job給spark。其他操作都是生成對應RDD的關系鏈,job提交是隱式完成的,無需用戶顯示的提交)。

Job的解析、調度執行整個流程可以劃分兩個階段:

  1. Stage劃分與提交

    (1)Job按照RDD之間的依賴關系是否為寬依賴,由DAGScheduler從RDD毅力鏈的末端開始觸發,遍歷RDD依賴鏈劃分為一個或多個具有依賴關系個Stage;

    (2)第一步劃分出Stage后,DAGScheduler生成job實例,從末端Stage-FinalStage開始,按照一定規則遞歸調度Stage,即交給TaskScheduler將每個stage轉化為一個TaskSet;

  2. Task調度與執行:由TaskScheduler負責將TaskSet中的Task調度到Worker節點的Executor上執行。

3.1Job到DAGScheduler過程

  1. 首先在SparkContext初始化的時候會創建DAGScheduler,這個DAGScheduelr每個應用只有一個。然后DAGScheduler創建的時候,會初始化一個事件捕獲對象,并且開啟監聽。之后我們的任務都會發給這個事件監聽器,它會按照任務的類型創建不同的任務。

  2. 再從客戶端程序方面說,當我們調用action操作的時候,就會觸發runjob,它內部其實就是向前面的那個事件監聽器提交一個任務。

  3. 最后事件監聽器調用DAGScheduler的handleJobSubmitted真正的處理

  4. 處理的時候,會先創建一個resultStage,每個job只有一個resultstage,其余的都是shufflestage.然后根據rdd的依賴關系,按照廣度優先的思想遍歷rdd,遇到shufflerdd就創建一個新的stage。

  5. 形成DAG圖后,遍歷等待執行的stage列表,如果這個stage所依賴的父stage執行完了,它就可以執行了;否則還需要繼續等待。

  6. 最終stage會以taskset的形式,提交給TaskScheduler,然后最后提交給excutor。


    image.png

3.1.1Job提交

當我們調用action操作的時候,就會觸發runjob,它內部其實就是向前面的那個事件監聽器提交一個任務,以 RDD#collect()算子介紹:

collect()算子提交任務:

def collect(): Array[T] = withScope {
    val results = sc.runJob(this, (iter: Iterator[T]) => iter.toArray)
    Array.concat(results: _*)
  }

SparkContext#runJob

def runJob[T, U: ClassTag](
      rdd: RDD[T],
      func: (TaskContext, Iterator[T]) => U,
      partitions: Seq[Int],
      resultHandler: (Int, U) => Unit): Unit = {
    ...
    dagScheduler.runJob(rdd, cleanedFunc, partitions, callSite, resultHandler, localProperties.get)
    ...
  }

在dagScheduler.runJob-》dagScheduler.submitJob方法中,會向eventProcessLoop發送一個‘JobSubmitted’-任務提交事件;

def runJob[T, U](
     rdd: RDD[T],
     func: (TaskContext, Iterator[T]) => U,
     partitions: Seq[Int],
     callSite: CallSite,
     resultHandler: (Int, U) => Unit,
     properties: Properties): Unit = {
   val start = System.nanoTime
   // 調用 submitJob 方法
   val waiter = submitJob(rdd, func, partitions, callSite, resultHandler, properties)
}
//
def submitJob[T, U](..){
    ...
     eventProcessLoop.post(JobSubmitted(
       jobId, rdd, func2, partitions.toArray, callSite, waiter,
       SerializationUtils.clone(properties)))
   }

事件隊列的處理最后會走到 DAGSchedulerEventProcessLoop 的 onReceive 的回調方法里面去。

/**
   * The main event loop of the DAG scheduler.
   */
  override def onReceive(event: DAGSchedulerEvent): Unit = {
    val timerContext = timer.time()
    try {
      // 調用 doOnReceive 方法
      doOnReceive(event)
    } finally {
      timerContext.stop()
    }
  }

后面會去調用 doOnReceive 方法,根據 event 進行模式匹配,匹配到 JobSubmitted 的 event 后實際上是去調用 DAGScheduler 的 handleJobSubmitted 這個方法

private def doOnReceive(event: DAGSchedulerEvent): Unit = event match {
    // 模式匹配
    case JobSubmitted(jobId, rdd, func, partitions, callSite, listener, properties) =>
      // 調用 handleJobSubmitted 方法
      dagScheduler.handleJobSubmitted(jobId, rdd, func, partitions, callSite, listener, properties)

3.1.2Job的劃分和調度

handleJobSubmitted主要完成下面的工作:

1.使用 觸發 job 的最后一個 rdd來創建 finalStage;注: Stage 是一個抽象類,一共有兩個實現,一個是 ResultStage,是用 action 中的函數計算結果的 stage;另一個是 ShuffleMapStage,是為 shuffle 準備數據的 stage。

2.構造一個 Job 對象,將上面創建的 finalStage 封裝進去,這個 Job 的最后一個 stage 也就是這個 finalStage;

3.將 Job 的相關信息保存到內存的數據結構中;

4.調用 submitStage 方法提交 finalStage。

構造finalStage和Job實例
private[scheduler] def handleJobSubmitted(jobId: Int,
      finalRDD: RDD[_],
      func: (TaskContext, Iterator[_]) => _,
      partitions: Array[Int],
      callSite: CallSite,
      listener: JobListener,
      properties: Properties) {
    var finalStage: ResultStage = null
    try {
      // 使用觸發 job 的最后一個 RDD 創建一個 ResultStage
      finalStage = createResultStage(finalRDD, func, partitions, jobId, callSite)
    } catch {
      case e: Exception =>
        logWarning("Creating new stage failed due to exception - job: " + jobId, e)
        listener.jobFailed(e)
        return
    }
    // 使用前面創建好的 ResultStage 去創建一個 job
    // 這個 job 的最后一個 stage 就是 finalStage
    val job = new ActiveJob(jobId, finalStage, callSite, listener, properties)
    clearCacheLocs()
    // 將 job 的相關信息存儲到內存中
    val jobSubmissionTime = clock.getTimeMillis()
    jobIdToActiveJob(jobId) = job
    activeJobs += job
    finalStage.setActiveJob(job)
    val stageIds = jobIdToStageIds(jobId).toArray
    val stageInfos = stageIds.flatMap(id => stageIdToStage.get(id).map(_.latestInfo))
    listenerBus.post(
      SparkListenerJobStart(job.jobId, jobSubmissionTime, stageInfos, properties))
    // 提交 finalStage
    submitStage(finalStage)
  }
3.1.2.1DAG劃分,并調度Stage

下面就會走進 submitStage 方法,這個方法是用來提交 stage 的,具體做了這些操作:

1,首先會驗證 stage 對應的 job id 進行校驗,存在才會繼續執行;

2,在提交這個 stage 之前會判斷當前 stage 的狀態。

如果是 running、waiting、failed 的話就不做任何操作。

如果不是這三個狀態則會根據當前 stage 去往前推前面的 stage,如果能找到前面的 stage 則繼續遞歸調用 submitStage 方法,直到當前 stage 找不到前面的 stage 為止,這時候的 stage 就相當于當前 job 的第一個 stage,然后回去調用 submitMissingTasks 方法去分配 task。

private def submitStage(stage: Stage) {
    val jobId = activeJobForStage(stage)
    // 看看當前的 job 是否存在
    if (jobId.isDefined) {
      logDebug("submitStage(" + stage + ")")
       // 判斷當前 stage 的狀態
      if (!waitingStages(stage) && !runningStages(stage) && !failedStages(stage)) {
        // 根據當前的 stage 去推倒前面的 stage
        val missing = getMissingParentStages(stage).sortBy(_.id)
        logDebug("missing: " + missing)
        // 如果前面已經沒有 stage 了,那么久將當前 stage 去執行 submitMissingTasks 方法
        // 如果前面還有 stage 的話那么遞歸調用 submitStage
        if (missing.isEmpty) {
          logInfo("Submitting " + stage + " (" + stage.rdd + "), which has no missing parents")
          submitMissingTasks(stage, jobId.get)
        } else {
          for (parent <- missing) {
            submitStage(parent)
          }
          // 將當前 stage 加入等待隊列
          waitingStages += stage
        }
      }
    } else {
      // abortStage 終止提交當前 stage
      abortStage(stage, "No active job for stage " + stage.id, None)
    }
  }
3.1.2.2劃分stage

getMissingParentStages 這個劃分算法做了哪些操作:

1.創建 missing 和 visited 兩個 HashSet,分別用來存儲根據當前 stage 向前找到的所有 stage 數據和已經調用過 visit 方法的 RDD;

2.創建一個存放 RDD 的棧,然后將傳進來的 stage 中的 rdd 也就是 finalStage 中的那個 job 觸發的最后一個 RDD 放入棧中;

3.然后將棧中的 RDD 拿出來調用 visit 方法,這個 visit 方法內部會根據當前 RDD 的依賴鏈逐個遍歷所有 RDD,并且會根據相鄰兩個 RDD 的依賴關系來決定下面的操作:

如果是寬依賴,即 ShuffleDependency ,那么會調用 getOrCreateShuffleMapStage 創建一個新的 stage,默認每個 job 的最后一個 stage 是 ResultStage,剩余的 job 中的其它 stage 均為 ShuffleMapStage。然后會將創建的這個 stage 加入前面創建的 missing 的 HashSet 中;

如果是窄依賴,即 NarrowDependency,那么會將該 RDD 加入到前面創建的 RDD 棧中,繼續遍歷調用 visit 方法。

直到所有的 RDD 都遍歷結束后返回前面創建的 missing 的集合。

private def getMissingParentStages(stage: Stage): List[Stage] = {
    // 存放下面找到的所有 stage
    val missing = new HashSet[Stage]
    // 存放已經遍歷過的 rdd
    val visited = new HashSet[RDD[_]]
    // We are manually maintaining a stack here to prevent StackOverflowError
    // caused by recursively visiting
    // 創建一個維護 RDD 的棧
    val waitingForVisit = new Stack[RDD[_]]
    // visit 方法
    def visit(rdd: RDD[_]) {
      // 判斷當前 rdd 是否 visit 過
      if (!visited(rdd)) {
        visited += rdd
        val rddHasUncachedPartitions = getCacheLocs(rdd).contains(Nil)
        if (rddHasUncachedPartitions) {
          // 遍歷當前 RDD 的依賴鏈
          for (dep <- rdd.dependencies) {
            dep match {
              // 如果是寬依賴
              case shufDep: ShuffleDependency[_, _, _] =>
                // 創建 ShuffleMapStage 
                val mapStage = getOrCreateShuffleMapStage(shufDep, stage.firstJobId)
                if (!mapStage.isAvailable) {
                  // 加入 missing 集合
                  missing += mapStage
                }
              // 如果是窄依賴
              case narrowDep: NarrowDependency[_] =>
                // 加入等待 visit 的集合中,準備下一次遍歷
                waitingForVisit.push(narrowDep.rdd)
            }
          }
        }
      }
    }
    // 將傳入的 stage 中的 rdd 拿出來壓入 waitingForVisit 的棧中
    waitingForVisit.push(stage.rdd)
    // 遍歷棧里的所有 RDD 
    while (waitingForVisit.nonEmpty) {
      // 調用 visit 方法
      visit(waitingForVisit.pop())
    }
    // 返回 missing 這個 stage 集合
    missing.toList
  }
3.1.2.3為創建的task分配最佳位置

submitMissingTasks 方法中做了這些事:

1.拿到 stage 中沒有計算的 partition;

2.獲取 task 對應的 partition 的最佳位置,使用最佳位置算法;

3.獲取 taskBinary,將 stage 的 RDD 和 ShuffleDependency(或 func)廣播到 Executor;

4.為 stage 創建 task和taskSet(當tasks長度大于0)

5.提交taskSet給TaskScheduler

submitMissingTasks 主要為task分配最佳位置計算-生成taskId和最佳partition的映射關系;

3.2提交Stage給TaskScheduler完成任務集的調度

前面已經分析到了 DAGScheduler 對 stage 劃分,并對 Task 的最佳位置進行計算之后,通過調用 taskScheduler 的 submitTasks 方法,將每個 stage 的 taskSet 進行提交。

在 taskScheduler 的 submitTasks 方法中會為每個 taskSet 創建一個 TaskSetManager,用于管理 taskSet。然后向調度池中添加該 TaskSetManager,最后會調用 backend.reviveOffers() 方法為 task 分配資源。

TaskScheduler維護task和executor對應關系,executor和物理資源對應關系,在排隊的task和正在跑的task。

3.2.1包裝taskSet,為task分配資源

TaskScheduler唯一實現類-TaskSchedulerImpl的submitTasks邏輯

override def submitTasks(taskSet: TaskSet) {
    //獲取 taskSet 中的 task
    val tasks = taskSet.tasks
    logInfo("Adding task set " + taskSet.id + " with " + tasks.length + " tasks")
    this.synchronized {
      // 為每個 taskSet 創建一個 TaskSetManager
      val manager = createTaskSetManager(taskSet, maxTaskFailures)
      // 拿到 stage 的 id
      val stage = taskSet.stageId
      // 創建一個 HashMap ,用來存儲 stage 對應的 TaskSetManager
      val stageTaskSets =
        taskSetsByStageIdAndAttempt.getOrElseUpdate(stage, new HashMap[Int, TaskSetManager])
      // 將上面創建的 taskSetManager 存入 map 中
      stageTaskSets(taskSet.stageAttemptId) = manager
      val conflictingTaskSet = stageTaskSets.exists { case (_, ts) =>
        ts.taskSet != taskSet && !ts.isZombie
      }
      if (conflictingTaskSet) {
        throw new IllegalStateException(s"more than one active taskSet for stage $stage:" +
          s" ${stageTaskSets.toSeq.map{_._2.taskSet.id}.mkString(",")}")
      }
      // 向調度池中添加剛才創建的 TaskSetManager
      schedulableBuilder.addTaskSetManager(manager, manager.taskSet.properties)

      // 判斷程序是否為 local 模式,并且 TaskSchedulerImpl 沒有收到 Task
      if (!isLocal && !hasReceivedTask) {
        // 創建一個定時器,通過指定時間檢查 TaskSchedulerImpl 的饑餓情況
        starvationTimer.scheduleAtFixedRate(new TimerTask() {
          override def run() {
            // 如果 TaskSchedulerImpl 已經安排執行了 Task,則取消定時器
            if (!hasLaunchedTask) {
              logWarning("Initial job has not accepted any resources; " +
                "check your cluster UI to ensure that workers are registered " +
                "and have sufficient resources")
            } else {
              this.cancel()
            }
          }
        }, STARVATION_TIMEOUT_MS, STARVATION_TIMEOUT_MS)
      }
      // 標記已經接收到 Task
      hasReceivedTask = true
    }
    // 給 Task 分配資源
    backend.reviveOffers()
  }

下面主要看 backend.reviveOffers() 這個方法,在提交模式是 standalone 模式下,實際上是調用 StandaloneSchedulerBackend 的 reviveOffers 方法,實則調用的是其父類 CoarseGrainedSchedulerBackend 的 reviveOffers 方法,這個方法是向 driverEndpoint 發送一個 ReviveOffers 消息。

代碼塊

override def reviveOffers() {
    // 向 driverEndpoint 發送 ReviveOffers 消息
    driverEndpoint.send(ReviveOffers)
  }

DriverEndpoint 收到信息后會調用 makeOffers 方法:

case ReviveOffers =>
        makeOffers()

makeOffers 方法內部會將 application 所有可用的 executor 封裝成一個 workOffers,每個 workOffers 內部封裝了每個 executor 的資源數量。

然后調用 taskScheduler 的 resourceOffers 從上面封裝的 workOffers 信息為每個 task 分配合適的 executor。

最后調用 launchTasks 啟動 task。

private def makeOffers() {
      // 過濾出可用的 executor
      val activeExecutors = executorDataMap.filterKeys(executorIsAlive)
      // 將這些 executor 封裝成 workOffers
      val workOffers = activeExecutors.map { case (id, executorData) =>
        new WorkerOffer(id, executorData.executorHost, executorData.freeCores)
      }.toIndexedSeq
      // 給每個 task 分配 executor,然后調用 launchTasks 啟動這些 task
      launchTasks(scheduler.resourceOffers(workOffers))
    }

下面看一下 launchTasks 這個方法。

這個方法主要做了這些操作:

1.遍歷每個 task,然后將每個 task 信息序列化。

2.判斷序列化后的 task 信息,如果大于 rpc 發送消息的最大值,則停止,建議調整 rpc 的 maxRpcMessageSize,如果小于 rpc 發送消息的最大值,則找到 task 對應的 executor,然后更新該 executor 對應的一些內存資源信息。

3.向 executor 發送 LaunchTask 消息。

private def launchTasks(tasks: Seq[Seq[TaskDescription]]) {
        // 遍歷所有的 task
      for (task <- tasks.flatten) {
        // 序列化 task 信息
        val serializedTask = ser.serialize(task)
        // 判斷序列化后的 task 信息是否大于 rpc 能夠傳送的最大信息量
        if (serializedTask.limit >= maxRpcMessageSize) {
         ....
        }
        else {
          // 找到對應的 executor
          val executorData = executorDataMap(task.executorId)
          // 更新 executor 的資源信息
          executorData.freeCores -= scheduler.CPUS_PER_TASK

          logDebug(s"Launching task ${task.taskId} on executor id: ${task.executorId} hostname: " +
            s"${executorData.executorHost}.")

          // 向 executor 發送 LaunchTask 消息
          executorData.executorEndpoint.send(LaunchTask(new SerializableBuffer(serializedTask)))
        }
      }
    }

3.2.2Executor端執行Task

Executor 收到消息后做了哪些操作?這里 executorData.executorEndpoint 實際上就是在創建 Executor 守護進程時候創建的那個 CoarseGrainedExecutorBackend。

CoarseGrainedExecutorBackend處理接收到 LaunchTask 消息后會判斷當前的 executor 是不是為空,如果不為空就會反序列化 task 的信息,然后調用 executor 的 launchTask 方法。

case LaunchTask(data) =>
        // 判斷當前 executor 是不是空
      if (executor == null) {
        exitExecutor(1, "Received LaunchTask command but executor was null")
      } else {
        // 反序列化 task 的信息
        val taskDesc = ser.deserialize[TaskDescription](data.value)
        logInfo("Got assigned task " + taskDesc.taskId)
        // 調用 executor 的 lauchTask 方法
        executor.launchTask(this, taskId = taskDesc.taskId, attemptNumber = taskDesc.attemptNumber,
          taskDesc.name, taskDesc.serializedTask)
      }

executor的 launchTask方法首先會為每個 task 創建一個 TaskRunner,然后會將 task 添加到 runningTasks 的集合中,并標記其為運行狀態,最后將 taskRunner 放到一個線程池中執行。

def launchTask(
      context: ExecutorBackend,
      taskId: Long,
      attemptNumber: Int,
      taskName: String,
      serializedTask: ByteBuffer): Unit = {
    // 創建 TaskRunner
    val tr = new TaskRunner(context, taskId = taskId, attemptNumber = attemptNumber, taskName,
      serializedTask)
    runningTasks.put(taskId, tr)
    // 將 taskRunner 放到線程池中執行
    threadPool.execute(tr)
  }

task 運行完成后回向 driver 發送消息,driver 會更新 executor 的一些資源數據,并標記 task 已完成。

TaskScheduler是一個trait接口,任務調度器的實現只有一種就是TaskSchedulerImpl。TaskSchedulerImpl主要處理一些通用的邏輯,例如在多個作業之間決定調度順序,執行推測執行的邏輯等等。主要邏輯是:

3.3小結

任務在driver中從誕生到最終發送的過程,主要有一下幾個步驟:

  • DAGScheduler對作業計算鏈按照shuffle依賴劃分多個stage,提交一個stage根據個stage的一些信息創建多個Task,包括ShuffleMapTask和ResultTask, 并封裝成一個任務集(TaskSet),把這個任務集交給TaskScheduler

  • TaskSchedulerImpl將接收到的任務集加入調度池中,然后通知調度后端SchedulerBackend

  • CoarseGrainedSchedulerBackend收到新任務提交的通知后,檢查下現在可用 executor有哪些,并把這些可用的executor交給TaskSchedulerImpl

  • TaskSchedulerImpl根據獲取到的計算資源,根據任務本地性級別的要求以及考慮到黑名單因素,按照round-robin的方式對可用的executor進行輪詢分配任務,經過多個本地性級別分配,多輪分配后最終得出任務與executor之間的分配關系,并封裝成TaskDescription形式返回給SchedulerBackend

  • SchedulerBackend拿到這些分配關系后,就知道哪些任務該發往哪個executor了,通過調用rpc接口將任務通過網絡發送即可。

4.Spark運行架構特點

(1)每個Application獲取專屬的executor進程,該進程在Application期間一直駐留,并以多線程方式運行tasks。這種Application隔離機制有其優勢的,無論是從調度角度看(每個Driver調度它自己的任務),還是從運行角度看(來自不同Application的Task運行在不同的JVM中)。當然,這也意味著Spark Application不能跨應用程序共享數據,除非將數據寫入到外部存儲系統。

(2)Spark與資源管理器無關,只要能夠獲取executor進程,并能保持相互通信就可以了。

(3)提交SparkContext的Client應該靠近Worker節點(運行Executor的節點),最好是在同一個Rack里,因為Spark Application運行過程中SparkContext和Executor之間有大量的信息交換;如果想在遠程集群中運行,最好使用RPC將SparkContext提交給集群,不要遠離Worker運行SparkContext。

(4)Task采用了數據本地性和推測執行的優化機制。

5.參考

  1. https://www.cnblogs.com/xing901022/p/6674966.html

  2. https://www.cnblogs.com/zhuge134/p/10965266.html

  3. https://blog.csdn.net/xianpanjia4616/article/details/84405145

  4. https://juejin.im/post/5d23069a6fb9a07f091bc66e

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

推薦閱讀更多精彩內容