1. 程式人生 > >SpringBoot-MongoDB 索引衝突分析及解決

SpringBoot-MongoDB 索引衝突分析及解決

一、背景

spring-data-mongo 實現了基於 MongoDB 的 ORM-Mapping 能力,
通過一些簡單的註解、Query封裝以及工具類,就可以通過物件操作來實現集合、文件的增刪改查;
在 SpringBoot 體系中,spring-data-mongo 是 MongoDB Java 工具庫的不二之選。

二、問題產生

在一次專案問題的追蹤中,發現SpringBoot 應用啟動失敗,報錯資訊如下:

Error creating bean with name 'mongoTemplate' defined in class path resource [org/bootfoo/BootConfiguration.class]: Bean instantiation via factory method failed; nested exception is org.springframework.beans.BeanInstantiationException: Failed to instantiate [org.springframework.data.mongodb.core.MongoTemplate]: Factory method 'mongoTemplate' threw exception; nested exception is org.springframework.dao.DataIntegrityViolationException: Cannot create index for 'deviceId' in collection 'T_MDevice' with keys '{ "deviceId" : 1}' and options '{ "name" : "deviceId"}'. Index already defined as '{ "v" : 1 , "unique" : true , "key" : { "deviceId" : 1} , "name" : "deviceId" , "ns" : "appdb.T_MDevice"}'.; nested exception is com.mongodb.MongoCommandException: Command failed with error 85: 'exception: Index with name: deviceId already exists with different options' on server 127.0.0.1:27017. The full response is { "createdCollectionAutomatically" : false, "numIndexesBefore" : 6, "errmsg" : "exception: Index with name: deviceId already exists with different options", "code" : 85, "ok" : 0.0 }
    at org.springframework.beans.factory.annotation.AutowiredAnnotationBeanPostProcessor$AutowiredFieldElement.inject(AutowiredAnnotationBeanPostProcessor.java:588)
    at org.springframework.beans.factory.annotation.InjectionMetadata.inject(InjectionMetadata.java:88)
    at org.springframework.beans.factory.annotation.AutowiredAnnotationBeanPostProcessor.postProcessPropertyValues(AutowiredAnnotationBeanPostProcessor.java:366)
    at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.populateBean(AbstractAutowireCapableBeanFactory.java:1264)
    at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:553)

...

Caused by: org.springframework.dao.DataIntegrityViolationException: Cannot create index for 'deviceId' in collection 'T_MDevice' with keys '{ "deviceId" : 1}' and options '{ "name" : "deviceId"}'. Index already defined as '{ "v" : 1 , "unique" : true , "key" : { "deviceId" : 1} , "name" : "deviceId" , "ns" : "appdb.T_MDevice"}'.; nested exception is com.mongodb.MongoCommandException: Command failed with error 85: 'exception: Index with name: deviceId already exists with different options' on server 127.0.0.1:27017. The full response is { "createdCollectionAutomatically" : false, "numIndexesBefore" : 6, "errmsg" : "exception: Index with name: deviceId already exists with different options", "code" : 85, "ok" : 0.0 }
    at org.springframework.data.mongodb.core.index.MongoPersistentEntityIndexCreator.createIndex(MongoPersistentEntityIndexCreator.java:157)
    at org.springframework.data.mongodb.core.index.MongoPersistentEntityIndexCreator.checkForAndCreateIndexes(MongoPersistentEntityIndexCreator.java:133)
    at org.springframework.data.mongodb.core.index.MongoPersistentEntityIndexCreator.checkForIndexes(MongoPersistentEntityIndexCreator.java:125)
    at org.springframework.data.mongodb.core.index.MongoPersistentEntityIndexCreator.<init>(MongoPersistentEntityIndexCreator.java:91)
    at org.springframework.data.mongodb.core.index.MongoPersistentEntityIndexCreator.<init>(MongoPersistentEntityIndexCreator.java:68)
    at org.springframework.data.mongodb.core.MongoTemplate.<init>(MongoTemplate.java:229)
    at org.bootfoo.BootConfiguration.mongoTemplate(BootConfiguration.java:121)
    at org.bootfoo.BootConfiguration$$EnhancerBySpringCGLIB$$1963a75.CGLIB$mongoTemplate$2(<generated>)
    at sun.reflect.DelegatingMethodAccessorImpl.invoke(Unknown Source)
    at java.lang.reflect.Method.invoke(Unknown Source)
    at org.springframework.beans.factory.support.SimpleInstantiationStrategy.instantiate(SimpleInstantiationStrategy.java:162)
    ... 58 more

