Yii2.0的樂觀鎖與悲觀鎖(轉)

原文:Yii2.0的樂觀鎖與悲觀鎖php

Web應用每每面臨多用戶環境,這種狀況下的併發寫入控制, 幾乎成爲每一個開發人員都必須掌握的一項技能。html

在併發環境下,有可能會出現髒讀(Dirty Read)、不可重複讀(Unrepeatable Read)、 幻讀(Phantom Read)、更新丟失(Lost update)等狀況。具體的表現能夠自行搜索。數據庫

爲了應對這些問題,主流數據庫都提供了鎖機制,並引入了事務隔離級別的概念。 這裏咱們都不做解釋了,拿這些關鍵詞一搜,網上大把大把的。併發

可是,就於具體開發過程而言,通常分爲悲觀鎖和樂觀鎖兩種方式來解決併發衝突問題。app

樂觀鎖

樂觀鎖(optimistic locking)表現出大膽、務實的態度。使用樂觀鎖的前提是, 實際應用當中,發生衝突的機率比較低。他的設計和實現直接而簡潔。 目前Web應用中,樂觀鎖的使用佔有絕對優點。yii

所以,Yii也爲ActiveReocrd提供了樂觀鎖支持。高併發

根據Yii的官方文檔,使用樂觀鎖,總共分4步:post

  • 爲須要加鎖的表增長一個字段,用於表示版本號。 固然相應的Model也要爲該字段的加入,做出適當調整。好比, rules() 中要加入該字段。
  • 重載 yii\db\ActiveRecord::optimisticLock() 方法,返回上一步中的字段名。
  • 在記錄的修改頁面表單中,加入一個 <input type="hidden"> 用於暫存讀取時的記錄的版本號。
  • 在保存代碼的地方,使用 try ... catch 看看是否能捕獲一個 yii\db\StaleObjectException 異常。若是是,說明在本次修改這個記錄的過程當中, 該記錄已經被修改過了。簡單應對的話,能夠做出相應提示。智能點的話, 能夠合併不衝突的修改,或者顯示一個diff頁面。

從本質上來說,樂觀鎖並無像悲觀鎖那樣使用數據庫的鎖機制。 樂觀鎖經過在表中增長一個計數字段,來表示當前記錄被修改的次數(版本號)。性能

而後在更新、刪除前經過比對版本號來實現樂觀鎖。this

聲明版本號字段

版本號是實現樂觀鎖的根本所在。因此第一步,咱們要告訴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() ,理所固然地,處理樂觀鎖的代碼, 也就隱藏在這個方法中:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
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 中:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
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以後,從新加上本身的鎖。換句話說, 此時悲觀鎖退化爲樂觀鎖。

大體的原理性代碼以下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
// 悲觀鎖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。

在具體使用方法上,能夠參照如下代碼:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 從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應用的特色, 具備必定的適用性,可是也存在必定的缺陷:

  • 最長容許鎖定時長會帶來必定的反作用。時間定得長了,可能要等很長時間, 才能從新編輯非正常解鎖的記錄。時間定得短了,則常常退化成樂觀鎖。
  • 時間戳精度問題。若是精度不夠,那麼在加鎖時,與咱們討論過的樂觀鎖失效存, 在一樣的漏洞。
  • 這種形式的鎖定,只是應用層面的鎖定,並不是數據庫層面的鎖定。 若是存在應用以外對於數據庫的寫入操做。這個鎖定機制是無效的。
相關文章
相關標籤/搜索