事务&锁

CSDN mysql数据库锁与隔离级别 博客园 MySQL锁机制与隔离级别

  • MySQL默认采用自动提交(AUTOCOMMIT)模式。也就是说,如果不是显式地开始一个事务,则每个查询都被当作一个事务执行提交操作
  • 加锁也需要消耗资源
  • 服务器会为诸如ALTER TABLE之类的语句使用表锁,而忽略存储引擎的锁机制。
  • 即使存储引擎不支持事务,也可以通过LOCK TABLES语句为应用提供一定程度的保护,这些选择用户都可以自主决定。
  • 写锁则是排他的,也就是说一个写锁会阻塞其他的写锁和读锁;
  • READ LOCAL表锁支持某些类型的并发写操作。另外,写锁也比读锁有更高的优先级,因此一个写锁请求可能会被插入到读锁队列的前面
  • 行级锁可以最大程度地支持并发处理(同时也带来了最大的锁开销)。众所周知,在InnoDB和XtraDB,以及其他一些存储引擎中实现了行级锁。
表引擎 表锁 页锁 行锁
InnoDB ×
MyISAM × × ×
  • 事务内的语句,要么全部执行成功,要么全部执行失败。

隔离级别

-- 查mysql隔离级别
show variables like 'tx_isolation'
SELECT @@tx_isolation;
Danger

MySQL服务器层不管理事务,事务是由下层的存储引擎实现的。所以在同一个事务中,使用多种存储引擎是不可靠的(非事务型的表上的变更就无法撤销)。

MySQL可以通过执行SET TRANSACTION ISOLATION LEVEL命令来设置隔离级别

新的隔离级别会在下一个事务开始的时候生效

InnoDB引擎也支持所有的隔离级别。

name 事务隔离级别 脏读 不可重复读 幻读 读取一致性 备注
read-uncommitted 读未提交 物理级(最低) 通常不使用
read-committed 读已提交(不可重复读) 语句级 大多数数据库系统的默认隔离级别
提交前数据不可见
repeatable-read 默认 可重复读 事务级 MySQL的默认隔离级别
需要解决幻读 可用MVCC
senalizable 串行化 事务级(最高) 最高的隔离级别 避免了幻读问题
会在读取的每一行数据上都加锁,所以可能导致大量的超时和锁争用的问题
适用于 银行系统、分布式系统
Tip

可以认为MVCC是行级锁的一个变种,但是它在很多情况下避免了加锁操作,因此开销更低。

虽然实现机制有所不同,但大都实现了非阻塞的读操作,写操作也只锁定必要的行。

MVCC的实现,是通过保存数据在某个时间点的快照来实现的

InnoDB的MVCC,是通过在每行记录后面保存两个隐藏的列来实现的。这两个列,一个保存了行的创建时间,一个保存行的过期时间(或删除时间)

MVCC只在REPEATABLE READ和READ COMMITTED两个隔离级别下工作。

可重复读:个事务操作中对于⼀个读取操作不管多少次,读取到的结果都是⼀样的。

幻读:在可重复读的模式下才会出现,示例:

事务A操作如下:
    
    1、打开事务
    2、查询号码为X的记录,不存在
    3、插⼊号码为X的数据,插⼊报错(为什么会报错,先向下看)
    4、查询号码为X的记录,发现还是不存在(由于是可重复读,所以读取记录X还是不存在的)

事物B操作:
    在事务A第2步操作时插⼊了⼀条X的记录,
    所以会导致A中第3步插⼊报错(违反了唯⼀约束)

上⾯操作对A来说就像发⽣了幻觉⼀样,明明查询X(A中第⼆步、第四步)不存在,但却⽆法插⼊成功

事务

Tip

InnoDB采用的是两阶段锁定协议(two-phase locking protocol)。

在事务执行过程中,随时都可以执行锁定,锁只有在执行COMMIT或者ROLLBACK的时候才会释放,并且所有的锁是在同一时刻被释放。

