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版更爲合適.後續需更換.

 

注1: 如果要使用 coreseek, 要注意其 sphinx 版本.看文檔時,不要去看 sphinx 最新文檔,而要看對應版本的.

注2:

Sphinx 官網: http://sphinxsearch.com/

Coreseek 官網: http://www.coreseek.cn/


搭建:


基於 CentOS 6.5 .


安裝 coreseek:


Coreseek 官網下載地址已失效 (-_- !!!), 需要自己在網上找一個.

Coreseek 官方給出的安裝文檔已非常詳實:

http://www.coreseek.cn/products-install/install_on_bsd_linux/

因爲我們不是爲了替換 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) 等,目前項目中並沒有使用場景,因此這個查詢輔助類就已經夠用了.


後記:


按以上思路,整個項目的大體框架已搭建完成,後續還需要增加對各個接口類的實現等工作.

只寫了大體思路,隨想隨寫(一大半是在去雲南休假的飛機上寫的...),肯定比較亂.聊做筆記,各位看客見諒~.


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章