Carpe Diem

備忘録

MongoDB Causal Consistency Session

概要

MongoDB Write Concern - Carpe Diem
MongoDB Read Concern - Carpe Diem

でデータの耐久性・一貫性・分離性・最新性などを保証する方法について説明しました。

しかし、これらの設定だけでは

Causal Consistency - Carpe Diem

で紹介した因果関係の一貫性を保証できないケースがあります。

MongoDBではLamport Clockを用いてそれらのケースを保証する仕組みがあり、それをCausal Consistency Sessionといいます。

一貫性が保証できないケース

  • write concern: majority
  • read concern: majority

に設定すれば多くのケースで問題はありませんが、例えば書き込み直後の読み取り(read after write)の場合

f:id:quoll00:20220317030451p:plain

上図のようにレプリケーションラグが大きいSecondaryからreadした場合、書き込んだはずのBが反映されてません。

これは書き込んでから読み込んだという因果関係(Causal)が一貫していないことになります。

Causal Consistency Session

MongoDBのCausal Consistency Sessionは上記のケースを保証する仕組みです。ただしRead Concern/Write Concernが保証するケースに大きく関わります。

Read Concern / Write ConcernとCausal Consistency Sessionの保証具合

f:id:quoll00:20220317114156p:plain

ref: https://docs.mongodb.com/manual/core/causal-consistency-read-write-concerns/

この表の通り、w: "majority", r: "majority"を設定した上でCausal Consistency Sessionを使うと、全ての因果関係の一貫性を保証できます。

検証

では実際にMongoDBのレプリカ構成を組んで動作検証を行ってみます。

環境構築

  • MongoDB 5.0.6
  • Go 1.18

MongoDBレプリカセット対応

以下のようなdocker-composeを用意します。

version: "3"
services:
  primary:
    image: mongo
    restart: always
    ports:
      - 27017:27017
    command:
      - --replSet
      - rs0
      - --port
      - "27017"
  secondary1:
    image: mongo
    restart: always
    ports:
      - 27018:27018
    command:
      - --replSet
      - rs0
      - --port
      - "27018"
  secondary2:
    image: mongo
    restart: always
    ports:
      - 27019:27019
    command:
      - --replSet
      - rs0
      - --port
      - "27019"

初期設定のスクリプトを用意します。

config = {
  _id: "rs0",
  members: [
    { _id: 0, host: "primary:27017" },
    { _id: 1, host: "secondary1:27018" },
    { _id: 2, host: "secondary2:27019" },
  ],
};
rs.initiate(config);

起動し、レプリカセットの設定を行います。

$ docker-compose up -d
$ mongo init.js

/etc/hosts

上記docker containerにアクセスできるよう/etc/hostsもいじっておきます。

##
# Host Database
#
# localhost is used to configure the loopback interface
# when the system is booting.  Do not change this entry.
##
127.0.0.1   localhost primary secondary1 secondary2
255.255.255.255 broadcasthost
::1             localhost

Go

client生成

  • write concern: majority
  • read concern: majority

を有効にし、ReadPreferenceをSecondaryにしておきます。

func newClient(ctx context.Context, addr string) (*client, error) {
        mongoURI := fmt.Sprintf("mongodb://%s/?replicaSet=rs0", addr)
        opts := options.Client().ApplyURI(mongoURI).
                SetWriteConcern(writeconcern.New(writeconcern.WMajority())).
                SetReadConcern(readconcern.Majority()).
                SetReadPreference(readpref.Secondary())

        cli, err := mongo.NewClient(opts)
        if err != nil {
                return nil, err
        }
        if err := cli.Connect(ctx); err != nil {
                return nil, err
        }
        ctxwt, cancel := context.WithTimeout(ctx, 10*time.Second)
        defer cancel()
        if err := cli.Ping(ctxwt, nil); err != nil {
                return nil, err
        }

        return &client{
                cli: cli,
                db:  cli.Database(dbName),
        }, nil
}

read your write部分

insert直後にリスト処理を行います。

func (c *client) readYourWrite(ctx context.Context, data User) ([]User, error) {
        if _, err := c.db.Collection(col).InsertOne(ctx, data); err != nil {
                return nil, err
        }

        cursor, err := c.db.Collection(col).Find(ctx, bson.M{})
        if err != nil {
                return nil, err
        }

        var res []User
        if err = cursor.All(ctx, &res); err != nil {
                return nil, err
        }
        return res, nil
}

Causal Consistency Session

contextとしてCausal Consistency Sessionを渡せるようにします。

func (c *client) withCausalConsistencySession(ctx context.Context) (context.Context, func(), error) {
        sess, err := c.cli.StartSession(options.Session().SetCausalConsistency(true))
        if err != nil {
                return nil, nil, err
        }
        closer := func() {
                sess.EndSession(context.TODO())
        }
        return mongo.NewSessionContext(ctx, sess), closer, nil
}

main()

100回ほどread after write処理を実行して検証します。
もし書き込んだ分のデータが取得できなかった場合ログを吐くようにしておきます。

func main() {
        ctx := context.TODO()
        cli, err := newClient(ctx, "primary:27017,secondary1:27018,secondary2:27019")
        if err != nil {
                log.Fatal(err)
        }

        // ここのsessionの有無で動作が変わるか確認する
        ctx, closer, err := cli.withCausalConsistencySession(ctx)
        if err != nil {
                log.Fatal(err)
        }
        defer closer()

        for i := 0; i < 100; i++ {
                data := User{
                        ID:   gofakeit.UUID(),
                        Name: gofakeit.Name(),
                        Age:  gofakeit.Number(10, 60),
                }
                list, err := cli.readYourWrite(ctx, data)
                if err != nil {
                        log.Fatal(err)
                }
                if i+1 != len(list) {
                        fmt.Printf("count: %d, data: %d\n", i+1, len(list))
                }
        }
        if err := cli.clean(ctx); err != nil {
                log.Fatal(err)
        }
}

動作確認

ではCausal Consistency Sessionの有無で一貫性の挙動が変わるか確認してみます。

Causal Consistency Sessionじゃない場合

Causal Consistency Sessionの部分をコメントアウトして実行してみます。

causal-consistency $ go run main.go
count: 94, data: 93
causal-consistency $ go run main.go
count: 90, data: 89
causal-consistency $ go run main.go
count: 49, data: 48
causal-consistency $ go run main.go
count: 95, data: 94
count: 97, data: 96

何度か実行しましたが100回に0~2回程度一貫性を保証できないケースがありました。

Causal Consistency Sessionの場合

causal-consistency $ go run main.go
causal-consistency $ go run main.go
causal-consistency $ go run main.go
causal-consistency $ go run main.go

データのズレは発生せず、常に一貫性が保証されていました。

サンプルコード

github.com

まとめ

Write ConcernやRead Concernを設定することでデータの耐久性や最新性などの保証ができますが、因果関係の一貫性を保証するためには追加でCausal Consistency Sessionを使う必要があると分かりました。

参考