Cloud Datastore用キャッシュパッケージの検討
現在App Engine(Standard Environment) + Go1.11環境*1でgoonというパッケージを利用し、Datastoreのデータをキャッシュしています。
goon
はバックエンドとしてApp Engineのmemcache*2を使用しており、こちらがGo1.12から使えなくなってしまうので他のパッケージに移行しなければなりません。
App Engineのmemcacheに依存しない既存パッケージとしては、
などがありますが、どちらも独自のクライアントを作成するものとなっています。
goon
を使っていて気になったのが、独自クライアント方式はGoogleが提供するパッケージには無い便利な機能があるというメリットがあるものの、本家の機能が全て使えなかったり、そもそもパッケージとしての使い方が異なっているため、Go1.11以降で推奨されているcloud.google.com/go/datastoreへの移行はもちろん、元になっているgoogle.golang.org/appengine/datastoreに戻すことすら手間がかかってしまうという点です。
機能面で困ることなく、ずっと使い続けることができるのであれば特に気にするところではないのかもしれませんが、goon
の場合は更新があまり活発ではなく、移行時に大幅にコードを書き直さないといけないことがわかっているため、次のようなパッケージを検討することにしました。
cloud.google.com/go/datastore
のClientをそのまま使うことができる- 使い方が大きく変わるような付加機能は提供しない
- キャッシュのバックエンドを自由に選択できる
上記を実現するのに使用するのがgRPCのUnaryClientInterceptorというAPIです。
UnaryClientInterceptor
はリモート呼び出しの代わりに呼ばれる関数となっています。
DatastoreはgRPCで各種オペレーションを実行するため、NewClientのopts
にUnaryClientInterceptor
を渡すことでキャッシュ処理を差し込むことが可能です。
使用例:
// optを渡さなければキャッシュを無効にできる opt := option.WithGRPCDialOption( grpc.WithUnaryInterceptor(MemoryInterceptor), // grpc.WithUnaryInterceptor(MemcacheInterceptor), // grpc.WithUnaryInterceptor(RedisInterceptor), )
ただし、UnaryClientInterceptorに渡されるmethod
やreq
、reply
といった引数はDatastoreのClient
の各メソッドと1:1で対応しているわけではないため、どのメソッドがどのgRPC呼び出しになるのかを把握した上でキャッシュ戦略に応じた実装をしていく必要があります。
goon
に合わせたキャッシュ戦略とする場合、
- 非トランザクションでキー指定のデータ取得時にキャッシュの参照や保存を行い、
- Get
- GetMulti
- データ変更時にキャッシュの削除を行う
- Put/Transaction.Put
- PutMulti/Transaction.PutMulti
- Delete/Transaction.Delete
- DeleteMulti/Transaction.DeleteMulti
- Mutation/Transaction.Mutation
ので、Lookup
とCommit
の2つのRPCをインターセプトすることになります。
func(ctx context.Context, method string, req, reply interface{}, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error { switch method { case "/google.datastore.v1.Datastore/Lookup": // キャッシュの参照や保存 return nil case "/google.datastore.v1.Datastore/Commit": // キャッシュの削除 return nil } return invoker(ctx, method, req, reply, cc, opts...) }
/google.datastore.v1.Datastore/Lookup (キャッシュの参照や保存)
Lookup
はreq
としてLookupRequestを受け取り、結果はreply
にLookupResponseを設定します。
実装する処理としては次のような流れです。
1. reqのread_optionsをチェックし、トランザクション内だった場合はinvokerを呼び出してreturnする 2. reqのkeysを使ってキャッシュを参照する * 全てのデータが取得できればreplyのfoundに設定してreturnする 3. reqのkeysを2で取得できなかったキーのみにしてinvokerを呼び出す 4. replyのfoundに2で取得したデータを追加する 5. 3で取得したデータをキャッシュに書き込む
データが見つからなかった場合のハンドリングはClient
のGet
やGetMulti
内で行われるため、UnaryClientInterceptor
の中で特殊な処理をする必要はありません。
/google.datastore.v1.Datastore/Commit (キャッシュの削除)
Commit
はreq
としてCommitRequestを受け取り、結果はreply
にCommitResponseを設定します。
実装する処理としては次のような流れです。
1. invokerを呼び出す * エラーだった場合はreturnする 2. reqのmutationsにupdate、upsert、deleteがあれば対応するキーのキャッシュデータを削除する * 削除に失敗した場合はエラーを返す
キャッシュの削除に失敗すると古いデータが残り続けてしまうため、
のいずれかを行わないとデータに不整合が生じてしまいます。
これについて、特に困っているのがRunInTransaction
の場合、キャッシュ削除の失敗はロールバックの対象にならず、個別にロールバックを呼び出すこともできないというところです。
そのため、
- キャッシュのバックエンドにリトライ処理を入れる
- 無限にリトライしてしまう可能性もある...
- キャッシュに有効期限を持たせる
- 一定時間は不整合を許容できるのであれば...
- キャッシュを削除してからDatastoreを更新する
- キャッシュ削除〜Datastore更新の間に別の処理が入ると結局不整合になる
- それならキャッシュ戦略を変更した方が...
- いっそのこと
RunInTransaction
を使わないようにする
などなど、このあたりはもう少し検討をしなければいけない状況です。
ただ実際に動かしてみないとわからないところもあると思うので、近いうちに実装して色々と試してみようと思っています。