悲观锁和乐观锁的原理与实践

   乐观锁并不是真正意义上的锁,只是对锁的一种应用,他们并不仅仅适用于数据库领域,而是适用绝大多数并发场景,他们是用解决并发问题的一种通用解决方案。

悲观锁:

   顾名思义,在面对并发问题时比较“悲观”,总是认为在并发场景下一定会出现问题,所以在每次更新操作前进行加锁。让并行的操作,变成了串行操作。这样程序运行的安全性和正确性提高了,但是运行效率降低了。

乐观锁

   在面对并发问题时比较“乐观”,认为在并发场景下很大可能不会产生并发问题的,所以在执行前不会进行加锁操作,保持原来的并行操作,这样执行效率会提高很多。当真的出现并发问题时,可以通过一定的手段检测出来,然后再对产生的这个并发问题进行解决。

   悲观锁和乐观锁都是处理并发问题的解决方案,只是思想不同而已,悲观锁从源头上杜绝了并发问题的产生,而代价就是降低了程序执行的效率,乐观锁在程序执行前不加锁,在程序执行过程中,通过检测并发问题是否产生,当检测到并发问题产生后再针对这个问题进行解决。这样可以避免不存在并发问题时,进行加锁造成的性能降低的问题。它提高了程序执行的效率,但是带来的问题就是,需要我们额外设计与我们业务无关的,检查是否产生了并发问题以及并发问题的解决方案。

悲观锁和乐观锁的使用(以数据库为例)

悲观锁的使用

   悲观锁的实现是利用数据库自身的锁机制来实现的,由数据库自来保证并发安全的。主要使用的就是数据自身提供的行锁和表锁(对于行锁和表锁不熟悉的老铁可以参考下数据库中那些“锁”)。
   如何使用数据库中锁:

select xxx from table where xxx=yyy for update

   当一个查询语句在结尾使用到了 for update时,数据库就会给指定的表或者对应的数据行加锁。只有当执行这条语句的事务结束时,锁才会被释放,在锁释放前,对该表或者对应的数据行进行操作的其他事务就会被阻塞,直至超时或者等到锁被释放。

行锁还是表锁

   使用for update时,添加的锁是行锁还是表锁这个主要取决于 sql语句的语义是否明确。
   以game表举例来说

