锁机制

锁是计算机协调多个进程或线程并发访问某一资源的机制。

在数据库中,除传统的计算资源(如CPU、RAM、I/O等)的争用以外,数据也是一种供许多用户共享的资源。如何保证数据并发访问的一致性、有效性是所有数据库必须解决的一个问题,锁冲突也是影响数据库并发访问性能的一个重要因素。从这个角度来说,锁对数据库而言显得尤其重要,也更加复杂。

锁的分类

  1. 从数据操作的类型(读、写)分
    • 读锁(共享锁):针对同一份数据,多个读操作可以同时进行而不会互相影响
    • 写锁(排它锁):当前写操作没有完成前,它会阻断其他写锁和读锁。
  2. 从对数据操作的颗粒度
    • 表锁
    • 行锁

表锁(偏读)

偏向MyISAM存储引擎,开销小,加锁快,无死锁,锁定粒度大,发生锁冲突的概率最高,并发最低。

案例分析:

-- 建表,引擎选择 myisam
create table mylock (
    id int not null primary key auto_increment,
    name varchar(20) default ''
) engine myisam;

insert into mylock(name) values('a');
insert into mylock(name) values('b');
insert into mylock(name) values('c');
insert into mylock(name) values('d');
insert into mylock(name) values('e');

-- 手动加锁和释放锁
lock table 表名1 read(write), 表名2 read(write), ...;
-- 释放表锁
unlock tables;
-- 查看当前数据库中表的上锁情况,0 表示未上锁
show open tables;

-- 在mylock表添加读锁
lock table mylock read;
select * from mylock; -- session1/2 都可读
update mylock set name='a2' where id = 1; -- session1可修改;session2必须等待其读锁释放
select * from book; -- session1不可读其他表;session2可读其他表

结论:

  1. 当前 session 可以读取但不能修改加了写锁的表;不能读取或修改其他表。
  2. 其他 session 想要读取加了写锁的表,必须等待其读锁释放;可以查询或修改其他未锁定的表。

行锁(偏写)

偏向InnoDB存储引擎,开销大,加锁慢;会出现死锁;锁定粒度最小,发生锁冲突的概率最低,并发度也最高。

InnoDB与MyISAM的最大不同有两点:一是支持事务(TRANSACTION);二是采用了行级锁

事务回顾

事务是由一组SQL语句组成的逻辑处理单元,事务具有以下4个属性,通常简称为事务的ACID属性。

事务的ACID属性

  1. 原子性(Atomicity):事务是一个原子操作单元,其对数据的修改,要么全都执行,要么全都不执行。
  2. 一致性(Consistent):在事务开始和完成时,数据都必须保持一致状态。这意味着所有相关的数据规则都必须应用于事务的修改,以保持数据的完整性;事务结束时,所有的内部数据结构(如B树索引或双向链表)也都必须是正确的。
  3. 隔离性(Isolation):数据库系统提供一定的隔离机制,保证事务在不受外部并发操作影响的“独立”环境执行。这意味着事务处理过程中的中间状态对外部是不可见的,反之亦然。
  4. 持久性(Durability):事务院成之后,它对于数据的修改是永久性的,即使出现系统故障也能够保持。

并发事务处理带来的问题

  1. 更新丢失(Lost Update):
    • 当两个或多个事务选择同一行,然后基于最初选定的值更新该行时,由于每个事务都不知道其他事务的存在,就会发生丢失更新问题一一最后的更新覆盖了由其他事务所做的更新。
    • 例如,两个程序员修改同一java文件。每程序员独立地更改其副本,然后保存更改后的副本,这样就覆盖了原始文档。最后保存其更改副本的编辑人员覆盖前一个程序员所做的更改。
    • 如果在一个程序员完成并提交事务之前,另一个程序员不能访问同一文件,则可避免此问题。
  2. 脏读(Dirty Reads):
    • 一个事务正在对一条记录做修改,在这个事务完成并提交前,这条记录的数据就处于不一致状态;这时,另一个事务也来读取同一条记录,如果不加控制,第二个事务读取了这些“脏”数据,并据此做进一步的处理,就会产生未提交的数据依赖关系。这种现象被形象地叫做”脏读”。
    • 一句话:事务A读取到了事务B已修改但尚未提交的的数据,还在这个数据基础上做了操作。此时,如果B事务回滚,A读取的数据无效,不符合一致性要求。
  3. 不可重复读(Non-Repeatable Reads):
    • 一个事务在读取某些数据后的某个时间,再次读取以前读过的数据,却发现其读出的数据已经发生了改变、或某些记录已经被删除了!这种现象就叫做“不可重复读”。
    • 一句话:事务A读取到了事务B已经提交的修改数据,不符合隔离性
  4. 幻读(Phantom Reads)
    • 一个事务按相同的查询条件重新读取以前检索过的数据,却发现其他事务插入了满足其查询条件的新数据,这种现象就称为“幻读一句话:事务A读取到了事务B体提交的新增数据,不符合隔离性。
    • 多说一句:幻读和脏读有点类似,脏读是事务B里面修改了数据,幻读是事务B里面新增了数据。

