2.9 并发性、线程安全性以及锁机制
这部分内容 将介绍三个紧密联系的主题:索引文件的并发访问、IndexReader和IndexWriter的线程安全性,以及Lucene用于避免索引被破坏而使 用的锁机制。通常,Lucene的初学者们对这几个主题都存在一定的误解。而准确地理解这些内容是十分重要的,因为,当索引应用程序同时服务于大量不同的 用户时,或为了满足一些突发性的请求、而需要通过对某些操作进行并行处理时,这些内容会帮助你消除在构建应用程序过程中所遇到的疑问。
2.9.1 并发访问的规则
Lucene 提供了一些修改索引的方法,例如索引新文档、更新文档和删除文档;在执行这些操作时,为了避免对索引文件造成损坏,需要遵循一些特定的规则。这类问题通常 会在web应用程序中突显出来。因为web应用程序是同时为多个请求而服务的。Lucene的并发性规则虽然比较简单,但我们必须严格遵守:
— 任意数量的只读操作都可以同时执行。例如,多个线程或进程可以并行地对同一个索引进行搜索。
— 在索引正在被修改时,我们也可以同时执行任意数量的只读操作。例如,当某个索引文件正在被优化,或正在对索引执行文档的添加、更新或删除操作时,用户仍然可以对这个索引进行搜索。
— 在某一时刻,只允许执行一个修改索引的操作。也就是说,在同一时间,一个索引文件只能被一个IndexWriter或IndexReader对象打开。
基于以上的并发性规则,我们可以构造一些关于并发性的更全面的例子,如表2.2中所示。表中说明了是否允许我们对一个索引文件进行各种并发性的操作。
表2.2 是否允许对某个Lucene索引进行并发性操作的举例
操 作 |
是否允许 |
对同一个索引运行多个并行的搜索进程 |
允许 |
对一个正在生成、被优化或正在与另一索引合并的索引运行多个并行的搜索进程,或该索引正在进行删除、更新文档等操作时,对索引运行多个并行的搜索进程 |
允许 |
对同一个索引用多个IndexWriter对象执行添加、更新文档的操作 |
不允许 |
当一个从索引中删除文档的IndexReader对象没有成功关闭时,打开一个IndexWriter对象用于在这个索引中添加新的文档 |
不允许 |
IndexWriter对象向索引中添加新文档后,未成功关闭;在此之后,打开一个IndexReader对象用于从这个索引中删除文档 |
不允许 |
注:当正在修改一个索引时,请记住在某一时刻,在同一个索引上只能执行一个修改操作。
2.9.2 线程安全性
尽管 Lucene不允许使用多个IndexWriter或IndexReader实例同时对一个索引进行修改,如表2.2所示,但是这两个类都是线程安全 (thread-safe)的,了解这一点相当重要。因此,这两个类的实例都可以被多线程共享,Lucene会对各个线程中所有对索引进行修改的方法的调 用进行恰当的同步处理,以此来确保修改操作能一个接着一个地有序进行。图2.7描述了这样的一个场景:
图2.7 一个IndexWriter或IndexReader对象可以被多个线程所共享
应用程序不 需要进行额外的同步处理。尽管IndexReader和IndexWriter这两个类都是线程安全的,使用Lucene的应用程序还是必须确保这两个类 的对象对索引的修改操作不能重叠。也就是说,在使用IndexWriter对象将新文档被添加至索引中之前,必须关闭所有已经完成在同一个索引上,进行删 除操作的IndexReader实例。同样地,在IndexReader对象对索引中的文档进行删除和更新之前,必须关闭此前已经打开该索引的 IndexWriter实例。
表2.3是 一个关于并发操作的矩阵,它向我们展示了一些具体操作是否能并发地执行。该表假定应用程序只使用了一个IndexWriter或IndexReader实 例。请注意,在此我们并没有将对索引的更新视为一个单独的操作列出,因为它实际上可以被看成是在删除操作后再进行一个添加操作。
表2.3 使用同一个IndexWriter或IndexReader实例时的并发操作矩阵,表中打叉的部分表示两个操作不能同时执行
|
查找 |
读文档 |
添加 |
删除 |
优化 |
合并 |
查找 |
|
|
|
|
|
|
读文档 |
|
|
|
|
|
|
添加 |
|
|
|
× |
|
|
删除 |
|
|
× |
|
× |
× |
优化 |
|
|
|
× |
|
|
合并 |
|
|
|
× |
|
|
这个矩阵可以归纳为:
— IndexReader对象在从索引中删除一个文档时,IndexWriter对象不能向其中添加文档。
— IndexWriter对象在对索引进行优化时,IndexReader对象不能从其中删除文档。
— IndexWriter对象在对索引进行合并时,IndexReader对象也不能从其中删除文档。
从上面的矩 阵及其归纳中,我们可以得到这样一个使用模式:当IndexWriter对象在对索引进行修改操作时,IndexReader对象不能对索引进行修改。这 个操作模式是对称的:当IndexReader对象正在对索引进行修改操作时,IndexWriter对象同样也不能对索引进行修改。
这里读者可 以感到,Lucene的并发性规则和社会中的那些良好的习惯以及合理的法规具有相通之处。我们不一定非得严格地遵守这些规则,但是如果违反这些规则将会造 成相应的后果。在现实生活中,违反法律法规也许得锒铛入狱。在使用Lucene时,违背这些规则,则会损坏你的索引文件。Lucene使用者可能会对并发 性有错误的理解甚至误用,但Lucene的创造者们对此早已有所预料,因此他们通过锁机制尽可能地避免应用程序对索引造成意外的损坏。本书将在2.9.3 节中对Lucene索引的锁机制进行进一步的介绍。
2.9.3 索引锁机制
在 Lucene中,锁机制是与并发性相关的一个主题。在同一时刻只允许执行单一进程的所有代码段中,Lucene都创建了基于文件的锁,以此来避免误用 Lucene 的API造成对索引的损坏。各个索引都有其自身的锁文件集;在默认的情况下,所有的锁文件都会被创建在计算机的临时目录中,这个目录由Java的 java.io.tmpdir中的系统属性所指定。
如果在索引 文档时,观察一下那个临时目录,就可以看到Lucene的write.lock文件;在段(segment)进行合并时,还可以看到 commit.lock文件。你可以通过设定org.apache.lucene.lockDir中的系统属性,使锁文件存放的目录改至指定的位置。这个 系统属性可以通过使用Java API在程序中进行设定,还可以通过命令行进行设定,如:-Dorg.apache.lucene.lockDir=/path/to/lock /dir。若有多台计算机需要访问存储在共享磁盘中的同一个索引,则应该在程序中显式地设定锁目录,这样位于不同计算机上的应用程序才能访问到彼此的锁文 件。根据已知的锁文件以及网络文件系统(NFS)出现的问题,锁目录应该选择放在一个不依赖于网络的文件系统卷上。以下就是上面提到的两个锁文件:
write.lock 文件用于阻止进程试图并发地修改一个索引。更精确地说,IndexWriter对象在实例化时获得write.lock文件,直到IndexWriter 对象关闭之后才释放。当IndexReader对象在删除、恢复删除文档或设定域规范时,也需要获得这个文件。因此,write.lock会在对索引进行 写操作时长时间地锁定索引。
当对段进行 读或合并操作时,就需要用到commit.lock文件。在IndexReader对象读取段文件之前会获取commit.lock文件,在这个锁文件中 对所有的索引段进行了命名,只有当IndexReader对象已经打开并读取完所有的段后,Lucene才会释放这个锁文件。IndexWriter对象 在创建新的段之前,也需要获得commit.lock文件,并一直对其进行维护,直至该对象执行诸如段合并等操作,并将无用的索引文件移除完毕之后才释 放。因此,commit.lock的创建可能比write.lock更为频繁,但commit.lock决不能过长时间地锁定索引,因为在这个锁文件生存 期内,索引文件都只能被打开或删除,并且只有一小部分的段文件被写入磁盘里。表2.4对Lucene 中各种使用Lucene API来锁定索引的情况进行了概括。
表2.4 Lucene中所有锁及创建和释放锁的操作
锁文件 |
类 |
何时获取 |
何时释放 |
描述 |
write.lock |
IndexWriter |
构造函数 |
close() |
在关闭IndexWriter对象时释放锁 |
write.lock |
IndexReader |
delete(int) |
close() |
在关闭IndexReader对象时释放锁 |
write.lock |
IndexReader |
undeleteAll(int) |
close() |
在关闭IndexReader对象时释放锁 |
write.lock |
IndexReader |
setNorms (int,String,byte) |
close() |
在关闭IndexReader对象时释放锁 |
commit.lock |
IndexWriter |
构造函数 |
构造函数 |
段信息被读取或写入后立即释放锁 |
commit.lock |
IndexWriter |
addIndexes (IndexReader[]) |
addIndexes (IndexReader[]) |
写入新的段时获取锁文件 |
commit.lock |
IndexWriter |
addIndexes (Directory[]) |
addIndexes (Directory[]) |
写入新的段时获取锁文件 |
commit.lock |
IndexWriter |
mergeSegment (int) |
mergeSegments (int)) |
写入新的段时获取锁文件 |
commit.lock |
IndexReader |
open(Direcory) |
Open(Direcory) |
所有段被读取后获取锁文件 |
commit.lock |
SegmentReader |
doClose() |
doClose() |
段的文件被写入或重写后获取锁文件 |
commit.lock |
SegmentReader |
undeleteAll() |
undeleteAll() |
移除段.del文件后获取锁文件 |
请注意另外两个与锁相关的方法:
— IndexReader的isLocked(Directory) ——这个方法可以判断参数中指定的索引是否已经被上锁。在想要对索引进行某种修改操作之前,应用程序需要检查索引是否被锁保护时,通过使用这个方法可以很方便地得到结果。
— IndexReader的unlock(Directory) ——该方法的作用正如其命名那样。尽管通过这个方法可以使你在任意时刻对任意的Lucene索引进行解锁,然而它的使用具有一定的危险性。因为 Lucene创建锁自有其理由,此外,在修改一个索引时对其解锁可能导致这个索引被损坏,从而使得这个索引失效。
虽然知 道Lucene使用了哪些锁文件,何时、为什么要使用它们,以及在文件系统的何处存放这些锁文件,但是你不能直接在文件系统对它们进行操作。你应该通过 Lucene的API对它们进行操作。否则,如果将来Lucene开始启用一种不同的锁机制,或者Lucene改变了锁文件的命名或存储位置时,应用程序 可能会受到影响而不能顺利执行。
锁机制的实例
为了演示锁是如何使用的,程序2.7演示了Lucene如何利用锁来避免在同一时刻对同一索引文件进行多个修改操作。在testWriteLock( )方法中,Lucene对一个已经被IndexWriter对象打开的索引上锁,阻止第二个IndexWriter对象对这个索引进行修改。在这个例子中使用了write.lock文件:
程序2.7 使用基于文件的锁以防止索引被损坏
testCommitLock( )方法展示了应用程序是如何使用commit.lock文件,程序通过IndexReader的open(Directory)方法获得这个锁文件,并在 读取了所有的索引段之后就通过同样的方式立即释放它。因为程序是通过与获得锁文件相同的方法释放的。因此,甚至在关闭第一个IndexReader对象之 前,我们仍可以用第二个IndexReader对象来访问同一个目录。(你可能对这个方法中的IndexWriter对象感到诧异:它的惟一目的在于通过 创建程序所需的段文件,这个段文件包含了已经存在的所有索引段的信息。若没有这些段文件,IndexReader对象就会成为无的之矢,因为那样的话,它 就不知道该从索引目录中读取哪些段的信息了)。
当运行这段代码时,可以看到已被上锁的索引造成了类似如下异常的堆栈跟踪信息:
如同我们先 前提到的,Lucene的初学者们有时对这一章中介绍的并发性没有很好理解,从而陷入到本小节中提到的关于锁的问题里,以至在程序中出现了上面所示的异 常。在你的应用程序中如果出现了类似的异常,而索引的一致性对用户而言又十分重要,那么请不要漠视这些异常。与锁相关的异常通常是误用了Lucene API的一个标志;若在应用程序中出现了这种异常,应该妥善地处理它们。
2.9.4 禁用索引锁
我们强烈地 建议读者不要对Lucene的锁机制进行随意修改,不要漠视与锁相关的异常。然而在一些情况下,你也许想禁用Lucene当中的锁机制,并且这样做不会破 坏索引文件。例如,应用程序可能需要访问存储在CD-ROM上的Lucene索引。因为CD是一种只读介质,这意味着应用程序对索引的操作也是只读模式 的。换句话说,该应用程序只使用Lucene来搜索索引而不需要对索引进行任何形式的修改。尽管Lucene已经将锁文件保存在系统的临时目录(这个目录 通常可以被系统的所有用户打开以用于写操作)中,但是你仍可以通过将disableLuceneLocks这个系统属性设定为“true”,从而禁用 write.lock和commit.lock文件。