文章

ES range 查询时,时间转字符串后小 8 小时

ES range 查询时,时间转字符串后小 8 小时

背景

某索引mapping中,假设设置日期字段格式如下,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
    "index_xxx": {
        "mappings": {
            "fulltext": {
                "properties": {
                    // 此处省略一万字...
                    "expire_time": {
                        "type": "date",
                        "format": "yyyy-MM-dd HH:mm:ss||epoch_millis"
                    }
                    // ...
                }
            }
        }
    }
}

Date对象的本质是毫秒级时间戳,时间戳本身是无时区的,但ES转字符串时,会把时间戳解析为UTC时间的字符串(2025-11-06 11:35:24)。例如,

  • 系统时间:2025-11-06 19:35:24 CST(东八区);
  • Elasticsearch存储为:2025-11-06 11:35:24 UTC

反过来看,如果数据是从MySQL同步过来的,在同步时也没有做时区转换,所以ES存储的时间其实是UTCMySQL存储的时间是当前时间,两者差8小时。字面语义上看时间一样,但实际存储格式不一样,所以代表的时间不一样。

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": 2,
    "timed_out": false,
    "_shards": {
        "total": 5,
        "successful": 5,
        "skipped": 0,
        "failed": 0
    },
    "hits": {
        "total": 4,
        "max_score": 1,
        "hits": [
            {
                "_index": "index_xxx",
                "_type": "fulltext",
                "_id": "xxx_id",
                "_score": 1,
                "_source": {
                    // ...
                    "expire_time": "2025-11-06 19:35:24",
                    // ...
                }
            },
            // 此处省略一万字...
        ]
    }
}

中国时区是GMT+8(东八区),你的应用服务器代码运行在东八区,而ES默认存储/解析时间时,如果未指定时区,会以UTC(零时区)为基准,这是8小时差的核心原因。

代码实现时间范围查询

不管是Java,还是Go等其他语言,都会根据系统时区(比如东八区)生成时间,下面以Java代码举例。

直观地看,查询的时候,可以加8小时查询,把当前系统时间(东八区)主动往后偏移8小时;ES接收到带+08:00时区的时间后,会自动转换为UTC时间(减去8小时),最终和ES存储的UTC时间对齐,抵消ES默认UTC的影响。

Desktop View Client代码侧向后偏移8小时

但这种手动offset硬编码的写法属于代码臭味,可以帮助理解,不推荐在实际工程中使用。

直接使用毫秒级时间戳,这种方式能避免时区转换,实际测试,确实避免时间转换,这种倒是没转换,但索引字段用的是UTC格式,和我们ES存储的时间数据不一致。

Desktop View 查询直接使用毫秒级时间戳

推荐的做法,一般有几种方式,

  1. SimpleDateFormat指定时区

    把系统东八区时间直接转为UTC时间字符串(带Z+00:00),ESUTC解析后和存储的时间完全对齐,无偏移误差。

    Desktop View 查询给SimpleDateFormat指定时区

  2. 查询时指定时区(ES 7.x+支持)

    无需手动格式化时间,直接传Date对象,通过timeZone告诉ES按东八区解析,底层自动转换为UTC对比。

    Desktop View 查询直接指定时区

  3. 直接把参数类型从日期转换成字符串

    针对上面问题,最直接的解法是,查询的时候,把参数类型从日期转换成字符串,传到ES,避免ES日期类型的时区转换,ES接收到无时区的时间字符串时,会默认按UTC时区解析,相当于把东八区的时间直接当成UTC时间,按照上面的说法,本身ES同步MySQL的数据,就是把东八区的时间直接当成UTC时间存储到ES里边的。

    Desktop View 传字符串,可以避免ES日期类型的时区转换

调试——探查底层原理

ES底层将时间对象转换成字符串的序列化链路

RangeQuery中传入一个Date类型(而非字符串)的时间值时,

Desktop View Client侧应用实现

ES 不会直接用这个对象,而是会通过XContentElasticsearchExtensiongetDateTransformers做时间对象到字符串的序列化(这是ES内部的标准化流程)。