前面描述的锁定都是隐式锁定,InnoDB会根据隔离级别在需要的时候自动加锁。

事务的4个基本特征ACID

name 特性 注释 怎样实现
Atomic 原子性 整体操作要么全部成功,要么全部失败。 undo log
Consistency 一致性 仅仅有合法的数据能够被写入数据库,否则事务应该将其回滚到最初状态。 业务代码逻辑
Isolation 隔离性 并行事务的改动必须与其它并行事务的改动相互独立。 MVCC
Durability 持久性 事务结束后,事务处理的结果必须可以得到固化。 内存+redo log
  • 事务提交的时候通过 redo log 刷盘,宕机的时候可以从 redo log 恢复
  • undo log 日志保证,它记录了需要回滚的日志信息,事务回滚时撤销已经执行成功的 sql
  • mysql 中事务默认是隐式事务,执⾏ insert、update、delete 操作的时候,数据库⾃动开启事务、提交或回滚事务。
  • 是否开启隐式事务是由变量 autocommit 控制的

在事务中我们执⾏了⼀⼤批操作,可能我们只想回滚部分数据,可以使⽤ savepoint 来实现

start transaction;
    insert into test1 values (1);

    -- 设置⼀个保存点
    savepoint part1; 

    insert into test1 values (2);
    
    --  将savepoint = part1的语句到当前语句之间所有的操作回滚
    rollback to part1; 

    --  提交事务
    commit;

只读事务:

start transaction read only;
    select * from test1;

    --  Cannot execute statement in a READ ONLY transaction.
    --  只读事务中执⾏delete会报错
    delete from test1;

    commit;

显式锁定

Tip

除了事务中禁用了AUTOCOMMIT,可以使用LOCK TABLES之外,其他任何时候都不要显式地执行LOCK TABLES,不管使用的是什么存储引擎。

  • InnoDB也支持通过特定的语句进行显式锁定,这些语句不属于SQL规范。这是在服务器层实现的,和存储引擎无关。
    SELECT ... LOCK IN SHARE MODE 
    SELECT ... FOR UPDATE 
    MySQL也支持LOCK TABLESUNLOCK TABLES语句,
    
  • 它们有自己的用途,但并不能替代事务处理。
  • 如果应用需要用到事务,还是应该选择事务型存储引擎。

  • 共享锁(S):又称读锁。允许一个事务去读一行,阻止其他事务获得相同数据集的排他锁。
    若事务T对数据对象A加上S锁,则事务T可以读A但不能修改A,其他事务只能再对A加S锁,而不能加X锁,直到T释放A上的S锁。
    这保证了其他事务可以读A,但在T释放A上的S锁之前不能对A做任何修改。
    
  • 排他锁(X):又称写锁。允许获取排他锁的事务更新数据,阻止其他事务取得相同的数据集共享读锁和排他写锁。
    若事务T对数据对象A加上X锁,事务T可以读A也可以修改A,其他事务不能再对A加任何锁,直到T释放A上的锁。
    
  • 意向共享锁(IS):事务打算给数据行共享锁,事务在给一个数据行加共享锁前必须先取得该表的IS锁。
  • 意向排他锁(IX):事务打算给数据行加排他锁,事务在给一个数据行加排他锁前必须先取得该表的IX锁。
兼容性列表 X IX S IS
X 冲突 冲突 冲突 冲突
IX 冲突 兼容 冲突 兼容
S 冲突 冲突 兼容 兼容
IS 冲突 兼容 兼容 兼容

MyISAM表锁:表共享读锁(Table Read Lock)和表独占写锁(Table Write Lock)

  • 加锁:lock tables tablename1 read, tablename2 read;
  • 解锁:Unlock tables;
  • 执行更新操作 ( UPDATE、DELETE、INSERT 等)前,会自动给涉及的表加写锁,这个过程并不需要用户干预,因此,用户一般不需要直接用LOCK TABLE命令给MyISAM表显式加锁。

