深入理解Yii2.0乐观锁与悲观锁的原理与使用
本文介绍了深入理解Yii2.0乐观锁与悲观锁的原理与使用,分享给大家,具体如下:
Web应用往往面临多用户环境,这种情况下的并发写入控制,几乎成为每个开发人员都必须掌握的一项技能。
在并发环境下,有可能会出现脏读(DirtyRead)、不可重复读(UnrepeatableRead)、幻读(PhantomRead)、更新丢失(Lostupdate)等情况。具体的表现可以自行搜索。
为了应对这些问题,主流数据库都提供了锁机制,并引入了事务隔离级别的概念。这里我们都不作解释了,拿这些关键词一搜,网上大把大把的。
但是,就于具体开发过程而言,一般分为悲观锁和乐观锁两种方式来解决并发冲突问题。
乐观锁
乐观锁(optimisticlocking)表现出大胆、务实的态度。使用乐观锁的前提是,实际应用当中,发生冲突的概率比较低。他的设计和实现直接而简洁。目前Web应用中,乐观锁的使用占有绝对优势。
因此,Yii也为ActiveReocrd提供了乐观锁支持。
根据Yii的官方文档,使用乐观锁,总共分4步:
- 为需要加锁的表增加一个字段,用于表示版本号。当然相应的Model也要为该字段的加入,作出适当调整。比如,rules()中要加入该字段。
- 重载yii\db\ActiveRecord::optimisticLock()方法,返回上一步中的字段名。
- 在记录的修改页面表单中,加入一个
用于暂存读取时的记录的版本号。 - 在保存代码的地方,使用try...catch看看是否能捕获一个yii\db\StaleObjectException异常。如果是,说明在本次修改这个记录的过程中,该记录已经被修改过了。简单应对的话,可以作出相应提示。智能点的话,可以合并不冲突的修改,或者显示一个diff页面。
从本质上来讲,乐观锁并没有像悲观锁那样使用数据库的锁机制。乐观锁通过在表中增加一个计数字段,来表示当前记录被修改的次数(版本号)。
然后在更新、删除前通过比对版本号来实现乐观锁。
声明版本号字段
版本号是实现乐观锁的根本所在。所以第一步,我们要告诉Yii,哪个字段是版本号字段。这个由yii\db\BaseActiveRecord负责:
publicfunctionoptimisticLock() { returnnull; }
这个方法返回null,表示不使用乐观锁。那么我们的Model中,要对此进行重载。返回一个字符串,表示我们用于标识版本号的字段。比如可以这样:
publicfunctionoptimisticLock() { return'ver'; }
说明当前的ActiveRecord中,有一个ver字段,可以为乐观锁所用。那么Yii具体是如何借助这个ver字段实现乐观锁的呢?
更新过程
具体来讲,使用乐观锁之后的更新过程,就是这么一个流程:
- 读取要更新的记录。
- 对记录按照用户的意愿进行修改。当然,这个时候不会修改ver字段。这个字段对用户是没意义的。
- 在保存记录前,再次读取这个记录的ver字段,与之前读取的值进行比对。
- 如果ver不同,说明在用户修改过程中,这个记录被别人改动过了。那么,我们要给出提示。
- 如果ver相同,说明这个记录未被修改过。那么,对ver+1,并保存这个记录。这样子就完成了记录的更新。同时,该记录的版本号也加了1。
由于ActiveRecord的更新过程最终都需要调用yii\db\BaseActiveRecord::updateInteranl(),理所当然地,处理乐观锁的代码,也就隐藏在这个方法中:
protectedfunctionupdateInternal($attributes=null) { if(!$this->beforeSave(false)){ returnfalse; } //获取等下要更新的字段及新的字段值 $values=$this->getDirtyAttributes($attributes); if(empty($values)){ $this->afterSave(false,$values); return0; } //把原来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){ thrownewStaleObjectException('Theobjectbeingupdatedisoutdated.'); } $changedAttributes=[]; foreach($valuesas$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个,一是主键要存在,二是要能够完成更新。
- 当启用乐观锁后,只有下列两种情况会抛出StaleObjectException异常:
- 当记录在被别人删除后,由于主键已经不存在,更新失败。
- 版本号已经变更,不满足更新的第二个条件。
删除过程
与更新过程相比,删除过程的乐观锁,更简单,更好理解。代码仍在yii\db\BaseActiveRecord中:
publicfunctiondelete() { $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){ thrownewStaleObjectException('Theobjectbeingdeletedisoutdated.'); } $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,坑之所以能够坑死人,正是由于其隐蔽性。
对此,也有一些意见提出来,使用时间戳作为版本号字段,就可以避免这个问题。但是,时间戳的话,如果精度不够,如毫秒级别,那么在高并发,或者非常凑巧情况下,仍有失效的可能。而如果使用高精度时间戳的话,成本又太高。
使用时间戳,可靠性并不比使用整型好。问题还是要回到使用严谨的主键成生策略上来。
悲观锁
正如其名字,悲观锁(pessimisticlocking)体现了一种谨慎的处事态度。其流程如下:
- 在对任意记录进行修改前,先尝试为该记录加上排他锁(exclusivelocking)。
- 如果加锁失败,说明该记录正在被修改,那么当前查询可能要等待或者抛出异常。具体响应方式由开发者根据实际需要决定。
- 如果成功加锁,那么就可以对记录做修改,事务完成后就会解锁了。
- 其间如果有其他对该记录做修改或加排他锁的操作,都会等待我们解锁或直接抛出异常。
悲观锁确实很严谨,有效保证了数据的一致性,在C/S应用上有诸多成熟方案。但是他的缺点与优点一样的明显:
- 悲观锁适用于可靠的持续性连接,诸如C/S应用。对于Web应用的HTTP连接,先天不适用。
- 锁的使用意味着性能的损耗,在高并发、锁定持续时间长的情况下,尤其严重。Web应用的性能瓶颈多在数据库处,使用悲观锁,进一步收紧了瓶颈。
- 非正常中止情况下的解锁机制,设计和实现起来很麻烦,成本还很高。
- 不够严谨的设计下,可能产生莫名其妙的,不易被发现的,让人头疼到想把键盘一巴掌碎的死锁问题。
总体来看,悲观锁不大适应于Web应用,Yii团队也认为悲观锁的实现过于麻烦,因此,ActiveRecord也没有提供悲观锁。
作为Yii的构成基因之一的Rubyonrails,他的ActiveReocrd模型,倒是提供了悲观锁,但是使用起来也很麻烦。
悲观锁的实现
虽然悲观锁在Web应用上存在诸多不足,实现悲观锁也需要解决各种麻烦。但是,当用户提出他就是要用悲观锁时,牙口再不好的码农,就是咬碎牙也是要啃下这块骨头来。
对于一个典型的Web应用而言,这里提供个人常用的方法来实现悲观锁。
首先,在要锁定的表里,加一个字段如locked_at,表示当前记录被锁定时的时间,当为0时,表示该记录未被锁定,或者认为这是1970年时加的锁。
当要修改某个记录时,先看看当前时间与locked_at字段相差是否超过预定的一个时长T,比如30min,1h之类的。
如果没超过,说明该记录有人正在修改,我们暂时不能打开(读取)他来修改。否则,说明可以修改,我们先将当前时间戳保存到该记录的locked_at字段。那么之后的时长T内如果有人要来改这个记录,他会由于加锁失败而无法读取,从而无法修改。
我们在完成修改后,即将保存时,要比对现在的locked_at。只有在locked_at一致时,才认为刚刚是我们加的锁,我们才可以保存。否则,说明在我们加锁后,又有人加了锁正在修改,或者已经完成了修改,使得locked_at归0。
这种情况主要是由于我们的修改时长过长,超过了预定的T。原先的加锁自动解开,其他用户可以在我们加锁时刻再过T之后,重新加上自己的锁。换句话说,此时悲观锁退化为乐观锁。
大致的原理性代码如下:
//悲观锁AR基类,需要使用悲观锁的AR可以由此派生 classPLockARextends\yii\db\BaseActiveRecord{ //声明悲观锁使用的标记字段,作用类似于optimisticLock()方法 publicfunctionpesstimisticLock(){ returnnull; } //定义锁定的最大时长,超过该时长后,自动解锁。 publicfunctionmaxLockTime(){ return0; } //尝试加锁,加锁成功则返回true publicfunctionlock(){ $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){ returnfalse; } returntrue; } //重载updateInternal() protectedfunctionupdateInternal($attributes=null) { //这些与原来代码一样 if(!$this->beforeSave(false)){ returnfalse; } $values=$this->getDirtyAttributes($attributes); if(empty($values)){ $this->afterSave(false,$values); return0; } $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){ thrownewStaleObjectException('Theobjectbeingupdatedisoutdated.'); } $changedAttributes=[]; foreach($valuesas$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派生模型类 classPostextendsPLockAR{ //重载定义悲观锁标识字段,如locked_at publicfunctionpesstimisticLock(){ return'locked_at'; } //重载定义最大锁定时长,如1小时 publicfunctionmaxLockTime(){ return3600000; } } //修改前要尝试加锁 classSectionControllerextendsController{ publicfunctionactionUpdate($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应用的特点,具有一定的适用性,但是也存在一定的缺陷:
- 最长允许锁定时长会带来一定的副作用。时间定得长了,可能要等很长时间,才能重新编辑非正常解锁的记录。时间定得短了,则经常退化成乐观锁。
- 时间戳精度问题。如果精度不够,那么在加锁时,与我们讨论过的乐观锁失效存,在同样的漏洞。
- 这种形式的锁定,只是应用层面的锁定,并非数据库层面的锁定。如果存在应用之外对于数据库的写入操作。这个锁定机制是无效的。
以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持毛票票。