MongoDB와 동시성 처리
안녕하세요~ 오늘은 MongoDB 의 개념과 특징을 간단히 짚고, MongoDB에서 데이터의 동시성을 막기 위해 어떻게 동작하는지 확인해보는 시간을 가져보려고 합니다.
NoSQL
먼저 MongoDB는 NoSQL 데이터베이스입니다.
NoSQL은 non-sql, non-relational 이라는 의미로 데이터 집합 간의 관계를 정의하지 않는 데이터베이스로, 대용량의 데이터를 동시처리하는데 적합합니다. 기존의 관계형 데이터베이스 즉, RDB는 데이터를 테이블로 저장하면서 중복을 줄이고 데이터를 효율적으로 관리합니다. 그러나 RDB는 이런 테이블이 정의된 스키마에 맞추어 데이터를 저장해야 하기 때문에 사전에 테이블에 맞추어 데이터를 조작해야 합니다. 또한 필요한 데이터를 조회할 때는 여러 테이블을 조인해서 확인하는 경우가 빈번합니다.
반대로 NoSQL 데이터베이스는 관계를 정의하지 않고 중복을 허용합니다. 이 때문에 데이터를 형태 그대로 빠르게 저장할 수 있으며, 조인이 필요하지 않으므로 조회 또한 빠릅니다.
MongoDB 특징
그럼 MongoDB는 NoSQL 데이터베이스이면서도 어떤 장점을 가지고 있을까요?
- MongoDB는 Document(json 과 유사한 형태)로 문서를 저장합니다. 이 때문에 여러 테이블로 나눈 데이터를 하나의 Document로 모아서 저장할 수 있습니다.
- Document 에 또 다른 Document를 저장할 수 있습니다 (Embedded Document)
- 데이터를 여러 서버에 분산해 저장(Shard)하기 용이하게 설계되었으며, 장애 대응을 위해 복제나 회복도 가능하다.
- RDB 쿼리 변환기가 있을 만큼 RDB와 개념이 유사해 이전이 쉬우면서도 RDB에 비해 성능이 매우 빠르다.
- 원자적(atomic)하게 데이터의 동시성을 관리합니다. 기본적으로 Document간 트랜잭션을 지원하지 않았지만, 4.0 버전부터 기능이 추가됩니다. 단, RDB에 비해 성능은 확연히 느립니다 :)
ObjectID
MongoDB 는 12 byte binary 데이터로 된 ObjectID로 document의 유일함을 보장합니다. 다음과 같이 나타납니다: `ObjectId("507f191e810c19729de860ea")`
전통적인 RDB는 중복되지 않은 단일 컬럼을 primary key 로 설정해야 하지만, MongoDB는 자체적으로 단일 key를 생성합니다. MongoDB는 분산 데이터베이스에 특화되어 있어 분산된 데이터 중 찾고자 하는 데이터가 저장된 서버(=샤드)에 데이터를 요청해야 하기 때문에 서버가 관리한 유일키가 반드시 필요합니다.
Reference
MongoDB 에서 관계를 연결하기 위해서(reference)는 다음과 같이 자동으로 만들어진 ObjectID 값을 다른 document에 명시해주면 됩니다.
MongoDB는 다른 NoSQL처럼 플러그인 형태로 다양한 스토리지 엔진을 선택해 사용할 수 있습니다. WiredTiger가 기본 엔진입니다.
Lock
데이터베이스에서 Lock은 하나의 데이터에 동시 접근하는 상황에서 정확한 데이터의 무결성을 보장하기 위해 도입된 개념입니다. 멀티 쓰레드로 동일한 데이터를 동시에 처리하게 되면 충돌이 발생합니다. 아들이 통장에서 3만원을 인출하는 요청과 엄마가 2만원을 입금하는 요청이 동시에 들어왔을 때, 최종 상황이 3만원만 인출되거나 2만원만 입금되어서는 안되겠죠.
이를 막기위해 MongoDB는 총 3가지 계층 database, collection, document에 대한 잠금을 사용합니다. 명령어를 통해 명시적으로 사용할 수 있는 global lock과 서버 자체에서 묵시적으로 사용하는 object lock이 있습니다.
Global Lock
- = 인스턴스 잠금. MongoDB 서버에 단 하나만 있는 잠금입니다.
- 모든 커넥션의 데이터 저장이나 변경을 막으며 이 때, 데이터 읽기는 가능합니다.
Object Lock (database, collection, document)
- 동시성 처리를 보장하기 위해 Multiple granularity locking (다중 레벨 잠금) 방식을 사용합니다.
- S잠금(=읽기잠금), X잠금(=쓰기잠금) + IS잠금, IX잠금 (Intention Lock) 이 존재하며, 명령의 직접적인 대상인 객체는 S/X 잠금이 걸리고, 그 상위계층들은 IS/IX 잠금이 걸립니다.
session-1)
db.users.update( {user_id:"wow"}, {$set: {user_name:"Lee"} )
session-2)
db.users.update( {user_id:"hey"}, {$set: {user_name:"Kim"} )
// 두 세션모두 global, db, collection 모두 IX잠금, 그리고 실제 변경 대상인 도큐먼트는 X잠금
// IX잠금 끼리는 서로 허용되므로, 같은 디비의 같은 컬렉션에 두 변경 작업이 이뤄지는 것이 가능
// 도큐먼트들은 X잠금이므로 도큐먼트를 변경하는 작업은 불가능 (위는 서로 다른 도큐먼트이므로 변경가능하다)
- 각 오브젝트(database, collection) 단위로 잠금 요청 큐(Queue)를 관리하며, 요청의 순서대로 비동기적으로 실행됩니다. 상호 간에 허용되는 잠금끼리는 동시에 잠금하므로 항상 순차적으로 잠금이 발생하지는 않습니다.
요청큐 : IS - IS - X - X - S - IS
실행 순서 : ----1---|-2-|-3-|---1----
// IS 가 실행될 시, S와 IS는 서로 호환(동시실행가능)되므로 가장 나중에 들어온 S, IS 까지 동시에 허용
- Find() 쿼리의 경우 MVCC(Multi version concurrency control)에 의해 도큐먼트 변경 이전 버전을 읽으므로, 별도의 잠금이 필요하지 않습니다.
- MongoDB는 높은 동시성 처리를 위해 오랜 시간 실행되거나 자원을 많이 소모하는 쿼리에 대해 지정된 시간 동안 쉬었다(CPU 놓기)가 다시 실행되도록 합니다. (yield)
- 쿼리가 지정된 건수의 도큐먼트를 읽는 경우
- 쿼리가 지정된 시간 동안 수행된 경우
Transaction
single document transaction
MongoDB 는 완벽하게 single document transaction만 지원합니다. 하나의 document를 변경할 때마다 내부적으로 커밋되며 각 document들은 개별적인 트랜잭션으로 쿼리가 수행됩니다. 예를 들어 보겠습니다. updateMany는 하나의 쓰기 작업이 여러 document를 수정합니다. 그러나 MongoDB는 하나의 document에 대한 transaction만 지원하므로, 모든 document가 변경될 때까지 lock이 걸리지 않습니다. updateMany 구문 실행 중에 요청시와는 데이터가 달라질 수 있으므로 전체 작업이 원자적이지 않습니다.
write conflict
MongoDB 스토리지 엔진이 동일한 document에 대해 write conflict을 감지하면 '취소하고 재시도'합니다. 따라서 많은 쓰레드가 동시에 하나의 document를 변경하려는 경우가 잦아지면 성능에 심각한 문제가 될 수 있습니다.
mutli document transaction
- v4 부터는 하나의 쿼리가 여러개의 도큐먼트를 변경하는 작업에 대해서도 트랜잭션을 보장하는 method가 있습니다
- 성능 비용이 높으므로 데이터를 적절하게 모델링하여 다중 문서 트랜잭션의 필요성을 최소화해야 합니다
// Step 1: Define the callback that specifies the sequence of operations to perform inside the transaction.
callback := func(sessCtx mongo.SessionContext) (interface{}, error) {
// Important: You must pass sessCtx as the Context parameter to the operations for them to be executed in the
// transaction.
if _, err := fooColl.InsertOne(sessCtx, bson.D{{"abc", 1}}); err != nil {
return nil, err
}
if _, err := barColl.InsertOne(sessCtx, bson.D{{"xyz", 999}}); err != nil {
return nil, err
}
return nil, nil
}
// Step 2: Start a session and run the callback using WithTransaction.
session, err := client.StartSession()
if err != nil {
panic(err)
}
defer session.EndSession(ctx)
result, err := session.WithTransaction(ctx, callback)
if err != nil {
panic(err)
}
Read Isolation
- 이제까지 살펴본 바로 쓰기 작업은 단일 도큐먼트와 관련해서는 atomic 합니다. 따라서 한 document의 여러 필드가 수정 중이라면 reader가 일부만 업데이트된 document를 볼 수 없습니다
- single document 경우 read/write atomicity 보장 multiple document 경우 상위의 multi-document transaction을 사용해 atomcity 보장 가능합니다
- 따라서 standalone mongod instance(샤딩X 레플리카X) 에서 트랜잭션이 커밋 될 때까지 트랜잭션의 데이터 변경 사항을 트랜잭션 외부에서 볼 수 없습니다