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类
<?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级别的验证。
欢迎大家一起探讨更好的解决方案。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,684评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,143评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,214评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,788评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,796评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,665评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,027评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,679评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 41,346评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,664评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,766评论 1 331
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,412评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,015评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,974评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,203评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,073评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,501评论 2 343

推荐阅读更多精彩内容