乐观锁存在失效的情况,属小概率事件,需要多个条件共同配合才会出现。如:
- 应用采用自己的策略管理主键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之后,重新加上自己的锁。换句话说, 此时悲观锁退化为乐观锁。
大致的原理性代码如下:
// 定义锁定的最大时长,超过该时长后,自动解锁。
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。
(编辑:瑞安网)
【声明】本站内容均来自网络,其相关言论仅代表作者个人观点,不代表本站立场。若无意侵犯到您的权利,请及时与联系站长删除相关内容!
|