簡單聊聊mysql的髒讀、不可重複讀

子月生發表於2021-12-24

最近,在一次 mysql 死鎖的生產事故中,我發現,關於 mysql 的鎖、事務等等,我所知道的東西太碎了,所以,我試著用幾個例子將它們串起來。具體做法就是通過不斷地問問題、回答問題,再加上“適當”的比喻,來逐步構建腦子裡的“知識樹”。

需要提醒一下,這篇部落格並不適合小白,因為你需要先了解排它鎖、共享鎖、事務,最重要的是你需要知道事務中的鎖是什麼時候加上、什麼時候開啟的。而這篇部落格更多的是希望把這些碎片化的知識給連線起來。

專案環境

mysql 版本:5.7.28-winx64

OS:win 10

資料庫指令碼:

DROP TABLE IF EXISTS `demo_user`;

CREATE TABLE `demo_user` (
  `id` varchar(32) NOT NULL COMMENT '使用者id',
  `name` varchar(16) NOT NULL COMMENT '使用者名稱',
  `gender` tinyint(1) DEFAULT '0' COMMENT '性別',
  `age` int(3) unsigned DEFAULT NULL COMMENT '使用者年齡',
  `gmt_create` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '記錄建立時間',
  `gmt_modified` timestamp NULL DEFAULT NULL COMMENT '記錄最近修改時間',
  `deleted` tinyint(1) DEFAULT '0' COMMENT '是否刪除',
  `phone` varchar(11) NOT NULL COMMENT '電話號碼',
  PRIMARY KEY (`id`),
  KEY `idx_phone` (`phone`),
  KEY `idx_name` (`name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='使用者表';

insert  into `demo_user`(`id`,`name`,`gender`,`age`,`gmt_create`,`gmt_modified`,`deleted`,`phone`) values ('222','zzs001',0,18,'2021-12-13 15:11:03','2021-12-13 09:59:12',0,'188******26');
insert  into `demo_user`(`id`,`name`,`gender`,`age`,`gmt_create`,`gmt_modified`,`deleted`,`phone`) values ('111','zzf001'0,18,'2001-08-27 11:00:11','2001-08-27 11:00:13',0,'188******22');

髒讀

準備工作

在講髒讀之前,我們先開啟兩個會話,並把事務隔離級別更改為讀未提交(read uncommitted)。這時,id 為 222 的使用者初始年齡為 18。

mysql_lock_01.png

萬事俱備,我們開始吧。

什麼是髒讀

髒讀,就是讀到了其他會話還沒有提交的修改。下面用例子說明:

mysql_lock_02.png

可以看到,會話 2 修改了 id 為 222 的使用者,在還沒提交或回滾事務之前,會話 1 就讀到了這些改動。

髒讀的本質就是,還沒結束的寫操作被讀操作分割了。所以,為了解決髒讀,就必須讓寫操作不可被讀操作分割(當然,也不能被其他寫操作分割),即保證所謂的原子性。

如何解決髒讀

那麼,應該如何實現呢?這裡給出兩種方案。

第一種,給讀增加鎖。為了保證寫操作的原子性,從更新操作開始到事務結束(注意,不是事務開始到事務結束),會話 2 都應該鎖著 id 為 222 的記錄,會話 1 的讀操作要等會話 2 的事務結束後才能執行。上面的例子中,我們理所當然地會認為是會話 2 的寫操作沒有加排它鎖導致的髒讀,然而並非如此,通過SELECT * FROM information_schema.INNODB_TRX;可以發現,會話 2 已經鎖住了 id 為 222 的記錄,但會話 1 的讀操作並沒有等待,為什麼呢?根本原因在於會話 1 的讀是無鎖讀,在讀未提交的事務隔離級別中,無鎖讀不需要等待寫操作。所以,我們需要給讀加上鎖(共享鎖和排它鎖均可,但為了併發讀,建議用共享鎖),如下:

mysql_lock_03.png

可以看到,因為會話 2 的更新操作還沒結束,所以,會話 1 需要一直等待,直到會話 2 的事務結束,這就避免了髒讀的問題。你可能會覺得奇怪,實際專案好像不是這樣的吧?沒錯,因為我們用的更多的是第二種方案。

第二種方案,將事務隔離級別更改為讀已提交(read committed)。第一種方案中,讀寫是序列的,然而,我們既要讀寫並行,又不想出現髒讀。需求刁鑽但合理,於是,就有了第二種方案。如下:

mysql_lock_04.png

可以看到,會話 2 的更新操作還沒結束,會話 1 就讀到了同一條記錄,結果卻沒有產生髒讀。如何實現的呢?

這裡我說說自己的理解,可能並不嚴謹。邏輯上有點像 java 中的CopyOnWriteArrayList,當事務隔離級別為已提交時,不會在實際記錄上進行寫操作,而是將需要修改的記錄快取一份進行更改,事務提交時才把這部分快取刷入實際記錄,而這個過程,其他會話可以正常讀實際記錄,而不會讀到修改中的資料。

不可重複讀

準備工作

在講不可重複讀之前,我們可以把事務隔離級別設定為讀未提交(read uncommitted),也可以設定為讀已提交(read committed)。

什麼是不可重複讀

不可重複讀,就是在同一個事務中,多次讀相同的記錄但讀到了不同的結果。下面用例子說明:

mysql_lock_05.png

可以看到,會話 1 第一次讀 id 為 222 的使用者年齡為 18,在事務還沒結束之前,會話 2 將他的年齡更改為 19,會話 1 再次讀就會出現前後不一致的情況。

不可重複讀的本質就是,還沒結束的讀操作被寫操作分割了。所以,為了解決不可重複讀,就必須讓讀操作不可被寫操作分割,即保證所謂的原子性。

如何解決不可重複讀

那麼,應該如何實現呢?和解決髒資料一樣,這裡也給出兩種方案。

第一種方案,給讀增加鎖來。為了保證讀操作的原子性,從讀操作開始到事務結束(注意,不是事務開始到事務結束),會話 1 都應該鎖著 id 為 222 的記錄,會話 2 的寫操作要等會話 1 的事務結束後才能執行。所以,我們需要給讀加上鎖(共享鎖和排它鎖均可,但為了併發讀,建議用共享鎖),如下:

mysql_lock_06.png

可以看到,會話 2 的寫操作需要等待會話 1 的事務結束才能執行,在事務結束之前,會話 1 讀幾次資料都不會出現不可重複讀。

第二種方案,將事務隔離級別更改為可重複讀(repeatable read)。第一種方案中,讀寫是序列的,然而,我們既要讀寫並行,又不想出現不可重複讀。於是,就有了第二種方案。如下:

mysql_lock_07.png

可以看到,會話 1 的讀操作並沒有加鎖,會話 2 的寫操作也不需要等待,最終卻沒有產生不可重複讀。如何實現的呢?

這裡我還是說說自己不嚴謹的理解。當第一次讀到 id 為 222 的記錄時,mysql 會把這條記錄放在當前事務的快取區裡,下次讀這條資料的時候直接從快取拿就好,不需要去讀實際記錄,所以,其他會話的寫操作並不需要等待。這種讀被稱為快照讀,如果讀已提交中的寫是 copy on write,那可重複讀的讀就是 copy on read。

幻讀

未完待續。

結語

以上只是自己對 mysql 鎖、事務的一點點思考。因為我並沒有看過底層的邏輯,所以都是一些抽象層面的解讀。如有錯誤,歡迎指正。

最後,感謝閱讀。

參考資料

MySQL中的鎖(表鎖、行鎖,共享鎖,排它鎖,間隙鎖)

本文為原創文章,轉載請附上原文出處連結:https://www.cnblogs.com/ZhangZiSheng001/p/15727027.html

相關文章