Web應用往往面臨多使用者環境,這種情況下的並發寫入控制, 幾乎成為每個開發人員都必須掌握的一項技能。本文主要和大家介紹Yii2.0樂觀鎖與悲觀鎖的原理與使用,希望能協助到大家。
在並發環境下,有可能會出現髒讀(Dirty Read)、不可重複讀取(Unrepeatable Read)、 幻讀(Phantom Read)、更新丟失(Lost update)等情況。具體的表現可以自行搜尋。
為了應對這些問題,主流資料庫都提供了鎖機制,並引入了交易隔離等級的概念。 這裡我們都不作解釋了,拿這些關鍵詞一搜,網上大把大把的。
但是,就於具體開發過程而言,一般分為悲觀鎖和樂觀鎖兩種方式來解決並發衝突問題。
樂觀鎖
樂觀鎖(optimistic locking)表現出大膽、務實的態度。使用樂觀鎖的前提是, 實際應用當中,發生衝突的機率比較低。他的設計和實現直接而簡潔。 目前Web應用中,樂觀鎖的使用佔有絕對優勢。
因此,Yii也為ActiveReocrd提供了樂觀鎖支援。
根據Yii的官方文檔,使用樂觀鎖,總共分4步:
為需要加鎖的表增加一個欄位,用於表示版本號碼。 當然相應的Model也要為該欄位的加入,作出適當調整。比如, rules() 中要加入該欄位。
重載 yii\db\ActiveRecord::optimisticLock() 方法,返回上一步中的欄位名。
在記錄的修改頁面表單中,加入一個 <input type="hidden"> 用於暫存讀取時的記錄的版本號碼。
在儲存代碼的地方,使用 try ... catch 看看是否能捕獲一個 yii\db\StaleObjectException 異常。如果是,說明在本次修改這個記錄的過程中, 該記錄已經被修改過了。簡單應對的話,可以作出相應提示。智能點的話, 可以合并不衝突的修改,或者顯示一個diff頁面。
從本質上來講,樂觀鎖並沒有像悲觀鎖那樣使用資料庫的鎖機制。 樂觀鎖通過在表中增加一個計數欄位,來表示目前記錄被修改的次數(版本號碼)。
然後在更新、刪除前通過比對版本號碼來實現樂觀鎖。
聲明版本號碼欄位
版本號碼是實現樂觀鎖的根本所在。所以第一步,我們要告訴Yii,哪個欄位是版本號碼欄位。 這個由 yii\db\BaseActiveRecord 負責:
public function optimisticLock(){ return null;}
這個方法返回 null ,表示不使用樂觀鎖。那麼我們的Model中,要對此進行重載。 返回一個字串,表示我們用於標識版本號碼的欄位。比如可以這樣:
public function optimisticLock(){ return 'ver';}
說明當前的ActiveRecord中,有一個 ver 欄位,可以為樂觀鎖所用。 那麼Yii具體是如何藉助這個 ver 欄位實現樂觀鎖的呢?
更新過程
具體來講,使用樂觀鎖之後的更新過程,就是這麼一個流程:
讀取要更新的記錄。
對記錄按照使用者的意願進行修改。當然,這個時候不會修改 ver 欄位。 這個欄位對使用者是沒意義的。
在儲存記錄前,再次讀取這個記錄的 ver 欄位,與之前讀取的值進行比對。
如果 ver 不同,說明在使用者修改過程中,這個記錄被別人改動過了。那麼, 我們要給出提示。
如果 ver 相同,說明這個記錄未被修改過。那麼,對 ver +1, 並儲存這個記錄。這樣子就完成了記錄的更新。同時,該記錄的版本號碼也加了1。
由於ActiveRecord的更新過程最終都需要調用 yii\db\BaseActiveRecord::updateInteranl() ,理所當然地,處理樂觀鎖的代碼, 也就隱藏在這個方法中:
protected function updateInternal($attributes = null){ if (!$this->beforeSave(false)) { return false; } // 擷取等下要更新的欄位及新的欄位值 $values = $this->getDirtyAttributes($attributes); if (empty($values)) { $this->afterSave(false, $values); return 0; } // 把原來ActiveRecord的主鍵作為等下更新記錄的條件, // 也就是說,等下更新的,最多隻有1個記錄。 $condition = $this->getOldPrimaryKey(true); // 擷取版本號碼欄位的欄位名,比如 ver $lock = $this->optimisticLock(); // 如果 optimisticLock() 返回的是 null,那麼,不啟用樂觀鎖。 if ($lock !== null) { // 這裡的 $this->$lock ,就是 $this->ver 的意思; // 這裡把 ver+1 作為要更新的欄位之一。 $values[$lock] = $this->$lock + 1; // 這裡把舊的版本號碼作為更新的另一個條件 $condition[$lock] = $this->$lock; } $rows = $this->updateAll($values, $condition); // 如果已經啟用了樂觀鎖,但是卻沒有完成更新,或者更新的記錄數為0; // 那就說明是由於 ver 不匹配,記錄被修改過了,於是拋出異常。 if ($lock !== null && !$rows) { throw new StaleObjectException('The object being updated is outdated.'); } $changedAttributes = []; foreach ($values as $name => $value) { $changedAttributes[$name] = isset($this->_oldAttributes[$name]) ? $this->_oldAttributes[$name] : null; $this->_oldAttributes[$name] = $value; } $this->afterSave(false, $changedAttributes); return $rows;}
從上面的代碼中,我們不難得出:
當 optimisticLock() 返回 null 時,樂觀鎖不會被啟用。
版本號碼只增不減。
通過樂觀鎖的條件有2個,一是主鍵要存在,二是要能夠完成更新。
當啟用樂觀鎖後,只有下列兩種情況會拋出 StaleObjectException 異常:
當記錄在被別人刪除後,由於主鍵已經不存在,更新失敗。
版本號碼已經變更,不滿足更新的第二個條件。
刪除過程
與更新過程相比,刪除過程的樂觀鎖,更簡單,更好理解。代碼仍在 yii\db\BaseActiveRecord 中:
public function delete(){ $result = false; if ($this->beforeDelete()) { // 刪除的SQL語句中,WHERE部分是主鍵 $condition = $this->getOldPrimaryKey(true); // 擷取版本號碼欄位的欄位名,比如 ver $lock = $this->optimisticLock(); // 如果啟用樂觀鎖,那麼WHERE部分再加一個條件,版本號碼 if ($lock !== null) { $condition[$lock] = $this->$lock; } $result = $this->deleteAll($condition); if ($lock !== null && !$result) { throw new StaleObjectException('The object being deleted is outdated.'); } $this->_oldAttributes = null; $this->afterDelete(); } return $result;}
比起更新過程,刪除過程確實要簡單得多。唯一的區別就是省去了版本號碼+1的步驟。 都要刪除了,版本號碼+1有什麼意義?
樂觀鎖失效
樂觀鎖存在失效的情況,屬小機率事件,需要多個條件共同配合才會出現。如:
應用採用自己的策略管理主鍵ID。如,常見的取當前ID欄位的最大值+1作為新ID。
版本號碼欄位 ver 預設值為 0 。
使用者A讀取了某個記錄準備修改它。該記錄正好是ID最大的記錄,且之前沒被修改過, ver 為預設值 0。
在使用者A讀取完成後,使用者B恰好刪除了該記錄。之後,使用者C又插入了一個新記錄。
此時,陰差陽錯的,新插入的記錄的ID與使用者A讀取的記錄的ID是一致的, 而版本號碼兩者又都是預設值 0。
使用者A在使用者C操作完成後,修改完成記錄並儲存。由於ID、ver均可以匹配上, 因此使用者A成功儲存。但是,卻把使用者C插入的記錄覆蓋掉了。
樂觀鎖此時的失效,根本原因在於應用所使用的主鍵ID管理原則, 正好與樂觀鎖存在極小程度上的不相容。
兩者分開來看,都是沒問題的。組合到一起之後,大致看去好像也沒問題。 但是bug之所以成為bug,坑之所以能夠坑死人,正是由於其隱蔽性。
對此,也有一些意見提出來,使用時間戳作為版本號碼欄位,就可以避免這個問題。 但是,時間戳記的話,如果精度不夠,如毫秒層級,那麼在高並發,或者非常湊巧情況下, 仍有失效的可能。而如果使用高精度時間戳記的話,成本又太高。
使用時間戳,可靠性並不比使用整型好。問題還是要回到使用嚴謹的主鍵成生策略上來。
悲觀鎖
正如其名字,悲觀鎖(pessimistic locking)體現了一種謹慎的處事態度。其流程如下:
在對任意記錄進行修改前,先嘗試為該記錄加上獨佔鎖定(exclusive locking)。
如果加鎖失敗,說明該記錄正在被修改,那麼當前查詢可能要等待或者拋出異常。 具體回應程式式由開發人員根據實際需要決定。
如果成功加鎖,那麼就可以對記錄做修改,事務完成後就會解鎖了。
其間如果有其他對該記錄做修改或加獨佔鎖定的操作,都會等待我們解鎖或直接拋出異常。
悲觀鎖確實很嚴謹,有效保證了資料的一致性,在C/S應用上有諸多成熟方案。 但是他的缺點與優點一樣的明顯:
悲觀鎖適用於可靠的持久性串連,諸如C/S應用。 對於Web應用的HTTP串連,先天不適用。
鎖的使用意味著效能的損耗,在高並發、鎖定期間長的情況下,尤其嚴重。 Web應用的效能瓶頸多在資料庫處,使用悲觀鎖,進一步收緊了瓶頸。
非正常中止情況下的解鎖機制,設計和實現起來很麻煩,成本還很高。
不夠嚴謹的設計下,可能產生莫名其妙的,不易被發現的, 讓人頭疼到想把鍵盤一巴掌碎的死結問題。
總體來看,悲觀鎖不大適應於Web應用,Yii團隊也認為悲觀鎖的實現過於麻煩, 因此,ActiveRecord也沒有提供悲觀鎖。
作為Yii的構成基因之一的Ruby on rails,他的ActiveReocrd模型,倒是提供了悲觀鎖, 但是使用起來也很麻煩。
悲觀鎖的實現
雖然悲觀鎖在Web應用上存在諸多不足,實現悲觀鎖也需要解決各種麻煩。但是, 當使用者提出他就是要用悲觀鎖時,牙口再不好的碼農,就是咬碎牙也是要啃下這塊骨頭來。
對於一個典型的Web應用而言,這裡提供個人常用的方法來實現悲觀鎖。
首先,在要鎖定的表裡,加一個欄位如 locked_at ,表示目前記錄被鎖定時的時間, 當為 0 時,表示該記錄未被鎖定,或者認為這是1970年時加的鎖。
當要修改某個記錄時,先看看目前時間與 locked_at 欄位相差是否超過預定的一個時間長度T,比如 30 min ,1 h 之類的。
如果沒超過,說明該記錄有人正在修改,我們暫時不能開啟(讀取)他來修改。 否則,說明可以修改,我們先將目前時間戳儲存到該記錄的 locked_at 欄位。 那麼之後的時間長度T內如果有人要來改這個記錄,他會由於加鎖失敗而無法讀取, 從而無法修改。
我們在完成修改後,即將儲存時,要比對現在的 locked_at 。只有在 locked_at 一致時,才認為剛剛是我們加的鎖,我們才可以儲存。 否則,說明在我們加鎖後,又有人加了鎖正在修改, 或者已經完成了修改,使得 locked_at 歸 0。
這種情況主要是由於我們的修改時間長度過長,超過了預定的T。原先的加鎖自動解開, 其他使用者可以在我們加鎖時刻再過T之後,重新加上自己的鎖。換句話說, 此時悲觀鎖退化為樂觀鎖。
大致的原理性代碼如下:
// 悲觀鎖AR基類,需要使用悲觀鎖的AR可以由此派生class PLockAR extends \yii\db\BaseActiveRecord { // 聲明悲觀鎖使用的標記欄位,作用類似於 optimisticLock() 方法 public function pesstimisticLock() { return null; } // 定義鎖定的最大時間長度,超過該時間長度後,自動解鎖。 public function maxLockTime() { return 0; } // 嘗試加鎖,加鎖成功則返回true public function lock() { $lock = $this->pesstimisticLock(); $now = time(); $values = [$lock => $now]; // 以下2句,更新條件為主鍵,且上次鎖定時間距現在超過規定時間長度 $condition = $this->getOldPrimaryKey(true); $condition[] = ['<', $lock, $now - $this->maxLockTime()]; $rows = $this->updateAll($values, $condition); // 加鎖失敗,返回 false if (! $rows) { return false; } return true; } // 重載updateInternal() protected function updateInternal($attributes = null) { // 這些與原來代碼一樣 if (!$this->beforeSave(false)) { return false; } $values = $this->getDirtyAttributes($attributes); if (empty($values)) { $this->afterSave(false, $values); return 0; } $condition = $this->getOldPrimaryKey(true); // 改為擷取悲觀鎖識別欄位 $lock = $this->pesstimisticLock(); // 如果 $lock 為 null,那麼,不啟用悲觀鎖。 if ($lock !== null) { // 等下儲存時,要把識別欄位置0 $values[$lock] = 0; // 這裡把原來的識別欄位值作為更新的另一個條件 $condition[$lock] = $this->$lock; } $rows = $this->updateAll($values, $condition); // 如果已經啟用了悲觀鎖,但是卻沒有完成更新,或者更新的記錄數為0; // 那就說明之前的加鎖已經自動失效了,記錄正在被修改, // 或者已經完成修改,於是拋出異常。 if ($lock !== null && !$rows) { throw new StaleObjectException('The object being updated is outdated.'); } $changedAttributes = []; foreach ($values as $name => $value) { $changedAttributes[$name] = isset($this->_oldAttributes[$name]) ? $this->_oldAttributes[$name] : null; $this->_oldAttributes[$name] = $value; } $this->afterSave(false, $changedAttributes); return $rows; }}
上面的代碼對比樂觀鎖,主要不同點在於:
新增加了一個加鎖方法,一個擷取鎖定最大時間長度的方法。
儲存時不再是把識別欄位+1,而是把識別欄位置0。
在具體使用方法上,可以參照以下代碼:
// 從PLockAR派生模型類class Post extends PLockAR { // 重載定義悲觀鎖識別欄位,如 locked_at public function pesstimisticLock() { return 'locked_at'; } // 重載定義最大鎖定時間長度,如1小時 public function maxLockTime() { return 3600000; }}// 修改前要嘗試加鎖class SectionController extends Controller { public function actionUpdate($id) { $model = $this->findModel($id); if ($model->load(Yii::$app->request->post()) && $model->save()) { return $this->redirect(['view', 'id' => $model->id]); } else { // 加入一個加鎖的判斷 if (!$model->lock()) { // 加鎖失敗 // ... ... } return $this->render('update', [ 'model' => $model, ]); } }}
上述方法實現的悲觀鎖,避免了使用資料庫自身的鎖機制,契合Web應用的特點, 具有一定的適用性,但是也存在一定的缺陷:
最長允許鎖定時間長度會帶來一定的副作用。時間定得長了,可能要等很長時間, 才能重新編輯非正常解鎖的記錄。時間定得短了,則經常退化成樂觀鎖。
時間戳記精度問題。如果精度不夠,那麼在加鎖時,與我們討論過的樂觀鎖失效存, 在同樣的漏洞。
這種形式的鎖定,只是應用程式層面的鎖定,並非資料庫層面的鎖定。 如果存在應用之外對於資料庫的寫入操作。這個鎖定機制是無效的。