php + MongoDB + Sphinx 实现全文检索

现状:

Sphinx 目前的稳定版本为 2.2.11.
Sphinx 目前对英文等字母语言采用空格分词,故其对中文分词支持不好,目前官方中文分词方案仅支持按单字分词.
在 Sphinx 基础上,目前国内有两个中文分词解决方案,一个是 sphinx-for-chinese, 一个是 coreseek.
sphinx-for-chinese 没有官网,文档较少,可查到的最新版本可支持 sphinx 1.10 .
coreseek 官方还在维护,但貌似不打算将最新版作为开源方案释出了.
coreseek 最后的开源稳定版本为 3.2.14, 更新时间为2010年中, 基于 sphinx 0.9.9, 不支持string类型的属性.
coreseek 最后的开源beta版本为 4.1, 更新时间为2011年底, 基于 sphinx 2.0.2, 已可支持string类型的属性.
相比而言, coreseek 文档较多,网上用的也更为广泛,因此使用 coreseek 方案.
目前暂时用了 coreseek 3.2.14 稳定版,在后续了解中,发现使用 4.1 beta版更为合适.后续需更换.
注: 如果要使用 coreseek, 要注意其 sphinx 版本.看文档时,不要去看 sphinx 最新文档,而要看对应版本的.

搭建:

基于 CentOS 6.5 . 安装 coreseek:
Coreseek 官网下载地址已失效 (-_- !!!), 需要自己在网上找一个.
Coreseek 官方给出的 安装文档 已非常详实.
因为我们不是为了替换 mysql 的全文检索,因此不需要安装 mysql 的 sphinx 插件.

安装 php 的 sphinx 扩展:
Sphinx 官方文档中直接包含了 php 调用 sphinx 的文档,因此还是相当方便的.
扩展安装方法,当时没记录下来,也不难,网上一大堆.这里就不展开了...
扩展需要编译两个 so 文件 (当然路径不一定是我这个路径.):

/usr/local/php/lib/php/extensions/no-debug-non-zts-20131226/sphinx.so
/usr/local/lib/libsphinxclient-0.0.1.so

需要在 php.ini 中增加扩展:

extension=sphinx.so

附: 重启 php-fpm 信号量含义:

INT, TERM:立刻终止
QUIT :平滑终止
USR1:重新打开日志文件
USR2:平滑重载所有worker进程并重新载入配置和二进制模块

示例:
1)php-fpm 关闭:

# kill -INT `cat /usr/local/php/var/run/php-fpm.pid`

2)php-fpm 重启:

# kill -USR2 `cat /usr/local/php/var/run/php-fpm.pid`

将 MongoDB 作为数据源:

sphinx 最常见搭配是 mysql + php. 非mysql数据源需要解决数据导入问题.
用 Sphinx 全文索引 MongoDB 主要有两个问题需要解决:
一是导入数据到 sphinx 索引, 二是 mongo objectId 到 sphinx document id 的映射.

第 一个问题还算好解决,因为除了 mysql, sphinx 还支持 xml 和 python 数据源.但这里还是建议用 mysql 作为 mongo 数据的中转,因为 xml 数据源不支持步进取数据,性能会是个大问题. python 数据源需要额外增加编译项目,搞了半天没有编译过去,又查不到几篇文档,就放弃了.

第二个问题,起因是 sphinx 有一条重要限制,就是其索引的每条数据都需要一个 "唯一,非零,32位以下 的 整数" 作为 id. 而 mongo 的 objectId 是一个 24位16进制字符串, 这串16进制转为10进制是一个 64-bit int 都存不下的大数.
在 sphinx 1.10 后也算好解决. mongo 的 objectId 可以作为 sphinx 索引中的一个 string 类型的属性值存起来 . 但目前 sphinx 的最新版本,官方文档中也是写明 string 属性会被保存在内存而非索引文件中,数据集较大时则需要考虑这方面的性能. 总之如果可以用 int 类型的 sphinx 属性,就尽量不要用 string 类型的 sphinx 属性.
在 sphinx 0.9.9 中,不支持 string 作为属性,只能用 int, bigint, bool 等作为属性. 而我采用的是 coreseek 3.2.14 - sphinx 0.9.9. 因此肯定需要再想办法.

