秒殺超賣 解決方案(史上最全)


文章很長,而且持續更新,建議收藏起來,慢慢讀!瘋狂創客圈總目錄 博客園版 為您奉上珍貴的學習資源 :

免費贈送 :《尼恩Java面試寶典》 持續更新+ 史上最全 + 面試必備 2000頁+ 面試必備 + 大廠必備 +漲薪必備
免費贈送 經典圖書:《Java高並發核心編程(卷1)加強版》 面試必備 + 大廠必備 +漲薪必備 加尼恩免費領
免費贈送 經典圖書:《Java高並發核心編程(卷2)加強版》 面試必備 + 大廠必備 +漲薪必備 加尼恩免費領
免費贈送 經典圖書:《Java高並發核心編程(卷3)加強版》 面試必備 + 大廠必備 +漲薪必備 加尼恩免費領
免費贈送 經典圖書:尼恩Java面試寶典 最新版 面試必備 + 大廠必備 +漲薪必備 加尼恩免費領
免費贈送 資源寶庫: Java 必備 百度網盤資源大合集 價值>10000元 加尼恩領取


前言

先來就庫存超賣的問題作描述:一般電子商務網站都會遇到如團購、秒殺、特價之類的活動,而這樣的活動有一個共同的特點就是訪問量激增、上千甚至上萬人搶購一個商品。然而,作為活動商品,庫存肯定是很有限的,如何控制庫存不讓出現超買,以防止造成不必要的損失是眾多電子商務網站程序員頭疼的問題,這同時也是最基本的問題。

在秒殺系統設計中,超賣是一個經典、常見的問題,任何商品都會有數量上限,如何避免成功下訂單買到商品的人數不超過商品數量的上限,這是每個搶購活動都要面臨的難點。

說明:本文會以pdf格式持續更新,更多最新尼恩3高pdf筆記,請從下面的鏈接獲取:語雀 或者 碼雲

一、問題描述

在多個用戶同時發起對同一個商品的下單請求時,先查詢商品庫存,再修改商品庫存,會出現資源競爭問題,導致庫存的最終結果出現異常。問題:
當商品A一共有庫存15件,用戶甲先下單10件,用戶乙下單8件,這時候庫存只能滿足一個人下單成功,如果兩個人同時提交,就出現了超賣的問題。

在這里插入圖片描述

二、解決的三種方案

  • 悲觀鎖

通過悲觀鎖解決超賣

  • 樂觀鎖

通過樂觀鎖解決超賣

  • 分段執行的排隊方案

通過分段執行的排隊方案解決超賣

說明:本文會以pdf格式持續更新,更多最新尼恩3高pdf筆記,請從下面的鏈接獲取:語雀 或者 碼雲

解決方案1: 悲觀鎖

當查詢某條記錄時,即讓數據庫為該記錄加鎖,鎖住記錄后別人無法操作,使用類似如下語法:

beginTranse(開啟事務)
 
try{
 
    query('select amount from s_store where goodID = 12345');
 
    if(庫存 > 0){
 
        //quantity為請求減掉的庫存數量
 
        query('update s_store set amount = amount - quantity where goodID = 12345');
 
    }
 
}catch( Exception e ){
 
    rollBack(回滾)
 
}
 
commit(提交事務)

問題:

注意,上面的代碼容易出現死鎖,采用不多。

有社群小伙伴,對死鎖的的原因比較關心,這里簡單分析一下。

上面的語句,可能出現死鎖的簡單的原因,在事務的隔離級別為Serializable時,假設事務t1通過 select拿到了共享鎖,而其他事務如果拿到了 排他鎖,此時t1 去拿排他鎖的時候, 就有可能會出現死鎖,注意,這里是可能,並不是一定。實際的原因,與事務的隔離級別和語句的復雜度,都有關系。

總之,避免死鎖的方式之一(稍后介紹):為了在單個 InnoDB 表上執行多個並發寫入操作時避免死鎖,可以在事務開始時通過為預期要修改的每個元祖(行)使用 SELECT … FOR UPDATE 語句來獲取必要的鎖,即使這些行的更改語句是在之后才執行的。

解決方案:一般提前采用 select for update,提前加上寫鎖。

beginTranse(開啟事務)
 
try{
 
    query('select amount from s_store where goodID = 12345   for update');
 
    if(庫存 > 0){
 
        //quantity為請求減掉的庫存數量
 
        query('update s_store set amount = amount - quantity where goodID = 12345');
 
    }
 
}catch( Exception e ){
 
    rollBack(回滾)
 
}
 
commit(提交事務)

行鎖和表鎖

行鎖:分為 共享鎖 和 排它鎖。

共享鎖又稱:讀鎖。當一個事務對某幾行上讀鎖時,允許其他事務對這幾行進行讀操作,但不允許其進行寫操作,也不允許其他事務給這幾行上排它鎖,但允許上讀鎖。

上共享鎖的寫法:lock in share mode

例如: select math from zje where math>60 lock in share mode;

排它鎖又稱:寫鎖。當一個事務對某幾個上寫鎖時,不允許其他事務寫,但允許讀。更不允許其他事務給這幾行上任何鎖。包括寫鎖。

上排它鎖的寫法:for update

例如:select math from zje where math >60 for update;

死鎖

死鎖:例如說兩個事務,事務A鎖住了15行,同時事務B鎖住了610行,此時事務A請求鎖住610行,就會阻塞直到事務B施放610行的鎖,而隨后事務B又請求鎖住15行,事務B也阻塞直到事務A釋放15行的鎖。死鎖發生時,會產生Deadlock錯誤。

表鎖:不會出現死鎖,發生鎖沖突幾率高,並發低。

表鎖是對表操作的,所以自然鎖住全表的表鎖就不會出現死鎖。但是表鎖效率低。

行鎖:會出現死鎖,發生鎖沖突幾率低,並發高。

3.行鎖的要點

注意幾點:

1.行鎖必須有索引才能實現,否則會自動鎖全表,那么就不是行鎖了。

2.兩個事務不能鎖同一個索引,例如:

事務A先執行:
select math from zje where math>60 for update;
 
事務B再執行:
select math from zje where math<60 for update;
這樣的話,事務B是會阻塞的。如果事務B把 math索引換成其他索引就不會阻塞,
但注意,換成其他索引鎖住的行不能和math索引鎖住的行有重復。

3.insert ,delete , update在事務中都會自動默認加上排它鎖。

實現:

會話1: 會話2:
begin;
select math from zje where math>60 for update;
begin;
update zje set math=99 where math=68;
阻塞

MyISAM與InnoDB 的區別

MyISAM:MyISAM是默認存儲引擎(Mysql5.1前),每個MyISAM在磁盤上存儲成三個文件,每一個文件的名字均以表的名字開始,擴展名指出文件類型。

​ .frm文件存儲表定義

​ ·MYD (MYData)文件存儲表的數據

​ .MYI (MYIndex)文件存儲表的索引

InnoDB:MySQL的默認存儲引擎,給 MySQL 提供了具有事務(transaction)、回滾(rollback)和崩潰修復能力(crash recovery capabilities)、多版本並發控制(multi-versioned concurrency control)的事務安全(transaction-safe (ACID compliant))型表。InnoDB 提供了行級鎖(locking on row level),提供與 Oracle 類似的不加鎖讀取(non-locking read in SELECTs)。

MyISAM與InnoDB 的區別

  1. InnoDB支持事務,MyISAM不支持,對於InnoDB每一條SQL語言都默認封裝成事務,自動提交,這樣會影響速度,所以最好把多條SQL語言放在begin和commit之間,組成一個事務;

  2. InnoDB支持外鍵,而MyISAM不支持。對一個包含外鍵的InnoDB表轉為MYISAM會失敗;

  3. 聚集索引 VS 非聚集索引

    InnoDB是聚集索引,使用B+Tree作為索引結構,數據文件是和(主鍵)索引綁在一起的(表數據文件本身就是按B+Tree組織的一個索引結構),必須要有主鍵,通過主鍵索引效率很高。但是輔助索引需要兩次查詢,先查詢到主鍵,然后再通過主鍵查詢到數據。因此,主鍵不應該過大,因為主鍵太大,其他索引也都會很大。

