一、踩坑现场:SQL查OpenSearch时的“奇怪结果”

做数据查询的开发者大概率都遇到过这种情况:用SQL查OpenSearch(以下简称OS)时,明明字段存的是数字,查出来的结果却要么缺数据、要么多数据,甚至连分组聚合都完全乱掉。我之前就踩过一个典型的坑:运营需要统计近7天内“消费金额大于100的订单”数量,我写的SQL逻辑完全没毛病,可查出来的数比实际少了一大半,排查了半天才发现是OS的字段类型搞的鬼。

先给大家还原这个踩坑的完整场景,所有示例统一用OpenSearch 2.15版本的技术栈。

首先是订单数据的OS索引,我之前创建时没太注意字段类型,用了动态映射(就是OS自动识别字段类型),订单的“消费金额”字段被自动识别成了字符串类型,索引的映射配置如下:

{
  "order_index": {
    "mappings": {
      "properties": {
        "order_id": { "type": "keyword" },
        "user_id": { "type": "keyword" },
        "pay_amount": { "type": "text" }, // 这里是坑!本来应该是数字类型,却被识别成了字符串
        "pay_time": { "type": "date" }
      }
    }
  }
}

然后我插入了几条测试数据:

// 测试数据1:消费金额150
{
  "order_id": "ord001",
  "user_id": "u001",
  "pay_amount": "150",
  "pay_time": "2024-05-01T12:00:00Z"
}
// 测试数据2:消费金额90
{
  "order_id": "ord002",
  "user_id": "u002",
  "pay_amount": "90",
  "pay_time": "2024-05-01T13:00:00Z"
}
// 测试数据3:消费金额200
{
  "order_id": "ord003",
  "user_id": "u003",
  "pay_amount": "200",
  "pay_time": "2024-05-02T10:00:00Z"
}

接下来我写了统计的SQL,逻辑是查近7天内pay_amount>100的订单数:

SELECT COUNT(*) AS cnt FROM order_index 
WHERE pay_amount > 100 
AND pay_time >= '2024-04-25T00:00:00Z'

执行结果居然是0!这完全不符合预期,我当时第一反应是SQL写错了,反复核对时间范围、字段名都没问题,后来才想到是OS的类型隐式转换搞的鬼。

二、底层逻辑:OS用SQL查询时的类型隐式转换规则

要搞清楚这个坑,得先明白OS用SQL查询时的类型转换逻辑。OS的SQL引擎本质上是把SQL语句转成OS原生的DSL查询(比如term、range这些),在转换过程中如果遇到字段类型和查询条件的类型不匹配,就会触发隐式转换,这个转换不是我们想的“字符串转数字”那么简单,不同场景下的转换规则完全不同。

2.1 字符串类型字段的范围查询规则

当查询条件是数字(比如>100),而字段是字符串类型时,OS的隐式转换规则是:把数字类型的查询条件转成字符串,然后做字符串的字典序比较,而不是数字大小比较。这就是刚才踩坑的核心原因:

  • 字符串“90”和“100”的字典序比较:“1”开头的字符串比“9”开头的小,所以“90”>“100”是假;
  • 字符串“150”和“100”的字典序比较:前两位“15”比“10”大,所以“150”>“100”是真;
  • 字符串“200”和“100”的字典序比较:前两位“20”比“10”大,所以“200”>“100”是真? 等等,那刚才的结果为什么是0?哦,还有另一个坑:OS的text类型字段会被分词,“150”会被拆成“1”“5”“0”三个词,当做范围查询时,OS无法对分词后的text字段做范围匹配,所以直接返回不匹配!

2.2 其他场景的隐式转换坑