最 终的办法是,将 24 个字母的 16 进制 objectId 分为 4 段,每段 6 个字母.每段转换为10进制数就可以落在一个 32-bit uint 范围内了.这4个 objectId 的片段作为属性被 sphinx 索引,拿到查询结果后,再将其还原为 mongo 的 objectId. Sphinx 的 document id 则采用无具体意义的自增主键.

将全文检索作为系统服务:

将全文检索服务独立出来,作为单独项目,向外暴露ip或端口来提供服务.需实现以下功能:

  1. 新增或修改索引,由单一文件(下称 driver file)驱动如下功能:

    • data source -> mysql : 由数据源(mongo)向mysql中转数据

    • generate sphinx index conf : 生成sphinx索引配置文件

    • mysql -> sphinx (create index) : 由mysql数据及sphinx配置文件生成索引

  2. 单一 bash 脚本实现更新索引,重建索引,以便 crontab 引用

  3. 查询时自动返回 driver file 中描述的字段,并包括数据在mongo中的库名及表名,以便反向查询

难点及核心在于 driver file 的策略.

Plan A:

mongo -> mysql -> sphinx , 三者间有两重转换:

  • 字段类型转换
  • 字段值转移

因此第一想法是将字段含义抽象出来,沟通三者.
字段抽象类提供接口,分别返回 mongo, mysql, sphinx 对应字段类型,并编写接口将字段值在三者间映射.
初步定下三种字段类型:

attr_object_id : 用以映射 mongo 中的 ObjectId
attr_field : 用以将 string 类型字段映射为 sphinx 全文检索项
attr_int : 用以将 int 类型字段映射为 sphinx 属性 (可用作排序,过滤,分组)

driver file 则选取 json, xml 等通用数据格式 (最终选择了 json).
因为一个index的数据源有可能有多个,因此要求 driver file 中可配置多个数据源 (json 数组)
如下为一个具体索引对应的 driver file:

{
  "name": "example_index",
  "source": [
    {
      "database": "db_name",
      "table": "table_name",
      "attrs": [
          { "mongo": "text1", "type": "field" },
          { "mongo": "text2", "type": "field" },
          { "mongo": "_id", "type": "objectId" },
          { "mongo": "type", "type": "int" },
          { "mongo": "someId", "type": "int" },
          { "mongo": "createTime", "type": "int" },
          { "mongo": "status", "type": "int" }
        ]
    }
  ]
}

为每个索引配置一个此格式的json文件,解析所有json文件,则可完成 mongo -> mysql -> sphinx 的流程.

已编码完成字段抽象, mongo -> mysql 部分.
编写过程及后续思考中,发现这种抽象方式有如下缺点:

  • 编码复杂: int 类型的映射规则尚简单,object_id这样的字段需要将mongo中的一个字段映射为mysql中的四个字段,则要求统一将字段抽象接口都定义为一对多的映射,复杂度增加.以字段为基本单元,编码需要多次遍历,多层遍历,复杂度增加.
  • 字段接口的共同属性不足: 除了上述一个一对多字段将所有字段都抽象为一对多外,当操作最新的mongo维权表时意识到,即使只限定将一个mongo表映射到一个sphinx索引中,也会遇到全文索引字段被保存在其他表中的情况.比如维权表中的tag是以id数组的形式存储的,因此在转储数据时需要查询tag表.这种行为只能单独为字段抽象接口编写一个实现类,而这个实现类也只能用于tag这一个字段而已.这种抽象方式会导致具体实现类过多,且关联不大.
  • 只能支持 mongo -> mysql -> sphinx 这样的数据源配置.如果有其他数据源,则不能采用这种抽象方式.

基于以上缺陷,决定放弃此方案(在此方案上已耗费了三天的工作量 T_T)

Plan B:

