并发事务处理带来的问题

相对于串行处理来说,并发事务处理能够大大增加数据库资源的利用率,提高数据库系统的事务吞吐量,从而可以支持更多的用户。但并发事务处理也会因隔离级别不同带来一些如下问题:

  • 更新丢失:当两个或者多个事务更新同一行,然后基于最初选定的值更新该行时,由于每个事务都不知道其他事务的存在,就会发生丢失更新问题—最后的更新覆盖了由其他事务所做的更新。
  • 脏读:一个事务正在对一条记录做修改,在这个事务完成并提交前,这条记录的数据就处于不一致状态;这时,另一个事务也来读取同一条记录,如果不加控制,第二个事务读取了这些“脏”数据,并据此做进一步的处理,就会产生未提交的数据依赖关系,这种现象被称为“脏读”
  • 不可重复读:一个事务在读取某些数据后的某个时间,再次读取之前读过的数据,却发现其读出的数据已经发生了改变、或者某些记录已经被删除了。这种现象被称为“不可重复读”
  • 幻读:一个事务按相同的查询条件重新读取以前检索过的数据,却发现其他事务插入了满足其查询条件的新数据,这种现象被称为“幻读”

事物隔离级别

事务隔离性要求系统必须保证事务不受其他并发执行的事务的影响。隔离级别规定了一个事务中所做的修改,在哪些事务内和事务间是可见的,哪些是不可见的,从而一定程度上解决并发带来的数据问题。较低的隔离通常可以执行较高的并发,系统的开销也比较低。
为了解决“隔离”和“并发”的矛盾,MySQL实现了ISO/ANSI SQL92定义的4个事务隔离级别,每个级别的隔离程度不同,允许出现的副作用也不同,应用根据自己的业务逻辑需求,通过选择不同隔离级别来平衡“隔离”和“并发”的矛盾。
ANSI SQL定义的隔离级别如下:

隔离级别 含义 读数据一致性 脏读可能性 不可重复读可能性 幻读可能性
读未提交(Read Uncommitted) 事务中的修改,即使没有提交,对其他事务都是可见的 最低级别,只能保证不读取物理上损坏的数据
读已提交(Read Committed) 事务从开始到提交之前,所做的修改对其他事务都不可见 语句级
可序列化(Serializable) 在读取的每一行数据上加锁,强制事务串行执行 最高级别,事务级
可重复读(Repeatable read) 同一事务中多次读取同样的记录结果是一致的 事务级

这4种隔离级别,并行性能依次降低,安全性依次提高。MySQL 默认的隔离级别为RR, 但内部采用MVCC解决了幻读问题。

事物隔离的实现

数据库实现事务隔离的方式,基本上分为以下两种:

  • 加锁:读取数据之前,对其加锁,阻止其他事务对数据进行修改
  • MVCC:不加任何锁,采用多版本并发控制实现。MVCC使得大部分支持行锁的事务引擎不再单纯的使用行锁来进行数据库的并发控制,而是把数据库的行锁和行的多个版本结合起来,可以实现非锁定读,从而提高数据库的并发性能。

    数据加锁

    InnoDB锁的类型

    自增锁Auto-inc Locks
    是特殊的表级别锁,专门针对事务插入AUTO_INCREMENT类型的列。
    原理:每张表自增长值并不保存在磁盘上进行持久化,而是每次InnoDB存储引擎启动时,执行以下操作:

    SELECT MAX(auto_inc_col) FROM T FOR UPDATE;

之后得到的值会用变量auto_inc_lock作缓存,插入操作会根据这个自增长值加1赋予自增长列。因为每张表只有同一时刻只能有一个自增锁,可以避免同一表锁对象在各个事务中不断地被申请。 自增锁内部采用互斥量来实现,在MySQL5.1版本之后,针对普通的insert语句,自增锁每次申请完后马上释放。所有有时候insert产生错误时,例如主键重复,下次执行自增id的insert时,得到的id是不连续的,因为内存中的计数器是持续自增的,每次申请释放后,即为一个新的值。

共享/排他锁shared and exclusive locks
共享和排他都是锁标准行级锁

  • 共享锁(S):事务拿到某一行记录共享S锁,才能读取。可以提高读读并发。
    • SELECT * FROM table_name WHERE … LOCK IN SHARE MODE
  • 排他锁(X):事务拿到某一行记录排他X锁,修改或者删除。用来保证数据强一致。
    • SELECT * FROM table_name WHERE … FOR UPDATE

共享锁之间兼容,排它锁与任何锁都不兼容。

