深入理解Yii2.0乐观锁与悲观锁的原理与使用


Posted in PHP onJuly 26, 2017

本文介绍了深入理解Yii2.0乐观锁与悲观锁的原理与使用,分享给大家,具体如下:

Web应用往往面临多用户环境,这种情况下的并发写入控制, 几乎成为每个开发人员都必须掌握的一项技能。

在并发环境下,有可能会出现脏读(Dirty Read)、不可重复读(Unrepeatable Read)、 幻读(Phantom Read)、更新丢失(Lost update)等情况。具体的表现可以自行搜索。

为了应对这些问题,主流数据库都提供了锁机制,并引入了事务隔离级别的概念。 这里我们都不作解释了,拿这些关键词一搜,网上大把大把的。

但是,就于具体开发过程而言,一般分为悲观锁和乐观锁两种方式来解决并发冲突问题。

乐观锁

乐观锁(optimistic locking)表现出大胆、务实的态度。使用乐观锁的前提是, 实际应用当中,发生冲突的概率比较低。他的设计和实现直接而简洁。 目前Web应用中,乐观锁的使用占有绝对优势。

因此,Yii也为ActiveReocrd提供了乐观锁支持。

根据Yii的官方文档,使用乐观锁,总共分4步:

  • 为需要加锁的表增加一个字段,用于表示版本号。 当然相应的Model也要为该字段的加入,作出适当调整。比如, rules() 中要加入该字段。
  • 重载 yii\db\ActiveRecord::optimisticLock() 方法,返回上一步中的字段名。
  • 在记录的修改页面表单中,加入一个 <input type="hidden"> 用于暂存读取时的记录的版本号。
  • 在保存代码的地方,使用 try ... catch 看看是否能捕获一个 yii\db\StaleObjectException 异常。如果是,说明在本次修改这个记录的过程中, 该记录已经被修改过了。简单应对的话,可以作出相应提示。智能点的话, 可以合并不冲突的修改,或者显示一个diff页面。

从本质上来讲,乐观锁并没有像悲观锁那样使用数据库的锁机制。 乐观锁通过在表中增加一个计数字段,来表示当前记录被修改的次数(版本号)。

然后在更新、删除前通过比对版本号来实现乐观锁。

声明版本号字段

版本号是实现乐观锁的根本所在。所以第一步,我们要告诉Yii,哪个字段是版本号字段。 这个由 yii\db\BaseActiveRecord 负责:

public function optimisticLock()
{
  return null;
}

这个方法返回 null ,表示不使用乐观锁。那么我们的Model中,要对此进行重载。 返回一个字符串,表示我们用于标识版本号的字段。比如可以这样:

public function optimisticLock()
{
  return 'ver';
}

说明当前的ActiveRecord中,有一个 ver 字段,可以为乐观锁所用。 那么Yii具体是如何借助这个 ver 字段实现乐观锁的呢?

更新过程

具体来讲,使用乐观锁之后的更新过程,就是这么一个流程:

  1. 读取要更新的记录。
  2. 对记录按照用户的意愿进行修改。当然,这个时候不会修改 ver 字段。 这个字段对用户是没意义的。
  3. 在保存记录前,再次读取这个记录的 ver 字段,与之前读取的值进行比对。
  4. 如果 ver 不同,说明在用户修改过程中,这个记录被别人改动过了。那么, 我们要给出提示。
  5. 如果 ver 相同,说明这个记录未被修改过。那么,对 ver +1, 并保存这个记录。这样子就完成了记录的更新。同时,该记录的版本号也加了1。

由于ActiveRecord的更新过程最终都需要调用 yii\db\BaseActiveRecord::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;
}

从上面的代码中,我们不难得出:

  1. 当 optimisticLock() 返回 null 时,乐观锁不会被启用。
  2. 版本号只增不减。
  3. 通过乐观锁的条件有2个,一是主键要存在,二是要能够完成更新。
  4. 当启用乐观锁后,只有下列两种情况会抛出 StaleObjectException 异常:
    1. 当记录在被别人删除后,由于主键已经不存在,更新失败。
    2. 版本号已经变更,不满足更新的第二个条件。

