Elasticsearch 的脚本模块主要是对 ES 的字段进行再处理操作。例如,可以用来重新评估查询的自定义得分,可以对索引中的某个字段再次加工处理。
在解决复杂业务问题(如,自定义评分、自定义文本相关度、自定义过滤、自定义聚合分析等)时,脚本是 Elasticsearch 强悍的利器之一;其中 Elasticsearch 脚本最典型的使用场景是作为搜索粗排模块使用。
脚本使用
脚本语言
Elasticsearch 脚本模块可以支持多种脚本语言,可以通过语言插件来支持不同的语言脚本。默认的脚本语言可以通过 script.default_lang
进行配置修改,任何使用脚本的地方,都可以通过设置 lang 参数来指定脚本的语言。
语言 | 沙盒 | 必要插件 | 用途 |
---|---|---|---|
painless | 是 | 内置 | |
groovy | 否 | 内置 | |
expression | 是 | 内置 | 快速自定义评分与排序 |
mustache | 是 | 内置 | 模板 |
javascript | 否 | lang-javascript | |
python | 否 | lang-python | |
java | 自己写 | 专业 API |
脚本语言在设计时,是考虑到安全沙盒的。然而,非沙盒语言可能是一个安全问题。
脚本使用
无论 Elasticsearch API 支持何种脚本,语法都遵循相同的模式:
"script": {
"lang": "...",
"source" | "id": "...",
"params": { ... }
}
- 脚本编写的语言,默认为 painless。
- 脚本本身可以指定为内联脚本的 source 或存储脚本的 id。
- 应传递给脚本的任何命名参数。
在 Elasticsearch 中使用脚本有三种方式:
- 直接在请求体中使用脚本,又叫内联(inline)脚本;
- 脚本可以使用 _scripts 端点存储在集群状态中,并通过脚本 id 来使用;
- 把脚本存储在本地磁盘中,默认位置为 config/scripts,通过脚本 id 来使用;
脚本操作
下面介绍 Elasticsearch 的脚本 CRUD(增删改查)操作。
脚本的创建
如上所述,脚本的添加也主要有三种形式,对应上述三种使用方式。
第一种使用方式就是直接在请求体中使用脚本,这种方式是动态编译的,当然 Elasticsearch 针对这种形式的脚本也有相应的缓存策略。
Elasticsearch 第一次看到一个新脚本,它会编译它并将编译后的版本存储在缓存中,编译可能是一个繁重的过程。
如果需要将变量传递给脚本,则应将它们作为命名参数传递给脚本本身而不是硬编码值,例如,如果你希望能够将字段值乘以不同的乘数,请不要将乘数硬编码到脚本中:
"source": "doc['my_field'] * 2"
相反,将其作为命名参数传递:
"source": "doc['my_field'] * multiplier",
"params": {
"multiplier": 2
}
如上所述第一个版本每次乘数改变时都必须重新编译,第二个版本只编译一次。第二个版本的写法就是模型和数据做了分离。
如果你在很短的时间内编译了太多新的脚本(缓存中不存在的),Elasticsearch 将使用 circuit_breaking_exception
错误拒绝新的动态脚本。
默认情况下,每分钟将编译最多 15 个内联脚本,你可以通过参数 script.max_compilations_rate
动态更改此设置。
第二种添加方式是将脚本使用 _scripts 端点存储在集群状态中,起到将脚本进行存储并持久化,存储的脚本指定全局唯一的 id 作为标识。
如下示例,首先,在集群状态下,创建名为 calculate-score 的脚本:
POST _scripts/calculate-score
{
"script": {
"lang": "painless",
"source": "Math.log(_score * 2) + params.my_modifier"
}
}
其中,calculate-score 为指定的脚本 id,后续使用该脚本时,通过该 id 进行引用使用。
第三种添加方式是先把脚本存储在文件中,默认脚本存放位置在 config/scripts
下,建立对应的脚本文件。
以 painless 脚本语言为例,创建文件名 filescriptdemo.groovy
,其中 filescriptdemo 为脚本名称,也是脚本唯一的标识,文件扩展名为脚本使用的脚本语言。
文件内容示例如下,表示对最终得分进行 log 相关的变换:
return Math.log(_score * 2);
文件形式的脚本需要涉及到脚本自动重载的情况,即周期性扫描 config/scripts
目录的修改。新的和修改的脚本会被重载,删除的脚本会从预加载脚本缓存中移除。重载频率可以使用 resource.reload.interval
设置指定,默认值为 60s。设置 script.auto_reload_enabled
为 false
可以完全禁用脚本重载。
脚本的删除
第一种使用方式的脚本,默认情况下,所有脚本都被缓存,因此只有在更新发生时才需要重新编译它们,默认情况下,脚本没有基于时间的过期,但你可以使用 script.cache.expire
设置更改此行为,你可以使 script.cache.max_size
设置配置此缓存的大小,默认情况下,缓存大小为 100。
存储脚本的大小限制为 65,535 字节,这可以通过设置 script.max_size_in_bytes
设置来增加软限制来更改,但如果脚本非常大,则应考虑原生脚本引擎。
GET /_cat/nodes?v&h=id,port,v,script.*
id port v script.compilations script.cache_evictions
MyUs 9300 5.6.4 38407 38307
T50o 9300 5.6.4 38940 38840
vK2H 9300 5.6.4 39552 39452
lWj_ 9300 5.6.4 114195 114095
x7oS 9300 5.6.4 119973 119873
BAKD 9300 5.6.4 38715 38615
如上所述是真实场景使用的情况,通过 _cat 命令查看的编译的脚本情况,发现总编译数(script.compilations
)和缓存剔除的脚本数(script.cache_evictions
)之差为 100,即脚本缓存的默认设置 script.cache.max_size
为 100。
第二种基于存储索引的脚本方式,删除操作非常简单,指定脚本 id,进行 DELETE 请求即可。
示例如下:
DELETE _scripts/script_id_demo
如果删除成功,会返回如下所示:
{
"acknowledged": true
}
若指定脚本不存在,会返回如下所示:
{
"error": {
"root_cause": [
{
"type": "resource_not_found_exception",
"reason": "stored script [script_id_demo] does not exist and cannot be deleted"
}
],
"type": "resource_not_found_exception",
"reason": "stored script [script_id_demo] does not exist and cannot be deleted"
},
"status": 404
}
第三种将脚本存储在文件中的使用方式的删除就非常直接,就是直接删除相应目录文件即可。
需要注意的是,存储在文件中的脚本,删除时,不会立即生效,因为和使用时一样,都是需要脚本重载(如上提到的)或重启后才生效,因为它们都是预加载在缓存中的。
脚本的修改
Elasticsearch 的脚本在实际使用场景中很少用修改操作,第一种使用方式不存在修改之说;第二种使用方式 POST 改写指定脚本 id 即可覆盖;第三种将脚本文件修改即可,但是等到脚本重载或重启 ES 方可生效。
脚本的查询
第一种方式的脚本创建由开发者控制之外,删除查询操作都由 ES 控制。
第二种方式的脚本查询方式如下:
GET _scripts/script_id_demo
第三种方式的脚本查询无须多言,到 ES 相对应目录(默认 config/scripts
)查看即可。
脚本的表达式
文档的字段访问
大多数脚本围绕指定文档字段数据来使用。doc['field_name']
可以用来访问文档内指定字段数据。注意,只能是简单值字段(不能返回 JSON 对象),并且只有不分词字段或单索引词字段是有意义的。
表达式 | 描述 |
---|---|
doc['field_name'].value |
字段的本地值 |
doc['field_name'].values |
字段的本地数组值。如果字段没有值,返回一个空数组 |
doc['field_name'].empty |
布尔值,表示文档中的字段是否有值 |
doc['field_name'].multiValued |
布尔值,表示文档中有多个值的字段 |
doc['field_name'].lat |
地理点类型的纬度 |
doc['field_name'].lon |
地理点类型的经度 |
doc['field_name'].lats |
地理点类型的纬度(复数) |
doc['field_name'].lons |
地理点类型的经度(复数) |
doc['field_name'].distance(lat,lon) |
地理点字段和提供的经纬度之间的平面距离(米) |
doc['field_name'].distanceWithDefault(lat,lon,default) |
地理点字段和提供的经纬度和默认值的平面距离(米) |
doc['field_name'].distanceInMiles(lat,lon) |
地理点字段和提供的经纬度之间的平面距离(英里) |
doc['field_name'].distanceInMilesWithDefault(lat,lon,default) |
地理点字段和提供的经纬度和默认值的平面距离(英里) |
doc['field_name'].distanceInKm(lat,lon) |
地理点字段和提供的经纬度之间的平面距离(千米) |
doc['field_name'].distanceInKmWithDefault(lat,lon,default) |
地理点字段和提供的经纬度和默认值的平面距离(千米) |
doc['field_name'].arcDistance(lat,lon) |
地理点字段和提供的经纬度之间的天穹距离(米) |
doc['field_name'].arcDistanceWithDefault(lat,lon,default) |
地理点字段和提供的经纬度和默认值的天穹距离(米) |
doc['field_name'].arcDistanceInMiles(lat,lon) |
地理点字段和提供的经纬度之间的天穹距离(英里) |
doc['field_name'].arcDistanceInMilesWithDefault(lat,lon,default) |
地理点字段和提供的经纬度和默认值的天穹距离(英里) |
doc['field_name'].arcDistanceInKm(lat,lon) |
地理点字段和提供的经纬度之间的天穹距离(千米) |
doc['field_name'].arcDistanceInKmWithDefault(lat,lon,default) |
地理点字段和提供的经纬度和默认值的天穹距离(千米) |
doc['field_name'].factorDistance(lat,lon) |
地理点字段从提供的经纬度之间的距离因子 |
doc['field_name'].factorDistance(lat,lon,default) |
地理点字段从提供的经纬度和默认值的距离因子 |
doc['field_name'].geohashDistance(geohash) |
地理点字段从提供的地理散列的天穹距离(米) |
doc['field_name'].geohashDistanceInKm(geohash) |
地理点字段从提供的地理散列的天穹距离(千米) |
doc['field_name'].geohashDistanceInMiles(geohash) |
地理点字段从提供的地理散列的天穹距离(英里) |
es(Elasticsearch)painless 脚本获取文档(document)的字段值的场景描述。
在脚本中访问文档的得分
当使用脚本计算文档得分(例如,通过 function_score
查询),可以在 painless 脚本中使用 _score
变量访问得分。
源字段
当执行脚本的时候,可以访问源字段。每个文档都会加载源字段、分析并且提供给脚本进行评估。使用方式,形如 _source.obj1.obj2.field3
。