再次思考应用场景,可将模型简化:

  • 规划功能中的第三点, "查询时自动返回 driver file 中描述的字段,并包括数据在mongo中的库名及表名,以便反向查询",是希望做到对调用者完全透明:
    调用者不需要知道具体索引了哪些字段,就可以根据查询结果在mongo数据库中检索到相应数据. 但为了实现完全黑箱化,需要的工作量太大,比如 driver file 内需要添加描述搜索返回数据的接口,以及反向映射某些字段的接口(比如mongo的objectId).
    将此功能简化为:

    1. 根据 driver file 为每个索引生成一个静态的帮助页面(manual),在此页面中列出索引字段.这样功能实现尚可接受,而 driver file 将可减少很多职能: 只关注索引建立,不关注索引查询.
    2. 编写索引查询接口,定义一个字段转换的interface,用于将查询出的 sphinx 属性反向映射到希望得到的数据.
  • 既然不需要为每个字段建立反指向数据源的映射,就更没有必要以字段作为抽象依据. driver file 只关注索引建立,因此可以将建立索引的各个步骤作为抽象依据.
    以步骤作为抽象依据,相比于以字段作为抽象依据,
    缺点是:

    • driver file 将不再是静态的, driver file 内必须包含代码罗辑,且每增加一个 driver file (对应一个索引),都要写新的代码罗辑;
    • 因为索引的维护和索引的查询被分开,则在一个索引有属性改动时,需要更改两个文件: driver file 和 查询字段映射规则;
    • 抽象程度较低,各 driver file 之间可公用的部分较少.
      优点是:
    • 实现简单(do not over design);
    • 可以灵活适配其他类型数据源;
  • 为了可以支持一个 sphinx 索引的数据来自 mongo 的多个库和多个表的情况, Plan A 引入了json数组.但其实可以将 index 与数据库表 一对多 的关系,放在 mongo -> mysql 数据中转时实现,sphinx 永远只索引来自同一张 mysql 数据库表的数据.即由 "mongo 多对一 mysql + sphinx" 改为 "mongo 多对一 mysql, mysql 一对一 sphinx". 这种做法下,将 mongo -> mysql 的实现方式自由度放的大些,其他步骤就可以统一实现了.

该方案将整个项目分为不相关的两个部分:
一部分是由bash脚本驱动的索引操作 (重建 sphinx conf 文件; 更新索引; 导入数据等) 工具集;
一部分是由 nginx + phalcon 驱动的索引查询 restful api 接口.

索引操作工具集:

这个方案中,所有 driver file 都继承如下接口:

/**
 * @author lx
 * date: 2016-11-30
 * 
 * 该接口代表一个 sphinx 索引项目.用于完成以下任务:
 * data source => mysql
 * create sphinx searchd.conf
 * refresh sphinx index with searchd.conf
 * create manual (static web page) for each index
 */
interface IndexDriver {

    /**
     * 索引名称,需在项目内唯一.
     */
    public function getIndexName();

    /**
     * 索引字段数组: 元素为 IndexField 类型的数组.
     * @see IndexField
     */
    public function getIndexFields();

    /** 
     * 用于在 crontab 调度中,判断是否要重建索引
     * @param last_refresh_time 上一次重建索引的时间, 单位秒
     * @return 需要重建则返回 true; 不需要重建则返回 false
     */
    public function shouldRefreshIndex($last_refresh_time);

    /**
     * 以步进方式获取数据, 需和 getIndexFields() 对应.
     * 数据为二维数组:
     * 第一个维度为顺序数组,代表将要插入mysql的多行数据;
     * 第二个维度为键值对数组,代表每行数据的字段及其值.
     * example:
     * array(
     *     array("id" => "1", "type" => "404", "content" => "I'm not an example"),
     *     array("id" => "2", "type" => "500", "content" => "example sucks"),
     *     array("id" => "3", "type" => "502", "content" => "what's the point /_\"),
     * )
     * 
     * @param int $offset 步进偏移量
     * @param int $limit 返回数据的最大行数
     */
    public function getValues($offset, $limit);

    /**
     * 为该索引生成相应文档.
     */
    public function generateDocument();
}

字段以如下类表示:

/**
 * @author lx
 * date: 2016-11-30
 * 
 * 该类代表一个 sphinx 全文索引字段 或 sphinx 索引属性.
 */
class IndexField {

