Yii2.0樂觀鎖與悲觀鎖實例詳解

小云云
發布: 2023-03-19 19:56:01
原創
2262 人瀏覽過

Web應用程式往往面臨多用戶環境,這種情況下的並發寫入控制, 幾乎成為每個開發人員都必須掌握的技能。本文主要與大家介紹Yii2.0樂觀鎖與悲觀鎖的原理與使用,希望能幫助大家。

在並發環境下,有可能會出現髒讀(Dirty Read)、不可重複讀取(Unrepeatable Read)、 幻讀(Phantom Read)、更新遺失(Lost update)等情況。具體的表現可以自行搜尋。

為了回應這些問題,主流資料庫都提供了鎖定機制,並引入了事務隔離層級的概念。 這裡我們都不作解釋了,拿這些關鍵字一搜,網路上大把大把的。

但是,就於具體開發過程而言,一般分為悲觀鎖和樂觀鎖兩種方式來解決並發衝突問題。

樂觀鎖

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

因此,Yii也為ActiveReocrd提供了樂觀鎖定支援。

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

  • 為需要加鎖的表增加一個字段,用於表示版本號。 當然相應的Model也要為該欄位的加入,並做出適當調整。例如, rules() 中要加入該欄位。

  • 重載 yii\db\ActiveRecord::optimisticLock() 方法,傳回上一個步驟中的欄位名稱。

  • 在記錄的修改頁面表單中,加入一個 用於暫存讀取時的記錄的版本號。

  • 在儲存程式碼的地方,使用 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 字段實現樂觀鎖的呢?

更新過程

具體來講,使用樂觀鎖定之後的更新過程,就是這麼一個流程:

  1. 讀取要更新的記錄。

  2. 對記錄依照使用者的意願進行修改。當然,這個時候不會修改 ver 欄位。 這個欄位對使用者是沒意義的。

  3. 在儲存記錄前,再次讀取這個記錄的 ver 字段,與先前讀取的值進行比對。

  4. 如果 ver 不同,表示在使用者修改過程中,這個記錄被別人改動過了。那麼, 我們要給提示。

  5. 如果 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;
}
登入後複製

從上面的程式碼中,我們不難得出:

  1. 當optimisticLock( ) 當傳回null 時,樂觀鎖不會啟用。

  2. 版本號只增不減。

  3. 透過樂觀鎖的條件有2個,一是主鍵要存在,二是要能完成更新。

  4. 當啟用樂觀鎖定後,只有下列兩種情況會拋出StaleObjectException 例外:

    1. 當記錄在被別人刪除後,由於主鍵已經不存在,更新失敗。

    2. 版本號碼已經變更,不滿足更新的第二個條件。

刪除過程

與更新過程相比,刪除過程的樂觀鎖,更簡單,更能理解。程式碼仍在 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有什么意义?

乐观锁失效

乐观锁存在失效的情况,属小概率事件,需要多个条件共同配合才会出现。如:

  1. 应用采用自己的策略管理主键ID。如,常见的取当前ID字段的最大值+1作为新ID。

  2. 版本号字段 ver 默认值为 0 。

  3. 用户A读取了某个记录准备修改它。该记录正好是ID最大的记录,且之前没被修改过, ver 为默认值 0。

  4. 在用户A读取完成后,用户B恰好删除了该记录。之后,用户C又插入了一个新记录。

  5. 此时,阴差阳错的,新插入的记录的ID与用户A读取的记录的ID是一致的, 而版本号两者又都是默认值 0。

  6. 用户A在用户C操作完成后,修改完成记录并保存。由于ID、ver均可以匹配上, 因此用户A成功保存。但是,却把用户C插入的记录覆盖掉了。

乐观锁此时的失效,根本原因在于应用所使用的主键ID管理策略, 正好与乐观锁存在极小程度上的不兼容。

两者分开来看,都是没问题的。组合到一起之后,大致看去好像也没问题。 但是bug之所以成为bug,坑之所以能够坑死人,正是由于其隐蔽性。

