IT/NOSQL

[MongoDB] Timeseries Collection에 대한 연구

JeongHyeongKim 2022. 3. 17. 03:05

0. Table Of Content

 

1. Time Series Collection을 연구하게 된 계기

각 현장의 장비에 대해 데이터를 수집하기 위해 현재 mongodb를 도입하여 운영중이다. 그러나 성능이 좋지 않은 환경에서 초 단위의 데이터를 binary read, binary parsing, mongodb document create 및 validation까지 한꺼번에 하다보니 자원의 한계가 있었으며, 또한 한정된 ssd(512GB)에 데이터를 적재하다보니 최대한 local storage를 효율적으로 사용해야하는 상황에 직면하였다. 이러한 이유로 데이터 크기 자체를 경량화 하고, 이를 필요할 때만 binary를 cloud 환경에서 parsing 하는 환경을 생각하게 되었다.

이 생각의 시작으로 로컬 db의 경량화를 먼저 생각하던 중, Mongodb 5.0에 새로 등장한 Time Series Collection을 보게되었다. 대표적인 특징은 다음과 같다.

  • 시계열 데이터에 대해 일반 collection data 보다 차지하는 데이터의 크기가 작다.
  • 자동으로 시간에 대해 index를 제공한다.
  • TTL을 이용하여 원하는 시간이 지나면 삭제할 수 있다.

 

위 3가지 특징이 현재 상황에서 필요한 부분이었기 때문에 MongoDB의 Time Series Collection을 연구해보게 되었다.

 

 

 

2. Time Series Data란 무엇인가?

  • 시간에 따라 변하며, 일정시간 간격으로 배치된 데이터의 수열
  • 시간에 따라 변하는 object property외에는 일반적으로 metaData성으로, update가 거의 일어나지 않는다.
  • 대표적으로 sensor 등에서 나오는 실시간 데이터가 있다.

 

 

 

3. 일반 Collection과 Time Series Collection의 동작 방식

3.1. 일반 Collection

일반 Collection의 경우, 각 block에 sequencial하게 데이터가 쌓인다. 그리고 이러한 데이터들을 쉽게 찾고, 삭제하기 위해 하나 또는 그 이상의 인덱스를 사용하기도 한다. 또한 필요시 replication을 사용한다.

 

 

document를 받게되면 해당 document들은 순차적으로 disk의 block에 쌓이게 된다. 우리는 block에 쌓은 데이터에 access 하기 위해 인덱스를 생성하곤 한다. 그러나 이는 장기적으로 보았을 때, document를 read하는 데에서 문제가 발생한다. 특정 시간대의 device data를 보기 위해서는 여러 작은 document들을 fetch 해야하며, 이를 위해 컴퓨터는 넓게 분산된 disk block를 뒤지기 시작한다. 결론적으로, 읽어야 하는 block에 대해 동일한 DB의 cache를 사용하게 되는 비효율성이 생긴다. 이 과정에서 많은 resource가 낭비가 된다.

 

 

3.2. TimeSeries Collection

 

TimeSeries Collection을 사용하게되면, MongoDB는 같은 source의 데이터를 비슷한 시간대의 다른 데이터와 함께 같은 block에 저장하도록 프로세스를 진행한다. 블록은 디스크에 종속적이기 떄문에 가득차면, 자동적으로 또다른 블록을 생성한다. 여기서 가장 중요한 것은 각 블록이 하나의 source와 특정 시간대를 다루며 이 범위를 find 하는데 도움이 되는 index 또한 가지고있다.

즉, 하나의 블록에 대해 source, time range에 대한 인덱스를 가지고 있기 때문에, 100배 이상으로 index size를 줄일 수 있다.

 

데이터 저장 측면에서 뿐만 아니라, 압축에 대한 측면에서도 훨씬 좋은 측면이 있다. 시간이 지남에 따라 거의 변하지 않기 대문에, MongoDB는 비슷한 위치에 있는 데이터들을 한꺼번에 압축 할 수 있다. 이는 최소 3배에서 5배 사이의 데이터 사이즈 향상을 야기시킨다.

 

