一、社群版Phoenix時間相關類型介紹
時間資料處理是資料開發者經常遇到的問題,衆所周知時間都是跟時區相關的,如果對于時區處理不當,會造成時間資料錯誤,進而引入一系列棘手的問題。Phoenix中跟時間相關的類型有TIMESTAMP,DATE和TIME,這些類型對于時區的處理邏輯是相同的,後面筆者就以TIMESTAMP類型為例來說明Phoenix關于時區的處理方式。首先,我們先來看下Phoenix文檔中對于TIMESTAMP類型的描述:
The timestamp data type. The format is yyyy-MM-dd hh:mm:ss[.nnnnnnnnn]. Mapped to java.sql.Timestamp with an internal representation of the number of nanos from the epoch. The binary representation is 12 bytes: an 8 byte long for the epoch time plus a 4 byte integer for the nanos. Note that the internal representation is based on a number of milliseconds since the epoch (which is based on a time in GMT), while java.sql.Timestamp will format timestamps based on the client's local time zone.
這段描述中明确指出TIMESTAMP類型在處理時是基于GMT時區的毫秒值(預設的基準都是"1970-01-01 00:00:00.000"),而java.sql.Timestamp使用的是用戶端的本地時區。下面我們通過一個例子來說明這個設定在實際使用中,容易遇到的問題。
Statement stmt = con.createStatement();
stmt.execute("drop table test");
stmt.execute("create table test(mykey integer primary key, mytime timestamp)");
stmt.execute("upsert into test values(1, '2018-11-11 10:00:00.000')");
PreparedStatement pstmt = con.prepareStatement("upsert into test values(?, ?)");
pstmt.setInt(1, 2);
pstmt.setTimestamp(2, Timestamp.valueOf("2018-11-11 10:00:00.000"));
pstmt.executeUpdate();
con.commit();
stmt.execute("select * from test");
ResultSet rs = stmt.getResultSet();
System.out.println("select without filter results:");
while (rs.next()) {
System.out.println(rs.getInt(1) + " : " + rs.getString(2) + " : " + rs.getTimestamp(2));
}
stmt.execute("select * from test where mytime = timestamp'2018-11-11 10:00:00.000'");
rs = stmt.getResultSet();
System.out.println("select with statement:");
while (rs.next()) {
System.out.println(rs.getInt(1) + " : " + rs.getString(2) + " : " + rs.getTimestamp(2));
}
pstmt = con.prepareStatement("select * from test where mytime = ?");
pstmt.setTimestamp(1, Timestamp.valueOf("2018-11-11 10:00:00.000"));
pstmt.execute();
rs = pstmt.getResultSet();
System.out.println("select with preparedStatement:");
while (rs.next()) {
System.out.println(rs.getInt(1) + " : " + rs.getString(2) + " : " + rs.getTimestamp(2));
}
結果輸出如下:
select without filter results:
1 : 2018-11-11 10:00:00.000 : 2018-11-11 18:00:00.0
2 : 2018-11-11 02:00:00.000 : 2018-11-11 10:00:00.0
select with statement:
1 : 2018-11-11 10:00:00.000 : 2018-11-11 18:00:00.0
select with preparedStatement:
2 : 2018-11-11 02:00:00.000 : 2018-11-11 10:00:00.0
我們可以發現以下規律:
- 用string寫入用getTimestamp讀取時時間戳多了8個小時;而用setTimestamp寫入,用getString讀出時間戳則少了8個小時。
- 當查詢時,按照字元串的方式拼where條件隻能比對到使用string寫入的資料,而用setTimestamp設定where條件中的字段隻能比對到用setTimestamp方式寫入的時間戳。
需要指出的是,當我們使用用戶端也就是sqlline.py時,隻能是用字元串寫入,然後字元串讀出。使用者經常遇到的使用場景是,線上系統用 setTimestamp寫入,然後會用sqlline.py做查詢,或者用getString在頁面展示,這個時候就會出現多8個小時的情況;而做條件過濾時,使用者一定要注意使用方式,否則會出現比對不到的情況,而當使用sqlline查詢時,必須使用convert_tz方法做時區轉換才能得到正确結果。
回過頭來,我們再來看開源Phoenix内部關于時區的實作邏輯,進一步了解文檔中關于時區的表述。java.sql.Timestamp類型是帶時區的,預設是本地時區,且不能通過函數參數設定。Phoenix在做String和Timestamp轉換時使用的是GMT時區,也可以認為不帶時區。比如對于"1970-01-01 08:00:00.000",Phoenix存儲的數值是28800000,而Timestamp.valueOf("1970-01-01 08:00:00.000").getTime()得到的數值則是0,兩者混用就會出現偏差。這個邏輯也是造成程式測試結果的根本原因。
此外,上面提到的是Phoenix重用戶端的邏輯,而Phoenix輕用戶端對于時區的處理跟Phoenix重用戶端也有不一樣的地方。我們使用前面完全相同的邏輯,在實作中把jdbc url串換成輕用戶端的格式,列印結果如下:
select without filter results:
1 : 2018-11-11 10:00:00 : 2018-11-11 10:00:00.0
2 : 2018-11-11 02:00:00 : 2018-11-11 02:00:00.0
select with statement:
1 : 2018-11-11 10:00:00 : 2018-11-11 10:00:00.0
select with preparedStatement:
2 : 2018-11-11 02:00:00 : 2018-11-11 02:00:00.0
我們可以發現以下規律:
- 列印的時候輕用戶端的getString和getTimestamp的結果是一樣的,且和重用戶端的getString保持一緻。
- 寫入和查詢的時候輕用戶端和重用戶端邏輯一樣。
這是由于社群版輕用戶端在實作getTimestamp的時候,在構造Timestamp對象之前先把得到的毫秒數值減去了時區,而其他操作都是直接透傳給重用戶端實作的。
通過以上描述,我們可以發現Phoenix對于時區的處理非常複雜,稍不留意就會出錯。更嚴重的,如果使用者在寫入的時候混用了拼SQL語句和setTimestamp的方式,會導緻髒資料,并且是沒有辦法區分的。
不要混用兩種方式!字元串拼SQL和對象設定PreparedStatement,隻選一種,不管是讀還是寫。
二、阿裡雲Phoenix對時區問題的解決
首先,我們先看下傳統開源資料庫中對于時區問題處理方法。
在ANSI SQL标準中,TIMESTAMP類型分兩種,分别是TIMESTAMP WITH TIMEZONE和TIMESTAMP,前一種是考慮時區的,後一種是不考慮時區的。在MYSQL中TIMESTAMP類型是預設帶時區的,使用者輸入的如果不指定時區,預設是本地時區,在實際存儲時會轉變為GMT時區,當使用者讀取時再轉化為本地時區;而不帶時區的類型在MYSQL中是DATETIME類型,使用者在調用getTimestamp接口時,會根據DATETIME的年月日時分秒構造出來Timestamp對象,這樣使用者通過getString和getTimestamp拿到的時間始終是一緻的。
PostgresSQL對于時區的處理跟MYSQL不同,PG的TIMESTAMP類型是不帶時區的,而TIMESTAMPTZ是帶時區的。處理的邏輯同MYSQL類似,隻是内部存儲和實作上會有不同,這裡不再贅述。文末附有MYSQL和PG對于時區的參考文檔,感興趣的讀者可以進一步研究。有一點相同的是,不管MYSQL和PG怎麼實作和表述,在使用者使用的過程中都不會像開源Phoenix那麼讓人困惑。
阿裡雲團隊在
Phoenix 5.x版本中對時區問題進行了統一解決,不管使用者使用輕用戶端和重用戶端,都不會再像以前那麼費解。實作邏輯跟MYSQL類似,也就是,TIMESTAMP類型在實際存儲時都是使用GMT時區,使用者使用用戶端讀寫時,會根據本地時區進行轉化。不管使用者使用輕用戶端還是重用戶端,在寫入時使用statement還是PreparedStatement,在讀取時使用getString還是getTimestamp,在查詢時使用拼字元串還是setTimestamp等,拿到的結果都是一緻,容易了解且符合預期的。
我們同樣使用前文提到的測試程式,把Phoenix版本改成阿裡雲版本的Phoenix 5.x,得到的結果如下:
select without filter results:
1 : 2018-11-11 10:00:00.000 : 2018-11-11 10:00:00.0
2 : 2018-11-11 10:00:00.000 : 2018-11-11 10:00:00.0
select with statement:
1 : 2018-11-11 10:00:00.000 : 2018-11-11 10:00:00.0
2 : 2018-11-11 10:00:00.000 : 2018-11-11 10:00:00.0
select with preparedStatement:
1 : 2018-11-11 10:00:00.000 : 2018-11-11 10:00:00.0
2 : 2018-11-11 10:00:00.000 : 2018-11-11 10:00:00.0