一個關于 nolock 的故事

sql-server.png

加入滬江不久,我就被扔到一個將集團 SQL Sever 的數據庫遷移到 MySQL 的項目里,
同時伴隨進行的還有 .net 系統遷移到 Java 系統。
在這個過程中我發現了一個很有趣的現象:歷史遺留的 .net 項目中,
幾乎所有的 SQL 中都會使用一個關鍵字:nolock
這讓我很困惑,nolock 的字面意思是對當前技術不使用鎖技術,為什么要這樣用呢?

我找了一個范例如下:

SELECT [id] 
FROM   [dbo].[foos] WITH(nolock) 
WHERE  aField = 42 
       AND bField = 1 

作為橫向支持工程師,開發工程師會問我:「數據庫即將從 SQL Server
遷移到 MySQL,我們編碼中還需要使用 nolock 么?
MySQL 里面對應的寫法是什么?」。
我并沒有 SQL Server 的生產環境使用經驗,一時間無法回答。
于是課后做相關知識學習,這里就是這次學習的一點成果。

這個問題將被拆解成三個小問題進行回答:

  • nolock 是什么?
  • 為什么會需要在每個 Query 語句使用 nolock
  • MySQL 的對應寫法是什么?

讓我們一個一個來看。

第一個問題:nolock 是什么?

nolock 是 SQL Server 的一個關鍵字,這類關鍵字官方將其稱之為 Hints。
Hints 的設計目的是為了能夠讓 SQL 語句在運行時,動態修改查詢優化器的行為。
在語法上,Hints 以 WITH 開頭。除了 WITH(nolock)
還有 TABLOCK / INDEX / ROWLOCK 等常見的 Hints。

讓我們仔細看看 MSDN 文檔上的解釋:

nolock 的作用等同于 READUNCOMMITTED

READUNCOMMITTED 這是一種 RDBMS 隔離級別。
使用 nolock 這個關鍵詞,可以將當前查詢語句隔離級別調整為 READ UNCOMMITTED

計算機基礎好的同學,應該對 READUNCOMMITTED 這個關鍵詞還有印象。
而基礎不扎實的同學,也許只是覺得這個關鍵詞眼熟,但是講不清楚這是什么。
如果閱讀這句話完全沒有理解困難,那恭喜你,你可以直接跳到下一節了。
其他朋友就跟隨我繼續探索一下 RDMBS 的世界,復習一下隔離級別相關的知識。

隔離級別

SQL 92 定義了四個隔離級別
Isolation (database systems) - Wikipedia),
其隔離程度由高到低是:

  • 可序列化(Serializable)
  • 可重復讀(Repeatable reads)
  • 提交讀(Read committed)
  • 未提交讀(Read uncommitted)

單單將這幾個技術名詞簡單地羅列出來并沒有什么意義,還有這幾個問題需要搞清楚:

  • 隔離級別解決什么問題?
  • 為什么存在多種隔離級別?
  • 我們所謂的隔離級別從高到低,是什么含義,如何逐層降低的?

首先是「隔離級別解決什么問題?」,
用通俗的語言描述就是:加一個針對數據資源的鎖,從而保證數據操作過程中的一致性。

這是最簡單的實現方式,過于粗暴的隔離性將大幅降低性能,
多種隔離級別就是是為了取得兩者的平衡。

接下來我們來回答第二個問題「為什么存在多種粒度的隔離級別?」
這其實是一個需求和性能逐步平衡的過程,

我們逐層遞進,將隔離級別由低到高逐層面臨進行分析。

Read Uncommitted

Read Uncommitted 這個隔離級別是最低粒度的隔離級別,
如同它的名字一般,它允許在操作過程中不會鎖,從而讓當前事務讀取到其他事務的數據。

read-uncommitted.png

如上圖所示,在 Transaction 2 查詢時候,Transaction 1 未提交的數據就已經對外暴露。
如果 Transaction 1 最后 Rollback 了,那么 Transaction 讀取的數據就是錯誤的。

「讀到了其他事務修改了但是未提交的數據」即是臟讀

Read Committed

想要避免臟讀,最簡單的方式就是在事務更新操作上加一把寫鎖,
其他事務需要讀取數據時候,需要等待這把寫鎖釋放。

read-committed-1.png

如上圖所示,Transaction 1 在寫操作時候,對數據 A 加了寫鎖,
那么 Transaction 2 想要讀取 A,就必須等待這把鎖釋放。
這樣就避免當前事務讀取其他事務的未提交數據。

但是除了臟讀,一致性的要求還需要「可重復讀」,即
「在一個事務內,多次讀取的特定數據都必須是一致的
(即便在這過程中該數據被其他事務修改)」。

read-committed-2.png

上圖就是沒能保證「可重復度」,Transaction 2 第一次讀取到了數據 A,
然后 Transaction 1 對數據 A 更新到 A',那么當 Tranction 2 再次讀取 A 時候,
它本來期望讀到 A,但是卻讀到了 A',這和它的預期不相符了。
解決這個問題,就需要提升隔離級別到「Repeatable Read」。

Repeatable Read

這個名字非常容易理解,即保障在一個事務內重復讀取時,
始終能夠讀取到相同的內容。來看圖:

repeatable-read.png

如上所示,當 Transation 2 讀取 A 時候,會同時加上一把 Read Lock,
這把鎖會阻止 Transaction 1 將 A 更新為 A',Transaction 1 要么選擇等待,
要么就選擇結束。

當我們將隔離級別升到這里是,似乎已經完美無缺了。
不管是寫入還是讀取,我們都可以保證數據的一致性不被破壞。
但是其實還有漏洞:新增數據的一致性!

上述的三個隔離級別,都是對特定的一行數據進行加鎖,
那假如將要更新的數據還沒有寫入數據庫,如何進行加鎖呢?
比如自增表的新鍵,或者現有數據內的空缺 Key?

repeatable-read-2.png

如圖所示,在上述操作中,Transaction 2 查詢了一個范圍 Range 之后,Transaction 1
在這個范圍內插入了一條新的數據。此時 Transaction 2 再次進行范圍查詢時候,
會發現查詢到的 Range 和上次已經不一樣了,多了一個 newA。

這就是最高隔離級別才能解決的「幻影讀」:
當兩個完全相同的查詢語句執行得到不同的結果集,
這常常在范圍查詢中出現。

Serializable

從字面意思看,該隔離級別需要將被操作的數據加鎖加一把鎖。
任何讀寫操作都需要先獲得這把鎖才能進行。如果操作中帶 WHERE 條件,
還需要將 WHERE 條件相關的范圍全部加鎖。

serializable.png

如圖所示,在 Transaction 2 操作過程中,會對 Range 進行加鎖,
此時其他事務無法操作其中的數據,只能等待或者放棄。

DB 的默認隔離級別

現在我們已經理解了隔離級別,那么「SQL Server 默認使用的隔離級別是什么呢?」
根據 Customizing Transaction Isolation Level
這個文檔描述,SQL Server 默認隔離級別是 READ COMMITTED。

MySQL InnoDB 的默認隔離級別可以在 MySQL :: MySQL 5.7 Reference Manual :: 14.5.2.1 Transaction Isolation Levels
查詢到,是 Read-Repeatable。

隔離級別并沒有最好之說,越高隔離級別會導致性能降低。
隔離級別的設定需要考慮業務場景。

第二個問題:為什么要使用 nolock?

我們已經知道 nolock 的作用是動態調整隔離級別。
那為什么在 SQL Server 的 Query 操作中,需要啟用 nolock 呢?
我問了幾個工程師,他們都語焉不詳,或者是很泛泛地說:禁用讀寫鎖,可以提升查詢性能。

此時我產生了困惑:「那么此時的數據一致性就不需要考慮了么?
我們的數據庫,已經到了需要禁用鎖的程度來進行優化了么?」
我于是自己去探索,想知道為何廣泛使用 nolock 會成為一個「最佳實踐」?

由于時代久遠,我只能追述到一些相關信息,比如
Top 10 SQL Server Integration Services Best Practices | SQL Server Customer Advisory Team
中提到 「Use the NOLOCK or TABLOCK hints to remove locking overhead.」
但這個是針對于 SSIS 查詢器,并不是針對業務內部使用。
反而能找到一大堆的文檔,在反對使用 nolock 這個關鍵字。

繼續追查下去,還從蛛絲馬跡中尋找到一個使用 nolock 的理由,
SQL Server 默認是 Read Committed,
更新操作會產生排它鎖,會 block 這個資源的查詢操作,
已插入但未提交的數據主鍵也會產生一個共享鎖,
而此時則會 block 這張表的全表查詢和 Insert 操作。
為了避免 Insert 被 Block,就會推薦使用 nolock

為了驗證這是原因,我做一些 nolock 測試。

nolock 測試

檢查當前 SQL Server 隔離級別,確認隔離級別是默認的 Read Committed:

SELECT CASE transaction_isolation_level
       WHEN 0
         THEN 'Unspecified'
       WHEN 1
         THEN 'ReadUncommitted'
       WHEN 2
         THEN 'ReadCommitted'
       WHEN 3
         THEN 'Repeatable'
       WHEN 4
         THEN 'Serializable'
       WHEN 5
         THEN 'Snapshot' END AS TRANSACTION_ISOLATION_LEVEL
FROM sys.dm_exec_sessions
WHERE session_id = @@SPID

-- ReadCommitted

創建表,初始化數據:

CREATE TABLE foos (
  id    BIGINT    NOT NULL,
  value NCHAR(10) NULL,
  CONSTRAINT pk PRIMARY KEY clustered (id)
);
INSERT INTO foos (id, value) VALUES (1, '1'), (2, '2');

在 Transaction 1 中發起 Update 操作(INSERT / DELETE 同理),但是并不做 Commit 提交:

BEGIN TRANSACTION;
INSERT INTO foos (id, value) VALUES (3, '3');

開啟一個新的 Session,發起全表查詢和新增 PK 查詢操作:

SELECT * FROM foos;
SELECT * FROM foos WHERE id = 4;

不出所料,此時查詢果然會被 Block 住。

MVCC

并發控制的手段有這些:封鎖、時間戳、樂觀并發控制、悲觀并發控制。
SQL Server 在 2015 后,引入了 MVCC(多版本控制)。
如果最終數據是一致,會允許數據寫入,否則其他事務會被阻止寫入。
那么 MVCC 引入是否可以解決 Insert 數據的鎖問題?
同樣,我做了以下測試:

查詢 SQL Server 使用啟用 MVCC ALLOW_SNAPSHOT_ISOLATION:

SELECT name, snapshot_isolation_state FROM sys.databases;

使用 T-SQL 啟用測試表的 SNAPSHOT_ISOLATION:

ALTER DATABASE HJ_Test3D SET ALLOW_SNAPSHOT_ISOLATION ON;

接著重復上面里面的 Insert 試驗,依然被 Block 住。
看來 MVCC 并不能解決 Insert 鎖的問題。

SQL Server 2005 之后還需要使用 nolock 么?

從官方文檔和上文測試可以看到,在 Insert 時候,由于排它鎖的存在,
會導致 SELECT ALL 以及 SELECT 新插入數據的相關信息被鎖住。
在這兩種情景下面是需要使用 nolock 的。

除此之外,有這么幾類場景可以使用 nolock

  • 在 SSIS 查詢器中進行數據分析,不需要精準數據
  • 歷史數據進行查詢,沒有數據更新操作,也不會產生臟數據

我們需要思考一下,性能和數據一致性上的權衡上,
我們是否愿意放棄數據一致性而為了提高一絲絲性能?
以及我們有多少場景,會頻繁使用 SELECT ALL 操作而沒有查詢條件?

微軟官方在 2015 的特性列表里面,明確地指出 nolock 特性未來會在某個版本被廢除:

Specifying NOLOCK or READUNCOMMITTED in the FROM clause of an UPDATE or DELETE statement.

而改為推薦:

Remove the NOLOCK or READUNCOMMITTED table hints from the FROM clause.

事實上,我聽過不少團隊會禁止在生產環境使用不帶 WHERE 條件的 SQL。
那在這種模式下,產生相關的問題的幾率也就更小了。
如果有很高的并發需求,那需要考慮一下是否需要其他優化策略:比如使用主從分離、
Snapshot 導出、流式分析等技術。

第三個問題:MySQL 的對應寫法是什么?

終于輪到 MySQL 的討論了。MySQL,InnoDB 天生支持 MVCC,
并且支持 innodb_autoinc_lock_mode AUTO_INCREMENT Handling in InnoDB
這樣可以避免 Insert 操作鎖住全局 Select 操作。
只有在同時 Insert 時候,才會被 Block 住。

innodb_autoinc_lock_mode 支持幾種模式:

  • innodb_autoinc_lock_mode = 0 (“traditional” lock mode)
    • 涉及auto-increment列的插入語句加的表級AUTO-INC鎖,只有插入執行結束后才會釋放鎖
  • innodb_autoinc_lock_mode = 1 (“consecutive” lock mode)
    • 可以事先確定插入行數的語句,分配連續的確定的 auto-increment 值
    • 對于插入行數不確定的插入語句,仍加表鎖
    • 這種模式下,事務回滾,auto-increment 值不會回滾,換句話說,自增列內容會不連續
  • innodb_autoinc_lock_mode = 2 (“interleaved” lock mode)
    • 同一時刻多條 SQL 語句產生交錯的 auto-increment 值

這里也做了相應的測試。首先檢查數據庫隔離級別和 innodb_autoinc_lock_mode 模式:

SELECT @@global.tx_isolation, @@session.tx_isolation, @@tx_isolation;
SHOW variables LIKE 'innodb_autoinc_lock_mode';

檢查后發現都是 Repeatable Read,innodb_autoinc_lock_mode 模式是 1。
然后創建測試表:

CREATE TABLE `foos` (
  `id` int(11) unsigned NOT NULL AUTO_INCREMENT,
  `name` varchar(20) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=18 DEFAULT CHARSET=utf8;

在 Transaction 1 中 Insert 數據:

START TRANSACTION;
INSERT INTO foos (name) VALUES ("a");

在 Transaction 2 中 Select 數據,可以正常查詢:

SELECT * FROM   foos;

在 Transaction 2 中 Insert 數據,會被 Block 住:

START TRANSACTION;
INSERT INTO foos (name) VALUES ("a");

這個測試可以證明 MySQL 可以在 innodb_autoinc_lock_mode=1 下,
Insert 同時 Query 不會被 Block,
但是在另外一個事務中 Insert 會被 Block。
結論是,由于 innodb_autoinc_lock_mode 的存在,MySQL 中可以不需要使用 nolock
關鍵詞進行查詢。

回顧一下

本文著重去回答這么幾個問題:

  • 為什么要用 noloc
  • 為什么要改變隔離級別?
  • 為什么 MySQL 不需要做類似的事情?

雖然只湊足了三個 「為什么」 的排比,
但是聰明的讀者仍然會發現,我是使用了著名的
五個為什么
方法思考問題。
通過使用這個方法,我們最后不但打破了老舊的最佳實踐,還了解了本質原理,
并找到了新的最佳實踐。

希望讀者朋友在遇到困難時候,多問幾個為什么,多抱著打破砂鍋問到底的精神,
這樣才能讓每個困難成為我們成長的墊腳石。

相關資料


原文鏈接: 一個關于 nolock 的故事 - Log4D
歡迎關注我的微信公眾號:窺豹


3a1ff193cee606bd1e2ea554a16353ee

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容