事务的隔离级别**

“脏读”、“不可重复读”和“幻读”,其实都是数据库读一致性问题,必须由数据库提供一定的事务隔离机制来解决。

image-20200824223912977

数据库的事务隔离越严格,并发副作用越小,但付出的代价也就越大,因为事务隔离实质上就是使事务在一定程度上“串行化”进行,这显然与“并发”是矛盾的。同时,不同的应用对读一致性和事务隔离程度的要求也是不同的,比如许多应用对“不可重复读”和“幻读”并不敏感,可能更关心数据并发访问的能力。

查看当前数据库的事务隔离级别:show variables like 'tx_isolation'; mysql 默认是可重复读

案例分析:

-- 准备数据
CREATE TABLE test_innodb_lock (a INT(11),b VARCHAR(16))ENGINE=INNODB;

INSERT INTO test_innodb_lock VALUES(1,'b2');
INSERT INTO test_innodb_lock VALUES(3,'3');
INSERT INTO test_innodb_lock VALUES(4, '4000');
INSERT INTO test_innodb_lock VALUES(5,'5000');
INSERT INTO test_innodb_lock VALUES(6, '6000');
INSERT INTO test_innodb_lock VALUES(7,'7000');
INSERT INTO test_innodb_lock VALUES(8, '8000');
INSERT INTO test_innodb_lock VALUES(9,'9000');
INSERT INTO test_innodb_lock VALUES(1,'b1');

CREATE INDEX test_innodb_a_ind ON test_innodb_lock(a);
CREATE INDEX test_innodb_lock_b_ind ON test_innodb_lock(b);

-- 关闭自动提交
set autocommit=0;

-- session1中
update test_innodb_lock set b='4001' where a=4;
-- session2 修改不同行时
-- 由于采用行锁,session2 和 session1 互不干涉,所以 session2 中的修改操作没有阻塞
update test_innodb_lock set b='9001' where a=9; 
-- session2 修改相同行时 阻塞
update test_innodb_lock set b='4003' where a=4;

无索引行锁升级为表锁

-- session1 开启事务,修改 test_innodb_lock 中的数据,varchar 不用 ’’ ,导致系统自动转换类型,导致索引失效
set autocommit=0;
update test_innodb_lock set b=4001 where a=4;
-- session2 ⚠️ 阻塞了
update test_innodb_lock set b='9001' where a=9;

间隙锁的危害:

什么是间隙锁?

  1. 当我们用范围条件而不是相等条件检索数据,并请求共享或排他锁时,InnoDB会给符合条件的已有数据记录的索引项加锁;对于键值在条件范围内但并不存在的记录,叫做“间隙(GAP)”。
  2. InnoDB也会对这个“间隙”加锁,这种锁机制是所谓的间隙锁(Next-Key锁)。

危害:

  1. 因为Query执行过程中通过过范围查找的话,他会锁定整个范围内所有的索引键值,即使这个键值并不存在。
  2. 间隙锁有一个比较致命的弱点,就是当锁定一个范围键值之后,即使某些不存在的键值也会被无辜的锁定,而造成在锁定的时候无法插入锁定键值范围内的任何数据。在某些场景下这可能会对性能造成很大的危害。

例子:

set autocommit=0;
-- session 1
update test_innodb_lock set b='100086' where a>1 and a<6;
-- session 阻塞
update test_innodb_lock set b='9001' where a=9;

手动加行锁/如何锁定一行

  1. 表中创建索引, select … where 字段(必须是索引) 不然行锁就无效。

  2. 必须要有事务,这样才是 行锁(排他锁)

  3. 在select 语句后面 加 上 FOR UPDATE;

    • mysql innodb中 select for update 和直接update 的差别是什么?
      • ANSWER1:因为select for update 到 commit之间可以加业务逻辑。这是悲观锁处理的常见方案。可以防止第三方在commit之前修改数据。
      • ANSWER2:涉及到Mysql数据中乐观锁和悲观锁的使用。性能上肯定直接update会优越一点。不过数据量不大的话可以忽略不计。使用select … for update,当我们在查询出信息后就把当前的数据锁定,直到我们修改完毕后再解锁。那么在这个过程中,因为被锁定了,就不会出现有第三者来对其进行修改了。