InnoDB的B+樹主鍵索引的葉子節點就是數據文件,輔助索引的葉子節點是主鍵的值

img

but, MyISAM是非聚集索引,也是使用B+Tree作為索引結構,索引和數據文件是分離的,索引保存的是數據文件的指針。主鍵索引和輔助索引是獨立的。

img

總結

​ 也就是說:InnoDB的B+樹主鍵索引的葉子節點就是數據文件,輔助索引的葉子節點是主鍵的值;而MyISAM的B+樹主鍵索引和輔助索引的葉子節點都是數據文件的地址指針。

  1. InnoDB不保存表的具體行數,執行select count(*) from table時需要全表掃描。而MyISAM用一個變量保存了整個表的行數,執行上述語句時只需要讀出該變量即可,速度很快(注意不能加有任何WHERE條件);

那么為什么InnoDB沒有了這個變量呢?

​ 因為InnoDB的事務特性,在同一時刻表中的行數對於不同的事務而言是不一樣的,因此count統計會計算對於當前事務而言可以統計到的行數,而不是將總行數儲存起來方便快速查詢。InnoDB會嘗試遍歷一個盡可能小的索引除非優化器提示使用別的索引。如果二級索引不存在,InnoDB還會嘗試去遍歷其他聚簇索引。
​ 如果索引並沒有完全處於InnoDB維護的緩沖區(Buffer Pool)中,count操作會比較費時。可以建立一個記錄總行數的表並讓你的程序在INSERT/DELETE時更新對應的數據。和上面提到的問題一樣,如果此時存在多個事務的話這種方案也不太好用。如果得到大致的行數值已經足夠滿足需求可以嘗試SHOW TABLE STATUS

  1. Innodb不支持全文索引,而MyISAM支持全文索引,在涉及全文索引領域的查詢效率上MyISAM速度更快高;PS:5.7以后的InnoDB支持全文索引了

  2. MyISAM表格可以被壓縮后進行查詢操作

  3. InnoDB支持表、行(默認)級鎖,而MyISAM支持表級鎖

InnoDB的行鎖是實現在索引上的,而不是鎖在物理行記錄上。潛台詞是,如果訪問沒有命中索引,也無法使用行鎖,將要退化為表鎖。

例如:

    t_user(uid, uname, age, sex) innodb;

    uid PK
    無其他索引
    update t_user set age=10 where uid=1;             命中索引,行鎖。

    update t_user set age=10 where uid != 1;           未命中索引,表鎖。

    update t_user set age=10 where name='chackca';    無索引,表鎖。

8、InnoDB表必須有唯一索引(如主鍵)(用戶沒有指定的話會自己找/生產一個隱藏列Row_id來充當默認主鍵),而Myisam可以沒有

9、Innodb存儲文件有frm、ibd,而Myisam是frm、MYD、MYI

​ Innodb:frm是表定義文件,ibd是數據文件

​ Myisam:frm是表定義文件,myd是數據文件,myi是索引文件

如何選擇:

​ 1. 是否要支持事務,如果要請選擇innodb,如果不需要可以考慮MyISAM;

​ 2. 如果表中絕大多數都只是讀查詢,可以考慮MyISAM,如果既有讀也有寫,請使用InnoDB。

​ 3. 系統奔潰后,MyISAM恢復起來更困難,能否接受;

​ 4. MySQL5.5版本開始Innodb已經成為Mysql的默認引擎(之前是MyISAM),說明其優勢是有目共睹的,如果你不知道用什么,那就用InnoDB,至少不會差。

InnoDB為什么推薦使用自增ID作為主鍵?

​ 答:自增ID可以保證每次插入時B+索引是從右邊擴展的,可以避免B+樹和頻繁合並和分裂(對比使用UUID)。如果使用字符串主鍵和隨機主鍵,會使得數據隨機插入,效率比較差。

innodb引擎的4大特性

​ 插入緩沖(insert buffer),二次寫(double write),自適應哈希索引(ahi),預讀(read ahead)

事務與死鎖

在MySQL的InnoDB中,預設的Tansaction isolation level 為REPEATABLE READ(可重讀)

在SELECT 的讀取鎖定主要分為兩種方式:

SELECT ... LOCK IN SHARE MODE

SELECT ... FOR UPDATE

這兩種方式在事務(Transaction) 進行當中SELECT 到同一個數據表時,都必須等待其它事務數據被提交(Commit)后才會執行。

而主要的不同在於共享鎖(lock in share mode) 在有一方事務要Update 同一個表單時很容易造成死鎖。

簡單的說,如果SELECT 后面若要UPDATE 同一個表單,最好使用SELECT ... UPDATE。

MySQL SELECT ... FOR UPDATE 的Row Lock 與Table Lock

上面介紹過SELECT ... FOR UPDATE 的用法,不過鎖定(Lock)的數據是判別就得要注意一下了。由於InnoDB 預設是Row-Level Lock,所以只有「明確」的指定主鍵,MySQL 才會執行Row lock (只鎖住被選取的數據) ,否則MySQL 將會執行Table Lock (將整個數據表單給鎖住)。

舉個例子:

假設有個表單products ,里面有id 跟name 二個欄位,id 是主鍵。

例1: (明確指定主鍵,並且有此數據,row lock)

SELECT * FROM products WHERE id='3' FOR UPDATE;

例2: (明確指定主鍵,若查無此數據,無lock)

SELECT * FROM products WHERE id='-1' FOR UPDATE;

例2: (無主鍵,table lock)

SELECT * FROM products WHERE name='Mouse' FOR UPDATE;

例3: (主鍵不明確,table lock)

SELECT * FROM products WHERE id<>'3' FOR UPDATE;

例4: (主鍵不明確,table lock)

SELECT * FROM products WHERE id LIKE '3' FOR UPDATE;

淘寶是如何使用悲觀鎖的

那么后端的數據庫在高並發和超賣下會遇到什么問題呢?主要會有如下3個問題:(主要討論寫的問題,讀的問題通過增加cache可以很容易的解決)

  I: 首先MySQL自身對於高並發的處理性能就會出現問題,一般來說,MySQL的處理性能會隨着並發thread上升而上升,但是到了一定的並發度之后會出現明顯的拐點,之后一路下降,最終甚至會比單thread的性能還要差。

  II: 其次,超賣的根結在於減庫存操作是一個事務操作,需要先select,然后insert,最后update -1。最后這個-1操作是不能出現負數的,但是當多用戶在有庫存的情況下並發操作,出現負數這是無法避免的。

  III:最后,當減庫存和高並發碰到一起的時候,由於操作的庫存數目在同一行,就會出現爭搶InnoDB行鎖的問題,導致出現互相等待甚至死鎖,從而大大降低MySQL的處理性能,最終導致前端頁面出現超時異常。

針對上述問題,如何解決呢? 我們先看眼淘寶的高大上解決方案:

I: 關閉死鎖檢測,提高並發處理性能。

在一個高並發的MySQL服務器上,事務會遞歸檢測死鎖,當超過一定的深度時,性能的下降會變的不可接受。FACEBOOK早就提出了禁止死鎖檢測。

我們做了一個實驗,在禁止死鎖檢測后,TPS得到了極大的提升,如下圖所示:

img

禁止死鎖檢測后,即使死鎖發生,也不會回滾事務,而是全部等待到超時

Mysql 的 innobase_deadlock_check是在innodb里新加的系統變量,用於控制是否打開死鎖檢測