CREATE TABLE `game` (
  `id` int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
  `game_name` varchar(128) NOT NULL DEFAULT '' COMMENT '名称',
  `status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态信息',
	`user_cnt` int(10) NOT NULL DEFAULT 0 COMMENT '用户人数',
	`mtime` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE 				CURRENT_TIMESTAMP COMMENT '修改时间',
  PRIMARY KEY (`id`) USING BTREE,
  KEY `ix_status` (`status`) USING BTREE
) ENGINE=InnoDB COMMENT '游戏基本信息表';

insert into game (game_name,status) value('游戏1',1);
insert into game (game_name,status) value('游戏2',1);
insert into game (game_name,status) value('游戏3',0);

行锁的场景

场景1:锁单行情况
在console1中执行:

begin;
select status from game where id=1 for update;

在console2中执行:

select status from game where id=1 for update;

此时console2会阻塞住,直到console1执行commit或者consol2超时。

如果在consol2中执行:

select status from game where id=2 for update;

consol2不会被锁住。
结论:consol1 在加锁的时候,使用主键索引明确指出只会锁住 id=1的那一行,其他行的数据不会被锁。

场景2:锁多行的情况
在consol1中输入:

begin;
select status from game where STATUS=1 for update;

在consol2中输入:

select status from game where `status`=1 for update;

此时consol2会阻塞,直到consol1事务提交,或者conso2超时。
在consol3中输入

select status from game where id=1 for update;

或者

select status from game where id=2 for update;

此时consol3也会阻塞,直到consol1事务提交,或者consol3超时,
但是如果consol4如果输入:

select status from game where id=3 for update;

此时consol4是不会被阻塞的。
结论:如果对添加索引列status=1进行加锁的话,那么会对所有status=1的行都会被锁住。而status不等于1的行不会被锁。

场景3:锁表的情况
在consol1中输入

begin;
select status from game where game_name = '游戏01' for update;

在consol2中输入

select status from game where id=1 for update;

或者

select status from game where id=2 for update;

或者

select status from game where STATUS=1 for update;

都会被阻塞,直到consol1事务提交,或者consol2超时。
出现锁表的原因在于

select status from game where game_name = '游戏01' for update;

中没有使用索引,mysql只有通过全表扫描才能只知道game_name=’游戏01’的行有哪些,全表扫描的成本比较大,所以直接将表进行全部锁定。
两个特殊情况,不会产生阻塞:
1.在consol1中对表或者特定行加锁后,
在consol2中执行简单的查询操作:

select status from game where id=1 

consol2不会被阻塞。
2.如果consol2中的查询结果为空的话,也还是不会阻塞的。
在consol1中输入:

select status from game where game_name = '游戏01' for update;

在consol2中输入:

select status from game where id = 100 for update;

   因为consol2的查询结果为空,所以不会阻塞。这个也比较好理解,既然结果都为空了,那么也就无法对数据进行更新了,也就不会产生任何更新冲突的问题了
   以上是我们在编写sql语句是使用锁的常用场景,也是在实现悲观锁的常用手段,在并发场景下,通过使用select xxx from table where xx=yyy for update;对目标行进行锁定,在提交事务前,其他事务无法更新目标行,从而达到从源头上避免并发问题的出现。

乐观锁

   乐观锁的思想是“在问题出现前,不做任何处理,等问题出现后再解决”。在使用乐观锁的过程中,主要要做两件事:
   1.如何检测并发问题是否出现。
   2.当并发问题出现了如何解决。

检测并发问题是否出现

   常用的用于检测并发问题的方案是使用一个“版本”字段,每次更新后,这个版本都会变化,通常是单调递增的(否则可能产生ABA的问题),用来表示当前行数据的版本。在更新操作执行前,先获取当前数据行的版本,在执行更新操作时,判断一下版本字段的值是否发生了变化,如果没有发生变化,则说明在此期间没有并发问题产生,则执行正常操作。如果发现版本字段发生了变化,说明在此期间存在并发问题,具体过程如图所示:
在这里插入图片描述

具体使用方式为:

update table1 set col1=v1,version=version1+1 where version=version1; 

并发问题出现后,如何解决

   并发问题产生后的解决方案要结合具体的业务场景。简单的做法,可以给客户端返回错误信息,让用户重试,把上面的流程重新走一次。也可以在服务器端,重新获取新的版本并执行更新操作。
   注:可能会有老铁有疑问,有没有可能,更新时候,版本没有发生变化,在更新到数据库的那一刹那,多个线程同时更新,造成数据错乱呢?其实是不会的,因为在执行更新sql时,数据库底层会有写锁保护,保证更新操作按照获取写锁的顺序执行。

使用“修改时间“ 作为控制数据行变化的“版本“字段

   在很多时候设计表的时候,会使用修改时间(mtime)这个字段记录数据行修改的时间,这个字段的变化不需要人工维护,数据库帮我们维护,只要我们在定义这个字段按照如下配置即可:

`mtime` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',

   每当我们对某一行的数据进行更新时,数据库会自动更新这个字段的值为当前时间。实现了天然的“版本”字段的功能。

update table1 set col1=v1 where mtime=mtime;

   不过在使用mtime作为“版本”字段存在一个“坑”:在使用mtime作为“版本”字段时,偶尔会存在失效的情况:
   线程1:

select id,game_name,status,mtime from game where id = 1;
update game set game_name = '游戏x' where mtime = '${mtime}';

   线程2:

select id,game_name,status,mtime from game where id = 1;
update game set game_name = '游戏y' where mtime = '${mtime}';

   正常情况下,两个线程之可能有一个执行成功。但是线程1和线程2的update语句“偶尔”会都执行成功。
   出现这个问题的原因在于mtime使用timestame作为字段类型,精度精确到秒,
如果当目标记录插入数据的后,线程1立即对其进行修改,整个过程在1s内完成,虽然mtime有发生变化,但是由于精度较低,数值上没有体现,也就是"检测并发问题是否出现"出现了问题,数据更新了,但是"版本"却没有发生变化。最终导致线程2执行更新操作时,判断mtime没有发生变化,更新操作成功。
   优化的方案:可以调整mtime的精度,精确到微妙甚至更小的粒度,保证每次数据更新,都能够在"版本"上体现出来。从而减少避免该类问题的产生。修改timestamp精度的方式如下:

alter table game modify column `mtime` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6) COMMENT '修改时间';

猜你喜欢

转载自blog.csdn.net/weixin_45701550/article/details/106607569