(2018/2/26 追記)Datastore/Go のデータ設計のコツ

Datastoreを使っていて、
ある程度コツとか注意点みたいなものが分かってきたので、
まとめてみました。
継続的に追記していく予定です。

間違っているところがあれば
コメント or twitter で教えてください。

Datastoreの entity, kind などの用語は理解している前提です。

ParentKeyに気をつける

外部キーのノリで利用してはいけない。
詳しくはこちら。
http://pospome.hatenablog.com/entry/20161009/1475990332


Go では Filter による OR, IN 検索ができない

Keyでの GetMulti() は可能だが、
それ以外の property で OR, IN による検索はできない。
https://cloud.google.com/appengine/docs/go/datastore/queries#filters

以下のようにループで回す必要がある。

var users []User
for _, name := range names {
    var tmp []User
    q := datastore.NewQuery("User").Filter("name =", name)
    if err = q.GetAll(ctx, &tmp); err != nil {
        //error
    }
    users = append(users, tmp)
}

ちなみに、Java, Pythonでは利用できる。
https://cloud.google.com/appengine/docs/java/datastore/queries#filters
https://cloud.google.com/appengine/docs/python/datastore/queries#filters

地味に「!=」も利用できない。
これはDatastoreがindexベースの検索を提供しているので、当然といえば当然だけど。


文字列に対する LIKE 検索がない

これもない。


結局どんなクエリが発行できるのか?

Datastore には IN, OR, LIKE がないことを説明したが、
結局何ができて、何ができないのかが気になると思う。

以下の関数一覧を見れば大体分かる。
https://cloud.google.com/appengine/docs/standard/go/datastore/reference#index

COUNT, DISTINCT, LIMIT, OFFSET, ORDER は利用できる。

そして、以下がよくまとまっている。
Datastoreでの検索実装パターン ~Search APIもあるよ~ - Qiita

注意したいのは Filter() で指定できる検索条件。
以下に目を通しておくと良い。
https://cloud.google.com/appengine/docs/standard/go/datastore/reference#Query.Filter
Datastore Queries  |  App Engine standard environment for Go 1.11 docs
https://cloud.google.com/datastore/docs/concepts/queries?hl=ja#restrictions_on_queries


あとは Cursor というページングに利用できる機能もある。
https://cloud.google.com/appengine/docs/go/datastore/reference#Cursor

ただし、Cursor は前に戻ることができないので、
よくある「戻るボタン」「進むボタン」でのページングをすることができない。
前ページで利用した Cursor を Datastore, memcache に突っ込んで保存しておいたり、
戻る操作が不要なUIにする必要がある。
Twitter の TL みたいに次々と読み込んでいく感じ

SUM() などの集計系が弱い(というかない)ので、
集計結果が欲しい場合はDatastoreのデータをBigQueryに突っ込むなり、
Datastoreのデータに対して集計処理をしてあげて、サマリ用の kind に突っ込む、
的なことが必要になる。


SearchAPIによる柔軟な検索

RDBに比べると、どうしても検索の柔軟性が低くなってしまうが、
SearchAPIを利用することで柔軟な検索が可能になる。
http://mame0112.hatenablog.com/entry/2015/05/31/114057

ただ、SearchAPI は Datastore 内のデータについて検索をかけることはできないので、
Datastoreと同じデータを SearchAPI にも用意する必要があるっぽい。
http://gcp-memo.nomiso.net/2015/12/datastore-serach-api.html

そして、Datastore ほどスケールしないらしい。




とはいえ、
ソシャゲでも利用されているくらいなので、
Datastore ほどスケールしないだけであって、
ある程度の規模感 & ユースケースであれば、十分実用に耐えるものだとは思う。

パフォーマンスに関しては以下が参考になるかもしれない。
https://apps-gcp-tokyo.appspot.com/seach-api-part5/


structのネストによってValueObjectを利用する

Datastore に保存する entity の定義は struct を利用するが、
以下のように struct をネストすることができる。

type User struct {
    Name        string
    Profile     Profile
}

type Profile struct {
    Tel        string
    Address    string
}



