확장 가능한 parallel fuzzing 인프라 구축 및 성능 비교 분석 (2-1) LEVELDB

OOSUlZ·2023년 2월 23일
0

2. LEVELDB

levelDB: https://github.com/google/leveldb

Level DB는 구글에 의해 만들어진 Key Value Storage Systems이고, NO SQL 기술이다


1) LEVELDB 개요

(1) Key Value Storage Systems

=Key-value storage systems은 많은 수 (백만개 이상) 의 작은 사이즈(KB-MB)의 records 를 저장
=Records 는 여러 machines에 partition 되어 저장
=Queries 는 시스템에 의해서 알맞은 machine 으로 routing
=다른 machine이 fail되었을 경우, Availability 를 보장하기 위해서 Records는 여러 machines 에 복제
=대량의 데이터를 저장해야 하지만 검색을 위해 복잡한 쿼리를 수행할 필요가 없는 사용 사례에 적합

(2) NO SQL

NoSQL은 기존 RDBMS 형태의 관계형 데이터베이스가 아닌 다른 형태의 데이터 저장 기술을 의미하며, 관계형 데이터베이스의 한계를 극복하기 위한 데이터 저장소의 새로운 형태
= 데이터 간의 관계를 정의하지 않음
= 대용량의 데이터를 저장
= 분산형 구조
= 가변적인 구조로 데이터 저장이 가능 (스키마 일정 하지 않음)

Database of Databases - LevelDB

(3) 특징

  • 키와 값은 임의의 바이트 배열
  • 데이터는 키별로 정렬되어 저장
  • 호출자는 정렬 순서를 다시 바꿀 수 있음 (사용자 지정 비교 함수 사용 가능)
  • 기본 조작은 Put(key,value),Get(key),Delete(key)
  • 하나의 원자 배치에서 여러 변경을 수행할 수 있음
  • 사용자는 임시 스냅샷을 생성하여 일관된 데이터 뷰를 볼 수 있음
  • 데이터에 대해 정방향 및 역방향 반복이 지원됨
  • 데이터는 Snappy 압축 라이브러리를 사용하여 자동으로 압축
  • 외부 활동(파일 시스템 작업 등)은 가상 인터페이스를 통해 전달되므로 시스템 상호 작용을 사용자 정의할 수 있음
  • LevelDB는 로그 구조화 된 복제(Log-Structured Merge Tree) 방식을 사용하여 빠른 읽기와 쓰기 속도를 제공
  • 기본적으로 복수의 SSTable(키-값 쌍으로 정렬된 데이터를 저장하는 단위)을 사용하여 저장소 공간을 효율적으로 사용
  • LevelDB는 C++, Java, Go, Python, Ruby 등 다양한 언어에서 사용할 수 있도록 바인딩이 제공

(4) 한계

= NoSQL문이라, 관계형 데이터 모델이 없고 SQL 쿼리를 지원하지 않으며 인덱스를 지원하지 않음

= 한 번에 하나의 프로세스만 특정 DB에 접근 가능 (멀티 쓰레드 가능)

= 클라이언트와 서버간 지원이 라이브러리에 내장되어 있지 않음
ㄴ 만약 이러한 지원이 필요하다면 자체 서버를 라이브러리로 감싸야함

= 높은 메모리 사용량: LevelDB는 메모리 사용량이 높아 대규모 데이터를 다룰 때 메모리 부족 문제가 발생할 수 있습니다.

LevelDB는 적은 용량과 빠른 속도가 필요한 작은 규모의 프로젝트나 임베디드 시스템에서 유용하게 사용될 수 있음


2) 사용 방법 및 설치법

(1) LEVELDB 설치 실습

1. 유닉스(리눅스 기반) 깃 소스 클론

git clone --recurse-submodules [https://github.com/google/leveldb.git](https://github.com/google/leveldb.git)

2. 빌드 방법 및 직접 실행해보기

mkdir -p build && cd build
cmake -DCMAKE_BUILD_TYPE=Release .. && cmake --build .

1) 위 클론 받은 폴더 디렉토리에서 위 명령어 실행
(~$ sudo apt install cmake로 cmake 설치 선행 되어야함)

빌드가 완료되면, build 폴더에 파일들 다수 생성된 상태!