死鎖是指兩個或兩個以上的進程在執行過程中,因爭奪資源而造成的一種互相等待的現象,可以認為如果一個資源被鎖定,它總會在以后某個時間被釋放。而死鎖發生在當多個進程訪問同一數據庫時,其中每個進程擁有的鎖都是其他進程所需的,由此造成每個進程都無法繼續下去。
InnoDB的並發寫操作會觸發死鎖,InnoDB也提供了死鎖檢測機制,可以通過設置innodb_deadlock_detect參數可以打開或關閉死鎖檢測:

innodb_deadlock_detect = on 打開死鎖檢測,數據庫發生死鎖時自動回滾(默認選項)
innodb_deadlock_detect = off 關閉死鎖檢測,發生死鎖的時候,用鎖超時來處理,通過設置鎖超時參數innodb_lock_wait_timeout 可以在超時發生時回滾被阻塞的事務

設置mysql 事務鎖超時時間 innodb_lock_wait_timeout

Mysql數據庫采用InnoDB模式,默認參數:innodb_lock_wait_timeout設置鎖等待的時間是50s,一旦數據庫鎖超過這個時間就會報錯。

mysql> SHOW GLOBAL VARIABLES LIKE 'innodb_lock_wait_timeout';
+--------------------------+-------+
| Variable_name | Value |
+--------------------------+-------+
| innodb_lock_wait_timeout | 50 |
+--------------------------+-------+
1 row in set (0.00 sec)

mysql> SET GLOBAL innodb_lock_wait_timeout=120;
Query OK, 0 rows affected (0.00 sec)

mysql> SHOW GLOBAL VARIABLES LIKE 'innodb_lock_wait_timeout';
+--------------------------+-------+
| Variable_name | Value |
+--------------------------+-------+
| innodb_lock_wait_timeout | 120 |
+--------------------------+-------+
1 row in set (0.00 sec)

mysql>

設置InnoDB Monitors方法

還可以通過設置InnDB Monitors來進一步觀察鎖沖突詳細信息

建立test庫

mysql>create database test;
Query OK, 1 row affected (0.20 sec)
mysql> use test
Reading table information for completion of table and column names
You can turn off this feature to get a quicker startup with -A


Database changed
mysql> create table innodb_monitor(a INT) engine=innodb;
Query OK, 0 rows affected (1.04 sec)


mysql> create table innodb_tablespace_monitor(a INT) engine=innodb;
Query OK, 0 rows affected (0.70 sec)


mysql> create table innodb_lock_monitor(a INT) engine=innodb;
Query OK, 0 rows affected (0.36 sec)


mysql> create table innodb_table_monitor(a INT) engine=innodb;
Query OK, 0 rows affected (0.08 sec)

可以通過show engine innodb status命令查看死鎖信息

mysql> show engine innodb status \G
*************************** 1. row ***************************
  Type: InnoDB
  Name: 
Status: 
=====================================
2018-05-10 09:17:10 0x7f1fbc21a700 INNODB MONITOR OUTPUT
=====================================
Per second averages calculated from the last 46 seconds
-----------------
BACKGROUND THREAD
-----------------
srv_master_thread loops: 53 srv_active, 0 srv_shutdown, 240099 srv_idle
srv_master_thread log flush and writes: 0
----------
SEMAPHORES
----------
OS WAIT ARRAY INFO: reservation count 2007
OS WAIT ARRAY INFO: signal count 1987
RW-shared spins 3878, rounds 5594, OS waits 1735
RW-excl spins 3, rounds 91, OS waits 4
RW-sx spins 1, rounds 30, OS waits 1
Spin rounds per wait: 1.44 RW-shared, 30.33 RW-excl, 30.00 RW-sx
------------
TRANSACTIONS
------------
Trx id counter 78405
Purge done for trx's n:o < 78404 undo n:o < 10 state: running but idle
History list length 21
LIST OF TRANSACTIONS FOR EACH SESSION:
---TRANSACTION 421249967052640, not started
0 lock struct(s), heap size 1136, 0 row lock(s)
--------
FILE I/O
--------
I/O thread 0 state: waiting for completed aio requests (insert buffer thread)
I/O thread 1 state: waiting for completed aio requests (log thread)
I/O thread 2 state: waiting for completed aio requests (read thread)
.............................................................................
.............................................................................
.............................................................................

II:請求排隊

修改源代碼,將排隊提到進入引擎層前,降低引擎層面的並發度。

如果請求一股腦的涌入數據庫,勢必會由於爭搶資源造成性能下降,通過排隊,讓請求從混沌到有序,從而避免數據庫在協調大量請求時過載。

請求排隊:如果請求一股腦的涌入數據庫,勢必會由於爭搶資源造成性能下降,通過排隊,讓請求從混沌到有序,從而避免數據庫在協調大量請求時過載。

III:請求合並(組提交)

請求合並(組提交),降低server和引擎的交互次數,降低IO消耗。

甲買了一個商品,乙也買了同一個商品,與其把甲乙當做當做單獨的請求分別執行一次商品庫存減一的操作,不如把他們合並后統一執行一次商品庫存減二的操作,請求合並的越多,效率提升的就越大。

實操建議

不過結合我們的實際,死鎖監測可以關閉,但是,改mysql源碼這種高大上的解決方案顯然有那么一點不切實際。

說明:本文會以pdf格式持續更新,更多最新尼恩3高pdf筆記,請從下面的鏈接獲取:語雀 或者 碼雲

InnoDB鎖定模式及實現機制

考慮到行級鎖定均由各個存儲引擎自行實現,而且具體實現也各有差別,而InnoDB是目前事務型存儲引擎中使用最為廣泛的存儲引擎,所以這里我們就主要分析一下InnoDB的鎖定特性。
總的來說,InnoDB的鎖定機制和Oracle數據庫有不少相似之處。InnoDB的行級鎖定同樣分為兩種類型,共享鎖和排他鎖,而在鎖定機制的實現過程中為了讓行級鎖定和表級鎖定共存,InnoDB也同樣使用了意向鎖(表級鎖定)的概念,也就有了意向共享鎖和意向排他鎖這兩種。
當一個事務需要給自己需要的某個資源加鎖的時候,如果遇到一個共享鎖正鎖定着自己需要的資源的時候,自己可以再加一個共享鎖,不過不能加排他鎖。但是,如果遇到自己需要鎖定的資源已經被一個排他鎖占有之后,則只能等待該鎖定釋放資源之后自己才能獲取鎖定資源並添加自己的鎖定。而意向鎖的作用就是當一個事務在需要獲取資源鎖定的時候,如果遇到自己需要的資源已經被排他鎖占用的時候,該事務可以需要鎖定行的表上面添加一個合適的意向鎖。如果自己需要一個共享鎖,那么就在表上面添加一個意向共享鎖。而如果自己需要的是某行(或者某些行)上面添加一個排他鎖的話,則先在表上面添加一個意向排他鎖。意向共享鎖可以同時並存多個,但是意向排他鎖同時只能有一個存在。

InnoDB的鎖定模式實際上可以分為四種:共享鎖(S),排他鎖(X),意向共享鎖(IS)和意向排他鎖(IX),我們可以通過以下表格來總結上面這四種所的共存邏輯關系:
img

如果一個事務請求的鎖模式與當前的鎖兼容,InnoDB就將請求的鎖授予該事務;反之,如果兩者不兼容,該事務就要等待鎖釋放。

意向鎖是InnoDB自動加的,不需用戶干預。對於UPDATE、DELETE和INSERT語句,InnoDB會自動給涉及數據集加排他鎖(X);對於普通SELECT語句,InnoDB不會加任何鎖;事務可以通過以下語句顯示給記錄集加共享鎖或排他鎖。

共享鎖(S):SELECT * FROM table_name WHERE ... LOCK IN SHARE MODE
排他鎖(X):SELECT * FROM table_name WHERE ... FOR UPDATE