entity の property は User.Name, User.Profile.Tel, User.Profile.Address のようになり、
これらの値は entity を取得したときに User, Profile それぞれの struct にバインドされる。
もちろん User.Profile.Tel, User.Profile.Address のようにネストした struct の property で entity を引くこともできる。

とはいえ、以下のようにフラットにするのとあまり変わりない気がする。

type User struct {
    Name       string
    tel        string
    address    string
}



struct をネストすると何が嬉しいのかというと、
アプリケーション側のコードに関係してくる。

DatastoreはJOINができないので、
1つのentityが持つ情報量が多くなる傾向にある。

全ての property をフラットに並べてしまうと
1つの巨大なstructに振る舞いを持たせることになるので、
その struct が持つ責務が大きくなってしまう。

struct を適切な粒度で管理しておけば(ネストさせておけば)、
こういった責務の肥大化を防ぐことができる。
なので、Datastoreというよりは、
設計ベースで考えてネストさせるかどうかを決めればいい。

struct のネストについてはこちらも参考にするといい。
http://qiita.com/hogedigo/items/463d71715fa14a5d5fd3

ネストを利用した設計についてはこちらをどーぞ。
http://pospome.hatenablog.com/entry/2017/04/28/000625

ちなみに、以下を利用すると
Datastoreから entity を get するタイミングと
put するタイミングをフックできるので、
Datastore のデータ構造と struct に乖離があっても、
手動でバインドさせることができる。
https://cloud.google.com/appengine/docs/go/datastore/reference#PropertyLoadSaver


named type は後からでも利用可能。

以下の string の Name を

type User struct {
    Name        string `datastore:"name"`
}

以下のように named type にした Name に変更しても、
エラーになることはない。

type Name string

type User struct {
    Name        Name `datastore:"name"`
}

まあ、これはどっちも string だから当然といえば当然。

以下は挙動を確認した際のコード。
DatastorePut() でエンティティを PUT し、
DatastoreGet() で GET できることを確認した。
https://gist.github.com/pospome/1f6d920c71fc49cfbfa4b00ed1bb806b




map は保存できないが、sliceは保存できる

https://cloud.google.com/appengine/docs/go/datastore/reference#hdr-Basic_Operations

