Web應用往往面臨多用戶環境,這種情況下的并發寫入控制, 幾乎成為每個開發人員都必須掌握的一項技能。
在并發環境下,有可能會出現臟讀(Dirty Read)、不可重復讀(Unrepeatable Read)、 幻讀(Phantom Read)、更新丟失(Lost update)等情況。具體的表現可以自行搜索。
為了應對這些問題,主流數據庫都提供了鎖機制,并引入了事務隔離級別的概念。 這里我們都不作解釋了,拿這些關鍵詞一搜,網上大把大把的。
但是,就于具體開發過程而言,一般分為悲觀鎖和樂觀鎖兩種方式來解決并發沖突問題。
## 樂觀鎖[](http://www.digpage.com/lock.html#id2 "Permalink to this headline")
樂觀鎖(optimistic locking)表現出大膽、務實的態度。使用樂觀鎖的前提是, 實際應用當中,發生沖突的概率比較低。他的設計和實現直接而簡潔。 目前Web應用中,樂觀鎖的使用占有絕對優勢。
因此,Yii也為ActiveReocrd提供了樂觀鎖支持。
根據Yii的官方文檔,使用樂觀鎖,總共分4步:
* 為需要加鎖的表增加一個字段,用于表示版本號。 當然相應的Model也要為該字段的加入,作出適當調整。比如,?rules()?中要加入該字段。
* 重載?yii\db\ActiveRecord::optimisticLock()?方法,返回上一步中的字段名。
* 在記錄的修改頁面表單中,加入一個??type="hidden">?用于暫存讀取時的記錄的版本號。
* 在保存代碼的地方,使用?try?...?catch?看看是否能捕獲一個?yii\db\StaleObjectException?異常。如果是,說明在本次修改這個記錄的過程中, 該記錄已經被修改過了。簡單應對的話,可以作出相應提示。智能點的話, 可以合并不沖突的修改,或者顯示一個diff頁面。
從本質上來講,樂觀鎖并沒有像悲觀鎖那樣使用數據庫的鎖機制。 樂觀鎖通過在表中增加一個計數字段,來表示當前記錄被修改的次數(版本號)。
然后在更新、刪除前通過比對版本號來實現樂觀鎖。
### 聲明版本號字段[](http://www.digpage.com/lock.html#id3 "Permalink to this headline")
版本號是實現樂觀鎖的根本所在。所以第一步,我們要告訴Yii,哪個字段是版本號字段。 這個由yii\db\BaseActiveRecord?負責:
~~~
public function optimisticLock()
{
return null;
}
~~~
這個方法返回?null?,表示不使用樂觀鎖。那么我們的Model中,要對此進行重載。 返回一個字符串,表示我們用于標識版本號的字段。比如可以這樣:
~~~
public function optimisticLock()
{
return 'ver';
}
~~~
說明當前的ActiveRecord中,有一個?ver?字段,可以為樂觀鎖所用。 那么Yii具體是如何借助這個ver?字段實現樂觀鎖的呢?
### 更新過程[](http://www.digpage.com/lock.html#id4 "Permalink to this headline")
具體來講,使用樂觀鎖之后的更新過程,就是這么一個流程:
* 讀取要更新的記錄。
* 對記錄按照用戶的意愿進行修改。當然,這個時候不會修改?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?異常:
* 當記錄在被別人刪除后,由于主鍵已經不存在,更新失敗。
* 版本號已經變更,不滿足更新的第二個條件。
### 刪除過程[](http://www.digpage.com/lock.html#id5 "Permalink to this headline")
與更新過程相比,刪除過程的樂觀鎖,更簡單,更好理解。代碼仍在?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有什么意義?
## 樂觀鎖失效[](http://www.digpage.com/lock.html#id6 "Permalink to this headline")
樂觀鎖存在失效的情況,屬小概率事件,需要多個條件共同配合才會出現。如:
* 應用采用自己的策略管理主鍵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,坑之所以能夠坑死人,正是由于其隱蔽性。
對此,也有一些意見提出來,使用時間戳作為版本號字段,就可以避免這個問題。 但是,時間戳的話,如果精度不夠,如毫秒級別,那么在高并發,或者非常湊巧情況下, 仍有失效的可能。而如果使用高精度時間戳的話,成本又太高。
使用時間戳,可靠性并不比使用整型好。問題還是要回到使用嚴謹的主鍵成生策略上來。
## 悲觀鎖[](http://www.digpage.com/lock.html#id7 "Permalink to this headline")
正如其名字,悲觀鎖(pessimistic locking)體現了一種謹慎的處事態度。其流程如下:
* 在對任意記錄進行修改前,先嘗試為該記錄加上排他鎖(exclusive locking)。
* 如果加鎖失敗,說明該記錄正在被修改,那么當前查詢可能要等待或者拋出異常。 具體響應方式由開發者根據實際需要決定。
* 如果成功加鎖,那么就可以對記錄做修改,事務完成后就會解鎖了。
* 其間如果有其他對該記錄做修改或加排他鎖的操作,都會等待我們解鎖或直接拋出異常。
悲觀鎖確實很嚴謹,有效保證了數據的一致性,在C/S應用上有諸多成熟方案。 但是他的缺點與優點一樣的明顯:
* 悲觀鎖適用于可靠的持續性連接,諸如C/S應用。 對于Web應用的HTTP連接,先天不適用。
* 鎖的使用意味著性能的損耗,在高并發、鎖定持續時間長的情況下,尤其嚴重。 Web應用的性能瓶頸多在數據庫處,使用悲觀鎖,進一步收緊了瓶頸。
* 非正常中止情況下的解鎖機制,設計和實現起來很麻煩,成本還很高。
* 不夠嚴謹的設計下,可能產生莫名其妙的,不易被發現的, 讓人頭疼到想把鍵盤一巴掌碎的死鎖問題。
總體來看,悲觀鎖不大適應于Web應用,Yii團隊也認為悲觀鎖的實現過于麻煩, 因此,ActiveRecord也沒有提供悲觀鎖。
作為Yii的構成基因之一的Ruby on rails,他的ActiveReocrd模型,倒是提供了悲觀鎖, 但是使用起來也很麻煩。
## 悲觀鎖的實現[](http://www.digpage.com/lock.html#id8 "Permalink to this headline")
雖然悲觀鎖在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應用的特點, 具有一定的適用性,但是也存在一定的缺陷:
* 最長允許鎖定時長會帶來一定的副作用。時間定得長了,可能要等很長時間, 才能重新編輯非正常解鎖的記錄。時間定得短了,則經常退化成樂觀鎖。
* 時間戳精度問題。如果精度不夠,那么在加鎖時,與我們討論過的樂觀鎖失效存, 在同樣的漏洞。
* 這種形式的鎖定,只是應用層面的鎖定,并非數據庫層面的鎖定。 如果存在應用之外對于數據庫的寫入操作。這個鎖定機制是無效的。
如果覺得《深入理解Yii2.0》對您有所幫助,也請[幫助《深入理解Yii2.0》](http://www.digpage.com/donate.html#donate)。 謝謝!
- 更新記錄
- 導讀
- Yii是什么
- Yii2.0的亮點
- 背景知識
- 如何閱讀本書
- Yii基礎
- 屬性(Property)
- 事件(Event)
- 行為(Behavior)
- Yii約定
- Yii應用的目錄結構和入口腳本
- 別名(Alias)
- Yii的類自動加載機制
- 環境和配置文件
- 配置項(Configuration)
- Yii模式
- MVC
- 依賴注入和依賴注入容器
- 服務定位器(Service Locator)
- 請求與響應(TBD)
- 路由(Route)
- Url管理
- 請求(Reqeust)
- Web應用Request
- Yii與數據庫(TBD)
- 數據類型
- 事務(Transaction)
- AcitveReocrd事件和關聯操作
- 樂觀鎖與悲觀鎖
- 《深入理解Yii2.0》視頻教程
- 第一講:基礎配置
- 第二講:用戶登錄
- 第三講:文章及評論的模型
- 附錄
- 附錄1:Yii2.0 對比 Yii1.1 的重大改進
- 附錄2:Yii的安裝
- 熱心讀者