getDateTransformersES内置的日期转换器集合,负责将不同类型的时间对象(DateInstantLocalDateTime等)转为标准化的字符串格式;这个转换器的默认行为是,如果没有显式指定时区,会将时间对象按UTC时区转为字符串(而非东八区)。

Desktop View 调试:进入时间范围方法实现

真正在Search.Builder构建时,会触发序列化转换,

Desktop View 调试:构建Search.Builder

拦截RangeQuery参数序列化的入口(RangeQueryBuilder.doXContent()

lt()方法退出后,找到执行ES查询的代码(比如restHighLevelClient.search(searchRequest, RequestOptions.DEFAULT)),在这行打断点;

执行到该断点后,步入search()方法,直到进入XContent序列化相关代码(核心类:XContentBuilder);

关键跳转节点: SearchRequest构建 → QueryBuilder.toXContent()RangeQueryBuilder.doXContent();

this.from就是你传入的东八区Date对象,

Desktop View 调试:doXContent

触发转换器的核心方法(XContentBuilder.value(Object)

进入XContentBuilderfield()方法,

Desktop View 调试:XContentBuilder.field()

进到value方法中,触发日期转换的方法,

Desktop View 调试:XContentBuilder.value()

日期的Writer对应XContentBuilder::timeValue

Desktop View 调试:XContentBuilder.unknownValue()

XContentBuilder::timeValue如下,可以看到DATE_TRANSFORMERS日期转换器,

Desktop View 调试:XContentBuilder.timeValue()

就在这里,转换了UTC时区,在往下可以看到值对应时区就变了,

Desktop View 调试:对应时区变了

再往下,就走的StringWriter了。

Desktop View 调试:对应时区变了

查看 Date 转换器的定义(XContentElasticsearchExtension.getDateTransformers()

Desktop View 调试:XContentElasticsearchExtension.getDateTransformers()

附初始化DateTransformers

Desktop View 调试:初始化DateTransformers

这部分代码,也是跟踪兜底序列化逻辑(XContentBuilder.unknownValue()

Desktop View 调试:兜底序列化

如果你的时间对象类型(比如普通Date)没有匹配到getDateTransformers中带时区的转换器,就会落入XContentBuilder.unknownValue分支(这是ES的兜底序列化逻辑)。 unknownValue的作用:处理所有ES未预设转换规则的未知类型,核心逻辑是尝试把对象转为字符串; 这里的关键是,兜底逻辑不会主动处理时区,只会调用maybeConvertToString做简单的类型转字符串。

maybeConvertToString最终转字符串,这个方法的本质是,只做字节/字符到字符串的纯格式转换,完全不处理时区。

Desktop View 调试:maybeConvertToString

此时传入的时间对象,如果是东八区的Date(比如系统当前时间2024-05-29 18:00:00东八区),ES底层在序列化时会默认按UTC解析这个Date对象,转为2024-05-29 10:00:00UTC 时间,比东八区少8小时),再通过这个方法转成字符串。

maybeConvertToString只是把UTC时间的字符串(10:00:00)原样输出,最终传给ES查询的时间字符串就比你预期的东八区时间少了8小时;

ES 用这个少8小时的UTC字符串,去对比索引中存储的时间(索引中时间也是 UTC),最终导致查询结果不符合预期(比如本该过期的时间,因为字符串少8小时,判断为未过期)。

为什么直接传字符串和传Date对象差异大?

  • 如果你手动传带时区的字符串(比如2024-05-29T18:00:00+08:00ES会解析时区,自动转为UTC时间(10:00:00),和索引时间对齐;
  • 如果你传无时区的字符串(比如2024-05-29 18:00:00),ES会默认按UTC解析,相当于把东八区的18:00当成UTC18:00(对应东八区26:00),差8小时;
  • 如果你传Date对象,ES底层默认按UTC转字符串,直接把Date的时间戳解析为UTC字符串,比东八区少8小时(这就是遇到的情况)。

解决思路
要么在传入ES前,将Date对象转为带+08:00时区的字符串;要么在RangeQuery中通过.timeZone("+08:00")显式指定时区,让ES按东八区解析时间对象。

本文由作者按照 CC BY 4.0 进行授权

© ManShouyuan. 保留部分权利。

本站总访问量 本站访客数人次

🚩🚩🚩🚩🚩🚩