锁及事务隔离水平的关系
事务的隔离水平:指同时运行多个事务时,事务与事务之间的影响程度。其实是利用了各种类型的锁来解决并发问题,数据库内部基础机制,执行事务其实也在执行锁。(其他章有讲)
锁: 主要指悲观锁和乐观锁。
只有当事务的隔离水平无法解决并发问题时,才需要手动设置锁。
|--共享所
|--行锁-|
|--悲观锁-| |--排它锁
| |--表锁(不使用索引时)
|
|--乐观锁
--排他锁
SELECT * FROM table_name WHERE ... LOCK IN SHARE MODE;
--共享锁
SELECT * FROM table_name WHERE ... FOR UPDATE;
在事务中,关于锁的锁定,对于update、delete、insert,Innodb会自动加排它锁;
普通的select,不会加任何锁。我们可以显示的加共享锁或排它锁。
综合:隔离级别越高,越能保证数据的完整性和一致性,但是对并发性能的影响也越大。对于多数应用程序,可以优先考虑把数据库系统的隔离级别设为Read Committed。它能够避免脏读取,而且具有较好的并发性能。尽管它会导致不可重复读、幻读和第二类丢失更新这些并发问题,在可能出现这类问题的个别场合,可以由应用程序采用悲观锁或乐观锁来控制。
悲观锁
悲观锁示例1:
//0.开始事务
begin;/begin work;/start transaction; (三者选一就可以)
//1.查询出商品信息
select status from t_goods where id=1 for update;
//2.根据商品信息生成订单
insert into t_orders (id,goods_id) values (null,1);
//3.修改商品status为2
update t_goods set status=2 where …;
//4.提交事务
commit;/commit work;
上面的第一步我们执行了一次查询操作:select status from t_goods where id=1 for update;
与普通查询不一样的是,我们使用了select…for update的方式,这样就通过数据库实现了悲观锁。此时在t_goods表中,id为1的 那条数据就被我们锁定了,其它的事务必须等本次事务提交之后才能执行。这样我们可以保证当前的数据不会被其它事务修改。
说明:需要注意的是,在事务中,只有SELECT … FOR UPDATE 或LOCK IN SHARE MODE 同一笔数据时会等待其它事务结束后才执行,一般SELECT … 则不受此影响(事务里也能查到)。
拿上面的实例来说,当我执行select status from t_goods where id=1 for update;后。我在另外的事务中如果再次执行select status from t_goods where id=1 for update; 则第二个事务会一直等待第一个事务的提交,此时第二个查询处于阻塞的状态,但是如果我是在第二个事务中执行select status from t_goods where id=1;则能正常查询出数据,不会受第一个事务的影响。
行锁与表锁
MySQL select…for update的Row Lock与Table Lock
MySQL InnoDB默认Row-Level Lock,所以只有「明确」地指定索引(如主键),MySQL 才会执行Row lock (只锁住被选取的数据) ,否则MySQL 将会执行Table Lock (将整个数据表单给锁住)。
1. 明确指出索引(如主键),查有数据,row lock |
事务1:(console1)
mysql> select * from t_goods where id=1 for update;
+----+--------+------+
| id | status | name |
+----+--------+------+
| 1 | 1 | 道具 |
+----+--------+------+
1 row in set
mysql>
事务2:
mysql> select * from t_goods where id=1 for update;
说明:事务1未提交,事务2将阻塞。长时间会报错。
2. 明确指出索引(如主键),查无数据,无lock |
事务1:
mysql> select * from t_goods where id=3 for update;
Empty set
事务2:
mysql> select * from t_goods where id=3 for update;
Empty set
说明:查询结果为空,查询无阻塞,事务1无锁定
3. 没有用到索引、索引不明确(范围查),table lock |
事务1:无索引时
mysql> select * from t_goods where name='道具' for update;
+----+--------+------+
| id | status | name |
+----+--------+------+
| 1 | 1 | 道具 |
+----+--------+------+
1 row in set
mysql>
事务2:
mysql> select * from t_goods where name='装备' for update;
说明:无索引,事务1把整个表锁定了。查询这个表的其他字段也会阻塞。
事务1:(范围查时)
mysql> begin;
Query OK, 0 rows affected
mysql> select * from t_goods where id>0 for update;
+----+--------+------+
| id | status | name |
+----+--------+------+
| 1 | 1 | 道具 |
| 2 | 1 | 装备 |
+----+--------+------+
2 rows in set
mysql>
事务2:
mysql> select * from t_goods where id>1 for update;
说明:事务2会阻塞。当事务1提交后,事务2会查询出来。
注:①没有用到索引
②索引不明确,如 where id>1
将会表锁(table lock)。
乐观锁
乐观锁的实现方式
1. 使用数据版本(Version) |
为数据增加一个版本标识,一般是通过为数据库表增加一个数字类型的 “version” 字段来实现。当读取数据时,将version字段的值一同读出,数据每更新一次,对此version值加一。
当我们提交更新的时候,判断数据库表对应记录的当前版本信息与第一次取出来的version值进行比对,如果数据库表当前版本号与第一次取出来的version值相等,则予以更新,否则认为是过期数据。
以确保期间没有更新过。
update t_goods
set status=2,version=version+1
where id=#{id} and version=#{version};
2. 增加时间戳 |
在记录上增加修改的时间戳(也可利用ora_rowscn伪列)。即在事务开始时,获取该记录的时间戳,修改时,校验该时间戳,若一致则修改。(跟version差不多)
3. 更新时带入原始数据(见总结)。 |
悲观锁与乐观锁的对比
这两种策略的核心其实就是持有锁的时间的起止点不同,悲观锁是从读取记录的那一刻就开始了,而乐观锁只从UPDATE那一刻开始;结束的点两者是一样的,都是发出commit或rollback命令。所以,悲观锁策略会使锁的持续时间更长,而乐观锁的持续时间则较短。其影响就是并发。悲观锁的并发性低于乐观锁。
无论是采用哪种策略,都要保证数据的完整性。所以,在采用乐观锁策略时,是有可能出现数据的不完整。举例来说:储户甲的存款余额100元,假设在几乎相同的时刻,发生了两笔业务,业务1为其存入了50元,另一个业务是其网上购物消费了30元。显然,这两个操作结束后,甲的存款余额应为120元(100+50-30)。但我们设想一下在数据库层面,可能出现这种情况,当其在银行柜台存入50元时,银行操作员收到了甲存入的50元现金,并通过 select 语句看到甲的当前余额为100元(其发出的指令是下面的语句:
select 余额
from 存款余额表
where 储户帐号=储户甲的银行帐号;)
--接着:
update 存款余额表
set 余额=150
where 储户帐号=储户甲的银行帐号;
但就在其看到甲的余额为100元,到其修改甲的余额为150这期间,甲在网上的消费行为导致交易系统已经将甲的余额变成了70元(100-30)并提交了。当银行员工发出的指令也被提交后,甲的余额变成了150元,相当于甲网上消费的行为没有产生任何扣款。这显然是不正确的,更是要避免出现的。
策略1:悲观锁
select 余额
from 存款余额表
where 储户帐号=储户甲的银行帐号 for update;)
不足:当上述占时过长,会影响其他(如存钱、消费等)操作阻塞。
因为考虑到悲观锁策略可以产生的这种问题,所以,我们在设计应用时,可以采取一些其它方法来避免上述情况的发生。其思想就是在真正提交时,确认要修改的数据没有变化过。主要的方法如下:
策略2:乐观锁
(1)更新时带入原始的数据。
update 存款余额表
set 余额=150
where 储户帐号=储户甲的银行帐号 and 余额=100;
(2)在记录上增加修改的时间戳(也可利用ora_rowscn伪列)。即在事务开始时,获取该记录的时间戳,修改时,校验该时间戳,若一致则修改。
(3)其实,我上面举的这个例子,如果在业务设计时,选择更新指令为
update 存款余额表
set 余额=余额+50
where 储户帐号=储户甲的银行帐号;
那么,即使是在乐观锁策略的情况下,依然可以保证数据的正确性和完整性。
总结
在实际生产环境里边,如果并发量不大且不允许脏读,可以使用悲观锁解决并发问题;但如果系统的并发非常大的话,悲观锁定会带来非常大的性能问题,所以我们就要选择乐观锁定的方法.
对于多数应用程序,可以优先考虑把数据库系统的隔离级别设为Read Committed。它能够避免脏读取,而且具有较好的并发性能。尽管它会导致不可重复读、幻读和第二类丢失更新这些并发问题,在可能出现这类问题的个别场合,可以由应用程序采用悲观锁或乐观锁来控制。