MongoDB 4.0 トランザクション機能 ベストプラクティス


# はじめに

今年6月にMongoDB 4.0がリリースされ、MongoDBでも複数ドキュメントにまたがるトランザクションが使えるようになりました。いろいろなところにコードサンプルなども出てきていますので、試してみた人もいることでしょう。

これまでMongoDBは、過去のバージョンで問題を抱えていたこともありましたし[^1][^2][^3]、離れていったユーザーもいましたし[^4][^5]、いろいろ言われたりもしました[^6]。3.0でストレージエンジンをWiredTigerにかえた頃から、遅まきながらも整合性、信頼性の向上にエネルギーが注がれ、ようやくたどり着いた[^7][^8]のが今回のトランザクションだったのかなと思います。

これまでのイメージを跳ね返して発展していくことを期待しながら、トランザクション機能を試し、注意点をまとめてみます。

[^1]: [Jepsen: Mongodb](https://aphyr.com/posts/284-call-me-maybe-mongodb) 過去のバージョンにおいてデータを失う可能性について報告されている。
[^2]: [MongoDB 3.4.0-rc3](https://jepsen.io/analyses/mongodb-3-4-0-rc3) 過去のバージョンで、Lost update、Dirty Read、Stale Readが発生することについて報告されている。
[^3]: [Broken by Design: MongoDB Fault Tolerance](http://hackingdistributed.com/2013/01/29/mongo-ft/)
[^4]: [Which companies have moved away from MongoDB and why? What did they move to?](https://www.quora.com/Which-companies-have-moved-away-from-MongoDB-and-why-What-did-they-move-to)
[^5]: [だれがなぜMongoDB辞めたんですか？(Quora訳)](https://qiita.com/scova0731/items/81a7428737efa28859a2)
[^6]: [RethinkDB: why we failed](https://www.defmacro.org/2017/01/18/why-rethinkdb-failed.html) "Every time MongoDB shipped a new release and people congratulated them on making improvements, I felt pangs of resentment." の付近など。
[^7]: [MongoDB Multi-Document ACID Transactions are GA](https://www.mongodb.com/blog/post/mongodb-multi-document-acid-transactions-general-availability) 3.0以来3年以上かけて、ストレージなど下位の部分に基礎となる機能を実装し続けてきたことが書かれている。
[^8]: [7の日本語訳](https://github.com/satokano/MongoDB-Documents-JP/blob/master/multidocumenttransaction.md)

# ソース

今回使用したソース一式は以下のリポジトリに格納してあります。

https://github.com/satokano/MongoTransaction

# 複数ドキュメントトランザクション

Javaでコード例を示します。

```Java
ClientSession clientSession = client.startSession(ClientSessionOptions.builder().causallyConsistent(true).build());
try {
    clientSession.startTransaction(TransactionOptions.builder().readConcern(ReadConcern.SNAPSHOT).writeConcern(WriteConcern.MAJORITY));
    collection.updateOne(clientSession, eq("name", "satoshi"), combine(set("flag", flagValue), currentDate("lastMofified"));
    collection.updateOne(clientSession, eq("name", "vigyan"), combine(set("flag", flagValue), currentDate("lastModified"));
    // Thread.sleep(10000); // テスト用
    clientSession.commitTransaction();
} catch (MongoException me) {
    clientSession.abortTransaction();
} finally {
    clientSession.close();
}
```

## 処理概要

トランザクションはClientSessionに紐づくため、3.6から登場したClientSessionを使うことになります。正常な場合はコミットし、途中で例外が発生した場合はcatch節の中でabortTransactionによりロールバックするという流れです。

更新内容は、単純なupdateとしています。クエリの書き方がMongo ShellやJavaScriptの場合とかなり違っていて、単なる文字列では書けず、検索条件のフィルタやUpdateのオペレータとして専用のクラスを使う必要があるので、慣れが必要かもしれません。

## 例外処理

Javaの例外関連として、Mongo関連の例外はすべてRuntimeExceptionを継承したMongoExceptionになっているところが例外処理を考える上で要注意かと思われます[^8]。例外発生時は、[別のブログ記事](https://www.mongodb.com/blog/post/java-and-mongodb-40-support-for-multidocument-acid-transactions)でのJava実装も参考にして、必ずabortTransactionを発行してからcloseするようにしています。[^9]

commitTransactionが失敗したとしても、[ドライバの機能で内部的に1回だけリトライ](https://docs.mongodb.com/manual/core/transactions/#retry-commit-operation)してくれるようになっています（retryWritesの設定に関わらず）。MongoExceptionの内容によってはリトライ可能な場合もあるとされているのですが、このドライバによる1回のリトライ機能でも十分と考えて、シンプルな実装にとどめています。[^10]

なおClientSessionはAutoCloseableを実装しており、try-with-resources形式で書くことも可能になっています。

## ReadConcern / WriteConcern / Causal Consistency

ここではSessionをCausal Consistentにしています。またTransactionに対しては、Read Concernをsnapshot、Write Concernをmajorityにしておきます。これによりトランザクション開始直前のオペレーションとも、Causal Consistencyが保たれるようになります。[^11]

[^8]: [MongoExceptionのマニュアル](http://mongodb.github.io/mongo-java-driver/3.9/javadoc/com/mongodb/MongoException.html)。JDBCの場合に代表的なSQLExceptionがチェック例外であるのとは異なる。

[^9]: [JDBCの場合は、例外発生時など、commitもrollbackも実行せずにcloseを実行した場合の動作は実装依存となるので、close前に必ずcommitまたはrollbackを実行することを推奨とされている。](https://docs.oracle.com/javase/8/docs/api/java/sql/Connection.html#close--) MongoDB 4.0で、commitTransactionもabortTransactionも実行せずにcloseを実行した場合の仕様は不明、[ドキュメント](http://mongodb.github.io/mongo-java-driver/3.9/javadoc/com/mongodb/session/ClientSession.html#close--)に記載は無い。ここではJDBCの場合や、上記別のブログ記事も参考にして、close前にcommitTransactionまたはabortTransactionを実行することとした。ちなみにOracle Databaseの場合、commitもrollbackもせずにcloseした場合、[暗黙的commitとなる](http://otndnld.oracle.co.jp/document/products/oracle10g/102/doc_cd/java.102/B19275-03/basic.htm#i1019153)。

[^10]: [トランザクション全体に対するリトライ機能もある。](https://docs.mongodb.com/manual/core/transactions/#retry-transaction)

[^11]: [Transaction Options (Read Concern/Write Concern/Read Preference)](https://docs.mongodb.com/manual/core/transactions/#transaction-options-read-concern-write-concern-read-preference) / [Causal Consistency](https://docs.mongodb.com/manual/core/read-isolation-consistency-recency/#causal-consistency) / [Causal Consistency and Read and Write Concerns](https://docs.mongodb.com/manual/core/causal-consistency-read-write-concerns/index.html)

# 試してみる

では実行してみます。環境としてはatlasの無料版を利用するのが手軽です。[^12]ローカルでレプリカセットを組むのでもOKです。

[^12]: [CosmosDBは、現時点でMongoDB3.2互換で、3.4の機能がプレビューの状態。](https://docs.microsoft.com/ja-jp/azure/cosmos-db/mongodb-feature-support#mongodb-protocol-support) [MongoDB 4.0のトランザクション機能は使用できない。計画も無い。](https://feedback.azure.com/forums/263030-azure-cosmos-db/suggestions/35324563-support-for-transactions-via-mongo-4-0-api) 実際に今回のプログラムをCosmosDBに対して実行すると Exception in thread "main" com.mongodb.MongoClientException: Sessions are not supported by the MongoDB cluster to which this client is connected というエラーが発生した。

## 1回目 単発実行

単発で実行してみます。

```shell
java -cp mongotransaction-0.0.1-SNAPSHOT.jar;dependency\bson-3.9.1.jar;dependency\mongodb-driver-core-3.9.1.jar;dependency\mongodb-driver-sync-3.9.1.jar com.qiita.kabao.Sample1 "mongodb://<....>/test?replicaSet=rsokano&authSource=admin&retryWrites=true" 0000
Start Session
Start Transaction
{ "_id" : { "$oid" : "5c18d7a46a5db864b14f3a47" }, "name" : "satoshi", "address" : { "country" : "日本", "pref" : "神奈川", "city" : "横浜", "zipcode" : "220-0001" }, "lastModified" : { "$date" : 1545131967068 } }
Commit....
....done
close
end
```

結果

```shell
rsokano:PRIMARY> db.sample1.find().pretty()
{
        "_id" : ObjectId("5c18d7a46a5db864b14f3a47"),
        "name" : "satoshi",
        "address" : {
                "country" : "日本",
                "pref" : "神奈川",
                "city" : "横浜",
                "zipcode" : "220-0001"
        },
        "lastModified" : ISODate("2018-12-18T11:19:00.286Z"),
        "flag" : "0000"
}
{
        "_id" : ObjectId("5c18d7a46a5db864b14f3a49"),
        "name" : "vigyan",
        "address" : {
                "country" : "Australia",
                "state" : "VIC",
                "city" : "Melbourne",
                "street" : "120 Collins Street",
                "postcode" : "3000"
        },
        "lastModified" : ISODate("2018-12-18T11:19:27.111Z")
}
rsokano:PRIMARY>
```

## 2回目 並列実行

次に2個ほぼ同時に実行してみます。

1個目。正常に動作しています。

```shell
java -cp mongotransaction-0.0.1-SNAPSHOT.jar;dependency\bson-3.9.1.jar;dependency\mongodb-driver-core-3.9.1.jar;dependency\mongodb-driver-sync-3.9.1.jar com.qiita.kabao.Sample1 "mongodb://<....>/test?replicaSet=rsokano&authSource=admin&retryWrites=true" 1111
Start Session
Start Transaction
{ "_id" : { "$oid" : "5c18d7a46a5db864b14f3a47" }, "name" : "satoshi", "address" : { "country" : "日本", "pref" : "神奈川", "city" : "横浜", "zipcode" : "220-0001" }, "lastModified" : { "$date" : 1545132857511 } }
Commit....
....done
close
end
```

2個目。WriteConflictでMongoCommandExceptionが出ました。

```shell
java -cp mongotransaction-0.0.1-SNAPSHOT.jar;dependency\bson-3.9.1.jar;dependency\mongodb-driver-core-3.9.1.jar;dependency\mongodb-driver-sync-3.9.1.jar com.qiita.kabao.Sample1 "mongodb://<....>/test?replicaSet=rsokano&authSource=admin&retryWrites=true" 2222
Start Session
Start Transaction
MongoException
Abort....
....done
com.mongodb.MongoCommandException: Command failed with error 112 (WriteConflict): 'WriteConflict' on server 192.168.56.102:27017. The full response is { "errorLabels" : ["TransientTransactionError"], "operationTime" : { "$timestamp" : { "t" : 1545132829, "i" : 1 } }, "ok" : 0.0, "errmsg" : "WriteConflict", "code" : 112, "codeName" : "WriteConflict", "$clusterTime" : { "clusterTime" : { "$timestamp" : { "t" : 1545132829, "i" : 1 } }, "signature" : { "hash" : { "$binary" : "AAAAAAAAAAAAAAAAAAAAAAAAAAA=", "$type" : "00" }, "keyId" : { "$numberLong" : "0" }} } }
        at com.mongodb.internal.connection.ProtocolHelper.getCommandFailureException(ProtocolHelper.java:179)
        at com.mongodb.internal.connection.InternalStreamConnection.receiveCommandMessageResponse(InternalStreamConnection.java:299)
        at com.mongodb.internal.connection.InternalStreamConnection.sendAndReceive(InternalStreamConnection.java:255)
        at com.mongodb.internal.connection.UsageTrackingInternalConnection.sendAndReceive(UsageTrackingInternalConnection.java:99)
        at com.mongodb.internal.connection.DefaultConnectionPool$PooledConnection.sendAndReceive(DefaultConnectionPool.java:444)
        at com.mongodb.internal.connection.CommandProtocolImpl.execute(CommandProtocolImpl.java:72)
        at com.mongodb.internal.connection.DefaultServer$DefaultServerProtocolExecutor.execute(DefaultServer.java:200)
        at com.mongodb.internal.connection.DefaultServerConnection.executeProtocol(DefaultServerConnection.java:269)
        at com.mongodb.internal.connection.DefaultServerConnection.command(DefaultServerConnection.java:131)
        at com.mongodb.operation.MixedBulkWriteOperation.executeCommand(MixedBulkWriteOperation.java:419)
        at com.mongodb.operation.MixedBulkWriteOperation.executeBulkWriteBatch(MixedBulkWriteOperation.java:257)
        at com.mongodb.operation.MixedBulkWriteOperation.access$700(MixedBulkWriteOperation.java:68)
        at com.mongodb.operation.MixedBulkWriteOperation$1.call(MixedBulkWriteOperation.java:201)
        at com.mongodb.operation.MixedBulkWriteOperation$1.call(MixedBulkWriteOperation.java:192)
        at com.mongodb.operation.OperationHelper.withReleasableConnection(OperationHelper.java:424)
        at com.mongodb.operation.MixedBulkWriteOperation.execute(MixedBulkWriteOperation.java:192)
        at com.mongodb.operation.MixedBulkWriteOperation.execute(MixedBulkWriteOperation.java:67)
        at com.mongodb.client.internal.MongoClientDelegate$DelegateOperationExecutor.execute(MongoClientDelegate.java:193)
        at com.mongodb.client.internal.MongoCollectionImpl.executeSingleWriteRequest(MongoCollectionImpl.java:960)
        at com.mongodb.client.internal.MongoCollectionImpl.executeReplaceOne(MongoCollectionImpl.java:602)
        at com.mongodb.client.internal.MongoCollectionImpl.replaceOne(MongoCollectionImpl.java:597)
        at com.qiita.kabao.Sample1.main(Sample1.java:83)
close
end
```

結果。やはり1個目の方しか反映されていません。

```shell
rsokano:PRIMARY> db.sample1.find().pretty()
{
        "_id" : ObjectId("5c18d7a46a5db864b14f3a47"),
        "name" : "satoshi",
        "address" : {
                "country" : "日本",
                "pref" : "神奈川",
                "city" : "横浜",
                "zipcode" : "220-0001"
        },
        "lastModified" : ISODate("2018-12-18T11:33:50.876Z"),
        "flag" : "1111"
}
{
        "_id" : ObjectId("5c18d7a46a5db864b14f3a49"),
        "name" : "vigyan",
        "address" : {
                "country" : "Australia",
                "state" : "VIC",
                "city" : "Melbourne",
                "street" : "120 Collins Street",
                "postcode" : "3000"
        },
        "lastModified" : ISODate("2018-12-18T11:34:17.563Z")
}
rsokano:PRIMARY>
```

## Write Conflict

あるトランザクション内で変更しているドキュメントに対して、他のトランザクションからも変更しようとすると、後続のトランザクションはロック確保待ちとなり、一定時間待っても確保できなかった場合Write Conflictとしてエラーになります。

ロック確保のタイムアウトは[maxTransactionLockRequestTimeoutMillis](https://docs.mongodb.com/master/reference/parameters/#param.maxTransactionLockRequestTimeoutMillis)で指定可能で、デフォルト5msです。

RDBの世界のロック待ち、キー重複などと似た概念と考えられますが、MongoDBでは4.0以降新しく出てくるエラーなので注意が必要です。

# ベストプラクティス

トランザクション機能を利用する場合に一番注意するべきポイントは、Write Conflictの対応となるのではないでしょうか。

ただしそれ以前に、MongoDBとしては必ずしもトランザクションを使わず、単一ドキュメント単位で処理できるようにデータモデルを考えることを推奨しているようです。

1. 4.0以降を使う場合でも、あらゆるところでトランザクションを使おうとしないこと。ドキュメントデータモデルを考慮し、単一ドキュメント単位で処理できるように検討すること。
2. Write Conflictを考慮すること。
3. トランザクションを使う場合、短い時間で終わるように考慮すること。[transactionLifetimeLimitSeconds](https://docs.mongodb.com/master/reference/parameters/#param.transactionLifetimeLimitSeconds)を超えるとabortされる。デフォルトは60秒。
4. トランザクションに含まれるデータ量は16MBを超えないようにすること。[^12]
5. トランザクション内にはDDL操作は含めることはできない。同一ネームスペースでトランザクションが仕掛中の場合、DDL操作はブロックされるので注意すること。

[^12]: 1トランザクションを1つのOplogエントリとして処理していることから来る制約。[Oplog Size Limit](https://docs.mongodb.com/master/core/transactions-production-consideration/index.html#oplog-size-limit)