除了字符串转数字的范围查询,还有很多其他场景的隐式转换坑:

  • 数字转字符串的匹配:比如字段是数字类型的pay_amount,查询条件是pay_amount = "150",OS会把字符串“150”转成数字150,这个是正常的;但如果是pay_amount LIKE "15%",OS会把数字150转成字符串“150”,然后做前缀匹配,这个是对的;
  • 日期类型的转换:比如字段是日期类型的pay_time,查询条件是pay_time > 1714406400(时间戳),OS会把数字时间戳转成日期,这个是正常的;但如果查询条件是pay_time > "2024-05-01",OS会把字符串转成日期,这个也是正常的;但如果字段是字符串类型的pay_time,查询条件是日期类型,就会触发错误;
  • 布尔类型的转换:比如字段是布尔类型的is_paid,查询条件是is_paid = 1,OS会把数字1转成布尔值true,这个是正常的;但如果字段是字符串类型的is_paid,查询条件是is_paid = true,OS会把布尔值true转成字符串“true”,然后做匹配,这个也是正常的,但如果字段是数字类型的is_paid(0代表未支付,1代表已支付),查询条件是is_paid = true,OS会把布尔值true转成数字1,这个是正常的,但如果查询条件是is_paid = false,OS会把布尔值false转成数字0,这个也是正常的。

三、避坑第一步:验证OS字段映射的正确方法

要避免隐式转换的坑,首先要确保字段映射是正确的,也就是字段的类型符合业务需求。那怎么验证OS的字段映射呢?很多开发者只会看OS的索引管理页面的可视化配置,但可视化配置可能会有隐藏的细节,比如text类型的子字段、动态映射的规则等,所以正确的验证方法是用命令行获取完整的映射配置。

3.1 用命令行获取完整映射

所有示例统一用OpenSearch 2.15版本的技术栈,用curl命令获取指定索引的完整映射:

# 获取order_index索引的完整映射
curl -X GET "http://localhost:9200/order_index/_mapping?pretty"

执行结果会返回完整的映射配置,包括所有字段的类型、分词器、子字段等细节,比如刚才的order_index的映射结果会显示pay_amount是text类型,而不是我们预期的数字类型。

3.2 验证字段类型的业务匹配性

拿到完整的映射配置后,要逐一验证核心业务字段的类型是否符合需求,比如:

  • 金额、数量、时间戳等需要做大小比较、聚合统计的字段,必须是数字类型(integer、long、float、double等);
  • 日期字段必须是date类型;
  • 布尔字段必须是boolean类型;
  • 字符串匹配的字段,如果是精确匹配(比如订单号、用户ID),必须是keyword类型;如果是全文搜索(比如商品名称、描述),必须是text类型。

比如刚才的pay_amount字段,因为需要做大小比较、聚合统计(比如统计金额大于100的订单数、统计平均金额等),所以必须是数字类型,而不是text类型。

3.3 验证动态映射的规则

很多开发者会用动态映射,也就是OS自动识别字段类型,这时候要验证动态映射的规则是否符合业务需求。比如OS默认的动态映射规则是:

  • 字符串类型的字段会被识别成text类型;
  • 数字类型的字段会被识别成long类型;
  • 日期类型的字段会被识别成date类型;
  • 布尔类型的字段会被识别成boolean类型。

但如果业务上需要把字符串类型的字段识别成keyword类型,或者把数字类型的字段识别成float类型,就需要自定义动态映射规则。比如自定义动态映射规则,把所有字符串类型的字段识别成keyword类型:

{
  "dynamic_templates": [
    {
      "string_to_keyword": {
        "match_mapping_type": "string",
        "mapping": {
          "type": "keyword"
        }
      }
    }
  ]
}

验证动态映射规则的方法是:插入测试数据,然后获取映射配置,看字段类型是否符合预期。

四、避坑第二步:处理已出现的隐式转换坑

如果已经出现了隐式转换的坑,比如字段类型错误,已经有大量数据了,不能直接修改字段类型(因为OS不支持修改已存在字段的类型),那该怎么处理呢?

4.1 临时解决方法:用CAST函数显式转换

如果只是临时需要查询正确的结果,可以用CAST函数把字段转成正确的类型,比如刚才的pay_amount字段是text类型,需要转成数字类型再做范围查询:

