轻松构建微服务之单机事物和mysql锁

2019/02/07

单机事物

事物的定义

我们知道,一个计算机的CPU同一时刻,要么在写数据,要么在计算,要么在读数据,所有的任务都是这3件事情的集合,计算机都是支持多任务的,也就是会为每一个任务分配一个CPU使用时间,在这个时间内只有被分配时间的任务可以使用CPU进行读写和计算,时间到了之后,会让出CPU给其他任务用,这样看起来就像多个任务在并行的执行.

当多个任务并行运行的时候,如果这些任务都会读写同一份文件,假设这个文件存储在硬盘上,一个任务在读,一个任务在写,这样肯定会出问题,而事物相当于在任务的读写计算之外,增加了一个同步模块,让任务按照我们需要的顺序执行,并且不会出现数据的不一致,这就是事物.

当然,如果让多个任务能够一个一个顺序执行,第一个处理完在处理第二个,这样不存在竞争,那么这样一个任务肯定是满足事物的ACID的,但是当我们有多个CPU的话,这种所有任务顺序执行肯定是不可取的,当然单cpu下性能也会有影响,因为当一个任务内有睡眠,或者IO等待操作的时候,实际上CPU这个时候是空闲的,但是也不能让出执行权给其他任务,造成了CPU的浪费,所以事物的本质应该是在并发任务执行的环境下,让任务还能有高效的处理速度.

当然,在单线程环境下如果只有纯碎的计算,这样就没有CPU上下文切换带来的损害,性能反而会越高,redis就是这种实现.

事物的ACID

通过上面的分析我们发现,事物其实就是在协调多个任务在读写共享资源的时候,来协调哪个任务先读写,哪个任务后读写,避免造成冲突,同时尽可能减小这个协调操作引起的性能问题,那么怎样才能算读写不冲突呢,我们可以根据事物的4个特性来分析.

  • A:原子性

一个事物内的操作,要么同时成功,要么同时失败,一个事物是为了满足一个目标,而事物内的操作都是为了最终完成这个目标,原子性避免了系统只执行了这些操作的一个子集.

mysql利用undolog来实现事物的回滚,每一个操作都会记录对应的undolog,例如insert操作对应的delete就是undolog,如果事物最终决定回滚,将会按顺序依次执行相关的undolog可以回到起始状态,不允许执行到一半就返回,要么全部执行完,要么全部回滚.

  • C.一致性

一致性是最难理解的,官方给出的解释是,在事物完成的时候必须使所有的数据都保持一致的状态,但是并没有解释什么是一致状态,什么情况下会破坏一致状态.

我们可以举一个例子,在一个事物中我读取到a的值是5,那么只要我不对a做修改,那么我在这个事物内的任意时刻继续读a的值,他永远是5不可能改变,这个就是数据的一致性, 在比如,我们修改一个变量b的值为3,那么当事物提交后,持久化后磁盘上的数据就应该是3不可能是其他值,这个就是一致性的含义.

那么怎样才能保证数据的一致性呢,或者什么情况下才能出现数据的不一致性呢?

还是上面的例子,如果我先查询取到a的值为3,那么其他线程将a的值改为1,那么我在这个事物内在查询a的值发现变成了1,这就出现了不一致,我们可以看到,事物的一致性其实是在讲一个事物单元的中间状态是否要对其他事物可见,例如另一个线程将a的值改为1,但是这个修改只有他这个线程看的到,其他线程看不到那么原来的线程继续查询a的值就会发现还是3.

一个事物单元要在数据全部提交后才可见,这就是一致性想表达的内容,在举个例子A给B转账100块钱,A减了100块钱,B还没加上去的时刻,这就是一个中间状态,这个中间状态不能被外面看到,这个就是一致性.

怎么才能实现一致性呢,没错,就是加锁,让有资源竞争的事物顺序执行,例如数据库的表锁,行锁等,要达到强一致性就要加互斥锁才能保证.

  • I. 隔离性

隔离性其实是为了解决强一致性性能太差的一种平衡方案,以性能为理由对强一致性的破坏,官方的解释是多个事物之间互不干扰,在A事物执行的时候,不能影响到B事物的结果,例如一个线程在做A给B转转100,而另外一个线程在做A给C转100块钱,而A只有100元.两个线程能够同时对A进行操作就是违背了隔离性,