案例结论

  1. Innodb存储引擎由于实现了行级锁定,虽然在锁定机制的实现方面所带来的性能损耗可能比表级锁定会要更高一些,但是在整体并发处理能力方面要远远优于MyISAM的表级锁定的。
  2. 当系统并发量较高的时候,Innodb的整体性能和MyISAM相比就会有比较明显的优势了
  3. 但是,Innodb的行级锁定同样也有其脆弱的一面,当我们使用不当的时候(索引失效,导致行锁变表锁),可能会让Innodb的整体性能表现不仅不能比MyISAM高,甚至可能会更差。

如何分析行锁定

通过检查InnoDB_row_lock状态变量来分析系统上的行锁的争夺情况

show status like 'innodb_row_lock%';

对各个状态量的说明如下:

  1. Innodb_row_lock_current_waits:当前正在等待锁定的数量;
  2. Innodb_row_lock_time:从系统启动到现在锁定总时间长度;
  3. Innodb_row_lock_time_avg:每次等待所花平均时间;
  4. Innodb_row_lock_time_max:从系统启动到现在等待最常的一次所花的时间;
  5. Innodb_row_lock_waits:系统启动后到现在总共等待的次数;

优化建议

  1. 尽可能让所有数据检索都通过索引来完成,避免无索引行锁升级为表锁
  2. 合理设计索引,尽量缩小锁的范围
  3. 尽可能较少检索条件,避免间隙锁
  4. 尽量控制事务大小,减少锁定资源量和时间长度
  5. 尽可能低级别事务隔离

乐观锁和悲观锁

1.乐观锁不是数据库自带的,需要我们自己去实现。乐观锁指每次去拿数据的时候都认为别人不会修改,使用不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号等机制。乐观锁适用于多度的应用类型,这样可以提高吞吐量。

乐观锁策略:提交版本必须大于记录当前版本才能执行更新。

  • CAS

    CAS是一种乐观锁实现方式,顾名思义就是先比较后更新。在对一个数据进行更新前,先持有对这个数据原有值的备份。比如,要将a=2更新为a=3,在进行更新前会比较此刻a是否为2.如果是2,才会进行更新操作。当多个线程尝试使用CAS同时更新一个变量时,只有一个线程能够成功,其余都是失败。失败的线程不会被挂起,而是被告知这次竞争失败,并且可以再次尝试。 比如前面的扣减库存问题,乐观锁方式实现如下:

    //查询出商品库存信息,quantity = 3
    select quantity from items where id=1
    //修改商品库存为2
    update items set quantity=2 where id=1 and quantity = 3;
    

    在更新之前,先查询库存表中当前库存数,然后在做update时,以库存数作为一个修改条件。当进行提交更新的时候,判断数据库的当前库存数与第一次取出来的库存数进行比对,相等则更新,否则认为是过期数据。 但是这种更新存在一个比较严重的问题,即ABA问题。

  • ABA问题

    A线程去除库存数3,B线程取出库存数3,B线程先将库存数变为2,又将库存数变为3,A线程在进行更新操作时发现库存是仍然是3,然后操作成功。尽管A线程操作是成功的,但是不能代表这个过程就是没问题的

  • 增加版本字段

    解决ABA问题的一个方法是通过一个顺序递增的version字段,这也是实现乐观锁的常用方式

    通常实现是这样的:在表中的数据进行操作时(更新),先给数据表加一个版本(version)字段,每操作一次,将那条记录的版本号加1。也就是先查询出那条记录,获取出version字段,如果要对那条记录进行操作(更新),则先判断此刻version的值是否与刚刚查询出来时的version的值相等,如果相等,则说明这段期间,没有其他程序对其进行操作,则可以执行更新,将version字段的值加1;如果更新时发现此刻的version值与刚刚获取出来的version的值不相等,则说明这段期间已经有其他程序对其进行操作了,则不进行更新操作。

    //查询出商品信息,version = 1
    select version from items where id=1
    //修改商品库存为2
    update items set quantity=2,version = 2 where id=1 and version = 1;
    

2.与乐观锁相对应的就是悲观锁了。悲观锁就是在操作数据时,认为此操作会出现数据冲突,所以在进行每次操作时都要通过获取锁才能进行对相同数据的操作,这点跟java中的synchronized很相似,所以悲观锁需要耗费较多的时间。另外与乐观锁相对应的,悲观锁是由数据库自己实现了的,要用的时候,我们直接调用数据库的相关语句就可以了。

#开始事务
start transaction
#查询出商品库存信息
select quantity from items where id=1 for update;
#修改商品库存为2
update items set quantity=2 where id = 1;
#提交事务
commit;

页锁

了解一下即可

开销和加锁时间界于表锁和行锁之间:会出现死锁;

锁定粒度界于表锁和行锁之间,并发度一般。