記一次 MySQL timestamp 精度問題的排查 → 過程有點曲折

開心一刻

  下午正準備出門,跟正刷着手機的老媽打個招呼

  我:媽,今晚我跟朋友在外面喫,就不在家吃了

  老媽拿着手機跟我說道:你看這叫朋友騙緬北去了,tm血都抽乾了,多危險

  我:那是他不行,你看要是吳京去了指定能跑回來

  老媽:還吳京八經的,特麼牛魔王去了都得耕地,唐三藏去了都得打出舍利,孫悟空去了都得演大馬戲

  我:那照你這麼說,唐僧師徒取經走差地方了唄

  老媽:那可沒走錯,他當年擱西安出發,他要是擱雲南出發呀,上午到緬北,下午他就到西天

  我:哈哈哈,那西遊記就兩級唄,那要是超人去了呢?

  老媽:那超人去了,回來光剩超,人留那了

問題復現

  我簡化下業務與項目

  數據庫: MySQL 8.0.25 

  基於 spring-boot 2.2.10.RELEASE 搭建 demo :spring-boot-jpa-demo

  表: tbl_user 

  測試代碼:

/**
 * @description: xxx描述
 * @author: 博客園@青石路
 * @date: 2024/1/9 21:42
 */
@RunWith(SpringRunner.class)
@SpringBootTest
@Slf4j
public class UserTest {

    @Resource
    private UserRepository userRepository;

    @Test
    public void get() {
        DateTimeFormatter dft = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS");
        Timestamp lastModifiedTime  = Timestamp.valueOf(LocalDateTime.parse("2024-01-11 09:33:26.643", dft));

        // 1.先保存一個user
        User user = new User();
        user.setUserName("zhangsan");
        user.setPassword("zhangsan");
        user.setBirthday(LocalDate.now().minusYears(25));
        user.setLastModifiedTime(lastModifiedTime);
        log.info("user.lastModifiedTime = {}", user.getLastModifiedTime());
        userRepository.save(user);
        log.info("user 保存成功,userId = {}", user.getUserId());

        // 2.然後再根據id查詢這個user
        Optional<User> userOptional = userRepository.findById(user.getUserId());
        if (userOptional.isPresent()) {
            log.info("從數據庫查詢到的user,user.lastModifiedTime = {}", userOptional.get().getLastModifiedTime());
        }
    }
}
View Code

  這麼清晰的代碼,大家都能看懂吧?

  我們來看下日誌輸出

  保存的時候, lastModifiedTime 的值是 2024-01-11 09:33:26.643 ,從數據庫查詢得到的卻是: 2024-01-11 09:33:27.0 

  是不是被震驚到了?

