作者 | 吳天際
Too long; didn't read:
- 大多數(shù)應(yīng)用中,只需要用“絕對(duì)時(shí)間 DateTime”一種技術(shù)實(shí)現(xiàn)即可
- 后端應(yīng)統(tǒng)一用 UTC 時(shí)間(包括 DB 落盤、接口定義),不應(yīng)當(dāng)受用戶時(shí)區(qū)或服務(wù)器時(shí)區(qū)的影響
- 前端輸入、展示的時(shí)間,根據(jù)具體業(yè)務(wù)場(chǎng)景進(jìn)行時(shí)區(qū)調(diào)整,以及精度調(diào)整
- 面對(duì)不帶時(shí)間的日期,要明確區(qū)分「紀(jì)念日」與「精度不高的絕對(duì)時(shí)間」兩種用途,大部分時(shí)候你看到的日期是后者,它也應(yīng)當(dāng)用“確定時(shí)區(qū)的 DateTime”來實(shí)現(xiàn)
1. 重要性
日期時(shí)間的處理,一直是計(jì)算機(jī)系統(tǒng)中看似簡(jiǎn)單,實(shí)則經(jīng)常爆雷的問題。
例如,每隔幾年,都會(huì)爆出的「千年蟲問題」的各種變種,通常因?yàn)橄到y(tǒng)在設(shè)計(jì)之初,沒有設(shè)計(jì)好日期時(shí)間的數(shù)據(jù)存儲(chǔ)方式,或者低估了產(chǎn)品設(shè)計(jì)的生命周期,導(dǎo)致最初選型的數(shù)據(jù)結(jié)構(gòu)不夠用了。
千年蟲問題:
- 年紀(jì)大的程序員,都知道千年蟲問題。在 2000 年之前,很多系統(tǒng)用 2 位數(shù)字表示年份,這樣 99 年是它能表達(dá)的最大數(shù)值。因此 1999 年之后的一年,在這些系統(tǒng)中是沒有定義的,甚至可能出現(xiàn)多種奇怪的情況,例如“1900”、“1:00”、“19:0”(為什么?感興趣的讀者可以自己推測(cè))。
如果說,「千年蟲」是在時(shí)間維度上缺乏前瞻性的設(shè)計(jì)導(dǎo)致的,那么另一種缺乏前瞻性的問題,是空間維度的,即產(chǎn)品全球化、跨時(shí)區(qū)帶來的問題。
全球化的產(chǎn)品中,如果時(shí)間的處理沒有遵循統(tǒng)一的標(biāo)準(zhǔn),會(huì)讓整個(gè)系統(tǒng)充斥著難以理解和維護(hù)的時(shí)間轉(zhuǎn)換。各種接口的對(duì)接文檔,都不得不明確說明「這個(gè)接口的時(shí)間是什么時(shí)區(qū)的?需要如何處理?」后端服務(wù)如果需要跨國(guó)部署在多個(gè)大洲的機(jī)房時(shí),因?yàn)榉?wù)器的時(shí)區(qū)不同,需要做大量的改造。
遺憾的是,大多情況下,產(chǎn)品不會(huì)一開始就有「全球化」屬性。所以在一開始,產(chǎn)研團(tuán)隊(duì)都不會(huì)重視全球化的設(shè)計(jì)問題,很容易留下缺乏前瞻性的設(shè)計(jì)問題。
通常情況下,我們都不鼓勵(lì)「過度設(shè)計(jì)」。然而,日期時(shí)間的設(shè)計(jì),是最不怕「過度」的。這時(shí)因?yàn)?,在技術(shù)上實(shí)現(xiàn)一個(gè)前瞻的時(shí)間日期方案,成本并不高;但如果一開始的設(shè)計(jì)不夠,后期的升級(jí)和數(shù)據(jù)遷移工作,卻是傷筋動(dòng)骨的。
2. 如何表達(dá)時(shí)間和日期?
2.1 時(shí)間日期的傳遞:用字符串
在微服務(wù)之間,以及在前后端之間,建議用字符串傳遞日期時(shí)間。字符串清晰易讀,易于人工調(diào)試,帶來的開銷通常也完全可以接受。(帶大量時(shí)間數(shù)據(jù)的接口,建議考慮用 Unix Timestamp。)
如果用字符串,格式就不要自己發(fā)明了。有個(gè)非常明確的國(guó)際標(biāo)準(zhǔn):ISO 8601(wikipedia: https://en.wikipedia.org/wiki/ISO_8601)
下面舉例是符合規(guī)范的常用格式:
- 僅日期:2022-02-09
- UTC 日期時(shí)間:2022-02-09T12:36:42Z
- 特定時(shí)區(qū)的日期時(shí)間:2022-02-09T20:36:42+08:00
- 精度更高的時(shí)間:2022-02-09T12:36:42.123456789Z
注意,MySQL 中使用的字符串格式(如 2022-02-09 12:36:42)并不符合規(guī)范,不建議使用。
2.2 時(shí)間日期的存儲(chǔ):關(guān)注 MySQL 中的 DateTime
不同數(shù)據(jù)庫在時(shí)間日期相關(guān)對(duì)象的處理差異很大。這里單說 MySQL,因?yàn)榭硬恍 ?/p>
MySQL 的 DateTime 數(shù)據(jù)在存儲(chǔ)時(shí)并不包含時(shí)區(qū)信息,因此,在讀取時(shí)也不會(huì)做任何時(shí)區(qū)的轉(zhuǎn)換。
同時(shí),每個(gè) MySQL 連接會(huì)話,都有「會(huì)話時(shí)區(qū)」的概念,但這個(gè)概念只影響 MySQL 的 NOW() 等有關(guān)當(dāng)前時(shí)間的函數(shù)的行為,對(duì)數(shù)據(jù)中已經(jīng)保存的 DateTime 沒有任何影響。
例如:
SET time_zone = '+00:00' ;
UPDATE tab SET datetime_colume = '2020-01-01 00:00:00';
SET time_zone = '+08:00' ; -- 換一個(gè)會(huì)話時(shí)區(qū)
SELECT datetime_colume FROM tab;
-- 返回值仍然是 '2020-01-01 00:00:00',和寫入的數(shù)據(jù)一致,和會(huì)話時(shí)間無關(guān)
---------
SET time_zone = '+00:00' ;
SELECT NOW(); -- 假設(shè)返回 '2022-01-01 00:00:00'
UPDATE tab SET datetime_colume = NOW(); -- 存入的是 '2022-01-01 00:00:00'
SET time_zone = '+08:00' ; -- 換一個(gè)會(huì)話時(shí)區(qū)
SELECT NOW(); -- '2022-01-01 08:00:00' 根據(jù)時(shí)區(qū)變化了
SELECT datetime_colume FROM tab; -- '2022-01-01 00:00:00' 已經(jīng)寫入的不會(huì)變
2.3 時(shí)間日期的計(jì)算:語言原生的 DateTime 類型
各語言一般都提供了原生的 DateTime 數(shù)據(jù)類型,以表達(dá)絕對(duì)的日期時(shí)間,并且都支持上面 ISO 8601 規(guī)范的解析和格式化。
處理相對(duì)時(shí)區(qū)時(shí),各種語言通常都是使用操作系統(tǒng)的時(shí)區(qū)數(shù)據(jù)庫,來轉(zhuǎn)化為絕對(duì)時(shí)區(qū)。時(shí)區(qū)數(shù)據(jù)庫需要在聯(lián)網(wǎng)情況下,由操作系統(tǒng)負(fù)責(zé)定時(shí)更新。
2.4 萬能的 Unix Timestamp
Unix Timestamp 在存儲(chǔ)、計(jì)算、傳遞環(huán)節(jié)都可以使用,可謂萬能。它唯獨(dú)不適合表達(dá)紀(jì)念日日期。
它通過一個(gè)數(shù)值表示了一個(gè)絕對(duì)時(shí)間與 Unix Epoch 時(shí)間(定義為 1970-01-01T00:00:00Z)的差值秒數(shù)。Unix Timestamp 本身已經(jīng)表達(dá)了絕對(duì)時(shí)間,并不需要時(shí)區(qū)信息。
使用 Unix Timestamp 時(shí),應(yīng)特別注意選用合適的數(shù)值類型,它會(huì)影響時(shí)間表示的范圍。稍不留神,你就可能種下一個(gè)新的千年蟲。
- 用有符號(hào)int32,最多表示到 2038 年。MySQL 的 TIMESTAMP 類型也是它,一個(gè)千年蟲變種
- 用有符號(hào)int64,并使用 9 位 10 進(jìn)制定點(diǎn)小數(shù)位時(shí),就是 Golang 的UnixNano(),可以表示 1678 年至 2262 年
- 一般不會(huì)用浮點(diǎn)數(shù)表示,因?yàn)楦↑c(diǎn)數(shù)的精度不固定
3. 產(chǎn)品視角的日期時(shí)間設(shè)計(jì)
本著不重不漏的原則,我們可以按如下表格劃分產(chǎn)品中的所有日期時(shí)間對(duì)象:
指明時(shí)區(qū),需根據(jù)用戶所在時(shí)區(qū)做轉(zhuǎn)換② 表示全球唯一確定時(shí)間點(diǎn)④ 表示全球可理解的重復(fù)性時(shí)間? 不存在的場(chǎng)景
下面逐一解釋這五種場(chǎng)景。
3.1 表示全球唯一確定時(shí)間點(diǎn)(表中的 ②)
信息量包含「年月日-時(shí)分秒-時(shí)區(qū)」。這樣,就可以完全確定歷史長(zhǎng)河中的一個(gè)無歧義的時(shí)間點(diǎn)。這個(gè)時(shí)間點(diǎn)是完全客觀的,和訪問的用戶地理位置無關(guān),和服務(wù)器的地理位置無關(guān),和什么都無關(guān)。
產(chǎn)品表現(xiàn)上,通常會(huì)根據(jù)查看者所在的時(shí)區(qū)來重新調(diào)整時(shí)間的顯示。
用途舉例:
- 單個(gè)事件發(fā)生的時(shí)間。如 2022 年冬奧會(huì)開幕式的時(shí)間:2022 年 2 月 4 日,20 點(diǎn)整,+0800 時(shí)區(qū)。一個(gè)英國(guó)人看電視轉(zhuǎn)播預(yù)告時(shí),會(huì)看到開幕式的轉(zhuǎn)播時(shí)間是:2022 年 2 月 4 日中午 12 點(diǎn)整。這體現(xiàn)了根據(jù)查看者做時(shí)間的轉(zhuǎn)換。
3.2 表示本地的確定時(shí)間點(diǎn)(表中的 ①)
包含「年月日-時(shí)分秒」,因?yàn)闆]有時(shí)區(qū)信息,所以它本身并不能確定一個(gè)精確的時(shí)間點(diǎn),而是只在特定的情境下才有意義。
所謂特定的情境,是因?yàn)闃I(yè)務(wù)場(chǎng)景中蘊(yùn)含了時(shí)區(qū)的信息,并且是大家公認(rèn)的共識(shí)。因此,本質(zhì)上它仍然表示了一個(gè)絕對(duì)時(shí)間。在產(chǎn)品表現(xiàn)上,因?yàn)閷?duì)時(shí)區(qū)的共識(shí),所以不需要根據(jù)查看者的時(shí)區(qū)來調(diào)整時(shí)間的展示。
用途舉例:
- 在非國(guó)際化的產(chǎn)品中,明確知道用戶所在的時(shí)區(qū),那么去掉時(shí)區(qū)是最簡(jiǎn)單的處理方式,可以省去很多麻煩。
- 對(duì)于時(shí)區(qū)有其他約定俗成的理解。例如:飛機(jī)的起飛降落時(shí)間,酒店的入住離店時(shí)間,一定是按照飛機(jī)起落地、酒店坐落地當(dāng)?shù)貢r(shí)區(qū)來表達(dá)的。在所有訂票網(wǎng)站上,都會(huì)按照這個(gè)規(guī)則顯示時(shí)間,不論訪問的用戶身處哪個(gè)時(shí)區(qū)。
3.3 表示重復(fù)性時(shí)間(表中的 ③ 和 ④)
和前兩類相比,去掉了「日期」這個(gè)信息,是為了描述重復(fù)性的日程。它可以是指明了時(shí)區(qū)的,也可以不指明時(shí)區(qū),而基于人們對(duì)時(shí)區(qū)的共識(shí)去理解。
用途舉例:
- 每周三 8:00+0800 開會(huì),如果這可以是個(gè)跨國(guó)的會(huì)議,大家都能理解正確的時(shí)間。這時(shí),產(chǎn)品表現(xiàn)上應(yīng)該注意根據(jù)查看者來調(diào)整顯示。
- 每周三 8:00 起飛的航班,航班起飛地的時(shí)區(qū)是蘊(yùn)含的共識(shí)。產(chǎn)品表現(xiàn)中不必根據(jù)查看者的時(shí)區(qū)調(diào)整顯示。
3.4 紀(jì)念日日期(表中的 ⑤)
日期對(duì)象幾乎只有一個(gè)有意義的用途:表示紀(jì)念日/節(jié)日。它不會(huì)包含時(shí)區(qū)信息。
認(rèn)為「日期」只能用于「紀(jì)念日」,有些絕對(duì)了。但我確實(shí)查閱了很多資料,也沒有看到任何非「紀(jì)念日」用途的日期。
例如:
- 小吳的生日是 3 月 11 日,那么不管他在中國(guó)還是美國(guó),都會(huì)在 3 月 11 日這一天過生日。
- 每年 12 月 25 日是西方的圣誕節(jié),各個(gè)國(guó)家都在 12 月 25 日這一天慶祝,雖然它們并不在同一個(gè)時(shí)區(qū)。
產(chǎn)品體現(xiàn)上,不需要根據(jù)時(shí)區(qū)調(diào)整日期的顯示。本質(zhì)上,「紀(jì)念日」的邏輯,其實(shí)是人腦的不嚴(yán)謹(jǐn)導(dǎo)致的一種習(xí)慣,是不嚴(yán)謹(jǐn)、不客觀的習(xí)慣。不包含時(shí)區(qū)信息,就是為了滿足這種不嚴(yán)謹(jǐn)?shù)牧?xí)慣。
3.5 區(qū)分「紀(jì)念日日期」與「精度不高的絕對(duì)時(shí)間」
上面說過,日期對(duì)象不能包含時(shí)區(qū)。你可能會(huì)問,我需要表示“北京時(shí)間 2022 年 3 月 22 日”呢?答案是:這不是一個(gè)日期,而是一個(gè)「精度不高的絕對(duì)時(shí)間」。
很多情況下,當(dāng)你想用日期時(shí),其實(shí)很可能需要的是個(gè)「精度不高的絕對(duì)時(shí)間」。在飛書人力套件的業(yè)務(wù)中,經(jīng)常會(huì)遇到這種場(chǎng)景。
例如,一個(gè)在美國(guó)的同學(xué)與一個(gè)在日本的同學(xué),都在 2022 年 3 月 22 日這天從公司離職了,由同一個(gè)在北京的 HR 辦理離職事項(xiàng)。
可見,從我們用戶視角理解的「一個(gè)事件發(fā)生的日期」,其實(shí)是我們忽略了時(shí)間的精度。在產(chǎn)品全球化之前,我們通過一些默認(rèn)的簡(jiǎn)化,忽略了時(shí)間精度的問題(例如把時(shí)間都填成 00:00:00)。一旦面臨產(chǎn)品的全球化,就需要補(bǔ)齊時(shí)間、提高精度。
而補(bǔ)齊時(shí)間、提高精度的方式,需要根據(jù)具體的產(chǎn)品形態(tài)具體考慮、明確定義。
例如,在上述離職場(chǎng)景下,就需要按照這個(gè)公司對(duì)離職的定義來補(bǔ)充,可以是當(dāng)?shù)貢r(shí)間當(dāng)天的 23:59:59,也可以是當(dāng)天下班時(shí)間,如 17:00:00。
又比如,對(duì)于跨團(tuán)隊(duì)的業(yè)務(wù),例如一個(gè)同學(xué)的上級(jí)匯報(bào)線從一個(gè)美國(guó) Leader 轉(zhuǎn)到一個(gè)日本 Leader,那么為了避免歧義,通常會(huì)約定一個(gè)確定的生效時(shí)區(qū),如統(tǒng)一按照公司的總部所在地的時(shí)間來計(jì)算。
4. 日期時(shí)間的技術(shù)實(shí)現(xiàn)
4.1 確定時(shí)區(qū)的 DateTime
適用于上面的 ①②③④ 四種場(chǎng)景。
所有后端暴露的接口中的時(shí)間對(duì)象,全部以 UTC 時(shí)間表示。
同時(shí),所有后端在存儲(chǔ)、計(jì)算、傳輸時(shí)間時(shí),也統(tǒng)一使用 UTC 時(shí)間。由于 DB 存儲(chǔ)時(shí)間時(shí),時(shí)區(qū)信息會(huì)被丟掉,因此應(yīng)保證丟掉的時(shí)區(qū),是大家明確約定清楚的無歧義的,即 UTC。這樣一來,DB 中的所有時(shí)間字段也都沒有歧義。
接口內(nèi)部產(chǎn)生的時(shí)間,例如 CreatedAt、UpdatedAt時(shí)間,都應(yīng)該轉(zhuǎn)換為 UTC 再落盤。如果直接使用了 MySQL 的NOW()函數(shù),應(yīng)確保 MySQL Session 的時(shí)區(qū)設(shè)置正確。
在前端或 BFF 負(fù)責(zé)處理用戶輸入的時(shí)間,以及展示給客戶看到的時(shí)間。包括兩個(gè)步驟:
處理“精度不高的時(shí)間”問題。 比如:?jiǎn)T工異動(dòng)的生效時(shí)間,用戶只設(shè)置到“天”的精度。那么如果不跨國(guó),可以補(bǔ)全用戶會(huì)話時(shí)區(qū)的 00:00:00 為精確生效時(shí)間;如果跨國(guó),那就看客戶如何定義,以及產(chǎn)品給客戶怎樣的靈活性:例如,可以以客戶公司總部所在地的時(shí)區(qū)的 00:00:00 為精確生效的時(shí)間。
時(shí)區(qū)轉(zhuǎn)換。 注意,這里不一定是使用用戶的會(huì)話時(shí)區(qū)來轉(zhuǎn)換。如前面介紹的飛機(jī)火車賓館的預(yù)定時(shí)間,就要以預(yù)定當(dāng)?shù)氐臅r(shí)區(qū)來轉(zhuǎn)換。
上述兩點(diǎn),是一定需要在產(chǎn)品設(shè)計(jì)中定義清晰的,切忌含糊不清。
不要較真兒抬杠的幾點(diǎn):
- 由于歷史原因,DB 里已經(jīng)采用北京時(shí)間保存了,那么我們可以約定+0800 時(shí)區(qū)是我們所有后端接口的時(shí)間。只要用一個(gè)確定的絕對(duì)時(shí)區(qū),就不會(huì)有歧義,不必非要時(shí) UTC。
- 也可以在后端接口的網(wǎng)關(guān)層處理時(shí)間轉(zhuǎn)換。不要較真那算不算 BFF,我們需要的是,時(shí)區(qū)轉(zhuǎn)換邏輯應(yīng)嚴(yán)禁深入到后端的下層去。
4.2 不帶時(shí)區(qū)的 Date
適用于上面的 ⑤,即紀(jì)念日?qǐng)鼍啊?/p>
輸入或展示時(shí),都不對(duì)日期做任何處理。日期對(duì)象直接保存在 DB 中。
只有真正的紀(jì)念日有必要用這種方式,應(yīng)當(dāng)非常謹(jǐn)慎。例如保存一個(gè)聯(lián)系人的生日時(shí)。
5. 關(guān)于時(shí)區(qū)的特殊處理
5.1 時(shí)區(qū)的不確定性
使用絕對(duì)的時(shí)差來表示時(shí)區(qū),例如:“東 8 區(qū)”表示比世界協(xié)調(diào)時(shí)間(UTC)早 8 個(gè)小時(shí)的時(shí)區(qū)。這是個(gè)客觀的時(shí)區(qū)。
很多時(shí)候,我們關(guān)注的是一個(gè)城市或地區(qū)的時(shí)區(qū)。例如:Asia/Shanghai 表示中國(guó)時(shí)間;三字母的縮寫 EST 表示美國(guó)東部標(biāo)準(zhǔn)時(shí)間。注意,這些根據(jù)地理位置定義的時(shí)區(qū)的時(shí)差是會(huì)發(fā)生變化的,變化因素包括:可能受到當(dāng)?shù)卣叩挠绊懀蛳牧顣r(shí)影響。
對(duì)于歷史的時(shí)間,地理時(shí)區(qū)是可以確定客觀時(shí)區(qū)的,因?yàn)闆]有人會(huì)重新定義已經(jīng)過去的時(shí)間。
對(duì)于未來的時(shí)間,地理時(shí)區(qū)并不能確定客觀時(shí)區(qū)。因此,如果一個(gè)未來的事件是按照非絕對(duì)時(shí)區(qū)約定的,那么它很可能變化。并且,我們的產(chǎn)品需要考慮到處理這種變化。
例如,中國(guó)員工發(fā)起一個(gè)“每天早 8 點(diǎn)”的跨國(guó)會(huì)議,那么在美國(guó),由于夏令時(shí)的改變,冬天開會(huì)的時(shí)間和夏天是不一樣的。反之,美國(guó)員工發(fā)起的一個(gè)“每天早 8 點(diǎn)”的跨國(guó)會(huì)議,由于美國(guó)夏令時(shí)的變化,對(duì)中國(guó)員工的時(shí)間也是夏天和冬天不一樣的。
5.2 夏令時(shí)
某些國(guó)家在夏天,會(huì)把時(shí)間調(diào)快一小時(shí)(提前一小時(shí))。這表現(xiàn)為,同一個(gè)地區(qū),在冬天和夏天用不同的絕對(duì)時(shí)區(qū)。
這樣做,是因?yàn)橄奶彀滋旌荛L(zhǎng),調(diào)整后會(huì)在白天的更早的時(shí)段上班,從而下班后有更長(zhǎng)的天亮的時(shí)間。注意,并不是把 10 點(diǎn)上班調(diào)整到 9 點(diǎn)上班,而是全社會(huì)重新定義了 10 點(diǎn)提前了一小時(shí)。
一個(gè)具體的例子,在美國(guó):
- 在 2021 年 3 月 14 日凌晨 1:59:59 后,下一秒就是凌晨 3:00:00。因此,美國(guó)的 2021 年 3 月 14 日凌晨 2:10:00 這個(gè)時(shí)間實(shí)際上不存在。為了兼容,根據(jù) RFC5545,如果日程約在了這個(gè)不存在的時(shí)間,會(huì)認(rèn)為是 3:10:00。
- 在 2021 年 11 月 7 日凌晨 1:59:59 后,下一秒是凌晨 1:00:00。因此,美國(guó)的 2021 年 11 月 7 日凌晨 1:10:00 這個(gè)時(shí)間實(shí)際上會(huì)出現(xiàn)兩次。為了避免歧義,根據(jù) RFC5545,看到這個(gè)時(shí)間時(shí),會(huì)認(rèn)為是靠前的時(shí)間點(diǎn)。因此,除非用別國(guó)的時(shí)區(qū)來約日程,否則,美國(guó)老板是不可能約你在重疊的第二個(gè)小時(shí)內(nèi)開會(huì)的。
6. 閱讀更多及參考文獻(xiàn)
Wikipedia: ISO8601 - 用字符串表達(dá)各種時(shí)間對(duì)象的標(biāo)準(zhǔn)??https://en.wikipedia.org/wiki/ISO_8601??
RFC3339 - 互聯(lián)網(wǎng)上關(guān)于時(shí)間和日期實(shí)現(xiàn)的通用建議
??https://www.rfc-editor.org/rfc/rfc3339??
RFC5545 - iCalendar 互聯(lián)網(wǎng)日歷應(yīng)用的規(guī)范
??https://datatracker.ietf.org/doc/html/rfc5545??
Stackoverflow: Daylight saving time and time zone best practices [closed] - 技術(shù)實(shí)現(xiàn)建議
https://stackoverflow.com/questions/2532729/daylight-saving-time-and-time-zone-best-practices
Stackoverflow: How to store repeating dates keeping in mind daylight saving time - 技術(shù)實(shí)現(xiàn)建議
https://medium.com/@vivekmadurai/how-to-deal-with-date-and-time-across-time-zones-39b1bd747f35
Medium: How to Deal with Date and Time across Time Zones - 技術(shù)實(shí)現(xiàn)建議
https://medium.com/@vivekmadurai/how-to-deal-with-date-and-time-across-time-zones-39b1bd747f35
Microsoft365: Behavior and format options of the Date and Time field - 微軟的時(shí)間和日期字段的文檔
Time Change 2021 in the United States - 美國(guó) 2021 年夏令時(shí)的調(diào)整方式