MySQL中的悲观锁和乐观锁
悲观锁(即悲观并发控制)和乐观锁(即乐观并发控制)是数据库系统中并发控制主要采用的技术手段。针对不同的业务场景,应该选用不同的并发控制方式。
注意: 不要把它们和数据库中提供的锁机制(行锁、表锁、排他锁、共享锁)混为一谈。
1. 悲观锁
悲观锁: 又称悲观并发控制(Pessimistic Concurrency Control),指的是对数据被外界(包括本系统当前的其他事务,以及来自外部系统的事务)修改持保守态度(悲观),在整个事务处理过程中,将数据进行锁定,直到事务处理完毕,才释放锁。
悲观锁,悲观地认为会发生并发问题,屏蔽一切可能违反数据一致性的操作。
悲观锁的特点:
- 需要依靠数据库中的锁机制来实现,即通过常用的select … for update操作来实现悲观锁。
- 需要开启事务,在事务中实现锁机制。
- 可以最大程度的保证数据操作的独占性。
- select for update语句中所有扫描过的行都会被锁上,这一点很容易造成问题。如果用悲观锁请确保用到了索引,而不是全表扫描。
- 长事务中的锁等待,会导致其他用户长时间无法操作。
- 主要用于数据争用激烈的环境,以及发生并发冲突时使用锁保护数据的成本要低于回滚事务的成本的环境中。
2. 乐观锁
乐观锁: 又称乐观并发控制(Optimistic Concurrency Control),乐观地认为不会发生并发问题,只在提交更新操作时检查是否违反数据的一致性。
乐观锁在数据库中的实现完全是逻辑性的,不需要数据库提供特殊的支持。一般的做法是在数据表中增加一个字段(版本号或者时间戳),作为数据的版本标识。读取数据时,将版本号一同读出;之后更新数据时,加入版本号条件,更新成功就将版本号加1。
乐观锁的重点在于,更新数据时,加入版本号匹配条件,将数据的版本与数据表中对应记录的当前版本进行匹配更新,如果数据的版本号等于数据表的当前版本号,则获取锁成功,也就是更新成功;否则,更新失败,需要回滚整个业务操作。
乐观锁是否在事务中其实是无所谓的,它的机制是:
在数据库中,update同一行的情况是不允许并发的,即数据库每次执行一条update语句时会获取被update行的写锁,直到这一行被成功更新后才释放。因此在业务操作进行前获取需要锁的数据的当前版本号,然后实际更新数据时,以版本号作为条件,再次对比版本号确认与之前获取的相同,并更新版本号,即可确认没有发生并发的修改。如果更新失败即可认为老版本的数据已经被并发修改掉了,此时认为获取锁失败,需要回滚整个业务操作并可根据需要重试整个过程。
乐观锁的特点:
- 不需要依靠数据库中的锁机制来实现,但需要在表中新增一个版本号,在逻辑上实现。
- 无论是否开启事务,都可以在逻辑上实现乐观锁。
- 乐观锁在不发生取锁失败的情况下开销比悲观锁小,但是一旦发生失败回滚开销则比较大,因此适合用在取锁失败概率比较小的场景,可以提升系统并发性能。
示例: 假如,现在有一个用户表(test_user1),包含三个字段:id、username、score。用户每支付成功一次,异步回调时,就将他的积分加10。
异步回调的地址(以TP框架为例),具体代码为:
public function test() {
$id = 1; // 用户id
$score = 10; // 用户的积分
$Model = M('user1', 'test_');
$user_info = $Model->where(array('id'=>$id))->find();
$data = array('score'=>array("exp", "score+{$score}"));
$res = $Model->where(array('id'=>$id))->save($data);
echo 'result:'.$res.'--sql:'.$Model->getLastSql();
}
如果支付成功的异步通知不会出现并发(理想情况下),是没有问题的。
但是,实际生活中,异步通知可能产生并发(比如用户支付成功后,异步通知同时请求了2次),该并发就会产生问题(用户的积分居然加了20!!!)。
解决办法: 用乐观锁防止该并发冲突。
首先,给用户表(test_user1)新增一个字段version。修改后的表结构为:
CREATE TABLE IF NOT EXISTS `test_user1` ( `id` int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键', `username` varchar(8) NOT NULL DEFAULT '' COMMENT '用户名', `score` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '积分', `version` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '版本号', PRIMARY KEY (`id`) ) ENGINE=MyISAM DEFAULT CHARSET=utf8 AUTO_INCREMENT=1 ;
用户表(test_user1)的初始数据为:
id | username | score | version |
---|---|---|---|
1 | user01 | 0 | 0 |
其次,更改异步回调地址的代码为:
public function test() {
$id = 1; // 用户id
$score = 10; // 用户的积分
$Model = M('user1', 'test_');
$user_info = $Model->where(array('id'=>$id))->find();
$version = $user_info['version']?:0; // 版本号
// 版本号加1
$data = array('score'=>array("exp", "score+{$score}"), 'version'=>array("exp", "version+1"));
// 乐观锁的核心思想:更新数据时,加入版本号version条件
$res = $Model->where(array('id'=>$id, 'version'=>$version))->save($data);
echo 'result:'.$res.'--sql:'.$Model->getLastSql();
}
最后,我们来模拟一下并发的请求。
测试环境:Windows系统服务器(64位架构、双内核)、XAMPP集成包
测试工具:apache自带的ab压力测试工具
测试方法:打开cmd命令行,切换到ab工具的可执行文件的所在目录(D:\xampp\apache\bin)后,执行如下命令:
ab -n200 -c10 http://127.0.0.1/index.php/Demo/test
参数说明:-n 表示请求的总数;-c 表示每次的请求数,即并发数。
注意: 需确保ab工具和项目代码在同一个服务器上,否则,可能无法测试出并发问题。
命令执行完毕后,用户表的数据变为:
id | username | score | version |
---|---|---|---|
1 | user01 | 1560 | 156 |
说明: 上面的ab命令,每次执行的结果并不会完全一样。如果把-n和-c的值都改为2,再来多次执行ab命令,会发现有时用户的积分加了10,有时却加了20。原因是:假设从查询版本号到更新数据所需的时间为50ms,如果两个请求是并发(严格意义上的同时请求)的,或者两个请求的间隔时间小于50ms(可以认为是非严格意义上的并发),用户的积分都只会加10;如果两个请求的间隔时间大于50ms,用户的积分就会加20,此时,可认为这两个请求是顺序执行的。对于顺序执行的两个请求,要想使他们只更新数据一次,可用幂等性的思想来解决。比如,用订单表的订单状态为依据,如果订单的状态为已处理,则直接退出程序逻辑。
也就是说,虽然ab工具的参数-c的值指定的是并发的请求数,但可能其中的某些请求并不能达到并发的要求,就会顺序执行。
由于乐观锁与事务无关,因此无论数据表的存储引擎是InnoDB还是MyISAM,都可以使用乐观锁。
还没有评论,来说两句吧...