用SELECT ... IN SHARE MODE獲得共享鎖,主要用在需要數據依存關系時來確認某行記錄是否存在,並確保沒有人對這個記錄進行UPDATE或者DELETE操作。

但是如果當前事務也需要對該記錄進行更新操作,則很有可能造成死鎖,對於鎖定行記錄后需要進行更新操作的應用,應該使用SELECT... FOR UPDATE方式獲得排他鎖。

間隙鎖(Next-Key鎖)

當我們用范圍條件而不是相等條件檢索數據,並請求共享或排他鎖時,InnoDB會給符合條件的已有數據記錄的索引項加鎖;
對於鍵值在條件范圍內但並不存在的記錄,叫做“間隙(GAP)”,InnoDB也會對這個“間隙”加鎖,這種鎖機制就是所謂的間隙鎖(Next-Key鎖)。
例:
假如emp表中只有101條記錄,其empid的值分別是 1,2,...,100,101,下面的SQL:

mysql> select * from emp where empid > 100 for update;

是一個范圍條件的檢索,InnoDB不僅會對符合條件的empid值為101的記錄加鎖,也會對empid大於101(這些記錄並不存在)的“間隙”加鎖。
InnoDB使用間隙鎖的目的:
(1)防止幻讀,以滿足相關隔離級別的要求。對於上面的例子,要是不使用間隙鎖,如果其他事務插入了empid大於100的任何記錄,那么本事務如果再次執行上述語句,就會發生幻讀;
(2)為了滿足其恢復和復制的需要。
很顯然,在使用范圍條件檢索並鎖定記錄時,即使某些不存在的鍵值也會被無辜的鎖定,而造成在鎖定的時候無法插入鎖定鍵值范圍內的任何數據。在某些場景下這可能會對性能造成很大的危害。
除了間隙鎖給InnoDB帶來性能的負面影響之外,通過索引實現鎖定的方式還存在其他幾個較大的性能隱患:
(1)當Query無法利用索引的時候,InnoDB會放棄使用行級別鎖定而改用表級別的鎖定,造成並發性能的降低;
(2)當Query使用的索引並不包含所有過濾條件的時候,數據檢索使用到的索引鍵所只想的數據可能有部分並不屬於該Query的結果集的行列,但是也會被鎖定,因為間隙鎖鎖定的是一個范圍,而不是具體的索引鍵;
(3)當Query在使用索引定位數據的時候,如果使用的索引鍵一樣但訪問的數據行不同的時候(索引只是過濾條件的一部分),一樣會被鎖定。
因此,在實際應用開發中,尤其是並發插入比較多的應用,我們要盡量優化業務邏輯,盡量使用相等條件來訪問更新數據,避免使用范圍條件。
還要特別說明的是,InnoDB除了通過范圍條件加鎖時使用間隙鎖外,如果使用相等條件請求給一個不存在的記錄加鎖,InnoDB也會使用間隙鎖。

並發事務有什么什么問題?應該如何解決?

並發事務可能造成:臟讀、不可重復讀和幻讀等問題 ,這些問題其實都是數據庫讀一致性問題,必須由數據庫提供一定的事務隔離機制來解決,解決方案如下:

  • 加鎖:在讀取數據前,對其加鎖,阻止其他事務對數據進行修改。
  • 提供數據多版本並發控制(MultiVersion Concurrency Control,簡稱 MVCC 或 MCC),也稱為多版本數據庫:不用加任何鎖, 通過一定機制生成一個數據請求時間點的一致性數據快照(Snapshot), 並用這個快照來提供一定級別 (語句級或事務級) 的一致性讀取,從用戶的角度來看,好象是數據庫可以提供同一數據的多個版本。

什么是 MVCC?

MVCC 全稱是多版本並發控制系統,InnoDB 和 Falcon 存儲引擎通過多版本並發控制(MVCC,Multiversion Concurrency Control)機制解決幻讀問題。

MVCC 是怎么工作的?

InnoDB 的 MVCC 是通過在每行記錄后面保存兩個隱藏的列來實現,這兩個列一個保存了行的創建時間,一個保存行的過期時間(刪除時間)。當然存儲的並不是真實的時間而是系統版本號(system version number)。每開始一個新的事務,系統版本號都會自動新增,事務開始時刻的系統版本號會作為事務的版本號,用來查詢到每行記錄的版本號進行比較。

REPEATABLE READ(可重讀)隔離級別下 MVCC 如何工作?

  • SELECT:InnoDB 會根據以下條件檢查每一行記錄:第一,InnoDB 只查找版本早於當前事務版本的數據行,這樣可以確保事務讀取的行要么是在開始事務之前已經存在要么是事務自身插入或者修改過的。第二,行的刪除版本號要么未定義,要么大於當前事務版本號,這樣可以確保事務讀取到的行在事務開始之前未被刪除。
  • INSERT:InnoDB 為新插入的每一行保存當前系統版本號作為行版本號。
  • DELETE:InnoDB 為刪除的每一行保存當前系統版本號作為行刪除標識。
  • UPDATE:InnoDB 為插入的一行新紀錄保存當前系統版本號作為行版本號,同時保存當前系統版本號到原來的行作為刪除標識保存這兩個版本號,使大多數操作都不用加鎖。它不足之處是每行記錄都需要額外的存儲空間,需要做更多的行檢查工作和一些額外的維護工作。

快照讀和當前讀

在mysql中select分為快照讀和當前讀,執行下面的語句

select * from table where id = ?;
執行的是快照讀,讀的是數據庫記錄的快照版本,是不加鎖的。(這種說法在隔離級別為Serializable中不成立)

select加鎖分析

下面六句Sql的區別呢

select * from table where id = ?
select * from table where id < ?
select * from table where id = ? lock in share mode
select * from table where id < ? lock in share mode
select * from table where id = ? for update
select * from table where id < ? for update

在不同的事務隔離級別下,是否加鎖,加的是共享鎖還是排他鎖,是否存在間隙鎖,您能說出來嘛?
要回答這個問題,先問自己三個問題

  • 當前事務隔離級別是什么
  • id列是否存在索引
  • 如果存在索引是聚簇索引還是非聚簇索引呢?

關於mysql的索引,啰嗦一下:

  • innodb一定存在聚簇索引,默認以主鍵作為聚簇索引
  • 有幾個索引,就有幾棵B+樹(不考慮hash索引的情形)
  • 聚簇索引的葉子節點為磁盤上的真實數據。非聚簇索引的葉子節點還是索引,指向聚簇索引B+樹。

鎖類型

  • 共享鎖(S鎖):假設事務T1對數據A加上共享鎖,那么事務T2可以讀數據A,不能修改數據A。
  • 排他鎖(X鎖):假設事務T1對數據A加上共享鎖,那么事務T2不能讀數據A,不能修改數據A。
    我們通過update、delete等語句加上的鎖都是行級別的鎖。只有LOCK TABLE … READ和LOCK TABLE … WRITE才能申請表級別的鎖。
  • 意向共享鎖(IS鎖):一個事務在獲取(任何一行/或者全表)S鎖之前,一定會先在所在的表上加IS鎖。
  • 意向排他鎖(IX鎖):一個事務在獲取(任何一行/或者全表)X鎖之前,一定會先在所在的表上加IX鎖。

意向鎖存在的目的?

這里說一下意向鎖存在的目的。假設事務T1,用X鎖來鎖住了表上的幾條記錄,那么此時表上存在IX鎖,即意向排他鎖。那么此時事務T2要進行LOCK TABLE … WRITE的表級別鎖的請求,可以直接根據意向鎖是否存在而判斷是否有鎖沖突。

  • Record Locks:簡單翻譯為行鎖吧。注意了,該鎖是對索引記錄進行加鎖!鎖是在加索引上而不是行上的。注意了,innodb一定存在聚簇索引,因此行鎖最終都會落到聚簇索引上!
  • Gap Locks:簡單翻譯為間隙鎖,是對索引的間隙加鎖,其目的只有一個,防止其他事物插入數據。在Read Committed隔離級別下,不會使用間隙鎖。

