原文: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 字段实现乐观锁的呢?

更新过程

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

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

由于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;
}

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

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

删除过程

与更新过程相比,删除过程的乐观锁,更简单,更好理解。代码仍在 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。如,常见的取当前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之后,重新加上自己的锁。换句话说, 此时悲观锁退化为乐观锁。

大致的原理性代码如下:

 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,而是把标识字段置0。

在具体使用方法上,可以参照以下代码:

 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应用的特点, 具有一定的适用性,但是也存在一定的缺陷:

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

Yii2.0的乐观锁与悲观锁(转)的更多相关文章

  1. Hibernate事务与并发问题处理(乐观锁与悲观锁)

    目录 一.数据库事务的定义 二.数据库事务并发可能带来的问题 三.数据库事务隔离级别 四.使用Hibernate设置数据库隔离级别 五.使用悲观锁解决事务并发问题 六.使用乐观锁解决事务并发问题 Hi ...

  2. mysql的锁--行锁,表锁,乐观锁,悲观锁

    一 引言--为什么mysql提供了锁 最近看到了mysql有行锁和表锁两个概念,越想越疑惑.为什么mysql要提供锁机制,而且这种机制不是一个摆设,还有很多人在用.在现代数据库里几乎有事务机制,aci ...

  3. SQL Server 锁机制 悲观锁 乐观锁 实测解析

    先引入一些概念,直接Copy其他Blogs中的,我就不单独写了. 一.为什么会有锁 多个用户同时对数据库的并发操作时会带来以下数据不一致的问题: 1.丢失更新 A,B两个用户读同一数据并进行修改,其中 ...

  4. 【数据库】mysql深入理解乐观锁与悲观锁

    转载:http://www.hollischuang.com/archives/934 在数据库的锁机制中介绍过,数据库管理系统(DBMS)中的并发控制的任务是确保在多个事务同时存取数据库中同一数据时 ...

  5. 【BAT面试题系列】面试官:你了解乐观锁和悲观锁吗?

    前言 乐观锁和悲观锁问题,是出现频率比较高的面试题.本文将由浅入深,逐步介绍它们的基本概念.实现方式(含实例).适用场景,以及可能遇到的面试官追问,希望能够帮助你打动面试官. 目录 一.基本概念 二. ...

  6. [数据库锁机制] 深入理解乐观锁、悲观锁以及CAS乐观锁的实现机制原理分析

    前言: 在并发访问情况下,可能会出现脏读.不可重复读和幻读等读现象,为了应对这些问题,主流数据库都提供了锁机制,并引入了事务隔离级别的概念.数据库管理系统(DBMS)中的并发控制的任务是确保在多个事务 ...

  7. 乐观锁vs悲观锁

    引言 为什么需要锁(并发控制) 在并发的环境中,会存在多个用户同时更新同一条数据,这时就会产生冲突. 冲突结果: 丢失更新:一个事务的更新覆盖了其它事务的更新结果,就是所谓的更新丢失. 脏读:当一个事 ...

  8. 多线程深入:乐观锁与悲观锁以及乐观锁的一种实现方式-CAS(转)

    原文:https://www.cnblogs.com/qjjazry/p/6581568.html 首先介绍一些乐观锁与悲观锁: 悲观锁:总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每 ...

  9. SQL并发处理方案——乐观锁和悲观锁

    (一)乐观锁和悲观锁的概念 悲观锁 在关系数据库管理系统里,悲观并发控制(又名“悲观锁”,Pessimistic Concurrency Control,缩写“PCC”)是一种并发控制的方法.它可以阻 ...

随机推荐

  1. mysql 一些常用指令

    登陆: mysql -u root -p //登陆,输入root密码 退出登陆 mysql>exit; mysql 为所有ip授权 mysql> GRANT ALL PRIVILEGES ...

  2. JavaScript学习日志(1)

    javascript用法: 1.HTML中的脚本必须位于<script>与</script>标签之间,可被放置在HTML页面的<body>和<head> ...

  3. 使用command对象添加删除查询记录

    private void button1_Click(object sender, EventArgs e) { //实例化数据库连接对象 SqlConnection sqlcon = new Sql ...

  4. rsync--数据镜像备份_转

    转载:rsync的配置使用 Rsync全称为remoterynchronization,rsync具有可使本地和远程两台主机之间的数据快速复制同步镜像.远程备份的功能,这个功能类似于scp命令,但优于 ...

  5. HBase学习笔记——Java API操作

    1.1.  配置 HBaseConfiguration 包:org.apache.hadoop.hbase.HBaseConfiguration 作用:通过此类可以对HBase进行配置 用法实例: C ...

  6. js 数组取出最大值最小值和平均值的方法

    1.直接遍历数组 ,,,,,,,]; ]; ;i<arr.length;i++){ if(max<arr[i]) max=arr[i]; } 2.借用Math的方法 ,,,,,,,]; v ...

  7. python 的简单抓取图片

    在我们日常上网浏览网页的时候,经常会看到一些好看的图片,我们就希望把这些图片保存下载,或者用户用来做桌面壁纸,或者用来做设计的素材. 我们最常规的做法就是通过鼠标右键,选择另存为.但有些图片鼠标右键的 ...

  8. 【PM日记】处理事务的逻辑

    首先你得时刻搞清楚在你的当下什么类型事情是最重要的,是与人交流,是推进项目,还是需要更加埋头学习知识. 每天你得有个list,可以是上一日遗留下来的部分未完成项,可以是idea收集箱中拿到的新任务,总 ...

  9. mac上制作u盘启动盘

    Mac上制作Ubuntu USB启动盘 一.下载ubuntu iso镜像 二.将iso转换为img文件 $ hdiutil convert -format UDRW -o /path/to/gener ...

  10. 目标跟踪之卡尔曼滤波---理解Kalman滤波的使用

    http://www.cnblogs.com/jcchen1987/p/4371439.html