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配置檔案生成索引
- 單一 bash 指令碼實現更新索引,重建索引,以便 crontab 引用
- 查詢時自動返回 driver file 中描述的欄位,幷包括資料在mongo中的庫名及表名,以便反向查詢
難點及核心在於 driver file 的策略.
Plan A:
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 部分.
編寫過程及後續思考中,發現這種抽象方式有如下缺點:
- 編碼複雜: 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 將可減少很多職能: 只關注索引建立,不關注索引查詢.- 編寫索引查詢介面,定義一個欄位轉換的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
: 用於將資料匯入 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()."
";
}
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 MongoDBBSONObjectID($r[`_id`]));
}
// response result set
}
因為 sphinx 提供的 weight, group, 並行查詢(AddQuery
) 等,目前專案中並沒有使用場景,因此這個查詢輔助類就已經夠用了.
後記:
按以上思路,整個專案的大體框架已搭建完成,後續還需要增加對各個介面類的實現等工作.
只寫了大體思路,隨想隨寫(一大半是在出去浪的飛機上寫的…),肯定比較亂.聊做筆記,各位看客見諒~.
參考:
後後記:
本來領導讓搭建 sphinx 時說只支援非實時索引即可, 後來又整么蛾子, 讓做實時索引.
實時索引就得讓後臺在資料入庫時附帶著在 sphinx 這也插入一份, 但領導又要求不能影響主框架, 讓我想辦法非同步實現自己找到差異資料往 sphinx 裡面插.
但但但… php 不支援非同步啊… 殘念…
幾經掙扎後, 我決定整體放棄這套 php 程式碼, 轉而用 python 按上面思路重新寫了一遍, 對下面幾個方面進行了改進:
- Mongo ObjectId 的拆分不再是按6位分割來拆, 而是按照其定義拆為 4 個有意義的整型值.
- 實現了 python 流式生成/讀取 xml 文件, 不再需要 mysql 做中轉.
- 改進流程, 讓其自動化程度更高.
- 引入增量索引機制, 避免單次索引重建耗時過長.
- 引入 SphinxQL 機制來支援實時索引.
- 用 flask 搭建了一個 api 伺服器, 以實現和主框架解偶.
有空時再寫寫這個 python 框架吧.
另: 後來又接觸並搭建了 elasticsearch, 感覺現在用 sphinx 畢竟是少了, 畢竟其中文分詞器居然還不是外掛外掛就可以的, 居然還要改原始碼… 但兩個搜尋框架都用了, 會發現 sphinx 佔用資源比 elasticsearch 少的多. 呃… 起碼在我這個規模上吧.