背景
最近一直忙於手上澳洲線上項目的整體遷移和升級的准備工作,導致博客和公眾號停更。本周終於艱難的完成了任務,借此機會,總結一下項目中遇到的一些問題。
EF Core一直是我們團隊中中小型項目常用的ORM框架,在使用SQL Server作為持久化倉儲的場景一下,一直表現還中規中矩。但是在本次項目中,項目使用了MySql作為持久化倉儲。為了與EF Core集成,團隊使用了Pomelo.EntityFrameworkCore.MySql
作為EF Core For MySql的擴展。在開發過程中,團隊遇到了各種各樣在SQL Server場景下沒有遇到過的問題,其中最奇怪的,也是隱藏最深的問題,就是將DateTime.Now
作為查詢條件,產生了非預期的結果。
問題場景
本周在項目升級的過程中,客戶反饋了一個問題。
在當前系統的Dashboard頁面,有一個消息提醒功能,客戶可以自定義一些消息,並且指定提醒的日期。客戶遇到的問題是通常添加的消息提醒,在指定日期的上午時間段是不會顯示,只有在下午時間段才能看到,比如說客戶指定2019年10月26號看到一個的消息提醒,但是在10月26日這天早上8:00-12:00這個時間段,系統總是看不到提醒,只有到了下午的時間段才能看到提醒。
PS:這里客戶表達的只是個籠統的問題,但問題確實是上午的大部分時間是看不到消息提醒的,但並不是精確到中午12:00點這個時間, 所以此處不必過於糾結於具體的時間。
查看問題代碼
看到這個問題的時候,我自己也很奇怪,難道代碼或者數據庫使用了時區,導致查詢出現了偏差?
於是我就Review了一下此處的查詢, 代碼如下。
var query = DbContext.CRM_Note_Reminders
.Include(x => x.CRM_Note)
.Where(x => !x.CRM_Note.Is_Deleted
&& !x.Is_Deleted
&& x.Reminder_Date.Date <= DateTime.Now.Date)
.ToList();
PS: 這里可能有同學會有疑問,為啥不用
DbFunctions.DiffDays
? 原因是DbFunctions.DiffDays
是 EF Core for SQLServer的擴展方法,針對MySql還沒有官方的實現方案。
從這個查詢中,我沒有看出任何問題,於是我直接借助一些日志工具,將EF Core生成的查詢語句的輸出了出來。
其中WHERE條件部分如下:
WHERE (((`x.CRM_Note`.`Is_Deleted` = FALSE)
AND (`x`.`Is_Deleted` = FALSE))
AND (CONVERT(`x`.`Reminder_Date`, date)
<= CONVERT(CURRENT_TIMESTAMP(), date)))
這里CURRENT_TIMESTAMP()
是MySql的內置函數,與SQLServer的內置函數GETDATE()
不同,CURRENT_TIMESTAMP()
默認返回的是UTC時間。因此我們大概能知道,為什么澳洲客戶會遇到上面的場景了。
PS: 根據7樓兄弟的反饋,我試了一下,改動Mysql的時區配置之后,果然
CURRENT_TIMESTAMP()
就改為了對應時區的時間。這里使用UTC時間的原因應該是我在AWS RDS上創建Mysql實例的時候,忽略了時區配置。
由於澳洲處於東10區,與UTC時間有+10個小時的時差,所以當澳洲上午的10點之前,UTC時間都是在當前澳洲日期的前一天,所以系統中出現了當天的消息提醒在上午時間段不能正常顯示的問題。
PS: 由於澳洲是分冬令時和夏令時的,夏令時時間要加一個小時,所以實際上客戶在每天的11點之前都無法看到正確的消息提醒。
深入思考
你這可能會非常奇怪,為什么DateTime.Now
會被轉化成內置函數CURRENT_TIMESTAMP()
,而沒有使用我們傳入的值DateTime.Now.Date
呢?
其實EF/EF Core在查詢是時候是分2個階段的,一個是組合查詢表達式樹的階段,一個是真正的查詢階段。
在組合查詢表達式樹的階段,EF/EF Core只會去組合表達式,而不會去嘗試計算表達式的值,所以這個階段DateTime.Now.Date
的值並沒有被計算出來, 在進入正常查詢階段的時候, EF/EF Core會嘗試將查詢表達式樹翻譯成SQL腳本,這時候由於我們的EF Provider
是MySql Provider
, 恰巧DateTime.Now
可以翻譯成Mysql的內置函數CURRENT_TIMESTAMP()
, 所以這里EF/EF Core就跳過了表達式值的計算,直接將其翻譯成了對應的內置函數,所以導致生成的SQL查詢和我們的預期有偏差。
那么我們該如何解決這個問題呢?
解決方案
經過了以上的思考,其實解決這個問題也就很簡單了,我們可以將DateTime.Now.Date
先計算出來,保存在一個變量中,然后將這個變量傳入查詢中。
var today = DateTime.Now.Date;
var query = DbContext.CRM_Note_Reminders
.Include(x => x.CRM_Note)
.Where(x => !x.CRM_Note.Is_Deleted
&& !x.Is_Deleted
&& x.Reminder_Date.Date <= today)
.ToList();
由此生成的MySQL腳本如下:
WHERE (((`x.CRM_Note`.`Is_Deleted` = FALSE)
AND (`x`.`Is_Deleted` = FALSE))
AND (CONVERT(`x`.`Reminder_Date`, date) <= @__date_0))
這樣我們就得到了一個正確的結果,澳洲客戶也就收到了正確的消息。
是不是有種差之毫厘,謬以千里的感覺呢?