問題分析
忽然間被運營滴滴說某個活動的報名人數超過了限制人數,問怎麼回事,我一會兒還挺蒙的,我明明有在報名的操做以前設置了檢查若是超過報名人數代碼邏輯會拋錯繼續報名的呀。php
而後我又打開數據庫看了一下,出現瞭如下的狀況:mysql
因而狀況就很明瞭了,這明顯就是併發控制沒有作好。爲了敘述清楚這個狀況,下面講述一下業務邏輯:首先是從meeting表查是否報名已滿,若是未滿,則開始事務,將signed字段自增1,而後把參會記錄插入到meeting_member表,提交事務。這裏其實是出現了丟失更新,舉例以下。sql
T1 | T2 | 數據庫中signed的值 |
---|---|---|
BEGIN; | 0 | |
SELECT signed FROM meeting WHERE meeting_id=xx; (讀出來的值爲0) | BEGIN; | |
UPDATE meeting SET signed=signed+1 WHERE meeting_id=xx; | SELECT signed FROM meeting WHERE meeting_id=xx; (讀出來的值也爲0) | |
COMMIT; | UPDATE meeting SET signed=signed+1 WHERE meeting_id=xx; | |
COMMIT; | 1 |
能夠看到,若是T1沒有提交,T2會讀取到原來的值,最終出現T1的更新丟失的問題。對應到具體場景就是,若是有兩個事務,第一個讀取、而後更新,但尚未提交,這時候開始了第二個事務,他讀取到的就是第一個事務更新前的數據,一樣進行自增,這是T1提交,T2也提交,可是signed只增長了1。thinkphp
(中間有進行實驗,可是因爲填這個坑花的時間太長,就不把實驗過程放上來了,結果跟上表中羅列的一致)數據庫
PS
一開始我立刻聯想到的是事務的隔離級別以及髒讀、不可重複讀、幻讀之類的問題,實際上這裏出現的是第二類丟失更新1問題,丟失更新是指兩個事務更新數據時可能會覆蓋對方的更新,並非髒讀(T2錯誤讀取到T1已經修改但未提交的數據)、不可重複讀(T1進行兩次讀,中間T2對數據進行修改並提交,T1兩次讀到的數據不一樣)、幻讀(T1修改了表中符合某種條件的數據,T2又新增了一條符合這種條件的數據,T1會發現還有沒有修改的數據)2。這就是爲何即便mysql已是可重複讀(Repeatable Read)的事務隔離等級,但仍是會出現丟失更新的緣由3。併發
這裏蠻坑的,我往事務隔離等級這個方向想了好久,才發現方向根本不對,可重複讀已經解決了髒讀和不可重複讀的問題4,問題不是出在事務隔離等級上,而是應該在這裏加上一個鎖的機制。這裏又有新的疑惑,爲何有了事務(鎖協議是事務的一種實現方式),還須要另外的鎖呢?這裏考慮到粒度的問題5。所以最後應該使用的解決方法是悲觀鎖或者樂觀鎖。thinkphp5
解決思路
解決方法實際上比較簡單(簡單個屁),只須要加上悲觀鎖或者樂觀鎖就能夠了。值得一提的是,其實把事務隔離等級調成未提交讀或者可串行化也能解決問題,但若是使用未提交讀那會是一個倒退,可串行化會形成併發性能的嚴重降低,因此不採用。性能
若是對比樂觀鎖和悲觀鎖,樂觀鎖須要代碼實現(增長一個版本字段),而悲觀鎖能夠用數據庫原生的方式實現。悲觀鎖實現較爲簡單,但悲觀鎖的併發性能不如樂觀鎖。spa
悲觀鎖介紹
悲觀的意思是,每次獲取數據的時候,都會擔憂數據被修改,因此每次獲取數據的時候都會進行加鎖。在當前場景下,悲觀鎖就是在讀取目前的signed時,給這個數據加上行級的排他鎖,而後再進行更新。若是在T1尚未提交時,T2(一個一樣步驟的事務)想要讀取這個數據,由於他想要得到的是一個排它鎖,因爲T1還未提交,T1持有的排它鎖會阻塞T2,T2只能等待T1提交以後才能讀這一個數據。.net
排他鎖
在mysql中,排他鎖能夠這樣使用:
SELECT ... FOR UPDATE;
mysql會對查詢結果集中每行都添加排它鎖,在事務操做中,更新和刪除操做會自動加上排他鎖6。
若是數據被加上了排他鎖,那麼若是查詢中不管是請求共享鎖或是排他鎖,都會由於以前的排他鎖而被阻塞,直到以前的排他鎖由於事務提交或回滾釋放。若是不帶任何鎖的SELECT語句,不管查詢的數據是否有被加鎖(包括共享鎖和排他鎖),都可以進行查詢而不被阻塞。具體的表現能夠見7。
行鎖和表鎖
mysql對錶的鎖有兩種不一樣的粒度,分別是表鎖和行鎖。行鎖是粒度最小的鎖,InnoDB支持行鎖和表鎖,而MyISAM只支持表鎖。這裏特地提出來,是由於並非SELECT ... FOR UPDATE;
就是行級鎖,只有查詢到數據、根據主鍵和/或對非主鍵含索引進行查詢時才能使用行級鎖,其餘狀況使用的仍是表鎖。具體緣由是,InnoDB行鎖是經過對索引的索引項加鎖來實現的,這點值得注意。例如這裏要實現行鎖,就要對這個字段建索引。
實驗
講了那麼多,作一個實驗驗證一下。
首先看一下mysql8.0默認的事務隔離級別。(注意8.0與5.x有分別)
select @@global.transaction_isolation,@@transaction_isolation;
能夠看到默認的事務隔離級別是Repeatable Read(可重複讀)。
而後新開兩個查詢鏈接,這裏使用university數據庫爲例(上課用慣了),事務裏進行一個查詢和更新操做,以下所示。
BEGIN; SELECT * FROM instructor WHERE name='Srinivasan' FOR UPDATE; UPDATE instructor SET salary=65001 WHERE ID=10101; -- ROLLBACK; COMMIT;
先在第一個鏈接裏執行前兩行,開始一個事務T1,而後進行查詢並加鎖。注意這裏對name這一列進行了索引,因此能夠實現行級的鎖。能夠看到T1可以正常進行查詢。
而後用另外一個鏈接也開始一個事務,對這一個數據進行查詢。能夠看到查詢被阻塞了(沒有結果返回)。(實際上,等待一段時間(默認50s)後,就會出現當前會話鎖等待超時)
回到第一個窗口繼續第一個事務的更新操做,這時候第二個事務中的查詢和更新操做繼續被阻塞。若是第二個事務查詢另外一行的數據,則不會被阻塞。
當第一個事務提交或者回滾時,鎖被釋放,第二個事務立刻能夠進行查詢和更新操做。
thinkphp5.0實現
講到這麼久,終於步入正題。這裏也是有一點感慨,實現就兩行代碼,實際考慮的東西、涉及到的內容遠遠不止這麼點。
在tp5的用法中比較簡單,只須要在鏈式查詢中加入lock(true)
就能夠。具體代碼如:
Db::name('user')->where('id',1)->lock(true)->find(); //指定使用共享鎖 Db::name('user')->where('id',1)->lock('lock in share mode')->find();
在模型中也可用,用法同數據庫方法。
坑點
文檔中有提到8
就會自動在生成的SQL語句最後加上
FOR UPDATE
或者FOR UPDATE NOWAIT
(Oracle數據庫)。
說明實現的方法就是加上FOR UPDATE
,但沒說明是行鎖仍是表鎖,也沒有說明要先開啓事務才能使用,若是對數據庫不夠熟悉(例如一年前的我)看到這裏就會一臉懵逼。另外,據聞tp6已經實現了樂觀鎖(?),同時,tp5也能夠經過traits來實現樂觀鎖。
參考
https://blog.csdn.net/paopaopotter/article/details/79259686 「數據庫第一類第二類丟失更新」 ↩︎
https://blog.csdn.net/yishizuofei/article/details/79453588 「髒讀、丟失更新、不可重複讀、幻讀」 ↩︎
https://zhuanlan.zhihu.com/p/67210493 「Mysql RR級別依然可能丟失更新數據」 ↩︎
https://www.cnblogs.com/zhoujinyi/p/3437475.HTML 「MySQL 四種事務隔離級的說明」 ↩︎
https://blog.csdn.net/Scrat_Kong/article/details/84454519 「爲何有了事務還須要樂觀鎖和悲觀鎖?」 ↩︎
https://blog.csdn.net/tigernorth/article/details/7948539 「MySQL鎖的用法之行級鎖」 ↩︎
https://blog.csdn.net/She_lock/article/details/82022431 「mysql讀鎖(共享鎖)與寫鎖(排他鎖)」 ↩︎
https://www.kancloud.cn/manual/thinkphp5/118086 「ThinkPHP5.0徹底開發手冊-lock」 ↩︎