    private $name;
    private $mysql_type;
    private $sphinx_type;

    /**
     * 创建作为 sphinx int 类型属性的 IndexField. 该字段必须为一个正整数.
     * @param string $name 字段名
     */
    public static function createIntField($name) {
        return new IndexField($name, "int", "sql_attr_uint");
    }

    /**
     * 创建作为 sphinx 全文索引字段的 IndexField. 该字段必须为一个字符串.
     * @param string $name 字段名
     * @param int $char_length 字段值的最大长度.
     */
    public static function createField($name, $char_length = 255) {
        return new IndexField($name, "varchar($char_length)", null);
    }

    /**
     * @param string $name 字段名
     * @param string $mysql_type 该字段在mysql下的类型
     * @param string $sphinx_type 该字段在sphinx配置文件中的类型
     */
    public function __construct($name, $mysql_type, $sphinx_type = null) {
        $this->name = $name;
        $this->mysql_type = $mysql_type;
        $this->sphinx_type = $sphinx_type;
    }

    /**
     * 获取字段名.
     */
    public function getName() {
        return $this->name;
    }

    /**
     * 获取该字段在 mysql 数据库中的类型.主要用于 mysql create 语句创建数据表.
     * 例: 可能返回的值如下:
     * int
     * varchar(255)
     */
    public function getMysqlType() {
        return $this->mysql_type;
    }

    /**
     * 获取该字段在 sphinx conf 文件中的类型.主要用于构建全文索引conf文件.
     * 如果该字段为一个全文索引字段,则该函数应返回 null.
     * 例: 可能返回的值如下:
     * sql_attr_uint
     */
    public function getSphinxType() {
        return $this->sphinx_type;
    }

    /**
     * 判断该字段是否为全文索引字段.
     * 目前的判断依据为 sphinx_type 是否为空.
     */
    public function isSphinxField() {
        return empty($this->sphinx_type);
    }
}

将需要做索引的数据源都抽象为上述 driver file, 然后将所有 driver file 统一放在一个文件夹下.编写脚本扫描该文件夹,根据 driver file 列表实现重建sphinx索引配置文件,更新索引(全量,增量),crontab排期任务等操作. 当未来有新的数据源要建立索引,或者现有数据源调整时,只需要更新 driver file 即可.

可将索引相关操作分解到三个类中:
MysqlTransmitter: 用于将数据导入 mysql
SphinxConfGenerator: 用于重建 sphinx 配置文件 (只能重建,不能更新.不过开销很小,不构成问题)
DocumentGenerator: 用于为每个索引建立手册页面

然后再编写统一入口脚本,调用以上工具类,接合 sphinx 的内建工具 searchd, indexer 等,完成索引相关操作.
该部分已全部实现,目前运行良好.

索引查询:

上文采用 Plan B 后,需要制定一套索引属性反向映射规则.

比如 mongo 的 ObjectId, 其在数据源导入时被拆开为4个int类型数字,现在要将这4个int类型拼接为可用的 ObjectId,以便进一步查询 mongo.
比如有一个字段 code,需要在其前面补零才可与 mongo 内的某个字段对应起来.

这是一个多对多映射问题: 将 sphinx 查询出的多个属性转换为其他的多个属性.因此定义如下接口:

/**
 * 将 sphinx 查询到的一个或多个属性进行转换,并加入到查询结果中去.
 * 被转换的属性将从结果集中去掉; 转换结果将被加入到结果集中去.
 * @author lx
 */
interface FieldParser {
    /**
     * 声明要转换的 sphinx 属性名称.
     * 这些被指定的属性的值将作为参数传入 parseValues() 函数中.
     * @return array 属性名称的数组.例: array("id1", "id2", "id3)
     */
    function getRequiredKeys();

    /**
     * 将选定的属性值进行转换.转换结果以键值对数组形式返回.
     * @param array $values 选定的属性值,键值对数组.
     * @return array 属性及其值的兼职对. 例: array("id" => "123", "id_ext" => 456)
     */
    function parseValues(array $values);
}

将该接口的具体实现类加入到一个数组(队列),逐个遍历,以对sphinx的返回结果集进行转换.

