php + MongoDB + Sphinx 实现全文检索

瑞邦 2019-06-27

NOTE : 此文成于 2017 年 3 月.

现状:

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

将 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配置文件生成索引

  1. 单一 bash 脚本实现更新索引,重建索引,以便 crontab 引用
  2. 查询时自动返回 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 将可减少很多职能: 只关注索引建立,不关注索引查询.

    1. 编写索引查询接口,定义一个字段转换的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 按上面思路重新写了一遍, 对下面几个方面进行了改进:

  • Mongo ObjectId 的拆分不再是按6位分割来拆, 而是按照其定义拆为 4 个有意义的整型值.
  • 实现了 python 流式生成/读取 xml 文档, 不再需要 mysql 做中转.
  • 改进流程, 让其自动化程度更高.
  • 引入增量索引机制, 避免单次索引重建耗时过长.
  • 引入 SphinxQL 机制来支持实时索引.
  • 用 flask 搭建了一个 api 服务器, 以实现和主框架解偶.

有空时再写写这个 python 框架吧.

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

相关推荐