Sphinx实时搜索设计探讨

背景

Sphinx是一个全文搜索引擎,虽然官方没对中文分词检索做直接支持,但是配合coreseek还是能很好地实现中文全文检索的。至于分词结果,不本文讨论范围内,本文主要针对Sphinx的实时搜索能力进行探讨。
用过Sphinx的应该都知道,虽然提供了更新属性的接口(php中是 UpdateAttributes 函数),但是却无法对文本类型字段进行更新。本文主要以PHP来进行实际操作示范。

题外话:其实ElectricSearch这款全文检索工具做实时索引支持更好,但是因为本人工作中使用的是Sphinx,且ElectricSearch的使用成本相对来Sphinx来说较重,所以本人暂时没有迁移过去,但还是十分推荐有条件的直接使用ElasticSearch,真香!

解决思路

总结就是: 全量索引(更新周期1天) + 增量索引(更新周期1分钟) + 实时索引

  1. 全量索引,main,对目标数据源在一个较长周期中进行全量更新
  2. 增量索引, inc,对目标数据源在一个相对较短的周期中进行增量更新,也就是把上次全量索引后新增的以及修改过的数据进行索引
  3. 实时索引, rtdata,sphinx对实时所以有较好的更新支持,而且是基于内存的(未超过设定的最大值时),速度较快,弥补UpdateAttributes 函数不能更新文本字段的缺陷,且为增量索引更新间隙中生成的新内容进行索引。

通过这三者的结合,就可以基于Sphinx实现一个无限接近于实时,且占用资源相对可观的全文搜索。

疑点与要点

  1. 全量索引与增量索引之间的覆盖问题
    增量索引中会含有全量索引的部分数据,如全量索引中有个 id1 的文档,keywrods 字段为 黄金,加入全量索引后,该字段被更新为了 铂金,这时候,无论是搜索 黄金 还是 铂金 都能检索出 id = 1 的这个文档。这种情况可以通过在增量索引源中配置 sql_query_killlist 参数来避免增量索引更新后,全量索引内容还能被检索的问题。注意 较新 的索引数据在搜索时候要放在 较旧 的索引数据后。放在此处就是,搜索时候应该是 $sphinx->Query('xxx', 'main;inc')
  2. 实时索引与全量索引及增量索引之间的覆盖问题
    和上面说到的情况一样,如果在增量索引更新间隔中,旧数据被更新的时候,也会检索出滞后的数据。而且受限于前面说到的 不能即时更新文本类型字段 的问题,我们可以在 非实时索引 中添加过滤字段,如 is_del,并通过 UpdateAttributes 函数将其更新后,再在 实时索引 中添加/修改该条记录,搜索时候加上 is_del = 0 的filter,就可以避免这种覆盖的情况。

实际操作

一、数据准备

  1. 数据源
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';
  1. 更新记录表
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';
  1. 全量索引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
}

  1. 增量索引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
}

  1. 实时索引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内容节选

二、更新脚本与任务

  1. 全量更新脚本
# 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
  1. 增量更新脚本
# 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
  1. 定时任务设置
-> crontab -l

# 每分钟更新一次增量脚本
* * * * * /path/to/reload_inc.sh
# 每天凌晨3点更新全量脚本
0 3 * * * /path/to/reload_all.sh

三、相关php伪代码

增量索引已经解决了很大部分的索引更新问题,我们主要关注在新增与修改时候更新实时索引即可。此处以thinkphp5.0代码为例子。

  1. 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]]);
    }
}
  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类
  ]
# .......忽略部分
  1. tp5适配sphinx的Query类

最后搜索时候应该三个索引都进行搜索,且按照 全量->增量->实时 顺序,避免旧数据不更新问题
$sphinx->Query('xxxx', 'main;inc;rtdata');

总结

通过全量+增量+实时索引,我们可以愉快地进行近实时的全文检索了。总的来说,我们在常规的全量索引上,增加了增量索引,来避免建立全量索引时候耗时过长的问题;再增加实时索引来进一步规避在增量索引更新间隔中新增/修改数据无法正确检索的问题。同时,由于每次更新增量索引的时候会清除实时索引,所以实时索引占用的内存不会很高。进一步的优化点,可以增加一条规则,比如每N个小时,来进行一次增量索引与全量索引的合并(注意更新相关的记录表),来减少增量所以每次建立的时间(其实一般不是十分海量的情况,建立速度还是秒级的,可以查下相关测试数据)。如果面对海量数据,还可以假设分布式的结构。当然,Elasticsearch才是真的香啊!!!

声明

本人技术有限,如有不当的地方还望指正。
同时,受限于本人接触的数据量大小问题,该方案还没经历过TB级别的验证。
欢迎大家一起探讨更好的解决方案。

你可能感兴趣的:(Sphinx实时搜索设计探讨)