MongoDB的锁模式
-
MongoDB的锁设计
MongoDB的高性能表现离不开它的多粒度锁机制。多粒度主要可以针对不同层级的数据库对象进行枷锁,通过避免全局性的互斥来提升并发能力。从整个数据库层面看,MongoDB的并发锁的分层如下图所示:
从上往下是一个逐步细分的关系,分别为Global(全局)、Database(数据库)、Collection(集合)、Document(文档)。需要说明的是,mongodb只定义了前三种级别的锁,对于文档级的由WiredTiger引擎实现的,其内部使用了MVCC乐观锁的方式来实现并发控制。因此,并不是所有引擎的实现都支持文档级别的锁。
除此之外,针对不同的读写方式,数据库将所类型分为以下几种。
- 读锁(R),代表共享锁(S),允许多个线程同时读取一个集合,读读不互斥。
- 写锁(W),代表排它锁(X),允许一个线程写入数据,写写互斥,读写互斥。
- 意向读锁(r),代表意向的共享锁(IS)。
- 意向写锁(w),代表意向的排它锁(IX)。
其中,读写锁一般比较容易理解,但是意向锁却有着更为重要的意义。他描述了一种中间状态(非真正的互斥)。对于某一层资源进行锁定时,都需要对其高层次的资源添加意向锁。而且,意向锁之间不是互斥的,这样可以保证在高层次资源上尽可能不会出现锁争用的情况。
比如,更新users集合中的某一条用户记录(id=1)时,需要经过如下流程:
- 对Global添加意向写锁。
- 对Database添加意向写锁。
- 对Collection添加意向写锁。
- 对users(id=1)记录执行更新(乐观锁)。
-
查看锁的状态
javascriptdb.currentOp()
-
锁的让步
在某些情况下,读写操作会让出它们所持有的锁,这个操作是在数据库内部发生的。对于一些长时间运行的读写操作,如查询、更新和删除,有很大的概率会产生这种让步行为。对于updateMany这样的操作,mongodb同样会在每次更新文档的间隙让出锁。wiredTiger已经达到了文档级别的细粒度并发控制,对于集合以及以上级别的意向锁也不会影响并发,那为什么还需要进行让渡呢?
- 避免长时间执行的存储性事务,因为这些会给内存造成较大的压力。
- 作为中断响应点,以便可以杀死长时间运行的操作。
- 允许一些关键的排它新操作得到执行,例如索引/集合的创建、删除。
MVCC
在并发场景下,用于保证一致性的做法一般有两种:
- 使用互斥锁。
- 基于MVCC的多版本并发控制。
其中,MVCC是目前数据库中使用最广泛的一种机制,包括mysql、mongodb等流行数据库都在使用。我们可以将MVCC看成行级锁的一种妥协,它在许多情况下避免了使用锁,同时可以提供更小开销。相比互斥锁来说,MVCC允许存在数据的多份复制,可以使读操作和写操作同时进行,很大程度上提升了并发能力。
不同数据库对MVCC的实现方式有些不同,Mongodb对于文档的并发控制是由wiredtiger引擎实现的。其对于数据的每一次修改都会在内存中产生一个新的版本,并通过链表结构进行记录。
访问数据的线程会自动检查是否存在更新的版本并获取最近修改的副本。如果是删除操作,则会写入数据的delete标记,读取时进行判别即可。在这种模式下,允许写入线程在读取线程执行读操作时并发创建新版本,该过程是无锁的。而只有在多个线程尝试更新同一个记录时才会产生写冲突,此时只会有一个更新操作成功,其他操作会在稍后进行重试。
mongodb在写入更新记录时使用了基于version的乐观锁模式,当写冲突产生(尝试更新失败)时,wiredTiger内部会产生WT_ROLLBACK结果,而mongodb检测到该状态之后会抛出异常,最终由写入的执行线程捕获并重试。
mongodb在一开始就实现了单文档的事务,用来保证文档写操作、oplog以及journal日志的原子性。但这种单文档事务仍然属于内部机制,这是一种隐性的事务。而mongodb4.0版本之后支持的多文档事务则是显性的事务,多文档事务提供了基于快照一致性读能力,这样同样离不开MVCC。
总的来说,mongodb的MVCC在实现上有如下特性。
- 在内存中存放文档的多个版本。
- 读取数据行默认获取到当前最新的提交。如果在多文档事务中,还可以使用snapshot级别读取事务一致的版本。
- 写操作时只会追加新的版本,不会和读操作互斥。
- 对于同一个文档的并发更新会导致写冲突,由mongodb内部自动进行重试。
原子性操作
在分布式系统中,有很大概率会出现并发读写的情况。为了保证业务数据的一致性状态不遭受破坏,开发者通常需要对潜在的并发以及异常场景做出估量并采取适当的原子性保护。
几乎所有主流的编程语言都提供了良好的并发框架支持,例如,java的concurrent包就提供了全面的锁特性实现。借由这些能力,我们很容易在单进程应用中解决原子性方面的问题。但是,微服务架构让应用程序处理并发原子性问题变得更加复杂,关键在于这些进程内施加的本地锁无法解决分布式的问题 。
对于mongodb来说,更多的应用实践倾向于利用单文档事务性来解决原子性问题,当然,也可以使用高版本中的多文档事务来实现,但缺点是必须接受多文档事务带来的性能损失。
乐观锁
CAS是避免并发冲突的优选模式,实现乐观锁的前提是文档的原子性更新。借助mongodb的CAS模式,可以巧妙解决许多并发性的问题。同时,可以使用版本号模式来实现CAS,通过在文档中加入一个特殊的版本号字段实现。
缓解行锁竞争
尽管mongodb实现了MVCC的文档级的并发控制,但如果是对于但文档的更新,则仍然会遇到高并发的问题。
一个典型的例子就计数器,这种需求在大量应用中是很常见的。比如用计数器表示缓存一段时间内的用户浏览量。计数器文档的体积很小,通常只有一个key和一个整数型value,而且很容易被缓存和快速读取。但唯一不足的是它的更新会受到行锁互斥的影响,从而导致性能的下降。
例如对一个文档同时执行100个更新操作,会产生100个MVCC版本,但只有一个会被成功保留,剩余的99个将会被重试,接下来继续更新,将又会剩下98个进行重试。。。这个过程会持续到所有操作都成功。因此,对于n个并发更新会产生指数级别的提交任务,这就会导致CPU使用率高居不下。如果希望改善这种情况,则可以采用分槽的做法,将一个文档分成诺干个文档,插入数据的时候随机插入其中一个文档中。这样,我们就将单个文档的写压力分摊到n个文档上,此时的行锁冲突会降低不少!但这样的读取方式需要做一些变动,即查询一天的计数器值需要对n个slot的值进行累计计算。当然,如果查询非常频繁,还可以使用定时器将slot的值预先汇聚到总表读取,进一步提高效率。
避免重复数据
某些重复性的数据会干扰系统的允许,严重的情况下还会导致一些难以修复的错误,所以应避免植入重复的业务数据。在开发层面,我们能做就是为字段添加唯一索引。
那些影响并发的操作
最后,我们来关注一些可能会影响并发性能的命令。这里所指的是,一些管理性质的命令实质上会对数据库集合,甚至整个库产生锁,在不经意间影响正常的业务操作。
- 创建索引,createIndex命令可以用于创建多个索引。整个命令的执行需要使用临时内存,这部分内存会由mongodb额外向操作系统申请。一个createIndex命令可用的最大内存默认是500mb,如果超过了这个值就会开始使用磁盘空间交换,所以这里可能会出现性能拐点。在mongodb4.0以及之前的版本中,索引的创建默认会使用前台模式,这会对整个数据库产生一个全局的排它锁,从而阻碍正常的业务。因此,建议创建索引应准确使用background模式。这个问题在mongodb4.2版本中有所改进,索引创建不再区分模式,而是仅仅在创建的一开始和最后产生短暂的排它锁,而且目标对象也改成了当前的集合。对超大集合建立索引的过程可能是缓慢的,这是因为创建索引时需要扫描全部的文档,而且一些常规的业务操作会使得建立索引的工作线程出现让步等待而进一步延缓。考虑到对性能的影响,在副本集执行大集合的索引创建时可以采取滚动操作的方式。
- 删除索引,dropIndex命令在执行时同样会产生库级的排他锁,在mongodb4.3版本中调整为集合的排它锁。
- 查询全部集合,listCollections会对数据库产生一个意向读锁。在mongodb4.0版本之前,这个命令对数据库产生的是一个共享锁,由于共享锁和意向写锁是互斥的,所以这会影响数据库中数据的写操作。
- 重命名集合,renameCollection会产生集合的排它锁,但rename操作的时间一般较短。
- 一些全局性的维护操作,如db.copyDatabase,db.collection.reIndex操作会导致所有数据库都被锁住,直到操作完成才能释放锁。