技术饭
PHP中ElasticSearch的简单使用
1、简介:
Elasticsearch 是一个分布式、高扩展、高实时的搜索与数据分析引擎。它能很方便的使大量数据具有搜索、分析和探索的能力。充分利用Elasticsearch的水平伸缩性,能使数据在生产环境变得更有价值。
Elasticsearch 的实现原理主要分为以下几个步骤,首先用户将数据提交到Elasticsearch 数据库中,再通过分词控制器去将对应的语句分词,将其权重和分词结果一并存入数据,当用户搜索数据时候,再根据权重将结果排名,打分,再将返回结果呈现给用户。
Elasticsearch是与名为Logstash的数据收集和日志解析引擎以及名为Kibana的分析和可视化平台一起开发的。这三个产品被设计成一个集成解决方案,称为“Elastic Stack”(以前称为“ELK stack”)。
Elasticsearch可以用于搜索各种文档。它提供可扩展的搜索,具有接近实时的搜索,并支持多租户。Elasticsearch是分布式的,这意味着索引可以被分成分片,每个分片可以有0个或多个副本。每个节点托管一个或多个分片,并充当协调器将操作委托给正确的分片。再平衡和路由是自动完成的。相关数据通常存储在同一个索引中,该索引由一个或多个主分片和零个或多个复制分片组成。一旦创建了索引,就不能更改主分片的数量。
Elasticsearch使用Lucene,并试图通过JSON和Java API提供其所有特性。它支持facetting和percolating,如果新文档与注册查询匹配,这对于通知非常有用。另一个特性称为“网关”,处理索引的长期持久性;例如,在服务器崩溃的情况下,可以从网关恢复索引。Elasticsearch支持实时GET请求,适合作为NoSQL数据存储,但缺少分布式事务。
2、相关概念:
cluster:代表一个集群,集群中有多个节点,其中有一个为主节点,这个主节点是可以通过选举产生的,主从节点是对于集群内部来说的。es的一个概念就是去中心化,字面上理解就是无中心节点,这是对于集群外部来说的,因为从外部来看es集群,在逻辑上是个整体,你与任何一个节点的通信和与整个es集群通信是等价的。
shards:代表索引分片,es可以把一个完整的索引分成多个分片,这样的好处是可以把一个大的索引拆分成多个,分布到不同的节点上。构成分布式搜索。分片的数量只能在索引创建前指定,并且索引创建后不能更改。
replicas:代表索引副本,es可以设置多个索引的副本,副本的作用一是提高系统的容错性,当某个节点某个分片损坏或丢失时可以从副本中恢复。二是提高es的查询效率,es会自动对搜索请求进行负载均衡。
recovery:代表数据恢复或叫数据重新分布,es在有节点加入或退出时会根据机器的负载对索引分片进行重新分配,挂掉的节点重新启动时也会进行数据恢复。
river:代表es的一个数据源,也是其它存储方式(如:数据库)同步数据到es的一个方法。它是以插件方式存在的一个es服务,通过读取river中的数据并把它索引到es中,官方的river有couchDB的,RabbitMQ的,Twitter的,Wikipedia的。
gateway:代表es索引快照的存储方式,es默认是先把索引存放到内存中,当内存满了时再持久化到本地硬盘。gateway对索引快照进行存储,当这个es集群关闭再重新启动时就会从gateway中读取索引备份数据。es支持多种类型的gateway,有本地文件系统(默认),分布式文件系统,Hadoop的HDFS和amazon的s3云存储服务。
discovery.zen:代表es的自动发现节点机制,es是一个基于p2p的系统,它先通过广播寻找存在的节点,再通过多播协议来进行节点之间的通信,同时也支持点对点的交互。
Transport:代表es内部节点或集群与客户端的交互方式,默认内部是使用tcp协议进行交互,同时它支持http协议(json格式)、thrift、servlet、memcached、zeroMQ等的传输协议(通过插件方式集成)。
3、PHP中使用ElasticSearch
通过composer 安装
composer require 'elasticsearch/elasticsearch'
在代码中引入
require 'vendor/autoload.php';
use Elasticsearch\ClientBuilder;
$client = ClientBuilder::create()->setHosts(['172.16.55.53'])->build();
下面循序渐进完成一个简单的添加和搜索的功能。
首先要新建一个 index:index 对应关系型数据(以下简称MySQL)里面的数据库,而不是对应MySQL里面的索引,这点要清楚
$params = [
'index' => 'myindex', #index的名字不能是大写和下划线开头
'body' => [
'settings' => [
'number_of_shards' => 2, //分片
'number_of_replicas' => 0, //副本
]
]
];
$client->indices()->create($params);
在MySQL里面,光有了数据库还不行,还需要建立表,ES也是一样的,ES中的type对应MySQL里面的表。
注意:ES6以前,一个index有多个type,就像MySQL中一个数据库有多个表一样自然,但是ES6以后,每个index只允许一个type,在往以后的版本中很可能会取消type。
type不是单独定义的,而是和字段一起定义
$params = [
'index' => 'myindex',
'type' => 'mytype',
'body' => [
'mytype' => [
'_source' => [
'enabled' => true
],
'properties' => [
'id' => [
'type' => 'integer'
],
'first_name' => [
'type' => 'text',
'analyzer' => 'ik_max_word'
],
'last_name' => [
'type' => 'text',
'analyzer' => 'ik_max_word'
],
'age' => [
'type' => 'integer'
]
]
]
]
];
$client->indices()->putMapping($params);
在定义字段的时候,可以看出每个字段可以定义单独的类型,在first_name中还自定义了 分词器 ik,这个分词器是一个插件,需要单独安装的
现在 数据库和表都有了,可以往里面插入数据了
概念:这里的 数据 在ES中叫 文档
$params = [
'index' => 'myindex',
'type' => 'mytype', //'id' => 1, #可以手动指定id,也可以不指定随机生成
'body' => [
'first_name' => '张',
'last_name' => '三',
'age' => 35
]
];
$client->index($params);
多插入一点数据,然后来看看怎么把数据取出来:
通过id取出单条数据:
插曲:如果你之前添加文档的时候没有传入id,ES会随机生成一个id,这个时候怎么通过id查?id是多少都不知道啊。
所以这个插入一个简单的搜索,最简单的,一个搜索条件都不要,返回所有index下所有文档:
$data = $client->search();
现在可以去找一找id了,不过你会发现id可能长这样:zU65WWgBVD80YaV8iVMk,不要惊讶,这是ES随机生成的。
现在可以通过id查找指定文档了:
$params = [
'index' => 'myindex',
'type' => 'mytype',
'id' =>'zU65WWgBVD80YaV8iVMk'
];
$data = $client->get($params);
最后一个稍微麻烦点的功能:
注意:这个例子我不打算在此详细解释,看不懂没关系,这篇文章主要的目的是基本用法,并没有涉及到ES的精髓地方,
ES精髓的地方就在于搜索,后面的文章我会继续深入分析
$query = [
'query' => [
'bool' => [
'must' => [
'match' => [
'first_name' => '张',
]
],
'filter' => [
'range' => [
'age' => ['gt' => 76]
]
]
]
]
];
$params = [
'index' => 'myindex',// 'index' => 'm*', #index 和 type 是可以模糊匹配的,甚至这两个参数都是可选的
'type' => 'mytype',
'_source' => ['first_name','age'], // 请求指定的字段
'body' => array_merge([
'from' => 0,
'size' => 5
],$query)
];
$data = $this->EsClient->search($params);
上面的是一个简单的使用流程,但是不够完整,只讲了添加文档,没有说怎么删除文档,
下面我贴出完整的测试代码,基于Laravel环境,当然环境只影响运行,不影响理解,包含基本的常用操作:
<?php
use Elasticsearch\ClientBuilder;
use Faker\Generator as Faker;
/**
* ES 的 php 实测代码
*/
class EsDemo
{
private $EsClient = null;
private $faker = null;
/**
* 为了简化测试,本测试默认只操作一个Index,一个Type,
* 所以这里固定为 megacorp和employee
*/
private $index = 'megacorp';
private $type = 'employee';
public function __construct(Faker $faker)
{
/**
* 实例化 ES 客户端
*/
$this->EsClient = ClientBuilder::create()->setHosts(['172.16.55.53'])->build();
/**
* 这是一个数据生成库,详细信息可以参考网络
*/
$this->faker = $faker;
}
/**
* 批量生成文档
* @param $num
*/
public function generateDoc($num = 100) {
foreach (range(1,$num) as $item) {
$this->putDoc([
'first_name' => $this->faker->name,
'last_name' => $this->faker->name,
'age' => $this->faker->numberBetween(20,80)
]);
}
}
/**
* 删除一个文档
* @param $id
* @return array
*/
public function delDoc($id) {
$params = [
'index' => $this->index,
'type' => $this->type,
'id' =>$id
];
return $this->EsClient->delete($params);
}
/**
* 搜索文档,query是查询条件
* @param array $query
* @param int $from
* @param int $size
* @return array
*/
public function search($query = [], $from = 0, $size = 5) {
// $query = [
// 'query' => [
// 'bool' => [
// 'must' => [
// 'match' => [
// 'first_name' => 'Cronin',
// ]
// ],
// 'filter' => [
// 'range' => [
// 'age' => ['gt' => 76]
// ]
// ]
// ]
//
// ]
// ];
$params = [
'index' => $this->index,
//'index' => 'm*', #index 和 type 是可以模糊匹配的,甚至这两个参数都是可选的
'type' => $this->type,
'_source' => ['first_name','age'], // 请求指定的字段
'body' => array_merge([
'from' => $from,
'size' => $size
],$query)
];
return $this->EsClient->search($params);
}
/**
* 一次获取多个文档
* @param $ids
* @return array
*/
public function getDocs($ids) {
$params = [
'index' => $this->index,
'type' => $this->type,
'body' => ['ids' => $ids]
];
return $this->EsClient->mget($params);
}
/**
* 获取单个文档
* @param $id
* @return array
*/
public function getDoc($id) {
$params = [
'index' => $this->index,
'type' => $this->type,
'id' =>$id
];
return $this->EsClient->get($params);
}
/**
* 更新一个文档
* @param $id
* @return array
*/
public function updateDoc($id) {
$params = [
'index' => $this->index,
'type' => $this->type,
'id' =>$id,
'body' => [
'doc' => [
'first_name' => '张',
'last_name' => '三',
'age' => 99
]
]
];
return $this->EsClient->update($params);
}
/**
* 添加一个文档到 Index 的Type中
* @param array $body
* @return void
*/
public function putDoc($body = []) {
$params = [
'index' => $this->index,
'type' => $this->type,// 'id' => 1, #可以手动指定id,也可以不指定随机生成
'body' => $body
];
$this->EsClient->index($params);
}
/**
* 删除所有的 Index
*/
public function delAllIndex() {
$indexList = $this->esStatus()['indices'];
foreach ($indexList as $item => $index) {
$this->delIndex();
}
}
/**
* 获取 ES 的状态信息,包括index 列表
* @return array
*/
public function esStatus() {
return $this->EsClient->indices()->stats();
}
/**
* 创建一个索引 Index (非关系型数据库里面那个索引,而是关系型数据里面的数据库的意思)
* @return void
*/
public function createIndex() {
$this->delIndex();
$params = [
'index' => $this->index,
'body' => [
'settings' => [
'number_of_shards' => 2,
'number_of_replicas' => 0
]
]
];
$this->EsClient->indices()->create($params);
}
/**
* 检查Index 是否存在
* @return bool
*/
public function checkIndexExists() {
$params = [
'index' => $this->index
];
return $this->EsClient->indices()->exists($params);
}
/**
* 删除一个Index
* @return void
*/
public function delIndex() {
$params = [
'index' => $this->index
];
if ($this->checkIndexExists()) {
$this->EsClient->indices()->delete($params);
}
}
/**
* 获取Index的文档模板信息
* @return array
*/
public function getMapping() {
$params = [
'index' => $this->index
];
return $this->EsClient->indices()->getMapping($params);
}
/**
* 创建文档模板
* @return void
*/
public function createMapping() {
$this->createIndex();
$params = [
'index' => $this->index,
'type' => $this->type,
'body' => [
$this->type => [
'_source' => [
'enabled' => true
],
'properties' => [
'id' => [
'type' => 'integer'
],
'first_name' => [
'type' => 'text',
'analyzer' => 'ik_max_word'
],
'last_name' => [
'type' => 'text',
'analyzer' => 'ik_max_word'
],
'age' => [
'type' => 'integer'
]
]
]
]
];
$this->EsClient->indices()->putMapping($params);
$this->generateDoc();
}
}
ES文档中字段有多种类型 官方文档。
这几个比较常用:
text, keyword, integer, float, boolean, object, geo_point(地理坐标), geo_shape(描述地理区域),date.
注:不要以为date只能表示 2015-01-01 这种类型,2015/01/01 12:10:30这种类型也一样可以,不像MySQL里面时间还分很多种细分的类型,ES就一个date类型。
注意:这里没有列出array,在ES中,array不是一种单独的类型,但是你可以往ES里面存数组,这个地方有点难以理解,
举个例子: 文档里面我要定义一个字段叫 friends ,用来存储用户的朋友列表,用 text 类型定义字段:
'friends' => [
'type' => 'text'
]
看似这仅仅定义了一个text类型的字段,并不是我们想要的数组,重点解释来了,虽然我们的friends是字符串类型,但是我们在存入数据的时候 往 friends里面存储两个或者三个字符串,他就变成数组了!其实这句话描述还是不准确,不是从字符串变成数组,而是多个字符串组成了一个数组!
插入数据:
$this->putDoc([
'first_name' => $this->faker->name,
'last_name' => $this->faker->name,
'age' => $this->faker->numberBetween(20,80),
'height' => (float)($this->faker->numberBetween(160,200)/100),
'friends' => [
$this->faker->name(),
$this->faker->name(),
$this->faker->name(),
$this->faker->name()
]
]);
你看,friends明明是 text 类型,但是我在插入的时候插入了多条数据,他变成了一个字符串类型的集合,前面我说他是数组,这个地方我把他说成是集合,这回更准确了,因为数组在ES中查询是不能保证顺序的,所以集合更准确,官方文档中也表示他更像集合再说一下object,模板里面这样定义:
'info' => [
'type' => 'object',
'properties' => [
'country' => [
'type' => 'text',
'analyzer' => 'ik_max_word'
],
'sex' => [
'type' => 'keyword'
]
]
]
这里定义了一个对象文档,指定了下面两个属性的基本信息,但是不代表这个对象就只能存储两个属性,比如我还可以在添加文档的时候往里面添加一个skin 肤色的字段,完全没有问题,只不过这里定义的两个字段我们设置了类型和具体的analyzer,没有在这里定义,但是我们实际上添加了的字段比如skin,ES会自动设置正确的类型,以及默认的analyzer.
存入数据:
'info' => [
'country' => ['中国','印度','法国','英国','瑞士','刚果共和国'][random_int(0,5)],
'sex' => ['男','女'][random_int(0,1)],
'skin' => ['白','黑','黄'][random_int(0,2)],
]
还有一个keyword,他和text都表示字符串,区别在于 keyword里面的值不会被分词器分词,text里面的值会被分词器智能拆分,记住这一点,这一点非常重要,后面还会讲到这个区别。在定义text字段的时候 analyzer和index你需要清楚的地方:
'last_name' => [
'type' => 'text',
//'analyzer' => 'standard', // 这个地方不设置analyzer会默认standard
//'index' => false
]
analyzer不设置analyzer会默认standard
对于老版本的 ES,这里的index允许设置为 analyzed/not_analyzed/no,大部分网络上的文章都是这样讲的,但是,最新版本已经移除了这些选项,现在只能是 true或false,所以我建议当你有一点基础后,通读一下官方最新文档,虽然是英文的,如果这里设置为false,这个字段不加入索引,不能在查询条件中出现,默认为true
等一下,这里突然发现有点不对劲,以前可以设置 分析/不分析/不索引,现在只能设置索引和不索引了,如果想实现索引且不分析,那keyword类型刚好符合,而text字段是为分析而生的。
ES中的搜索分两个概念,匹配和过滤
匹配通常针对的是 text 类型的字段,而过滤通常针对的精确的类型,比如 integer,keyword,date等,之所以加了通常二字,说明他们之间没有明确的规定,匹配可以去匹配data,integer等类型,过滤也可以去过滤text字段,通过匹配的方式去找精确类型通常不会出现什么问题,通过过滤去找text类型的数据通常会得到意外的结果。
谨记:如果没有特殊情况,匹配针对text类型,过滤针对其他类型,但是对于精确类型使用过滤的性能通常比匹配更高,所以能使用过滤的地方都过滤。
注意:这里要区别一下MySQL中的过滤概念,MySQL中的过滤是对查找后的数据进行过滤,而在ES中,过滤和匹配都等同于MySQL中的查找,匹配适合查找模糊数据,过滤适合查找精确数据而已。
为了简化代码,下面的搜索都基于一下这份代码,更改的部分只是 $query:
$params = [
'index' => $this->index,
'type' => $this->type,
'body' => array_merge([
'from' => $from,
'size' => $size
],$query)
];
常用的过滤:
term(精确查找)
查找倪玲为44的数据
$query = [
'query' => [
'term' => [
'age' => 44
]
]
];
terms(精确查找多个字段)
查找年龄为 44或55或66的数据
$query = [
'query' => [
'terms' => [
'age' => [44,55,66]
]
]
];
range(范围查找)
$query = [
'query' => [
'range' => [
'age' => [
'gt' => 43,
'lt' => 45
]
]
]
];
exists(等同于MySQL中的 is not null),查找存在age属性的文档
$query = [
'query' => [
'exists' => [
'field' => 'age'
]
]
];
missing(等同于 MySQL中的 is null),注意:这个过滤方法在2.x版本就废弃了,请使用 must_not 嵌套 exists 来实现
bool(用来组合其他过滤条件,包含 must,must_not,should操作)
$query = [
'query' => [
'bool' => [
'should' => [
'range' => [
'height' => ['gt' => 1.8]
]
],
'must_not' => [
'term' => [
'info.sex' => '女'
]
],
'must' => [
[
'term' => [
'info.country' => '法国'
]
],
[
'term' => [
'info.skin' => '白'
]
]
]
]
]
];
上面这个查询的意思是,身高应该大于1.8,性别不能是女,国家是法国且肤色是黑色。
这里country实际上是text类型,但是我任然通过过滤的方法找到了正确的值,但是这种方式是非常危险的,这里之所以找到了正确的值,是因为country类型很简单,碰巧analyzer(这里用的ik,如果是standard就没那么好运了)没有对其进行拆分。
常用的查询:
match(匹配一个字段)
$query = [
'query' => [
'match' => [
'height' => '1.8'
]
]
];
match_all(匹配所有文档,相当于没有条件)
等于是 $query = []
multi_match(匹配多个字段)
匹配姓和名里面包含 'Riley Libby Preston' 的数据
$query = [
'query' => [
'multi_match' => [
'query' => 'Riley Libby Preston',
'fields' => ['first_name','last_name']
]
]
];
bool(用来组合其他匹配条件,包含 must,must_not,should操作)
$query = [
'query' => [
'bool' => [
'should' => [
'match' => [
'height' => '1.8'
]
],
'must_not' => [
'match' => [
'info.sex' => '男'
]
]
]
]
];
在实际使用中,匹配和过滤都是混合搭配使用的,比如:
$query = [
'query' => [
'bool' => [
'should' => [
'match' => [
'height' => '1.8'
]
],
'must_not' => [
'term' => [
'info.sex' => '女'
]
],
'must' => [
[
'match' => [
'info.country' => '法国'
]
],
[
'match' => [
'info.skin' => '白'
]
]
]
]
]
];
match时常会出现一些怪异的现象,如果你不清楚你用的analyzer,比如这个例子:
$query = [
'query' => [
'bool' => [
'must' => [
[
'match' => [
'last_name' => 'Hamill'
]
],
[
'match' => [
'info.country' => '法国'
]
]
]
]
]
];
这个查询的需求是选出last_name中匹配到Hamill并且国家匹配到法国的结果,但是查询的结果是这样的,last_name 的中包含 Hamill,在我们意料之中,但是 country出现了英国,法国等很多国家,这个太意外了,现在来改造一下这个 $query,很小的改造,只需要把法国改成法,再次查询,这次的结果完美的实现了我们的需求。
原因在于:
文档中的法国二字被analyzer拆分成 (法,国) 存储在索引中,同理,英国被拆分为 (英,国),现在你搜索法国的时候,你的这个搜索词默认会被拆分成 (法,国),然后拿着这两个词去分别查找,第一个法可以匹配所有法国,第二个国字可以匹配到英国,美国等所有包含国字的结果。
现在你知道结果的形成原因了。
这个很大程度上上取决于你使用的analyzer,不同的analyzer分词的策略不一样,所以你有必要先搞明白你用的分词器,他的大概分词策略,上面这个例子没有指定analyzer,是ES默认的分词器在起作用,当我指定analyzer为 ik_max_word后,情况发生了变化,这个时候法国被当成了一个整体,没有被拆分。
可以通过简单的测试来看看具体分词器的分词方式:
$params = [
'body' => [
'analyzer' => 'ik_max_word', //默认 standard
'text' => '我在广场吃着炸鸡'
]
];
return $this->EsClient->indices()->analyze($params);
默认分词器standard会把这句话简单的拆分成单个字,而ik相对就更懂中文一点,拆分出来的词更有语义化,大部分的analyzer对英文的分词都基于空格拆分
参考:
Elasticsearch Request Body与Query DSL简介
Elasticsearch Guide [8.0] » Cross-cluster search, clients, and integrations » Request body
文明上网理性发言!