删除过程

与更新过程相比,删除过程的乐观锁,更简单,更好理解。代码仍在 yii\db\BaseActiveRecord 中:

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有什么意义?

乐观锁失效

乐观锁存在失效的情况,属小概率事件,需要多个条件共同配合才会出现。如:

  1. 应用采用自己的策略管理主键ID。如,常见的取当前ID字段的最大值+1作为新ID。
  2. 版本号字段 ver 默认值为 0 。
  3. 用户A读取了某个记录准备修改它。该记录正好是ID最大的记录,且之前没被修改过, ver 为默认值 0。
  4. 在用户A读取完成后,用户B恰好删除了该记录。之后,用户C又插入了一个新记录。
  5. 此时,阴差阳错的,新插入的记录的ID与用户A读取的记录的ID是一致的, 而版本号两者又都是默认值 0。
  6. 用户A在用户C操作完成后,修改完成记录并保存。由于ID、ver均可以匹配上, 因此用户A成功保存。但是,却把用户C插入的记录覆盖掉了。

乐观锁此时的失效,根本原因在于应用所使用的主键ID管理策略, 正好与乐观锁存在极小程度上的不兼容。

两者分开来看,都是没问题的。组合到一起之后,大致看去好像也没问题。 但是bug之所以成为bug,坑之所以能够坑死人,正是由于其隐蔽性。

对此,也有一些意见提出来,使用时间戳作为版本号字段,就可以避免这个问题。 但是,时间戳的话,如果精度不够,如毫秒级别,那么在高并发,或者非常凑巧情况下, 仍有失效的可能。而如果使用高精度时间戳的话,成本又太高。

使用时间戳,可靠性并不比使用整型好。问题还是要回到使用严谨的主键成生策略上来。

悲观锁

正如其名字,悲观锁(pessimistic locking)体现了一种谨慎的处事态度。其流程如下:

  1. 在对任意记录进行修改前,先尝试为该记录加上排他锁(exclusive locking)。
  2. 如果加锁失败,说明该记录正在被修改,那么当前查询可能要等待或者抛出异常。 具体响应方式由开发者根据实际需要决定。
  3. 如果成功加锁,那么就可以对记录做修改,事务完成后就会解锁了。
  4. 其间如果有其他对该记录做修改或加排他锁的操作,都会等待我们解锁或直接抛出异常。

悲观锁确实很严谨,有效保证了数据的一致性,在C/S应用上有诸多成熟方案。 但是他的缺点与优点一样的明显:

  1. 悲观锁适用于可靠的持续性连接,诸如C/S应用。 对于Web应用的HTTP连接,先天不适用。
  2. 锁的使用意味着性能的损耗,在高并发、锁定持续时间长的情况下,尤其严重。 Web应用的性能瓶颈多在数据库处,使用悲观锁,进一步收紧了瓶颈。
  3. 非正常中止情况下的解锁机制,设计和实现起来很麻烦,成本还很高。
  4. 不够严谨的设计下,可能产生莫名其妙的,不易被发现的, 让人头疼到想把键盘一巴掌碎的死锁问题。

总体来看,悲观锁不大适应于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. 新增加了一个加锁方法,一个获取锁定最大时长的方法。
  2. 保存时不再是把标识字段+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应用的特点, 具有一定的适用性,但是也存在一定的缺陷:

  1. 最长允许锁定时长会带来一定的副作用。时间定得长了,可能要等很长时间, 才能重新编辑非正常解锁的记录。时间定得短了,则经常退化成乐观锁。
  2. 时间戳精度问题。如果精度不够,那么在加锁时,与我们讨论过的乐观锁失效存, 在同样的漏洞。
  3. 这种形式的锁定,只是应用层面的锁定,并非数据库层面的锁定。 如果存在应用之外对于数据库的写入操作。这个锁定机制是无效的。

以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持三水点靠木。

