深入理解Berkeley DB的锁:理论与实践篇
本文仅仅从应用的角度来谈一谈Berkeley DB中锁相关的理论与实践经验,接下来还会有一篇博客来介绍BDB锁的内部实现。
锁粒度
除了Queue Access Method,其他所有的Access Pattern都是页级锁(page-level locking),而Page大小默认为操作系统filesystem的block size(Linux下默认为4K)。
(可以通过减少Page大小,使一个Page上容纳更少的记录来减少页级锁粒度,但是减小Page会影响数据库的IO效率,在缺乏足够性能数据支撑的情况下,很少会这样做。)
BDB的页级别的锁粒度一向是比较恼人的问题,由于Queue并不常用(key为逻辑记录号,value为定长),而一般使用BDB的都需要较为松散自由的key-value存取,来满足灵活(Schema-Free)的数据,注定了使用BDB大部分情况下都要和页级锁打交道。
页级锁的存在大大增加了锁冲突的可能,减少了高并发情况下的吞吐量。对于读-写冲突,可以根据业务逻辑的需要选择“脏读"(uncommited read)或者使用快照事务(snapshot)来避免,但是对于写-写冲突,锁争用无法避免,应用程序需要随时做好应付死锁的准备。关于这两点,下文会详细说明。
锁协议与隔离级别
默认情况下,BDB的事务采用的是严格的二阶段锁协议(strong strict 2-phase locking, SS2PL),即随着事务的进行不断获取锁(读锁/写锁),直到事务结束(commit/abort)时才会释放所有的锁。
实际上,SS2PL的约束过于强烈,如果在某些情况下不需要如此之高的隔离程度,可以配置BDB不同的隔离级别(Isolation level)以放宽SS2PL的限制,减少锁争用以提高整个系统的吞吐。
Berkeley DB有4种隔离级别以供选择:
(注意:所有的隔离级别都不允许”脏写“,即一个事务更改另一个事务未经提交的数据,这是事务隔离的基本保证)
1. Read-Uncommitted :允许”脏读“(一个事务可以读取另一个事务修改中但未提交的数据)。这是能够配置的低的隔离级别,读写冲突小。
2. Read-Committed :不允许”脏读“,基本上和默认级别一样,除了事务游标(Cursor)在移动时会释放之前的持有的锁。
3. Serializable:(默认级别)可序列化,遵守SS2PL。相对于Read-Committed级别,游标的锁在其关闭之前不会释放,保证了游标的”可重复读“(repeatable reads)。
4. Snapshot:快照隔离,能够保证和Serializable一样的隔离效果。snapshot事务读DB时不会请求读锁,大大减少读-写冲突。
谈谈隔离级别的选择:
Berkeley DB对隔离级别的配置是很灵活的,允许统一数据库环境下的不同的事务采用不同的隔离级别,只要显示在数据层开启了该级别的支持。
1. 对于数据一致性要求不高的场景(如大部分的Web应用),对大部分non-critical数据的读写可以采用Read-Uncommitted级别。该级别下,由于允许”脏读“,读写几乎没有冲突(为什么是”几乎“,下文还会说明),但读到的数据不一定正确。
2. Read-Committed和默认级别几乎没有区别,除了Cursor的锁协议。
在默认级别下,如果使用事务游标遍历数据库,游标会逐渐获取所有的读锁,极大阻塞其他事务的进行,使用Read-Committed级别会使游标使用锁耦合(lock-coupling)协议,在获取到下一页的锁的同时释放上一页的锁,减少了锁的占用。
3. 默认级别不多说,适合大部分对数据一致性要求高的场景,如处理关键/敏感数据的应用
4. Snapshot在保证默认级别隔离程度的同时减少了读写冲突,适用于多个读事务/单个写事务的应用场景。
快照隔离的原理是多版本并发控制(Multi-version Concurrency Control),所有事务都会采用Copy-on-write的协议,即需要写数据时,先将原页面的内容拷贝到一个新的页面上,在新的页面上进行修改,提交时再合并到数据库中:由于写事务没有在原页面上修改,保证了快照事务可以安全地读取该页面——实际上,快照事务读数据时不需要加读锁。
快照隔离不是的。1.耗内存:由于需要写前复制,数据库需要的Cache数量大约是不开启MVCC支持前的2倍(可以使用db_stat -m查看当前数据库使用cache的情况)2.依然不能解决写-写冲突。
锁类型
除了常见的读/写锁,为了减少锁冲突、提高吞吐量,Berkeley DB提供了多种粒度的意向锁(multi-granularity intention lock)。
BDB的锁相容矩阵(conflict matrix)如下图所示:(横栏表示当前持有的锁类型,竖栏表示加锁请求,勾表示锁冲突)
图1:BDB的锁相容矩阵
iRead/iWrite/iRW都是意向锁,意向锁是为了支持层次化锁(hierarchical locking),举例说明:
如果我们需要写某数据某页的某一条记录,我们将会发出一连串原子的锁请求:给DB加iWrite锁,给page加iWrite锁,给record加Write锁。在每个锁请求都被允许的条件下,加锁才算成功,否则放弃之前步骤已经获取的锁。
我们可以看出,对单条记录的读写操作在DB和Page层加的都是意向锁,意向锁比读/写锁弱的多,与之冲突的锁类型大大减少。只有DB级的全局操作(如遍历全记录、修改)才会在DB加上标准的读/写锁。
在这种层次化场景下,意向锁使得锁的粒度被减少了,同时加锁时检查的效率被提高了。
uRead/iwasWrite不是意向锁,而是BDB为了支持”脏读“(Read-Uncommitted)而使用的特殊的锁类型。
iwasWrite:在Read-Uncommitted级别下,所有事务的写操作先获取写锁,在Page上完成具体的修改后,写锁降级(downgrade)为iwasWrite——”已写锁“:iwasWrite的锁相容列表和普通的写锁基本相同:除了允许uRead。
uRead:在Read-Uncommitted级别下,允许”脏读“的事务在读数据时,会尝试获取该数据的”脏读锁“(uRead),在Page上完成一次完整的读取后,释放该uRead锁(注意是完成读取后即释放,"脏读锁“是一种临时锁,不会被长期持有,想一想为什么)
死锁与死锁检测
决定使用事务的一刻起,我们注定要与锁冲突进行无休止的战争。正如前文所述:
1. 尽管我们可以设置各种隔离级别来减少读-写冲突,写-写冲突总是不可避免的。
2. 即使应用层能够保证不会同时写同一个逻辑记录,页级锁的存在常常使这样的努力成为徒劳:)
除了影响并发性能,锁冲突带来的另一个严重问题是死锁。有两种情形可能造成BDB的死锁:
1. 资源的循环依赖:如线程1中的事务A持有Page1的锁,想要获取Page2的锁;线程2的事务B持有Page2的锁,想要获取Page1的锁:谁也不撒手。
2. ”自死锁“:同一个线程中开启了两个事务,一个事务等待另一个事务的锁,其实上是自己等待自己。
对于种死锁,Berkeley DB提供了两种死锁检测接口:
1. 自动检测:env->set_lk_detect(reject policy),每当一个加锁请求即将被阻塞时,BDB都会遍历内部的锁表(lock table)以检测是否有死锁发生。
如果有死锁发生,一部分拥有锁的事务将会被强制abort以解除死锁(abort时会释放所有已获得的锁)。可以指定BDB选择abort事务的策略,默认情况下是随机,为了系统的吞吐量考虑,一般选择abort掉拥有写锁数量少的事务(DB_LOCK_MINWRITE),因为持有写锁多的事务一般是已经执行了更多工作的事务。
2. 手工检测,如直接使用db_deadlock工具来检测并解决当前的死锁,在调试时极为有用。
然而对于第二种的“自死锁”,BDB的死锁检测无能为力。根据我们项目中使用BDB的经验来看,由于程序不慎而导致的“自死锁”还是比较常见的。
可以使用如下的方法判断是死锁还是“自死锁”:
1. 使用db_stat -Co工具来打印当前数据库的锁表,查看是否有锁的循环依赖:
图2:一个典型的死锁:
图3:一个典型的“自死锁”:
2. 使用db_deadlock工具,如果是正常的死锁,则一定可以被检测并解除。
应用层策略
Design for failure:
在支持事务的数据库中,死锁是常态。一定要在系统设计中考虑到死锁的可能,尽可能防止死锁,并提供相应的容错、重试的策略。
尽可能地防止死锁:
1. 所有事务使用一致地顺序来获取锁
如按照固定的顺序访问多个数据库、按Key的顺序重排(reorder)记录写入数据库的次序
2. 在事务的后访问热点资源(hot spot),使其锁持有时间尽可能短
容错与重试:
在事务进行的任意一点,都有可能因为出现死锁而被BDB终止。如果需要重试,必须回到原事务起点,开启一个新事物并重新执行。
(这也是不鼓励长事务的原因:除了长时间持有锁影响了并发的其他事务,在发现死锁时,长事务也相对较难找到一个起点,将之前的操作重演一遍)
来源 https://www.cnblogs.com/promise6522/archive/2012/06/01/2529407.html
相关文章