背景
锁机制要解决的是并发请求下资源分配的问题,对于数据库来说,就是并发的读写。锁机制要处理两个问题,一个是最基本的,要保证写的原子性,否则会在并发情况下产生混乱。另一个是提高并发效率。
锁的类型
锁加在被请求的资源上,用于标明资源当前的状态。从能否被多个用户共享的角度来说,锁有两种类型:用于读的共享锁(S)和用于写的排它锁(X)。
每一个对资源的请求,都需要首先确保请求的资源上有相应的锁。比如对某个文档的读请求,要先给它加上用于读的S锁,被S锁住的资源不能再加上X锁,确保读的过程中资源不会被改写。同时这个S锁本身是共享的,它锁住的资源可以被其他并发的读请求获取。而写锁是排他的,同一个资源的写锁,只能被单个的请求持有。这种机制即保证了写的原子性(X锁排他),又保证了读的并发性(S锁共享)。
上面这种处理并发的方式,叫做悲观并发控制(pessimistic concurrency control),也叫悲观锁。每一个读写请求都要检查相应的锁,有了对应的锁才进行后续操作,严格的杜绝了数据的混乱。同时悲观锁的代价是要消耗CPU资源去做锁相关的操作,请求可能会互相阻塞,甚至发生死锁。比如两个写请求A和B同时请求a、b两个资源。如果A给a加上了锁,还没给b加上锁;B给b加上了锁,还没有给a加上锁,就会导致死锁。
锁的粒度
随着mongo版本的迭代,锁的粒度越来越精细。对于mongo3.0之后的默认引擎WiredTiger,锁的粒度最小是文档级别的。对于MMAPv1存储引擎最小粒度的锁是表级别的,对一个表的读写操作会锁住整个表,即使这个操作只涉及了少数几条记录。一般来说,锁的粒度越大,越不容易产生死锁,锁的控制就会越简单。同时带来的代价是并发性能变差。
上面锁的粒度是从空间上来说的,从时间粒度上,悲观锁是在读写操作的整个过程上加了锁。整个读或者写的过程上是互相阻塞的。很自然的,如果减少时间维度上锁的粒度,会带来并发上的好处。
比如,只给”写入“的步骤加锁,改成下面这种方式:
但是,这样会产生两个问题:
- 读写冲突:读操作没有了S锁,读的过程中有可能数据部分被修改,发生脏读;
- 写写冲突:写操作的读和处理阶段,其他的写操作会修改数据
对于第二个问题,写操作写入时,可以做一个校验,确保从读开始没有其他写操作修改相应数据。如果有了修改,就重新执行整个写操作。这样就减少了资源被锁住的时间,成本是多个写操作修改相同数据时,有可能会发生多次重试。对于读多写少,写冲突少的情况,这种交换是合适的。这种并发处理机制称为乐观并发控制(optimistic concurrency control),也叫乐观锁。和悲观锁相比,通过增加校验和重试机制,放弃了在整个过程上加锁。
对于第一个问题,一种解决方法是保留数据的历史版本(MVCC),读的是数据的历史版本,写入时创建新的数据版本。
mongodb的WiredTiger引擎对大多数读写操作使用的是乐观锁,同时使用了MVCC。
锁的调度和让渡
mongodb的锁是公平的,所有的请求会排队获取相应的锁。但是mongodb为了优化吞吐量,在执行某个请求时,会同时执行和它相容的其他请求。比如一个请求队列需要的锁如下,执行IS请求的同时,会同时执行和它相容的其他S和IS请求。等这一批请求的S锁释放后,再执行X锁的请求。
IS → IS → X → X → S → IS
这种处理机制保证了在相对公平的前提下,提高了吞吐量,不会让某一类请求长时间的等待。
对于长时间的读或者写操作,某些条件下,mongodb会临时的让渡锁,以防止长时间的阻塞。
常用操作与锁
insert:库级别的意向读锁(r),表级别的意向读锁(r),文档级别的读锁(R)
update:库级别的意向写锁(w),表级别的意向写锁(w),文档级别的写锁(W)
foreground方式创建索引:库级别的写锁(W)
background方式创建索引:库级别的意向写锁(w),表级别的意向写锁(w)