对此,也有一些意见提出来,使用时间戳作为版本号字段,就可以避免这个问题。 但是,时间戳的话,如果精度不够,如毫秒级别,那么在高并发,或者非常凑巧情况下, 仍有失效的可能。而如果使用高精度时间戳的话,成本又太高。

使用时间戳,可靠性并不比使用整型好。问题还是要回到使用严谨的主键成生策略上来。

悲观锁

正如其名字,悲观锁(pessimistic locking)体现了一种谨慎的处事态度。其流程如下:

  1. 在对任意记录进行修改前,先尝试为该记录加上排他锁(exclusive locking)。

  2. 如果加锁失败,说明该记录正在被修改,那么当前查询可能要等待或者抛出异常。 具体响应方式由开发者根据实际需要决定。

  3. 如果成功加锁,那么就可以对记录做修改,事务完成后就会解锁了。

  4. 其间如果有其他对该记录做修改或加排他锁的操作,都会等待我们解锁或直接抛出异常。

悲观锁确实很严谨,有效保证了数据的一致性,在C/S应用上有诸多成熟方案。 但是他的缺点与优点一样的明显:

  1. 悲观锁适用于可靠的持续性连接,诸如C/S应用。 对于Web应用的HTTP连接,先天不适用。

  2. 锁的使用意味着性能的损耗,在高并发、锁定持续时间长的情况下,尤其严重。 Web应用的性能瓶颈多在数据库处,使用悲观锁,进一步收紧了瓶颈。

  3. 非正常中止情况下的解锁机制,设计和实现起来很麻烦,成本还很高。

  4. 不够严谨的设计下,可能产生莫名其妙的,不易被发现的, 让人头疼到想把键盘一巴掌碎的死锁问题。

总体来看,悲观锁不大适应于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[] = [&#39;<&#39;, $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(&#39;The object being updated is outdated.&#39;);
    }
    $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. 新增加了一个加锁方法,一个获取锁定最大时长的方法。

  2. 保存时不再是把标识字段+1,而是把标识字段置0。

在具体使用方法上,可以参照以下代码:


// 从PLockAR派生模型类
class Post extends PLockAR {
  // 重载定义悲观锁标识字段,如 locked_at
  public function pesstimisticLock() {
    return &#39;locked_at&#39;;
  }
  // 重载定义最大锁定时长,如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([&#39;view&#39;, &#39;id&#39; => $model->id]);
    } else {
      // 加入一个加锁的判断
      if (!$model->lock()) {
        // 加锁失败
        // ... ...
      }
      return $this->render(&#39;update&#39;, [
        &#39;model&#39; => $model,
      ]);
    }
  }
}
登入後複製

上述方法实现的悲观锁,避免了使用数据库自身的锁机制,契合Web应用的特点, 具有一定的适用性,但是也存在一定的缺陷:

  1. 最长允许锁定时长会带来一定的副作用。时间定得长了,可能要等很长时间, 才能重新编辑非正常解锁的记录。时间定得短了,则经常退化成乐观锁。

  2. 时间戳精度问题。如果精度不够,那么在加锁时,与我们讨论过的乐观锁失效存, 在同样的漏洞。

  3. 这种形式的锁定,只是应用层面的锁定,并非数据库层面的锁定。 如果存在应用之外对于数据库的写入操作。这个锁定机制是无效的。

相关推荐:

实现redis中事务机制及乐观锁的方法

MySQL数据库优化(三)—MySQL悲观锁和乐观锁(并发控制)

悲观锁和乐观锁的比较和使用

以上是Yii2.0樂觀鎖與悲觀鎖實例詳解的詳細內容。更多資訊請關注PHP中文網其他相關文章!

相關標籤:
來源:php.cn
本網站聲明
本文內容由網友自願投稿,版權歸原作者所有。本站不承擔相應的法律責任。如發現涉嫌抄襲或侵權的內容,請聯絡admin@php.cn
熱門教學
更多>
最新下載
更多>
網站特效
網站源碼
網站素材
前端模板
關於我們 免責聲明 Sitemap
PHP中文網:公益線上PHP培訓,幫助PHP學習者快速成長!