2) 이 후 만들어진 ~/leveldb/build 디렉토리에서 다음 명령어 실행
(라이브러리와 헤더파일을 복사하는 과정)

$ cp -av libleveldb.* $HOME/<사용자명>/lib/

3) ~/leveldb 디렉토리에서 다음 명령어 실행

$ cp -av include/leveldb $HOME/<사용자명>/include/

이 명령들을 실행하기 앞서, 경로에 맞는 디렉터리가 만들어져 있어야 함

💡 **cp** ⇒ 리눅스의 파일 복사 명령어 아무런 옵션을 주지 않고 cp 를 이용해서 파일을 복사하면 퍼미션과 시간 정보 등이 복사를 시도한 계정과 현 시간으로 바뀜 **-av** 옵션 ⇒ -a & -v 옵션 같이 쓰기 -a 옵션 : 모든 정보가 유지된 채로 복사가 되게끔 하고, 숨김 속성을 포함한 모든 디렉터리와 파일이 백업 -v 옵션 : 작업 내용 보기

4) 예제 작업을 할 디렉토리를 만들고, 다음과 같이 test.cpp 파일 작성

//test.cpp

#include <cassert> 
#include <iostream> 
#include "leveldb/db.h" 

using namespace std; 

int main(int argc,char * argv[]) 
{ 
leveldb::DB* db; 
leveldb::Options options; 
options.create_if_missing = true; 
leveldb::Status status = leveldb::DB::Open(options, "/tmp/testdb", &db); 
assert(status.ok()); 
std::string key1 = "edge"; 
std::string value1 = "cloud"; 
cout<<"leveldb test OK"<<endl; 

string value; 
leveldb::Status s ; 
s = db->Put(leveldb::WriteOptions(), key1, value1);/*key1,value1 삽입*/ 
s = db->Get(leveldb::ReadOptions(), key1, &value);/**/ 

cout<< key1 << "'s value == " << value << endl; 
delete db;

return 0; 
}

<함수 세부사항은 아래 공식 문서 정리 에 기재되어 있음>
vim 에디터로 파일 생성 후 ~$ chmod 777 파일명.cpp 으로 실행권한 부여

delete db;를 해주지 않으면, test 파일에 사용했던 데이터가 .ldb 파일로 지정한 경로에 저장이 된다

5) cpp 파일 컴파일하기

$ g++ -o <바꿀 파일명> test.cpp ~/lib/libleveldb.a -lpthread -I ~/include

물결기호 ( ~ ) 는 루트폴더 ( / ) 로부터 사용자폴더(username)까지의 경로를 축약한 형태

g++ ⇒ c++ 전용 컴파일러

-lpthread ⇒ thread를 사용한 소스파일은 컴파일 할때 -lpthread라는 옵션을 주어야함

thread ⇒ CPU 코어에서 돌아가는 프로그램 단위 & 프로세서에서 처리되는 프로그램(프로세스) 내부에 n개 존재

-I ⇒ 헤더 파일을 검색할 디렉터리 목록에 ~/include 디렉터리를 추가함 
-I 로 명명된 디렉토리 는 표준 시스템이 디렉토리를 포함하기 전에 검색되고, 디렉터리 ~/include 가 표준 시스템 포함 디렉터리인 경우 시스템 디렉터리의 기본 검색 순서와 시스템 헤더의 특수 처리가 실패하지 않도록 옵션이 무시

6) 실행

$ ./<바꾼 파일명>

(2) 공식 문서 정리

(1) DB 생성

vim 에디터를 활용해서 다음과 같은 예제 데이터 베이스 생성을 할 수 있다.

#include <cassert>
#include "leveldb/db.h"

leveldb::DB* db;
leveldb::Options options;
options.create_if_missing = true;
// options.error_if_exists = true; //중복 DB 체크 및 에러 발생
leveldb::Status status = leveldb::DB::Open(options, "/tmp/testdb", &db);  //지정한 경로에 물리적 데이터 저장
assert(status.ok());

(2) DB 닫기

delete db;
// leveldb::DB* 삭제하기 

(3) Data 읽고 쓰기

std::string value;
leveldb::Status s = db->Get(leveldb::ReadOptions(), key1, &value); 
//데이터 읽기 (GET)
if (s.ok()) s = db->Put(leveldb::WriteOptions(), key2, value);
//데이터 삽입 (PUT)
if (s.ok()) s = db->Delete(leveldb::WriteOptions(), key1);
//데이터 삭제 (DELETE)