這里我對官網補充一下,隔離級別比Read Committed低的情況下,也不會使用間隙鎖,如隔離級別為Read Uncommited時,也不存在間隙鎖。當隔離級別為Repeatable Read和Serializable時,就會存在間隙鎖。

  • Next-Key Locks:這個理解為Record Lock + 索引前面的Gap Lock。記住了,鎖住的是索引前面的間隙!比如一個索引包含值,10,11,13和20。那么,間隙鎖的范圍如下

(negative infinity, 10]
(10, 11]
(11, 13]
(13, 20]
(20, positive infinity)

索引原理介紹

先來一張帶主鍵的表,如下所示,pId是主鍵

pId name birthday
5 zhangsan 2016-10-02
8 lisi 2015-10-04
11 wangwu 2016-09-02
13 zhaoliu 2015-10-07

畫出該表的結構圖如下
image

如上圖所示,分為上下兩個部分,上半部分是由主鍵形成的B+樹,下半部分就是磁盤上真實的數據!那么,當我們, 執行下面的語句

select * from table where pId='11'

那么,執行過程如下
image
如上圖所示,從根開始,經過3次查找,就可以找到真實數據。如果不使用索引,那就要在磁盤上,進行逐行掃描,直到找到數據位置。顯然,使用索引速度會快。但是在寫入數據的時候,需要維護這顆B+樹的結構,因此寫入性能會下降!

聚簇索引、非聚簇索引

聚簇索引:將數據存儲與索引放到了一塊,索引結構的葉子節點保存了行數據

非聚簇索引:將數據與索引分開存儲,索引結構的葉子節點指向了數據對應的位置

在innodb中,在聚簇索引之上創建的索引稱之為輔助索引,非聚簇索引都是輔助索引,像復合索引、前綴索引、唯一索引。輔助索引葉子節點存儲的不再是行的物理位置,而是主鍵值,輔助索引訪問數據總是需要二次查找

img

  1. InnoDB使用的是聚簇索引,將主鍵組織到一棵B+樹中,而行數據就儲存在葉子節點上,若使用"where id = 14"這樣的條件查找主鍵,則按照B+樹的檢索算法即可查找到對應的葉節點,之后獲得行數據。
  2. 若對Name列進行條件搜索,則需要兩個步驟:第一步在輔助索引B+樹中檢索Name,到達其葉子節點獲取對應的主鍵。第二步使用主鍵在主索引B+樹種再執行一次B+樹檢索操作,最終到達葉子節點即可獲取整行數據。(重點在於通過其他鍵需要建立輔助索引)

聚簇索引具有唯一性,由於聚簇索引是將數據跟索引結構放到一塊,因此一個表僅有一個聚簇索引。

表中行的物理順序和索引中行的物理順序是相同的在創建任何非聚簇索引之前創建聚簇索引,這是因為聚簇索引改變了表中行的物理順序,數據行 按照一定的順序排列,並且自動維護這個順序;

聚簇索引默認是主鍵,如果表中沒有定義主鍵,InnoDB 會選擇一個唯一且非空的索引代替。如果沒有這樣的索引,InnoDB 會隱式定義一個主鍵(類似oracle中的RowId)來作為聚簇索引。如果已經設置了主鍵為聚簇索引又希望再單獨設置聚簇索引,必須先刪除主鍵,然后添加我們想要的聚簇索引,最后恢復設置主鍵即可。

MyISAM使用的是非聚簇索引,非聚簇索引的兩棵B+樹看上去沒什么不同,節點的結構完全一致只是存儲的內容不同而已,主鍵索引B+樹的節點存儲了主鍵,輔助鍵索引B+樹存儲了輔助鍵。表數據存儲在獨立的地方,這兩顆B+樹的葉子節點都使用一個地址指向真正的表數據,對於表數據來說,這兩個鍵沒有任何差別。由於索引樹是獨立的,通過輔助鍵檢索無需訪問主鍵的索引樹

img

使用聚簇索引的優勢:

每次使用輔助索引檢索都要經過兩次B+樹查找,看上去聚簇索引的效率明顯要低於非聚簇索引,這不是多此一舉嗎?聚簇索引的優勢在哪?

1.由於行數據和聚簇索引的葉子節點存儲在一起,同一頁中會有多條行數據,訪問同一數據頁不同行記錄時,已經把頁加載到了Buffer中(緩存器),再次訪問時,會在內存中完成訪問,不必訪問磁盤。這樣主鍵和行數據是一起被載入內存的,找到葉子節點就可以立刻將行數據返回了,如果按照主鍵Id來組織數據,獲得數據更快。

2.輔助索引的葉子節點,存儲主鍵值,而不是數據的存放地址。好處是當行數據放生變化時,索引樹的節點也需要分裂變化;或者是我們需要查找的數據,在上一次IO讀寫的緩存中沒有,需要發生一次新的IO操作時,可以避免對輔助索引的維護工作,只需要維護聚簇索引樹就好了。另一個好處是,因為輔助索引存放的是主鍵值,減少了輔助索引占用的存儲空間大小。

注:我們知道一次io讀寫,可以獲取到16K大小的資源,我們稱之為讀取到的數據區域為Page。而我們的B樹,B+樹的索引結構,葉子節點上存放好多個關鍵字(索引值)和對應的數據,都會在一次IO操作中被讀取到緩存中,所以在訪問同一個頁中的不同記錄時,會在內存里操作,而不用再次進行IO操作了。除非發生了頁的分裂,即要查詢的行數據不在上次IO操作的換村里,才會觸發新的IO操作。

3.因為MyISAM的主索引並非聚簇索引,那么他的數據的物理地址必然是凌亂的,拿到這些物理地址,按照合適的算法進行I/O讀取,於是開始不停的尋道不停的旋轉。聚簇索引則只需一次I/O。(強烈的對比)

4.不過,如果涉及到大數據量的排序、全表掃描、count之類的操作的話,還是MyISAM占優勢些,因為索引所占空間小,這些操作是需要在內存中完成的。

聚簇索引需要注意的地方

當使用主鍵為聚簇索引時,主鍵最好不要使用uuid,因為uuid的值太過離散,不適合排序且可能出線新增加記錄的uuid,會插入在索引樹中間的位置,導致索引樹調整復雜度變大,消耗更多的時間和資源。

建議使用int類型的自增,方便排序並且默認會在索引樹的末尾增加主鍵值,對索引樹的結構影響最小。而且,主鍵值占用的存儲空間越大,輔助索引中保存的主鍵值也會跟着變大,占用存儲空間,也會影響到IO操作讀取到的數據量。

為什么主鍵通常建議使用自增id

聚簇索引的數據的物理存放順序與索引順序是一致的,即:只要索引是相鄰的,那么對應的數據一定也是相鄰地存放在磁盤上的。如果主鍵不是自增id,那么可以想 象,它會干些什么,不斷地調整數據的物理地址、分頁,當然也有其他一些措施來減少這些操作,但卻無法徹底避免。但,如果是自增的,那就簡單了,它只需要一 頁一頁地寫,索引結構相對緊湊,磁盤碎片少,效率也高。

四個隔離級別

我們先回憶一下事務的四個隔離級別,他們由弱到強如下所示:

  • Read Uncommited(RU):讀未提交,一個事務可以讀到另一個事務未提交的數據!
  • Read Committed (RC):讀已提交,一個事務可以讀到另一個事務已提交的數據!
  • Repeatable Read:(RR):可重復讀,加入間隙鎖,一定程度上避免了幻讀的產生!注意了,只是一定程度上,並沒有完全避免!我會在下一篇文章說明!另外就是記住從該級別才開始加入間隙鎖(這句話記下來,后面有用到)!
  • Serializable:串行化,該級別下讀寫串行化,且所有的select語句后都自動加上lock in share mode,即使用了共享鎖。因此在該隔離級別下,使用的是當前讀,而不是快照讀。