InnoDB行锁

  • 乐观锁:假设认为数据一般情况下不会造成冲突,提交更新时,才会校验版本号是否有冲突。
  • 悲观锁:默认认为对数据的访问一定会产生并发问题,为了提高并发效率,会将悲观锁细化为读锁与写锁
    读锁,又称共享锁,多个连接在同一时间读取同一个资源,二者不相互干扰。
    写锁,又叫排它锁,一个写锁会阻塞其他的写锁和读锁。
    
  • 悲观锁是数据库自身机制来实现的
锁级别 开销 加锁速度 是否会出现死锁 锁定粒度 发生锁冲突概率 并发度 适合场景
表级锁 开销小 加锁快 不会 最高 最低 查询
页面锁 表锁< 中 <行锁 表锁< 中 <行锁 行锁< 中 <表锁 表锁< 中 <行锁 一般  
行级锁 开销大 加锁慢 最低 最高 在线事务处理(OLTP)
  • InnoDB 存储引擎的锁的算法有三种
lock 名称 作用范围
Record lock 记录锁 单个行记录上的锁
Gap lock 间隙锁 锁定一个范围,不包括记录本身
Next-key lock : record+gap 临键锁 锁定一个范围,包含记录本身
Danger

MySQL InnoDB 的 REPEATABLE-READ(可重读)并不保证避免幻读,需要应用使用加锁读来保证。而这个加锁度使用到的机制就是 Next-Key Locks。

Danger

MySQL InnoDB 的 delete 使用 业务条件 删除数据 即使是 uniq_key 也会造成间隙锁 导致并行死锁问题事务中的删除操作 尽可能 先查询数据记录的id,再通过id删除数据

如何加锁

Danger

【MySQL的行锁是针对索引加的锁】,不是针对记录加的锁,所以虽然是访问不同行的记录,但是如果是使用相同的索引键,是会出现锁冲突的。

  • 意向锁是InnoDB自动加的,不需用户干预。
  • update,delete,insert都会自动
  • select 语句默认不会加任何锁类型
  • select …for update 语句加排他锁
  • select … lock in share mode 加共享锁
主要用在需要数据依存关系时来确认某行记录是否存在,并确保没有人对这个记录进行UPDATE或者DELETE操作。

但是如果当前事务也需要对该记录进行更新操作,则很有可能造成死锁,

对于锁定行记录后需要进行更新操作的应用,应该使用SELECT… FOR UPDATE方式获得排他锁。
  • 只有通过索引条件检索数据,InnoDB才使用行级锁,否则将使用表锁,这个需要通过explain来确定查询是否走索引!
  • 当表有多个索引的时候,不同的事务可以使用不同的索引锁定不同的行,另外,不论是使用主键索引、唯一索引或普通索引,InnoDB都会使用行锁来对数据加锁。

分布式锁

  • 分布式锁使⽤者位于不同的机器中,锁获取成功之后,才可以对共享资源进⾏操作
  • 锁具有重⼊的功能:即⼀个使⽤者可以多次获取某个锁
  • 获取锁有超时的功能:即在指定的时间内去尝试获取锁,超过了超时时间,如果还未获取成功,则返回获取失败
  • 能够⾃动容错:持有锁的时候可以加个持有超时时间,超时间强制释放

死锁

Tip

死锁是指两个或者多个事务在同一资源上相互占用,并请求锁定对方占用的资源,从而导致恶性循环的现象。

当多个事务试图以不同的顺序锁定资源时,就可能会产生死锁。

多个事务同时锁定同一个资源时,也会产生死锁。

InnoDB目前处理死锁的方法是,将持有最少行级排他锁的事务进行回滚(这是相对比较简单的死锁回滚算法)。

