
本教程深入探讨了如何在elasticsearch中实现复杂的条件多字段排序。针对文档中标签字段的存在与否,以及创建时间字段的升序或降序需求,文章详细介绍了如何利用Elasticsearch的脚本排序功能,结合Painless脚本语言来构建灵活的排序逻辑,并提供了完整的索引映射、数据示例和查询代码,帮助读者理解并应用这一高级排序技巧。
在Elasticsearch中,数据排序是检索结果呈现的关键环节。通常,我们可以通过指定一个或多个字段及其排序方向(升序或降序)来实现排序。然而,当业务逻辑需要更复杂的条件判断,例如根据某个字段的存在与否来决定后续字段的排序方式时,传统的字段排序可能无法满足需求。本文将介绍如何利用Elasticsearch的脚本排序(Script-Based sorting)功能来解决这类高级排序问题。
1. 业务场景与挑战
假设我们有如下结构的文档,包含 createdAt(创建时间)和 tags(标签列表)字段:
doc1: {     "createdAt": "2022-11-25T09:45:00.000Z",     "tags": [       "Response Needed"     ] } doc2 : {     "createdAt": "2022-11-24T09:45:00.000Z",     "tags": [       "Customer care","Response Needed"     ] } doc3 : {     "createdAt": "2022-11-24T09:45:00.000Z",     "tags": [      ] }
我们的目标是实现以下复杂的排序逻辑:
传统的 sort 语句难以直接表达“如果 tags 存在则 createdAt 升序,否则 createdAt 降序”这种条件逻辑。这时,脚本排序就成为了理想的解决方案。
2. 核心解决方案:脚本排序
Elasticsearch的脚本排序允许我们使用Painless脚本语言定义自定义的排序逻辑。通过在脚本中编写条件判断,我们可以根据文档的特定属性动态地生成一个用于排序的值。
2.1 索引映射与示例数据
首先,我们需要创建一个索引并定义好字段映射。createdAt 字段应为 date 类型,tags 字段为 keyword 类型,以便我们能够准确地处理日期和标签数据。
PUT idx_sort {   "mappings": {       "properties": {         "createdAt": {           "type": "date"         },         "tags": {           "type": "keyword"         }       }     } }
接下来,我们插入一些示例数据,以验证我们的排序逻辑:
POST idx_sort/_doc {     "createdAt": "2022-11-25T09:45:00.000Z",     "tags": [       "Response Needed"     ] }  POST idx_sort/_doc {     "createdAt": "2022-11-24T09:45:00.000Z",     "tags": [       "Response 02"     ] }  POST idx_sort/_doc {     "createdAt": "2022-11-24T09:45:00.000Z",     "tags": [       "Customer care","Response Needed"     ] }  POST idx_sort/_doc {     "createdAt": "2022-11-24T09:45:00.000Z",     "tags": [      ] }
2.2 实现排序逻辑
为了实现上述复杂的排序需求,我们将使用一个包含脚本排序和普通字段排序的组合。
GET idx_sort/_search {   "sort": [     {       "_script": {         "type": "number",         "script": {           "lang": "painless",           "source": """           def list = doc['tags.keyword'];           if(list.size() > 0){             return 1; // 有标签的文档,返回一个较高的值           } else {             return 0; // 没有标签的文档,返回一个较低的值           }           """         },         "order": "desc" // 脚本排序结果降序,使有标签的文档优先       }     },     {       "createdAt": {         "order": "asc" // 针对所有文档,按createdAt升序排列       }     }   ] }
代码解析:
- 
_script 排序: - type: “number”:指定脚本返回值的类型为数字。
- lang: “painless”:使用Painless脚本语言编写逻辑。
- source 脚本内容:
- def list = doc[‘tags.keyword’];:获取当前文档的 tags.keyword 字段值。注意,对于 keyword 类型的多值字段,doc[‘field_name’] 返回的是一个 List 对象。
- if(list.size() > 0){ return 1; } else { return 0; }:这是一个核心判断。如果 tags 列表的尺寸大于0(即存在标签),则脚本返回 1;否则(标签为空),返回 0。
 
- order: “desc”:由于我们希望有标签的文档(返回 1)优先于无标签的文档(返回 0),所以这里使用降序排序,使得 1 排在 0 之前。
 
- 
createdAt 排序: - “createdAt”: { “order”: “asc” }:这是第二个排序条件。在第一个脚本排序结果相同(即同为有标签或无标签)的文档中,Elasticsearch会根据 createdAt 字段进行升序排列。
 
重要提示: 上述解决方案部分满足了原始需求。它成功地将“有标签的文档”和“无标签的文档”分开,并确保了“有标签的文档”优先。同时,它在每个分组内部都按照 createdAt 升序排列。
然而,原始需求中提到:“如果 tags 为空,则 createdAt 应该降序排列。” 上述提供的解决方案并未实现这一部分。 createdAt 始终是升序排列的。要完全实现“如果 tags 为空则 createdAt 降序”的逻辑,需要更复杂的脚本或组合查询。例如,可以在脚本中根据 tags 的存在与否,动态地返回一个结合了 createdAt 值的排序键,并对其进行处理。
一个更完善的实现(但性能可能受影响)可能如下:
GET idx_sort/_search {   "sort": [     {       "_script": {         "type": "number",         "script": {           "lang": "painless",           "source": """           def hasTags = doc['tags.keyword'].size() > 0;           def createdAtMillis = doc['createdAt'].value.toInstant().toEpochMilli();            if (hasTags) {             // 有标签的文档:高优先级 + createdAt 升序             // 例如,将 createdAt 转换为负数以实现降序,但这里要升序,所以直接用 createdAtMillis             // 为了保证有标签的文档排在无标签文档之前,可以给一个较大的偏移量             return 1_000_000_000_000_000L + createdAtMillis;           } else {             // 无标签的文档:低优先级 + createdAt 降序             // 通过对 createdAtMillis 取负数来实现降序             return -createdAtMillis;           }           """         },         "order": "desc" // 整个脚本结果降序       }     }   ] }
这个更复杂的脚本将所有排序逻辑封装在一个脚本中,并返回一个单一的数字作为排序键。有标签的文档会得到一个非常大的正数(基于 createdAt),而无标签的文档会得到一个负数(基于 createdAt 的负值)。然后通过 order: “desc” 使得正数优先,且正数内部按 createdAt 升序,负数内部按 createdAt 降序。但请注意,这种方式需要仔细调整偏移量以避免数值溢出和排序冲突,并且对 createdAt 字段的负数处理可能需要额外考虑。
回到原始提供的解决方案: 它旨在通过两阶段排序实现:
- 阶段一(脚本): 将文档分为“有标签”和“无标签”两大组,并使“有标签”组优先。
- 阶段二(createdAt): 在这两大组内部,都按照 createdAt 升序排列。
2.3 响应结果分析
执行上述查询后,Elasticsearch会返回如下结果(顺序可能因实际数据略有不同,但排序逻辑一致):
"hits": [       {         "_index": "idx_sort",         "_id": "j489toQBEoAIompjkkXO",         "_score": null,         "_source": {           "createdAt": "2022-11-24T09:45:00.000Z",           "tags": [             "Response 02"           ]         },         "sort": [           1, // 脚本返回1 (有标签)           1669283100000 // createdAt的毫秒值         ]       },       {         "_index": "idx_sort",         "_id": "kI89toQBEoAIompjxkWN",         "_score": null,         "_source": {           "createdAt": "2022-11-24T09:45:00.000Z",           "tags": [             "Customer care",             "Response Needed"           ]         },         "sort": [           1,           1669283100000         ]       },       {         "_index": "idx_sort",         "_id": "jo83toQBEoAIompjcEXD",         "_score": null,         "_source": {           "createdAt": "2022-11-25T09:45:00.000Z",           "tags": [             "Response Needed"           ]         },         "sort": [           1,           1669369500000         ]       },       {         "_index": "idx_sort",         "_id": "kY8-toQBEoAIompj6kXg",         "_score": null,         "_source": {           "createdAt": "2022-11-24T09:45:00.000Z",           "tags": []         },         "sort": [           0, // 脚本返回0 (无标签)           1669283100000         ]       }     ]
从 sort 数组中我们可以看到:
- 前三个文档的 sort 数组第一个元素是 1,表示它们有标签。
- 第四个文档的 sort 数组第一个元素是 0,表示它没有标签。
- 由于脚本排序是降序 (order: “desc”),所以 1 的文档排在 0 的文档之前。
- 在 1 的文档组内部,它们按照 createdAt 的毫秒值 (1669283100000 对应 2022-11-24T09:45:00.000Z,1669369500000 对应 2022-11-25T09:45:00.000Z) 进行了升序排列。
- 如果存在多个 0 的文档,它们也会按照 createdAt 升序排列。
3. 注意事项与总结
- 性能开销: 脚本排序虽然功能强大,但相比于基于字段值的普通排序,其性能开销通常更大。Painless脚本需要在每个文档上执行,这会增加CPU和内存的消耗。因此,应尽量避免在大型数据集上过度使用复杂的脚本排序,或对其进行性能优化。
- Painless脚本安全性: Painless是Elasticsearch专门设计的安全脚本语言,但仍需谨慎编写,避免不必要的复杂逻辑,以防潜在的安全风险或性能问题。
- 精确度与类型: 在脚本中处理日期或数值时,确保类型转换和比较的准确性。例如,日期字段在脚本中通常以毫秒为单位表示。
- 需求分析: 在设计排序逻辑前,务必清晰地定义所有排序条件和优先级。对于非常复杂的条件排序,可能需要重新评估数据模型或考虑在应用层进行部分排序处理。
- 解决方案的局限性: 本文介绍的基于原始问答的解决方案,能够很好地实现“有标签优先,且内部按 createdAt 升序”的逻辑。但如前所述,它未能实现“无标签时 createdAt 降序”的完整需求。对于这种更高级的条件排序,需要将所有条件(包括 createdAt 的升降序)都封装在一个更复杂的Painless脚本中,或者考虑使用多个查询和合并结果。
通过脚本排序,Elasticsearch为我们提供了极大的灵活性,能够处理传统字段排序难以实现的复杂业务场景。理解其工作原理和潜在的性能影响,将有助于我们更高效、更准确地利用Elasticsearch进行数据检索和呈现。


