0%

前言

一般情况下,如果谈起查询性能优化,多数人的第一感知都是想到一些复杂的语句,想到查询需要返回大量的数据。
但有些情况下,“查一行”,也会执行得特别慢。今天,就来探讨这个问题,看看什么情况下,会出现这个现象。

当然,如果 MySQL 数据库本身就有很大的压力,导致数据库服务器 CPU 占用率很高或 ioutil(IO 利用率)很高,
这种情况下所有语句的执行都有可能变慢,不属于今天的探讨范围。

正题

首先,先做一个测试表在插入1万条数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
CREATE TABLE `t1` (
`id` int(11) NOT NULL,
`c` int(11) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB;

delimiter ;;
create procedure idata()
begin
declare i int;
set i=1;
while(i<=10000)do
insert into t1 values(i,i);
set i=i+1;
end while;
end;;
delimiter ;

call idata();

几种场景类型分析

第一类:查询长时间不返回

执行下面的sql

1
mysql> select * from t1 where id=1;

查询长时间不返回

一般碰到这种情况的话,大概率是表 t1 被锁住了。接下来分析原因的时候,一般都是首先执行一下 show processlist 命令,看看当前语句处于什么状态。

然后再根据状态去分析产生的原因,以及能否复现

等MDL锁

如下图所示,就是使用 show processlist 命令查看 Waiting for table metadata lock 的示意图。

这个状态表示的是,现在有一个线程正在表 t1 上请求或者持有 MDL 写锁,把 select 语句堵住了。

不过,在 MySQL 5.7 版本下复现这个场景,也很容易。

SessionASessionB
lock table t1 write
select * from t1 where id=1;

session A 通过 lock table 命令持有表 t1 的 MDL 写锁,而 session B 的查询需要获取 MDL 读锁。所以,session B 进入等待状态。

这类问题的处理方式,就是找到谁持有 MDL 写锁,然后把它 kill 掉。

但是,由于在 show processlist 的结果里面,session A 的 Command 列是“Sleep”,导致查找起来很不方便。不过有了 performance_schema 和 sys 系统库以后,就方便多了。(MySQL 启动时需要设置 performance_schema=on,相比于设置为 off 会有 10% 左右的性能损失)

查看是否支持performance_schema

1
select * from information_schema.engines where engine ='performance_schema';


是否开启performance_schema

1
show variables like 'performance_schema';

通过查询 sys.schema_table_lock_waits 这张表,我们就可以直接找出造成阻塞的 process id,把这个连接用 kill 命令断开即可。

等FLUSH

另一种场景
我在表t1上执行这样一条sql

1
mysql> select * from information_schema.processlist where id=1;

查出来这个线程的状态是 Waiting for table flush

这个状态表示的是,现在有一个线程正要对表 t1 做 flush 操作。MySQL 里面对表做 flush 操作的用法,一般有以下两个:

1
2
flush tables t1 with read lock;
flush tables with read lock;

这两个 flush 语句,如果指定表 t1 的话,代表的是只关闭表 t1;如果没有指定具体的表名,则表示关闭 MySQL 里所有打开的表。

但是正常这两个语句执行起来都很快,除非它们也被别的线程堵住了。

所以,出现 Waiting for table flush 状态的可能情况是:有一个 flush tables 命令被别的语句堵住了,然后它又堵住了我们的 select 语句。

复现一下这种情况:

SessionASessionBSessionC
select sleep(1) from t1
flush table t1
select * from t1 where id=1;
在 session A 中,我故意每行都调用一次 sleep(1),这样这个语句默认要执行 1 万秒,在这期间表 t 一直是被 session A“打开”着。然后,session B 的 flush tables t 命令再要去关闭表 t,就需要等 session A 的查询结束。这样,session C 要再次查询的话,就会被 flush 命令堵住了。

下面是show processlist 结果

等行锁

现在,经过了表级锁的考验,我们的 select 语句终于来到引擎里了。

1
mysql> select * from t1 where id=1 lock in share mode; 

由于访问 id=1 这个记录时要加读锁,如果这时候已经有一个事务在这行记录上持有一个写锁,我们的 select 语句就会被堵住。

行锁操作复现:

SessionASessionB
begin;
update t1 set c=c+1 where id=1;
select * from t1 where id=1 lock in share mode;
下面是show processlist 结果

显然,session A 启动了事务,占有写锁,还不提交,是导致 session B 被堵住的原因。

这个问题并不难分析,但问题是怎么查出是谁占着这个写锁。如果你用的是 MySQL 5.7 版本,可以通过 sys.innodb_lock_waits 表查到。

查询方法:

1
mysql> select * from t1 sys.innodb_lock_waits where locked_table='`test`.`t1`'\G

可以看到,这个信息很全,9957 号线程是造成堵塞的罪魁祸首。而干掉这个罪魁祸首的方式,就是 KILL QUERY 9957 或 KILL 9957。

不过,这里不应该显示“KILL QUERY 9957”。这个命令表示停止 9957 号线程当前正在执行的语句,而这个方法其实是没有用的。因为占有行锁的是 update 语句,这个语句已经是之前执行完成了的,现在执行 KILL QUERY,无法让这个事务去掉 id=1 上的行锁。

实际上,KILL 9957 才有效,也就是说直接断开这个连接。这里隐含的一个逻辑就是,连接被断开的时候,会自动回滚这个连接里面正在执行的线程,也就释放了 id=1 上的行锁。

第二类:查询慢

经过了重重封“锁”,再来看看一些查询慢的例子

1
mysql> select * from t1 where c=9000 limit 1;

由于字段 c 上没有索引,这个语句只能走 id 主键顺序扫描,因此需要扫描 5 千行。

通过slow_log 看一下

1
2
set global slow_query_log=ON;
set long_query_time=0; #我们让所有查询都加入到slow_log中
1
show variables like '%slow%';

slow_log结果

Rows_examined 显示扫描了 9000 行。你可能会说,不是很慢呀,6 毫秒就返回了,我们线上一般都配置超过 1 秒才算慢查询。但你要记住:
坏查询不一定是慢查询。我们这个例子里面只有 1 万行记录,数据量大起来的话,执行时间就线性涨上去了。

扫描行数多,所以执行慢,这个很好理解。

但是接下来,我们再看一个只扫描一行,但是执行很慢的语句。

看下面的例子

SessionASessionB
start transaction with consistent snapshot;
update t1 set c=c+1 where id=1;//执行100万次
select * from t1 where id = 1;
select * from t1 where id = 1 lock in share mode;
先看看执行一次的结果

由此可推出SessionB执行100万次后结果
select * from t1 where id = 1 lock in share mode;

idc
11000001
1 row in set (0.00 sec)

此时,session A 先用 start transaction with consistent snapshot 命令启动了一个事务,之后 session B 才开始执行 update 语句。

session B 更新完 100 万次,生成了 100 万个回滚日志 (undo log)。

带 lock in share mode 的 SQL 语句,是当前读,因此会直接读到 1000001 这个结果,所以速度很快;而 select * from t where id=1 这个语句,是一致性读,因此需要从 1000001 开始,依次执行 undo log,执行了 100 万次以后,才将 1 这个结果返回。

小结

今天列举了在一个简单的表上,执行“查一行”,可能会出现的被锁住和执行慢的例子。这其中涉及到了表锁、行锁和一致性读的概念。
在实际使用中,碰到的场景会更复杂。但大同小异。

参考

  • 《极客时间林晓斌Mysql专场》

Query-Then-Fetch

Search执行的时候实际分两个步骤运作的

  • Query阶段
  • Fetch阶段

在es里被称为Query-Then-Fetch机制

Search的运行机制-Query阶段

node3在接收到用户的search请求后,会先进行Query阶段(此时是CoordinatingNode角色)
node3在6个主副分片中随机选择3个分片,发送search request
被选中的3个分片会分别执行查询并排序,返回from+size个文档Id和排序值
node3整合3个分片返回的from+size个文档Id ,根据排序值排序后选取from到from+size的文档Id

request
1
2
3
4
5
GET movies/_search
{
"from": 10,
"size": 20
}

表示从第10个文档开始,截取第10-29的文档

Search的运行机制-Fetch阶段

node3根据Query阶段获取的文档Id列表去对应的shard上获取文档详情数据a

  • node3向相关的分片发送multi-get请求
  • 3个分片返回文档详细数据
  • node3拼接返回的结果并返回给客户

Search的运行机制-相关性算分问题

相关性算分在shard与shard间是相互独立的,也就意味着同一个Term的IDF等值在不同shard上是不同的。文档的相关性算分和它所处的shard相关

在文档数量不多时,会导致相关性算分严重不准的情况发生

加上explain参数可查看文档内容所在shard

request
1
2
3
4
5
6
7
8
9
10
11
12
GET movies/_search
{
"explain": true,
"query": {
"match": {
"genre": {
"query": "Comedy Children",
"operator": "and"
}
}
}
}

解决思路有两个:

一是设置分片数为1个,从根本上排除问题,在文档数量不多的时候可以考虑该方。案,比如百万到千万级别的文档数量
二是使用DFS Query-then-Fetch查询方式.DFS Query-then-Fetch是在拿到所有文档后再重新完整的计算一次相关性算分,耗费更多的cpu和内存,执行性能也比较低下,一般不建议使用。使用方式如下:

request
1
2
3
4
5
6
7
8
9
10
11
12
GET movies/_search?search_type=dfs_query_then_fetch
{
"explain": false,
"query": {
"match": {
"genre": {
"query": "Comedy Children",
"operator": "and"
}
}
}
}

sorting

es默认会采用相关性算分排序,用户可以通过设定sort参数来自行设定排序规则

排序的过程实质是对字段原始内容排序的过程,这个过程中倒排索引无法发挥作用,需要用到正排索引,也就是通过文档Id和字段可以快速得到字段原始内容。

es对此提供了2种实现方式:

  • fielddata默认禁用
  • doc values默认启用,除了text类型
request
1
2
3
4
5
6
7
8
9
10
GET movies/_search
{
"sort": [
{
"title": {
"order": "desc"
}
}
]
}

response:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
{
"error": {
"root_cause": [
{
"type": "illegal_argument_exception",
"reason": "Fielddata is disabled on text fields by default. Set fielddata=true on [title] in order to load fielddata in memory by uninverting the inverted index. Note that this can however use significant memory. Alternatively use a keyword field instead."
}
],
"type": "search_phase_execution_exception",
"reason": "all shards failed",
"phase": "query",
"grouped": true,
"failed_shards": [
{
"shard": 0,
"index": "movies",
"node": "6pljLZ0vQQKObirnkOOsnw",
"reason": {
"type": "illegal_argument_exception",
"reason": "Fielddata is disabled on text fields by default. Set fielddata=true on [title] in order to load fielddata in memory by uninverting the inverted index. Note that this can however use significant memory. Alternatively use a keyword field instead."
}
}
],
"caused_by": {
"type": "illegal_argument_exception",
"reason": "Fielddata is disabled on text fields by default. Set fielddata=true on [title] in order to load fielddata in memory by uninverting the inverted index. Note that this can however use significant memory. Alternatively use a keyword field instead.",
"caused_by": {
"type": "illegal_argument_exception",
"reason": "Fielddata is disabled on text fields by default. Set fielddata=true on [title] in order to load fielddata in memory by uninverting the inverted index. Note that this can however use significant memory. Alternatively use a keyword field instead."
}
}
},
"status": 400
}

解决办法:

  • 使用keyword排序
  • fielddata开启
    request
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    GET movies/_search
    {
    "sort": [
    {
    "title.keyword": {
    "order": "desc"
    }
    }
    ]
    }

Fileddata vs DocValues

对比FileddataDocValues
创建时机搜索时即时创建索引时创建
创建位置JVM Heap磁盘
优点不占用磁盘空间不占用Heap内存
缺点文档过多时,即时创建花费时间长,内存占用高减慢索引的速度,占用磁盘空间

Fielddata默认是关闭的,可以通过如下api开启:

  • 此时字符串是按照分词后的term排序,往往结果很难符合预期
  • 一般是在对分词做聚合分析的时候开启,
request
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
PUT movies/_mapping
{
"properties": {
"title": {
"type": "text",
"fielddata": true # 可随时开启
}
}
}
# 开启后在查询不会报错
GET movies/_search
{
"sort": [
{
"title.keyword": {
"order": "desc"
}
}
]
}

Doc Values默认是启用的,可以在创建索引的时候关闭:-如果后面要再开启doc values ,需要做reindex操作

request
1
2
3
4
5
6
7
8
9
10
11
DELETE test_doc_values
PUT test_doc_values
PUT test_doc_values/_mapping
{
"properties": {
"username": {
"type": "keyword",
"doc_values": false
}
}
}

关闭后在排序会报错

request
1
2
3
4
5
6
7
8
9
10
GET test_doc_values/_search
{
"sort": [
{
"username": {
"order": "desc"
}
}
]
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
{
"error": {
"root_cause": [
{
"type": "illegal_argument_exception",
"reason": "Can't load fielddata on [username] because fielddata is unsupported on fields of type [keyword]. Use doc values instead."
}
],
"type": "search_phase_execution_exception",
"reason": "all shards failed",
"phase": "query",
"grouped": true,
"failed_shards": [
{
"shard": 0,
"index": "test_doc_values",
"node": "6pljLZ0vQQKObirnkOOsnw",
"reason": {
"type": "illegal_argument_exception",
"reason": "Can't load fielddata on [username] because fielddata is unsupported on fields of type [keyword]. Use doc values instead."
}
}
],
"caused_by": {
"type": "illegal_argument_exception",
"reason": "Can't load fielddata on [username] because fielddata is unsupported on fields of type [keyword]. Use doc values instead.",
"caused_by": {
"type": "illegal_argument_exception",
"reason": "Can't load fielddata on [username] because fielddata is unsupported on fields of type [keyword]. Use doc values instead."
}
}
},
"status": 400
}

text类型不支持Doc Values,Fielddata只能支持与text类型

分页与遍历

分页与遍历-fromsize

es提供了3种方式来解决分页与遍历的问题:

  • from/size
  • scroll
  • search_after
    from/size最常用的分页方案

from指明开始位置size指明获取总数

深度分页是一个经典的问题:在数据分片存储的情况下如何获取前1000个文档?
获取从990-1000的文档时,会在每个分片上都先获取1000个文档,然后再由.Coordinating Node聚合所有分片的结果后再排序选取前1000个文档
页数越深,处理文档越多,占用内存越多,耗时越长,尽量避免深度分页, es通过 index.max_result_window限定最多到10000条数据

request
1
2
3
4
5
6
7
8
9
10
11
GET movies/_search
{
"from": 10,
"size": 10000
}

GET movies/_search
{
"from": 10000,
"size": 10
}

response

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
{
"error": {
"root_cause": [
{
"type": "illegal_argument_exception",
"reason": "Result window is too large, from + size must be less than or equal to: [10000] but was [10010]. See the scroll api for a more efficient way to request large data sets. This limit can be set by changing the [index.max_result_window] index level setting."
}
],
"type": "search_phase_execution_exception",
"reason": "all shards failed",
"phase": "query",
"grouped": true,
"failed_shards": [
{
"shard": 0,
"index": "movies",
"node": "6pljLZ0vQQKObirnkOOsnw",
"reason": {
"type": "illegal_argument_exception",
"reason": "Result window is too large, from + size must be less than or equal to: [10000] but was [10010]. See the scroll api for a more efficient way to request large data sets. This limit can be set by changing the [index.max_result_window] index level setting."
}
}
],
"caused_by": {
"type": "illegal_argument_exception",
"reason": "Result window is too large, from + size must be less than or equal to: [10000] but was [10010]. See the scroll api for a more efficient way to request large data sets. This limit can be set by changing the [index.max_result_window] index level setting.",
"caused_by": {
"type": "illegal_argument_exception",
"reason": "Result window is too large, from + size must be less than or equal to: [10000] but was [10010]. See the scroll api for a more efficient way to request large data sets. This limit can be set by changing the [index.max_result_window] index level setting."
}
}
},
"status": 400
}

分页与遍历-scroll

遍历文档集的api ,以快照的方式来避免深度分页的问题

  • 不能用来做实时搜索,因为数据不是实时的
  • 尽量不要使用复杂的sort条件,使用_doc最高效
  • 使用稍嫌复杂

第一步需要发起1个scroll search:

es在收到该请求后会根据查询条件创建文档Id合集的快照

request
1
2
3
4
5
GET movies/_search?scroll=5m
{
"size": 1
}
5m至快照有效时常,size返回条数

response

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
{
"_scroll_id" : "DnF1ZXJ5VGhlbkZldGNoAgAAAAAAAAagFnY3WnRoVnRLVDNtWncyZ3k4QzlKU1EAAAAAAAAGnxZ2N1p0aFZ0S1QzbVp3Mmd5OEM5SlNR",
"took" : 3,
"timed_out" : false,
"_shards" : {
"total" : 2,
"successful" : 2,
"skipped" : 0,
"failed" : 0
},
"hits" : {
"total" : {
"value" : 9743,
"relation" : "eq"
},
"max_score" : 1.0,
"hits" : [
{
"_index" : "movies",
"_type" : "_doc",
"_id" : "movieId",
"_score" : 1.0,
"_source" : {
"year" : 0,
"title" : "title",
"@version" : "1",
"genre" : [
"genres"
],
"id" : "movieId"
}
}
]
}
}

第二步调用scroll search的api ,获取文档集合

不断迭代调用直到返回hits.hits数组为空时停止

request
1
2
3
4
5
POST _search/scroll
{
"scroll": "5m",
"scroll_id": "DnF1ZXJ5VGhlbkZldGNoAgAAAAAAAAagFnY3WnRoVnRLVDNtWncyZ3k4QzlKU1EAAAAAAAAGnxZ2N1p0aFZ0S1QzbVp3Mmd5OEM5SlNR"
}

过多的scroll调用会占用大量内存,可以通过clear api删除过多的scroll快照:

request
1
2
3
4
5
6
DELETE _search/scroll
{
"scroll_id":["DnF1ZXJ5VGhlbkZldGNoAgAAAAAAAAagFnY3WnRoVnRLVDNtWncyZ3k4QzlKU1EAAAAAAAAGnxZ2N1p0aFZ0S1QzbVp3Mmd5OEM5SlNR"]
}

DELETE _search/scroll/_all

分页与遍历-search_after

避免深度分页的性能问题,提供实时的下一页文档获取功能

  • 缺点是不能使用from参数,即不能指定页数
  • 只能下一页,不能上一页
  • 使用简单

第一步为正常的搜索,但要指定sort值,并保证值唯一
第二步为使用上一步最后一个文档的sort值进行查询

request
1
2
3
4
5
6
7
8
9
10
GET movies/_search
{
"size": 1,
"sort": [
{
"year": "desc",
"title": "desc"
}
]
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
{
"took" : 4,
"timed_out" : false,
"_shards" : {
"total" : 2,
"successful" : 2,
"skipped" : 0,
"failed" : 0
},
"hits" : {
"total" : {
"value" : 9743,
"relation" : "eq"
},
"max_score" : null,
"hits" : [
{
"_index" : "movies",
"_type" : "_doc",
"_id" : "187717",
"_score" : null,
"_source" : {
"year" : 2018,
"title" : "Won't You Be My Neighbor?",
"@version" : "1",
"genre" : [
"Documentary"
],
"id" : "187717"
},
"sort" : [
2018,
"you"
]
}
]
}
}

request
1
2
3
4
5
6
7
8
9
10
11
GET movies/_search
{
"size": 1,
"sort": [
{
"year": "desc",
"title": "desc"
}
],
"search_after": [2018,"you"]
}

如何避免深度分页问题?
通过唯一排序值定位将每次要处理的文档数都控制在size内

返回排序值在search after之后的size个文档,假如size=10,有5个分片,Coordinating Node只需要聚合5个分片取10个文档共50个文档,然后在取前10个文档返回即可
有效的控制了聚合的文档的数量

场景:

类型场景
from/size需要实时获取顶部的文档,可自由分页
scroll需要全部文档,常用于导出,数据非实时
search after需要全部的文档,不需要自由分页,数据实时

search 官方文档

Term

term query会去倒排索引中寻找确切的term,它并不知道分词器的存在,这种查询适合keywordnumericdate等明确值的

term:查询某个字段里含有某个关键词的文档

request
1
2
3
4
5
6
7
8
GET test_search_index/_search
{
"query": {
"term": {
"username": "alfred"
}
}
}

返回结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
"hits" : [
{
"_index" : "test_search_index",
"_type" : "_doc",
"_id" : "2",
"_score" : 0.636667,
"_source" : {
"username" : "alfred",
"job" : "java senior engineer and java specialist",
"age" : 28,
"birth" : "1980-05-07",
"isMarried" : true
}
},
{
"_index" : "test_search_index",
"_type" : "_doc",
"_id" : "1",
"_score" : 0.48898652,
"_source" : {
"username" : "alfred way",
"job" : "java engineer",
"age" : 18,
"birth" : "1990-01-02",
"isMarried" : false
}
},
{
"_index" : "test_search_index",
"_type" : "_doc",
"_id" : "4",
"_score" : 0.39691794,
"_source" : {
"username" : "alfred junior way",
"job" : "ruby engineer",
"age" : 23,
"birth" : "1989-08-07",
"isMarried" : false
}
}
]

发现,username里有关alfred的关键字都查出来了,但是我只想精确匹配alfred way这个,按照下面的写法看看能不能查出来:

request
1
2
3
4
5
6
7
8
GET test_search_index/_search
{
"query": {
"term": {
"username": "alfred way"
}
}
}

执行发现无数据,从概念上看,term属于精确匹配,只能查单个词。我想用term匹配多个词怎么做?可以使用terms来:

request
1
2
3
4
5
6
7
8
GET test_search_index/_search
{
"query": {
"terms": {
"username": ["alfred", "way"]
}
}
}

发现全部查询出来,为什么?因为terms里的[ ]多个是或者的关系,只要满足其中一个词就可以。想要通知满足两个词的话,就得使用在search api那篇中提到的bool查询来做了

match查询:

match query 知道分词器的存在,会对field进行分词操作,然后再查询

request
1
2
3
4
5
6
7
8
GET test_search_index/_search
{
"query": {
"match": {
"username": "alfred"
}
}
}

match_all:查询所有文档
{ “match_all”: {}} 匹配所有的, 当不给查询条件时,默认。

request
1
2
3
4
5
6
7
8
GET test_search_index/_search
{
"query": {
"match_all": {
"boost": 1.2
}
}
}

_score随boost值改变而改变:

multi_match:可以指定多个字段

request
1
2
3
4
5
6
7
8
9
10
GET test_search_index/_search
{
"profile": "true",
"query": {
"multi_match": {
"query" : "alfred java",
"fields": ["username","job"]
}
}
}

返回结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
"hits" : [
{
"_index" : "test_search_index",
"_type" : "_doc",
"_id" : "1",
"_score" : 0.636667,
"_source" : {
"username" : "alfred way",
"job" : "java engineer",
"age" : 18,
"birth" : "1990-01-02",
"isMarried" : false
}
},
{
"_index" : "test_search_index",
"_type" : "_doc",
"_id" : "2",
"_score" : 0.636667,
"_source" : {
"username" : "alfred",
"job" : "java senior engineer and java specialist",
"age" : 28,
"birth" : "1980-05-07",
"isMarried" : true
}
},
{
"_index" : "test_search_index",
"_type" : "_doc",
"_id" : "3",
"_score" : 0.48898652,
"_source" : {
"username" : "lee",
"job" : "java and ruby engineer",
"age" : 22,
"birth" : "1985-08-07",
"isMarried" : false
}
},
{
"_index" : "test_search_index",
"_type" : "_doc",
"_id" : "4",
"_score" : 0.39691794,
"_source" : {
"username" : "alfred junior way",
"job" : "ruby engineer",
"age" : 23,
"birth" : "1989-08-07",
"isMarried" : false
}
}
]

官方文档
实现对es中存储的数据进行查询分析,endpoint为_search,查询主要有两种形式:

  • URI Search:操作简便,方便通过命令行测试,仅包含部分查询语法
  • Request Body Search:es提供完备查询语法Query DSL

示例:

request
1
2
3
4
5
6
7
8
9
GET /user/_search?q=gender:M
GET /user/_search
{
"query": {
"match": {
"gender": "M"
}
}
}

URI Search详解

通过url query参数来实现搜索,常用参数如下:

  • q: 指定查询语句,语法为 Query String Syntax
  • df: q中不指定字段时默认查询的字段,如果不指定,es会查询所有字段
  • sort:排序
  • timeout:指定超时时间,默认不超时
  • from,size:用于分页
request
1
2
GET /myindex/_search?q=alfred&df=user&sort=age:asc&from=4&size=10&timeout=1s
#查询user字段包含alfred的文档,结果按照age升序排列,返回第5-14个文档,如果超过1s没有结束,则以超时结束

Query String Syntax

语法介绍

  1. term与phrase

    1
    2
    alfred way 等效于 alfred OR way
    "alfred way" 词语查询,要求先后顺序
  2. 泛查询

    1
    alfred等效于在所有字段去匹配该term
  3. 指定字段

    1
    name:alfred
  4. Group分组指定,使用括号指定匹配的规则

    1
    2
    (quick OR brown) AND fox
    status:(active OR pending) title:(full text search)

    q=<…>内容如果有空格会当作or处理

加括号和不加括号的区别:加括号表示status是active或status是pengding结果,不加括号表示
status是active或者结果中包含pending

  1. 布尔操作符
    AND(&&),OR(||),NOT(!)

    1
    name:(tom NOT lee) 注意大写,不能小写
  2. “+ -“分别对应must和must_not

    1
    2
    3
    name:(tom +lee -alfred)
    等价于
    name:((lee && !alfred)||(tom && lee && !alfred))

    +在url中会被解析为空格,要使用encode后的结果才可以,为%2B

  3. 范围查询,支持数值和日志

  • 区间写法,闭区间用[],开区间用{}
    1
    2
    3
    4
    age: [1 TO 10]意为 1<=age<=10
    age: [1 TO 10}意为 1<=age<10
    age: [1 TO ]意为 age>=1
    age: [* TO 10]意为 age<=10
  • 算数符号写法
    1
    2
    age:>=1
    age:(>=1&&<=10)或者age:(+>=1 +<=10)
  1. 通配符查询:?代表一个字符,*代表0或多个字符

    1
    2
    3
    4
    5
    name:t?m
    name:tom*

    通配符匹配执行效率低,且占用较多内存,不建议使用
    如无特殊需求,不要将?/*放在最前面
  2. 正则表达式

    1
    name:/[mb]oat/
  3. 模糊匹配 fuzzy query

    1
    2
    name:roam~1
    匹配与roam差一个character的词,比如foam roams等
  4. 近似度查询 proximity search

    1
    2
    3
    "fox quick"~5
    允许fox 和quick 之间差5个词语
    以term为单位进行差异比较,比如"quick fox" "quick brown fox"都会被匹配

查询示例

添加一些数据

request
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
DELETE test_search_index

PUT test_search_index
{
"settings": {
"index":{
"number_of_shards": "1"
}
}
}

POST test_search_index/_bulk
{"index":{"_id":"1"}}
{"username":"alfred way","job":"java engineer","age":18,"birth":"1990-01-02","isMarried":false}
{"index":{"_id":"2"}}
{"username":"alfred","job":"java senior engineer and java specialist","age":28,"birth":"1980-05-07","isMarried":true}
{"index":{"_id":"3"}}
{"username":"lee","job":"java and ruby engineer","age":22,"birth":"1985-08-07","isMarried":false}
{"index":{"_id":"4"}}
{"username":"alfred junior way","job":"ruby engineer","age":23,"birth":"1989-08-07","isMarried":false}

查询所有关键词中有alfred的

request
1
2
3
4
5
6
GET test_search_index/_search?q=alfred
#profile设置为true可以分析elasticsearch的具体过程
GET test_search_index/_search?q=alfred
{
"profile":true
}

查询所有关键词中有alfred的并指定为username字段

request
1
GET test_search_index/_search?q=username:alfred

查询所有关键词中有alfred或者way的并指定为username字段

request
1
GET test_search_index/_search?q=username:alfred way

如果加上””则表示关键词中必须包含alfred way

request
1
GET test_search_index/_search?q=username:"alfred way"

username字段中必须包含alfred和其它字段包含way的

request
1
GET test_search_index/_search?q=username:alfred AND way

username字段中必须包含alfred和way的

request
1
GET test_search_index/_search?q=username:(alfred AND way)

为了便于区别()的作用,在加入一条数据

request
1
2
3
POST test_search_index/doc/_bulk/
{"index":{"_id":"7"}}
{"username":"alfred","job":"java engineer way","age":18,"birth":"1990-01-02","isMarried":false}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
#q=username:alfred AND wayd的结果
{
"took" : 3,
"timed_out" : false,
"_shards" : {
"total" : 1,
"successful" : 1,
"skipped" : 0,
"failed" : 0
},
"hits" : {
"total" : 3,
"max_score" : 2.329832,
"hits" : [
{
"_index" : "test_search_index",
"_type" : "doc",
"_id" : "7",
"_score" : 2.329832,
"_source" : {
"username" : "alfred",
"job" : "java engineer way",
"age" : 18,
"birth" : "1990-01-02",
"isMarried" : false
}
},
{
"_index" : "test_search_index",
"_type" : "doc",
"_id" : "1",
"_score" : 1.2048805,
"_source" : {
"username" : "alfred way",
"job" : "java engineer",
"age" : 18,
"birth" : "1990-01-02",
"isMarried" : false
}
},
{
"_index" : "test_search_index",
"_type" : "doc",
"_id" : "4",
"_score" : 0.966926,
"_source" : {
"username" : "alfred junior way",
"job" : "ruby engineer",
"age" : 23,
"birth" : "1989-08-07",
"isMarried" : false
}
}
]
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#q=username:(alfred AND way)的结果
{
"took" : 2,
"timed_out" : false,
"_shards" : {
"total" : 1,
"successful" : 1,
"skipped" : 0,
"failed" : 0
},
"hits" : {
"total" : 2,
"max_score" : 1.2048805,
"hits" : [
{
"_index" : "test_search_index",
"_type" : "doc",
"_id" : "1",
"_score" : 1.2048805,
"_source" : {
"username" : "alfred way",
"job" : "java engineer",
"age" : 18,
"birth" : "1990-01-02",
"isMarried" : false
}
},
{
"_index" : "test_search_index",
"_type" : "doc",
"_id" : "4",
"_score" : 0.966926,
"_source" : {
"username" : "alfred junior way",
"job" : "ruby engineer",
"age" : 23,
"birth" : "1989-08-07",
"isMarried" : false
}
}
]
}
}

username字段中必须包含alfred但是没有way的

request
1
GET test_search_index/_search?q=username:(alfred NOT way)

username必须含有way的

request
1
2
GET test_search_index/_search?q=username:(alfred +way)  +会被识别为空格
GET test_search_index/_search?q=username:(alfred %2Bway)

范围查询:

request
1
2
3
4
5
6
GET test_search_index/_search?q=username:alfred age:>26
#username字段包含alfred或者age大于26
GET test_search_index/_search?q=username:alfred AND age:>20
#username字段包含alfred并且age大于20
GET test_search_index/_search?q=birth:(>1980 AND <1990)
#birth字段在1980到1990之间

正则表达式和通配符:

request
1
2
GET test_search_index/_search?q=username:alf*
GET test_search_index/_search?q=username:/[a]?l.*/

模糊查询和近似度:

request
1
2
3
4
GET test_search_index/_search?q=username:alfed~1
GET test_search_index/_search?q=username:alfd~2
GET test_search_index/_search?q=job:"java engineer"
GET test_search_index/_search?q=job:"java engineer"~1

将查询语句通过http request body 发送到es,主要包含如下参数:

  • query: 符合Query DSL语法的查询语句
  • from,size
  • timeout
  • sort

Query DSL

Query DSL: 基于json定义的查询语言,主要包含如下两种类型:

  • 字段类查询:如term、match、range等,只针对某一字段进行查询
  • 复合查询:如bool查询等,包含一个或多个字段类查询或者复合查询语句

字段类查询

字段类查询主要包括以下两类:

  • 全文匹配:针对text类型的字段进行全文检索,会对查询语句先进行分词处理,如match,match_phrase等query类型
  • 单词匹配:不会对查询语句做分词处理,直接去匹配字段的倒排索引,如term,terms,range等query类型

查询示例

request
1
2
3
4
5
6
7
8
GET test_search_index/_search
{
"query": {
"match": {
"username": "alfred way"
}
}
}

查询流程如下所示:

通过operator参数可以控制单词间的匹配关系,可选项为or和and(默认为or)
通过minimum_should_match参数可以控制需要匹配的单词数

request
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
GET test_search_index/_search
{
"query": {
"match": {
"username": {
"query": "alfred way",
"operator": "and"
}
}
}
}

GET test_search_index/_search
{
"query": {
"match": {
"username": {
"query": "alfred way",
"minimum_should_match": 2
}
}
}
}
#`alfred` 和 `way`必须同时存在才能满足

相关性算分

相关性算分是指文档与查询语句间的相关度,英文为relevance
通过倒排索引可以获取与查询语句相匹配的文档列表,那么如何将最符合用户查询需求的文档放到前列呢?本质是一个排序问题,排序依据是相关性算分。

相关性算分的几个重要概念:

  • Term Frequency(TF): 词频,即单词在该文档中出现的次数。词频越高,相关度越高
  • Document Frequency(DF): 文档频率,即单词出现的文档数
  • Inverse Document Frequency(IDF):逆向文档频率,与文档频率相反,简单理解为1/DF。即单词出现的文档数越少,相关度越高。
  • Field-length Norm: 文档越短,相关性越高

ES目前主要有两个相关性算分模型:

  • TF/IDF模型
  • BM25模型:5.x之后的默认模型

可以通过explain参数来查看具体的计算方法,但要注意:es的算分是按照shard进行的,即shard分数计算时互相独立的,所以在使用explain的时候注意分片数;可以通过设置索引的分片数为1来避免这个问题。

request
1
2
3
4
5
6
7
8
{
"explain": true,
"query": {
"match": {
"FIELD": "TEXT"
}
}
}

Match-Phrase-Query

对字段作检索,有顺序要求,API示例:

request
1
2
3
4
5
6
7
8
GET test_search_index/_search
{
"query": {
"match_phrase": {
"job": "engineer java"
}
}
}

通过slop参数可以控制单词间的间隔,类似url search里的近似度匹配

request
1
2
3
4
5
6
7
8
9
10
11
GET test_search_index/_search
{
"query": {
"match_phrase": {
"job": {
"query": "java engineer",
"slop": 1
}
}
}
}

Query-String-Query

类似于URI Search中的q参数查询

request
1
2
3
4
5
6
7
8
9
10
GET test_search_index/_search
{
"profile": "true",
"query": {
"query_string": {
"default_field": "job",
"query": "engineer AND java"
}
}
}

多字段查询

request
1
2
3
4
5
6
7
8
9
10
GET test_search_index/_search
{
"profile": "true",
"query": {
"query_string": {
"fields": ["username", "job"],
"query": "engineer AND alfred"
}
}
}

Simple-Query-String-Query

类似Query String ,但是会忽略错误的查询语法,并且仅支持部分查询语法
其常用的逻辑符号如下,不能使用AND, OR, NOT等关键词

  • +代指AND
  • |代指OR
  • -代指NOT请求
request
1
2
3
4
5
6
7
8
9
10
GET test_search_index/_search
{
"profile": "true",
"query": {
"simple_query_string": {
"fields": ["username"],
"query": "alfred +way"
}
}
}

与?q=alfred +way不同的是这里的alfred 和 way必须同时存在

Term-Terms-Query

将查询语句作为整个单词进行查询,即不对查询语句做分词处理:

request
1
2
3
4
5
6
7
8
9
GET test_search_index/_search
{
"profile": "true",
"query": {
"term": {
"username": "alfred way"
}
}
}

一次传入多个单词进行查询:

request
1
2
3
4
5
6
7
8
9
10
11
12
GET test_search_index/_search
{
"profile": "true",
"query": {
"terms": {
"username": [
"alfred",
"way"
]
}
}
}

Range Query

范围查询主要针对数值和日期类型:

request
1
2
3
4
5
6
7
8
9
10
11
GET test_search_index/_search
{
"query": {
"range": {
"age": {
"gt": 20,
"lt": 40
}
}
}
}

range 过滤器既能包含也能排除范围,通过下面的选项:

  • gt: > 大于
  • lt: < 小于
  • gte: >= 大于或等于
  • lte: <= 小于或等于

range过滤器也可以用于日期字段:

1
2
3
4
5
6
"range":{
"timestamp" : {
"gt" : "2014-01-01 00:00:00",
"lt" : "2014-01-07 00:00:00"
}
}

这个过滤器将始终能找出所有时间戳大于当前时间减 1 小时的文档,让这个过滤器像移窗一样通过你的文档。

日期计算也能用于实际的日期,而不是仅仅是一个像 now 一样的占位符。只要在日期后加上双竖线 ||,就能使用日期数学表达式了。

1
2
3
4
5
6
"range" : {
"timestamp" : {
"gt" : "2014-01-01 00:00:00",
"lt" : "2014-01-01 00:00:00||+1M" <1>
}
}

<1> 早于 2014 年 1 月 1 号加一个月
日期计算是与日历相关的,所以它知道每个月的天数,每年的天数,等等。更详细的关于日期的信息可以在这里找到
日期格式手册

range过滤器也可以用于字符串。字符串范围根据字典或字母顺序来计算。例如,这些值按照字典顺序排序:
假如我们想让范围从 a 开始而不包含 b,我们可以用类似的 range 过滤器语法:

1
2
3
4
5
6
"range" : {
"title" : {
"gte" : "a",
"lt" : "b"
}
}

数字和日期字段的索引方式让他们在计算范围时十分高效。但对于字符串来说却不是这样。为了在字符串上执行范围操作,Elasticsearch 会在这个范围内的每个短语执行 term 操作。这比日期或数字的范围操作慢得多。
字符串范围适用于一个基数较小的字段,一个唯一短语个数较少的字段。你的唯一短语数越多,搜索就越慢。

复合查询

复合查询是指包含字段类查询或复合查询的类型,主要包括以下几类:

  • constant_score query
  • bool query
  • dis_max query
  • function_score query
  • boosting query

Constant Score Query
该查询将其内部的查询结果文档得分都设定为1或者boost的值
多用于结合bool查询实现自定义得分

request
1
2
3
4
5
6
7
8
9
10
11
12
GET test_search_index/_search
{
"query": {
"constant_score": {
"filter": {
"match": {
"username": "alfred"
}
}
}
}
}

查询结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
{
"took" : 13,
"timed_out" : false,
"_shards" : {
"total" : 1,
"successful" : 1,
"skipped" : 0,
"failed" : 0
},
"hits" : {
"total" : {
"value" : 3,
"relation" : "eq"
},
"max_score" : 1.0,
"hits" : [
{
"_index" : "test_search_index",
"_type" : "_doc",
"_id" : "1",
"_score" : 1.0,
"_source" : {
"username" : "alfred way",
"job" : "java engineer",
"age" : 18,
"birth" : "1990-01-02",
"isMarried" : false
}
},
{
"_index" : "test_search_index",
"_type" : "_doc",
"_id" : "2",
"_score" : 1.0,
"_source" : {
"username" : "alfred",
"job" : "java senior engineer and java specialist",
"age" : 28,
"birth" : "1980-05-07",
"isMarried" : true
}
},
{
"_index" : "test_search_index",
"_type" : "_doc",
"_id" : "4",
"_score" : 1.0,
"_source" : {
"username" : "alfred junior way",
"job" : "ruby engineer",
"age" : 23,
"birth" : "1989-08-07",
"isMarried" : false
}
}
]
}
}

Bool Query

布尔查询由一个或多个布尔子句组成,主要包含如下4个:

子句含义
filter只过滤符合条件的文档,不计算相关性算分
must文档必须符合must中所有条件,影响相关性算分
must_not文档必须不符合must中所有条件
should文档可以符合should中的条件,不计算相关性算分

filter
API:

request
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
GET test_search_index/_search
{
"query": {
"bool": {
"must": [
{}
],
"must_not": [
{}
],
"should": [
{}
],
"filter": [
{}
]
}
}
}

Filter查询只过滤符合条件的文档,不会进行相关性算分

es针对filter会有智能缓存,因此其执行效率很高
做简单匹配查询且不考虑算分时,推荐使用filter替代query

request
1
2
3
4
5
6
7
8
9
10
11
12
13
14
GET test_search_index/_search
{
"query": {
"bool": {
"filter": [
{
"term": {
"username":"alfred"
}
}
]
}
}
}

must:

request
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
GET test_search_index/_search
{
"query": {
"bool": {
"must": [
{
"match": {
"username":"alfred"
}
},
{
"match": {
"job":"java"
}
}
]
}
}
}

返回结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
{
"_index" : "test_search_index",
"_type" : "_doc",
"_id" : "2",
"_score" : 1.2314217,
"_source" : {
"username" : "alfred",
"job" : "java senior engineer and java specialist",
"age" : 28,
"birth" : "1980-05-07",
"isMarried" : true
}
},
{
"_index" : "test_search_index",
"_type" : "_doc",
"_id" : "1",
"_score" : 1.1256535,
"_source" : {
"username" : "alfred way",
"job" : "java engineer",
"age" : 18,
"birth" : "1990-01-02",
"isMarried" : false
}
}

_score为两个match查询到的分数之和

must_not:

request
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
GET test_search_index/_search
{
"query": {
"bool": {
"must": [
{
"match": {
"username":"alfred"
}
}
],
"must_not": [
{
"match": {
"job":"specialist"
}
}
]
}
}
}

匹配username包含alfred并且job不能包含specialist

Should:

  • Should使用分两种情况:
    • bool查询中只包含should ,不包含must查询
    • bool查询中同时包含should和must查询
  • 只包含should时,文档必须满足至少一个条件
    • minimum_should_match可以控制满足条件的个数或者百分比
request
1
2
3
4
5
6
7
8
9
10
11
12
13
GET test_search_index/_search
{
"query": {
"bool": {
"should": [
{"term": {"username": "alfred"}},
{"term": {"username": "way"}},
{"term": {"username": "junior"}}
],
"minimum_should_match": 2
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
{
"_index" : "test_search_index",
"_type" : "_doc",
"_id" : "4",
"_score" : 1.8147054,
"_source" : {
"username" : "alfred junior way",
"job" : "ruby engineer",
"age" : 23,
"birth" : "1989-08-07",
"isMarried" : false
}
},
{
"_index" : "test_search_index",
"_type" : "_doc",
"_id" : "1",
"_score" : 0.97797304,
"_source" : {
"username" : "alfred way",
"job" : "java engineer",
"age" : 18,
"birth" : "1990-01-02",
"isMarried" : false
}
}

minimum_should_match加上后只返回了两个结果

同时包含should和must时,文档不必满足should中的条件,但是如果满足条件,会增加相关性得分

request
1
2
3
4
5
6
7
8
9
10
11
12
13
GET test_search_index/_search
{
"query": {
"bool": {
"should": [
{"term": {"job": "ruby"}}
],
"must": [
{"term": {"username": "alfred"}}
]
}
}
}

jobruby的会增加分数排名更靠前

Query Context VS Filter Context

当一个查询语句位于Query或者Filter上下文时, es执行的结果会不同,对比如下:

上下文类型执行类型使用方式
查找与查询语句最匹配的文档,对所有文档进行算分和排序query
bool中的mustshould
查找与查询语句相匹配的文档bool中的filtermust_not
constant_score中的filter

Count And Source Filtering

count

获取符合条件的文档数, endpoint为_count

source filtering
过滤返回结果中source中的字段,主要有如下几种方式:

uri:

request
1
GET test_search_index/_search?_source=username

禁用source:

request
1
2
3
4
GET test_search_index/_search
{
"_source": false
}

传字段名:

request
1
2
3
4
GET test_search_index/_search
{
"_source": ["username","job"]
}

包括和不包括模糊匹配

request
1
2
3
4
5
6
7
GET test_search_index/_search
{
"_source": {
"includes": "*i*",
"excludes": "birth"
}
}

mapping

mapping是类似于数据库中的表结构定义,主要作用如下:

  • 定义index下的字段名
  • 定义字段类型,比如数值型、浮点型、布尔型等
  • 定义倒排索引相关的设置,比如是否索引、记录position等
request
1
2
3
4
5
6
7
PUT test_index/_doc/1
{
"username": "jack",
"age": 15
}

GET test_index/_mapping

自定义mapping

request
1
2
3
4
5
6
7
8
9
10
11
12
13
14
PUT my_index
{
"mappings": {
"dynamic": false,
"properties": {
"username": {
"type": "keyword"
},
"age": {
"type": "integer"
}
}
}
}

mapping中的字段类型一旦设置,禁止直接修改,因为 lucene实现的倒排索引生成后不允许修改,应该重新建立新的索引,然后做reindex操作。

但是可以新增字段,通过 dynamic 参数来控制字段的新增,这个参数的值如下:

true:默认值,表示允许选自动新增字段
false:不允许自动新增字段,但是文档可以正常写入,但无法对字段进行查询等操作
strict:严格模式,文档不能写入,报错

然后写入一个文档:

request
1
2
3
4
5
6
PUT my_index/_doc/1
{
"username": "lili",
"desc": "this is my index",
"age": 20
}

在mapping设置中,”dynamic”: false,表示在写入文档时,如果写入字段不存在也不会报错。这里的desc字段就是不存在的字段。

查询文档

request
1
2
3
4
5
6
7
8
GET my_index/_search
{
"query": {
"match": {
"username": "lili"
}
}
}
request
1
2
3
4
5
6
7
8
GET my_index/_search
{
"query": {
"match": {
"desc": "this is my index"
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
{
"took" : 1,
"timed_out" : false,
"_shards" : {
"total" : 1,
"successful" : 1,
"skipped" : 0,
"failed" : 0
},
"hits" : {
"total" : {
"value" : 1,
"relation" : "eq"
},
"max_score" : 0.2876821,
"hits" : [
{
"_index" : "my_index",
"_type" : "_doc",
"_id" : "1",
"_score" : 0.2876821,
"_source" : {
"username" : "lili",
"desc" : "this is my index",
"age" : "20"
}
}
]
}
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
{
"took" : 1,
"timed_out" : false,
"_shards" : {
"total" : 1,
"successful" : 1,
"skipped" : 0,
"failed" : 0
},
"hits" : {
"total" : {
"value" : 0,
"relation" : "eq"
},
"max_score" : null,
"hits" : [ ]
}
}

对比两个结果可以看出能通过mapping中设置的字段查询到

copy_to

作用是将该字段的值复制到目标字段,实现类似(6.0版本之前的)_all的作用。不会出现在_source中,只能用来搜索。

request
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
PUT my_index2
{
"mappings": {
"properties": {
"first_name": {
"type": "text",
"copy_to": "full_name"
},
"last_name": {
"type": "text",
"copy_to": "full_name"
},
"full_name": {
"type": "text"
}
}
}
}

PUT:

request
1
2
3
4
5
PUT my_index2/_doc/1
{
"first_name": "David",
"last_name": "john"
}

查询:

request
1
2
3
4
5
6
7
8
9
10
11
GET my_index2/_search
{
"query": {
"match": {
"full_name": {
"query": "David john",
"operator": "and"
}
}
}
}

返回结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
{
"took" : 316,
"timed_out" : false,
"_shards" : {
"total" : 1,
"successful" : 1,
"skipped" : 0,
"failed" : 0
},
"hits" : {
"total" : {
"value" : 1,
"relation" : "eq"
},
"max_score" : 0.5753642,
"hits" : [
{
"_index" : "my_index2",
"_type" : "_doc",
"_id" : "1",
"_score" : 0.5753642,
"_source" : {
"first_name" : "David",
"last_name" : "john"
}
}
]
}
}

可以通过full_name来查询first_name,lastname两个字段,并且不区分大小写,但是一旦有一个字段的值匹配不上,就会返回为空

Index

index参数作用是控制当前字段是否被索引,默认为true,false表示不记录,即不可被搜索。

request
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
PUT my_index3
{
"mappings": {
"properties": {
"cookie": {
"type": "text",
"index": false
},
"content": {
"type": "text",
"index": true
}
}
}
}
request
1
2
3
4
5
PUT my_index3/_doc/1
{
"cookie": "efdfsdiadsasd",
"content": "this is a cookie"
}

查询测试:

request
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
GET my_index3/_search
{
"query": {
"match": {
"cookie": "efdfsdiadsasd"
}
}
}

GET my_index3/_search
{
"query": {
"match": {
"content": "this is a cookie"
}
}

cookie字段不可被查询

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
{
"error": {
"root_cause": [
{
"type": "query_shard_exception",
"reason": "failed to create query: {\n \"match\" : {\n \"cookie\" : {\n \"query\" : \"efdfsdiadsasd\",\n \"operator\" : \"OR\",\n \"prefix_length\" : 0,\n \"max_expansions\" : 50,\n \"fuzzy_transpositions\" : true,\n \"lenient\" : false,\n \"zero_terms_query\" : \"NONE\",\n \"auto_generate_synonyms_phrase_query\" : true,\n \"boost\" : 1.0\n }\n }\n}",
"index_uuid": "5iLoJa4vRmiWA23sZ8-4TQ",
"index": "my_index3"
}
],
"type": "search_phase_execution_exception",
"reason": "all shards failed",
"phase": "query",
"grouped": true,
"failed_shards": [
{
"shard": 0,
"index": "my_index3",
"node": "IWeH0fFvQD604ZiJ9OAdRw",
"reason": {
"type": "query_shard_exception",
"reason": "failed to create query: {\n \"match\" : {\n \"cookie\" : {\n \"query\" : \"efdfsdiadsasd\",\n \"operator\" : \"OR\",\n \"prefix_length\" : 0,\n \"max_expansions\" : 50,\n \"fuzzy_transpositions\" : true,\n \"lenient\" : false,\n \"zero_terms_query\" : \"NONE\",\n \"auto_generate_synonyms_phrase_query\" : true,\n \"boost\" : 1.0\n }\n }\n}",
"index_uuid": "5iLoJa4vRmiWA23sZ8-4TQ",
"index": "my_index3",
"caused_by": {
"type": "illegal_argument_exception",
"reason": "Cannot search on field [cookie] since it is not indexed."
}
}
}
]
},
"status": 400
}

index_options

  • index_options的作用是用于控制倒排索引记录的内容,有如下四种配置:

    • docs:只记录doc id
    • freqs:记录doc id 和term frequencies
    • positions:记录doc id、 term frequencies和term position
    • offsets:记录doc id、 term frequencies、term position、character offsets
  • text类型的默认配置为positions,其他默认为docs。

  • 记录的内容越多,占据的空间越大。

index_options设定:

request
1
2
3
4
5
6
7
8
9
10
11
PUT my_index4
{
"mappings": {
"properties": {
"text": {
"type": "text",
"index_options": "offsets"
}
}
}
}

PUT:

request
1
2
3
4
PUT my_index4/_doc/1
{
"text": "Quick brown fox"
}
request
1
2
3
4
5
6
7
8
9
10
11
12
13
GET my_index4/_search
{
"query": {
"match": {
"text": "brown fox"
}
},
"highlight": {
"fields": {
"text": {}
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
{
"took" : 68,
"timed_out" : false,
"_shards" : {
"total" : 1,
"successful" : 1,
"skipped" : 0,
"failed" : 0
},
"hits" : {
"total" : {
"value" : 1,
"relation" : "eq"
},
"max_score" : 0.5753642,
"hits" : [
{
"_index" : "my_index4",
"_type" : "_doc",
"_id" : "1",
"_score" : 0.5753642,
"_source" : {
"text" : "Quick brown fox"
},
"highlight" : {
"text" : [
"Quick <em>brown</em> <em>fox</em>"
]
}
}
]
}
}

brown fox会被高亮显示

null_value

这个参数的作用是当字段遇到null值的时候的处理策略,默认为null,即空值,此时es会忽略该值。可以通过这个参数设置某个字段的默认值

request
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
PUT my_index5
{
"mappings": {
"properties": {
"status_code": {
"type": "keyword",
"null_value": "NULL"
}
}
}
}

PUT my_index5/_doc/1
{
"status_code": null
}

PUT my_index5/_doc/2
{
"status_code": []
}

GET my_index5/_search
{
"query": {
"term": {
"status_code": "NULL"
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
{
"took" : 1,
"timed_out" : false,
"_shards" : {
"total" : 1,
"successful" : 1,
"skipped" : 0,
"failed" : 0
},
"hits" : {
"total" : {
"value" : 1,
"relation" : "eq"
},
"max_score" : 0.2876821,
"hits" : [
{
"_index" : "my_index5",
"_type" : "_doc",
"_id" : "1",
"_score" : 0.2876821,
"_source" : {
"status_code" : null
}
}
]
}
}
  1. 用术语null替换显式null值。
  2. 空数组不包含显式null,因此不会用null_value替换。
  3. 对NULL的查询返回文档1,而不是文档2。

    null_value需要与字段具有相同的数据类型。例如,长字段不能有字符串null_value。
    null_value只影响数据的索引方式,它不修改_source文档。

更多详见Mapping parameters

数据类型

核心数据类型

  • 字符串型:text、keyword(不会分词)
  • 数值型:long、integer、short、byte、double、float、half_float等
  • 日期类型:date
  • 布尔类型:boolean
  • 二进制类型:binary
  • 范围类型:integer_range、float_range、long_range、double_range、date_range

复杂数据类型

  • 数组类型:array
  • 对象类型:object
  • 嵌套类型:nested object
  • 地理位置数据类型:geo_point、geo_shape
  • 专用类型:ip(记录ip地址)、completion(实现自动补全)、token_count(记录分词数)、murmur3(记录字符串hash值)

多字段特性

  • 多字段特性(multi-fields),表示允许对同一字段采用不同的配置,比如分词。

常见例子是对人名实现拼音搜索,只需要在人名中新增一个字段pinyin即可。但是这种方式不是十分优雅,multi-fields可以在不改变整体结构的前提下,增加一个子字段:

request
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
PUT my_index6
{
"mappings": {
"properties": {
"username": {
"type": "text",
"fields": {
"pinyin": {
"type": "text",
"analyzer": "pinyin"
}
}
}
}
}
}
GET my_index6
{
"query": {
"match": {
"username.pinyin": "pinyin"
}
}
}

Dynamic mapping

Elasticsearch最重要的特性之一是,它试图摆脱您的阻碍,让您尽可能快地开始研究您的数据。要为文档建立索引,您不需要首先创建索引、定义映射类型和字段——您只需要为文档建立索引,索引、类型和字段就会自动出现:

默认情况下,当在文档中发现以前没有看到的字段时,Elasticsearch将把新字段添加到类型映射中。通过将动态参数设置为false(忽略新字段)或strict(遇到未知字段时抛出异常),可以在文档和对象级别禁用此行为

字段映射规则

JSON类型ES类型
null忽略
true or falseboolean
floating point numberfloat
integerlong
objectobject
array取决于数组中的第一个非空值。
string要么是一个日期字段(如果值通过了日期检测),要么是一个双字段或长字段(如果值通过了数值检测),要么是一个带有关键字子字段的文本字段。
request
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
DELETE test_index
PUT /test_index/_doc/1
{
"username":"alfred",
"age":1.2
}

GET test_index/_search
{
"query": {
"match": {
"username.keyword": "alfred"
}
}
}

当你索引一个包含新字段的文档——一个之前没有的字段——Elasticsearch将使用动态映射猜测字段类型,这类型来自于JSON的基本数据类型,使用以下规则:

JSON typeField type
Boolean: true or false"boolean"
Whole number: 123"long"
Floating point: 123.45"double"
String, valid date: "2014-09-15""date"
String: "foo bar""string"

注意

这意味着,如果你索引一个带引号的数字——"123",它将被映射为"string"类型,而不是"long"类型。然而,如果字段已经被映射为"long"类型,Elasticsearch将尝试转换字符串为long,并在转换失败时会抛出异常。

日期自动识别

日期的自动识别可以自行配置日期的格式,默认情况下是:

1
["strict_date_opeional_time", "yyyy/MM/dd HH:mm:ss Z||yyyy/MM/dd Z"]

strict_date_opeional_time 是ISO 标准的日期格式,完整的格式如下:

1
YYYY-MM-DDhh:mm:ssTZD(eg:1997-07-16y19:20:30+01:00)

dynamic_date_formats:可以自定义日期类型
date_detection:可以关闭日期自动识别机制(默认开启)
首先创建一个日期自动识别的索引:

request
1
2
3
4
5
6
7
8
9
10
11
12
13
14
DELETE my_index
PUT my_index
{
"mappings": {
"dynamic_date_formats": ["MM/dd/yyyy"]
}
}

PUT my_index/_doc/1
{
"create_time": "09/21/2016"
}

GET my_index/_mapping
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"my_index" : {
"mappings" : {
"dynamic_date_formats" : [
"MM/dd/yyyy"
],
"properties" : {
"create_time" : {
"type" : "date",
"format" : "MM/dd/yyyy"
}
}
}
}
}

关闭日期自动识别可以如下:

request
1
2
3
4
5
6
7
8
9
10
11
PUT my_index1
{
"mappings": {
"date_detection": false
}
}

PUT my_index1/_doc/1
{
"create": "2015/09/02"
}

自定义时间类型:

request
1
2
3
4
5
6
7
8
9
10
11
PUT my_index2
{
"mappings": {
"dynamic_date_formats": ["MM/dd/yyyy"]
}
}

PUT my_index2/_doc/1
{
"create_date": "09/25/2015"
}

数字自动识别

字符串为数字的时候,默认不会自动识别为整型,因为字符串中出现数字是完全合理的。numeric_detection 可以开启字符串中数字的自动识别

request
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
DELETE my_index3
PUT my_index3
{
"mappings": {
"numeric_detection": true
}
}

PUT my_index3/_doc/1
{
"my_float": "1.0",
"my_integer": "1"
}

GET my_index3/_mapping
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"my_index3" : {
"mappings" : {
"numeric_detection" : true,
"properties" : {
"my_float" : {
"type" : "float"
},
"my_integer" : {
"type" : "long"
}
}
}
}
}

Dynamic Template

允许根据es自动识别的数据类型、字段名等动态设定字段类型,可以实现如下效果:

  • 所有字符串都设定为keyword类型,即默认不分词
  • 所有以message开头字段都设定为text类型,即分词
  • 所有以long_开头的字段都设定为long类型
  • 所有自动匹配为double类型的都设定为float类型,以节省空间
request
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
PUT test_index
{
"mappings": {
"dynamic_templates": [
{
"strings": {
"match_mapping_type": "string",
"mapping": {
"type": "keyword"
}
}
}
]
}
}

PUT test_index/_doc/1
{
"name": "alfred"
}

GET test_index/_mapping

匹配规则一般有如下几个参数:

  • match_mapping_type: 匹配es自动识别的字段类型,如boolean,long,string
  • match,unmatch:匹配字段名
  • path_match,path_unmatch: 匹配路径

设置以message开头的字段都设置为text类型 (顺序由上而下)

request
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
PUT test_index
{
"mappings": {
"dynamic_templates": [
{
"message_as_text": {
"match_mapping_type": "string",
"match": "message*",
"mapping": {
"type": "text"
}
}
},
{
"strings_as_keywords": {
"match_mapping_type": "string",
"mapping": {
"type": "keyword"
}
}
}
]
}
}

Index Template

Index Template帮你设定mappings和settings,并按照一定规则,自动匹配到新创建的索引上

  • 仅生效与新创建的索引,不会影响之前的索引
  • 可设定多个,会自动merge在一起
  • 可指定order值,控制merging的过程
request
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
PUT _template/default_template
{
"index_patterns": ["*"],
"order": 0,
"version": 1,
"settings": {
"number_of_shards": 3,
"number_of_replicas": 1
}
}

PUT _template/default_template1
{
"index_patterns": ["*"],
"order": 1,
"version": 1,
"settings": {
"number_of_shards": 3,
"number_of_replicas": 1
}
}

工作方式:

当一个索引被创建时:

  • 应用默认的settings和mappings
  • 应用order数值低的index template的设定
  • 应用order高的index template的设定,之前设定被覆盖
  • 用户指定的settings和mappings会覆盖默认的模版设定

上面的两个设定都会被应用,但是order高的会覆盖低的设定

入门介绍

安装

  1. ElasticSearch需要的jdk版本至少是1.8的,所以安装之前先查看jdk版本号

2.下载ElasticSearch

1
2
3
wget https://github.com/elastic/elasticsearch/archive/v6.5.0.tar.gz
tar -zxvf elasticsearch-6.5.0.tar.gz
cd elasticsearch-6.5.0

简单本地集群环境搭建

vim config/elasticsearch.yml

1
cluster.name: my-application    注释打开

启动三个集群命令

1
2
3
./bin/elasticsearch -Ehttp.port=7200 -Epath.data=node3
./bin/elasticsearch -Ehttp.port=8200 -Epath.data=node2
./bin/elasticsearch

集群状态查看

1
2
3
4
5
6
7
pengshiliang@pengshiliang-OptiPlex-3020:~$ curl 127.0.0.1:9200/_cat/nodes?v
ip heap.percent ram.percent cpu load_1m load_5m load_15m node.role master name
127.0.0.1 30 98 2 2.03 0.96 0.53 mdi - dRA_DV4
127.0.0.1 30 98 2 2.03 0.96 0.53 mdi - j8_g2SD
127.0.0.1 31 98 3 2.03 0.96 0.53 mdi * 3cYY9cL
pengshiliang@pengshiliang-OptiPlex-3020:~$ curl 127.0.0.1:9200/_cluster/stats
{"_nodes":{"total":3,"successful":3,"failed":0},"cluster_name":"my-application","cluster_uuid":"5nD6JB_MRyiin3n7QTCoDg","timestamp":1552028850410,"status":"green","indices":{"count":0,"shards":{},"docs":{"count":0,"deleted":0},"store":{"size_in_bytes":0},"fielddata":{"memory_size_in_bytes":0,"evictions":0},"query_cache":{"memory_size_in_bytes":0,"total_count":0,"hit_count":0,"miss_count":0,"cache_size":0,"cache_count":0,"evictions":0},"completion":{"size_in_bytes":0},"segments":{"count":0,"memory_in_bytes":0,"terms_memory_in_bytes":0,"stored_fields_memory_in_bytes":0,"term_vectors_memory_in_bytes":0,"norms_memory_in_bytes":0,"points_memory_in_bytes":0,"doc_values_memory_in_bytes":0,"index_writer_memory_in_bytes":0,"version_map_memory_in_bytes":0,"fixed_bit_set_memory_in_bytes":0,"max_unsafe_auto_id_timestamp":-9223372036854775808,"file_sizes":{}}},"nodes":{"count":{"total":3,"data":3,"coordinating_only":0,"master":3,"ingest":3},"versions":["6.5.0"],"os":{"available_processors":12,"allocated_processors":12,"names":[{"name":"Linux","count":3}],"mem":{"total_in_bytes":24839368704,"free_in_bytes":437612544,"used_in_bytes":24401756160,"free_percent":2,"used_percent":98}},"process":{"cpu":{"percent":0},"open_file_descriptors":{"min":305,"max":306,"avg":305}},"jvm":{"max_uptime_in_millis":74396,"versions":[{"version":"1.8.0_171","vm_name":"Java HotSpot(TM) 64-Bit Server VM","vm_version":"25.171-b11","vm_vendor":"Oracle Corporation","count":3}],"mem":{"heap_used_in_bytes":972703928,"heap_max_in_bytes":3116630016},"threads":143},"fs":{"total_in_bytes":483753484288,"free_in_bytes":438342832128,"available_in_bytes":413745967104},"plugins":[],"network_types":{"transport_types":{"security4":3},"http_types":{"security4":3}}}}

多机部署

1
2
network.host: your ip #(主机地址)对外发布的网络地址 
discovery.zen.ping.unicast.hosts: ["$ip"] $ip一定要包含该集群中其他机器的ip

Elasticsearch 常用术语

Document 文档数据
Index 索引
Type 索引中的数据类型
Field 字段,文档属性
Query DSL查询语法

CRUD

Create

request
1
2
3
4
5
6
POST /accounts/person/1
{
"name": "Bob",
"job": "developer",
"sex": "male"
}

这条语句表述的含义是在index为accounts的person类型下创建一条id为1的数据
Result:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"_index" : "accounts",
"_type" : "person",
"_id" : "1",
"_version" : 1,
"result" : "created",
"_shards" : {
"total" : 2,
"successful" : 1,
"failed" : 0
},
"_seq_no" : 0,
"_primary_term" : 1
}

GET

request
1
GET /accounts/person/1

Result:

1
2
3
4
5
6
7
8
9
10
11
12
{
"_index" : "accounts",
"_type" : "person",
"_id" : "1",
"_version" : 1,
"found" : true,
"_source" : {
"name" : "Bob",
"job" : "developer",
"sex" : "male"
}
}

Update

request
1
2
3
4
5
6
POST /accounts/person/1/_update
{
"doc": {
"name": "John"
}
}

Result:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"_index" : "accounts",
"_type" : "person",
"_id" : "1",
"_version" : 2,
"result" : "updated",
"_shards" : {
"total" : 2,
"successful" : 1,
"failed" : 0
},
"_seq_no" : 1,
"_primary_term" : 2
}

Delete

request
1
DELETE /accounts/person/1

Result:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"_index" : "accounts",
"_type" : "person",
"_id" : "1",
"_version" : 5,
"result" : "deleted",
"_shards" : {
"total" : 2,
"successful" : 1,
"failed" : 0
},
"_seq_no" : 4,
"_primary_term" : 2
}

查询语法

Query String

后面接?q形式

request
1
GET /accounts/person/_search?q=John

Query Dsl

request
1
2
3
4
5
6
7
8
GET /accounts/person/_search
{
"query": {
"match": {
"name": "John"
}
}
}

详细参考query-dsl

ElasticSearch初步介绍

正排/倒排索引

正排索引:

文档ID文档内容
1elasticsearch是最流行的搜索引擎
2php是世界最好的语言
3搜索引擎是如何诞生的

倒排索引:

文档内容文档ID
elasticsearch1
流行1
搜索引擎1,3
php2
世界2
最好2
语言2
如何3
诞生3

查询包含搜索引擎的文档:

  1. 通过倒排索引获得含有“搜索引擎”的文档ID是1,3
  2. 通过正排索引查看1,3的内容
  3. 返回结果

倒排索引基本概念

倒排索引(Inverted Index):倒排索引是实现“单词-文档矩阵”的一种具体存储形式,通过倒排索引,可以根据单词快速获取包含这个单词的文档列表。倒排索引主要由两个部分组成:“单词词典”和“倒排文件”。

单词词典(Lexicon):搜索引擎的通常索引单位是单词,单词词典是由文档集合中出现过的所有单词构成的字符串集合,单词词典内每条索引项记载单词本身的一些信息以及指向“倒排列表”的指针。

倒排列表(PostingList):倒排列表记载了出现过某个单词的所有文档的文档列表及单词在该文档中出现的位置信息,每条记录称为一个倒排项(Posting)。根据倒排列表,即可获知哪些文档包含某个单词。

倒排文件(Inverted File):所有单词的倒排列表往往顺序地存储在磁盘的某个文件里,这个文件即被称之为倒排文件,倒排文件是存储倒排索引的物理文件。

倒排索引的组成

倒排索引是搜索引擎的核心,主要包含两个部分:

  1. 单词词典(Trem Dictionary):记录的是所有的文档分词后的结果
  2. 倒排列表(Posting List):记录了单词对应文档的集合,由倒排索引项(Posting)组成。

单词字典的实现一般采用B+Tree的方式,来保证高效

倒排索引项(Posting)主要包含如下的信息:

  1. 文档ID,用于获取原始文档的信息
  2. 单词频率(TF,Term Frequency),记录该单词在该文档中出现的次数,用于后续相关性算分。
  3. 位置(Position),记录单词在文档中的分词位置(多个),用于做词语搜索。
  4. 偏移(Offset),记录单词在文档的开始和结束位置,用于高亮显示

倒排列表:

“elasticsearch是最流行的搜索引擎”可分为3个词语,搜索引擎在第二个位置,并且字符位置是18-22之间

es存储的是一个json格式的文档,其中包含多个字段,每个字段都会有自己的倒排索引,类似这种

1
2
3
4
{
"username": "huangtoufa",
"job": "programmer"
}

job和username都有自己的倒排索引

分词介绍

分词是指将文本转换成一系列单词( term or token )的过程,也可以叫做文本分析,在es里面称为Analysis

·分词器是es中专门处理分词的组件,英文为Analyzer ,它的组成如下
  - Character Filters
   -针对原始文本进行处理,比如去除html特殊标记符
  - Tokenizer
   -将原始文本按照一定规则切分为单词
  - Token Filters
   -针对tokenizer处理的单词就行再加工,比如转小写、删除或新增等处理

分词调用顺序:

analyze_api

es提供了一个测试分词的api接口,方便验证分词效果, endpoint是_analyze
  -可以直接指定analyzer进行测试
  -可以直接指定索引中的字段进行测试
  -可以自定义分词器进行测试

关键词解释:

analyzer:分词器
text:分词文本
token:分词结果
start_offset:开始偏移位置
end_offset:结束偏移位置
position:分词位置

当没有定义analyzer的时候会使用默认分词器”analyzer”:”standard”

指定field分词:

自定义分词器:tokenizer指名要用哪个分词器,filter指明的是token filter:

自带分词器

es自带如下的分词器
  - Standard Simple
  - Whitespace
  - Stop
  - Keyword
  - Pattern
  - Language






中文分词

·难点
  -中文分词指的是将一个汉字序列切分成一个一个单独的词。在英文中,单词之间是以空格作为自然分界符,汉语中词没有一个形式上的分界符。
  -上下文不同,分词结果迥异,比如交叉歧义问题,比如下面两种分词都合理
    -乒乓球拍/卖完了
    -乒乓球/拍卖/完了

·常用分词系统
  -IK
    -实现中英文单词的切分,支持ik smart, ik maxword等模式
    -可自定义词库,支持热更新分词词典,
    - https://github.com/medcl/elasticsearch-analysis-ik
  - jieba
    -python中最流行的分词系统,支持分词和词性标注
    -支持繁体分词、自定义词典、并行分词等
    - https://github.com/sing1ee/elasticsearch-jieba-plugin
·基于自然语言处理的分词系统
  - Hanlp
    -由一系列模型与算法组成的Java工具包,目标是普及自然语言处理在生产环境中的应用
    - https://github.com/hankcs/HanLP
  -THULAC
    -THU Lexical Analyzer for Chinese ,由清华大学自然语言处理与社会人文计算实验室研制推出的一套中文词法分析工具包,具有中文分词和词性标注功能
    - https://github.com/microbun/elasticsearch-thulac-plugin

自定义分词

CharacterFilter:

当自带的分词无法满足需求时,可以自定义分词

  • 通过自定义Character Filters, Tokenizer和Token Filter实现. Character Filters
  • 在Tokenizer之前对原始文本进行处理,比如增加、删除或替换字符等
  • 自带的如下:
    • HTML Strip去除html标签和转换html实体
    • Mapping进行字符替换操作
    • Pattern Replace进行正则匹配替换
  • 会影响后续tokenizer解析的postion和offset信息

Character Filters测试时可以采用如下api :

Tokenizer:
Tokenizer

  • 将原始文本按照一定规则切分为单词( term or token )
  • 自带的如下:
    • standard按照单词进行分割
    • letter按照非字符类进行分割
    • whitespace按照空格进行分割
    • UAX URL Email按照standard分割,但不会分割邮箱和url
    • NGram和Edge NGram连词分割
    • Path Hierarchy按照文件路径进行切割

api:

request
1
2
3
4
5
POST _analyze
{
"tokenizer": "path_hierarchy",
"text": "one/two/three"
}

Result:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
{
"tokens" : [
{
"token" : "one",
"start_offset" : 0,
"end_offset" : 3,
"type" : "word",
"position" : 0
},
{
"token" : "one/two",
"start_offset" : 0,
"end_offset" : 7,
"type" : "word",
"position" : 0
},
{
"token" : "one/two/three",
"start_offset" : 0,
"end_offset" : 13,
"type" : "word",
"position" : 0
}
]
}

TokenFilter:
Token Filters

  • 对于tokenizer输出的单词( term )进行增加、删除、修改等操作
  • 自带的如下:
    • lowercase将所有term转换为小写
    • stop删除stop words
    • NGram和Edge NGram连词分割
    • Synonym添加近义词的term

api:

request
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
POST _analyze
{
"tokenizer": "standard",
"text": "a Hello, world!",
"filter": [
"stop",
"lowercase",
{
"type": "ngram",
"min_gram": 4,
"max_gram": 4
}
]
}

Result:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
{
"tokens" : [
{
"token" : "hell",
"start_offset" : 2,
"end_offset" : 7,
"type" : "<ALPHANUM>",
"position" : 1
},
{
"token" : "ello",
"start_offset" : 2,
"end_offset" : 7,
"type" : "<ALPHANUM>",
"position" : 1
},
{
"token" : "worl",
"start_offset" : 9,
"end_offset" : 14,
"type" : "<ALPHANUM>",
"position" : 2
},
{
"token" : "orld",
"start_offset" : 9,
"end_offset" : 14,
"type" : "<ALPHANUM>",
"position" : 2
}
]
}

Custorm:
自定义分词需要在索引的配置中设定,如下所示:

创建自定义分词器:

request
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
PUT test_index
{
"settings": {
"analysis": {
"analyzer": {
"my_custom_analyzer": {
"type": "custom",
"tokenizer": "standard",
"char_filter": [
"html_strip"
],
"filter": [
"lowercase",
"asciifolding"
]
}
}
}
}
}
request
1
GET test_index
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
{
"test_index" : {
"aliases" : { },
"mappings" : { },
"settings" : {
"index" : {
"number_of_shards" : "5",
"provided_name" : "test_index",
"creation_date" : "1552462361614",
"analysis" : {
"analyzer" : {
"my_custom_analyzer" : {
"filter" : [
"lowercase",
"asciifolding"
],
"char_filter" : [
"html_strip"
],
"type" : "custom",
"tokenizer" : "standard"
}
}
},
"number_of_replicas" : "1",
"uuid" : "novqTjprQE24bJPdtxydCA",
"version" : {
"created" : "6050099"
}
}
}
}
}

自定义分词验证:

request
1
2
3
4
5
POST test_index/_analyze
{
"analyzer": "my_custom_analyzer",
"text": "<a>This is a <b>Text</b></a>"
}

分词结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
{
"tokens" : [
{
"token" : "this",
"start_offset" : 3,
"end_offset" : 7,
"type" : "<ALPHANUM>",
"position" : 0
},
{
"token" : "is",
"start_offset" : 8,
"end_offset" : 10,
"type" : "<ALPHANUM>",
"position" : 1
},
{
"token" : "a",
"start_offset" : 11,
"end_offset" : 12,
"type" : "<ALPHANUM>",
"position" : 2
},
{
"token" : "text",
"start_offset" : 16,
"end_offset" : 28,
"type" : "<ALPHANUM>",
"position" : 3
}
]
}

分词使用说明

分词会在如下两个时机使用:

  • 创建或更新文档时(Index Time ) ,会对相应的文档进行分词处理
  • 查询时( Search Time ) ,会对查询语句进行分词

索引时分词

索引时分词是通过配置Index Mapping中每个字段的analyzer属性实现的如下:不指定分词时,使用默认standard

查询时分词

查询时分词的指定方式有如下几种:

  • 查询的时候通过analyzer指定分词器
  • 通过index mapping设置search_analyzer实现

实例1:我们做一个查询,我们试图通过搜索 message2 这个关键词来搜索这个文档

request
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
GET test_index1/doc/1

POST test_index1/doc/1
{
"message": "this Is a Message"
}

POST test_index1/doc/2
{
"message": "this Is a Message2"
}

POST test_index1/_search
{
"query": {
"match": {
"message": {
"query": "message2",
"analyzer": "standard"
}
}
}
}

返回结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
{
"took" : 1,
"timed_out" : false,
"_shards" : {
"total" : 5,
"successful" : 5,
"skipped" : 0,
"failed" : 0
},
"hits" : {
"total" : 1,
"max_score" : 0.2876821,
"hits" : [
{
"_index" : "test_index1",
"_type" : "doc",
"_id" : "2",
"_score" : 0.2876821,
"_source" : {
"message" : "this Is a Message2"
}
}
]
}
}

查询时使用自定义分词器

request
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
DELETE test_index1

PUT test_index1
{
"settings": {
"analysis": {
"analyzer": {
"my_custom_analyzer": {
"type": "custom",
"tokenizer": "standard",
"char_filter": [
"html_strip"
],
"filter": [
"lowercase",
"asciifolding"
]
}
}
}
}
}

POST test_index1/doc/1
{
"message": "<a>this Is a <b>Message</b></a>"
}

POST test_index1/doc/2
{
"message": "<a>this Is a <b>Message2</b></a>"
}

POST test_index1/_search
{
"query": {
"match": {
"message": {
"query": "Message2",
"analyzer": "my_custom_analyzer"
}
}
}
}

分词建议

  • 明确字段是否需要分词,不需要分词的字段就将type设置为keyword ,可以节省空间和提高写性能
  • 善用_analyze API ,查看文档的具体分词结果
  • 动手测试

分词使用分析

创建自定义分词

request
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
DELETE test_index

PUT test_index
{
"settings": {
"analysis": {
"analyzer": {
"my_custom_analyzer": {
"type": "custom",
"tokenizer": "standard",
"char_filter": [
"html_strip"
],
"filter": [
"lowercase",
"asciifolding"
]
}
}
}
}
}

插入数据:

request
1
2
3
4
POST test_index/doc/1
{
"message": "<a>This is a <b>Text</b></a>"
}

分词检索:

request
1
2
3
4
5
6
7
8
9
10
11
POST test_index/_search
{
"query": {
"match": {
"message": {
"query": "<div>this is</div>",
"analyzer": "my_custom_analyzer"
}
}
}
}

返回结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
{
"took" : 0,
"timed_out" : false,
"_shards" : {
"total" : 5,
"successful" : 5,
"skipped" : 0,
"failed" : 0
},
"hits" : {
"total" : 1,
"max_score" : 0.5753642,
"hits" : [
{
"_index" : "test_index",
"_type" : "doc",
"_id" : "1",
"_score" : 0.5753642,
"_source" : {
"message" : "<a>This is a <b>Text</b></a>"
}
}
]
}
}

为什么会返回这条记录,我们使用分词分析来分析原因:

request
1
2
3
4
5
POST test_index/_analyze
{
"analyzer": "my_custom_analyzer",
"text": "<a>This is a <b>Text</b></a>"
}

Result:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
{
"tokens" : [
{
"token" : "this",
"start_offset" : 3,
"end_offset" : 7,
"type" : "<ALPHANUM>",
"position" : 0
},
{
"token" : "is",
"start_offset" : 8,
"end_offset" : 10,
"type" : "<ALPHANUM>",
"position" : 1
},
{
"token" : "a",
"start_offset" : 11,
"end_offset" : 12,
"type" : "<ALPHANUM>",
"position" : 2
},
{
"token" : "text",
"start_offset" : 16,
"end_offset" : 28,
"type" : "<ALPHANUM>",
"position" : 3
}
]
}

由上面的结果看一看出我们自定义的分词将<a>This is a <b>Text</b></a> 分成了this,is,a,text
分成这种结果的原因是分词使用了如下图的分词器

我们由前面的分词器的解释可了解到lowercase将元文本规范化为小写,asciifolding 类型的词元过滤器,将不在前127个ASCII字符(“基本拉丁文”Unicode块)中的字母,数字和符号Unicode字符转换为ASCII等效项(如果存在)。
html strip将文本的html标签进行了过滤,standard见上面的介绍,所以最终自定义分词分成了this,is,a,text这样的结果,而上面query里面的<div>this is<div>首先html标签被过滤掉,所以不管什么标签都可以满足条件,其次
this is用_analyze分析会被拆成this,is也符合分词匹配结果,如果query里面的this is变为thisis就不符合当前匹配结果了

文档

什么是文档

程序中大多的实体或对象能够被序列化为包含键值对的JSON对象,键(key)是字段(field)或属性(property)的名字,值(value)可以是字符串、数字、布尔类型、另一个对象、值数组或者其他特殊类型,比如表示日期的字符串或者表示地理位置的对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{
"name": "John Smith",
"age": 42,
"confirmed": true,
"join_date": "2014-06-01",
"home": {
"lat": 51.5,
"lon": 0.1
},
"accounts": [
{
"type": "facebook",
"id": "johnsmith"
},
{
"type": "twitter",
"id": "johnsmith"
}
]
}

通常,我们可以认为对象(object)和文档(document)是等价相通的。不过,他们还是有所差别:对象(Object)是一个JSON结构体——类似于哈希、hashmap、字典或者关联数组;对象(Object)中还可能包含其他对象(Object)。 在Elasticsearch中,文档(document)这个术语有着特殊含义。它特指最顶层结构或者根对象(root object)序列化成的JSON数据(以唯一ID标识并存储于Elasticsearch中)。

归结

  1. Json Object,由字段组成:

    • 字符串:text, keyword
    • 数值型:long,integer,short,byte,double,float,half_float,scled_float
    • 布尔:boolean
    • 日期:date
    • 二进制:binary
    • 范围类型:integer_range,float_range,long_range,double_range,date_range
  2. 每个文档有唯一的id标识

    • 自行指定
    • es生成
  3. 元数据,用于标注文档的相关信息

    • _index:文档所在的索引名
    • _type:文档所在的类型名
    • _id:文档唯一id
    • _uid:组合id,由_type和_id组成(6.x_type不再起作用,同_id一样)
    • _source:文档的原始Json数据,可以从这里获取每个字段的内容
    • _all:整合所有字段内容到该字段,默认禁用

文档元数据

一个文档不只有数据。它还包含了元数据(metadata)——关于文档的信息。三个必须的元数据节点是:

节点说明
_index文档存储的地方
_type文档代表的对象的类
_id文档的唯一标识

_index

索引(index)类似于关系型数据库里的“数据库”——它是我们存储和索引关联数据的地方。

事实上,我们的数据被存储和索引在分片(shards)中,索引只是一个把一个或多个分片分组在一起的逻辑空间。然而,这只是一些内部细节——我们的程序完全不用关心分片。对于我们的程序而言,文档存储在索引(index)中。剩下的细节由Elasticsearch关心既可。

_type
在应用中,我们使用对象表示一些“事物”,例如一个用户、一篇博客、一个评论,或者一封邮件。每个对象都属于一个类(class),这个类定义了属性或与对象关联的数据。user类的对象可能包含姓名、性别、年龄和Email地址。
在关系型数据库中,我们经常将相同类的对象存储在一个表里,因为它们有着相同的结构。同理,在Elasticsearch中,我们使用相同类型(type)的文档表示相同的“事物”,因为他们的数据结构也是相同的。
每个类型(type)都有自己的映射(mapping)或者结构定义,就像传统数据库表中的列一样。所有类型下的文档被存储在同一个索引下,但是类型的映射(mapping)会告诉Elasticsearch不同的文档如何被索引。

在7.0前,一个index可以设置多个type,在7.0后type统一为_doc

_id

id仅仅是一个字符串,它与_index和_type组合时,就可以在Elasticsearch中唯一标识一个文档。当创建一个文档,你可以自定义_id,也可以让Elasticsearch帮你自动生成。

Index Api

以下操作均在es7.2版本操作

Index(createorupdate)

PUT {_index}/{_type}/{_id}
POST {_index}/{_type}/{_id}(id可不指定)

创建一个索引

1
PUT test_index

创建一个索引并指定文档内容和id

1
2
3
4
5
PUT /test_index/_doc/1
{
"username":"alfred",
"age":1
}

创建一个索引指定文档内容自动生成id

1
2
3
4
5
POST /test_index/_doc
{
"username":"lili",
"age":22
}

文档不存在执行的是创建操作,存在的话回变为更新操作,并且_version会增加1

Create

有两种方式:

第一种方法使用op_type查询参数:

1
2
3
4
5
PUT /test_index/_doc/1?op_type=create
{
"username":"alfred",
"age":1
}

或者第二种方法是在URL后加/_create做为端点:

1
2
3
4
5
PUT /test_index/_doc/1/_create
{
"username":"alfred",
"age":1
}

如果请求成功的创建了一个新文档,Elasticsearch将返回正常的元数据且响应状态码是201 Created。
另一方面,如果包含相同的_index、_type和_id的文档已经存在,Elasticsearch将返回409 Conflict响应状态码,错误信息类似如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
{
"error": {
"root_cause": [
{
"type": "version_conflict_engine_exception",
"reason": "[1]: version conflict, document already exists (current version [6])",
"index_uuid": "vENoH2YDQRiFHvu_pfzDcg",
"shard": "0",
"index": "test_index"
}
],
"type": "version_conflict_engine_exception",
"reason": "[1]: version conflict, document already exists (current version [6])",
"index_uuid": "vENoH2YDQRiFHvu_pfzDcg",
"shard": "0",
"index": "test_index"
},
"status": 409
}

POST和PUT的区别是POST不用加具体的id,它是作用在一个集合资源之上的(/uri),而PUT操作是作用在一个具体资源之上的(/uri/xxx)。
在ES中,如果不确定document的ID(documents具体含义见下),那么直接POST对应uri( “POST /website/blog” ),ES可以自己生成不会发生碰撞的UUID;
如果确定document的ID,比如 “PUT /website/blog/123”,那么执行创建或修改(修改时_version版本号提高1)

Update

1
2
3
4
5
6
PUT /test_index/_update/1
{
"doc": {
"hobby": "hit"
}
}

Delete

1
DELETE test_index

Get

获取所有

1
GET /test_index/_search

指定id

1
GET /test_index/_doc/1

归结

操作请求
CreatePUT /test_index/_doc/1/_create
{“username”:”alfred”,”age”:1}
POST /test_index/_doc/
{“username”:”alfred”,”age”:1}(不指定id自动生成)
IndexPUT Or POST /test_index/_doc/1/
{“username”:”alfred”,”age”:1}
ReadGET /test_index/_doc/1
UpdatePOST /test_index/_update/1
{“doc”: {“sex”: “女”}}
DeleteDELETE test_index2

Index和Update
Index实际上是文档不存在会创建一个文档,存在则会删除原来的文档,新的文档被索引,并且version加1
Update不会删除原来的文档,实现真正的更新,POST方法的payload需包含在”doc”里

Bulk

1
2
3
4
5
6
POST _bulk
{"index":{"_index":"test_index","_id":"3"}}
{"username":"alfred","age":20}
{"delete":{"_index":"test_index","_id":"1"}}
{"update":{"_id":"2","_index":"test_index"}}
{"doc":{"age":"20"}}

批量查询api

es允许一次查询多个文档,如果你需要从Elasticsearch中检索多个文档,相对于一个一个的检索,更快的方式是在一个请求中使用multi-get或者mget API。

mget API参数是一个docs数组,数组的每个节点定义一个文档的_index、_type、_id元数据。如果你只想检索一个或几个确定的字段,也可以定义一个_source参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
POST _mget
{
"docs": [
{
"_id": 1,
"_index":"test_index"
},
{
"_id": 2,
"_index":"test_index"
}
]
}

响应体也包含一个docs数组,每个文档还包含一个响应,它们按照请求定义的顺序排列。每个这样的响应与单独使用get request响应体相同:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
{
"docs" : [
{
"_index" : "test_index",
"_type" : "_doc",
"_id" : "1",
"_version" : 1,
"_seq_no" : 17,
"_primary_term" : 2,
"found" : true,
"_source" : {
"name" : "lili",
"age" : "10"
}
},
{
"_index" : "test_index",
"_type" : "_doc",
"_id" : "2",
"_version" : 1,
"_seq_no" : 16,
"_primary_term" : 2,
"found" : true,
"_source" : {
"name" : "john",
"age" : "40"
}
}
]
}

如果你想检索的文档在同一个_index中,你就可以在URL中定义一个默认的/_index。
你依旧可以在单独的请求中使用这些值:

1
2
3
4
5
6
7
POST /test_index/_doc/_mget
{
"docs" : [
{ "_id" : 2 },
{ "_id" : 1 }
]
}

在7.0以上版本后,_doc在mget中可以省略,不省略会提示#! Deprecation: [types removal] Specifying types in multi get requests is deprecated.

事实上,如果所有文档具有相同_index,你可以通过简单的ids数组来代替完整的docs数组:

1
2
3
4
POST /test_index/_mget
{
"ids" : [ "4", "1" ]
}

不存在的文档会显示found:false

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
{
"docs" : [
{
"_index" : "test_index",
"_type" : "_doc",
"_id" : "4",
"found" : false
},
{
"_index" : "test_index",
"_type" : "_doc",
"_id" : "1",
"_version" : 1,
"_seq_no" : 17,
"_primary_term" : 2,
"found" : true,
"_source" : {
"name" : "lili",
"age" : "10"
}
}
]
}

尽管前面提到有一个文档没有被找到,但HTTP请求状态码还是200。事实上,就算所有文档都找不到,请求也还是返回200,原因是mget请求本身成功了。如果想知道每个文档是否都成功了,你需要检查found标志。

实验环境

事物隔离场景

repeatable-read

表语句准备

1
2
3
4
5
6
7
8
9
10
CREATE TABLE `t` (
`id` int(11) NOT NULL,
`c` int(11) DEFAULT NULL,
`d` int(11) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `c` (`c`)
) ENGINE=InnoDB;

insert into t values(0,0,0),(5,5,5),
(10,10,10),(15,15,15),(20,20,20),(25,25,25);

先假设一个没有堵塞的场景:

SessionASessionBSessionC
T1begin;
select * from t where d=5 for update;
result:(5,5,5)
T2update t set d=5 where id=0;
T3select * from t where d=5 for update;
result:(0,0,5)(5,5,5)
T4insert into t values(1,1,5);
T5select * from t where d=5 for update;
result:(0,0,5)(1,1,5)(5,5,5)
T6commit;

可以看到,session A 里执行了三次查询,分别是 Q1、Q2 和 Q3。它们的 SQL 语句相同,都是 select * from t where d=5 for update。查所有 d=5 的行,而且使用的是当前读,并且加上写锁。现在,我们来看一下这三条 SQL 语句,分别会返回什么结果。

  1. Q1 只返回 id=5 这一行;
  2. 在 T2 时刻,session B 把 id=0 这一行的 d 值改成了 5,因此 T3 时刻 Q2 查出来的是 id=0 和 id=5 这两行;
  3. 在 T4 时刻,session C 又插入一行(1,1,5),因此 T5 时刻 Q3 查出来的是 id=0、id=1 和 id=5 的这三行。

其中,Q3 读到 id=1 这一行的现象,被称为“幻读”。也就是说,幻读指的是一个事务在前后两次查询同一个范围的时候,后一次查询看到了前一次查询没有看到的行。

Mysql如何解决幻读

本片需要用到的两种锁

LOCK IN SHARE MODE是读锁(只是不让别人写),FOR UPDATE是写锁(还不让别人加读锁)

InnoDB行锁

InnoDB行锁是通过索引上的索引项来实现的,InnoDB这种行锁实现特点意味者:只有通过索引条件检索数据,InnoDB才会使用行级锁,否则,InnoDB将使用表锁!
select * from t where d=5 for update;会锁住d=5索引覆盖范围的相关行,如果没有索引则会锁住所有行

间隙锁 (Gap Lock)

行锁只能锁住行,但是新插入记录这个动作,要更新的是记录之间的“间隙”。因此,为了解决幻读问题,InnoDB 只好引入新的锁,也就是间隙锁 (Gap Lock)。
间隙锁,锁的就是两个值之间的空隙。比如文章开头的表 t,初始化插入了 6 个记录,这就产生了 7 个间隙。
这样,当你执行 select * from t where d=5 for update 的时候,就不止是给数据库中已有的 6 个记录加上了行锁,还同时加了 7 个间隙锁。这样就确保了无法再插入新的记录。

行锁和间隙锁比较

行锁,分成读锁和写锁。下图就是这两种类型行锁的冲突关系。

读锁写锁
读锁兼容冲突
写锁冲突冲突

也就是说,跟行锁有冲突关系的是“另外一个行锁”。
但是间隙锁不一样,跟间隙锁存在冲突关系的,是“往这个间隙中插入一个记录”这个操作。间隙锁之间都不存在冲突关系。

SessionASessionB
begin;
select * from t where c=7 lock in share mode;
begin;
select * from t where c=7 for update;

这里 session B 并不会被堵住。因为表 t 里并没有 c=7 这个记录,因此 session A 加的是间隙锁 (5,10)。而 session B 也是在这个间隙加的间隙锁。它们有共同的目标,即:保护这个间隙,不允许插入值。但,它们之间是不冲突的。

间隙锁和行锁合称 next-key lock,每个 next-key lock 是前开后闭区间。也就是说,我们的表 t 初始化以后,如果用 select * from t for update 要把整个表所有记录锁起来,就形成了 7 个 next-key lock,分别是 (-∞,0]、(0,5]、(5,10]、(10,15]、(15,20]、(20, 25]、(25, +supremum]。

间隙锁和 next-key lock 的引入,帮我们解决了幻读的问题,但同时也带来了一些“困扰”。

看下面的实例

SessionASessionB
begin;
select * from t where id=9 for update;
begin;
select * from t where id=9 for update;
insert into t values(9,9,9);
result:(blocked)
insert into t values(9,9,9);
result:(Deadlock)

上面的场景形成死锁了。我们按语句执行顺序来分析一下:

  1. session A 执行 select … for update 语句,由于 id=9 这一行并不存在,因此会加上间隙锁 (5,10);
  2. session B 执行 select … for update 语句,同样会加上间隙锁 (5,10),间隙锁之间不会冲突,因此这个语句可以执行成功;
  3. session B 试图插入一行 (9,9,9),被 session A 的间隙锁挡住了,只好进入等待;
  4. session A 试图插入一行 (9,9,9),被 session B 的间隙锁挡住了。

至此,两个 session 进入互相等待状态,形成死锁。当然,InnoDB 的死锁检测马上就发现了这对死锁关系,让 session A 的 insert 语句报错返回了。

mysql锁类型

锁规则里面,包含了两个“原则”、两个“优化”和一个“bug”。

MySQL 后面的版本可能会改变加锁策略,所以这个规则只限于截止到现在的最新版本,即 5.x 系列 <=5.7.24,8.0 系列 <=8.0.13。

原则 1:加锁的基本单位是 next-key lock。希望你还记得,next-key lock 是前开后闭区间。
原则 2:查找过程中访问到的对象才会加锁。
优化 1:索引上的等值查询,给唯一索引加锁的时候,next-key lock 退化为行锁。
优化 2:索引上的等值查询,向右遍历时且最后一个值不满足等值条件的时候,next-key lock 退化为间隙锁。
一个特殊场景:唯一索引上的范围查询会访问到不满足条件的第一个值为止。

等值查询间隙锁

SessionASessionBSessionC
begin;
update t set d=d+1 where id=7;
insert into t values(8,8,8);
result:(blocked)
update t set d=d+1 where id=10;
result:(ok)

由于表 t 中没有 id=7 的记录,所以用我们上面提到的加锁规则判断一下的话:

  1. 根据原则 1,加锁单位是 next-key lock,session A 加锁范围就是 (5,10];
  2. 同时根据优化 2,这是一个等值查询 (id=7),而 id=10 不满足查询条件,next-key lock 退化成间隙锁,因此最终加锁的范围是 (5,10)。

所以,session B 要往这个间隙里面插入 id=8 的记录会被锁住,但是 session C 修改 id=10 这行是可以的。

非唯一索引等值锁

SessionASessionBSessionC
begin;
select id from t where c=5 lock in share mode(读锁);
update t set d=d+1 where id=5;
result:(ok)
insert into t values(7,7,7);
result:(blocked)

看到这个例子,你是不是有一种“该锁的不锁,不该锁的乱锁”的感觉?我们来分析一下吧。

  1. 根据原则 1,加锁单位是 next-key lock,因此会给 (0,5] 加上 next-key lock。
  2. 要注意 c 是普通索引,因此仅访问 c=5 这一条记录是不能马上停下来的,需要向右遍历,查到 c=10 才放弃。根据原则 2,访问到的都要加锁,因此要给 (5,10] 加 next-key lock。
  3. 但是同时这个符合优化 2:等值判断,向右遍历,最后一个值不满足 c=5 这个等值条件,因此退化成间隙锁 (5,10)。
  4. 根据原则 2 ,只有访问到的对象才会加锁,这个查询使用覆盖索引,并不需要访问主键索引,所以主键索引上没有加任何锁,这就是为什么 session B 的 update 语句可以执行完成。

但 session C 要插入一个 (7,7,7) 的记录,就会被 session A 的间隙锁 (5,10) 锁住。

需要注意,在这个例子中,lock in share mode 只锁覆盖索引,但是如果是 for update 就不一样了。 执行 for update 时,系统会认为你接下来要更新数据,因此会顺便给主键索引上满足条件的行加上行锁。

这个例子说明,锁是加在索引上的;同时,它给我们的指导是,如果你要用 lock in share mode 来给行加读锁避免数据被更新的话,就必须得绕过覆盖索引的优化,在查询字段中加入索引中不存在的字段。比如,将 session A 的查询语句改成 select d from t where c=5 lock in share mode。

主键索引范围锁

SessionASessionBSessionC
begin;
select * from t where id >=10 and id<11 for update;
insert into t values(7,7,7);
result:(ok)
insert into t values(13,13,13);
result:(blocked)
update t set d=d+1 where id=15;
result:(blocked)
  1. 开始执行的时候,要找到第一个 id=10 的行,因此本该是 next-key lock(5,10]。 根据优化 1, 主键 id 上的等值条件,退化成行锁,只加了 id=10 这一行的行锁。
  2. 范围查找就往后继续找,找到 id=15 这一行停下来,因此需要加 next-key lock(10,15]。

所以,session A 这时候锁的范围就是主键索引上,行锁 id=10 和 next-key lock(10,15]。

非唯一索引范围锁

SessionASessionBSessionC
begin;
select * from t where c>10 and c<11 for update;
insert into t values(8,8,8);
result:(blocked)
update t set d=d+1 where id=15;
result:(blocked)

这次 session A 用字段 c 来判断,加锁规则跟主键索引范围锁唯一的不同是:在第一次用 c=10 定位记录的时候,索引 c 上加了 (5,10] 这个 next-key lock 后,由于索引 c 是非唯一索引,没有优化规则,也就是说不会蜕变为行锁,因此最终 sesion A 加的锁是,索引 c 上的 (5,10] 和 (10,15] 这两个 next-key lock。

唯一索引范围锁特殊场景

SessionASessionBSessionC
begin;
select * from t where id >10 and id<=15 for update;
update t set d=d+1 where id=20;
result:(blocked)
insert into t values(16,16,16);
result:(blocked)

session A 是一个范围查询,按照原则 1 的话,应该是索引 id 上只加 (10,15] 这个 next-key lock,并且因为 id 是唯一键,所以循环判断到 id=15 这一行就应该停止了。
但是实现上,InnoDB 会往前扫描到第一个不满足条件的行为止,也就是 id=20。而且由于这是个范围扫描,因此索引 id 上的 (15,20] 这个 next-key lock也会被锁上。

什么是动态规划

动态规划,英文:Dynamic Programming,简称DP,如果某一问题有很多重叠子问题,使 用动态规划是最有效的。

动态规划的解题步骤

结合自己之前的经历分析,做动规题目的时候,只是把递归推导公式推出来就拉到了,然后就开始解题,提交题目的时候经常发现解题总是会出现一些细节问题,比如dp数组初始化不对,或者遍历的数组对象有问题等等…甚至很多时候把题目AC之后,都不太清楚dp[i]表示的是什么。
这就是一种朦胧的状态,然后就把题给过了,遇到稍稍难一点的,可能直接就不会了,然后 看题解,然后继续照葫芦画瓢陷入这种恶性循环中。
最近看了Carl的动态规划分析专栏,对这套算法体系有了一定的理解,并对解题思路进行了总结

对于动态规划问题,可拆解为如下五步曲,这五步都搞清楚了,才能说把动态规划真的掌握了!

  1. 确定dp数组(dp table)以及下标的含义
  2. 确定递推公式
  3. dp数组如何初始化
  4. 确定遍历顺序
  5. 举例推导dp数组

动态规划入门题目分析

斐波那契数列

斐波那契数
通常用 F(n) 表示,形成的序列称为 斐波那契数列 。该数列由 0 和 1 开始,后 面的每一项数字都是前面两项数字的和。也就是:

1
2
F(0) = 0,F(1) = 1
F(n) = F(n - 1) + F(n - 2),其中 n > 1

给你n ,请计算 F(n) 。

示例 1:

1
2
3
输入:2
输出:1
解释:F(2) = F(1) + F(0) = 1 + 0 = 1

示例 2:

1
2
输入:3 输出:2
解释:F(3) = F(2) + F(1) = 1 + 1 = 2

示例 3:

1
2
3
输入:4
输出:3
解释:F(4) = F(3) + F(2) = 2 + 1 = 3

提示:0 <= n <= 30

思路

这道题比较简单,题目中已经给出了递推公式,所以不需要分析,直接实现即可

dp5部曲

按照动态规划5部曲进行分析

  1. 确定dp数组以及下标的含义:表示第i个数的斐波那契数值dp[i]
  2. 确定递推公式 dp[i] = dp[i - 1] + dp[i - 2]
  3. dp数组如何初始化 dp[0] = 0, dp[1] = 1
  4. 确定遍历顺序 由于要保证第i个数的值为前两个数的和,所以要正向遍历
  5. 举例推导dp数组 打印dp数组应该是1,1,2,3,5,8,13,21…

代码实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func Fib(n int) int {
if n == 0 {
return 0
}
if n == 1 {
return 1
}
var dp = make([]int, n+1)
dp[0] = 0
dp[1] = 1
for i := 2; i <= n; i++ {
dp[i] = dp[i-1] + dp[i-2]
}
return dp[n]
}
  • 时间复杂度O(n)
  • 空间复杂度O(n)

动态规划题目一般往往可以优化空间复杂度,本道题也一样,可以将dp[0]dp[1]作为两个变量值指针,然后用sum接收一下两个变量的和,在对dp[0]、dp[1]进行前移dp[0] = dp[1],dp[1] = sum

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func Fib(n int) int {
if n == 0 {
return 0
}
if n == 1 {
return 1
}
var dp, sum = [2]int{0, 1}, 0
for i := 2; i <= n; i++ {
sum = dp[0] + dp[1]
dp[0] = dp[1]
dp[1] = sum
}
return sum
}
  • 时间复杂度O(n)
  • 空间复杂度O(1)

爬楼梯

爬楼梯
假设你正在爬楼梯。需要 n 阶你才能到达楼顶。

每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?

示例 1:

1
2
3
4
5
输入:n = 2
输出:2
解释:有两种方法可以爬到楼顶。
1. 1 阶 + 1 阶
2. 2 阶

示例 2:

1
2
3
4
5
6
输入:n = 3
输出:3
解释:有三种方法可以爬到楼顶。
1. 1 阶 + 1 阶 + 1 阶
2. 1 阶 + 2 阶
3. 2 阶 + 1 阶

提示:
1 <= n <= 45

思路

爬到第一层楼梯有一种方法,爬到二层楼梯有两种方法。

那么第一层楼梯再跨两步就到第三层 ,第二层楼梯再跨一步就到第三层。

所以到第三层楼梯的状态可以由第二层楼梯 和 到第一层楼梯状态推导出来,那么就可以想 到动态规划了。

dp5部曲

动态规划5步

  1. 确定dp数组以及下标的含义:表示爬到第n阶有dp[n]种爬法的
  2. 确定递推公式
    首先是dp[i - 1],上i-1层楼梯,有dp[i - 1]种方法,那么再一步跳一个台阶不就是dp[i]
    么。
    还有就是dp[i - 2],上i-2层楼梯,有dp[i - 2]种方法,那么再一步跳两个台阶不就是dp[i]了 么。
    那么dp[i]就是 dp[i - 1]dp[i - 2]之和!
    所以dp[i] = dp[i - 1] + dp[i - 2]
  3. dp数组如何初始化 dp[0] = 1, dp[1] = 1, dp[2] = 2
  4. 确定遍历顺序 从递推公式dp[i] = dp[i - 1] + dp[i - 2];中可以看出,遍历顺序一定是从前向后遍历的
  5. 举例推导dp数组 打印dp数组应该是1,2,3,5,8

通过dp5步分析后,得知这道题其实就是实现斐波那契数列,题目立马变得清晰了,这也是动态规划分析的重要性!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func ClimbStairs(n int) int {
if n == 1 || n == 0 {
return 1
}
if n == 2 {
return 2
}
var dp [3]int
dp[1] = 1
dp[2] = 2
sum := 0
for i := 3; i <= n; i++ {
sum = dp[1] + dp[2]
dp[1] = dp[2]
dp[2] = sum
}
return sum
}
  • 时间复杂度O(n)
  • 空间复杂度O(1)

使用最小花费爬楼梯

使用最小花费爬楼梯
数组的每个下标作为一个阶梯,第 i 个阶梯对应着一个非负数的体力花费值 cost[i](下标从 0 开始)。

每当你爬上一个阶梯你都要花费对应的体力值,一旦支付了相应的体力值,你就可以选择向 上爬一个阶梯或者爬两个阶梯。

请你找出达到楼层顶部的最低花费。在开始时,你可以选择从下标为 0 或 1 的元素作为初始阶梯。

示例 1:

1
2
3
4
5
输入:cost = [10,15,20]
输出:15
解释:你将从下标为 1 的台阶开始。
- 支付 15 ,向上爬两个台阶,到达楼梯顶部。
总花费为 15 。

示例 2:

1
2
3
4
5
6
7
8
9
10
输入:cost = [1,100,1,1,1,100,1,1,100,1]
输出:6
解释:你将从下标为 0 的台阶开始。
- 支付 1 ,向上爬两个台阶,到达下标为 2 的台阶。
- 支付 1 ,向上爬两个台阶,到达下标为 4 的台阶。
- 支付 1 ,向上爬两个台阶,到达下标为 6 的台阶。
- 支付 1 ,向上爬一个台阶,到达下标为 7 的台阶。
- 支付 1 ,向上爬两个台阶,到达下标为 9 的台阶。
- 支付 1 ,向上爬一个台阶,到达楼梯顶部。
总花费为 6 。

提示:

1
2
2 <= cost.length <= 1000
0 <= cost[i] <= 999

思路

这道题目可以说是昨天动态规划:爬楼梯的花费版本。 注意题目描述:每当你爬上一个阶梯你都要花费对应的体力值,一旦支付了相应的体力值,

你就可以选择向上爬一个阶梯或者爬两个阶梯

所以示例1中只花费一个15 就可以到阶梯顶,最后一步可以理解为 不用花费。

img.png

dp五部曲

动态规划5步

cost = [0:10,1:15,2:20]

  1. 确定dp数组以及下标的含义:dp[i]到达第i个台阶(顶层)的最小花费
  2. 确定递推公式
    由题目可知,最小花费方案分为从第i-1阶走1步,或者从第i-2阶走2步到达两种方案
    而不管选那种方案,由于最后一步一定要走,只不过选择的方案不同,所以,我的dp[i]都要固定花费cost[i]
    所以dp[i] = dp[i-1] + cost[i -1]或者dp[i] = dp[i-2] + cost[i - 2]
    最后的最小花费为dp[i] = min(dp[i-1] + cost[i -1], dp[i-2] + cost[i - 2]) + 0
    可得出爬i阶楼梯的最小花费为倒数第一步和倒数第二步的最小值 + 最后一步花费的费用
    dp[i] = min(dp[i - 1], dp[i -2]) + cost[i]
  3. dp数组如何初始化
    那么看一下递归公式,dp[i]dp[i-1],dp[i-2]推出,既然初始化所有的dp[i]是不可能的,
    那么只初始化dp[0]dp[1]就够了,其他的最终都是dp[0]dp[1]推出。
  4. 确定遍历顺序 从递推公式dp[i]dp[i - 1]dp[i - 2]推导出的,所以遍历顺序一定是从前向后遍历的
  5. 举例推导dp数组 题目中打印dp数组应该是
    题目中共有3阶
    1
    2
    3
    4
    dp[0] = 0 + cost[0] = 10
    dp[1] = 0 + cost[1] = 15
    dp[2] = min(dp[1], dp[0]) + cost[2] = 30
    dp[3] = min(dp[2], dp[1]) + cost[3] = 15 // cost[3] = 0
    dp[3]为我们最后返回的结果

代码实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func MinCostClimbingStairs(cost []int) int {
var dp = make([]int, len(cost))
dp[0] = 0 + cost[0]
dp[1] = 0 + cost[1]
for i := 2; i < len(cost); i++ {
dp[i] = min(dp[i-1], dp[i-2]) + cost[i]
}
// 这里实际上是dp[3] = min(dp[2], dp[1]) + cost[3] = 15, cost[3]=0所以被我们省略了
return min(dp[len(cost)-1], dp[len(cost)-2])
}

func min(a, b int) int {
if a < b {
return a
}
return b
}
  • 时间复杂度O(n)
  • 空间复杂度O(n)
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    func MinCostClimbingStairsPlus(cost []int) int {
    var dp0 = 0 + cost[0]
    var dp1 = 0 + cost[1]
    for i := 2; i < len(cost); i++ {
    dpi := min(dp0, dp1) + cost[i]
    dp0 = dp1
    dp1 = dpi
    }
    return min(dp0, dp1)
    }

优化空间复杂度后

  • 时间复杂度O(n)
  • 空间复杂度O(1)

概述

前置知识点

1
2
3
4
5
6
func main()  {
var a = []int{1,2}
b := a
b[1] = 3
fmt.Println(a)
}
1
[1 3]

先上一段简单代码,修改b[1]的值发现a[1]的值也跟着变化,原因是a和b里成员的地址是指向同一处的

1
2
3
4
5
6
7
var a = []int{1,2}
b := a
b[1] = 3
fmt.Printf("a[0]地址:%p\n", &a[0])
fmt.Printf("a[1]地址:%p\n", &a[1])
fmt.Printf("b[0]地址:%p\n", &b[0])
fmt.Printf("b[1]地址:%p\n", &b[1])
1
2
3
4
a[0]地址:0xc0000b2010
a[1]地址:0xc0000b2018
b[0]地址:0xc0000b2010
b[1]地址:0xc0000b2018

踩坑背景

踩坑经历是发生在刷力扣题组合总和这道题的时候,提交后发现一个case没通过
img_1.png
思考🤔了几遍,没发现出代码逻辑上有什么问题,百思不得其解情况下在本地运行了下该case,发现出现了同样的情况😖
下面是当时刷题提交的代码:(为了方便问题分析,由于力扣原题输出项比较多,因此对该case简化了一下)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
func main() {
res1 := CombinationSum1([]int{7,3,2}, 12)
fmt.Println(res1)
}

func CombinationSum1(candidates []int, target int) [][]int {
var res = make([][]int, 0)
var backtrack func([]int, int, int, []int, int)
backtrack = func(candidate[]int, begin, size int, ints []int, val int) {
if val < 0 {
return
}
if val == 0 {
res = append(res, ints)
}
for i := begin; i < size; i++ {
backtrack(candidate, i, size, append(ints, candidate[i]), val - candidate[i])
}
}
backtrack(candidates, 0, len(candidates), []int{}, target)
return res
}

查看输出:

1
[[7 3 2] [3 3 3 2] [3 3 2 2 2] [2 2 2 2 2 2]]

很明显[3 3 3 2]加起来不是12,和leetcode出现的问题是一样的

踩坑原因分析

一开始,先是在append处打印下ints变量内容,看看是不是路径和计算有问题

1
2
3
4
if val == 0 {
fmt.Printf("append ints %v\n", ints)
res = append(res, ints)
}
1
2
3
4
5
append ints [7 3 2]
append ints [3 3 3 3]
append ints [3 3 2 2 2]
append ints [2 2 2 2 2 2]
[[7 3 2] [3 3 3 2] [3 3 2 2 2] [2 2 2 2 2 2]]

此时,发现了事情的不妙,appendintsints的成员是正确的,但是最终输出ints的成员确由[3 3 3 3]变为了[3 3 3 2],
于是猜测一定函数backtrack(...append(ints, candidate[i])...)这个函数调用时ints[3]成员的地址的值在后面被修改了,因为通过先前的举例我们也知道了slice里的成员是引用的吗🔍
所以决定断点打印一下来印证自己的猜想,由于断点过程中需要每次查看栈空间函数各个参数的值已经函数递归调用是ints成员的地址值的变化,所以采用gdb工具进行断点

断点追踪

通过上面的输出结果我们知道ints是在第二次append后输出的,所以直接从第二次append后开始分析,并且ints[3]发生了非预期结果,所以只分析ints[3]的地址和值即可

追踪step1

首先,因为发现问题分歧的原因是在res = append(res, ints)这行产生的,因此在这里break一下(如下图所示)
img_11.png

追踪step2

看一下当前空间变量成员(如下图所示)
img_12.png

这时,ints[3]还是正常的,打印结果是3,我们记录下当前ints[3]的地址0xc0000ae078,ints变量的地址0xc0000ae060也记录下,后面会用到

追踪step3

beign=1;i=1时(如下图所示);candidate[i]会被append到ints中,看下candidate[i] = 3,这时即使ints[3]地址被重新赋值为candidate[i],也还是会为3,看不出来什么变化,继续走
img_13.png

追踪step4

我们直接跳到i=2;begin=1(如下图所示);此时发现下面的i=1,证明函数还没开始调用,继续
img_14.png

为什么在i=2后面i又变为了1,并且还有backtrack函数调用?
解释:因为在回溯递归场景中,当前循环里,函数可能还存在其他循环时递归调用的函数,而在其他循环里递归调用的函数成员内存地址是跟当前循环不一样的,所以这里不做追踪

追踪step5

打印candidate[2],发现值为2(如下图所示),因为这时函数还未调用,candidate[2]还未append,函数将会在下一行调用,所以ints[3]还是3继续追踪
img_15.png

追踪step6

当backtrack函数执行后;candidate[2]被append到了ints中,此时ints[3]的地址的值发生了修改(如下图所示),所以res最后返回结果的切片也发生了修改
img_16.png
此时ints[]3的地址0xc0000ae078还是之前的地址,证实了我们的猜想

追踪step7

接着往下走,因为i=2后,在++为3后不满足i<size判断,所以退出当前循环,进入到下一次循环的函数栈
img_17.png
此时ints[3]地址由0xc0000ae078变为了0xc0000ac058,ints地址由0xc0000ae060变为了0xc0000ac040

之前的ints地址和ints地址在追踪step2记录过

初步结论

在当前函数栈内,ints成员的地址在每次循环调用时是不变的,所以在当前循环函数调用时,ints发生append后,由于是浅拷贝,所以会发生返回切片结果成员数据被污染的情况

追踪结论印证

从前面的结论可知,由于candidate[2]是在循环最后被append的,所以对ints[3]地址指向的值发生了修改,我们可以通过在输入项中增加一个元素,来印证当前结论

1
res1 := CombinationSum1([]int{7,3,2, 8}, 12)

打印输出结果

1
2
3
4
5
6
append ints [7 3 2]
append ints [3 3 3 3]
append ints [3 3 2 2 2]
append ints [2 2 2 2 2 2]
append ints [2 2 8]
[[7 3 2] [3 3 3 8] [3 3 2 2 2] [2 2 2 2 2 8] [2 2 8]]

可以看到[3 3 3 2]变为了[3 3 3 8]

How To Fix

了解问题根本原因后想解决就很简单了,我们只要在append时深拷贝一次ints就可以了
解决后完整代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
func main() {
res1 := CombinationSum2([]int{7,3,2, 8}, 12)
fmt.Println(res1)
}

func CombinationSum1(candidates []int, target int) [][]int {
var res = make([][]int, 0)
var backtrack func([]int, int, int, []int, int)
backtrack = func(candidate[]int, begin, size int, ints []int, val int) {
if val < 0 {
return
}
if val == 0 {
var tmp = make([]int, len(ints))
copy(tmp, ints)
fmt.Println("append after copied ints", tmp)
res = append(res, tmp)
}
for i := begin; i < size; i++ {
backtrack(candidate, i, size, append(ints, candidate[i]), val - candidate[i])
}
}
backtrack(candidates, 0, len(candidates), []int{}, target)
return res
}

输出:

1
2
3
4
5
6
append after copied ints [7 3 2]
append after copied ints [3 3 3 3]
append after copied ints [3 3 2 2 2]
append after copied ints [2 2 2 2 2 2]
append after copied ints [2 2 8]
[[7 3 2] [3 3 3 3] [3 3 2 2 2] [2 2 2 2 2 2] [2 2 8]]

算法提交通过👏
img_18.png