Web アプリケーションはマルチユーザー環境に直面することが多く、この場合、同時書き込み制御はほぼすべての開発者が習得しなければならないスキルになります。この記事では主にYii2.0における楽観的ロックと悲観的ロックの原理と使い方について紹介しますので、お役に立てれば幸いです。
同時実行環境では、Dirty Read、Unrepeatable Read、Phantom Read、Lost updateなどが発生する可能性があります。特定のパフォーマンスを自分で検索できます。
これらの問題に対処するために、主流のデータベースはロック機構を提供し、トランザクション分離レベルの概念を導入しています。 ここでは説明しませんが、これらのキーワードを検索すると、インターネット上でたくさん見つかります。
ただし、具体的な開発プロセスに関する限り、同時実行性の競合の問題を解決するには、悲観的ロックと楽観的ロックという 2 つの方法が一般的です。
楽観的ロック
楽観的ロックは、大胆で現実的な態度を示します。オプティミスティック ロックを使用する前提は、実際のアプリケーションでは競合の可能性が比較的低いということです。彼の設計と実装は直接的かつ簡潔です。 現在の Web アプリケーションでは、オプティミスティック ロックの使用には絶対的な利点があります。
そのため、Yii は ActiveReocrd のオプティミスティック ロック サポートも提供します。
Yii の公式ドキュメントによると、オプティミスティック ロックの使用は 4 つのステップに分かれています:
バージョン番号を表すためにロックする必要があるフィールドをテーブルに追加します。 もちろん、対応するモデルもフィールドに追加し、適切な調整を行う必要があります。たとえば、このフィールドは rules() に追加する必要があります。
yiidbActiveRecord::optimisticLock() メソッドをオーバーロードして、前のステップでフィールド名を返します。
レコード変更ページのフォームに、読み取り時にレコードのバージョン番号を一時的に保存するための を追加します。
コードを保存する場所で、try ... catch を使用して、yiidbStaleObjectException 例外をキャッチできるかどうかを確認します。そうであれば、このレコードの変更中にレコードが変更されたことを意味します。単純な応答の場合は、対応するプロンプトを表示できます。賢ければ、競合しない変更をマージしたり、差分ページを表示したりできます。
本質的に言えば、楽観的ロックは悲観的ロックのようなデータベースロックメカニズムを使用しません。 オプティミスティック ロックでは、現在のレコードが変更された回数 (バージョン番号) を表す count フィールドがテーブルに追加されます。
次に、更新または削除する前にバージョン番号を比較することにより、楽観的ロックを実装します。
バージョン番号フィールドを宣言します
バージョン番号は、楽観的ロックを実装するための基礎です。したがって、最初のステップは、どのフィールドがバージョン番号フィールドであるかを Yii に伝えることです。 これは yiidbBaseActiveRecord:
public function optimisticLock() { return null; }
によって処理されます。このメソッドは、楽観的ロックが使用されていないことを示す null を返します。次に、これをモデルでオーバーロードする必要があります。 バージョン番号を識別するために使用したフィールドを表す文字列を返します。たとえば、次のようになります。
public function optimisticLock() { return 'ver'; }
これは、現在の ActiveRecord に、楽観的ロックに使用できる ver フィールドがあることを意味します。 それでは、Yii はこの ver フィールドをどのように使用して楽観的ロックを実装するのでしょうか?
更新処理
具体的には、楽観ロック使用後の更新処理は以下のような処理になります:
更新対象のレコードを読み込みます。
ユーザーの希望に従ってレコードを変更します。もちろん、この時点では ver フィールドは変更されません。 このフィールドはユーザーにとっては意味がありません。
レコードを保存する前に、このレコードの ver フィールドを再度読み取り、以前に読み取った値と比較します。
ver が異なる場合は、このレコードがユーザーの変更プロセス中に他の人によって変更されたことを意味します。そこで、ヒントをご紹介します。
verが同じ場合、このレコードは変更されていないことを意味します。次に、ver +1 を実行し、このレコードを保存します。以上で記録の更新が完了する。同時に、レコードのバージョン番号も 1 つ増加します。
ActiveRecord の更新プロセスは最終的に yiidbBaseActiveRecord::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 つの条件があります。1 つは主キーが存在すること、もう 1 つは更新が完了していることです。
楽観的ロックが有効な場合、StaleObjectException は次の 2 つの状況でのみスローされます:
レコードが他の人によって削除された場合、主キーが存在しないため更新は失敗します。
バージョン番号が変更されており、更新の 2 番目の条件が満たされていません。
削除プロセス
更新プロセスと比較して、削除プロセスの楽観的ロックはより単純で理解しやすいです。コードはまだ yiidbBaseActiveRecord:
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应用的特点, 具有一定的适用性,但是也存在一定的缺陷:
最长允许锁定时长会带来一定的副作用。时间定得长了,可能要等很长时间, 才能重新编辑非正常解锁的记录。时间定得短了,则经常退化成乐观锁。
时间戳精度问题。如果精度不够,那么在加锁时,与我们讨论过的乐观锁失效存, 在同样的漏洞。
这种形式的锁定,只是应用层面的锁定,并非数据库层面的锁定。 如果存在应用之外对于数据库的写入操作。这个锁定机制是无效的。
相关推荐:
MySQL数据库优化(三)—MySQL悲观锁和乐观锁(并发控制)
以上がYii2.0における楽観的ロックと悲観的ロックの例の詳細な説明の詳細内容です。詳細については、PHP 中国語 Web サイトの他の関連記事を参照してください。