key의 데이터 타입은 leveldb::Slice
value의 데이터 타입은 std:string
string 에 다른 바이트 배열도 삽입 가능 ⇒ 모든 데이터 타입 삽입 O

(4) Atomic update
(기능적으로 분할되지 않도록 값 갱신)

데이터의 값 보장을 위해 트랜잭션 제공

#include "leveldb/write_batch.h"
...
std::string value;
leveldb::Status s = db->Get(leveldb::ReadOptions(), key1, &value);
if (s.ok()) {
  leveldb::WriteBatch batch; // 트랜잭션
  batch.Delete(key1);
  batch.Put(key2, value);

  s = db->Write(leveldb::WriteOptions(), &batch);
}

(5) Synchronous Writes (동시 입력)

leveldb에 데이터 put 하는 방법 == 비동기식

프로세스에서 운영 체제 메모리로 데이터 write 작업 push 후 return

운영체제 메모리 ⇒ 로컬 저장소 데이터 전송 == 비동기식

만약 기록중인 데이터가 로컬 저장소로 완전히 push 될때까지, write 작업이 되지 않도록 동기화 플래그를 사용 가능
리눅스 ⇒ fsync(...) , fdatasync(...), msync(..., MS_SYNC)

비동기 쓰기

장점
1. 동기 쓰기보다 1000배 이상 빠름
2. 안전함
대량의 데이터를 데이터베이스에 로드할 때 충돌이 생긴다면, 대량 로드를 다시 시작하여 손실된 업데이트를 처리

단점
시스템 충돌로 업데이트 손실 가능
하지만 동기화가 실패해도 업데이트 이전에, 프로세스 메모리에서 운영체제로 업데이트(write 작업)가 push되기 때문에 write 작업의 충돌 만으로는 손실 발생X

(6) Concurrency (동시성)

보통 데이터베이스는 한 번에 하나의 프로세스에서만 열람 가능 
leveldb 오류를 방지하기 위해 운영 체제에서 lock 기능을 사용 ⇒ 단일 프로세스 내 동일한 leveldb::DB개체를 여러 동시 스레드에서 안전하게 공유가능 
다른 스레드는 외부 동기화 없이 동일한 데이터베이스에서 반복자를 쓰고 가져오거나 Get을 호출가능
└ leveldb는 필요한 동기화를 자동으로 수행 
<다른 객체(예: Iterator 및 WriteBatch)는 외부 동기화가 필요할 수 있음>
└ 두 스레드가 이러한 개체를 공유하는 경우 자체 잠금 프로토콜을 사용하여 액세스를 보호할 필요 있음

(7) 반복해서 Key,value 출력

= 데이터베이스의 모든 키, 값 쌍 인쇄하기

leveldb::Iterator* it = db->NewIterator(leveldb::ReadOptions());
for (it->SeekToFirst(); it->Valid(); it->Next()) {
  cout << it->key().ToString() << ": "  << it->value().ToString() << endl;
}
assert(it->status().ok());  // Check for any errors found during the scan
delete it;

= 정방향 (forward)
[start,limit) 범위의 키만 처리

for (it->Seek(start);
   it->Valid() && it->key().ToString() < limit;
   it->Next()) {
  ...
}

= 역방향(backward) 
역방향 반복이 정방향 반복보다 더 느림

for (it->SeekToLast(); it->Valid(); it->Prev()) {
  ...
}

(8) 스냅샷(snapshot)

스냅샷은 key-value 저장소의 전체 상태에 대해 일관된 뷰를 제공

ReadOptions::snapshot read가 DB 상태의 특정 버전에서 작동해야 함을 나타내기 위해 NULL이 아닐 수 있음 

NULL인 경우 ReadOptions::snapshot암시적으로 현재 상태 스냅샷에서 read 작업 수행

leveldb::ReadOptions options;
options.snapshot = db->GetSnapshot();
//... apply some updates to db ...
leveldb::Iterator* iter = db->NewIterator(options);
//... read using iter to view the state when the snapshot was created ...
delete iter;
db->ReleaseSnapshot(options.snapshot);

