技术饭

PHP中ElasticSearch的简单使用

copylian    0 评论    9119 浏览    2022.03.03

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对英文的分词都基于空格拆分



参考:

PHP中使用ElasticSearch(一)

PHP中使用ElasticSearch(二)

全文搜索引擎 Elasticsearch 入门教程

ElasticSearch简介

Elasticsearch Request Body与Query DSL简介

Elasticsearch Guide [8.0] » Cross-cluster search, clients, and integrations » Request body

ElasticSearch PHP 搜索实例

ElasticSearch中"distinct","count"和"group by"的实现

只袄早~~~
感谢你的支持,我会继续努力!
扫码打赏,感谢您的支持!

文明上网理性发言!

  • 还没有评论,沙发等你来抢