但是隔离性定了一些级别,如果一个事物在对数据A进行写操作,那么是否允许另外一个线程读取A呢? 其实就是在两个事物之间的读读,读写,写读,写写,定义了一些标准,不同的标准可能有适合的场景,主要是在性能和一致性级别上做权衡,下面我们一起看下SQL92标准定义的事物4种隔离级别.

  • 1.读未提交 read uncommited 可以读取到未提交的数据,生产环境基本不用,因为太容易造成数据不一致了,例如事物A读取到了a的值为5,另外一个事物将a的值改为4,那么事物A由于可以读取到未提交的值,那么当a在读a的值发现a为4和之前的5不一样,这就出现了不可重复读,再举一个例子,事物A查询a的值发现为4,但是这个值 是事物B刚从a=3修改过来的,不过事物B由于其他问题进行了rollback操作,将a的值改为3,那么事物a就出现了脏读,在比如,事物A查询名字叫小强的人有4个人,然后事物B又往里面插入了一个叫小强的记录,结果事物A在去读的时候发现有5个叫小强的人,这个就是幻读,所以在读未提交的情况下,会出现幻读,不可重复读,脏读.

  • 2.读已提交 可以读取已经提交的数据,这种隔离级别由于不会读取到别人未提交的数据,所以不会出现脏读,但是也会出现不可重复读,因为虽然读取到的是别人提交后的数据,但是在别人提交前读和提交后读还是不一样,当然,幻读也无法避免.

  • 3.可重复读 可以重复读,这种隔离级别下可以重复读,但还是存在幻读,具体怎么实现我们后面讲锁的时候在分析.

  • 4.串行化 所有的事物都串行,这样就规避了对共享资源的竞争,所以可以规避,可重复读,脏读,不可重复读 等问题,但是严重牺牲了性能.

  • 5.快照读 以上为SQL92标准定义的4种隔离级别,但是后来数据库的实现又出现了一种新的隔离级别,就是快照读,这种方式利用了CopyOnWrite技术,在多个事物执行的时候会记录数据的一个快照,就是每个数据都有一个版本,读的时候可以不用和写冲突,当有还没有提交的写操作的时候,读可以直接读取未提交的事物之前的版本.避免了加锁操作.

  • D.持久性 事物完成后,该事物对数据库的更改便持久的保存在数据库中,写完数据后数据不丢.如何保证数据不丢呢? 我们可以将数据写到多个磁盘保证一个磁盘损坏后数据不丢,如何保证写到多个磁盘可以同时写入成功或者同时失败,这也需要用到事物的原子性,目前数据库一般采用RAID实现,不可能每次写数据都要sync到磁盘,性能太差,肯定会再内存缓存,在刷到磁盘,但是内存在断电会丢,所以mysql会用WAL技术,write ahead logging,写数据的时候先写入redolog,redolog是顺序写,速度很快,等redolog满了或者空闲的时候在把redolog里的记录写入磁盘上的某一个具体数据页.innodb采用redolog来保证mysql的crashsafe的能力.

解决事物的方法

要满足事物的一致性需求,我们需要协调事物之间对资源的竞争,管理一个事物的写是否对另外一个事物可见(读),一个事物在写的时候是否允许其他事物写,写包括修改和插入,这个时候我们有必要分析下几种锁的实现.

  • 串行 这种情况相当于所有的操作都串行,无论是读操作还是写操作,不管是否有资源竞争,统一按顺序单线程执行,不做任何冲突检测
  • 排他锁 针对有锁竞争的资源进行排队,需要检测是否有冲突,一个事实在对记录A读,就不允许其他事物对记录A进行读,更不允许写,但是允许对记录B读写
  • 读写锁 在排他锁的基础上,允许两个事物同时读,允许读读,不允许读写,写读,写写
  • MVCC 多版本并发控制,允许写读,在写的时候读操作可以读取到历史版本的数据,主要依托于mysql的undolog进行计算,允许写得时候读,这个极大的提高了mysql的QPS,减小了锁的力度,所以现在大部分数据库都有实现MVCC

以上分析了几种锁的实现,但是我发现有人会对乐观锁和悲观锁有错误的认识,我们也简单分析下,乐观还是悲观是争抢锁的时候处理方式不同,获取到锁之后的处理是一样的,悲观锁,故名思议,他比较悲观,认为肯定有其他人会和他争抢锁他抢到锁的概率很小,而且认为别人会将锁持有很长时间,所以当去获取锁的时候如果发现锁已经被占有,就释放cpu一直等待,直到占有者释放锁在通知他去争抢,而乐观锁认为应该没有人或者很少有人会和自己竞争,所以他会先去获取锁如果没获取到他就会自旋一段时间然后在去尝试获取,自旋的时候并不会释放CPU资源,而悲观锁会释放CPU资源进行等待,因为他比较悲观,以为要很久才获取的到锁,如果不释放CPU一直空等待,那么别人将无法获取到cpu资源影响性能,而悲观锁觉得占有者很快就会释放所以不会占有很长时间,因为如果释放CPU下次获取到锁还会额外有CPU上下文切换的开销,还不如自己持有CPU不释放. 而公平锁和非公平锁,是在竞争同一把锁的时候,维护一个队列,先进来的先获取锁,后来的后获取锁,很公平,非公平锁为了节省CPU上下文切换,当占有者释放锁的时候,在队列之外另一个锁争抢者正好尝试获取锁,那么就把锁给这个竞争者,而不是让他进入队列等待,然后取队尾竞争者,这个就是公平锁和非公平锁的区别.

处理事物面临的问题

  • 怎么构建read-view

    在MYSQL中,实际上每条SQL的更新都会记录一个回滚操作,insert就会对应一个delete(根据主键delete),update的回滚操作通过在update的时候先查询.根据查询结果生成回滚操作,然后在将值更新,例如一个值从1按顺序被修改为2,3,4,在回滚日志里就会有类似下面的记录.

当前的值为4,但是在查询这条记录的时候,不同时刻启动的事物会有不同的read-view,如图中看到的,在视图A,B,C中看到的这个记录的值分别为1,2,4.同一个记录在数据库中存在多个版本,这就是数据库的MVCC多版本并发控制,对于read-view A来说,要得到记录的值1,就需要将当前值4依次执行回滚端到1.这个时候如果另外一个事物,将当前值4改为5,也对这个read-view是没有影响的,这就可以支持在读已提交的隔离级别下,还可以支持写的时候可以并发读.回滚日志虽然也会持久化到硬盘上,但是还是会删除的,当系统中没有一个read-view视图需要用到这个回滚日志的时候,就会将这个回滚日志删除.

mysql中用mvcc来支持读已提交和课重复读,在支持读已提交的时候,需要在每条sql语句执行的时候都要重新构建一次read-view,这样就可以读取到其他事物已经提交的数据,而可重复读,只需要在事物开始的时候构建read-view,而之后整个事物周期都用这个read-view.

  • 死锁检测

    我们先通过哲学家问题,来回顾下死锁的发生. 5个哲学家围做一个餐桌,餐桌上有一盘意大利面,每两个哲学家之间放一把餐叉,哲学家必须分别左手和右手都拿到餐叉才能吃东西,如果每个哲学家都左手拿一把餐叉,等右边的餐叉就产生死锁,可以设计一个超时时间,如果5分钟后拿不到就放开左边的餐叉过5分钟在尝试,但是如果5个人同时进入餐厅还是会产生死锁,一种解法是餐厅有个服务生作为协调,哲学家拿餐叉需要经过服务生的同意,另外一种解法是,将餐叉编号,哲学家按顺序从一个方向获取,如果做成一个环释放的可以被其他人获取就是一个ringbuffer

    数据库里面的所有的写其实都是先读取,insert需要先查询看是否有唯一索引冲突,update需要先查询出来在内存中修改后在写会缓存行,删除需要先查询出来然后在进行删除操作,所以我们队数据库的操作对数据库而言还需要进行拆分,因为我们的update对数据库而言并不一定是一个原子操作,需要将这些操作拆分后在进行是否存在共享资源的竞争问题,然后在进行加锁操作,如果一个事物获取到A锁,想要获取B锁,此时另外一个事物获取到B锁想要获取A锁,就出现了死锁.

    当出现死锁的时候,一种策略是直接进入等待,直到超时,这个超时时间可以通过参数innodb_lock_wait_timeout来设置等待锁多久后算超时,但是这个时间对业务来说很难预估,太小会出现误伤太大延迟太高业务无法接受,另外一种策略是每次需要获取锁的时候就判断下是否发生了死锁.

    那么数据库是如何做死锁检测呢? 每当一个线程想要获取锁但是需要阻塞等待的时候就去检查下他所依赖的其他事物有没有被别人锁住,最后判断是否出现了循环,来鉴别是否发生死锁.所以死锁的检测是有代价的,并发度越高,死锁检测的成本越高,要提高效率最简单的办法就是减少并发度,但是也不能在客户端做,客户端都是分布式的,而且对业务方来说就是希望数据库能支持高并发,而现在要客户端减少并发量,那么只能在mysql本身来做,一种想法是 在对相同行的更新,在进入引擎前进入一个队列里排队,这样INNODB内部进行死锁检测的成本就会小很多.另外就是在设计上将会产生热点更新的记录进行拆分,例如将一个热点账户拆分成10个子账户.