(9) 추출(slice)

  • **it->key()it->value() return 값** ⇒ leveldb::Slice 타입

  • slice ⇒ 길이와 외부 바이트 배열에 대한 포인터를 포함

  • slice를 리턴하는 것은 std::string 을 return 하는 것보다 효율적

     └ 큰 키와 값을 복사할 필요가 없기 때문
  • leveldb 메서드는 null로 끝나는 C 스타일 문자열을 반환하지 않음
    ('\0')도 key와 value 에서 byte를 포함하기 때문

  • C++ 문자열과 null로 끝나는 C 스타일 문자열은 slice로 쉽게 변환 가능

leveldb::Slice s1 = "hello";

std::string str("world");
leveldb::Slice s2 = str;

Slice는 쉽게 C++ 문자열로 다시 변환가능

std::string str = s1.ToString();
assert(str == std::string("hello"));

다음과 같은 상황에서는 오류 발생 (if 문 안에서 slice 선언했지만, if 문 밖에서 slice 사용)

leveldb::Slice slice;
if (...) {
  std::string str = ...;
  slice = str;
}
Use(slice);

Slice란?

Slice는 leveldb에서 데이터를 가져올 때 사용하는 클래스이다. 데이터의 key값과 value값을 가져올 때, 그 원본을 가져오지 않고 그 원본을 가리키는 Slice라는 객체로 가져오게 된다. 이렇게 하는 이유는 원본의 크기가 생각보다 커서 불러올 때 시스템에 과부하가 생길 것을 염려해서 우선 Slice라는 객체로 감싸고 추후에 풀어내라는 의도이다. 보통 문자열의 경우, Slice 객체에 .ToString()을 써서 원본 문자열을 받아올 수 있다. 아래는 slice.h 헤더파일이다.

#ifndef STORAGE_LEVELDB_INCLUDE_SLICE_H_
#define STORAGE_LEVELDB_INCLUDE_SLICE_H_
//만약 STORAGE_LEVELDB_INCLUDE_SLICE_H_가 정의되지 않았으면 정의하고 코드 실행
 
#include <assert.h>
#include <stddef.h>
#include <string.h>
#include <string>
 
namespace leveldb {
 
class Slice {
 public:
  // 비어있는 Slice를 만든다
  Slice() : data_(""), size_(0) { }
 
  // char배열과 사이즈가 입력됐을 경우
  Slice(const char* d, size_t n) : data_(d), size_(n) { }
 
  // 입력 s의 문자와 사이즈를 저장
  Slice(const std::string& s) : data_(s.data()), size_(s.size()) { }
 
  // char배열로 왔을 경우
  Slice(const char* s) : data_(s), size_(strlen(s)) { }
 
  // 참조된 데이터의 시작 부분에 포인터 반환
  //data_ 반환
  const char* data() const { return data_; }
 
  //size 반환
  size_t size() const { return size_; }
 
  //size가 0인지 반환
  bool empty() const { return size_ == 0; }
 
  /*
  size_t는 '이론상 가장 큰 사이즈를 담을 수 있는 unsigned 데이터 타입'으로 정의됩니다. 
  즉, 32비트 머신에서는 32비트 사이즈의 unsigned 정수형(int가 아니라 그냥 '정수'를 의미합니다),
  64비트 머신에서는 64비트 사이즈의 unsigned 정수형(unsigned long long)입니다. 
  향후 등장할지도 모르는 128비트 머신이라던가 더 큰 머신이 존재한다면 그에 따라 더 큰 사이즈가 되겠지요.
  */
  char operator[](size_t n) const {
    assert(n < size()); //size보다 n이 클 경우 종료
    return data_[n];    //data의n번째 char값 반환
  }
 
  // 다시 비어있는 slice로 만든다
  void clear() { data_ = ""; size_ = 0; }
 
  // Drop the first "n" bytes from this slice.
  //이 slice에서 첫번째"n"바이트를 삭제합니다.
  //접두사 remove?
  void remove_prefix(size_t n) {
    assert(n <= size()); //입력n이 size보다 작아야 한다
    data_ += n; //아스키코드값이 + 되는데?
    size_ -= n;
  }
 
  // 문자열 반환
  std::string ToString() const { return std::string(data_, size_); }
 