그리고, 읽기의 측면에서 원하는 데이터를 읽기 위해 필요없는 인접한 query에 해당하는 데이터들을 읽을 필요가 없기 때문에 최소 3~5배 빠른 읽기 성능을 제공한다.

 

 

4. 적용 방법

4.1. MongoDB 설치

아래 링크에서 5.0 버전 이상의 os에 맞는 mongodb를 로컬에 설치한다. 취향에 따라서 docker container를 선택해도 무방하다.

Install Stand alone link : https://docs.mongodb.com/manual/administration/install-community/

yarn add mongoose @nestjs/mongoose
npm install mongoose @nestjs/mongoose

 

MongoDB Docker Image link : https://hub.docker.com/_/mongo

import { Injectable } from '@nestjs/common';
import { MongooseModuleOptions, MongooseOptionsFactory } from '@nestjs/mongoose';
import { ConfigService } from '@nestjs/config';

@Injectable()
export class MongooseLocalConfigService implements MongooseOptionsFactory {
  constructor(private readonly configService: ConfigService) {}
  createMongooseOptions(): MongooseModuleOptions {
    /**
     * Mongodb Connection Configuration을 불러온다.
     */
    const mongoHost = this.configService.get<string>('MONGO_HOST');
    const mongoPort = this.configService.get<string>('MONGO_PORT');
    const mongoDatabase = this.configService.get<string>('MONGO_DATABASE');
    const mongoUsername = this.configService.get<string>('MONGO_USERNAME');
    const mongoPassword = this.configService.get<string>('MONGO_PASSWORD');

    const mongooseUrl: string =
      'mongodb://' + mongoUsername + ':' + mongoPassword + '@' + mongoHost + ':' + mongoPort + '/' + mongoDatabase;
    return {
      uri: mongooseUrl,
    };
  }
}

 

 

 

4.2. NestJS 초기 설정

공식 문서로 초기 설정을 대체합니다.

 

4.2.1. mongoose 설치

아래와 같은 패키지를 설치합니다.

yarn add mongoose @nestjs/mongoose
npm install mongoose @nestjs/mongoose​

 

4.2.2. mongoose connection 설정

아래와 같이 mongoose option을 return 해주는 Class를 생성한다.

import { Injectable } from '@nestjs/common';
import { MongooseModuleOptions, MongooseOptionsFactory } from '@nestjs/mongoose';
import { ConfigService } from '@nestjs/config';

@Injectable()
export class MongooseLocalConfigService implements MongooseOptionsFactory {
  constructor(private readonly configService: ConfigService) {}
  createMongooseOptions(): MongooseModuleOptions {
    /**
     * Mongodb Connection Configuration을 불러온다.
     */
    const mongoHost = this.configService.get<string>('MONGO_HOST');
    const mongoPort = this.configService.get<string>('MONGO_PORT');
    const mongoDatabase = this.configService.get<string>('MONGO_DATABASE');
    const mongoUsername = this.configService.get<string>('MONGO_USERNAME');
    const mongoPassword = this.configService.get<string>('MONGO_PASSWORD');

    const mongooseUrl: string =
      'mongodb://' + mongoUsername + ':' + mongoPassword + '@' + mongoHost + ':' + mongoPort + '/' + mongoDatabase;
    return {
      uri: mongooseUrl,
    };
  }
}​

 

 

 

이 클래스를 mongoose를 initialize 해주는 forRootAsync에 사용할 수 있도록 선언해준다.

import { MongooseModule } from '@nestjs/mongoose';
import { MongooseLocalConfigService } from '@/database/config/mongooseConfig.service';
import { AppController } from './app.controller';
import { Module } from '@nestjs/common';

@Module({
  imports: [
    MongooseModule.forRootAsync({
      useClass: MongooseLocalConfigService,
    }),
  ],
  controllers: [AppController],
})
export class AppModule {}

 

4.2.3. Schema 작성

import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { Device } from '@/device/device.schema';
import * as mongoose from 'mongoose';