select 分析的表數據

為了便於說明,我來個例子,假設有表數據如下,pId為主鍵索引

pId(int) name(varchar) num(int)
1 aaa 100
2 bbb 200
7 ccc 200

隔離級別:RC/RU ,條件列: 非索引

(1)select * from table where num = 200
不加任何鎖,是快照讀。
(2)select * from table where num > 200
不加任何鎖,是快照讀。
(3)select * from table where num = 200 lock in share mode
當num = 200,有兩條記錄。這兩條記錄對應的pId=2,7,因此在pId=2,7的聚簇索引上加行級S鎖,采用當前讀。
(4)select * from table where num > 200 lock in share mode
當num > 200,有一條記錄。這條記錄對應的pId=3,因此在pId=3的聚簇索引上加上行級S鎖,采用當前讀。
(5)select * from table where num = 200 for update
當num = 200,有兩條記錄。這兩條記錄對應的pId=2,7,因此在pId=2,7的聚簇索引上加行級X鎖,采用當前讀。
(6)select * from table where num > 200 for update
當num > 200,有一條記錄。這條記錄對應的pId=3,因此在pId=3的聚簇索引上加上行級X鎖,采用當前讀。

隔離級別:RC/RU ,條件列: 聚簇索引

大家應該知道pId是主鍵列,因此pId用的就是聚簇索引。此情況其實和RC/RU+條件列非索引情況是類似的。
(1)select * from table where pId = 2
不加任何鎖,是快照讀。
(2)select * from table where pId > 2
不加任何鎖,是快照讀。
(3)select * from table where pId = 2 lock in share mode
在pId=2的聚簇索引上,加S鎖,為當前讀。
(4)select * from table where pId > 2 lock in share mode
在pId=3,7的聚簇索引上,加S鎖,為當前讀。
(5)select * from table where pId = 2 for update
在pId=2的聚簇索引上,加X鎖,為當前讀。
(6)select * from table where pId > 2 for update
在pId=3,7的聚簇索引上,加X鎖,為當前讀。

為什么條件列加不加索引,加鎖情況是一樣的?

其實是不一樣的。在RC/RU隔離級別中,MySQL做了優化。在條件列沒有索引的情況下,盡管通過聚簇索引來掃描全表,進行全表加鎖。但是,MySQL Server層會進行過濾並把不符合條件的鎖當即釋放掉,因此你看起來最終結果是一樣的。但是RC/RU+條件列非索引比本例多了一個釋放不符合條件的鎖的過程!

隔離級別:RC/RU ,條件列: 非聚簇索引

在num列上建上非唯一索引。此時有一棵聚簇索引(主鍵索引,pId)形成的B+索引樹,其葉子節點為硬盤上的真實數據。以及另一棵非聚簇索引(非唯一索引,num)形成的B+索引樹,其葉子節點依然為索引節點,保存了num列的字段值,和對應的聚簇索引。

(1)select * from table where num = 200
不加任何鎖,是快照讀。
(2)select * from table where num > 200
不加任何鎖,是快照讀。
(3)select * from table where num = 200 lock in share mode
當num = 200,由於num列上有索引,因此先在 num = 200的兩條索引記錄上加行級S鎖。接着,去聚簇索引樹上查詢,這兩條記錄對應的pId=2,7,因此在pId=2,7的聚簇索引上加行級S鎖,采用當前讀。
(4)select * from table where num > 200 lock in share mode
當num > 200,由於num列上有索引,因此先在符合條件的 num = 300的一條索引記錄上加行級S鎖。接着,去聚簇索引樹上查詢,這條記錄對應的pId=3,因此在pId=3的聚簇索引上加行級S鎖,采用當前讀。
(5)select * from table where num = 200 for update
當num = 200,由於num列上有索引,因此先在 num = 200的兩條索引記錄上加行級X鎖。接着,去聚簇索引樹上查詢,這兩條記錄對應的pId=2,7,因此在pId=2,7的聚簇索引上加行級X鎖,采用當前讀。
(6)select * from table where num > 200 for update
當num > 200,由於num列上有索引,因此先在符合條件的 num = 300的一條索引記錄上加行級X鎖。接着,去聚簇索引樹上查詢,這條記錄對應的pId=3,因此在pId=3的聚簇索引上加行級X鎖,采用當前讀。

隔離級別:RR/Serializable,條件列: 非索引

RR級別需要多考慮的就是gap lock,他的加鎖特征在於,無論你怎么查都是鎖全表。接下來分析開始
(1)select * from table where num = 200
在RR級別下,不加任何鎖,是快照讀。
在Serializable級別下,在pId = 1,2,3,7(全表所有記錄)的聚簇索引上加S鎖。並且在
聚簇索引的所有間隙(-∞,1)(1,2)(2,3)(3,7)(7,+∞)加gap lock
(2)select * from table where num > 200
在RR級別下,不加任何鎖,是快照讀。
在Serializable級別下,在pId = 1,2,3,7(全表所有記錄)的聚簇索引上加S鎖。並且在
聚簇索引的所有間隙(-∞,1)(1,2)(2,3)(3,7)(7,+∞)加gap lock
(3)select * from table where num = 200 lock in share mode
在pId = 1,2,3,7(全表所有記錄)的聚簇索引上加S鎖。並且在
聚簇索引的所有間隙(-∞,1)(1,2)(2,3)(3,7)(7,+∞)加gap lock
(4)select * from table where num > 200 lock in share mode
在pId = 1,2,3,7(全表所有記錄)的聚簇索引上加S鎖。並且在
聚簇索引的所有間隙(-∞,1)(1,2)(2,3)(3,7)(7,+∞)加gap lock
(5)select * from table where num = 200 for update
在pId = 1,2,3,7(全表所有記錄)的聚簇索引上加X鎖。並且在
聚簇索引的所有間隙(-∞,1)(1,2)(2,3)(3,7)(7,+∞)加gap lock
(6)select * from table where num > 200 for update
在pId = 1,2,3,7(全表所有記錄)的聚簇索引上加X鎖。並且在
聚簇索引的所有間隙(-∞,1)(1,2)(2,3)(3,7)(7,+∞)加gap lock

隔離級別:RR/Serializable,條件列: 聚簇索引

大家應該知道pId是主鍵列,因此pId用的就是聚簇索引。該情況的加鎖特征在於,如果where后的條件為精確查詢(=的情況),那么只存在record lock。如果where后的條件為范圍查詢(>或<的情況),那么存在的是record lock+gap lock。
(1)select * from table where pId = 2
在RR級別下,不加任何鎖,是快照讀。
在Serializable級別下,是當前讀,在pId=2的聚簇索引上加S鎖,不存在gap lock。
(2)select * from table where pId > 2
在RR級別下,不加任何鎖,是快照讀。
在Serializable級別下,是當前讀,在pId=3,7的聚簇索引上加S鎖。在(2,3)(3,7)(7,+∞)加上gap lock
(3)select * from table where pId = 2 lock in share mode
是當前讀,在pId=2的聚簇索引上加S鎖,不存在gap lock。
(4)select * from table where pId > 2 lock in share mode
是當前讀,在pId=3,7的聚簇索引上加S鎖。在(2,3)(3,7)(7,+∞)加上gap lock
(5)select * from table where pId = 2 for update
是當前讀,在pId=2的聚簇索引上加X鎖。
(6)select * from table where pId > 2 for update
在pId=3,7的聚簇索引上加X鎖。在(2,3)(3,7)(7,+∞)加上gap lock
(7)select * from table where pId = 6 [lock in share mode|for update]
注意了,pId=6是不存在的列,這種情況會在(3,7)上加gap lock。
(8)select * from table where pId > 18 [lock in share mode|for update]
注意了,pId>18,查詢結果是空的。在這種情況下,是在(7,+∞)上加gap lock。