  // Three-way comparison.  Returns value:
  //   <  0 iff "*this" <  "b",
  //   == 0 iff "*this" == "b",
  //   >  0 iff "*this" >  "b"
  int compare(const Slice& b) const;
 
  // x의 data가 현 data의 시작과 같은지
  bool starts_with(const Slice& x) const {
    return ((size_ >= x.size_) &&
            //memcmp : 2개의 메모리 변수에 대해 내용을 비교하여 첫 번째 인수보다 두 번째 인수가 같은지, 큰지, 작은지를 구한다
            //x.size_ = 비교할 바이트 크기
            //앞에서부터 비교할 바이트 크기까지 비교한다
            (memcmp(data_, x.data_, x.size_) == 0));
  }
 
 private:
  const char* data_;
  size_t size_;
 
  // Intentionally copyable
};
 
/*
inline 함수 :
컴파일러가 함수를 호출하는 대신, 그에 대응하는 함수 코드로 대체한다는 것을 의미하며
함수 호출없이 삽입된 함수 코드를 그 자리에서 처리하므로 해당 함수를 수행하기 위해 프로그램이 다른 주소로
점프했다가 되돌아 올 필요가 없어 속도면에서 유리합니다.
*/
//slice의 비교
inline bool operator==(const Slice& x, const Slice& y) {
  return ((x.size() == y.size()) &&
          (memcmp(x.data(), y.data(), x.size()) == 0));
}
 
//slice의 !=
inline bool operator!=(const Slice& x, const Slice& y) {
  return !(x == y);
}
 
//slice의 compare
inline int Slice::compare(const Slice& b) const {
  const size_t min_len = (size_ < b.size_) ? size_ : b.size_;
  int r = memcmp(data_, b.data_, min_len);
  if (r == 0) {
    if (size_ < b.size_) r = -1;
    else if (size_ > b.size_) r = +1;
  }
  return r;
}
 
}  // namespace leveldb
 
 
#endif  // STORAGE_LEVELDB_INCLUDE_SLICE_H_

위 코드에서는 compare를 따로 구현했지만, slice.h의 코드를 살펴봤을 때 slice는 class이고, 중간에 compare라는 시그니쳐만 있는 것을 볼 수 있다. Java의 abstract 메소드처럼 정의는 안되어있고 사용자가 직접 원하는 방식으로 overwrite해서 구현할 수 있는 것처럼 보인다.

  // Three-way compaarison.  Returns value:
  //   <  0 iff "*this" <  "b",
  //   == 0 iff "*this" == "b",
  //   >  0 iff "*this" >  "b"
  int compare(const Slice& b) const;

위 코드를 살펴보면 Slice 객체에 .compare를 하는 방식으로 비교를 하는 것처럼 보인다.
완전히 새로운 comparator를 만들려면 따로 class를 정의해서 사용해야 한다.

(10) Comparators (비교연산자)

사전순으로 KEY에 대해 정렬하는 함수가 있지만, 사용자가 직접 정의해서 연산자 사용 가능
EXAMPLE) 만약 DB의 KEY가 2개의 숫자인데, 첫번째 숫자로 정렬을 하지만 두번째 숫자에서도 정렬 순서를 결정해야 할때

class TwoPartComparator : public leveldb::Comparator {
 public:
  // Three-way comparison function:
  //   if a < b: negative result
  //   if a > b: positive result
  //   else: zero result
  int Compare(const leveldb::Slice& a, const leveldb::Slice& b) const {
    int a1, a2, b1, b2;
    ParseKey(a, &a1, &a2);
    ParseKey(b, &b1, &b2);
    if (a1 < b1) return -1;
    if (a1 > b1) return +1;
    if (a2 < b2) return -1;
    if (a2 > b2) return +1;
    return 0;
  }

  // Ignore the following methods for now:
  const char* Name() const { return "TwoPartComparator"; }
  void FindShortestSeparator(std::string*, const leveldb::Slice&) const {}
  void FindShortSuccessor(std::string*) const {}
};

만든 연산자를 활용하려면 아래와 같이 사용 한다.

TwoPartComparator cmp;
leveldb::DB* db;
leveldb::Options options;
options.create_if_missing = true;
options.comparator = &cmp;
leveldb::Status status = leveldb::DB::Open(options, "/tmp/testdb", &db);

profile
천천히,꾸준하게

0개의 댓글