Caused by: com.mongodb.MongoCommandException: Command failed with error 85: 'exception: Index with name: deviceId already exists with different options' on server 127.0.0.1:27017. The full response is { "createdCollectionAutomatically" : false, "numIndexesBefore" : 6, "errmsg" : "exception: Index with name: deviceId already exists with different options", "code" : 85, "ok" : 0.0 }
    at com.mongodb.connection.ProtocolHelper.getCommandFailureException(ProtocolHelper.java:115)
    at com.mongodb.connection.CommandProtocol.execute(CommandProtocol.java:114)
    at com.mongodb.connection.DefaultServer$DefaultServerProtocolExecutor.execute(DefaultServer.java:168)


關鍵資訊:org.springframework.dao.DataIntegrityViolationException: Cannot create index

從異常資訊上看,出現的是索引衝突(Command failed with error 85),spring-data-mongo 元件在程式啟動時會實現根據註解建立索引的功能。
檢視業務實體定義:

@Document(collection = "T_MDevice")
public class MDevice {

    @Id
    private String id;

    @Indexed(unique=true)
    private String deviceId;

deviceId 這個欄位上定義了一個索引,unique=true表示這是一個唯一索引。
我們繼續 檢視 MongoDB中表的定義:

db.getCollection('T_MDevice').getIndexes()

>>
[
    {
        "v" : 1,
        "key" : {
            "_id" : 1
        },
        "name" : "_id_",
        "ns" : "appdb.T_MDevice"
    },
    {
        "v" : 1,
        "key" : {
            "deviceId" : 1
        },
        "name" : "deviceId",
        "ns" : "appdb.T_MDevice"
    }
]

發現數據庫表中同樣存在一個名為 deviceId的索引,但是並非唯一索引!

三、詳細分析

為了核實錯誤產生的原因,我們嘗試通過 Mongo Shell去執行索引的建立,發現返回了同樣的錯誤。
通過將資料庫中的索引刪除,或更正為 unique=true 之後可以解決當前的問題。

從嚴謹度上看,一個索引衝突導致 SpringBoot 服務啟動不了,是可以接受的。
但從靈活性來看,是否有某些方式能禁用索引的自動建立,或者僅僅是列印日誌呢?

嘗試 google spring data mongodb disable index creation
發現 JIRA-DATAMONGO-1201在2015年就已經提出,至今未解決。

stackoverflow 找到許多同樣問題
但大多數的解答是不採用索引註解,選擇其他方式對索引進行管理。

這些結果並不能令人滿意。

嘗試檢視 spring-data-mongo 的機制,定位到 MongoPersistentEntityIndexCreator類:

  1. 初始化方法中,會根據 MappingContext(實體對映上下文)中已有的實體去建立索引
public MongoPersistentEntityIndexCreator(MongoMappingContext mappingContext, MongoDbFactory mongoDbFactory,
            IndexResolver indexResolver) {
        ...
        //根據已有實體建立
        for (MongoPersistentEntity<?> entity : mappingContext.getPersistentEntities()) {
            checkForIndexes(entity);
        }
    }
  1. 在接收到MappingContextEvent時,建立對應實體的索引
    public void onApplicationEvent(MappingContextEvent<?, ?> event) {

        if (!event.wasEmittedBy(mappingContext)) {
            return;
        }

        PersistentEntity<?, ?> entity = event.getPersistentEntity();

        // Double check type as Spring infrastructure does not consider nested generics
        if (entity instanceof MongoPersistentEntity) {
            //建立單個實體索引
            checkForIndexes((MongoPersistentEntity<?>) entity);
        }
    }

MongoPersistentEntityIndexCreator是通過MongoTemplate引入的,如下:

    public MongoTemplate(MongoDbFactory mongoDbFactory, MongoConverter mongoConverter) {

        Assert.notNull(mongoDbFactory);

        this.mongoDbFactory = mongoDbFactory;
        this.exceptionTranslator = mongoDbFactory.getExceptionTranslator();
        this.mongoConverter = mongoConverter == null ? getDefaultMongoConverter(mongoDbFactory) : mongoConverter;
        ...

        // We always have a mapping context in the converter, whether it's a simple one or not
        mappingContext = this.mongoConverter.getMappingContext();
        // We create indexes based on mapping events
        if (null != mappingContext && mappingContext instanceof MongoMappingContext) {
            indexCreator = new MongoPersistentEntityIndexCreator((MongoMappingContext) mappingContext, mongoDbFactory);
            eventPublisher = new MongoMappingEventPublisher(indexCreator);
            if (mappingContext instanceof ApplicationEventPublisherAware) {
                ((ApplicationEventPublisherAware) mappingContext).setApplicationEventPublisher(eventPublisher);
            }
        }
    }