隔離級別:RR/Serializable,條件列: 非聚簇索引

這里非聚簇索引,需要區分是否為唯一索引。因為如果是非唯一索引,間隙鎖的加鎖方式是有區別的。
先說一下,唯一索引的情況。如果是唯一索引,情況和RR/Serializable+條件列是聚簇索引類似,唯一有區別的是:這個時候有兩棵索引樹,加鎖是加在對應的非聚簇索引樹和聚簇索引樹上!大家可以自行推敲!
下面說一下,非聚簇索引是非唯一索引的情況,他和唯一索引的區別就是通過索引進行精確查詢以后,不僅存在record lock,還存在gap lock。而通過唯一索引進行精確查詢后,只存在record lock,不存在gap lock。老規矩在num列建立非唯一索引
(1)select * from table where num = 200
在RR級別下,不加任何鎖,是快照讀。
在Serializable級別下,是當前讀,在pId=2,7的聚簇索引上加S鎖,在num=200的非聚集索引上加S鎖,在(100,200)(200,300)加上gap lock。
(2)select * from table where num > 200
在RR級別下,不加任何鎖,是快照讀。
在Serializable級別下,是當前讀,在pId=3的聚簇索引上加S鎖,在num=300的非聚集索引上加S鎖。在(200,300)(300,+∞)加上gap lock
(3)select * from table where num = 200 lock in share mode
是當前讀,在pId=2,7的聚簇索引上加S鎖,在num=200的非聚集索引上加S鎖,在(100,200)(200,300)加上gap lock。
(4)select * from table where num > 200 lock in share mode
是當前讀,在pId=3的聚簇索引上加S鎖,在num=300的非聚集索引上加S鎖。在(200,300)(300,+∞)加上gap lock。
(5)select * from table where num = 200 for update
是當前讀,在pId=2,7的聚簇索引上加S鎖,在num=200的非聚集索引上加X鎖,在(100,200)(200,300)加上gap lock。
(6)select * from table where num > 200 for update
是當前讀,在pId=3的聚簇索引上加S鎖,在num=300的非聚集索引上加X鎖。在(200,300)(300,+∞)加上gap lock
(7)select * from table where num = 250 [lock in share mode|for update]
注意了,num=250是不存在的列,這種情況會在(200,300)上加gap lock。
(8)select * from table where num > 400 [lock in share mode|for update]
注意了,pId>400,查詢結果是空的。在這種情況下,是在(400,+∞)上加gap lock。

死鎖

MyISAM表鎖是deadlock free的,這是因為MyISAM總是一次獲得所需的全部鎖,要么全部滿足,要么等待,因此不會出現死鎖。但在InnoDB中,除單個SQL組成的事務外,鎖是逐步獲得的,當兩個事務都需要獲得對方持有的排他鎖才能繼續完成事務,這種循環鎖等待就是典型的死鎖。

如何避免死鎖?

  • 為了在單個 InnoDB 表上執行多個並發寫入操作時避免死鎖,可以在事務開始時通過為預期要修改的每個元祖(行)使用 SELECT … FOR UPDATE 語句來獲取必要的鎖,即使這些行的更改語句是在之后才執行的。

  • 在事務中,如果要更新記錄,應該直接申請足夠級別的鎖,即排他鎖,而不應先申請共享鎖、更新時再申請排他鎖,因為這時候當用戶再申請排他鎖時,其他事務可能又已經獲得了相同記錄的共享鎖,從而造成鎖沖突,甚至死鎖

  • 如果事務需要修改或鎖定多個表,則應在每個事務中以相同的順序使用加鎖語句。在應用中,如果不同的程序會並發存取多個表,應盡量約定以相同的順序來訪問表,這樣可以大大降低產生死鎖的機會

  • 在程序以批量方式處理數據的時候,如果事先對數據排序,保證每個線程按固定的順序來處理記錄,也可以大大降低出現死鎖的可能。

  • 在REPEATABLE-READ隔離級別下,如果兩個線程同時對相同條件記錄用SELECT...FOR UPDATE加排他鎖,在沒有符合該條件記錄情況下,兩個線程都會加鎖成功。程序發現記錄尚不存在,就試圖插入一條新記錄,如果兩個線程都這么做,就會出現死鎖。這種情況下,將隔離級別改成READ COMMITTED,就可避免問題。

  • 當隔離級別為READ COMMITTED時,如果兩個線程都先執行SELECT...FOR UPDATE,判斷是否存在符合條件的記錄,如果沒有,就插入記錄。此時,只有一個線程能插入成功,另一個線程會出現鎖等待,當第1個線程提交后,第2個線程會因主鍵重出錯,但雖然這個線程出錯了,卻會獲得一個排他鎖。這時如果有第3個線程又來申請排他鎖,也會出現死鎖。對於這種情況,可以直接做插入操作,然后再捕獲主鍵重異常,或者在遇到主鍵重錯誤時,總是執行ROLLBACK釋放獲得的排他鎖

InnoDB 默認是如何對待死鎖的?

InnoDB 默認是使用設置死鎖時間來讓死鎖超時的策略,默認 innodblockwait_timeout 設置的時長是 50s。

如何開啟死鎖檢測?

設置 innodbdeadlockdetect 設置為 on 可以主動檢測死鎖,在 Innodb 中這個值默認就是 on 開啟的狀態。

說明:本文會以pdf格式持續更新,更多最新尼恩3高pdf筆記,請從下面的鏈接獲取:語雀 或者 碼雲

解決方案2:樂觀鎖

樂觀鎖

樂觀鎖並不是真實存在的鎖,而是在更新的時候判斷此時的庫存是否是之前查詢出的庫存,如果相同,表示沒人修改,可以更新庫存,否則表示別人搶過資源,不再執行庫存更新。類似如下操作:

update tb_sku set stock=2 where id=1 and stock=7;

SKU.objects.filter(id=1, stock=7).update(stock=2)

使用樂觀鎖需修改數據庫的事務隔離級別:

使用樂觀鎖的時候,如果一個事務修改了庫存並提交了事務,那其他的事務應該可以讀取到修改后的數據值,所以不能使用可重復讀的隔離級別,應該修改為讀取已提交(Read committed)。
修改方式:
在這里插入圖片描述
在這里插入圖片描述

MySQL事務隔離級別

事務隔離級別 臟讀 不可重復讀 幻讀
讀未提交(read-uncommitted)
不可重復讀(read-committed)
可重復讀(repeatable-read)
串行化(serializable)

mysql默認的事務隔離級別為repeatable-read

img

並發事務會帶來哪些問題?

  1、臟讀:事務A讀取了事務B更新的數據,然后B回滾操作,那么A讀取到的數據是臟數據

  2、不可重復讀:事務 A 多次讀取同一數據,事務 B 在事務A多次讀取的過程中,對數據作了更新並提交,導致事務A多次讀取同一數據時,結果 不一致。

  3、幻讀:系統管理員A將數據庫中所有學生的成績從具體分數改為ABCDE等級,但是系統管理員B就在這個時候插入了一條具體分數的記錄,當系統管理員A改結束后發現還有一條記錄沒有改過來,就好像發生了幻覺一樣,這就叫幻讀。

  小結:不可重復讀的和幻讀很容易混淆,不可重復讀側重於修改,幻讀側重於新增或刪除。解決不可重復讀的問題只需鎖住滿足條件的行,解決幻讀需要鎖表

三、MySQL事務隔離級別

img

Mysql默認的事務隔離級別為repeatable-read

img

四、用例子說明各個隔離級別的情況

1、讀未提交:

