概述
時間相關的字段是ElasticsSearch(以下簡稱ES)最常用的字段了,幾乎所有的索引應用場景都會有時間字段,一般用於基於時間範圍的搜索,聚合等場景。但是由於時區的問題,相信很多小夥伴都踩到過時間字段的坑,筆者自己就踩過。
本文希望給你提供一個避坑指南。
瞭解時區的基本概念
因爲本文不是專門講時區的,你只需要瞭解一些基本的概念就可以了。
我們知道全球分爲24個時區,包含23個整時區及180°經線左右兩側的2個半時區。東經的時間比西經要早,也就是如果格林威治時間是中午12時,則中央經線15°E的時區爲下午1時。比如北京位於東8區,所以北京時間應該是晚上8點。
- 格林威治標準時間GMT或者UTC
GMT和UTC可以認爲是一個東西,只是精度的差異。他們代表的是全球的一個時間參考點,全球都以格林威治的時間作爲標準來設定時間。
在程序中我們經常能見到這樣的字符串:
Thu Oct 16 07:13:48 GMT 2019
這說明這個時間是GMT時間。
- CST中國標準時間
China Standard Time,是中國的標準時間。CST = GMT(UTC) + 8。比如
Thu Aug 25 17:15:49 CST 2019
表示的就是CST時間。有時候我們也能見到類似下面這樣的表示:
2020-03-15T11:45:43Z
其中Z表示的就是UTC時間。
坑一,日期字段映射問題
我們知道ES有個Dynamic Mapping的機制,當索引不存在或者索引中的某些字段沒有設置mapping屬性,index的時候ES會自動創建索引並且根據傳入的字段內容自動推斷字段的格式。比如,整型的數字會變成Long,“yyyy-dd-mm”等格式的字段會轉成date ),不過有時候這個推斷並不是我們想要的。
舉個我自己在項目中遇到的例子。當時有個實體對象要寫入ES中,我用了fastjson轉換成json的字符串然後寫入ES。在ES查看的時候發現寫入的字段變成了Long型失去了日期的屬性,導致不能根據此字段進行日期相關的條件搜索。下面模擬下整個過程。
首先定義一個實體對象,
@Data
@ToString
public class TestEntity {
private String stringData;
private Byte byteData;
private Date timeData;
}
然後寫入整個對象,
TestEntity entity = new TestEntity();
entity.setByteData((byte)2);
entity.setStringData("test");
entity.setTimeData(new Date());
IndexRequest request = new IndexRequest("test_index");
request.id(id);
request.source(JSON.toJSONString(), XContentType.JSON);
client.index(request, RequestOptions.DEFAULT);
寫入成功後發現無法根據整個時間字段進行排序和篩選,在ES裏查看索引的mapping發現,timeData
字段居然被識別成了Long型。
原因是fastjson默認把Date類型轉換成long型的時間戳了。到ES這邊以爲是一個普通的整型。
這個問題的解決方案有兩種。
第一種是在fastjson序列化的時候不要使用默認行爲,而是指定日期類型的格式,
@Data
@ToString
public class TestEntity {
private String stringData;
private Byte byteData;
@JSONField(format="yyyy-MM-dd HH:mm:ss")
private Date timeData;
}
這樣寫進ES就會被自動識別成日期類型。
另一種解決方案是,在ES的maping裏明確的指定字段的屬性。
PUT test_index
{
"mappings": {
"properties": {
"TimeData": {
"type": "date",
"format": "yyyy-MM-dd HH:mm:ss||yyyy-MM-dd||epoch_millis"
}
}
}
}
這裏我們給TimeData
設置了日期類型,並且可以識別三種不同的日期格式。其中最後一個epoch_millis
就是毫秒單位的時間戳。
坑二,時區問題
這個坑最常見。比如很多時候我們是直接把mysql的數據讀出然後寫入到ES。mysql裏的日期寫入到ES後發現時間ES查詢的時間跟實際看到的時間差了8個小時,究竟是怎麼回事呢?
先來看看官方文檔怎麼說,
Internally, dates are converted to UTC (if the time-zone is specified) and stored as a long number representing milliseconds-since-the-epoch.
Queries on dates are internally converted to range queries on this long representation, and the result of aggregations and stored fields is converted back to a string depending on the date format that is associated with the field.
這兩段的意思是說,在ES內部默認使用UTC時間並且是以毫秒時間戳的long型存儲的。針對日期字段的查詢其實對long型時間戳的範圍查詢。
我們舉一個例子,很多時候我們會把mysql的數據同步的ES,方法很多,我這裏以用logstash遷移數據舉例。(關於logstash具體的配置方法不是本文的重點我就不表了)mysql的數據是這樣的:
logstash的配置如下:(只給出部分配置)
input {
jdbc {
jdbc_driver_class => "com.mysql.jdbc.Driver"
jdbc_connection_string => "jdbc:mysql://localhost:3306/test"
jdbc_user => "root"
jdbc_password => "11111111"
use_column_value => false
#記錄最後一次運行的結果
record_last_run => true
#上面運行結果的保存位置
last_run_metadata_path => "jdbc-position.txt"
statement => "SELECT * FROM kafkalogin"
執行logstash進行遷移,然後我們在kibana裏發現數據是這樣的:
很奇怪,似乎相差的時間也不是8個小時,而是5個小時或者6個小時。
這種問題我們的解決方案也很簡單。我們已經知道輸出端(ES)的默認時區是UTC,只需要再在輸入端(mysql)也明確時區即可。改下logstash的配置如下:
input {
jdbc {
jdbc_driver_class => "com.mysql.jdbc.Driver"
jdbc_connection_string => "jdbc:mysql://localhost:3306/test?useUnicode=true&characterEncoding=UTF-8&useJDBCCompliantTimezoneShift=true&useLegacyDatetimeCode=false&serverTimezone=UTC"
然後你就會發現兩邊的時間是一樣的。
如果你的mysql裏的時間不是UTC而是東八區的時間,可以用如下的配置:
input {
jdbc {
jdbc_driver_class => "com.mysql.jdbc.Driver"
jdbc_connection_string => "jdbc:mysql://localhost:3306/test?useUnicode=true&characterEncoding=UTF-8&useJDBCCompliantTimezoneShift=true&useLegacyDatetimeCode=false&serverTimezone=Asia/Shanghai"
這樣遷移的數據在ES裏查看是相差8個小時的。
還有一種解決方案是你存儲的時間字符串本身就帶有時區信息,比如 “2016-07-15T12:58:17.136+0800”。
我們在ES進行查詢或者聚合的時候,建議指定時區避免產生意想不到的結果。比如:
GET _search
{
"query": {
"range" : {
"timestamp" : {
"time_zone": "+01:00",
"gte": "2015-01-01 00:00:00",
"lte": "now"
}
}
}
}
加上這個時區信息,ES在搜索的時候時間起始就是2014-12-31T23:00:00 UTC
。
此外在使用Java Client聚合查詢日期的時候,也需要注意時區問題,最好是指定時區進行搜索或者聚合。