ここに載っているように int, string などの基本的な型はもちろん、
struct の slice まで保存できる。
でも、map は保存できない。(´・ω・`)

map を保存したい場合は
以下のような key, value を持った struct を slice で突っ込めばいいと思う。

type MyStruct struct {
    key string
    value string
}

以下にも注意。
http://pospome.hatenablog.com/entry/2017/02/05/164525
http://pospome.hatenablog.com/entry/2017/04/20/172148



slice を検索できる

[]int のような slice の property は filter 可能。
struct の slice も struct の property で filter 可能。


制限事項と保存できる文字列の最大長

https://cloud.google.com/datastore/docs/concepts/limits

こちらに制限事項が載っている。
entity のサイズ制限とかあるので、目を通すといい。

個人的に気になったのは
「インデックス付けされた文字列プロパティの UTF-8 エンコードの最大サイズ」

「インデックス付けされていないプロパティの最大サイズ」
のところ。

string の property に index を貼ると max 1500 byte だが、
index を貼らなければ max 1048487 byte まで保存できる。

1500 byte 以上の json などを保存する場合は
noindex を指定する必要がある。

index を貼らないとその property を検索できなくなってしまうが、
Datastore は string に対する LIKE 検索を提供していないので、
1500byte 以上の json を完全一致で検索することがない限り、
noindex でも問題ないと思う


key以外にユニークなフィールドを持つのは面倒

ユニークな property は key にした方がいい。
以下はDatastoreのエラーの記事だが、
例としてユニークな property の実現方法について説明している。
http://pospome.hatenablog.com/entry/20161108/1478611356

とはいえ、ユニークにしたい値が key として相応しいとは限らない。

例えば、email はユニークにしたいことが多いと思うが、
この場合、pospome@email.com のような記号を含んだ文字列が key になってしまう。
@, - などの記号が key に含まれるのを避けたい場合は
email をハッシュ化したものを key にするといい。
文字列にはなるが、key に記号が含まれるよりはいくらかマシかもしれない。

複合ユニーク制約を持たせるのも面倒なので、
ユニークな組み合わせになる property をハッシュ化して key にした方がいい。
(別にハッシュ化せずに property1-property2 とかでもいーんだけど)

ユニーク制約については以下が参考になる。
http://d.hatena.ne.jp/higayasuo/20091111/1257905482


JOINできないので、実データを突っ込むと良い

Datastoreに限らず、KVSは JOIN ができないことが多いので、
kind 同士でリレーションを貼らずにそのまま値を持った方がいい。

例えば、user が複数の task を保つ場合、
user kind と task kind を用意して、
taks kind に user kind の key を持たせて外部キーみたいなリレーションを貼るのではなく、
task の slice を user kind に突っ込む。

ただし、entity のサイズには制限があるので、
user が保持する task の個数多すぎて、そのサイズ制限を超える場合は別 kind にする必要がある。


Datastore の property が struct の field に存在しないとエラー

以下のような struct があって、

type User struct {
    Name string
    Score int
}

Datastore の property が Name, Score, Address で、
struct の field に Address が存在しない場合、
datastore.ErrFieldMismatch が発生する。

エラーメッセージでいうと以下。
「datastore: cannot load field xxxx into a xxx: no such struct field」

逆に struct の field が Datastore の property よりも多い場合は struct の field が zero value になるだけで、
エラーは発生しない。

Datastoreのスキーマを変更する際は
先に struct に field 定義を追加したコードをデプロイしてから
Datastore を触るコードをデプロイするという方法も選択肢の1つとして有効かもしれない。

ただ、エラーが発生してもエラー対象以外のフィールドには値がバインドされるので、
datastore.ErrFieldMismatch をハンドリングして無視してしまえば、
上記のようにデプロイを工夫する必要もない。

ちなみに、Goon という Datastore のラッパーを利用することで
これを回避できます。
http://pospome.hatenablog.com/entry/2017/07/19/213944




propertyのno-indexに注意

詳しくはココ。
https://cloud.google.com/datastore/docs/concepts/indexes#unindexed_properties

type User struct {
    Name string `datastore:"name,noindex"`
}

上記のように検索条件にならない property には noindex を指定してもいいが、
後から必要になって noindex を外した場合、
既存の entity に index が貼られることはない。

noindex を外した後に put された entity にのみ index が貼られる。
インデックスについてはしっかりと学んでおく必要がある。
https://cloud.google.com/datastore/docs/concepts/indexes


複合インデックス or 仕様割り切り or コードでフィルタリング

Datastore は filter で「検索条件指定」と「ソート」を指定すると、
複合インデックスが必要になる。
https://cloud.google.com/datastore/docs/concepts/indexes#index_definition_and_structure

複合インデックスがあまりにも増える場合は
「desc はサポートするけど、asc はサポートしない」
「指定する検索条件を限定して、アプリケーションコードでフィルタリングする」
といった割り切りが必要になるかもしれない。

アプリケーションコードでフィルタリングする場合に注意したいのが、
ページングの実装。

アプリケーションコードでフィルタリングした結果、
1ページに表示する件数に満たない可能性を考慮して、
entity を多めに取得したり、
各ページの最後に表示されている entity の id を管理しないといけなかったり、
意外と面倒になることが多い。

なので、
ページングが必要な場合は可能な限り 複合インデックス + Cursor を利用した方がいい。

ちなみに、1つのプロジェクトで定義できる複合インデックスには上限がある。
現在は 200 が上限。


エンティティグループは1秒間に1回しか更新できない & 解決策

以下によると、「エンティティグループへの最大書き込み速度」は「毎秒1回」に制限されている。
https://cloud.google.com/datastore/docs/concepts/limits

ParentKey(ParentEntity)を持たないエンティティも1つのエンティティグループとみなされるので、
毎秒1回しか書き込みできない。

例えば、
ホームページのアクセスカウンターを以下のような1つの entity で実現しようとした場合、

{
    id int
    access_count int
}

access_count のインクリメントは1秒間に1回しかできないので、
ホームページに秒間1アクセス以上のアクセスがあった場合、
このアクセスカウンターは機能しない。

ただ、自分は1秒に1回以上書き込んだことがないので、
「インクリメントが即時反映されないけど、書き込み操作自体は可能」なのか、
「書き込み操作自体不可能」なのかは不明。




これを解決する方法として、以下がある。
https://cloud.google.com/appengine/articles/sharding_counters

この ShardingCounters というテクニックの仕組みは単純で、
entity を複数個用意してランダムでインクリメントして、
それらの entry のカウントを合計すればいいというもの。
自分は元々ソーシャルゲームを運用していたが、
MySQLでこういった実装をしたことがある。

以下に実装時の注意点が載っているので目を通したほうがいい。
https://cloud.google.com/datastore/docs/best-practices#sharding_and_replication

it is important to note that you can only expect to update any single entity or entity group about five times a second.

ちなみに、さきほど「秒間1回しか書き込みできない」と書いたが、
この記事によると、1秒間に5回までしか entity を update できないと書いてある。
どちらが正しいかは不明。


トランザクションは楽観ロック

Datastoreにもトランザクションは存在するが、楽観ロックになっている。

仕組みとしては、
各 RootEntity は最終更新時間を持っていて、
その更新時間がトランザクションのコミット時間よりも前であればコミットできる。
後であれば、ロールバックしてリトライするというもの。

RootEntity の最終更新時間がベースになるので、
巨大な entityGroup かつ、更新頻度が高いものは、
トランザクションでリトライが走る可能性が高くなる。

以下を true にすると複数の kind にまたがってトランザクションをかけることができる。
https://cloud.google.com/datastore/docs/concepts/transactions

トランザクションはここが詳しい。
http://www.apps-gcp.com/datastore-transaction/
https://cloud.google.com/datastore/docs/concepts/transactions


トランザクションでは冪等性を担保する必要がある

Datastoreはトランザクションが失敗するとリトライする。
リトライ回数は以下で設定できる。
https://github.com/golang/appengine/blob/master/datastore/transaction.go#L84-L86

リトライはあくまで「トランザクションがコンフリクトした場合のエラー」でリトライされるのであって、
その他のエラーではリトライされない。
https://github.com/golang/appengine/blob/master/datastore/transaction.go#L66


ここでポイントになるのが RunInTransaction() 内の操作は冪等性を担保する必要があるということ。

以下のように RunInTransaction() 内で score++ してしまうと、
Put() が失敗して、
RunInTransaction() 内がリトライされるので、
score は 前回の 101 をインクリメントして 102 になってしまう。

score := 100
RunInTransaction(ctx, func(tx *datastore.Transaction) error {
    score++
    _, err := tx.Put( "key", &User{score})
   return err
})



以下のようにする必要がある。
RunInTransaction() の外で値を確定させておいた方が良い。

score := 100
score++
RunInTransaction(ctx, func(tx *datastore.Transaction) error {
    _, err := tx.Put( "key", &User{score})
   return err
})



ちなみに、Goon を利用すると、struct の key を自動的に埋めてくれるので便利ではあるが、
リトライが走った際に key が埋まったままリトライされてしまうので注意した方がいい。


インデックス爆発

https://cloud.google.com/datastore/docs/concepts/indexes#index_limits


Go から Datastore を扱うためのライブラリ

1. gae の datastore package
https://github.com/golang/appengine/tree/master/datastore
datastore の 公式ライブラリ。
goon, nds というラッパーは存在するが、
基本的に datastore package の API に沿っているので、
まずはこれを使って datastore を叩いてみるといいと思う。


2. goon
https://github.com/mjibson/goon
datastore package がよしなにやってくれないところをやってくれるラッパー。

以下を備える。
・entity を Get() した際に memcache へのキャッシュしてくれる。
 ただい、Query + Filter で取得した entity はキャッシュしてくれない。
・ローカルメモリキャッシュ
 memecache だけではなく、メモリにもキャッシュしてくれる。
・easy な API
 既存の simple な API を使いやすくラップしてくれている。
 以下の記事が分かりやすい。
 http://qiita.com/soundTricker/items/194d4067b0e145544b56
・datastore.ErrFieldMismatch の回避 
 http://pospome.hatenablog.com/entry/2017/07/19/213944


ただ、ローカルメモリキャッシュには注意。

こちらも確認しておくといい。
http://pospome.hatenablog.com/entry/2017/05/05/182836


3. nds
https://github.com/qedus/nds
memcache へのキャッシュ機能のみを提供するライブラリ。
キャッシュ機能しか提供しないので、
nds が提供する API は datastore package の API に準拠している。
goon のように easy な API は提供していない。


4.mercari/datastore
https://github.com/mercari/datastore
メルカリ製の Datastore ライブラリ。

以下の2つを読めば大体どーゆーものかが分かるはず。
https://qiita.com/vvakame/items/9310bcb5a4e87888d505
https://qiita.com/vvakame/items/f24444267a0ce786dffc

比較的新しい Datastore ライブラリなので、
2018/1/6 時点では unstable になっている。
実戦投入されているサービスも少ない。
実戦投入に関しては、以下の GCPUG slack で質問した方がいいかもしれない。
https://gcpug.jp/join


目を通すべき記事(随時 update していきます)

本記事で紹介していないコツが以下のリンクに載っていたりします。
*同じ内容を本記事に載せるのはコピーコンテンツっぽくて嫌なので、オリジナルの記事を尊重するためリンクを貼っています。

目を通しておいた方がいいです。
*ただし、内容が古いものもあるので、ちゃんと自分で裏を取るなり、動作確認しましょう。

https://cloud.google.com/datastore/docs/best-practices
https://sinmetal-slide.appspot.com/2016/gaejanight0607/gaejanight0607.slide#1
http://qiita.com/vvakame/items/f98b5189b302cd729734
https://cloud.google.com/datastore/docs/articles/balancing-strong-and-eventual-consistency-with-google-cloud-datastore/
https://cloud.google.com/datastore/docs/articles/fast-and-reliable-ranking-in-datastore/
http://qiita.com/hogedigo/items/25b4dbefe694dbfc7dbc
http://qiita.com/hnw/items/52addaebe95bb9a16eac
https://docs.google.com/presentation/d/1qX6DR8psaaBXaBDWDxK9e3zKHgZJg6DgCMe944ryLAg/edit#slide=id.gc6f919934_0_0
https://docs.google.com/presentation/d/1ogO88RFNY3guT8p_UsPHzA8lTNGwyjjNDz0-0iFwZlQ/edit#slide=id.g2b2c78386d_0_20


TaskQueue にもトランザクションをかけることができる

https://cloud.google.com/appengine/docs/go/datastore/transactions#transactional_task_enqueuing
GAEのTaskQueueにもDatastoreのトランザクションが有効。

Datastoreの更新とTaskQueueへのエンキューを一緒に実行するケースは多いと思うので、
トランザクションをかけた方がいい。
ロールバックしてくれる。

ただし、1つのトランザクションでは最大でも5つしかエンキューできない。

An application cannot insert more than five transactional tasks into task queues duing a single transaction



Get(), GetMulti() で指定した key が存在しない場合はエラーになる

http://pospome.hatenablog.com/entry/2017/03/15/204132


kindが存在しなくても複合インデックスを貼ることができる

kind作ってからインデックス貼らないといけないのかなと思っていたが、kind 作らなくても貼れる。


ローカルのエミュレータの挙動とGCP上での挙動が同じとは限らない

たしか、ローカルのエミュレータSQLite が裏で動いているので、GCP上の Datastore と挙動が同じとは限らない。
基本的にちゃんとエミュレートしてくれるっぽいが、GCP上で実動作を確認した方がいい。


cloud console から datastore に任意の int の key を指定した entity を作成できない

http://pospome.hatenablog.com/entry/2017/08/22/193005


Memcache が落ちると Datastore のパフォーマンスが劣化することがある

Datastore へのキャッシュに Memcache を利用している場合、Memcache が落ちると Datastore のパフォーマンスが劣化することがある - pospomeのプログラミング日記


トランザクションが成功してもエラーが返ることがある

以下の @hogedigo さんのツイートで初めて知った。


Datastore はトランザクションが成功してもエラーが返ることがある。
これは以下のドキュメントにも記載がある。

トランザクションの commit 中にアプリケーションが例外を受け取っても、必ずしもトランザクションが失敗したことを意味するわけではありません。トランザクションを commit し、正常に適用された場合でも、エラーが返されることがあります。トランザクションを何度繰り返しても同一の結果が得られるように、可能な限り Cloud Datastore トランザクションのべき等性を確保する必要があります。

https://cloud.google.com/datastore/docs/concepts/transactions?hl=ja#using_transactions


とはいえ、
トランザクション内の処理は冪等性を担保しているので、
トランザクションが成功した後に、トランザクション内の処理がリトライされても問題ないような気はする。
リトライされても、同じ値を PUT() するだけ。

と思ったが、key の採番を考えると、そうも言ってられない気がしてきた・・・。




というのも、自分は普段 goon を利用して実装している。
goon は key を空にすると、自動的に採番してくれる便利機能があるが、
その採番は当然ながらトランザクション内で実行されてしまう。
https://github.com/mjibson/goon/blob/master/entity.go#L771

なので、以下のようなコードを書きがち。

type User struct {
	id int64 `datastore:"-" goon:"id"`
	name string
}

func SaveUser(userName string) {
	_ := g.RunInTransaction(func(tg *Goon) error {
		user := &User{
			name: userName,
		}
		_, err := tg.Put(user) // ここで key が採番される。
		return err
	}, nil)
}

このようなコードだと、
トランザクション内で key が採番されてしまうので、
冪等性が担保されなくなってしまう。

トランザクションは成功したが、エラーになる
というケースがどの程度発生するのかは分からないが、
トランザクションの外で明示的に採番した方が良いと思う。


独自の time.Time 型を定義すると正常に動作しない

http://pospome.hatenablog.com/entry/2018/01/05/193425


メタデータクエリがある

Datastore の namespace, kind, property を検索できるメタデータクエリが存在する。
https://cloud.google.com/datastore/docs/concepts/metadataqueries

「Datastore に存在する kind を全て取得したいなー」的な用途はこれを使えばいい。
ちなみに自分は使ったことがない。


GCPUG slack で質問する

GCPUG = Google Cloud Platform User Group のこと。
詳しくは以下。
https://gcpug.jp/about

slack で GCP について情報交換ができる。
https://gcpug.jp/join

slack はかなりアクティブで、slack の Datastore channel で質問すれば、
誰かしらがアドバイスをくれる。

Datastore はクセが強い KVS なので、
データ設計や実装に困って、自分でやってみてハマるよりも、質問してみるといい。
自分も Datastore の謎挙動にハマったときは質問している。


cli ツールがある

大量のデータを作成したい時、cloud console でポチポチやるのはツライし、
かといって、それ用のスクリプトを書くのも面倒。

自分は使ったこと無いけど、
最近以下の cli ツールを知った。
https://github.com/nshmura/dsio

GQLの発行と、entity の upsert(PUT相当の操作かな?)を実行できるみたい。

upsert は csv, yaml などで複数指定可能なので、
大量のマスターデータを作るときなんかに便利かもしれない。


(おまけ)Datastore の特徴

なんとなく Datastore 自体の特徴を書いておこうかなと・・・。


レプリケーションという概念がない
RDBだと Slave遅延を考慮して、マスター、スレーブ を意識してコードを書くケースもあるが、
Datastore だとそういったところを意識せずにコードを書けばいい。

スケールする
RDBレプリケーションモデルだとマスターがスケールしないのが欠点で、
シャーディングで対応することがあるが、
Datastore はレプリケーションがないので、データ量が増えても、リクエスト数が増えてもスケールします。

速くもないが、遅くもない
memcache のような KVS をイメージすると、ハイパフォーマンスなものをイメージするかもしれないが、
Datastore は特別速い KVS ではない。
ただ、高いスケーラビリティを持っているわりには遅くもない。
自分の経験ですが、普段使いで困るほど遅くなることはない印象。

意外と柔軟な検索ができる
memcache のような KVS をイメージすると「key ベースでの get しかできない」と思うかもしれないが、
意外と柔軟な検索ができる。
ただ、RDBほど柔軟にできないので注意した方がいい。

バランスがいいので、逆にハマる
スケールする & 遅くない & そこそこ柔軟な検索ができる ので、
利用者の多くのニーズをカバーできると思う。
反面、できないこともあるので、そこでハマることがある。


まとめ

特にないです。(´・ω・`)

間違っているところがあれば
コメント or twitter で教えてください。