問題背景
一個(gè)砍價(jià)助力功能,用戶下完單后可以邀請好友砍價(jià)。
問題描述
在好友砍價(jià)過程中記錄好友砍價(jià)人數(shù),是先從數(shù)據(jù)庫查詢已砍人數(shù)然后加一再更新數(shù)據(jù)庫的方式實(shí)現(xiàn)的崭歧。如果有并發(fā)砍價(jià)情況出現(xiàn)隅很,后一個(gè)事務(wù)更新的已砍人數(shù)便會覆蓋前一個(gè)事務(wù)更新的已砍人數(shù)(更新丟失問題)撞牢。
問題分析
在mysql默認(rèn)的事務(wù)隔離級別(可重復(fù)讀)中事務(wù)查詢數(shù)據(jù)是不加鎖的,只有更新數(shù)據(jù)才加排他鎖叔营。并發(fā)情況下屋彪,倘若出現(xiàn)兩個(gè)事務(wù)都查詢了數(shù)據(jù)后,其中一個(gè)事務(wù)才去獲取這條數(shù)據(jù)的排它鎖去更新數(shù)據(jù)的情況绒尊,就會出現(xiàn)更新丟失畜挥。如下:
事務(wù)100和事務(wù)101同時(shí)開啟并同時(shí)查詢了同一條數(shù)據(jù),然后再這條數(shù)據(jù)的某個(gè)值上+1再去獲取這條數(shù)據(jù)的X鎖去更新數(shù)據(jù)婴谱,其中事務(wù)100搶占到了鎖蟹但,事務(wù)100先將數(shù)據(jù)的這個(gè)值更改,然后事務(wù)100提交后釋放了這條數(shù)據(jù)的鎖谭羔,此時(shí)事務(wù)101經(jīng)過等待獲取了這條數(shù)據(jù)的X鎖华糖,然后再去更新這條數(shù)據(jù)的那個(gè)值,更新完成后我們發(fā)現(xiàn)瘟裸,事務(wù)101的更新覆蓋了事務(wù)100的更新客叉,這就是更新丟失問題。
我本以為如果使用事務(wù)的話话告,每個(gè)事務(wù)都會一一獲取事務(wù)中相關(guān)記錄所有的鎖后才開始執(zhí)行事務(wù)的兼搏,這樣如果并發(fā)請求的話第一個(gè)事務(wù)優(yōu)先獲取要更新記錄的x鎖,第二個(gè)事務(wù)雖然先查詢但也無法獲取s鎖沙郭。顯然不是這樣的佛呻,(倘若是這樣的,豈不是變成了順序執(zhí)行)病线。這說明我對數(shù)據(jù)庫鎖在事務(wù)中的運(yùn)用理解還不深刻件相。以mysql為例再扭,在可重復(fù)讀的隔離級別下,一個(gè)事務(wù)中的查詢并不會獲取該記錄的共享鎖(S鎖),而是采用mvcc實(shí)現(xiàn)了類似樂觀鎖的機(jī)制夜矗。所以還是會出現(xiàn)更新丟失泛范。
解決方法
雖然用上了事務(wù),但是更新丟失的問題依然還沒解決(除非數(shù)據(jù)庫事務(wù)隔離級別設(shè)置成序列化紊撕,但是嚴(yán)重影響性能罢荡,相當(dāng)于順序執(zhí)行,沒法并發(fā)对扶,顯然這樣是不行的)区赵。所以我們這種情況經(jīng)常用前兩種解決方案
- 悲觀鎖實(shí)現(xiàn) 查詢時(shí)加鎖(select .....for update),這種方案使用悲觀鎖,預(yù)設(shè)每次更新都會有別的事務(wù)在更新此數(shù)據(jù)浪南,查詢時(shí)就上鎖笼才,這種方案固然能解決問題且容易實(shí)現(xiàn)。但是其實(shí)大部分情況下都不會發(fā)生并發(fā)络凿,所以這種方法無疑浪費(fèi)了資源骡送。但是在高爭用條件下還是值得推薦的。
- 樂觀鎖實(shí)現(xiàn): 數(shù)據(jù)記錄加版本標(biāo)記絮记,每次更新版本加1摔踱,更新時(shí)where條件后跟上查詢出的版本號,這樣如果此時(shí)版本號被別的事務(wù)更新過怨愤,那么更新條數(shù)就會返回0派敷,根據(jù)這個(gè)更新條數(shù)來判斷后續(xù)操作。這樣做增加了程序的復(fù)雜性撰洗,但是提高了并發(fā)效率篮愉,在資源低爭用條件下下值得推薦。
- 隔離級別實(shí)現(xiàn): 此外很多網(wǎng)上的文章介紹可以將數(shù)據(jù)庫事務(wù)隔離級別設(shè)置為REPEATABLE-READ來避免更新丟失差导。但是這種解決方案大多數(shù)數(shù)據(jù)庫下是不生效的试躏,至少在mysql里是不生效的,標(biāo)準(zhǔn)的REPEATABLE-READ只保證在同一事務(wù)中的可重復(fù)讀柿汛,并沒有保證更新不丟失冗酿。或許有些許數(shù)據(jù)庫或某些版本的數(shù)據(jù)庫實(shí)現(xiàn)可重復(fù)讀的方案是在select 上也加共享鎖(S鎖)直到事務(wù)結(jié)束釋放络断,這樣確實(shí)可以避免更新丟失裁替。但是mysql的實(shí)現(xiàn)方案是mvvc并不能避免更新丟失。mysql可以將事務(wù)隔離級別設(shè)置為Serializable可避免更新丟失貌笨,但是這樣數(shù)據(jù)庫的效率變得很低弱判。
引申 事務(wù)隔離性的實(shí)現(xiàn)原理
本節(jié)主要引申討論事務(wù)的隔離性實(shí)現(xiàn),重點(diǎn)討論鎖在事務(wù)中的運(yùn)用锥惋。首先有幾個(gè)基本概念需要先理解下:
數(shù)據(jù)庫的鎖一般有兩種維度的定義昌腰,按獨(dú)享和共享分為以下類型
- Exclusive Locks(排它鎖/X鎖)顧名思義這種鎖加在數(shù)據(jù)上开伏,別的事務(wù)不能再次加別的任何鎖了。
- Shared Locks(共享鎖/S鎖) 此種鎖可以加在另外的已有s鎖的數(shù)據(jù)上
按鎖的粒度分為以下鎖:
- table locks(表鎖) 此鎖是鎖在表級別的使用這種鎖會極大的降低數(shù)據(jù)庫的并發(fā)量遭商。
- Record Locks(行鎖) 此鎖是鎖在索引行上的固灵,注意是索引行。
- Gap Locks(間隙鎖) 此種鎖是加載一個(gè)范圍上的 如 where id>1 and id <10 for update
- Next-Key Locks(間隙鎖) 另外一種間隙鎖劫流,是Gap 鎖和行鎖的結(jié)合巫玻,where id>=1 and id<=10 for update就是使用此種間隙鎖。
- 讀未提交 READ UNCOMMITTED隔離級別下, 讀不會加任何鎖祠汇。而寫會加X鎖仍秤,直到事務(wù)結(jié)束釋放X鎖。既然寫會加排他鎖可很,那為何別的事務(wù)依然可以讀呢诗力,因?yàn)檫@個(gè)隔離級別下,讀不加任何鎖我抠。
- 讀已提交 READ COMMITTED顧名思義苇本,事務(wù)之間可以讀取彼此已提交的數(shù)據(jù)。我們當(dāng)然可以在讀上加s鎖(讀完就可以釋放s鎖屿良,不必等到事務(wù)結(jié)束)圈澈,寫加X鎖直到事務(wù)結(jié)束來達(dá)到讀已提交的隔離級別惫周,但是尘惧,如果鎖沖突很頻繁的情況下讀寫不能同時(shí)進(jìn)行會降低數(shù)據(jù)庫的并發(fā)度。mysql采用mvcc的方案來實(shí)現(xiàn)這種隔離級別递递。mvcc機(jī)制可以實(shí)現(xiàn)讀寫并發(fā)執(zhí)行喷橙,稍后重點(diǎn)再說。
- 可重復(fù)讀 REPEATABLE READ在這種隔離級別下登舞,同一事務(wù)中讀同一條數(shù)據(jù)的結(jié)果是相同的贰逾。我們當(dāng)然也可以使用鎖來實(shí)現(xiàn)這個(gè)特性。select時(shí)加s鎖菠秒,并到事務(wù)結(jié)束釋放疙剑,寫加X鎖直到事務(wù)結(jié)束釋放。這樣就可以實(shí)現(xiàn)可重復(fù)讀了践叠。但是mysql還是使用了mvcc來實(shí)現(xiàn)的言缤,原因和上一個(gè)一樣,鎖沖突較多的情況下并發(fā)性的問題禁灼。
- 序列化 SERIALISABLE此種隔離級別消除了幻讀管挟。但是代價(jià)也是最大的。幾乎是串行執(zhí)行事務(wù)了弄捕,同時(shí)只能有一個(gè)事務(wù)在執(zhí)行僻孝。這種方案對于互聯(lián)網(wǎng)高并發(fā)的業(yè)務(wù)來說幾乎不可接受导帝。但是mysql在 REPEATABLE READ級別已經(jīng)解決了部分情況的幻讀問題(innodb引擎),我們稍后再看穿铆。
再引申 mvcc多版本并發(fā)控制
mvcc是為了提高事務(wù)的并發(fā)性能而提供的一種解決方案您单,使用mvcc可以在讀的時(shí)候不加鎖就能實(shí)現(xiàn)可重復(fù)讀。mvcc的實(shí)現(xiàn)需要借助兩個(gè)隱藏列一個(gè)是trx_id表示的是這行的數(shù)據(jù)版本荞雏,實(shí)際這個(gè)列存儲的是更新這行數(shù)據(jù)的事務(wù)id睹限,另外一個(gè)rollback_pointer是指向上版本數(shù)據(jù)記錄的指針,mvcc下數(shù)據(jù)被更新時(shí)會copy一份老數(shù)據(jù)到undo日志讯檐,然后在修改這行數(shù)據(jù)并在rollback_pointer里加個(gè)指向老版本數(shù)據(jù)的指針羡疗。
假如有條數(shù)據(jù)初始化如下:
id | name | trx_id | rollback_pointer |
---|---|---|---|
1 | 小明 | 90 |
同時(shí)開啟兩個(gè)事務(wù)100 101
1 .事務(wù)100執(zhí)行了查詢語句
select * from user where id =1
首先這個(gè)事務(wù)會建立一個(gè)readview,這個(gè)readview包含當(dāng)前活動的事務(wù)id [100,101]
然后判斷數(shù)據(jù)行中trx_id與readview中事務(wù)id比較
- 如果當(dāng)前數(shù)據(jù)行中trx_id比readview中所有事務(wù)id都小則說明更新這條數(shù)據(jù)的事務(wù)早已提交,隨意當(dāng)前數(shù)據(jù)對查詢事務(wù)可見别洪。
- 如果trx_id大于readview中最小事務(wù)id 且小于readview中最大事務(wù)id則但不包含在readview中叨恨,則說明更新這行數(shù)據(jù)的事務(wù)不活躍已提交,當(dāng)前數(shù)據(jù)對查詢數(shù)據(jù)可見挖垛。
- 如果trx_id大于所有readview中事務(wù)id痒钝,則說明更新數(shù)據(jù)的事務(wù)開啟時(shí)間在當(dāng)前事務(wù)后,所以此時(shí)數(shù)據(jù)對當(dāng)前事務(wù)不可見
- 如果trx_id在readview中且trx_id不等于當(dāng)前事務(wù)id痢毒,這說明更新這條數(shù)據(jù)的事務(wù)還未提交送矩,則此數(shù)據(jù)不可見。
上述如果數(shù)據(jù)對當(dāng)事務(wù)不可見的情況哪替,會根據(jù)roollback_pointer找到上版本的數(shù)據(jù)栋荸,再次做上述判斷,以此類推凭舶,知道找到可見版本的數(shù)據(jù)為止晌块。
那再來看事務(wù)100查的數(shù)據(jù)trx_id此時(shí)為90,那對事務(wù)100的查詢是可見的帅霜。查詢出的數(shù)據(jù)就是當(dāng)前數(shù)據(jù)匆背。
2.事務(wù)101執(zhí)行了sql
update user set name='小白' where id = 1
那么此時(shí)數(shù)據(jù)便成了這樣:
id | name | trx_id | rollback_pointer |
---|---|---|---|
1 | 小白 | 101 | 指向undo舊版本數(shù)據(jù)的指針 |
undo日志中記錄的舊版本數(shù)據(jù)
id | name | trx_id | rollback_pointer |
---|---|---|---|
1 | 小明 | 90 |
3.然后事務(wù)100如果再次執(zhí)行查詢的話,(readview在可重復(fù)讀的隔離級別下首次執(zhí)行select創(chuàng)建身冀,在讀已提交的隔離級別下钝尸,每次select都會創(chuàng)建新的readview)
判斷 當(dāng)前數(shù)據(jù)trx_id(101)在readview中且不等于當(dāng)前事務(wù)id:100,所以該數(shù)據(jù)不可見搂根,根據(jù)rollback_pointer找到undo中舊版本數(shù)據(jù)在此做比較 trx_id(90)<當(dāng)前事務(wù)id100且不在readview中珍促,所以undo中舊版本數(shù)據(jù)對事務(wù)100是可見的。這樣就實(shí)現(xiàn)了可重復(fù)讀兄墅。