(1)打開一個客戶端A,並設置當前事務模式為read uncommitted(未提交讀),查詢表account的初始值:

img

 (2)在客戶端A的事務提交之前,打開另一個客戶端B,更新表account:

img

 (3)這時,雖然客戶端B的事務還沒提交,但是客戶端A就可以查詢到B已經更新的數據:

img

(4)一旦客戶端B的事務因為某種原因回滾,所有的操作都將會被撤銷,那客戶端A查詢到的數據其實就是臟數據:

img

(5)在客戶端A執行更新語句update account set balance = balance - 50 where id =1,lilei的balance沒有變成350,居然是400,是不是很奇怪,數據不一致啊,如果你這么想就太天真 了,在應用程序中,我們會用400-50=350,並不知道其他會話回滾了,要想解決這個問題可以采用讀已提交的隔離級別

img

 2、讀已提交

(1)打開一個客戶端A,並設置當前事務模式為read committed(提交讀),查詢表account的所有記錄:

img

 (2)在客戶端A的事務提交之前,打開另一個客戶端B,更新表account:

img

(3)這時,客戶端B的事務還沒提交,客戶端A不能查詢到B已經更新的數據,解決了臟讀問題:

img

(4)客戶端B的事務提交

img

(5)客戶端A執行與上一步相同的查詢,結果 與上一步不一致,即產生了不可重復讀的問題

img

3、可重復讀

(1)打開一個客戶端A,並設置當前事務模式為repeatable read,查詢表account的所有記錄

img

(2)在客戶端A的事務提交之前,打開另一個客戶端B,更新表account並提交

img

(3)在客戶端A查詢表account的所有記錄,與步驟(1)查詢結果一致,沒有出現不可重復讀的問題

img

(4)在客戶端A,接着執行update balance = balance - 50 where id = 1,balance沒有變成400-50=350,lilei的balance值用的是步驟(2)中的350來算的,所以是300,數據的一致性倒是沒有被破壞。可重復讀的隔離級別下使用了MVCC機制,select操作不會更新版本號,是快照讀(歷史版本);insert、update和delete會更新版本號,是當前讀(當前版本)。

img

(5)重新打開客戶端B,插入一條新數據后提交

img

(6)在客戶端A查詢表account的所有記錄,沒有 查出 新增數據,所以沒有出現幻讀

img

 4.串行化

(1)打開一個客戶端A,並設置當前事務模式為serializable,查詢表account的初始值:

mysql> set session transaction isolation level serializable;
Query OK, 0 rows affected (0.00 sec)

mysql> start transaction;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from account;
+------+--------+---------+
| id   | name   | balance |
+------+--------+---------+
|    1 | lilei  |   10000 |
|    2 | hanmei |   10000 |
|    3 | lucy   |   10000 |
|    4 | lily   |   10000 |
+------+--------+---------+
4 rows in set (0.00 sec)

(2)打開一個客戶端B,並設置當前事務模式為serializable,插入一條記錄報錯,表被鎖了插入失敗,mysql中事務隔離級別為serializable時會鎖表,因此不會出現幻讀的情況,這種隔離級別並發性極低,開發中很少會用到。

mysql> set session transaction isolation level serializable;
Query OK, 0 rows affected (0.00 sec)

mysql> start transaction;
Query OK, 0 rows affected (0.00 sec)

mysql> insert into account values(5,'tom',0);
ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction

補充:

  1、事務隔離級別為讀提交時,寫數據只會鎖住相應的行

2、事務隔離級別為可重復讀時,如果檢索條件有索引(包括主鍵索引)的時候,默認加鎖方式是next-key 鎖;如果檢索條件沒有索引,更新數據時會鎖住整張表。一個間隙被事務加了鎖,其他事務是不能在這個間隙插入記錄的,這樣可以防止幻讀。

3、事務隔離級別為串行化時,讀寫數據都會鎖住整張表

4、隔離級別越高,越能保證數據的完整性和一致性,但是對並發性能的影響也越大。

樂觀鎖在高並發場景下的問題

樂觀鎖在高並發場景下的問題,是嚴重的空自旋

具體可以參考 入大廠必備的基礎書籍: 《Java高並發核心編程 卷2》

說明:本文會以pdf格式持續更新,更多最新尼恩3高pdf筆記,請從下面的鏈接獲取:語雀 或者 碼雲

超賣解決方案3:分階段排隊下單方案

分階段排隊下單方案的思想來源

最優的解決方案,其實思想來自於JUC的原理

JUC是如何提高性能的,引入隊列

原始的CLH隊列

用於減少線程爭用的最簡單的隊列,CLH隊列,具體可以參考 入大廠必備的基礎書籍: 《Java高並發核心編程 卷2》

在這里插入圖片描述

JUC的AQS內部隊列

AQS內部隊列是JUC高性能的基礎,AQS隊列,具體可以參考 入大廠必備的基礎書籍: 《Java高並發核心編程 卷2》

在這里插入圖片描述

分階段排隊下單方案

將提交操作變成兩段式:

  • 第一階段申請,申請預減減庫,申請成功之后,進入消息隊列;

  • 第二階段確認,從消息隊列消費申請令牌,然后完成下單操作。 查庫存 -> 創建訂單 -> 扣減庫存。通過分布式鎖保障解決多個provider實例並發下單產生的超賣問題。

申請階段:

將存庫從MySQL前移到Redis中,所有的預減庫存的操作放到內存中,由於Redis中不存在鎖故不會出現互相等待,並且由於Redis的寫性能和讀性能都遠高於MySQL,這就解決了高並發下的性能問題。

確認階段:

然后通過隊列等異步手段,將變化的數據異步寫入到DB中。

引入隊列,然后數據通過隊列排序,按照次序更新到DB中,完全串行處理。當達到庫存閥值的時候就不在消費隊列,並關閉購買功能。這就解決了超賣問題。

分階段排隊架構圖

圖解削峰限流技術RabbitMq 消息隊列解決高並發高並發下削峰限流

基於分段的排隊執行方案的性能提升

一個高性能秒殺的場景:

假設一個商品1分鍾6000訂單,每秒的 600個下單操作。

在排隊階段,每秒的 600個預減庫存的操作,對於 Redis 來說,沒有任何壓力。甚至每秒的 6000個預減庫存的操作,對於 Redis 來說,也是壓力不大。

但是在下單階段,就不一樣了。假設加鎖之后,釋放鎖之前,查庫存 -> 創建訂單 -> 扣減庫存,經過優化,每個IO操作100ms,大概200毫秒,一秒鍾5個訂單。600個訂單需要120s,2分鍾才能徹底消化。

如何提升下單階段的性能呢?

在這里插入圖片描述

可以使用Redis 分段鎖。

為了達到每秒600個訂單,可以將鎖分成 600 /5 =120 個段,每個段負責5個訂單,600個訂單,在第二個階段1秒鍾下單完成。

在這里插入圖片描述

有關Redis分段鎖的詳細知識,請閱讀下面的博文:

Redis分布式鎖 (圖解-秒懂-史上最全)

基於分段的排隊執行方案優點:

解決超賣問題,庫存讀寫都在內存中,故同時解決性能問題。

基於分段的排隊執行方案缺點:

  • 數據不一致的問題:

由於異步寫入DB,可能存在數據不一致,存在某一時刻DB和Redis中數據不一致的風險。

  • 可能存在少買

可能存在少買,也就是如果拿到號的人不真正下訂單,可能庫存減為0,但是訂單數並沒有達到庫存閥值。

說明:本文會以pdf格式持續更新,更多最新尼恩3高pdf筆記,請從下面的鏈接獲取:語雀 或者 碼雲

參考文獻:

https://www.cnblogs.com/rjzheng/p/9950951.html
https://blog.csdn.net/caoxiaohong1005/article/details/78292457
https://www.cnblogs.com/wyaokai/p/10921323.html


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM