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 最新文檔,而要看對應版本的.php
基於 CentOS 6.5 . 安裝 coreseek:
Coreseek 官網下載地址已失效 (-_- !!!), 須要本身在網上找一個.
Coreseek 官方給出的 安裝文檔 已很是詳實.
由於咱們不是爲了替換 mysql 的全文檢索,所以不須要安裝 mysql 的 sphinx 插件.python
安裝 php 的 sphinx 擴展:
Sphinx 官方文檔中直接包含了 php 調用 sphinx 的文檔,所以仍是至關方便的.
擴展安裝方法,當時沒記錄下來,也不難,網上一大堆.這裏就不展開了...
擴展須要編譯兩個 so 文件 (固然路徑不必定是我這個路徑.):mysql
/usr/local/php/lib/php/extensions/no-debug-non-zts-20131226/sphinx.so /usr/local/lib/libsphinxclient-0.0.1.so
須要在 php.ini 中增長擴展:linux
extension=sphinx.so
sphinx 最多見搭配是 mysql + php. 非mysql數據源須要解決數據導入問題.
用 Sphinx 全文索引 MongoDB 主要有兩個問題須要解決:
一是導入數據到 sphinx 索引, 二是 mongo objectId 到 sphinx document id 的映射.nginx
第 一個問題還算好解決,由於除了 mysql, sphinx 還支持 xml 和 python 數據源.但這裏仍是建議用 mysql 做爲 mongo 數據的中轉,由於 xml 數據源不支持步進取數據,性能會是個大問題. python 數據源須要額外增長編譯項目,搞了半天沒有編譯過去,又查不到幾篇文檔,就放棄了.web
第二個問題,原由是 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. 所以確定須要再想辦法.sql
最 終的辦法是,將 24 個字母的 16 進制 objectId 分爲 4 段,每段 6 個字母.每段轉換爲10進制數就能夠落在一個 32-bit uint 範圍內了.這4個 objectId 的片斷做爲屬性被 sphinx 索引,拿到查詢結果後,再將其還原爲 mongo 的 objectId. Sphinx 的 document id 則採用無具體意義的自增主鍵.mongodb
將全文檢索服務獨立出來,做爲單獨項目,向外暴露ip或端口來提供服務.需實現如下功能:數據庫
1. 新增或修改索引,由單一文件(下稱 driver file)驅動以下功能:json
* data source -> mysql : 由數據源(mongo)向mysql中轉數據
* generate sphinx index conf : 生成sphinx索引配置文件
* mysql -> sphinx (create index) : 由mysql數據及sphinx配置文件生成索引
難點及核心在於 driver file 的策略.
mongo -> mysql -> sphinx , 三者間有兩重轉換:
所以第一想法是將字段含義抽象出來,溝通三者.
字段抽象類提供接口,分別返回 mongo, mysql, sphinx 對應字段類型,並編寫接口將字段值在三者間映射.
初步定下三種字段類型:
attr_object_id
: 用以映射 mongo 中的 ObjectIdattr_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 部分.
編寫過程及後續思考中,發現這種抽象方式有以下缺點:
基於以上缺陷,決定放棄此方案(在此方案上已耗費了三天的工做量 T_T)
再次思考應用場景,可將模型簡化:
規劃功能中的第三點, "查詢時自動返回 driver file 中描述的字段,幷包括數據在mongo中的庫名及表名,以便反向查詢",是但願作到對調用者徹底透明:
調用者不須要知道具體索引了哪些字段,就能夠根據查詢結果在mongo數據庫中檢索到相應數據. 但爲了實現徹底黑箱化,須要的工做量太大,好比 driver file 內須要添加描述搜索返回數據的接口,以及反向映射某些字段的接口(好比mongo的objectId).
將此功能簡化爲:
1. 根據 driver file 爲每一個索引生成一個靜態的幫助頁面(manual),在此頁面中列出索引字段.這樣功能實現尚可接受,而 driver file 將可減小不少職能: 只關注索引創建,不關注索引查詢.
既然不須要爲每一個字段創建反指向數據源的映射,就更沒有必要以字段做爲抽象依據. driver file 只關注索引創建,所以能夠將創建索引的各個步驟做爲抽象依據.
以步驟做爲抽象依據,相比於以字段做爲抽象依據,
缺點是:
- driver file 將再也不是靜態的, driver file 內必須包含代碼羅輯,且每增長一個 driver file (對應一個索引),都要寫新的代碼羅輯;
- 抽象程度較低,各 driver file 之間可公用的部分較少. 優勢是: - 實現簡單(do not over design); - 能夠靈活適配其餘類型數據源;
該方案將整個項目分爲不相關的兩個部分:
一部分是由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
: 用於將數據導入 mysqlSphinxConfGenerator
: 用於重建 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 時說只支持非實時索引便可, 後來又整幺蛾子, 讓作實時索引.
實時索引就得讓後臺在數據入庫時附帶着在 sphinx 這也插入一份, 但領導又要求不能影響主框架, 讓我想辦法異步實現本身找到差別數據往 sphinx 裏面插.
但但但... php 不支持異步啊... 殘念...
幾經掙扎後, 我決定總體放棄這套 php 代碼, 轉而用 python 按上面思路從新寫了一遍, 對下面幾個方面進行了改進:
有空時再寫寫這個 python 框架吧.
另: 後來又接觸並搭建了 elasticsearch, 感受如今用 sphinx 畢竟是少了, 畢竟其中文分詞器竟然還不是外掛插件就能夠的, 竟然還要改源碼... 但兩個搜索框架都用了, 會發現 sphinx 佔用資源比 elasticsearch 少的多. 呃... 起碼在我這個規模上吧.