今天我们来学习真正的,最核心的索引管理相关的操作。但其实今天的内容还更简单一些,为啥呢?因为索引管理中,最核心的就是对于数据的增、删、改呀。其实要往大了说,查询也是针对索引的操作,只不过相对来说,搜索引擎引用往往是读多写少,而且相比数据库来说,它的写还要少一些。
因此,XS 在 SDK 组件中,将索引对象和查询对象分开了。同样地,后端服务,也是通过 8383 和 8384 两个端口区分开了索引操作和查询操作。不过这也带来了一个问题,那就是索引的增、删、改操作是异步的,在查询的反馈上并不是完全及时的。
说了这些,其实就是要弄清楚我们的业务场景了。对于需要使用 XS 的系统来说,主要是文章、商品详情、存档数据这一类的信息。通常来说,文章文档类的应用可能会更多一些。这些应用往往修改的频率不高,而且就像我们的数据库设计,对于状态为未发布的文章,也完全没有必要进搜索引擎。大部分情况下,其实更多的前端调用还是在搜索上。根据这些业务场景,XS 的异步问题就完全不是问题了。当然,如果你想更快,更及时,那估计就是 ES 之类的了,但是,ES 也不是完全同步的哦,它也有刷分片级别设置的,默认情况下也不是直接就能马上读取到新写入的数据的,只是说,看到的效果比我们 XS 稍快一些。另外包括 Sphinx ,它对增量索引的支持都还没 XS 好呢(Sphinx绑定 MySQL 后全量索引速度非常快,它不推荐增量索引)。换句话说,搜索引擎的索引,应该是变动小的,而查询量,则是非常大的,需要全文检索分词的,这类应用,才是搜索引擎的主战场。
好吧,又扯了一遍搜索引擎的概念和应该在什么场景下使用搜索引擎。目的其实也是再次提醒大家一定要转变一下思维,要不看了 XS 的增、删、改功能之后,又用 MySQL 的思维来套,就会说 XS 的多垃圾呀什么的。了解之后就知道了吧,搜索引擎都是这个鸟样,千万不要直接用数据库的增、删、改思维来直接开骂哦。
好了,接下来我们就进入正文。
添加数据的代码之前我们早就已经使用过的,没啥多说的。
$index = $xs->index; // 后期如果直接写一个 $index ,就是直接表示为 $xs->index 获得的 XSIndex 对象
$index2 = $xs->index->add(new XSDocument(['title'=>'添加一条','content'=>'添加一条'.date('YmdHis'),'id'=>uniqid()]));
var_dump($index === $index2);
add() 方法返回一个 XSIndex ,它自己本身,上面的代码我们就是来测一下 add() 返回的对象和调用 add() 方法时的对象是不是一样的,结果是全等于的 true 。
这里有什么问题呢?其实呀,这也是 XS 中一个比较被诟病的一点,添加操作,包括之后我们要学习的修改和删除操作,这些方法的返回值都只是一个 XSIndex 对象本身,没有其它内容。这样的话,我们就不知道这个操作是成功还是失败了。
在源码中,添加函数其实上调用的是修改的函数,这个我们在后面修改数据中再说。然后修改函数最终是通过之前我们学习过的 XSServer 对象中的 execCommand() 发送给服务端的。服务端在接收到之后返回的内容在 SDK 中没有处理,也没有返回或者记录。
这一点可能也会让大家比较困惑,其实 execCommand() 之后,是有返回值的,大家可以在源码中找到执行的地方打印它的返回值。
// /vendor/hightman/xunsearch/lib/XSIndex.class.php update() 方法下部
//…………………………
// execute cmd
if ($this->_bufSize > 0) {
$this->appendBuffer(implode('', $cmds));
} else {
for ($i = 0; $i < count($cmds) - 1; $i++) {
$this->execCommand($cmds[$i]);
}
$this->execCommand($cmds[$i], XS_CMD_OK_RQST_FINISHED); // 打印这里
}
//…………………………
就能看到返回的是一个 XSCommand 对象,属性内容是:
XSCommand Object
(
[cmd] => 128
[arg1] => 0
[arg2] => 250
[buf] =>
[buf1] =>
)
这样的,然后将 cmd 的值,也就是 128 放到 xs_cmd.inc.php 中查找,就会发现它对应的是 define('XS_CMD_OK', 128);
这个常量,意思很清楚了吧。
SDK 没有封装返回状态的结果,我觉得可能是因为这个 SDK 以完全面向对象的方式来写得,如果有问题直接会报错了,你可以再继续深入父类 XSServer 中 execCommand() 的源码进行查看。所以我们在调用 add() 之后,如果没报错,那么就可以认为是成功了。另一点就是由于索引的操作是异步的,返回的这个 128 状态也只是说服务端接收到了数据,完成了校验,但并不代表数据是正式插入成功了。
修改数据使用的是 update() 方法。
$xs->index->update(new XSDocument(['title'=>'添加一条','content'=>'添加一条'.date('YmdHis'),'id'=>uniqid()]));
上面这条语句是有问题的,不知道各位同学发现了没有。
语法没问题,数据没问题,问题出在那个 id 字段上。我们使用的是 uniqid() 这个函数是 PHP 中生成不唯一字符串的。也就是说,上面的这个更新语句中,主键是一个新的 id 。不过幸好,在 XS 中,update() 的执行原理是根据主键 id ,先删除原来的数据,然后再添加一条。如果主键 id 指定的数据不存在,就是新添加一条数据。
划重点,先删除原来的,再添加一条新的进去。也就是说,这个更新也不是传统数据库层面的上更新,而是类似于很多 OLAP 大数据数据库的处理方式。这样的操作就会带来几个问题,我们来看下。
首先,主键 id 是可以重复的,可以有多条数据的主键是一样的。比如我们插入两条 id 一样的数据。
$xs->index->add(new XSDocument(['title'=>'添加一条1','content'=>'添加一条'.date('YmdHis'),'id'=>'123123123']));
$xs->index->add(new XSDocument(['title'=>'添加一条2','content'=>'添加一条'.date('YmdHis'),'id'=>'123123123']));
关于为啥主键 id 可以插入同样的数据问题,之前已经说过了,这里就不在赘述了,不记得的小伙伴可以翻看一下之前的文章。添加成功之后,执行下面的更新操作。
$xs->index->update(new XSDocument(['title'=>'添加一条3','pub_time'=>time(),'id'=>'123123123']));
这里我们更新 id 为 123123123 的数据,但内容产生了很大的变化,标题最后的数字是 3 ,还没有 content 了,出来的结果是什么呢?
> php vendor/hightman/xunsearch/util/Quest.php --show-query ./config/5-zyarticle-test1.ini "添加一条"
--------------------
解析后的 QUERY 语句:Query()
--------------------
在 1 条数据中,大约有 1 条包含 ,第 1-1 条,用时:0.0118 秒。
1. 添加一条3 #123123123# [100%,0.00]
Category_name: Tags: Pub_time:20221128
看到结果了吧,数据只剩一条了,content 内容也没有了,但是 pub_time 有数据了。
这就是 update() 先删除,后添加的典型效果演示。如果你需要只更新其中某一个字段的值,也必须将所有的字段都带上,否则别的字段可能就没了哦。另外,删除多余的相同主键的数据其实在逻辑上是正确的,这个并没有其它多说的。
先别着急开骂,因为我们又要搬老大出来救场了。没错,ES 也是这样的!
如果你学过一点 ES 的话,那就会知道它可以通过 PUT 和 POST 两种请求方式来更新数据。不管使用哪个,如果直接在参数中使用字段更新,原来的文档数据就会被覆盖,就跟 XS 的效果是一样的,没写的字段就没有了。但 ES 可以通过指定 doc
字段,然后再更新,达到更新指定的字段效果。
总结一下,XS 中的 update() 相当于就是 ES 中的普通更新方式,但 XS 中没有提供 doc
的语法糖,只有先删后增这一种更新方式。
至于为什么搜索引擎都要这样来更新呢?因为倒排索引,之前我们已经学习过了,倒排索引是分词之后通过词项来建立和索引文档主键 id 的映射关系。如果是修改的话,需要的工作量非常大,需要遍历每个单词词项然后修改它所指向的 id 。而现在,我们先删,然后重新使用添加的过程进行索引。另外还有分数以及各种其它的计算都要重来一次,因此,直接删除再添加效率会更高一些,大概是这么个意思,但具体的原因和解释要更加的复杂,也不是我的水平所能理解的了,有兴趣的小伙伴可以自己再查找资料进行深入的学习。
前面我们已经看到了,在使用 update() 的时候,如果主键数据不存在,就是新增。通过源码,相信大家也能看到 add() 里面就一行代码,直接就是去调用 update() ,但第二个参数设置成了 true 。那么,咱们直接用 update() 做新增不就好了?为啥还要一个 add() 呢?
其实从 add() 和 update() 的行为就可以看出来 ,add() 明显是少了一个判断的,而这个判断就是主键 id 数据是否存在。也就是我们一直在说的,add() 会忽略主键的唯一性,直接添加数据。而 update() 则不会,它会去进行查找判断,如果找到了,那么还得先删除,相对来说其实就是多了两个步骤。
由上可知,如果我们是针对大量数据的全量新建或者重建索引,那么 add() 的效率更好。而如果是日常的索引建立更新,比如说日常的添加修改文章、添加修改商品等等,则使用 update() 会更为保险,能够保证主键唯一性。
在上面的添加和修改中,其实很多基础概念就已经讲完了,对于删除来说,没啥特别的东西,不过它有两种删除方式。
一是根据主键 id 进行删除,也是最推荐的方式。
$xs->index->del('6380e14c38b04');
这个参数就是 id 属性的字段值,我们在上面的测试代码中使用 add() 添加的是 uniqid() 类型的数据,所以 id 字段保存的内容就是这个样子的。除了单个 id 之外,我们也可以批量删除。
$xs->index->del(['6380e241c27e5','6380e2423b047']);
另一种就是根据分词词项删除,这个嘛,先看例子。
$xs->index->del('添加一条','title');
大家可以试试上面这条删除语句,不出意外的话,是删不掉数据的。这是为啥呢?又要提到我们关于分词的概念了。这里的第一个参数是一个词项,注意,是词项,就是我们之前说过的 term 。也就是说,倒排索引字典中需要有一个 “添加一条” 这样的完整的单词的词项索引,才会删除这条索引对应的文档。很明显,这一句话肯定是要被分词的。它会被分成什么词呢?在 SDK 的测试文件 Quest.php 后面增加的参数 --show-query ,就可以看到分词后的查询语句内容,大家可以使用 “添加一条” 来进行搜索,能看到它被拆分为这样的结果。
> php vendor/hightman/xunsearch/util/Quest.php --show-query ./config/5-zyarticle-test1.ini "添加一条"
--------------------
解析后的 QUERY 语句:Query((添@1 AND 加一@2 AND 条@3))
--------------------
…………………………
好了,后面的不用多说了,直接使用 “加一” 来删除好了。
$xs->index->del('加一','title');
这样我们之前测试的数据就都可以删掉了。
不过,并不推荐这种方式。为啥呢?没错,它很灵活,就像数据库中 Delete 语句时的 Where 条件一样。但是,如果你没有对分词和词项有清晰的了解,就很有可能删错或删多。毕竟,它不像数据库的 Where 是完全匹配的。因此,不是说不能用,只是说不太推荐而已。用不用,还是要看你自己的权衡咯。
这个批量操作呀,还是要先拿数据库来做为例子。我们知道,在数据库操作时,如果有大量的写入,一条一条的 Insert 和一次 Insert 多个 Value 那样的批量插入相比,后者速度能提升不少。特别是如果数据库不在一个网段或者是远程连接数据库时。另外,之前我们学习过的 Redis 中的 Pipeline ,也是类似的效果,一次性批量提交一堆操作命令。而 XS 中的批量操作,是更类似于 Redis 的,因为它不止可以有 add() 操作,还可以有别的操作。
先来一个简单的测试。XS 中使用缓冲区的概念来实现批量操作,开始批量操作使用 openBuffer() 而结束则使用 closeBuffer() 。
$index = $xs->index;
$index->openBuffer();
$index->add(new XSDocument(['title'=>'添加一条','content'=>'添加一条'.date('YmdHis'),'id'=>uniqid()]));
sleep(60);
$index->add(new XSDocument(['title'=>'添加一条','content'=>'添加一条'.date('YmdHis'),'id'=>uniqid()]));
$index->add(new XSDocument(['title'=>'添加一条','content'=>'添加一条'.date('YmdHis'),'id'=>uniqid()]));
$index->closeBuffer();
在这个批量操作中,我们先开启 openBuffer() 然后使用 add() 添加一条数据。接着休息 60 秒,这时,你可以去尝试搜索查询数据,60秒内是查不到信息的,因为这时还没有提交。然后再 add() 两次,最后通过 closeBuffer() 关闭缓冲区实现数据提交。这时,再稍等几秒就可以查询到数据了。
openBuffer() 有一个参数,可以设置一个缓冲区大小的值。这个概念和我们之前在 Nginx 中的各种缓冲区大小的概念是类似的,也就是在批量操作内部的数据,如果超过了缓冲区设置的大小,直接就提交了,如果没有超过,就会继续往缓冲区添加。这个缓冲区的概念也不用多解释了吧,就是开辟的一片内存嘛。
有的同学可能会问,这个缓冲区大小要怎么设置呢?默认值是 4MB ,可以根据我们部署 XS 的服务器的内存大小和内存使用情况来设置。缓冲区越大,一次提交的数据就越多,网络频繁连接的次数就减少。大部分情况下其实可以不用设置,而如果有特殊需要,比如单个文档过大或者需要大量的全量操作索引时。
那么它的效果有那么明显吗?咱们可以来试试。
$index = $xs->index;
$time = microtime(true);
$index->openBuffer();
for($i=1;$i<=100000;$i++){
$index->add(new XSDocument(['title'=>'添加一条'.$i,'content'=>'添加一条'.$i.date('YmdHis'),'id'=>$i]));
}
$index->closeBuffer();
echo microtime(true)-$time;
// 不使用buffer 91.203243017197
// 使用buffer 2.8002960681915
这一段测试是直接循环 100000 次添加 100000 条数据,可以看到我的测试结果写在下面的注释中了。差距还是非常明显的吧,又要搬出 ES 大佬了,在 ES 中,类似的功能是 _bulk
。
除了添加之外,在缓冲区中也可以执行其它操作。
$index->openBuffer();
...
$index->add($doc);
...
$index->del($doc);
...
$index->update($doc);
...
$index->closeBuffer();
这个就不测试了,大家可以自己试试哦。
清空索引的代码我们其实也用过了。
$xs->index->clean();
这个就不多说了,没啥参数,一把清空整个索引项目里的所有文档数据,相当于 MySQL 中的 truncate 。在之前我们使用 的 SDK 提供的 Indexer.php ,也有 --clean 参数相当于调用这个函数。
但是需要注意的,clean() 清空索引是同步的操作,也就是说,一调用这个函数,马上进行查询,也查不到内容。数据马上被清空了,而且这个操作不可恢复,线上生产环境要慎用哦。同时,如果你想全量重建索引,使用 clean() 的话,因为后续的添加是异步的,所以会短暂出现索引库是空的情况,在这段时间内是没有任何数据的。要想避免这种情况,也就是想实现一边重建索引,一边还能继续查询,当索引重建完成后,查询到的也变成新数据的这种效果,就要使用下一个要学习的功能啦。
上面我们已经说过,要想平滑的,也就是不中断地完成索引地重建,就需要使用到平滑重建索引的功能。这个功能也是通过 XSIndex 的几个函数方法来实现的。
$index = $xs->index;
$index->stopRebuild();
$index->beginRebuild();
$time = microtime(true);
$index->openBuffer();
for($i=1;$i<=100000;$i++){
$index->add(new XSDocument([
'title'=>'添加一条'.$i,
'content'=>'添加一条'.$i.date('YmdHis'),
'pub_time'=>time(), // 增加pub_time数据
'id'=>$i
]));
}
$index->closeBuffer();
$index->endRebuild();
stopRebuild() 方法,用于清除上次重建失败的错误状态。在重建过程中,可能因为各种原因导致重建工作意外终止,这时索引库会进入一个崩溃状态,出现 DB has been rebuilding 的错误。我们就需要先通过 stopRebuild() 清除错误状态。
接着通过 beginRebuild() 方法开始重建,这时你可以尝试继续访问查询数据,还是可以正常搜索到的。然后我们开始重建工作,针对之前的数据,我们增加了 pub_time 属性内容。前面的测试中,所有数据的 pub_time 字段是没有数据的,现在我们就给它加上。
操作完成之后,使用 endRebuild() 方法结束结束重建。
和全量添加索引一样,过一会我们再次查询数据,就会发现所有数据都有 pub_time 属性了。在这个过程中,服务一直都是可用的。
平滑重建的内部实现,相当于是在一个临时的区域开辟一个新的库,把所有数据先更新到新库,等到全部数据索引完成后,再用新库来替换老的库,从而保证服务的不中断。为确保重建的顺利完成,在重建时,不要对同一个项目开启多个进程、连接,避免同时交替重建引发错乱。
今天的内容真的不复杂吧,只是针对索引数据的增、删、改操作,另外还加上了清空、批量操作以及平滑重建的内容。函数方法的使用都简单,重点还是需要转变很多思维,与数据的操作不同的地方都是需要关注的。比如说添加是异步的、修改是先删后增、删除如果按分词词项的注意点等等。
下篇文章,我们将继续学习 XSIndex 中剩余部分的内容。
测试代码:
https://github.com/zhangyue0503/dev-blog/blob/master/xunsearch/source/9.php
参考文档:
http://www.xunsearch.com/doc/php/api/XSIndex#addExdata-detail
http://www.xunsearch.com/doc/php/guide/index.add
http://www.xunsearch.com/doc/php/guide/index.update
http://www.xunsearch.com/doc/php/guide/index.del
http://www.xunsearch.com/doc/php/guide/index.clean
http://www.xunsearch.com/doc/php/guide/index.rebuild
http://www.xunsearch.com/doc/php/guide/index.buffer