意向锁Intention Locks
意向锁是指未来某个时刻,事务要加共享/排他锁了,提前声明个意向。

  • 意向共享锁(IS):事务有意向对表中某几行加S锁
  • 意向排他锁(IX):事务有意向对表中某几行加X锁

意向锁协议:

  • 事务要获得某些行的S锁,必须先获得表的IS锁
  • 事务要获得某些行的X锁,必须先获得表的IX锁

MVCC

简介

MVCC使得大部分支持行锁的事务引擎不再单纯的使用行锁来进行数据库的并发控制,而是把数据库的行锁和行的多个版本结合起来,只需要很小的开销,就可以实现非锁定读,从而提高数据库的并发性能。MVCC(非锁定一致性读)主要是在REPEATABLE READ和READ COMMITED两个隔离级别下工作。因为READ UNCOMMITED总是读取最新的数据行,而不是符合当前事务版本的数据行,而SERIALIZABLE则会对所有读取的行都加锁。

原理

1.写任务时候,会克隆一份数据,用版本号来区分;
2.写任务会操作新的克隆数据,直至提交
3.并发的读任务可以继续读旧版本的数据,不至于阻塞
举个栗子:比如一个值被按顺序修改成了2、3、4,在回滚日志就会存储如图信息。

image.png

数据当前值是4,但是在查询时候,这条记录不同时刻会有不同的read-view,如图在视图A、B、C中会有1、2、4,同一条记录在系统存在了不同的版本,就叫MVCC。

长事务意味着系统里面会存在很老的事务视图,在这个事务提交之前,回滚记录都要保留,这会导致大量占用存储空间。 除此之外,长事务还占用锁资源,可能会拖垮库。

如何减少长事物?

  • 拆分复杂的大SQL为多个小SQL。大SQL逻辑计算复杂,需要占用大量CPU进行计算,一个SQL只能使用一个CPU进行计算,拆分后,可以通过并行执行来提高处理效率。
  • 建议单个事务提交影响行数小于1000行。
  • 客户端需要保证异常处理机制,确保事务完成提交

MVCC读分类

在MVCC并发控制中,读操作可以分为两类:快照读和当前读。

  • 快照读:或叫一致性读。读取的是记录的可见版本(有可能是历史版本),不用加锁。简单的SELECT操作,属于快照读,不加锁。 简单的select操作:select * from table where ?

  • 当前读:读取的是记录的最新版本,并且当前读返回的记录都会加上锁,保证其他记录不会再并发修改这条记录。特殊的读操作(显示加S锁和X锁),插入/更新/删除操作,属于当前读,需要加锁。

    • select * from table where ? lock in share mode; ————加共享锁
    • select * from table where ? for update;————加排它锁
    • insert into table values (…);————加排它锁
    • update table set ? where ?;————加排它锁
    • delete from table where ?;————加排它锁

      为什么插入/更新/删除操作属于当前读? 这是因为对于一个UPDATE操作,当UPDATE SQL发送给MySQL之后,MySQL Server会根据where条件,读取第一条满足条件的记录,然后InnoDB会将第一条记录返回,并加锁。当MySQL Server收到这条加锁记录之后,会再发起一个UPDATE请求,更新这条记录。一条记录操作完成之后再读取下一条记录,直到没有满足条件的记录位置。因此在UPDATE操作内部需要包含一个当前读。 DELETE也是一样。 INSERT稍微不同,这是因为INSERT操作可能会触发unique key的冲突检查,也会进行一个当前读。

针对一个当前读的SQL语句,InnoDB与MySQL Server的交互是一条一条进行的,因此加锁也是一条一条进行的,先对一条满足条件的记录加锁,返回给MySQL Server,做一些DML操作,然后再读取下一条加锁,直至读取完毕。

两阶段锁协议two-phase locking protocol

锁操作分为两个阶段:加锁阶段与解锁阶段,并且保证加锁阶段与解锁阶段不相交。
在 InnoDB 事务中,行锁是在需要的时候才加上的,但并不是不需要了就立刻释放, 而是要等到事务结束时才释放。

image.png

建议:如果你的事务中需要锁多个行,要把最可能造成锁冲突、最可能影响并发度的锁尽量往后放。

SQL语句加锁

加锁原则:

  1. 加锁基本单位是next-key lock
  2. 查找过程中访问到的对象才会加锁
  3. 索引上的等值查询,给唯一索引加锁的时候,next-key lock退化为记录锁
  4. 索引上的等值查询,向右遍历到最后一个不满足等值条件时候,退化为间隙锁
  5. 唯一索引上的查询范围会访问到不满足第一个值为止