《PHP实战:深入理解Yii2.0乐观锁与悲观锁的原理与使用》要点:
本文介绍了PHP实战:深入理解Yii2.0乐观锁与悲观锁的原理与使用,希望对您有用。如果有疑问,可以联系我们。
PHP实例本文介绍了深入理解Yii2.0乐观锁与悲观锁的原理与使用,分享给大家,具体如下:
PHP实例Web应用往往面临多用户环境,这种情况下的并发写入控制,几乎成为每个开发人员都必须掌握的一项技能.
PHP实例在并发环境下,有可能会出现脏读(Dirty Read)、不可重复读(Unrepeatable Read)、 幻读(Phantom Read)、更新丢失(Lost update)等情况.具体的表现可以自行搜索.
PHP实例为了应对这些问题,主流数据库都提供了锁机制,并引入了事务隔离级别的概念. 这里我们都不作解释了,拿这些关键词一搜,网上大把大把的.
PHP实例但是,就于具体开发过程而言,一般分为悲观锁和乐观锁两种方式来解决并发冲突问题.
PHP实例乐观锁
PHP实例乐观锁(optimistic locking)表现出大胆、务实的态度.使用乐观锁的前提是,实际应用当中,发生冲突的概率比较低.他的设计和实现直接而简洁. 目前Web应用中,乐观锁的使用占有绝对优势.
PHP实例因此,Yii也为ActiveReocrd提供了乐观锁支持.
PHP实例根据Yii的官方文档,使用乐观锁,总共分4步:
- 为需要加锁的表增加一个字段,用于表示版本号. 当然相应的Model也要为该字段的加入,作出适当调整.比如,rules() 中要加入该字段.
- 重载 yii\db\ActiveRecord::optimisticLock() 方法,返回上一步中的字段名.
- 在记录的修改页面表单中,加入一个 <input type=”hidden”> 用于暂存读取时的记录的版本号.
- 在保存代码的地方,使用 try … catch 看看是否能捕获一个 yii\db\StaleObjectException 异常.如果是,说明在本次修改这个记录的过程中,该记录已经被修改过了.简单应对的话,可以作出相应提示.智能点的话,可以合并不冲突的修改,或者显示一个diff页面.
PHP实例从本质上来讲,乐观锁并没有像悲观锁那样使用数据库的锁机制. 乐观锁通过在表中增加一个计数字段,来表示当前记录被修改的次数(版本号).
PHP实例然后在更新、删除前通过比对版本号来实现乐观锁.
PHP实例声明版本号字段
PHP实例版本号是实现乐观锁的根本所在.所以第一步,我们要告诉Yii,哪个字段是版本号字段. 这个由 yii\db\BaseActiveRecord 负责:
PHP实例 public function optimisticLock() { return null; }
PHP实例这个方法返回 null,表示不使用乐观锁.那么我们的Model中,要对此进行重载. 返回一个字符串,表示我们用于标识版本号的字段.比如可以这样:
PHP实例 public function optimisticLock() { return 'ver'; }
PHP实例说明当前的ActiveRecord中,有一个 ver 字段,可以为乐观锁所用. 那么Yii具体是如何借助这个 ver 字段实现乐观锁的呢?
PHP实例更新过程
PHP实例具体来讲,使用乐观锁之后的更新过程,就是这么一个流程:
- 读取要更新的记录.
- 对记录按照用户的意愿进行修改.当然,这个时候不会修改 ver 字段. 这个字段对用户是没意义的.
- 在保存记录前,再次读取这个记录的 ver 字段,与之前读取的值进行比对.
- 如果 ver 不同,说明在用户修改过程中,这个记录被别人改动过了.那么,我们要给出提示.
- 如果 ver 相同,说明这个记录未被修改过.那么,对 ver +1,并保存这个记录.这样子就完成了记录的更新.同时,该记录的版本号也加了1.
PHP实例由于ActiveRecord的更新过程最终都需要调用 yii\db\BaseActiveRecord::updateInteranl()
,理所当然地,处理乐观锁的代码,也就隐藏在这个方法中:
PHP实例 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; }
PHP实例从上面的代码中,我们不难得出:
- 当 optimisticLock() 返回 null 时,乐观锁不会被启用.
- 版本号只增不减.
- 通过乐观锁的条件有2个,一是主键要存在,二是要能够完成更新.
- 当启用乐观锁后,只有下列两种情况会抛出 StaleObjectException 异常:
- 当记录在被别人删除后,由于主键已经不存在,更新失败.
- 版本号已经变更,不满足更新的第二个条件.
PHP实例删除过程
PHP实例与更新过程相比,删除过程的乐观锁,更简单,更好理解.代码仍在 yii\db\BaseActiveRecord 中:
PHP实例 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; }
PHP实例比起更新过程,删除过程确实要简单得多.唯一的区别就是省去了版本号+1的步骤. 都要删除了,版本号+1有什么意义?
PHP实例乐观锁失效
PHP实例乐观锁存在失效的情况,属小概率事件,需要多个条件共同配合才会出现.如:
- 应用采用自己的策略管理主键ID.如,常见的取当前ID字段的最大值+1作为新ID.
- 版本号字段 ver 默认值为 0 .
- 用户A读取了某个记录准备修改它.该记录正好是ID最大的记录,且之前没被修改过,ver 为默认值 0.
- 在用户A读取完成后,用户B恰好删除了该记录.之后,用户C又插入了一个新记录.
- 此时,阴差阳错的,新插入的记录的ID与用户A读取的记录的ID是一致的,而版本号两者又都是默认值 0.
- 用户A在用户C操作完成后,修改完成记录并保存.由于ID、ver均可以匹配上,因此用户A成功保存.但是,却把用户C插入的记录覆盖掉了.
PHP实例乐观锁此时的失效,根本原因在于应用所使用的主键ID管理策略,正好与乐观锁存在极小程度上的不兼容.
PHP实例两者分开来看,都是没问题的.组合到一起之后,大致看去好像也没问题. 但是bug之所以成为bug,坑之所以能够坑死人,正是由于其隐蔽性.
PHP实例对此,也有一些意见提出来,使用时间戳作为版本号字段,就可以避免这个问题. 但是,时间戳的话,如果精度不够,如毫秒级别,那么在高并发,或者非常凑巧情况下,仍有失效的可能.而如果使用高精度时间戳的话,成本又太高.
PHP实例使用时间戳,可靠性并不比使用整型好.问题还是要回到使用严谨的主键成生策略上来.
PHP实例悲观锁
PHP实例正如其名字,悲观锁(pessimistic locking)体现了一种谨慎的处事态度.其流程如下:
- 在对任意记录进行修改前,先尝试为该记录加上排他锁(exclusive locking).
- 如果加锁失败,说明该记录正在被修改,那么当前查询可能要等待或者抛出异常. 具体响应方式由开发者根据实际需要决定.
- 如果成功加锁,那么就可以对记录做修改,事务完成后就会解锁了.
- 其间如果有其他对该记录做修改或加排他锁的操作,都会等待我们解锁或直接抛出异常.
PHP实例悲观锁确实很严谨,有效保证了数据的一致性,在C/S应用上有诸多成熟方案. 但是他的缺点与优点一样的明显:
- 悲观锁适用于可靠的持续性连接,诸如C/S应用. 对于Web应用的HTTP连接,先天不适用.
- 锁的使用意味着性能的损耗,在高并发、锁定持续时间长的情况下,尤其严重. Web应用的性能瓶颈多在数据库处,使用悲观锁,进一步收紧了瓶颈.
- 非正常中止情况下的解锁机制,设计和实现起来很麻烦,成本还很高.
- 不够严谨的设计下,可能产生莫名其妙的,不易被发现的,让人头疼到想把键盘一巴掌碎的死锁问题.
PHP实例总体来看,悲观锁不大适应于Web应用,Yii团队也认为悲观锁的实现过于麻烦,因此,ActiveRecord也没有提供悲观锁.
PHP实例作为Yii的构成基因之一的Ruby on rails,他的ActiveReocrd模型,倒是提供了悲观锁,但是使用起来也很麻烦.
PHP实例悲观锁的实现
PHP实例虽然悲观锁在Web应用上存在诸多不足,实现悲观锁也需要解决各种麻烦.但是,当用户提出他就是要用悲观锁时,牙口再不好的码农,就是咬碎牙也是要啃下这块骨头来.
PHP实例对于一个典型的Web应用而言,这里提供个人常用的方法来实现悲观锁.
PHP实例首先,在要锁定的表里,加一个字段如 locked_at,表示当前记录被锁定时的时间,当为 0 时,表示该记录未被锁定,或者认为这是1970年时加的锁.
PHP实例当要修改某个记录时,先看看当前时间与 locked_at 字段相差是否超过预定的一个时长T,比如 30 min,1 h 之类的.