以 mongo 的 ObjectId 为例,其具体转换类实现如下:

class MongoIdParser implements FieldParser {

    private $field_name;
    private $required_fields;

    public function __construct($field_name) {
        $this->field_name = $field_name;
        $this->required_fields = array(
            $this->field_name."1", $this->field_name."2",
            $this->field_name."3", $this->field_name."4",
        );
    }

    /**
     * {@inheritDoc}
     * @see FieldParser::getFieldNames()
     */
    public function getRequiredKeys() {
        return $this->required_fields;
    }

    /**
     * {@inheritDoc}
     * @see FieldParser::parseFieldValues()
     */
    public function parseValues(array $values) {
        $mongoId = $this->buildMongoId(
            $values[$this->field_name."1"],
            $values[$this->field_name."2"],
            $values[$this->field_name."3"],
            $values[$this->field_name."4"]);
        return array($this->field_name => $mongoId);
    }

    private function buildMongoId($_id1, $_id2, $_id3, $_id4) {
        $id = $this->toHex($_id1).$this->toHex($_id2).$this->toHex($_id3).$this->toHex($_id4);
        if (strlen($id) != 24) {
            return "";
        } else {
            return $id;
        }
    }

    private function toHex($_id) {
        $hex_str = dechex($_id);
        $count = strlen($hex_str);
        if ($count < 1 || $count > 6) {
            return "";
        }
        if ($count < 6) {
            for ($i = 0; $i < 6 - $count; $i ++) {
                $hex_str = "0".$hex_str;
            }
        }
        return $hex_str;
    }
}

有了以上接口后,定义一个方便调用的查询 sphinx 的类.

因为 sphinx 本身对php支持已经极度友好了,其实除了上面提到的属性值转换功能,基本没什么需要封装的了.
但因为大爱流式调用,因此就把调用sphinx封装为流式调用了.如下:

/**
 * @author lx
 * date: 2016-11-25
 * utility class to easy access sphinx search api.
 */
class EcoSearch {

    private $sphinx;
    private $query_index;

    private $field_parsers;

    /**
     * construct with sphinx searchd ip and port
     * @param string $ip  sphinx searchd ip
     * @param int $port  sphinx searchd port
     */
    public function __construct($ip, $port) {
        $this->sphinx = new SphinxClient();
        $this->sphinx->setServer($ip, $port);
        $this->sphinx->SetMatchMode(SPH_MATCH_ANY);
    }

    /**
     * construct with sphinx searchd ip and port
     * @param string $ip  sphinx searchd ip
     * @param int $port  sphinx searchd port
     */
    public static function on($ip = "127.0.0.1", $port = 9312) {
        $search = new EcoSearch($ip, $port);
        return $search;
    }

    public function setMatchAll() {
        $this->sphinx->SetMatchMode(SPH_MATCH_ALL);
        return $this;
    }

    public function setMatchAny() {
        $this->sphinx->SetMatchMode(SPH_MATCH_ANY);
        return $this;
    }

    public function setSortBy($attr, $asc = true) {
        if (!empty($attr) && is_string($attr)) {
            $mode = $asc ? SPH_SORT_ATTR_ASC : SPH_SORT_ATTR_DESC;
            $this->sphinx->SetSortMode($mode, $attr);
        }
        return $this;
    }

    public function setMongoIdName($mongo_id_name) {
        return $this->addFieldParser(new MongoIdParser($mongo_id_name));
    }

    public function addQueryIndex($index) {
        if (!empty(trim($index))) {
            $this->query_index = $this->query_index." ".$index;
        }
        return $this;
    }

    public function addFilter($attr, $values, $exclude = false) {
        $this->sphinx->SetFilter($attr, $values, $exclude);
        return $this;
    }

    public function addFilterRange($attr, $min, $max, $exclude = false) {
        $this->sphinx->SetFilterRange($attr, $min, $max, $exclude);
        return $this;
    }

    public function setLimits($offset, $limit) {
        $this->sphinx->SetLimits($offset, $limit);
        return $this;
    }

    public function addFieldParser($field_parser) {
        if ($field_parser instanceof FieldParser) {
            if (!$this->field_parsers) {
                $this->field_parsers = array();
            }
            $this->field_parsers[] = $field_parser;
        }
        return $this;
    }

    public function query($str) {
        if (empty(trim($this->query_index))) {
            $this->query_index = "*";
        }
        Logger::dd("search [$str] from index {$this->query_index}");
        $result_set = $this->sphinx->Query($str, $this->query_index);
        $error = $this->sphinx->GetLastError();
        if (!$error) {
            Logger::ww("search [$str] from index {$this->query_index}, last error: $error");
        }
        $ret = array();
        if (is_array($result_set) && isset($result_set['matches'])) {
            foreach ($result_set['matches'] as $result) {
                $ret_values = array();
                $values = $result['attrs'];
                foreach ($this->field_parsers as $parser) {
                    $parsed_values = $this->getParsedValues($parser, $values);
                    $ret_values = array_merge($ret_values, $parsed_values);
                }
                $ret_values = array_merge($ret_values, $values);
                $ret[] = $ret_values;
            }
        } else {
            //echo "sphinx query fail: ".$this->sphinx->GetLastError()."\n";
        }
        return $ret;
    }

    private function getParsedValues($parser, &$values) {
        $ret = null;
        $required_keys = $parser->getRequiredKeys($values);
        if (!empty($required_keys)) {
            $required_values = array();
            foreach ($required_keys as $key) {
                // get required values
                $required_values[$key] = $values[$key];
                // abondon the already parsed keys
                unset($values[$key]);
            }
            if (!empty($required_values)) {
                $ret = $parser->parseValues($required_values);
            }
        }
        return $ret;
    }
}

一个全文检索调用的形式大体如下:

        $offset = ($_POST["page"] - 1) * $_POST["pageSize"];
        $limit = $_POST["pageSize"];
        $search_result = EcoSearch::on()
            ->addQueryIndex("index_name")
            ->setMatchAll()
            ->setSortBy("createTime", false)
            ->setLimits($offset, $limit)
            ->setMongoIdName("_id")
            ->query($search);

        if (empty($search_result)) {
            // response "未搜索到相关结果";
        } else {
            $result = array();
            foreach ($search_result as $r) {
                $result[] = query_mongo_by_id(new MongoDB\BSON\ObjectID($r['_id']));
            }
            // response result set
        }

因为 sphinx 提供的 weight, group, 并行查询(AddQuery) 等,目前项目中并没有使用场景,因此这个查询辅助类就已经够用了.

后记:

按以上思路,整个项目的大体框架已搭建完成,后续还需要增加对各个接口类的实现等工作.
只写了大体思路,随想随写(一大半是在出去浪的飞机上写的...),肯定比较乱.聊做笔记,各位看客见谅~.

参考:

Sphinx 官网
Coreseek 官网


后后记:

本来领导让搭建 sphinx 时说只支持非实时索引即可, 后来又整幺蛾子, 让做实时索引.
实时索引就得让后台在数据入库时附带着在 sphinx 这也插入一份, 但领导又要求不能影响主框架, 让我想办法异步实现自己找到差异数据往 sphinx 里面插.
但但但... php 不支持异步啊... 残念...
几经挣扎后, 我决定整体放弃这套 php 代码, 转而用 python 按上面思路重新写了一遍, 顺带改进了下流程, 让其自动化程度更高. 又顺带用 flask 搭建了一个 api 服务器, 以实现和主框架解偶.
有空时再谢谢这个 python 框架吧.

另: 后来又接触并搭建了 elasticsearch, 感觉现在用 sphinx 毕竟是少了, 毕竟其中文分词器居然还不是外挂插件就可以的, 居然还要改源码... 但两个搜索框架都用了, 会发现 sphinx 占用资源比 elasticsearch 少的多. 呃... 起码在我这个规模上吧.

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,602评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,442评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,878评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,306评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,330评论 5 373
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,071评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,382评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,006评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,512评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,965评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,094评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,732评论 4 323
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,283评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,286评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,512评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,536评论 2 354
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,828评论 2 345

推荐阅读更多精彩内容