一、mysql数据库中的存储引擎
mysql在创建数据表时可以通过engine关键字设置存储引擎的类型,也可以通过alter命令来修改表的存储引擎。可以通过show engines命令来查看当前mysql数据库支持的存储引擎的类型,一般场景的存储引擎有:InnoDB、MyISAM、MEMORY、BLACKHOLE、TokuDB和MySQL Cluster。
InnoDB存储引擎是mysql5.5后的默认存储引擎,其特点主要有:1.索引组织表、2.支持事务、3.支持行级锁、4.数据块缓存、5.日志持久化。一般的线上项目推荐使用InnoDB存储引擎,因为其稳定可靠、性能好。
MyISAM存储引擎时mysql5.1之前的默认存储引擎,其特点主要有:1.堆表、2.不支持事务、3.只维护索引缓存块,表数据缓存交给操作系统、4.锁粒度大,表级锁,高并发下会有问题。5.数据文件可以直接拷贝。一般没有特殊需求不建议使用。
MEMORY存储引擎的特点有:1.数据全内存存放,无法持久化、2.性能较高、3.不支持事务。适合偶尔作为临时表的存储引擎使用。临时表创建是通过create temporary table来实现的,且临时表不是全局可见的,只用当前连接可见。
BLACKHOLE存储引擎的特点是:1.数据不做任何存储。一般利用mysql replicate充当日志服务器,再mysql replicate环境中充当代理主机。
TokuDB存储引擎的特点有:1.分形树存储结构、2.支持事务、3.行级锁、4.数据压缩效率高。一般使用于有大批量insert的业务场景,但是mysql官方版本中没有这个存储引擎,需要去其官网下载安装扩展。
MySQL Cluster存储引擎的特点有:1.多主机分布式集群、2.数据节点冗余、高可用、3.支持事物、4.设计易于扩展。这是一款面向未来发展的数据库,当前线上不建议使用。此外mysql官方版本中也没有这个存储引擎,需要去其官网下载安装扩展。
二、InnoDB事物锁
锁的作用是在并发的情况下保证数据的完整性和一致性。数据库中的锁有两种,1.事务锁lock,在事务的执行过程中保护数据库的逻辑内容。2.线程锁latch/mutex,多线程争夺临界资源时,保护内存数据结构。数据库中事务间用的是第一种事务锁来保护并发,将对同一行数据的修改串行化。
三、事务锁的粒度
1.行锁:InnoDB和Oracle中用的是行锁,即修改记录时只锁定这一行记录。
2.页所:SQL Server中用的是页锁,修改记录时锁定的是该记录所在的数据页。
3.表锁:MyISAM和MEMORY中用的是表锁,修改记录时锁定记录所在的表。
锁升级:当维护行锁或页锁的代价过高时,数据库会自动将锁升级为表锁,在InnoDB和Oracle中是不存在这种情况的,但是再SQL Server中有。
四、InnoDB中四种基本锁模式
InnoDB中有两种标准的行级锁,分别是共享锁(S Lock)和排它锁(X Lock)。共享锁就是读锁,允许事务读一行数据。排它锁就是写锁,允许事务删除或更新一行数据。量中锁的兼容性如下图所示。
此外InnoDB中还有一种被称为意向锁的锁,这种锁是由引擎自动添加和释放的,所以操作十分快,对用户而言可以认为是透明的。意向锁存在的目的是为数据库提供多粒度的上锁,例如当为一个行数据添加行锁时,先对数据所在的表和页添加表级和页级的意向锁,这样如果在表级或者页级就出现冲突,则不用在去检测行数据的锁兼容性了,可以减少冲突检测的代价,提高上锁的效率。一般行级锁是不会与意向锁冲突的。意向锁与读写锁的兼容性如下图所示。
五、InnoDB的加锁操作
一般的select语句不加任何的锁,也不会被任何事务锁阻塞,因为InnoDB中读操作采用的是一致性非锁定读,就是实际读取时不是读取的当前磁盘上的数据,而是这行数据的一个数据快照,可以认为就是一个回滚段。而多个版本的数据快照间的隔离性是由多版本并发控制(MVCC)来实现的。不同的事务级别下多版本并发控制也会有所不同,例如再READ COMMITTED级别下,总是会读取最新的一个数据快照,这样就会导致不可重复读。而在REPEATABLE READ级别下则是读取事务开始时的数据版本,这样就解决了不可重复读的问题。
S锁有两种上锁情况,一种是手动添加S锁,可以通过select * from table lock in share mode来手动添加S锁。另一种是自动添加,在执行insert操作前会自动添加S锁。
X锁也有两种上锁情况,一种是手动添加,可以通过select * from table lock for update。另一种是自动添加,在执行update和delete操作时会自动给这行数据添加X锁。
六、锁超时
当事务发现锁被其他事务获取后就进入等待,但是这个等待不是无休止的,InnoDB中有一个等待超时参数innodb_lock_wait_timeout,可以用show variables和set命令来查看和修改这个参数,该参数的单位是秒,一般默认设置是50秒,如果事务等待的时间超过了这个等待的上线时间就会抛出操作是吧的error。此外还有一个参数innodb_rollback_on__timeout用来设定是否在等待超时时对进行中的事务进行回滚操作(该参数值默认时OFF的,即关闭的),同样也可以用show variables和set命令来查看和修改这个参数。
七、InnoDB行锁的实现
InnoDB中的行锁是通过对索引项加锁实现的,而不是对一行数据的数据块进行加锁实现的(Oracle中是这样实现的)。因此只有当过滤条件走索引时才能实现行级锁,如果索引上有多条数据那就有可能同时锁住多条数据。而如果查询有多个索引可以使用时,可以对不同的索引加锁,这主要取决于mysql中的自动生成的执行计划。因此一般在做更新和删除条件时用自增主键来做条件性能最好。
下面通过具体实例来进行说明。
首先建立一张t2表并插入两条记录,sql语句如下。
create table t2(a int,b int,key idx1(a));
insert into t2 values(1,1);
insert into t2 values(1,5);
然后在连接A中开启事务,并执行如下命令。
mysql> select * from t2 where a=1 and b=5 for update;
接下来在连接B中开启事务执行如下命令。
select * from t2 where a=1 and b=1 for update;
执行结果如下图所示。
可以看出在执行连接A中的事务时虽然查询结果只有一行,但实际上是锁了两行记录,这就是因为InnoDB存储引擎中是使用锁索引的方法来实现行锁的。而上面的t2表中只有a列有索引,所以当执行连接A中的事务时存储引擎实际上是对a列值为1的数据都进行了锁定,所以锁了两行数据。而如果表中没有索引那么InnoDB就无法实现行锁了,每次锁定都将锁定表中的所有数据,就和表锁一样了。因此一般在update和delete操作中where条件中推荐使用自增主键来作为筛选条件,这样可以保证每次只锁定一行数据。
八、InnoDB的gap lock
InnoDB的gap lock是InnoDB中一种特殊的锁定算法,即锁定的时候不是单单锁定某个值下的索引记录,而是一个范围下的索引记录,其作用就是消灭幻读(什么是幻读可以去看上一篇博客)。但是其代价就是会降低数据库的并发性。下面来举例说明。
首先建立一张表t3并插入一些数据,代码如下。
create table t3(a int(11) default null,key idx1(a));
insert into t3 values(20),(23),(27),(27),(30),(31);
然后在连接A中开启事务,并执行如下命令。
select * from t3 where a=27 for update;
接下来在连接B中开启事务执行如下命令。
insert into t3 values(27);
结果如下图所示。
可以看出在连接B中27无法插入t3表中。这样就解决了幻读问题。看似很完美,但是之际上InnoDB中锁定的是(23,30)这样的数值范围,不知27无法插入了,连24、25、26、28、29都无法插入了。如果在B连接中执行如下语句。
insert into t3 values(28);
insert into t3 values(25);结果如下图。
因此在gap lock下数据库的并发性是比较差的,因为每次都会锁掉一个范围内的数据。当然解决的方法也很简单,就是采用自增主键,update和delete的时候where条件中根据自增主键来定位数据,这样就能保证每次数据库只锁定一行数据。
九、死锁
死锁是指两个或两个以上的事务再执行过程中因争夺资源而造成的一种相互等待的现象。mysql中等待图的方式来检查是否存在死锁的情况,如果存在死锁则选择回滚代价最小的事务进行回滚。
虽然mysql中可以自动检测死锁,但是在实际开发中还是要尽量的避免死锁,否则并发效率会收到极大的影响。常用的预防死锁的方法有,1. 尽可能缩短事务的长度,单步事务是永远不会出现死锁的。2. 可能存在冲突的跨表事务尽量避免并发。3. 进行批量更新操作时尽量用自增主键来作为选择条件,并对主键值进行排序,这样就不会造成死锁了。举例来说,如果两个连接同时执行一下语句是不会有死锁的。
update tb_goods set store_quantity=store_quantity +10 where goods_id=1;
update tb_goods set store_quantity=store_quantity +10 where goods_id=3;
update tb_goods set store_quantity=store_quantity +10 where goods_id=7;
update tb_goods set store_quantity=store_quantity +10 where goods_id=9;因为where中用的是自增主键,且语句中的主键值是递增的,这样多个事务中是不会出现循环锁定的。
如果两个事务分别执行如下语句,则可能出现死锁。
事务A
update tb_goods set store_quantity=store_quantity +10 where goods_id=1;
update tb_goods set store_quantity=store_quantity +10 where goods_id=3;
update tb_goods set store_quantity=store_quantity +10 where goods_id=7;
update tb_goods set store_quantity=store_quantity +10 where goods_id=9;事务B
update tb_goods set store_quantity=store_quantity +10 where goods_id=3;
update tb_goods set store_quantity=store_quantity +10 where goods_id=1;
update tb_goods set store_quantity=store_quantity +10 where goods_id=7;
update tb_goods set store_quantity=store_quantity +10 where goods_id=9;当两个事务同事执行完第一个更新语句,主键值为1和3的行并锁定,接下来两个事务在分别请求
主键值为1和3的行就出现了循环等待,也就是死锁。
死锁存在的条件有三个,1.有两个以上的并发修改的事务;2. 多个事务都是多步的;3. 多步操作中想抢占的锁资源存在并发关系。只要破坏这三个条件中的任意一个,则死锁就可以避免。
当线上数据库出现大量死锁,就需要进行排查,排查时不止要检查死锁出现的sql语句,还有检查触发改sql语句的上下文以及具体的业务逻辑,根据上下文语句的加锁范围分析存在争用的记录,从而定位死锁出现的原因。
十、事务的组织
以简单的一个购物业务场景为例,流程图如下。
简单来说就是用先想买商品1,那么先检查商品1是否有库存,如果有就将若干件商品1加入用户订单。接下来用户还想买商品2,那么再检查商品2是否有库存,如果有就将若干件商品2加入用户订单。最后用户确认提交订单并付款就完成了购物的操作。
这里需要用事务和锁来实现业务需求,原因有两个。
1. 因为业务需要保证操作的原子性,查询库存、更新订单和扣除库存要么都成功,要么都不成功,所以要用事务。
2. 要避免业务纠纷,业务中查询库存到扣除库存的过程中不能让库存再发生变化,因此要用锁,在查询库存的时候用for update人工加锁。
最简单的是用一个大事务来实现业务需求,如下图所示。
这样固然是可以保证业务的原子性的,确保库存不会被扣成负的。但是在实际业务中,往往用户在挑选完商品1后到用户再添加商品2这之间所间隔的时间是非常长的,这样会导致在很长的时间内只有一个人可以买商品1,这显然是不合理的。此外,如果用户挑选完商品1后连接意外中断了,当用户在重新连接回来后之前的未完成订单是找不到的,这肯定也是不能让人接受的。
因此需要对上面的大事务进行优化,将一个大事务切分为若干个小事务,例如上面的事物可以切分为三个小事务,一个是添加商品1的事务,一个是添加商品2的事务,最后是提交付款的事务,如下图所示。
如果是一个简单的购物网站,到这里就已经可以满足购物业务的需求了。但是像那行大的电商网站,这样还是不行的,因为可以每个商品下还有各种优惠、特价等,这样当将一个商品添加到订单中时不仅仅要检查是否有库存,还要检查是否有什么优惠、特价等等,这样事务就又变长了,商品锁定的时间又变长了,这对于大型电商网站是不能接受的,因此需要进一步优化。优化的方法也很简单,就是在用select检查库存时不用for update进行锁定了,而在执行update操作时添加一个判断条件,就是判断库存是否够,如下图所示。
可能有人会疑问,既然这样,那为什么还要在update前进行select操作查询商品的库存,这么做主要有两个原因,首先,在用户下单前需要给用户展示还有多少商品。另外,这样可以事先判断是否有库存,如果没有就不用执行下面的update操作了,可以提升应用服务器和数据库服务器的性能。
总结来说,事物和锁的优化思路就是在保证业务正确的前提下,尽量缩短锁的时间。
十一、悬挂事务与锁超时排除
实际的线上维护时可能会出现几个记录被长时间的锁定,导致访问这些数据的请求全部超时,这有可能是有一些悬挂事务中锁定的数据。悬挂事务就是一些长时间没有提交且没有进行进一步操作的事务。悬挂事务出现的原因可能是用户连接突然中断,而应用服务器确保留了这个数据库连接和执行到一半的事务。
这样的情况用show processlist是无法定位有悬挂事务的连接的。show processlist只能查出当前有多少个连接,哪些是活跃的,哪些是不活跃的,但是无法找出哪些连接中有事务长时间的占着锁没有释放。
要查看悬挂事务的连接可以用如下语句来查询。
select trx_mysql_thread_id,trx_state,now()-trx_started,trx_rows_locked from information_schema.innodb_trx;查询的结果有四列,分别表示连接Id、当前状态、存在的时间、锁定的资源数。
找到悬挂事务后可以用kill命令来结束连接。但是因为无法知道阻塞的sql具体在执行什么,因此需要与业务确认看是否可以直接杀死连接。