如果再有人问你数据库的原理,把这篇文章给他(16)


锁管理器
为了解决这个问题,多数数据库使用锁和/或数据版本控制 。这是个很大的话题,我会集中探讨锁,和一点点数据版本控制 。
悲观锁
原理是:

  • 如果一个事务需要一条数据
  • 它就把数据锁住
  • 如果另一个事务也需要这条数据
  • 它就必须要等第一个事务释放这条数据
    这个锁叫排他锁 。
但是对一个仅仅读取数据的事务使用排他锁非常昂贵,因为这会迫使其它只需要读取相同数据的事务等待 。因此就有了另一种锁,共享锁 。
共享锁是这样的:
  • 如果一个事务只需要读取数据A
  • 它会给数据A加上『共享锁』并读取
  • 如果第二个事务也需要仅仅读取数据A
  • 它会给数据A加上『共享锁』并读取
  • 如果第三个事务需要修改数据A
  • 它会给数据A加上『排他锁』,但是必须等待另外两个事务释放它们的共享锁 。
同样的,如果一块数据被加上排他锁,一个只需要读取该数据的事务必须等待排他锁释放才能给该数据加上共享锁 。
如果再有人问你数据库的原理,把这篇文章给他

文章插图
 
锁管理器是添加和释放锁的进程,在内部用一个哈希表保存锁信息(关键字是被锁的数据),并且了解每一块数据是:
  • 被哪个事务加的锁
  • 哪个事务在等待数据解锁
死锁
但是使用锁会导致一种情况,2个事务永远在等待一块数据:
如果再有人问你数据库的原理,把这篇文章给他

文章插图
 
在本图中:
  • 事务A 给 数据1 加上排他锁并且等待获取数据2
  • 事务B 给 数据2 加上排他锁并且等待获取数据1
这叫死锁 。
在死锁发生时,锁管理器要选择取消(回滚)一个事务,以便消除死锁 。这可是个艰难的决定:
  • 杀死数据修改量最少的事务(这样能减少回滚的成本)?
  • 杀死持续时间最短的事务,因为其它事务的用户等的时间更长?
  • 杀死能用更少时间结束的事务(避免可能的资源饥荒)?
  • 一旦发生回滚,有多少事务会受到回滚的影响?
在作出选择之前,锁管理器需要检查是否有死锁存在 。
哈希表可以看作是个图表(见上文图),图中出现循环就说明有死锁 。由于检查循环是昂贵的(所有锁组成的图表是很庞大的),经常会通过简单的途径解决:使用超时设定 。如果一个锁在超时时间内没有加上,那事务就进入死锁状态 。
锁管理器也可以在加锁之前检查该锁会不会变成死锁,但是想要完美的做到这一点还是很昂贵的 。因此这些预检经常设置一些基本规则 。
两段锁
实现纯粹的隔离最简单的方法是:事务开始时获取锁,结束时释放锁 。就是说,事务开始前必须等待确保自己能加上所有的锁,当事务结束时释放自己持有的锁 。这是行得通的,但是为了等待所有的锁,大量的时间被浪费了 。
更快的方法是两段锁协议(Two-Phase Locking Protocol,由 DB2 和 SQL Server使用),在这里,事务分为两个阶段:
  • 成长阶段:事务可以获得锁,但不能释放锁 。
  • 收缩阶段:事务可以释放锁(对于已经处理完而且不会再次处理的数据),但不能获得新锁 。

如果再有人问你数据库的原理,把这篇文章给他

文章插图
 
这两条简单规则背后的原理是:
  • 释放不再使用的锁,来降低其它事务的等待时间
  • 防止发生这类情况:事务最初获得的数据,在事务开始后被修改,当事务重新读取该数据时发生不一致 。
这个规则可以很好地工作,但有个例外:如果修改了一条数据、释放了关联的锁后,事务被取消(回滚),而另一个事务读到了修改后的值,但最后这个值却被回滚 。为了避免这个问题,所有独占锁必须在事务结束时释放 。
多说几句
当然了,真实的数据库使用更复杂的系统,涉及到更多类型的锁(比如意向锁,intention locks)和更多的粒度(行级锁、页级锁、分区锁、表锁、表空间锁),但是道理是相同的 。
我只探讨纯粹基于锁的方法,数据版本控制是解决这个问题的另一个方法 。
版本控制是这样的: