Web应用每每面临多用户环境,这种状况下的并发写入控制, 几乎成为每一个开发人员都必须掌握的一项技能。html
在并发环境下,有可能会出现脏读(Dirty Read)、不可重复读(Unrepeatable Read)、 幻读(Phantom Read)、更新丢失(Lost update)等状况。具体的表现能够自行搜索。数据库
为了应对这些问题,主流数据库都提供了锁机制,并引入了事务隔离级别的概念。 这里咱们都不做解释了,拿这些关键词一搜,网上大把大把的。并发
可是,就于具体开发过程而言,通常分为悲观锁和乐观锁两种方式来解决并发冲突问题。app
乐观锁(optimistic locking)表现出大胆、务实的态度。使用乐观锁的前提是, 实际应用当中,发生冲突的几率比较低。他的设计和实现直接而简洁。 目前Web应用中,乐观锁的使用占有绝对优点。yii
所以,Yii也为ActiveReocrd提供了乐观锁支持。高并发
根据Yii的官方文档,使用乐观锁,总共分4步:post
从本质上来说,乐观锁并无像悲观锁那样使用数据库的锁机制。 乐观锁经过在表中增长一个计数字段,来表示当前记录被修改的次数(版本号)。性能
而后在更新、删除前经过比对版本号来实现乐观锁。this
版本号是实现乐观锁的根本所在。因此第一步,咱们要告诉Yii,哪一个字段是版本号字段。 这个由 yii\db\BaseActiveRecord 负责:spa
public function optimisticLock() { return null; }
这个方法返回 null ,表示不使用乐观锁。那么咱们的Model中,要对此进行重载。 返回一个字符串,表示咱们用于标识版本号的字段。好比能够这样:
public function optimisticLock() { return 'ver'; }
说明当前的ActiveRecord中,有一个 ver 字段,能够为乐观锁所用。 那么Yii具体是如何借助这个 ver 字段实现乐观锁的呢?
具体来说,使用乐观锁以后的更新过程,就是这么一个流程:
因为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; } |
从上面的代码中,咱们不可贵出:
与更新过程相比,删除过程的乐观锁,更简单,更好理解。代码仍在 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管理策略, 正好与乐观锁存在极小程度上的不兼容。
二者分开来看,都是没问题的。组合到一块儿以后,大体看去好像也没问题。 可是bug之因此成为bug,坑之因此可以坑死人,正是因为其隐蔽性。
对此,也有一些意见提出来,使用时间戳做为版本号字段,就能够避免这个问题。 可是,时间戳的话,若是精度不够,如毫秒级别,那么在高并发,或者很是凑巧状况下, 仍有失效的可能。而若是使用高精度时间戳的话,成本又过高。
使用时间戳,可靠性并不比使用整型好。问题仍是要回到使用严谨的主键成生策略上来。
正如其名字,悲观锁(pessimistic locking)体现了一种谨慎的处事态度。其流程以下:
悲观锁确实很严谨,有效保证了数据的一致性,在C/S应用上有诸多成熟方案。 可是他的缺点与优势同样的明显:
整体来看,悲观锁不大适应于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 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应用的特色, 具备必定的适用性,可是也存在必定的缺陷: