Goと勢いでDynamoDBのキャッシュを作ってみた

こんばんは、gureguです。
もうすぐクリスマスですね。日本のクリスマスはフライドチキンと恋愛のイメージがありますが、母国のアメリカでは実家に帰って家族と過ごす日本の正月みたいなもんですよ。もちろん、仕事は休みです。
ということで[Go2 Advent Calendar](https://qiita.com/advent-calendar/2019/go2)の23日目の記事です。

最近、私はDynamoDBおじさんになりつつあります。[GoのDynamoDBライブラリ](https://github.com/guregu/dynamo)を作っていますし、仕事でも個人開発でもなんとなくDynamoDBを使ってしまいます。安くて便利ですね。
皆さん「[DAX](https://aws.amazon.com/dynamodb/dax/)」というサービスはご存知ですか？AWSが提供しているDynamoDBのキャッシュサービスです。DynamoDB専用のmemcachedみたいな感じですね。GoのDAXライブラリは[aws-sdk-goのDynamoDBのAPIのインターフェス](https://godoc.org/github.com/aws/aws-sdk-go/service/dynamodb/dynamodbiface)をそのまま実装しているので簡単に使えます。DAXは強いですが、お高いです。個人開発で作っているサービスはサーバ一台（DAU15人程度）だけなので、プラスDAX一台はちょっとキツイです。

ある日シャワー浴びている時に突然インスピレーションが来た…！
「メモリー上にキャッシュすれば自作DAX作れるじゃね？」
と。
そしてそれを勢いで作って「本番」にデプロイしてみました。その話をしようと思います。

## まずラッパを作ってみよう

`github.com/aws/aws-sdk-go/service/dynamodb/dynamodbiface.DynamoDBAPI`というインターフェスを実装すれば、guregu/dynamoでもどこでも本家のクライアントの代わりにカスタムなクライアントが使えます。

まず本家のクライアントそのままembedして、一つだけのメッソドを「オーバーロード」してみましょう。

```go
package localcache

import (
	"github.com/aws/aws-sdk-go/aws"
	"github.com/aws/aws-sdk-go/aws/client"
	"github.com/aws/aws-sdk-go/aws/request"
	"github.com/aws/aws-sdk-go/service/dynamodb"
	"github.com/aws/aws-sdk-go/service/dynamodb/dynamodbiface"

	"github.com/davecgh/go-spew/spew"
)

func New(p client.ConfigProvider, cfgs ...*aws.Config) dynamodbiface.DynamoDBAPI {
	db := dynamodb.New(p, cfgs...)
	return NewWithDB(db)
}

func NewWithDB(client *dynamodb.DynamoDB) dynamodbiface.DynamoDBAPI {
	return &Cache{
		DynamoDB: client,
	}
}

type Cache struct {
	// 本家のクライアントをembed
	*dynamodb.DynamoDB
}

func (c *Cache) GetItemWithContext(ctx aws.Context, input *dynamodb.GetItemInput, opts ...request.Option) (*dynamodb.GetItemOutput, error) {
	// とりあえずダンプしてみる
	spew.Dump(input)
	// 本家のクライアントを呼ぶ
	return c.DynamoDB.GetItemWithContext(ctx, input, opts...)
}
```

そして、個人のサービスで使ってみる

```go
func newDynamo() *dynamo.DB {
	cache := localcache.New(session.New(), &aws.Config{
		Region: aws.String("us-west-2"),
	})
	return dynamo.NewFromIface(cache)
}
```

これで動きます！！が、ログを吐いているだけです。

## キャッシュしてみよう

コンピュータサイエンスの名言があります。

> **「コンピュータサイエンスで難しい問題は二つだけだ。キャッシュの無効化と、名前をつけること。」**

このプロジェクトはまさにその２つの問題への挑戦です。

### 名前をつけること

Goのキャッシュライブラリのほとんどはキーが**文字列**です。キャッシュする際に、DynamoDBの各APIの処理をどうにか文字列に化けないといけないです。つまり、名前をつけないと。

とりあえずやってみようと
※クソコード注意

```go
import "github.com/patrickmn/go-cache"

type Cache struct {
	*dynamodb.DynamoDB
	getItem *cache.Cache
}

func (c *Cache) GetItemWithContext(ctx aws.Context, input *dynamodb.GetItemInput, opts ...request.Option) (*dynamodb.GetItemOutput, error) {
	key := *input.TableName + "$" + key2str(input.Key)
	if out, ok := c.getItem.Get(key); ok {
		log.Print("returning cached", key)
		return out.(*dynamodb.GetItemOutput), nil
	}
	out, err := c.DynamoDB.GetItemWithContext(ctx, input, opts...)
	if err != nil {
		return out, err
	}
	log.Print("caching", key)
	c.getItem.Set(key, out, cache.DefaultExpiration)
	return out, err
}

func key2str(key map[string]*dynamodb.AttributeValue) string {
	if len(key) == 1 {
		for k, v := range key {
			return k + ":" + av2str(v)
		}
	}
	var a, b string
	for k, v := range key {
		if a == "" {
			a = k + ":" + av2str(v)
		}
		b = k + ":" + av2str(v)
	}
	if a[0] < b[0] {
		return a + "/" + b
	}
	return b + "/" + a
}

func av2str(av *dynamodb.AttributeValue) string {
	switch {
	case av.S != nil:
		return *av.S
	case av.B != nil:
		return string(av.B)
	case av.N != nil:
		return *av.N
	}
	panic("invalid key av")
}
```

キーを「テーブル名$Key1名:価/Key2名:値」にしてみました。そして`*dynamodb.GetItemOutput`をそのままキャッシュして、次からはそれをキャッシュから取り出して返す。
そして、動く！！が… 実は様々な問題があります。

### キャッシュの無効化

この仕組みだと古いアイテムはexpireしないとずっと古い値が返されてしまいます。もっと賢くしたい…！PutItemをオーバーロードして、いい感じにキャッシュの無効化してみましょう。

しかし、PutItemのAPIだけだと、どの属性がHash Keyなのかは分からないのでテーブルのスキーマを取得してキャッシュキーを作ります。

```go
func itemKey(table string, key map[string]*dynamodb.AttributeValue, schema []*dynamodb.KeySchemaElement) string {
	if len(schema) == 1 {
		return table + "$" + *schema[0].AttributeName + ":" + av2str(key[*schema[0].AttributeName])
	}
	return table + "$" + *schema[0].AttributeName + ":" + av2str(key[*schema[0].AttributeName]) + "/" +
		*schema[1].AttributeName + ":" + av2str(key[*schema[1].AttributeName])
}
```

前はキーがアルファベット順でしたが、これだとちゃんと「テーブル名$HashKey名:価/RangeKey名:値」になります。

```go
func (c *Cache) PutItemWithContext(ctx aws.Context, input *dynamodb.PutItemInput, opts ...request.Option) (*dynamodb.PutItemOutput, error) {
	schema, err := c.schemaOf(*input.TableName) // 略
	if err != nil {
		return nil, err
	}
	key := itemKey(*input.TableName, input.Item, schema)

	out, err := c.DynamoDB.PutItemWithContext(ctx, input, opts...)
	if err != nil {
		return out, err
	}
	c.setItem(key, input.Item) // ← キャッシュ更新！
	return out, err
}
```

よし、これでひとまずGetとPutは対応済みです。ついでに似た方法でBatchGetもBatchWriteを対応しました。UpdateとDeleteも。QueryもScanも（その話は別記事でしようと思います）。

## 本番デプロイしてみる

ローカルでは動いてるしとりあえず本番にデプロイしてみよう。いわゆる「Move fast and break things」つまり「本番でテスト」ですね。
そして、動いた！！
が、**遅くなった！！** 😩

キャッシュしているのに遅くなった？！ありえない！
色々試してみたら、使っていたライブラリの`github.com/patrickmn/go-cache`はパーフォマンス性があまりよろしくない… とくに並行処理が多い場合に優れてないです。

それで`github.com/karlseguin/ccache`に変えてみました。
**速い！！！！** 😎

ユーザーに感想を聞いてみると「なんとなく速くなった気がする」と言われてすごい達成感！！やった！
どれくらい速くなったかというと、複雑なページは倍くらい速くなりました。シンプルなページは10ms以下に。

実は話がまだまだたくさんありますが、今日はここまでにしようと思います！
https://github.com/guregu/localcache でソースコードを公開しました。コミットを追えばなんとなくこの記事で話したことが見えるかと思います。
しかし勢いで１日で書いたしテストがないので使用はオススメしません！
本気出したらちゃんとしたライブラリにするかもしれません。

※ この記事も勢いで書いたので変な日本語があったらスミマセンユルシテクダサイ