背景
Sphinx是一个全文搜索引擎,虽然官方没对中文分词检索做直接支持,但是配合coreseek还是能很好地实现中文全文检索的。至于分词结果,不本文讨论范围内,本文主要针对Sphinx的实时搜索能力进行探讨。
用过Sphinx的应该都知道,虽然提供了更新属性的接口(php中是 UpdateAttributes
函数),但是却无法对文本类型字段进行更新。本文主要以PHP来进行实际操作示范。
题外话:其实ElectricSearch这款全文检索工具做实时索引支持更好,但是因为本人工作中使用的是Sphinx,且ElectricSearch的使用成本相对来Sphinx来说较重,所以本人暂时没有迁移过去,但还是十分推荐有条件的直接使用ElasticSearch,真香!
解决思路
总结就是: 全量索引(更新周期1天) + 增量索引(更新周期1分钟) + 实时索引
- 全量索引,
main
,对目标数据源在一个较长周期中进行全量更新 - 增量索引,
inc
,对目标数据源在一个相对较短的周期中进行增量更新,也就是把上次全量索引后新增的以及修改过的数据进行索引 - 实时索引,
rtdata
,sphinx对实时所以有较好的更新支持,而且是基于内存的(未超过设定的最大值时),速度较快,弥补UpdateAttributes
函数不能更新文本字段的缺陷,且为增量索引更新间隙中生成的新内容进行索引。
通过这三者的结合,就可以基于Sphinx实现一个无限接近于实时,且占用资源相对可观的全文搜索。
疑点与要点
- 全量索引与增量索引之间的覆盖问题
增量索引中会含有全量索引的部分数据,如全量索引中有个 id 为 1 的文档,keywrods
字段为 黄金,加入全量索引后,该字段被更新为了 铂金,这时候,无论是搜索 黄金 还是 铂金 都能检索出 id = 1 的这个文档。这种情况可以通过在增量索引源中配置sql_query_killlist
参数来避免增量索引更新后,全量索引内容还能被检索的问题。注意 较新 的索引数据在搜索时候要放在 较旧 的索引数据后。放在此处就是,搜索时候应该是$sphinx->Query('xxx', 'main;inc')
。 - 实时索引与全量索引及增量索引之间的覆盖问题
和上面说到的情况一样,如果在增量索引更新间隔中,旧数据被更新的时候,也会检索出滞后的数据。而且受限于前面说到的 不能即时更新文本类型字段 的问题,我们可以在 非实时索引 中添加过滤字段,如is_del
,并通过UpdateAttributes
函数将其更新后,再在 实时索引 中添加/修改该条记录,搜索时候加上is_del = 0
的filter,就可以避免这种覆盖的情况。
实际操作
一、数据准备
- 数据源
CREATE TABLE `article` (
`id` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT,
`title` VARCHAR(100) NOT NULL DEFAULT '0',
`keywrods` VARCHAR(100) NOT NULL DEFAULT '0',
`is_del` TINYINT(1) NOT NULL DEFAULT 0,
`create_at` INT(11) NOT NULL DEFAULT 0,
`update_at` INT(11) NOT NULL DEFAULT 0,
PRIMARY KEY (`id`)
)
COLLATE='utf8_general_ci';
- 更新记录表
CREATE TABLE `up_record` (
`id` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT,
`max_id` INT(11) UNSIGNED NOT NULL DEFAULT '0',
`update_at` INT(11) NOT NULL DEFAULT 0,
PRIMARY KEY (`id`)
)
COLLATE='utf8_general_ci';
- 全量索引sphinx配置
source main
{
type = mysql
sql_host = 127.0.0.1
sql_user = www
sql_pass = 123456
sql_db = test
sql_port = 3306 # optional, default is 3306
sql_query_pre = SET NAMES UTF8
sql_query_range = SELECT MIN(id), MAX(id) FROM artile # 配合step对数据源进行分段导入
sql_range_step = 1000 # 每次导入1000条
sql_query = SELECT id, title, keywrods, is_del FROM article WHERE id >= $start AND id <= $end
# 执行完毕后更新记录表,记录当前最大id与索引时间
sql_query_post_index = REPLACE INTO up_record SELECT 1, MAX(id), UNIX_TIMESTAMP() FROM article
sql_attr_uint = is_del
}
index main
{
source = main
path = /usr/local/sphinx/var/data/main
# 文件存储模式(默认为extern)
docinfo = extern
# 缓存数据内存锁定
mlock = 0
# 马氏形态学(对中文无效)
morphology = none
# 索引词最小长度
min_word_len = 1
# 数据编码(设置成utf8才能索引中文)
charset_type = utf-8
# 最小索引前缀长度
min_prefix_len = 0
# 最小索引中缀长度
min_infix_len = 1
# 对于非字母型数据的长度切割(for CJK indexing)
ngram_len = 1
# 对否对去除用户输入查询内容的html标签
html_strip = 0
# propen = 1
charset_table = 0..9, A..Z->a..z, _, a..z, U+410..U+42F->U+430..U+44F, U+430..U+44F
ngram_chars = U+3000..U+2FA1F
}
- 增量索引sphinx配置
source inc
{
type = mysql
sql_host = 127.0.0.1
sql_user = www
sql_pass = 123456
sql_db = test
sql_port = 3306 # optional, default is 3306
sql_query_pre = SET NAMES UTF8
sql_query_range = SELECT MIN(id), MAX(id) FROM artile # 配合step对数据源进行分段导入
sql_range_step = 1000 # 每次导入1000条
sql_query = SELECT id, title, keywrods, is_del FROM article \
WHERE id >= $start AND id <= $end \
AND (\
id > (SELECT max_id FROM up_record WHERE id = 1) \
OR update_at > (SELECT update_at FROM pm_sphinx WHERE id = 1) \
)
sql_attr_uint = is_del
}
index inc
{
source = inc
path = /usr/local/sphinx/var/data/inc
# 文件存储模式(默认为extern)
docinfo = extern
# 缓存数据内存锁定
mlock = 0
# 马氏形态学(对中文无效)
morphology = none
# 索引词最小长度
min_word_len = 1
# 数据编码(设置成utf8才能索引中文)
charset_type = utf-8
# 最小索引前缀长度
min_prefix_len = 0
# 最小索引中缀长度
min_infix_len = 1
# 对于非字母型数据的长度切割(for CJK indexing)
ngram_len = 1
# 对否对去除用户输入查询内容的html标签
html_strip = 0
# propen = 1
charset_table = 0..9, A..Z->a..z, _, a..z, U+410..U+42F->U+430..U+44F, U+430..U+44F
ngram_chars = U+3000..U+2FA1F
}
- 实时索引sphinx配置
index rtdata
{
type = rt
rt_mem_limit = 64M # 最大内存,视情况而定,超出此值会存入下面的path降低效率
path = /usr/local/sphinx/var/data/rtdata
# 中文分词词典
chinese_dictionary = /var/lib/sphinx/xdict
# 最小索引前缀长度
min_prefix_len = 0
# 最小索引中缀长度
min_infix_len = 1
# 对于非字母型数据的长度切割(for CJK indexing)
ngram_len = 1
# 对否对去除用户输入查询内容的html标签
html_strip = 0
#charset_type = utf-8
charset_table = 0..9, A..Z->a..z, _, a..z, U+410..U+42F->U+430..U+44F, U+430..U+44F
ngram_chars = U+3000..U+2FA1F
rt_attr_uint = id
rt_field = title
rt_field = keywords
rt_attr_uint = is_del
}
注意:上面的sphinx配置为同一个sphinx.conf内容节选
二、更新脚本与任务
- 全量更新脚本
# reload_all.sh
# 实时索引需要手动清理
mysql -P9306 -h127.0.0.1 -e "truncate rtindex rtdata;"
/usr/local/sphinx/bin/indexer -c /usr/local/sphinx/conf/sphinx.conf --all --rotate
- 增量更新脚本
# reload_inc.sh
# 实时索引需要手动清理
mysql -P9306 -h127.0.0.1 -e "truncate rtindex rtdata;"
/usr/local/sphinx/bin/indexer -c /usr/local/sphinx/conf/sphinx.conf inc --rotate
- 定时任务设置
-> crontab -l
# 每分钟更新一次增量脚本
* * * * * /path/to/reload_inc.sh
# 每天凌晨3点更新全量脚本
0 3 * * * /path/to/reload_all.sh
三、相关php伪代码
增量索引已经解决了很大部分的索引更新问题,我们主要关注在新增与修改时候更新实时索引即可。此处以thinkphp5.0代码为例子。
- article模型类伪代码
class Article extends Model
{
public function update($id, $data)
{
// 数据校验部分忽略
$this->where('id', $id)->update($data);
$this->updateSphinxRt($id, $data);
}
public function create($data)
{
// 数据校验部分忽略
$id = $this->insertGetId($data);
$this->updateSphinxRt($id, $data);
}
public function del($id)
{
$this->where('id', $id)->update(['is_del', 1]);
$this->updateSphinxRt($id, ['is_del' => 1]);
}
protected fuction updateSphinxRt($id, $data)
{
$field = ['id', 'title', 'keywords', 'is_del'];
$article = $this->where('id', $id)->field($field)->find()->toArray();
# 过滤data多余数据,并避免缺少所需字段
$data = array_merge($article , array_intersect_key($data, array_flip($field)));
# 更新实时索引,config('sphinx.rt')为tp5适配sphinx的Query配置,后面会提到
$rtDb = db('rtdata', config('sphinx.rt'));
if ($rtDb->where('id', $id)->find()) {
$rtDb->where('id', $id)->update($data);
} else {
$rtDb->insert($data);
}
# 更新全量与增量索引(重点)
$sphinx = SphinxClient::getInstance();// 对SphinxClient进行了单例封装
$sphinx->UpdateAttributes('main;inc', ['is_del'], [$id => [1]]);
}
}
- tp5适配sphinx的配置
# config.php
# .......忽略部分
'sphinx' => [
'type' => 'mysql',
'hostname' => '127.0.0.1',
'hostport' => Env::get('sphinx.rt_port', 9306),
'charset' => 'utf8',
'debug' => true,
'query' => 'app\common\lib\sphinx\RtQuery',// 适配sphinx后的Query类
]
# .......忽略部分
- tp5适配sphinx的Query类
<?php
namespace app\common\lib\sphinx;
use think\db\Query;
/**
* 解决Sphinx的rt索引操作时候读取表字段错误的问题,
* 直接在sphinx/rt_field中将需要用到的rt表字段定义好,
* 具体结构可以查看运行相关命令后产生的缓存,
* 指的注意的一点是id不可以设置为主键,否则无法写入
*/
class RtQuery extends Query
{
// 重写父类该方法
public function getTableInfo($tableName = '', $fetch = '')
{
# ......省略部分代码
if (!isset(self::$info[$db . '.' . $guid])) {
$schema = $guid;
// 强制读预先定义好的结构,主要重点!!
if (is_file(ROOT_PATH . 'sphinx/rt_field/' . $schema . '.php')) {
$info = include ROOT_PATH . 'sphinx/rt_field/' . $schema . '.php';
} else {
throw new \RuntimeException('rt field cache no exists');
}
# ......省略部分代码
}
return $fetch ? self::$info[$db . '.' . $guid][$fetch] : self::$info[$db . '.' . $guid];
}
}
最后搜索时候应该三个索引都进行搜索,且按照 全量->增量->实时 顺序,避免旧数据不更新问题
$sphinx->Query('xxxx', 'main;inc;rtdata');
总结
通过全量+增量+实时索引,我们可以愉快地进行近实时的全文检索了。总的来说,我们在常规的全量索引上,增加了增量索引,来避免建立全量索引时候耗时过长的问题;再增加实时索引来进一步规避在增量索引更新间隔中新增/修改数据无法正确检索的问题。同时,由于每次更新增量索引的时候会清除实时索引,所以实时索引占用的内存不会很高。进一步的优化点,可以增加一条规则,比如每N个小时,来进行一次增量索引与全量索引的合并(注意更新相关的记录表),来减少增量所以每次建立的时间(其实一般不是十分海量的情况,建立速度还是秒级的,可以查下相关测试数据)。如果面对海量数据,还可以假设分布式的结构。当然,Elasticsearch才是真的香啊!!!
声明
本人技术有限,如有不当的地方还望指正。
同时,受限于本人接触的数据量大小问题,该方案还没经历过TB级别的验证。
欢迎大家一起探讨更好的解决方案。