@Schema({
  timeseries: {
    timeField: 'createdAt',
    metaField: 'device',
    granularity: 'seconds',
  },
})
export class RawItem {
  /**
   * Item Document Unique Id
   */
  @Prop()
  _id: string;

  /**
   * 데이터를 생상한 장비
   */
  @Prop({ required: true, type: mongoose.Schema.Types.ObjectId, ref: 'Device' })
  device: Device;
  /**
   * Item 생성일시
   */
  @Prop({ type: mongoose.Schema.Types.Date })
  createdAt: Date;

  /**
   * 장비로 부터 받은 raw data
   */
  @Prop()
  buffer: Buffer;
}

export const RawItemSchema = SchemaFactory.createForClass(RawItem);

 

4.2.4. Schema를 injection 받아서 사용할 수 있도록 선언해준다.

아래와 같이 프로젝트에서 사용할 schema를 선언하고, injection 받는다.

import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { Device, DeviceSchema } from '@/device/device.schema';
import { Item, ItemSchema } from '@/item/item.schema';
import { StatisticItem, StatisticItemSchema } from '@/statistic-item/statistic-item.schema';
import { RawItem, RawItemSchema } from '@/raw-item/raw-item.schema';

@Module({
  imports: [
    MongooseModule.forFeature([
      { name: RawItem.name, schema: RawItemSchema },
      .
      .
      .
    ]),
  ],
  exports: [MongooseModule],
})
export class DatabaseModule {
  constructor() {}
}

 

 

4.3. MongoDB data auto delete by ttl 설정 (Optional)

Nestjs Schema Option으로 만들 수 있는 방법이 나오면 update 예정.

현재는 찾지 못해 아래와 같이 공식 문서대로 세팅하였다.

db.runCommand({
   collMod: "<YOUR_COLLECTION_NAME>",
   expireAfterSeconds: <YOUR_EXPIRE_TIME>})

 

위와 같이 세팅한 다음, 아래 명령어를 입력하고나서 아래 화면같이 결과가 나오게 되면 정상적으로 timeseries collection이 생성 된 것이다.

db.runCommand( { listCollections: 1 } )
 

 

 

 

 

 

 

5. 데이터 생성

modbus-serial를 이용하여 binary 형태의 buffer stream을 받아 객체로 만든 뒤, mongodb에 저장한다.

createVoltageRawItemModel(connection: ModbusConnection) {
    const { modbusClient } = connection;
    return modbusClient
      .readHoldingRegisters(VOLTAGE_MAP.startAddress, VOLTAGE_MAP.addressRange)
      .then(readRegisterResult => {
        const { buffer } = readRegisterResult;
        return this.bufferToDocument(buffer, connection.deviceId);
      })
      .then(async rawItem => {
        await this.itemModel.insertMany(rawItem);
      });
  }
  
  
bufferToDocument(buffer: Buffer, deviceId: ObjectId) {
    const createdAt = getNowDate();
    const rawItemDocument = new this.rawItemModel({
      device: deviceId,
      buffer: buffer,
      createdAt,
    });
    return rawItemDocument;
  }

 

 

테스트를 위해 데이터를 2/2 ~ 2/7까지 실시간 데이터를 수집하였다.

 

6. 일반 collection과 Timeseries collection 비교분석

위 3번의 스키마와 동일한 조건으로 비교하기 위해 실제로 데이터를 수집하고, 분석해보고자 한다.

 

6.1. 수집된 데이터의 크기와 인덱스의 크기

6.1.1. 일반 Collection

 
  • 쌓은 object count : 13,626,819 개
  • 데이터의 size : 2,657,229,705 byte (2.6 GB)
  • 압축된 데이터의 size : 479,129,600 byte (0.4 GB)
  • 인덱스의 size : 542,916,608 byte (0.54 GB)
    • {_id:-1} : 255,627,264 byte (0.25 GB)
    • {createdAt:-1} : 139,464,704 (0.14 GB)
    • {device:-1, createdAt:-1} : 147,824,640 (0.15 GB)

 

 