曲折排查

  先確認下 MySQL 表中存的值是多少

  數據庫表中的值就是 2024-01-11 09:33:27 ,此刻我只想來一句:臥槽!

  這說明數據入庫有問題,而不是讀取有問題

  我們來梳理下數據入庫經歷了哪些環節

  那問題肯定出在 Spring Data JPA 至 mysql-connector-java 之間

   MySQL 肯定是沒問題的!

  源碼跟蹤

  既然問題出在 Spring Data JPA 與 mysql-connector-java 之間,那麼我們就直接來個一穿到底,翻了它的源碼老底

  大家請坐好,我要開始裝逼了

   JPA 用的少,一時還不知道從哪裏開始去跟源碼,但不要慌,樓主有 葵花寶典 :雜談篇之我是怎麼讀源碼的,授人以漁

  斷點追蹤源碼,一時用一時爽,一直用一直爽

  直接在 userRepository.save(user) 前面打個斷點,然後一步一步往下跟,我就不細跟了,我只在容易跟丟的地方指出來,給你們合適的方向

  當斷點到 SessionImpl#firePersist 方法時

  我們應該去跟 PersistEventListener::onPersist 了,一路跟下去,會來到 AbstractSaveEventListener#performSaveOrReplicate 方法

  裏面有如下代碼

  添加的 Action 的實際類型是: EntityIdentityInsertAction 

  這裏涉及到了 hibernate 的 事件機制 ,簡單來說就是 EntityIdentityInsertAction 的 execute 方法會被調用

  所以我們繼續從 EntityIdentityInsertAction#execute 跟,會來到 GetGeneratedKeysDelegate#executeAndExtract 

  重點來了,大家打起精神

  繼續跟進 session.getJdbcCoordinator().getResultSetReturn().executeUpdate( insert ) 的 executeUpdate 

  它長這樣

  如果不是斷點跟的話

  你知道接下來跟誰嗎?

  當然,非常熟悉源碼的人(比如我),肯定知道跟誰

  但是用了斷點,大家都知道跟誰了

  繼續往下跟,當我們來到 ClientPreparedStatement#executeInternal 時,真相已經揭曉

  此時已經來到了 mysql-connector-java ,發送給 MySQL Server 的 SQL 是:

   last_modified_time 精度沒丟

  那問題出在哪?

  還能出在哪, MySQL 唄!

  說好的 MySQL 沒問題的了?

  MySQL 時間精度

  用排除法,排的只剩 MySQL 了,直接執行 SQL 試試

  哦豁,敢情前面的源碼分析全白分析了,我此刻的心情你們懂嗎

  這必須得找 MySQL 要個說法,真是太狗了

  我們去 MySQL 官方文檔找找看(注意參考手冊版本要和我們使用的 MySQL 版本一致)

  大家不要通篇去讀,那樣太費時間,直接 search 用起來

  The DATE, DATETIME, and TIMESTAMP Types 有這麼一段比較關鍵

  我給大家翻譯一下

  繼續看 Fractional Seconds in Time Values,內容不多,大家可以通篇讀完

   MySQL 的 TIME , DATETIME 和 TIMESTAMP 都支持微妙級別(6位數)的小數位

  精度直接在括號中指定,例如: CREATE TABLE t1 (t TIME(3), dt DATETIME(6)) 

  小數位的範圍是 0 到 6。0 表示沒有小數部分,如果小數位缺省,則默認是0(SQL規範規定的默認是 6,MySQL8 默認值取 0 是爲了兼容 MySQL 以前的版本

  當插入帶有小數部分的 TIME , DATETIME 或 TIMESTAMP 值到相同類型的列時,如果值的小數位與精度不匹配時,會進行四捨五入

  四捨五入的判斷位置是精度的後一位,比如精度是 0,則看值的第 1 位小數,來決定是舍還是入,如果精度是 2,則看值的第 3 位小數

  簡單來說:值的精度大於列類型的精度,就會存在四捨五入,否則值是多少就存多少

  當發生四捨五入時,既不會告警也不會報錯,因爲這就是 SQL 規範

  那如果我不像要四捨五入了,有沒有什麼辦法?

   MySQL 也給出了支持,就是啓用 SQL mode :TIME_TRUNCATE_FRACTIONAL

  啓用之後,當值的精度大於列類型的精度時,就是直接按列類型的精度截取,而不是四捨五入

  那這麼看下來,不是 MySQL 的鍋呀, MySQL 表示這鍋我不背

  那是誰的鍋?

  只能說是開發人員的鍋,爲什麼不按 MySQL 使用說明書使用?

  我要強調的是,產生這次問題的代碼不是我寫的,我寫的代碼怎麼可能有 bug 

總結

  1、 源碼 debug 堆棧

  2、MySQL 時間精度

     MySQL 的 TIME , DATETIME 和 TIMESTAMP 類型都支持微妙級別(6位數)的精度

    默認情況下會四捨五入,若想直接截斷,則需要開啓 SQL mode : TIME_TRUNCATE_FRACTIONAL 

  3、規範

    阿里巴巴的開發手冊中明確指出不能用: java.sql.Timestamp 

    另外很多公司的 MySQL 開發規範會強調:沒有特殊要求,時間類型用 datetime 

    主要出於兩點考慮:1、 datetime 可用於分區,而 timestamp 不行,2、 timestamp 的範圍只到 2038-01-19 03:14:07.499999 

    有的開發小夥伴可能會問:如果到了 2038-01-19 03:14:07.499999 之後, timestamp 該怎麼辦?

    我只能說:小夥子你想的太遠了, 2038 跟我們有什麼關係,影響我們送外賣嗎?

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章