読者です 読者をやめる 読者になる 読者になる

(継続的に追記)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 は利用できる。

注意したいのは Filter() で指定できる検索条件。
https://cloud.google.com/appengine/docs/standard/go/datastore/reference#Query.Filter
Datastore Queries  |  App Engine standard environment for Go  |  Google Cloud Platform


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

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


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 にバインドされる。

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

type User struct {
    Name       string
    tel        string
    address    string
}



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

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

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

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

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


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

以下の string の Name を

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

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

type Name string

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

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


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

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

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

以下にも注意。
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: cannot load field xxxx into a xxx: no such struct field」

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

Datastoreのスキーマを変更する際は
先に struct に field 定義を追加したコードをデプロイしてから
Datastore を触るコードをデプロイした方がいい。

エラーが発生してもエラー対象以外のフィールドに値をバインドされるので、
エラーを無視することも可能。
ただし、エラーメッセージには struct の field が入ってくるので、
エラーメッセージの文字列比較でエラーを判別する必要がある。
以下のようなエラー比較はできない。

if err != datastore.ErrNoSuchEntity {

}



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でこういった実装をしたことがある。

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

ここでポイントになるのが 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


目を通すべき記事(随時 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





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


まとめ

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

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