MySQL乐观锁与悲观锁
说明
遇见并发情况,需要保证数据的准确性,也就是与正确的预期一致,此时就会用到锁。
锁是在并发下控制程序的执行逻辑,以此来保证数据按照预期变动。
如果不加锁,并发情况下的可能数据不一致的情况,这是个概率问题。
乐观锁CAS
简介
乐观锁很乐观,假设数据一般情况不会造成冲突,属于程序层面的逻辑锁,在数据进行更新时,才进行锁的检测。是通过添加一个版本号的方式实现的,每当数据这一行所在的数据发生变化,则对应的版本号+1,更新数据时,将版本号作为查询条件。
至于是否要加事务,看写操作单条数据还是写操作多条数据。
注意:网上很多解决方案用时间戳来做version字段,我持反对意见,并发可能是一瞬间的事,不到一秒就有好多请求,用时间戳粒度太大,用随机字符串都比用这个强。
用法
#示例
update test set score = score + 1 where id = 1
#优化为,这种简单,但是会有ABA的问题:
select score as old_score from test where id = 1;
update test set score = score + 1 where id = 1 and score = old_score;
#或者添加一个version字段,这种不存在ABA的问题
select version from test where id = 1;
update test set score = score + 1 where id = 1 and version = version;
适用场景
- 读多写少:由于并发写操作较少,乐观锁的修改数据受影响行数为0概率也较低。
- 允许一定量的重试或不需要重试的场景:这个要根据业务,否则来回重试会降低性能。
优点
实现简单:乐观锁在代码上就可以实现,不需要额外对数据库额外操作。
无死锁风险:悲观锁有死锁风险,乐观锁没有。
无需重试情况下,性能较高:乐观锁机制在并发访问情况下,不需要像悲观锁那样阻塞其他事务,提供了更高的并发性能,前提当前业务需求能容忍写操作失败的情况。
缺点
并发冲突:多加了一个where条件,只能保证数据最终不会出错,不能保证每条写操作的SQL都执行成功(也就是受影响行数>0)。
不提供强一致性:强一致性要求数据的状态在任何时刻都保持一致,悲观锁是到写操作那一步才去验证,期间只是做了个where条件的过滤。
ABA问题:一个字段的值在请求X中查询出来是A,后续代码实现乐观锁,因为并发量大,同时过来一个Y请求,将A值改成了B,因为一些业务原因又改成了A,整个过程虽然不影响请求X的结果,且能正常执行,但是联合其它数据,这个情况是否符合业务场景,不好说,所以最好的解决方案,就是专门做一个version字段,且不会与之前的version重复,即可,把这个version字段作为where条件,而不是存A或者B字段的所在字段作为where条件。
悲观锁
简介
悲观锁比较悲观,假设数据一定会造成冲突,属于MySQL层面的锁。通过加锁阻塞其他事务,悲观锁可以保证在任何时刻,只有一个事务能够修改或访问共享资源,从而实现了强一致性。这意味着在悲观锁机制下,每个事务的读写操作都是有序、线性的。
需要事务的参与。
用法
在事务中的查询语句添加for update即可。
如果此时执行了三行内容没有commit,再次执行update test set score = score + 1 where id = 1;则处于阻塞状态,需要等commit之后,才能执行。
start transaction;
select * from test where id = 1 for update;
update test set score = score + 1 where id = 1;
commit;
适用场景
写多写操作的前提,是保证数据不出错,悲观锁的机制很符合。
优点
强一致性:基于事务又加锁,一致性可以保证。
实现简单:在事务中for update即可,开发者不需要在这上面关注太多。
缺点
死锁风险:悲观锁在使用不当的情况下可能导致死锁。如果多个事务持有锁并相互等待对方释放锁的情况发生,就可能发生死锁。
性能较低:悲观锁通常需要在整个事务过程中锁定资源,这可能导致其他事务阻塞。
模拟实现
前置准备
#创建一个非常简单的表,并插入一条数据
CREATE TABLE `test` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`score` int(11) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT INTO `test` (`id`, `score`) VALUES (1, 0);
需求模拟
查询test表id为1的数据,检测到score值为0,则自增,否则终止。
不加锁实现
为了提升性能,使用了原生PDO操作MySQL去实现。
//连接数据库
$pdo = new \PDO("mysql:host=127.0.0.1;port=3306;dbname=temp;", 'root', 'root');
$pdo->setAttribute(\PDO::ATTR_ERRMODE,\PDO::ERRMODE_EXCEPTION);
$pdo->query('set names utf8mb4');
//查询
$query = $pdo->query('select score from test');
$query->setFetchMode(\PDO::FETCH_ASSOC);
$res = $query->fetchALL();
if($res[0]['score'] == 0) {
$res = $pdo->exec('update test set score = score + 1 where id = 1');
var_dump($res);
}
并发模拟
用ab压测,发现效果不明显,可能是ab工具不够力或者电脑线程数量太少导致。
这里用的是ApiPost的压测工具。500个并发去多次压测一轮,发现score值是3,证明确实因为并发造成了与预期结果不一致的情况。
乐观锁解决方案(忽略ABA问题)
#将sql改为如下所示,实测多次,score最大值是1
#注意这种行为,只能保证score的值最大是1,无法保证执行这个SQL的时候,受影响行数>0
update test set score = score + 1 where id = 1 and score = 0
悲观锁解决方案
$pdo = new \PDO("mysql:host=127.0.0.1;port=3306;dbname=temp;", 'root', 'root');
$pdo->setAttribute(\PDO::ATTR_ERRMODE,\PDO::ERRMODE_EXCEPTION);
$pdo->query('set names utf8mb4');
$redis = new Redis;
$redis->connect('127.0.0.1', 6379);
try {
$pdo->beginTransaction();
$stmt = $pdo->prepare("select * from test where id = 1 for update");
$stmt->execute();
$res = $stmt->fetch(PDO::FETCH_ASSOC);
if($res['score'] == 0) {
$stmt = $pdo->prepare("UPDATE test SET score = (score + 1) where id = 1");
$stmt->execute();
$pdo->commit();
$redis->incr('commit');
} else {
$redis->incr('rollback');
$pdo->rollBack();
}
} catch (PDOException $e) {
$pdo->rollBack();
}
// 关闭数据库连接
$pdo = null;
500个并发压测一轮,查看redis数据,commit数量为1,其余499全部都是rollback,这么多的回滚不代表大错特错(演示效果),而是因为第一个事务执行成功后,再执行其它事务,正因为一个一个排队,就不会出现同时读取多个score值为0的情况了。