mysql的锁机制

  • 全局锁

对整个数据库加锁,Flush tables with read lock,这样整个数据库就会进入只读状态,这个操作一般用来做逻辑备份,将数据库的所有数据select出来生成一个文件,这个操作只能在从库上执行,执行后从主库同步过来的binlog将不会执行,这样会导致主从延迟,但是如果在主库上执行,那基本会导致业务不可用

  • 表锁

表锁有两种,一种是主动锁表操作 lock table t1 read,t2 write, 这样t1表将不能写,t2表不能读写. 另外一种是MDL锁,当对一个表做增删改查的时候加读锁,当对表结构进行更改的时候加写锁,所以在增加一个字段或者删除一个字段,增加索引的时候将导致这个表无法进行增删改查.

有时候即使我们对一个小表删除一个字段,如果这个小表访问比较频繁,也可能导致数据库挂掉,

Session先启动会申请一个MDL读锁,之后Session2也可以获取到MDL读锁,如果此时SessionA还没有释放读锁,那么Session3获取写锁将会阻塞,Session3阻塞不会给业务带来什么影响,但是会导致后面的所有读请求获取MDL读锁阻塞,如果后面有大量的查询请求过来,将导致数据库不可用,所以在对一个小表进行删除字段的时候需要看下当前是否有长事物正在执行,或者在执行ALTER TABle 的时候加一个超时时间

  • 行锁

Innodb 是支持行锁的,读锁和写锁,锁的实体是加在记录上的,同时也支持将锁的实体加在两行记录的间隙上,也就是gap lock间隙锁,主要是防止其他线程在间隙之间插入新数据,导致其他事物两次查询的数据不一致,而我们经常用到的 select for update,就会对扫描到的所有行上加锁,同时对这些行之间的间隙加锁,所以当我们使用

select * from table where id = 3 for update;
  • 当ID是主键,并且3存在的时候只会锁住id=3这一行记录
  • 当ID=3不存在的时候,会去主键索引上搜索ID=3这一行记录,这个搜索路径和索引的构建有关,可能会扫描到ID=2和ID=4这两行记录,那么将会将2,4这两行记录锁住,同时会锁住2和4之间的间隙,这些锁都针对的是主键索引.
  • 当ID不是主键的时候,并且ID上面没有加索引,那么将扫描主键索引,如果扫描到id=3的记录,那么将会把主键索引上扫描到的记录和之间的间隙加索引,没有扫描到也是一样
  • 当ID不是主键,但是ID上面有普通索引的时候,先扫描二级索引,如果扫描到并且这个索引是普通索引,就会将二级索引上的这条记录和主键索引上的这条记录加锁,如果扫描到但是不是唯一索引,就会将普通索引上扫描到的记录和之间的间隙加锁,并将id=6这条记录对应的主键索引的这条记录加锁,如果没有扫描到,会将扫描到的行和之间的间隙在二级索引上加锁,将扫描到的行在主键索引上加锁.

所以当我们使用 select for update的时候,需要关心这条记录是否存在,是否会扫描到多行,是否走索引,走的普通索引还是主键索引,普通索引是否是唯一索引,这样才能确定这条语句会对什么数据加锁.