PHP 相关文章推荐
Snoopy类使用小例子
Apr 15 PHP
PHP6 先修班 JSON实例代码
Aug 23 PHP
php 信息采集程序代码
Mar 17 PHP
PHP如何解决网站大流量与高并发的问题
Jun 25 PHP
PHP中计算字符串相似度的函数代码
Dec 29 PHP
基于PHP对XML的操作详解
Jun 07 PHP
PHP操作Memcache实例介绍
Jun 14 PHP
laravel中的错误与日志用法详解
Jul 26 PHP
php+redis实现注册、删除、编辑、分页、登录、关注等功能示例
Feb 15 PHP
根据key删除数组中指定的元素实现方法
Mar 02 PHP
PHP基于递归算法解决兔子生兔子问题
May 11 PHP
Laravel框架验证码类用法实例分析
Sep 11 PHP
php实现微信企业号支付个人的方法详解
Jul 26 #PHP
PHP编程实现微信企业向用户付款的方法示例
Jul 26 #PHP
浅谈Yii乐观锁的使用及原理
Jul 25 #PHP
PHP异常处理定义与使用方法分析
Jul 25 #PHP
PHP实现防盗链的方法分析
Jul 25 #PHP
浅谈PHP发送HTTP请求的几种方式
Jul 25 #PHP
php 删除指定文件夹的实例讲解
Jul 25 #PHP
You might like
PHP下载生成的csv文件及问题总结
2015/08/06 PHP
php验证码生成器
2017/05/24 PHP
浅谈ThinkPHP5.0版本和ThinkPHP3.2版本的区别
2017/06/17 PHP
插件:检测javascript的内存泄漏
2007/03/04 Javascript
讨论javascript(一)工厂方式 js面象对象的定义方法
2009/12/15 Javascript
调试Node.JS的辅助工具(NodeWatcher)
2012/01/04 Javascript
JavaScript高级程序设计(第3版)学习笔记5 js语句
2012/10/11 Javascript
jQuery实现单行文字间歇向上滚动源代码
2013/06/02 Javascript
JQuery验证jsp页面属性是否为空(实例代码)
2013/11/08 Javascript
js转化毫秒为时间格式代码
2014/04/10 Javascript
点评js异步加载的4种方式
2015/12/22 Javascript
深入理解jQuery之防止冒泡事件
2016/05/24 Javascript
在web中js实现类似excel的表格控件
2016/09/01 Javascript
js无提示关闭浏览器窗口的两种方法分析
2016/11/06 Javascript
微信小程序 五星评分(包括半颗星评分)实例代码
2016/12/14 Javascript
vue 解决循环引用组件报错的问题
2018/09/06 Javascript
微信小程序实现购物车小功能
2020/12/30 Javascript
Python中urllib2模块的8个使用细节分享
2015/01/01 Python
在服务器端实现无间断部署Python应用的教程
2015/04/16 Python
python 换位密码算法的实例详解
2017/07/19 Python
Python中判断子串存在的性能比较及分析总结
2019/06/23 Python
Django使用中间键实现csrf认证详解
2019/07/22 Python
python多线程案例之多任务copy文件完整实例
2019/10/29 Python
css3教程之倾斜页面
2014/01/27 HTML / CSS
介绍一下Ruby中的对象,属性和方法
2012/07/11 面试题
清洁工表扬信
2014/01/08 职场文书
计算机学生求职信范文
2014/01/30 职场文书
初三班主任寄语大全
2014/04/04 职场文书
奥巴马开学演讲稿
2014/05/15 职场文书
授权委托书
2014/09/17 职场文书
员工拾金不昧表扬稿
2015/05/05 职场文书
Redis读写分离搭建的完整步骤
2021/09/14 Redis
MySQL修炼之联结与集合浅析
2021/10/05 MySQL
Vue的列表之渲染,排序,过滤详解
2022/02/24 Vue.js
【海涛解说】史上最给力比赛,挑战DOTA极限
2022/04/01 DOTA
使用python绘制分组对比柱状图
2022/04/21 Python