SELECT COUNT(*) AS cnt FROM order_index 
WHERE CAST(pay_amount AS DOUBLE) > 100 
AND pay_time >= '2024-04-25T00:00:00Z'

执行这个SQL,结果就会是2,符合预期。但这个方法有个缺点:性能差,因为CAST函数会对每个文档的字段做转换,然后再做比较,当数据量很大时,查询速度会非常慢。

4.2 长期解决方法:重建索引

如果是生产环境的问题,长期解决方法是重建索引,把字段类型改成正确的类型。重建索引的步骤如下:

  1. 创建新的索引,指定正确的字段类型;
  2. 把旧索引的数据同步到新索引;
  3. 切换别名,让应用访问新索引。

具体操作如下:

步骤1:创建新索引,指定正确的字段类型

{
  "order_index_new": {
    "mappings": {
      "properties": {
        "order_id": { "type": "keyword" },
        "user_id": { "type": "keyword" },
        "pay_amount": { "type": "double" }, // 改成正确的数字类型
        "pay_time": { "type": "date" }
      }
    }
  }
}

步骤2:同步旧索引的数据到新索引

用OS的_reindex API同步数据:

curl -X POST "http://localhost:9200/_reindex?pretty" -H 'Content-Type: application/json' -d'
{
  "source": {
    "index": "order_index"
  },
  "dest": {
    "index": "order_index_new"
  },
  "script": {
    "source": "ctx._source.pay_amount = Double.parseDouble(ctx._source.pay_amount)"
  }
}
'

这个脚本会把旧索引的pay_amount字段(字符串类型)转成数字类型,然后同步到新索引。

步骤3:切换别名

给新索引创建别名,让应用访问新索引:

curl -X POST "http://localhost:9200/_aliases?pretty" -H 'Content-Type: application/json' -d'
{
  "actions": [
    {
      "remove": {
        "index": "order_index",
        "alias": "order_index_alias"
      }
    },
    {
      "add": {
        "index": "order_index_new",
        "alias": "order_index_alias"
      }
    }
  ]
}
'

然后应用把访问的索引改成别名order_index_alias,就可以访问新索引了。

五、应用场景、优缺点、注意事项

5.1 应用场景

隐式转换的坑主要出现在以下场景:

  • 用SQL查询OS时,字段类型和查询条件类型不匹配;
  • 动态映射的字段类型不符合业务需求;
  • 旧索引的字段类型错误,已经有大量数据;
  • 数据迁移时,字段类型不匹配。

5.2 技术优缺点

优点

  • 隐式转换可以简化SQL语句的编写,比如不需要每次都用CAST函数转换类型;
  • 动态映射可以简化索引的创建,不需要手动指定每个字段的类型。

缺点

  • 隐式转换的规则复杂,容易出现意想不到的结果;
  • 动态映射的字段类型可能不符合业务需求,导致后续的查询、聚合出现问题;
  • 字段类型错误后,修改成本高,需要重建索引。

5.3 注意事项

  • 创建索引时,尽量手动指定字段类型,不要依赖动态映射;
  • 用SQL查询OS时,尽量确保字段类型和查询条件类型匹配;
  • 定期验证OS的字段映射,确保字段类型符合业务需求;
  • 生产环境中,尽量避免用CAST函数做范围查询、聚合统计,性能差;
  • 数据迁移时,要验证字段类型的匹配性。

六、文章总结

OS用SQL查询时的类型隐式转换坑,本质上是字段类型和业务需求不匹配导致的。要避免这个坑,首先要确保字段映射是正确的,验证字段映射的正确方法是用命令行获取完整的映射配置,然后验证字段类型的业务匹配性。如果已经出现了隐式转换的坑,临时解决方法是用CAST函数显式转换,长期解决方法是重建索引。

在实际开发中,要养成良好的习惯:创建索引时手动指定字段类型,定期验证字段映射,用SQL查询时确保类型匹配。这样才能避免隐式转换的坑,确保查询结果的正确性。