    ...
    //MongoTemplate實現了 ApplicationContextAware,當ApplicationContext被例項化時被感知
    public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {

        prepareIndexCreator(applicationContext);

        eventPublisher = applicationContext;
        if (mappingContext instanceof ApplicationEventPublisherAware) {
            //MappingContext作為事件來源,向ApplicationContext釋出
            ((ApplicationEventPublisherAware) mappingContext).setApplicationEventPublisher(eventPublisher);
        }
        resourceLoader = applicationContext;
    }

    ...
    //注入事件監聽
    private void prepareIndexCreator(ApplicationContext context) {

        String[] indexCreators = context.getBeanNamesForType(MongoPersistentEntityIndexCreator.class);

        for (String creator : indexCreators) {
            MongoPersistentEntityIndexCreator creatorBean = context.getBean(creator, MongoPersistentEntityIndexCreator.class);
            if (creatorBean.isIndexCreatorFor(mappingContext)) {
                return;
            }
        }

        if (context instanceof ConfigurableApplicationContext) {
            //使 IndexCreator 監聽 ApplicationContext的事件
            ((ConfigurableApplicationContext) context).addApplicationListener(indexCreator);
        }
    }

由此可見,MongoTemplate在初始化時,先通過MongoConverter 帶入 MongoMappingContext,
隨後完成一系列初始化,整個過程如下:

  • 例項化 MongoTemplate;
  • 例項化 MongoConverter;
  • 例項化 MongoPersistentEntityIndexCreator;
  • 初始化索引(通過MappingContext已有實體);
  • Repository初始化 -> MappingContext 釋出對映事件;
  • ApplicationContext 將事件通知到 IndexCreator;
  • IndexCreator 建立索引

在例項化過程中,沒有任何配置可以阻止索引的建立。

四、解決問題

從前面的分析中,可以發現問題關鍵在 IndexCreator,能否提供一個自定義的實現呢,答案是可以的!

實現的要點如下

  1. 實現一個IndexCreator,可繼承MongoPersistentEntityIndexCreator,去掉索引的建立功能;
  2. 例項化 MongoConverter和 MongoTemplate時,使用一個空的 MongoMappingContext物件避免初始化索引;
  3. 將自定義的IndexCreator作為Bean進行註冊,這樣在prepareIndexCreator方法執行時,
    原來的 MongoPersistentEntityIndexCreator不會監聽ApplicationContext的事件
  4. IndexCreator 實現了ApplicationContext監聽,接管 MappingEvent事件處理。

例項化Bean

    @Bean
    public MongoMappingContext mappingContext() {
        return new MongoMappingContext();
    }

    // 使用 MappingContext 例項化 MongoTemplate
    @Bean
    public MongoTemplate mongoTemplate(MongoDbFactory mongoDbFactory, MongoMappingContext mappingContext) {
        MappingMongoConverter converter = new MappingMongoConverter(new DefaultDbRefResolver(mongoDbFactory),
                mappingContext);
        converter.setTypeMapper(new DefaultMongoTypeMapper(null));

        MongoTemplate mongoTemplate = new MongoTemplate(mongoDbFactory, converter);

        return mongoTemplate;
    }

自定義IndexCreator

    // 自定義IndexCreator實現
    @Component
    public static class CustomIndexCreator extends MongoPersistentEntityIndexCreator {

        // 構造器引用MappingContext
        public CustomIndexCreator(MongoMappingContext mappingContext, MongoDbFactory mongoDbFactory) {
            super(mappingContext, mongoDbFactory);
        }

        public void onApplicationEvent(MappingContextEvent<?, ?> event) {
            PersistentEntity<?, ?> entity = event.getPersistentEntity();

            // 獲得Mongo實體類
            if (entity instanceof MongoPersistentEntity) {
                System.out.println("Detected MongoEntity " + entity.getName());
                
                //可實現索引處理..
            }
        }
    }

在這裡 CustomIndexCreator繼承了MongoPersistentEntityIndexCreator,將自動接管MappingContextEvent事件的監聽。
在業務實現上可以根據需要完成索引的處理!

小結

spring-data-mongo 提供了非常大的便利性,但在靈活性支援上仍然不足。上述的方法實際上有些隱晦,在官方文件中並未提及這樣的方式。
ORM-Mapping 框架在實現Schema對映處理時需要考慮校驗級別,比如 Hibernate便提供了 none/create/update/validation 多種選擇,畢竟這對開發者來說更加友好。
期待 spring-data-mongo 在後續的演進中能儘快完善 Schema的管理功能!