웹 애플리케이션은 다중 사용자 환경에 직면하는 경우가 많습니다. 이 경우 동시 쓰기 제어는 거의 모든 개발자가 숙달해야 하는 기술이 되었습니다. 이번 글은 주로 Yii2.0의 낙관적 잠금과 비관적 잠금의 원리와 사용법을 소개합니다.
동시 환경에서는 Dirty Read, Unrepeatable Read, Phantom Read, Lost update 등이 발생할 수 있습니다. 특정 성과를 직접 검색할 수 있습니다.
이러한 문제를 해결하기 위해 주류 데이터베이스는 잠금 메커니즘을 제공하고 트랜잭션 격리 수준 개념을 도입합니다. 여기서는 설명하지 않겠습니다. 이러한 키워드를 검색하면 인터넷에서 많은 내용을 찾을 수 있습니다.
그러나 특정 개발 프로세스에 관한 한 동시성 충돌 문제를 해결하는 방법에는 일반적으로 비관적 잠금과 낙관적 잠금의 두 가지 방법이 있습니다.
Optimistic Locking
Optimistic Locking은 대담하고 실용적인 태도를 보여줍니다. 낙관적 잠금을 사용하는 전제는 실제 애플리케이션에서 충돌 가능성이 상대적으로 낮다는 것입니다. 그의 디자인과 구현은 직접적이고 간결합니다. 현재 웹 애플리케이션에서는 낙관적 잠금을 사용하면 절대적인 이점이 있습니다.
따라서 Yii는 ActiveReocrd에 대한 낙관적 잠금 지원도 제공합니다.
Yii의 공식 문서에 따르면 낙관적 잠금을 사용하는 것은 4단계로 나뉩니다.
버전 번호를 나타내기 위해 잠가야 하는 테이블에 필드를 추가합니다. 물론 해당 모델도 필드에 추가하고 적절하게 조정해야 합니다. 예를 들어, 이 필드는 rule()에 추가되어야 합니다.
이전 단계에서 필드 이름을 반환하려면 yiidbActiveRecord::optimisticLock() 메서드를 오버로드하세요.
기록 수정 페이지 양식에 10a0c4c6fe334f1dd2642c4aa224944a을 추가하여 읽을 때 기록의 버전 번호를 임시로 저장하세요.
코드를 저장하는 곳에서 try ... catch를 사용하여 yiidbStaleObjectException 예외를 포착할 수 있는지 확인하세요. 그렇다면 이는 해당 기록이 수정되는 동안 해당 기록이 수정되었음을 의미합니다. 간단한 응답인 경우 해당 프롬프트를 제공할 수 있습니다. 똑똑하다면 충돌하지 않는 변경 사항을 병합하거나 diff 페이지를 표시할 수 있습니다.
기본적으로 낙관적 잠금은 비관적 잠금과 같은 데이터베이스 잠금 메커니즘을 사용하지 않습니다. 낙관적 잠금은 테이블에 개수 필드를 추가하여 현재 레코드가 수정된 횟수(버전 번호)를 나타냅니다.
그런 다음 업데이트하거나 삭제하기 전에 버전 번호를 비교하여 낙관적 잠금을 구현하세요.
버전 번호 필드 선언
버전 번호는 낙관적 잠금 구현의 기초입니다. 따라서 첫 번째 단계는 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을 반환하면 낙관적 잠금이 활성화되지 않습니다.
버전 번호만 늘어납니다.
낙관적 잠금을 통과하려면 두 가지 조건이 있습니다. 하나는 기본 키가 있어야 하고, 다른 하나는 업데이트가 완료되어야 한다는 것입니다.
낙관적 잠금이 활성화되면 다음 두 가지 상황에서만 StaleObjectException이 발생합니다.
다른 사람이 레코드를 삭제하면 기본 키가 더 이상 존재하지 않기 때문에 업데이트가 실패합니다.
버전 번호가 변경되어 업데이트 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 중국어 웹사이트의 기타 관련 기사를 참조하세요!