6.1.2. Timeseries Collection

 
 

  • 쌓은 object count : 13,626,819 개
  • 데이터의 size : 2,194,723,580byte (2.1 GB)
  • 압축된 데이터의 size : 350,892,032 byte (0.35 GB)
  • 인덱스의 size : 798,720byte (약 0.80 MB)
    • {device:-1, createdAt:-1} : 798,720byte (약 0.80 MB)
    • {_id:1} : 생성되지 않음
    • {createdAt:-1} : 기본으로 timeField를 선언하면 제공된다고 문서상에서 언급하여 만들지 않음, 이 가려진 인덱스에 대해서 크기를 알 수 없다.

 

 

 

 

 

6.1.3. 비교

 

위 정리된 표를 보았을 때, 데이터 크기 및 압축률에 대해서는약 0.8배였으며, 인덱스에 대해서는 0.0015배라는 극강의 효율을 보였다.

 

 

 

또한 아래 공식문서를 참고하면

 

time series collection에서 index를 운영할 때 timeField, metaField에 명시된 field를 이용하여 compound index를 생성하는 것을 추천한다고 되어 있는데, 이에 대해 위와 같이 어느정도 효과가 있는 듯 하다.

 

 

6.2. 쿼리 성능

Comming soon

 

 

7. 직접 겪으며 정리한 TimeSeries Collection 사용시 유의해야 할 점

7.1. delete, update

  • 일반적인 방법으로 data delete 또는 deleteMany를 할 수 없으며 아래와 같은 방법으로 해야 데이터가 지워졌었다.
    • update 및 delete 조건문은 무조건 metaField를 이용해야했다. 첫 설계시 잘 설계를 해야한다.
    • 오직 metaField만 update가 가능했다.
  • 데이터를 지울 때는 TTL를 이용하여 지우는 것을 공식문서에서 권장하였다.

 

7.2. metaField

  • metaField는 한번 설정하면 어떠한 방법으로도 바꿀 수 없다. 따라서 첫 설계시 잘 설계를 할 필요가 있다.
    • 변경을 하기 위해서는 기존 collection을 drop 후 새로운 time series collection을 생성해야만한다.

 

7.3. timeField

  • 기본적으로 timeField 그 자체만으로 눈에보이지 않는 인덱스를 지원하여 find시에는 유리하다. 그러나, sort를 지원해주지 않기 때문에 sort를 자주 쓰는 환경에서는 이에 대한 index를 따로 만들어야 한다. sort 실행시 아래와 같은 에러가 출력되니 참고한다.
{ "ok" : 0,
  "errmsg" : "PlanExecutor error during aggregation :: caused by 
              :: Sort exceeded memory limit of 104857600 bytes,
              but did not opt in to external sorting.",
  "code" : 292,"codeName" : "QueryExceededMemoryLimitNoDiskUseAllowed"}

 

8. 그 이외에 유의해야 할 점

  • 일반적인 방법으로 document delete, index delete가 되지 않는다.
    • delete를 하려면 아예 collection을 drop 시켜야함
  • data ttl을 mongoose로 setting 하는 방법을 아직 찾지 못하였음. console 상에서만 작업했다.
  • index에 대한 여러 기능이 제공되지 않기 때문에 설계가 자주 바뀌는 환경에서는 취약하며, 아래와 같은 기능이 제공되지 않는다.
    • reindex 기능을 제공하지 않음
    • Partial, Unique, TTL index를 지원하지 않는다.
  • metaField로 아래와 같은 type을 지원하지 않는다
    • 2d
    • 2dsphere
    • text
  • grularity를 더 큰 것으로는 바꿀 수 있으나, 더 작은 것으로 수정할 수 없으며, 한번에 두단계로 큰 것으로 바꿀 수 없다.
    • ex) second → minute (O), minute → second → hour(x), hour → minute(x)
  • 샤딩을 할 수 있으나, 샤딩된 timeseries collection의 granularity 및 일부 admin commands를 사용할 수 없다.

 

 

9. Reference