通常来说,死锁都是应用设计的问题,通过调整业务流程、数据库对象设计、事务大小、以及访问数据库的SQL语句,绝大部分都可以避免。下面就通过实例来介绍几种死锁的常用方法。

  • 1.在应用中,如果不同的程序会并发存取多个表,应尽量约定以相同的顺序为访问表,这样可以大大降低产生死锁的机会。如果两个session访问两个表的顺序不同,发生死锁的机会就非常高!但如果以相同的顺序来访问,死锁就可能避免。
  • 2.程序以批量方式处理数据的时候,如果事先对数据排序,保证每个线程按固定的顺序来处理记录,也可以大大降低死锁的可能。
  • 3.在事务中,如果要更新记录,应该直接申请足够级别的锁,即排他锁,而不应该先申请共享锁,更新时再申请排他锁,甚至死锁。
  • 4.在REPEATEABLE-READ隔离级别下,如果两个线程同时对相同条件记录用SELECT…ROR UPDATE加排他锁,在没有符合该记录情况下,两个线程都会加锁成功。程序发现记录尚不存在,就试图插入一条新记录,如果两个线程都这么做,就会出现死锁。这种情况下,将隔离级别改成READ COMMITTED,就可以避免问题。
  • 5.当隔离级别为READ COMMITED时,如果两个线程都先执行SELECT…FOR UPDATE,判断是否存在符合条件的记录,如果没有,就插入记录。此时,只有一个线程能插入成功,另一个线程会出现锁等待,当第1个线程提交后,第2个线程会因主键重出错,但虽然这个线程出错了,却会获得一个排他锁!这时如果有第3个线程又来申请排他锁,也会出现死锁。对于这种情况,可以直接做插入操作,然后再捕获主键重异常,或者在遇到主键重错误时,总是执行ROLLBACK释放获得的排他锁。

如果出现死锁,可以用SHOW INNODB STATUS命令来确定最后一个死锁产生的原因和改进措施。

依赖关系

Danger

如果一个数据的更细依赖的另一条数据必须先更新

则在读已提交隔离级别下,应该拆分成两个事务,顺序执行

存储过程 procedure

Tip

存储过程不能修改,若涉及到修改的,可以先删除,然后重建。

存储过程中是否需要开启事务?

--  【创建存储过程】
--  参数模式有3种:
--      in:该参数可以作为输⼊,也就是该参数需要调⽤⽅传⼊值。
--      out:该参数可以作为输出,也就是说该参数可以作为返回值。
--      inout:该参数既可以作为输⼊也可以作为输出,也就是说该参数需要在调⽤的时候传⼊值,又可以作为返回值。
--      参数模式默认为IN。
--  ⼀个存储过程可以有多个输⼊、多个输出、多个输⼊输出参数
CREATE PROCEDURE 存储过程名([参数模式] 参数名 参数类型)
BEGIN
    存储过程体
END

--  【调⽤存储过程】
CALL 存储过程名称(参数列表);

--  【删除存储过程】
DROP PROCEDURE [if exists] 存储过程名称;

--  【查看存储过程】
SHOW CREATE PROCEDURE 存储过程名称;

实例:

--  【创建存储过程】
/*设置结束符为$*/
DELIMITER $
/*如果存储过程存在则删除*/
DROP PROCEDURE IF EXISTS proc2;
/*创建存储过程proc2*/
CREATE PROCEDURE proc2(id int,age int,in name varchar(16),out user_count int,out max_id int)
BEGIN
    INSERT INTO t_user VALUES (id,age,name);

    /*查询出t_user表的记录,放⼊user_count中,max_id⽤来存储t_user中最⼩的id*/
    SELECT COUNT(*),max(id) into user_count,max_id from t_user;
END $
/*将结束符置为默认 ';' */
DELIMITER ;

--  【调⽤存储过程:】
/*创建了3个⾃定义变量*/
SELECT @id:=3,@age:=56,@name:='张学友';
/*调⽤存储过程*/
CALL proc2(@id,@age,@name);

异常

mysql内部异常:sql执行报错(如:违反唯一性约束导致insert失败) 外部异常:sql执行成功,结果和预期不一致

/*删除存储过程*/
DROP PROCEDURE IF EXISTS proc2;
/*声明结束符为$*/
DELIMITER $

/*创建存储过程*/
CREATE PROCEDURE proc2(a1 int,a2 int)
BEGIN
    /*声明⼀个变量,标识是否有sql异常*/
    DECLARE hasSqlError int DEFAULT FALSE;
    /*在执⾏过程中出任何异常设置hasSqlError为TRUE 【mysql内部异常】*/
    DECLARE CONTINUE HANDLER FOR SQLEXCEPTION SET hasSqlError=TRUE;
    /*保存影响的⾏数*/
    DECLARE v_insert_count INT DEFAULT 0;

    /*开启事务*/
    START TRANSACTION;
    INSERT INTO test1(a) VALUES (a1);
    INSERT INTO test1(a) VALUES (a2);

    /*根据hasSqlError判断是否有异常,做回滚和提交操作*/
    IF hasSqlError THEN
        ROLLBACK;
    ELSE
        COMMIT;
    END IF;

    /*获取上⾯ insert 影响⾏数*/
    /* ROW_COUNT() 可以获取 mysql 中 insert 或者 update 影响的⾏数 */
    select ROW_COUNT() INTO v_insert_count;

    /*业务逻辑校验 【外部异常】 */
    IF v_insert_count=2 THEN
        COMMIT;
    ELSE
        ROLLBACK;
    END IF;
END $
/*结束符置为;*/
DELIMITER

MySQL如何确保数据不丢失

Tip

redo log 是有大小的,有多个,采用环形结构重复利用

当 redo log 不够用(阻塞,先处理 redo log 释放空间后继续)或者系统比较闲的时候,会对redo log 文件中的内容进行处理

方案一:内存—>磁盘

读取数据页到内存,修改内存数据,把内存中的页数据写入到磁盘

问题:事务执行过程中宕机,数据可靠性没有保障;随机写导致耗时较长

方案二:先写日志,再写磁盘

优点:

  • 两阶段提交确保 redo log 和 binlog ⼀致性
  • 异步处理,处理 redo log 的过程是顺序I/O 做到了高效操作
    start trx=全局的事务编号trxid;
        事务操作过程
        执行一个操作,写入一条记录
        ...
        
    prepare trx=全局的事务编号trxid;
        binlog 持久化到磁盘
    end trx=全局的事务编号trxid;

日志记录过程:

  • mysql 收到 start transactio 后,⽣成⼀个 全局的事务编号trxid
  • 按顺序循环执行下面两步

    • 找到要操作的数据行所在的页,将整页数据加载到内存中
    • 在内存中操作数据,将操作日志(start…end)放入 redo log buffer(内存中的一个区块,可理解为数组结构) 中
  • mysql 收到 commit 指令,将 redo log buffer 数组中内容写⼊到 redo log ⽂件中
  • 事务操作的 binlog 日志 一次性写入,与 redo log 保持一致性
  • 返回给客户端更新成功

此时:内存中的数据页被修改,还未同步到磁盘中,也叫脏页。修改被记录到 redo log 文件中,不会丢失

日志处理过程:

  • 读取 redo log 信息

    • 如果 start…**prepare end** 标签不完整(事务执行失败),直接跳过;
    • 如果 start…prepare 标签完整,trxid 对应的 binlog不存在 则跳过
    • 如果 start…prepare 标签完整,trxid 对应的 binlog 存在 则回滚
    • 读取⼀个完整的事务操作信息(start…**prepare end**),然后进⾏处理
  • 在内存中查找 操作对应的数据页

    • 如果 存在,则写入到磁盘中;
    • 如果不存在(如:宕机),则读取对应数据页到内存中,通过 redo log 修改数据,再写入到磁盘中
  • 将 redo log 文件中 对应的全局的事务编号trxid(start…end) 占有的空间标记为已处理,这块空间会被释放出来